BUAA_OO_UNIT3_单元总结
本单元要求我们基于给出的JML规格实现一个社交关系网络,并可以通过各种指令实现数据的增删改查。
一、实现规格所采取的设计策略
-
相较于前两个单元对于架构设计的整体性及可扩展性的考察,本单元对于需要实现的类都给出了相应的抽象类或接口,类中需要实现的方法也都给出了JML描述,相当于已经定好了整体框架,我们只需要填充内部方法的具体实现即可。因此,本单元作业不需要在架构上花费很多心思,更多考察的是JML规格的阅读能力和对时间复杂度的控制。
-
在动手写代码之前,我会先通读一遍各接口的JML规格,理清各个类中需要实现的方法及不同类之间的包含关系和调用关系,之后按照由易到繁的顺序依次编写自己的类。以最后一次作业为例,我的编写顺序为:异常类、
MyPerson、MyGroup、MyMessage、MyNoticeMessage、MyEmojiMessage、MyRedEnvelopeMessage、MyNetwork、MainClass。 -
对于可能会抛出异常的方法,我会先处理JML规格中定义的
exceptional_behavior部分。由于官方代码中JML规格的逻辑是完备的,因此如果该方法执行过程中没有抛出任何异常,自然就意味着当前输入数据符合normal_behavior的前置条件,直接按照要求进行处理即可。这样可以避免在方法的开头使用一个长长的if语句来判断输入是否满足normal_behavior的前置条件。 -
对于JML规格中定义的大部分简单方法,诸如
contains()、addPerson()、queryVaule()等,只需要将其直接翻译成java语言即可;对于一些比较复杂的方法,如queryBlockSum()、queryGroupValueSum()、sendIndirectMessage()等,要注意时间复杂度的控制,为此,我们可以定义一些辅助成员变量、辅助方法或辅助类。 -
在阅读JML规格时,要特别注意括号的位置,如
\old(getMessage(id)).getPerson1().getSocialValue()和\old(getMessage(id).getPerson1().getSocialValue())是不一样的。对于长度较长的JML语言,可以逐层拆解,逐步弄清方法的具体要求。
二、基于JML规格设计测试的方法和策略
-
使用JUnit进行单元测试
JUnit是一个Java语言的单元测试框架,其内部提供了一套断言机制,能够将我们预期的结果和实际的结果进行比对,判断结果是否满足我们的期望。
JUnit4通过注解的方式来识别测试方法。目前支持的主要注解有:
注解 使用说明 @BeforeClass 所有测试方法调用前执行一次,在测试类没有实例化之前就已被加载,需用static修饰 @Before 每一个测试方法调用前必执行的方法 @Test 将一个方法标记为测试方法 @After 每一个测试方法调用后必执行的方法 @AfterClass 所有测试方法调用后执行一次,在测试类没有实例化之前就已被加载,需用static修饰 @Ignore 暂不执行该方法 一个JUnit4的单元测试用例执行顺序为:
@BeforeClass \(\rightarrow\) @Before \(\rightarrow\) @Test \(\rightarrow\) @After \(\rightarrow\) @AfterClass
每一个测试方法的调用顺序为:
@Before \(\rightarrow\) @Test \(\rightarrow\) @After
常用断言方法如下:
断言方法 功能描述 assertTrue(boolean condition) 检查条件是否为真 assertFalse(boolean condition) 检查条件是否为假 assertEquals(XXX expected,XXX actual) 检查两个对象的值是否相等 assertNotEquals(XXX expected,XXX actual) 检查两个对象的值是否不相等 assertNull(Object object) 检查对象是否为空 assertNotNullObject object) 检查对象是否不为空 assertSame(Object expected, Object actual) 检查两个对象引用是否引用同一对象(即对象是否相等) assertNotSame(Object unexpected,Object actual) 检查两个对象引用是否不引用统一对象(即对象不等) assertArrayEquals(XXX[] expecteds,XXX [] actuals) 检查两个数组是否相等 assertThat(String reason, T actual, Matcher matcher) 要求matcher.matches(actual) == true,使用Matcher做自定义的校验 fail(String message) 要求执行的目标结构必然失败,通常用于标记某个不应该被到达的分支 -
以
isCircle()方法的部分测试函数为例:@Test public void isCircle() throws EqualPersonIdException, PersonIdNotFoundException, EqualRelationException { Person p1 = new MyPerson(111, "jack", 10); Person p2 = new MyPerson(222, "mark", 20); Person p3 = new MyPerson(333, "jerry", 30); network.addPerson(p1); network.addPerson(p2); network.addPerson(p3); assertFalse(network.isCircle(111, 222)); assertFalse(network.isCircle(111, 333)); assertFalse(network.isCircle(222, 333)); network.addRelation(111, 222, 5); assertTrue(network.isCircle(111, 222)); assertFalse(network.isCircle(111, 333)); assertFalse(network.isCircle(222, 333)); network.addRelation(111, 333, 10); assertTrue(network.isCircle(111, 333)); assertTrue(network.isCircle(222, 333)); } -
限时测试
对于那些逻辑比较复杂,循环嵌套比较深的方法,很可能会出现TLE的情况,这时可通过在@Test后添加timeout标注实现限时测试。一旦测试函数的执行时间超过了设置的限制时间,测试程序就会被系统强行终止。
@Test(timeout = 1) //timeout参数表明了设定的时间,单位为毫秒 public void queryBlockSum() { assertEquals(network.queryBlockSum(), 5); } -
异常测试
异常处理也是本单元作业中的一个重点,我们可以使用@Test标注的expected属性,将我们要检验的异常传递给测试函数,这样JUnit框架就能自动检测是否抛出了我们指定的异常。
@Test(expected = EqualPersonIdException.class) public void addPerson() throws EqualPersonIdException { Person p1 = new MyPerson(1, "aaa", 10); Person p2 = new MyPerson(1, "bbb", 20); network.addPerson(p1); network.addPerson(p2); fail("没有抛出EqualPersonIdException异常"); }此处
(expected = EqualPersonIdException.class)和fail("没有抛出EqualPersonIdException异常");之间相互配合,会检查是否抛出了EqualPersonIdException异常。如果抛出了异常那么测试通过,没有抛出异常则测试不通过,继续执行fail("没有抛出EqualPersonIdException异常");。
-
-
自动评测
使用JUnit进行单元测试时,要想覆盖每个方法的所有条件分支,需要手动编写大量代码,耗时较长,为此可以采用自动化测试,通过对拍来验证程序的正确性。对拍时所需的输入指令,可以通过python程序随机生成,也可以直接从文件中读入手动构造的测试样例。前者主要用于判断程序的正确性,后者主要用于判断CPU是否会超时。
三、容器选择和使用的经验
在JML模型域中,容器以一种类似数组的方式表示,代表是一类数据元素的集合。在实际编写代码的过程中,我们可以在满足JML规格限制的前提下灵活采用各种容器实现。
-
容器的选择
- 对于没有存放顺序要求的容器(如
people、groups、acquaintance、value等),我都会选择使用HashMap实现,以<id, 具体数据元素>作为键值对存储,使得基于id的查询操作基本上可以在\(O(1)\)的时间复杂度内完成。 - 对于
Network接口中的int[] emojiIdList和int[] emojiHeatList两个model,可以直接将<emojiId, emojiHeat>看作一个键值对,使用一个HashMap<Integer, Integer> emojiIdAndHeat实现。 - 对于Person接口中的
Message[] messages模型,由于getReceivedMessages、sendMessage和sendIndirectMessage方法对messages的存放顺序有要求,因此采用LinkedList容器实现。 - 第三次作业中,在使用堆优化的Dijkstra算法实现
sendIndirectMessage时,采用PriorityQueue维护一个小顶堆。
- 对于没有存放顺序要求的容器(如
-
容器的使用
-
查找
在
HashMap中提供了containsKey()和containsVaule()两种方法用于查询容器中是否存在指定的键/值,前者的的时间复杂度在理想情况下是\(O(1)\),后者的时间复杂度是\(O(n)\),因此使用containsKey()方法基于id的查询操作效率更高。get()操作的时间复杂度与containsKey()相同,可以通过key值获取对应的value值。 -
添加
Hashmap中可以使用put()方法添加键值对,如果容器中已经存在与当前键相同的节点,则会使用新的value值替换节点中的值。最好情况下,put方法的时间复杂度为\(O(1)\)。LinkedList中可以使用addFirst()方法将新元素插入链表头部。
-
遍历
使用
HashMap.entrySet()方法返回整个Entry<K,V>对象的集合,使用迭代器遍历该集合中的每一个键值对Map.Entry类型,利用Entry.getKey()或Entry.getValue()得到相应的键或值,效率较高。 -
删除
-
如果已知要删除的元素的id,可以使用
remove()方法删除HashMap中指定键key对应的键值对。 -
如果只知道删除操作的限定条件而不知道具体元素的信息(如
Network接口中的deleteColdEmoji方法),可以使用迭代器对HashMap进行遍历,当查找到符合条件的元素时对其进行删除:Iterator<Map.Entry<Integer, Integer>> emojiIter = emojiIdAndHeat.entrySet().iterator(); while (emojiIter.hasNext()) { if ((emojiIter.next()).getValue() < limit) { emojiIter.remove(); } }
-
-
四、容易出现的性能问题
第九次作业
-
isCircle()-
该方法的功能是查询两个Person是否位于同一个连通块内。在第九次作业中我采用BFS算法实现,后来发现在
queryBlockSum()方法中还要遍历调用该函数,时间复杂度较高,会导致CTLE,于是在后两次作业中改用并查集算法实现。 -
采用并查集算法实现的思路如下:
在
MyNetwork类中定义HashMap<Integer, Integer> father和HashMap<Integer, Integer> rank,分别用于记录每个Person对应的根节点的id及每个根节点对应的树的深度(如果不是根节点,其rank相当于以它作为根节点的子树的深度),并定义合并(路径压缩)方法merge()和查询方法findFather()。在addPerson时将每个人的父节点设为自己,对应的rank值设为1,并在addRelation时实现父节点的合并及路径压缩。这样就可以通过查看两个人的根节点是否相同来判断二者是否位于同一个连通块内。经过rank和路径压缩优化后,并查集的时间复杂度为\(O(α(n))\),其中α表示阿克曼函数的反函数。
-
-
queryBlockSum()该方法的功能是查询社交网络中连通块的个数。采用并查集算法实现
isCircle()后,我们只需要遍历people,查看一共有多少个不同的fatherId即可。public int queryBlockSum() { int blockSum = 0; for (int pid : people.keySet()) { if (pid == father.get(pid)) { blockSum++; } } return blockSum; //blockSum的数量即为不同父节点的数量 }在互测过程中我看到有的同学还定义了一个
blockSum成员变量并在addPerson、addRelation时对其进行维护,这样可以使时间复杂度从\(O(n)\)降低到\(O(1)\)。
第十次作业
-
queryGroupValueSum()该方法的功能是查询组内所有相互关联的人的
value总和。如果直接按照JML规格中的双重for循环遍历实现,时间复杂度为\(O(n^2)\),会导致CPU超时。因此,我在MyGroup类中定义了valueSum成员变量并对其进行维护,使查询操作的时间复杂度降低至\(O(1)\)。此处需要注意的是,根据JML规格,在遍历的过程中同一对朋友之间的value值会被计算2次。valueSum成员变量的具体维护过程如下:- 在
addToGroup时遍历people,如果当前person与新加入的person之间存在联系,对valueSum加上value * 2; - 对于
delFromGroup指令,遍历时如果满足条件则对valueSum减去value * 2。 - 在
addRelation时,对于两者都存在的group,其valueSum值要加上value * 2。
- 在
-
queryGroupAgeMean()该方法的功能是查询组内所有人的年龄平均值。可以在
MyGroup类中定义ageSum成员变量用于记录组内所有人的年龄和,并在addToGroup和delFromGroup时对其进行维护。这样在查询平均年龄时返回ageSum / people.size()即可,从而将时间复杂度降到\(O(1)\)。 -
queryGroupAgeVar()该方法的功能是查询组内所有人的年龄方差。根据公式
\[\sum(x_i-\bar x)^2=\sum(x_i^2+\bar x^2-2x_i\bar x)=\sum x_i^2+n\bar x^2-2\bar x\sum x_i \]可以在
MyGroup类中定义ageSquareSum成员变量用于记录组内所有人年龄的平方和,并在addToGroup和delFromGroup时对其进行维护。在查询年龄方差时返回ageSquareSum + n * getAgeMean() * getAgeMean() - 2 * getAgeMean() * ageSum,同样将时间复杂度降到\(O(1)\)。
第十一次作业
-
sendIndirectMessage()-
该方法的功能是在两个Person之间发送消息并返回二者之间的最短路径。普通的Dijkstra算法时间复杂度为\(O(n^2)\),存在TLE的风险,此处采用堆优化的Dijkstra算法实现,可以将时间复杂度控制在\(O(mlogn)\), 其中\(n\)表示点数(即人数),\(m\)表示边数(即关系数)。
-
通过使用java自带的
PriorityQueue优先队列维护一个小顶堆,使得其能够在\(O(logn)\)的时间复杂度内完成插入、删除最小值的操作,在\(O(1)\)的时间复杂度内完成取堆内最小值的操作,从而达到优化的目的。
-
五、作业架构设计
-
异常类
由于输出的异常信息中包含此类异常发生的总次数以及某一id触发此类异常的次数,因此我在每个异常类中都使用一个static类型的变量记录此类异常发生的总次数,以及一个static类型的
HashMap<Integer, Integer> idToCount用来存储每个id触发该类异常的次数。在互测中,我看到有的同学新建了一个Counter类用于记录和查询某一id触发某类异常的次数,并在自己实现的异常类中将其作为静态成员变量调用。虽然两种实现方式本质上没有区别,但额外创建一个计数器类会使程序的封装性更好。 -
官方接口的实现
本单元的三次作业为迭代式开发,每次完成作业时需要重点关注新增的模块及功能。
-
图模型的构建与维护
MyNetwork类模拟了一个社交关系网络图模型,其含有的成员变量如下:private HashMap<Integer, Person> people; private HashMap<Integer, Group> groups; private HashMap<Integer, Message> messages; private HashMap<Integer, Integer> emojiIdAndHeat; private HashMap<Integer, Integer> rank; private HashMap<Integer, Integer> emojiIdAndHeat;-
people是容纳Person的容器,对应图中顶点的集合。每一个顶点(Person变量)内部又包含acquaintance、value、messages三个成员变量:private HashMap<Integer, Person> acquaintance; private HashMap<Integer, Integer> value; private LinkedList<Message> messages;其中,
acquaintance存放了与该顶点直接相连的顶点,对应边的权值存放在value中,messages存放了该Person收到的消息。 -
groups是存放Group的容器,每个Group内包含若干个顶点(Person) -
messages存放了未发送的各类Message -
emojiIdAndHeat用于记录表情包类消息的id及热度值 -
father和rank是用于处理连通块计数问题所定义的辅助成员变量
图的构建与维护有关操作:
- 通过
addPerson方法增加图的顶点 - 通过
addRelation方法增加图的边,具体操作为在两个顶点的acquaintance中添加新的顶点,并将边的权值存放value中,同时完成father中的路径压缩,修改rank中对应键值 - 通过
queryValue方法查询两个顶点之间边的权值 - 通过
queryPeopleSum方法查询顶点个数 - 通过
isCircle方法查询图中两个顶点是否连通 - 通过
queryBlockSum方法查询图中连通块的个数 - 通过
addGroup方法增加新的组 - 通过
addToGroup方法向组中增加新的顶点 - 通过
delFromGroup方法删除组中的顶点 - 通过
queryGroupSum方法查询组的数量 - 通过
addMessage方法增加新的未发送消息 - 通过
storeEmojiId方法向emojiIdAndHeat中添加新的表情包类消息的id和初始热度(0)。 - 通过
deleteColdEmoji方法遍历emojiIdAndHeat和messages,删除其中热度小于给定数值的表情包类消息 sendMessage方法用于消息的发送,具体操作为将该消息从MyNetwork类公共的messages中删除,添加到接收顶点的messages中。根据消息类型的不同,还要实现一些额外的操作:- RedEnvelopeMessage:发送顶点的钱数减少,接收顶点的钱数增加
- EmojiMessage:该表情包的热度值加1
sendIndirectMessage方法与sendMessage类似,除此之外还要返回信息发送顶点与接收顶点之间的最短路径
-

浙公网安备 33010602011771号