OODC 2024 如约而至。
欢迎来到 @Monterey 官方渠道
观看 OO 你好,博客发布活动。
OODC (Object Oriented Developers Conference),OO 开发者大会的简称,每个课程单元结束时在 CSDN 举行,拥有最新的本单元 IDEA 平台、编程技术、代码架构和产品安全的学习和交流机会。预计共举办四期。本期是第三期。
在第二期OODC中,我们的研究团队用研究报告展示了我们的最新研究成果。本期OODC,我将为大家带来第三期主题内容——基于JML的社交网络构建的解决方案。
目录
1 测试概览
1.1 黑箱与白箱测试
黑箱测试和白箱测试是一组相对的测试思路。
黑箱测试,在测试的过程中不关心程序内部的具体实现,只关心给定该程序输入后,其能否返回正确的输出结果。
而白箱测试,就相对地要求我们关心程序内部的具体实现逻辑,完全了解程序的结构与处理过程,从而分析出程序的问题所在。
由于实现简单,在本单元的测试中笔者采取的思路基本为黑箱测试,白箱测试仅限于直接审视代码来分析可能的错误。然而,虽然白箱测试看起来比较耗费精力、难以实现,但也是有其优越性的。比如通过细心审查,白箱测试可能会发现黑箱测试难以发现的微小bug。
1.2 单元测试
指对程序中的各个单元进行测试。所谓“单元”的定义是很广泛的。比如一个java
的main
程序调用了6个自定义方法,那么就可以把这6个方法看成6个单元,对每个单元进行充分的测试,在确保了这6个单元的正确性后,我们还可以再将顶层的main
方法看成一个单元,再对这一个顶层单元进行充分的测试。从而确保整个程序正确性。
本届OO课程采用的就是将上学期先导课已经涉及的JUnit单元测试做为测试方案,并提供了相关的评测机制,具体内容与个人看法将在下一章节进行详细介绍。
1.3 功能测试
功能测试是指测试一个程序的功能是否完整正确。它要求我们构造测试样例,这些样例需要覆盖到程序的所有功能,然后让程序运行这些样例,通过观察结果来测试其功能是否完备。本单元作业源代码正确性的基本判断方法就是构造出由各个指令组成的用例,来测试我们写的程序是否满足了各指令功能需求。
1.4 集成测试
集成测试,顾名思义,就是把各个部分集合起来进行测试,观察各个部分能否正常的协调工作。比如我们通过单元测试的方式测试A、B 两个单元,测试完毕且没有发现问题后可以将它们集成起来,对整体进行测试,以确保正确实现的各个独立单元在结合后还能够做到正确地协同运行。
1.5 压力测试
压力测试是指针对测试对象以及测试功能,构造一些极端用例来对程序进行测试,如果程序能承受住这种极端测试的压力,就可以在很大程度上表明程序的性能良好,且功能也有着正确的实现。比如在本单元作业中,通过足够多数量的指令以及对CPU使用时间的限制等进行压力测试(强测环节),如果程序在这种情况下仍然能保持正确性以及较高的运行效率,说明程序性能优异、实现正确。
1.6 回归测试
回归测试是在涉及到程序的迭代开发时,用每次迭代后的程序来接受迭代前版本的测试数据的检验。确保不会出现因为修改程序把原本正确的功能改错的情况。例如,在强测环节与互测环节出现错误的数据点,将在bug修复环节用来对你的修复后程序进行回归测试。
2 测试精讲
2.1 JUnit测试
📌根据JML要求进行JUnit测试是本届课程的新特性,在此谈谈笔者的实现思路与建议。
2.1.1 基本思路
2.1.1.1 总体方向
在本单元的JUnit测试中,采用参数化测试的方法对通过JUnit测试点大有帮助。
总体而言,每对一个方法进行测试,应当遵循并思考以下三个步骤的实现:构造数据、运行测试、检查结果。 其中运行测试只需调用被测试方法,不再赘述。下面是关于其他两个步骤的一些思考:
- 构造数据:“精心”地构造出若干个
network
。network
的个数(测试组数)、每个network
点的个数、边的条数以及边的权值等等都有可能需要进行随机生成。这取决于被测试方法主要参考和修改的是network
哪些部分的数据,由此确定哪些数据是核心的参数,必须保证核心参数的随机性、覆盖面以及强度。例如在第十一次作业中需要让你的messages
容器中全面覆盖所有的Message
类型:type
为0的和type
为1的;基本Message
、红包Message
、提示Message
和表情Message
都必不可少。当然每个network
的所有Messge
还要保证随机以及足够的数量。 - 检查结果:实践证明,数据强度足够的参数化测试有一定几率可以仅通过判断被测试函数返回结果及
ensures
条件正确与否,完成某次作业的所有数据点,无需检查函数的pure
等其他边缘效应。虽然这可能不完全符合测试的本意,但是能够很好体现出参数化测试的有效性。当然真正正确的实现需要检查返回值、ensures
条件和所有可能的side effect
。
2.1.1.2 参考模板
📌此处将以第九次作业要求测试
queryTripleSum
方法为例,采用参数化测试。本单元其他作业针对其他方法的测试,完全可以参照下面的模板。
/*
该类对MyNetwork类进行测试,有两个成员变量:myNetwork是提前构造好的数据;
stdResult是被测试方法期望的返回值。
*/
@RunWith(Parameterized.class)
public class MyNetworkTest {
private MyNetwork myNetwork;
private int stdResult;
public MyNetworkTest(MyNetwork myNetwork, int stdResult) {
this.myNetwork = myNetwork;
this.stdResult = stdResult;
}
//构造测试数据-----步骤1
@Parameterized.Parameters
public static Collection prepareData() {
long seed = System.currentTimeMillis();
Random random = new Random(seed);
//要进行几次测试
int testNum = 40;
Object[][] object = new Object[testNum][];
for (int i = 0; i < testNum; i++) {
MyNetwork network = new MyNetwork();
int stdResult = 0;
//...在此处构造每次测试的network,并计算期望的返回值stdResult
//下面的语句将我们准备的network和stdResult赋值给该测试类的成员变量、
//以便在下面的queryTripleSum中使用
object[i] = new Object[]{network, stdResult};
}
return Arrays.asList(object);
}
@Test
public void queryTripleSum() {
// 运行测试,可以进行必要输出-----步骤2
System.out.println("stdResult: " + stdResult);
System.out.println("myResult: " + myNetwork.queryTripleSum());
// 检查结果-----步骤3
assertEquals(stdResult, myNetwork.queryTripleSum());
}
}
2.1.2 改进建议
经过实践,笔者发现JUnit评测机制仍存在一些缺点,在此提出本人的看法:
- 任务介绍不明确。最开始的指导书对JUnit的评测介绍很模糊,逐次改进后才有所改善,希望将来能够提供清晰简洁的任务描述。
- 不支持源代码分包。将源代码分包能使我们的代码结构更加清晰易读。然而由于JUnit评测时
test
代码和src
源代码需要一起编译,而src
代码会被替换为官方实现的结构,官方代码并没有分包,如果本地的src
代码是分包的,当测试代码使用本地代码包中的类时,需要import
语句。而源代码整个被替换后,import
就会失效报错。可以考虑保留提交代码的结构,仅替换需要测试的类,而不是将整个源代码结构进行替换。 - 反馈信息太少。仅仅反馈正确与否很不利于调试。可以考虑提供测试代码的标准输出。这样提交者就可以通过添加打印语句来明确是哪个数据的哪个结果出现错误,或者是哪方面的数据强度不够等等。
2.2 自测
2.2.1 测试工具
由于时间精力有限,笔者只是简单地构建自己的测试工具,更多地把精力放在构造测试数据上。笔者认为对于本单元的测试,应该使用数据生成器加之对拍验证的思路来构建测试工具。
数据生成器要按照一定的策略构造出数量足够多、强度足够大的测试数据,然后用被测试程序运行这些数据得到输出。将你的输出与其他程序或者标准程序的输出进行对拍验证,从而准确定位自己的错误。
在本单元完成作业的过程中,笔者使用了DPO评测机辅助测试,在此特别鸣谢。在面对单个或者较少的数据量时,也会在本地IDEA中直接运行程序来进行测试。
2.2.2 数据构造
笔者认为想要构造出有效的测试数据,可以从以下方面入手:
- 多样性。数据需要尽量覆盖到所有的情况,比如既要有能导致抛出异常的数据,来检验程序对异常的处理是否正确,又要有正常的数据,测试程序的基本功能。再比如对于消息机制,应该让输入包含各式各样的消息类型。
- 有效性。要避免构造的数据过于简单。比如
qci
操作判断两个点是否连通,如果构造的图过于稀疏,会导致几乎所有点都不连通,qci
的正确性就不能充分得到测试。再比如Tag
中人数不够多就无法测试出Tag
中最多1111人的限制。 - 极端性。对于各个数据限制构造出对应的边缘数据。例如
id
在int
范围内,那么就可以构造多个边缘int
值的id
。这样就能测试出id
直接相减导致溢出的问题。
3 架构设计
📌受JUnit测试影响,在正式提交时没有进行分包,且此处仅展示
src
包(源代码)结构。
3.1 main包
MainClass
类:主函数入口,负责通过官方的Runner
类启动整个程序。
3.2 system包
MyNetwork
类:实现Network
接口,仅有唯一实例变量,代表整张社交网络(图)。MyPerson
类:实现Person
接口,代表社交网络中的个体(顶点)。MyTag
类:实现Tag
接口,代表社交网络中的标签组(具有唯一数据特性的子图)。MyMessage
,MyNoticeMessage
、MyEmojiMessage
和MyRedEnvelopeMessage
类:实现对应的接口,和图结构关系不大,代表在社交网络中传递的消息以及它的三种子类型。
3.3 utils包
BlockSet
类:辅助数据结构,借鉴并查集的思想,用于确定社交网络(图)中任意两个个体是否熟悉(两个顶点的连通性),以及计算社交网络中的社交块数量(极大连通子集数目)。SortedAcquaintance
类和MyComparator
类:前者将作为TreeMap
数据结构中的key
,从而实现对所有熟人的排序,排序所遵循的规则就由后者的自定义比较器来规定。MyPathFinder
类:通过bfs广度优先搜索,找出图中给定两点间的最短距离。
3.4 exceptions包
MyEqualRelationException
类:自定义异常类,社交关系(边)相同时抛出。MyEqualPersonIdException
类:自定义异常类,个体id
(顶点)相同时抛出。MyRelationNotFoundException
类:自定义异常类,社交关系(边)未找到时抛出。MyPersonIdNotFoundException
类:自定义异常类,个体id
(顶点)未找到时抛出。MyAcquaintanceNotFoundException
类:自定义异常类,个体没有任何熟人时抛出。MyEqualTagIdException
类:自定义异常类,标签id
(子图)相同时抛出。MyPathNotFoundException
类:自定义异常类,给定两点间不连通(无路径)时抛出。MyTagIdNotFoundException
类:自定义异常类,标签id
(子图)找不到时抛出。MyMessageIdNotFoundException
类:自定义异常类,消息未找到时抛出。MyEqualMessageIdException
类:自定义异常类,消息id
相同时抛出。MyEmojiIdNotFoundException
类:自定义异常类,Emoji
消息的emojiId
未找到时抛出。MyEqualEmojiIdException
类:自定义异常类,Emoji
消息的emojiId
相同时抛出。
4 图论视角
4.1 图模型构建
这一单元课程组官方基本为我们设定好了项目代码的结构,因此重点之一是要正确理解本单元给出的基本框架。
本单元的主题是构建和管理一个社交网络,很容易想到,我们应该从图论的视角来看待这一问题。
4.1.1 Network
社交网络是最顶层的架构,它实质上是一张带权无向图。“带权”是因为社交关系(边)存在亲密值,用int
类型的value
变量表示(边权);而“无向”是因为并不区分两个个体(顶点)之间的社交关系(边)。
4.1.2 Person
社交网络中的个体,也就是图的顶点。每个个体还存储了它的所有熟人(邻接点)已经与熟人的亲密值(邻接边的边权)。在这种数据结构的保证下,所有Person
的集合就可以完整地表示一个Network
。
4.1.3 Tag
标签组,Person
存储的特殊结构,整个社交网络图的子图。对于每个Person
,每个子图有其唯一的标识id
,并具有如标签内个体平均年龄等可供查询的数据。每个Person
存储其所在的所有Tag
。
4.1.4 Message
消息,由社交网络中的两个Person
或者一个Person
与一个Tag
进行传递,并且下分三种子类:红包消息、表情消息以及提示消息。不同的消息通过不同的规则改变发送者和接收者的特定属性。事实上,Message
与图结构的关系不大,只是作为一种功能结构改变图中各个顶点的信息。
4.2 关键操作维护策略
📌这里的关键操作是指涉及到图论等相关知识,可以通过合理的优化降低时间复杂度的、实现难度较高的操作。
下面的所有操作,笔者实现了时间复杂度最高 O ( V + E ) O(V+E) O(V+E),最低 O ( 1 ) O(1) O(1)的性能。
4.2.1 qci
操作
时间复杂度: O ( 1 ) O(1) O(1)。但重建并查集为 O ( V + E ) O(V+E) O(V+E)。
4.2.1.1 操作本质
全称query_circle
,查询图中任意两点连通性。
指定两个id
(顶点),如果能找到一条由边组成的路径,起点是其中一个顶点,终点是另一个顶点,那么这两点连通。如果连通,返回true
,不连通,返回false
。
4.2.1.2 维护策略
采用“查时重建”的动态并查集策略。
专门设置一个辅助类BlockSet
,它的基本思想来自并查集,关于并查集的基本概念此处不再展开。它支持addRelation
时合并、query_circle
时查找两种基础操作。
此外,为了适应modifyRelation
造成的边删除,在此基础上提出了“查时重建”的扩展操作。这是一种相较于普通并查集新增的操作。因此称作动态并查集。
所谓“查时重建”,与操作系统课程中“写时复制”(COW)思想类似。
并查集实质上并不支持边删除的操作,因此如果想要使用并查集,在出现边被删除的情况时,最容易想到的处理办法就是重建并查集。
但是重建并查集需要遍历图的所有点和边,复杂度约为 O ( V + E ) O(V+E) O(V+E),显然是要付出一定的性能代价的。因此我们还应该减少不必要的重建。并查集的存在是为了查询连通性和极大连通子集数,也就是除了查询上述两个值的两个操作(下面称必要操作)外,其他操作并不关心并查集的状态。
因此我们可以为并查集设置一个boolean
状态位needRebuild
,初值为false
。在modifyRelation
造成边删除后将其设置为true
。当其为true
时,表明该并查集需要重建,此时并不立即重建,只是说明该并查集已经失效,在下次重建完成之前不必对其进行维护。直到读取到下一个必要操作之一时,才真正重建,并重新将needRebuild
设置为false
。
为了最大限度降低时间复杂度,对并查集还可以引入路径压缩和按秩合并的内部优化。这种改进版的动态并查集也可以实现类似的效果。下面将介绍这两个概念,在给出的最终实现框架中,您可以看到它们的具体实现。
- 路径压缩:我们只关心一个元素对应的根节点(代表元),那么希望每个元素到根节点的路径尽可能短,最好只需要一步。因此,当我们查找一个元素所在集合的代表元时,最好的情况是查找路径上所有元素的直接上级就是代表元。
- 按秩合并:为了到根节点(代表元)距离较长的节点个数尽量少并减少合并操作的性能消耗,我们可以把简单的树往复杂的树上合并。合并时将 “小秩”集合的代表元的直接上级设为 “大秩”集合的代表元。
最终形成的动态并查集结构如下:
public class BlockSet {
//每个项是一个极大连通子集,key是该子集代表元的id,value是子集中所有点(包括代表元)id
private HashMap<Integer, HashSet<Integer>> blocks;
//天然实现路径压缩,key是每个点的id,value是其直接上级,也是其所在极大连通子集的代表元id
private HashMap<Integer, Integer> indexTable;
//来自MyNetwork,用于重建并查集
private HashMap<Integer, Person> persons;
boolean needRebuild;
public BlockSet(HashMap<Integer, Person> persons) {
blocks = new HashMap<>(2048);
indexTable = new HashMap<>(2048);
this.persons = persons;
needRebuild = false;
}
//初始化
public void makeSet(int id) {
if (needRebuild || indexTable.containsKey(id)) {
return;
}
blocks.put(id, new HashSet<>(Arrays.asList(id)));
indexTable.put(id, id);
}
public boolean isSameSet(int id1, int id2) {
if (indexTable.containsKey(id1) && indexTable.containsKey(id2)) {
return indexTable.get(id1).equals(indexTable.get(id2));
} else {
return false;
}
}
public void merge(int id1, int id2) {
if (needRebuild || isSameSet(id1, id2)) {
return;
}
int root1 = indexTable.get(id1);
int root2 = indexTable.get(id2);
//将规模小的block合并到规模大的,即按秩合并
if (blocks.get(root1).size() < blocks.get(root2).size()) {
int temp = root1;
root1 = root2;
root2 = temp;
}
//...
}
public void rebuildSet() {
//...
}
public void notifyRebuild() {
needRebuild = true;
}
public boolean isNeedRebuild() {
return needRebuild;
}
//直接返回极大连通子集数目
public int getBlockSum() {
if (needRebuild) {
rebuildSet();
}
return blocks.size();
}
}
维护好该动态并查集后,每次qci
操作时,通过对传入的两个参数id1
和id2
调用isSameSet
方法即可判断连通性。
4.2.2 qbs
操作
时间复杂度: O ( 1 ) O(1) O(1)。但重建并查集为 O ( V + E ) O(V+E) O(V+E)。
4.2.2.1 操作本质
全称query_block_sum
,查询图的极大连通子集数目。
block可以理解成“社交块”,块内所有顶点均连通,且块外任何顶点都和块内顶点不连通。在图论中对应的就是极大连通子集的概念。
该操作要求返回实时的社交块(极大连通子集)数量。
4.2.2.2 维护策略
在qci
操作维护的动态并查集基础上,可以直接得到结果。
每次qbs
操作时,调用getBlockSum
取返回值即可。
4.2.3 qts
操作
时间复杂度: O ( V ) O(V) O(V)。
4.2.3.1 操作本质
全称query_triple_sum
,查询图的三元环数目。
所谓三元环是指,图中不同的三个顶点A、B、C,它们两两之间都有边直接相连,构成一个三角形环结构。
该操作要求返回实时的三元环数量。
4.2.3.2 维护策略
动态维护计数变量。
专门设置计数变量myTripleSum
,初值为0。
每次MyNetwork
进行addRelation
之后,遍历所有除了刚加完边的那两点的所有点,如果发现某个点与刚加完边的这两个点直接相连,值加1;
每次MyNetwork
进行会删除边的modifyRelation
之前,遍历所有除了即将删除边的那两点的所有点,如果发现某个点与即将删除边的这两个点直接相连,值减1;
动态维护好myTripleSum
变量后,每次qts
操作时,直接返回这个变量的值。
4.2.4 qtvs
操作
时间复杂度: O ( V + E ) O(V+E) O(V+E)。
4.2.4.1 操作本质
全称query_tag_value_sum
,查询特定子图的边权和。
特定子图就是某个Person
所属的某个Tag
。这里“边权和”的具体要求是:只需计算两个不同点之间的边权,且对于两个不同点的无向边,权值计算2次。
4.2.4.2 维护策略
静态查询。即需要查询时在当前状态下计算并返回结果。
之所以不采用动态维护,是因为动态维护不仅需要考虑Tag
内部的边权改变、顶点改变的情况,还需要考虑整个Network
的边和顶点的状态变化,容易考虑不全,而且过于频繁的动态维护同样消耗巨大。
反观静态查找,我们采用遍历Tag
子图中所有顶点和边的方法,复杂度为
O
(
V
+
E
)
O(V+E)
O(V+E),是可以接受的,并且不必考虑复杂的维护情况。
具体的步骤是:
遍历Tag
中每个Person
,对于每个Person
,遍历其所有acquaintance
,如果其也在该Tag
中,则将对应的value
计入累加和,最终得到边权和。
4.2.5 qtav
操作
时间复杂度: O ( 1 ) O(1) O(1)。
4.2.5.1 操作本质
全称query_tag_age_var
,查询特定子图的所有Person
的年龄方差。
4.2.5.2 维护策略
动态维护计数变量。
具体来说有两个变量:标签组内年龄和以及标签组内年龄的平方和。
通过前者我们就能在 O ( 1 ) O(1) O(1)复杂度下得到年龄均值 a g e M e a n ageMean ageMean。然后由 a g e M e a n ageMean ageMean、年龄平方和 s u m S q u a r e d sumSquared sumSquared以及年龄和 s u m sum sum在 O ( 1 ) O(1) O(1)复杂度下继续得到方差 a g e V a r ageVar ageVar。计算公式如下:
a g e V a r = ( s u m S q u a r e d − 2 × a g e M e a n × s u m + s i z e × a g e M e a n 2 ) ÷ s i z e ageVar= (sumSquared-2\times ageMean\times sum + size\times ageMean^2)\div size ageVar=(sumSquared−2×ageMean×sum+size×ageMean2)÷size
其中 s i z e size size是人数。
4.2.6 qba
操作
时间复杂度: O ( 1 ) O(1) O(1)。
4.2.6.1 操作本质
全称query_best_acquaintance
。查询某个顶点的边权最大的邻接点id
。
4.2.6.2 维护策略
动态维护每个Person
的bestAcquaintance
变量。
引入TreeMap
数据结构。需要自定义SortedAcquaintance
类及其比较器用于TreeMap
的排序。
SortedAcquaintance
类拥有两个属性:id
和value
。value
是首要判据,value
更大的对象排在前面,如果value
相等,id
更小的对象排在前面。两个对象相等,当且仅当id
和value
都相等。
将SortedAcquaintance
对象作为key
,它的id
作为value
,并以按上述规则构建的比较器传入,构建TreeMap
,那么第一项就是我们需要的bestAcquaintance
。
在Person
对象增加熟人、删除熟人或者修改亲密值时维护该Map
即可。
动态维护好bestAcquaintance
变量后,每次qba
操作时返回其id
即可。
4.2.7 qcs
操作
时间复杂度: O ( V ) O(V) O(V)。
4.2.7.1 操作本质
全称query_couple_sum
。查询图中新定义结构“情侣点对”的个数。
“情侣点对”中的两个点必须满足:两点不同且互相为对方的边权最大的邻接点。
4.2.7.2 维护策略
在qba
操作动态维护的bestAcquaintance
变量基础上静态查询。
每次查询遍历所有点,如果某个点的bestAcquaintance
的bestAcquaintance
是自己,那么它属于且仅属于某个“情侣点对”。
满足这种条件的点的数量的一半即为“情侣点对”的数量。
4.2.8 qsp
操作
时间复杂度: O ( V + E ) O(V+E) O(V+E)。
4.2.8.1 操作本质
全称query_shortest_path
。查询指定两点间的最短路径。
具体来说,最短路径长等于从起点到终点所需要经过的最少边数减去1。如果两点是同一点,那么值为0。
4.2.8.2 维护策略
静态查询,通过bfs广度优先搜索实现。
广度优先搜索将从起点出发,依次搜索起点的所有邻接点,再在这些邻接点基础上搜索它们的邻接点,如此递归下去。这样只要搜索到终点,搜索的层数自然就是最求的最短路径长。复杂度为 O ( V + E ) O(V+E) O(V+E)。且 V V V、 E E E分别是起点终点所在的极大连通子集的顶点数和边数。
5 性能分析
📌本章只谈及上一章没有提到的性能问题。事实上,上一部分的并查集等内容也是性能问题的一部分。性能优化指的是在正确性已经保证的前提下提升性能,而性能修复是为了解决性能带来的评测无法通过的问题。
5.1 性能优化——哈希数据结构
笔者推荐广泛地使用HashMap
等哈希数据结构做为存储结构。这里就以HashMap
为例。
利用每个Person
顶点id
值的唯一性,将其做为索引构建HashMap
。以第九次作业来说,有如下几处地方:
MyNetwork
类的persons
,索引是Person
的id
值,值是id
对应的Person
。MyPerson
类的acquaintance
和value
,对于前者索引是Person
的id
值,值是id
对应的Person
;对于后者,索引是Person
的id
值,值是id
对应的Person
和该类对象(也是Person
)的亲密度值。BlockSet
类的blocks
和indexTable
,在上一部分已有说明。- 所有自定义异常类的
exceptionCnt
,索引是触发此种异常的id
,值是此id
触发该种异常的次数。
使用HashMap
有很多显而易见的优点。最重要的便是哈希索引机制最大程度地保证查询、引用的高效快捷,能够有效地提升程序的整体性能。
此外,HashMap
的初始默认容量是16。如果需要存储大量的数据,可能需要频繁扩容。而每次扩容是需要一定的性能代价的。
因此对于那些在单次运行中只会实例化一次、且预估会存储大量数据的HashMap
类型变量,我们可以为其显式地指定一个合理的初始容量。
例如MyNetwork
类中的persons
、BlockSet
类的blocks
和indexTable
,所有自定义异常类的exceptionCnt
。
5.2 性能修复
5.2.1 并查集策略
在第九次作业中,笔者原本采用删边时立即重建的普通并查集策略。
这样如果有很多删边操作,但并不查询有关并查集的数据,就会白白浪费大量的CPU时间用于并查集重建,导致出现CTLE。
同时由于id
可能为负值,普通并查集的find
方法也不能通过返回 -1来标识异常,从而导致WA。因此需要返回其他标识值或者更换并查集策略。
综合上述分析,并且为后续迭代考虑,笔者通过更换并查集机制解决上述问题。
新的 “查时重建”动态并查集详见qci
操作。
5.2.2 int
类型的比较
在第十次作业中,一个非常不起眼的失误导致笔者只拿到70分的强测分数。
int
的范围是
[
−
2147483648
,
2147483647
]
[ -2147483648, 2147483647]
[−2147483648,2147483647]。
假设以下情景:在你的自定义比较器中,需要比较两个int
变量a
和b
的值,当a
大于b
时返回正值,a
小于b
时返回负值,a
等于b
时返回0。
一个代码量少的经典错误比较方法是:直接将需要比较的两个变量相减,返回相减的结果。
这种方法有着很致命的问题:两个int
变量相减可能会出现溢出的情况,这时所得到的结果就是不可预知的,很有可能导致判断大小出错。
因此,我们需要修复这个问题。修复后,自定义比较器的compare
方法如下:
public int compare(SortedAcquaintance o1, SortedAcquaintance o2) {
if (o1.getValue() == o2.getValue()) {
if (o1.getId() < o2.getId()) {
return -1;
} else if (o1.getId() > o2.getId()) {
return 1;
} else {
return 0;
}
}
if (o1.getValue() < o2.getValue()) {
return 1;
} else if (o1.getValue() > o2.getValue()) {
return -1;
} else {
return 0;
}
}
可以看到,我们直接使用java
提供的大于号、小于号操作符来比较int
类型(id
和value
),保证不会出现错误,然后再返回固定的0、1、-1三种标识值,这样就能完全避免相减比较的溢出问题,从而修复了这个bug。
6 学习体会
6.1 实现与规格分离
规格是需求的形式化表达,用于避免自然语言带来的歧义,严格地描述系统行为和功能,对属性和方法给予一定限制,但它不会涉及具体的细节。
在具体实现时,一般不可能直接翻译规格,否则性能将会十分低下,因为 JML的规格描述为了保证正确性,使用的是最基本的判断和计算方法,特别在数据量很大时,性能可能带来很大差异,具体实现时会在确保遵守规格的基础上使用更加快捷的方法。
6.2 学习心得
我第一次了解使用规格语言辅助代码设计的思想,通过这种方式,我们可以通过注释来描述方法的前置条件、后置条件和类的不变式等,起到在代码中明确规定预期行为的目的,以帮助我们准确地实现程序的功能。同时,这一单元以JML为背景,还涉及了很多关于图论的算法知识,比如BFS算法、并查集的使用等等,这些都让我感到收获颇丰。