北航面向对象设计与构造2021第三单元作业总结
一、实现规格要求所采取的设计策略和体会
本单元作业是通过 JML 规格来模拟实现一个社交网络。首先要读指导书,对作业有宏观认识。再读规格,如果比较简单,就直接翻译实现;如果比较复杂,就需要转化为自己的理解,然后再实现。
JML 作为一种形式化的规格建模语言,为体现严谨性,消除自然语言的歧义,势必会有啰嗦的地方。因此在实现时,我发现了一些共同之处,印象最深的就是这两条:
- 添加东西:旧的不能变,新的加进来,总数增加新加进来的数量。
- 删除东西:除了删掉的以外都不变,总数减少删除的数量。
使用容器时,添加和删除基本都是一行的事,而 JML 规格却需要啰啰嗦嗦写一大堆。因此可以发现 JML 规格并不实用。如果这样的规格占大多数,程序员可能会疲惫,看到类似的模式就写成添加或删除。如果规格有细微差别,则很容易会被忽视。
二、结合课程内容,整理基于 JML 规格来设计测试的方法和策略
很惭愧,因为各种原因,本单元作业并未针对规格进行详尽的测试。我知道Junit
是很不错的工具,但我后来才知道 Python 中的cyaron
可以生成图,而那时作业已经结束了qaq。
三、总结分析容器选择和使用的经验
这里就不再啰嗦数据结构那一套东西了,简言之:
- 对于这种靠
id
来唯一标识对象的情境中,尽量多用HashMap
,因为增删改查时间复杂度均为\(O(1)\)。- 如果需要根据
id
取Person
,可以把id
作为键,Person
作为值; - 如果只需根据
id
查Person
是否存在,或者查people
人数,可以只用HashSet
; - 如果需要根据
Person
取一些特定值(如socialValue
等),由于Person
已保存id
,仍然可以把id
作为键,Person
作为值; - 也可以在
MyPerson
中实现hash()
方法,使MyPerson
可以作为键,但由于官方接口方法签名中有些参数为Person
类型,将MyPerson
作为键需要强制类型转换,不太优雅;若Person
的其它实现没有hash()
方法,强制类型转换还可能会造成运行时错误。但出于方便考虑,acquaintances
采用了这种方法。
- 如果需要根据
- 对于需要在线性表首或中部添加或删除对象的情境中,尽量用
LinkedList
,因为在表首增删改查时间复杂度均为\(O(1)\),在中部增删可以仅改变指针所指对象,无需顺序移动后面的对象。
不用HashMap
而用ArrayList
的后果就是像我舍友一样 TLE >_<。
四、针对本单元容易出现的性能问题,总结分析原因
根据我的体会,出现性能问题有以下几点原因:
1. 高情商:自己懒,规格理解不到位,直接翻译 JML;低情商:被 JML 误导
规格仅限定功能,不限定实现方法。因此,实现时应遵循规格而非翻译规格,需要理解 JML 而非照搬 JML。
比如query_block_sum (qbs)
命令对应的queryBlockSum()
方法,JML 规格是这样写的:
/*@ ensures \result ==
@ (\sum int i; 0 <= i && i < people.length &&
@ (\forall int j; 0 <= j && j < i; !isCircle(people[i].getId(), people[j].getId()));
@ 1);
@*/
而其中的isCircle()
方法用于判断是否为环路,需要使用图的搜索算法(深度优先搜索(Depth-first search,DFS)和广度优先搜索(Breadth-first search,BFS)用邻接表时间复杂度均为\(O(V+E)\)(其中\(V\)为顶点数(即人数),\(E\)为边数(即关系数)),但由于是判断环路,DFS 更适合。DFS 用递归很简洁,但数据规模更大时需进行非递归改造)。
(还没正式学严格的复杂度分析理论,复杂度的具体值可能是错的,但大概意思应该是对的[手动捂脸])
如果直接翻译 JML,则该方法时间复杂度为\(O(N^3)\),这是无法接受的。因此必须改进算法。
注意到,从给定顶点进行 DFS 时,会遍历所有与其连通的顶点。因此可以用一个HashMap
标记每次 DFS 到达的顶点(其实就是染色),queryBlockSum()
方法中遍历people
时,只对未访问过的顶点进行 DFS,并且每标记一次计数器就加一,最后返回这个计数器,即为连通块的个数。这样最坏时间复杂度为\(O(N^2)\),虽然好了一些,但还有风险。
又注意到,如果没有添加Person
或关系,连续调用queryBlockSum()
方法时,并不需要重复计算。因此可以用一个 Cache 变量保存上一次计算的连通块个数,一些辅助变量用来标记是否添加了Person
或关系。只有当添加Person
或关系后,才需重复计算。这样,若要重复计算,至少额外添加 1 条指令;且若总人数太少或关系太简单,queryBlockSum()
方法也不会耗时太多。因此在对总命令条数和各命令条数设限的情况下,上述做法可以避免性能问题。
当然,还可以通过并查集来完成。
2. 本地未进行充分的压力测试,未发现性能问题
表面看上去都是\(O(N^2)\)的算法,实际上也有重大区别。比如query_group_value_sum (qgvs)
命令对应的getValueSum()
方法,JML 是这样写的:
/*@ ensures \result == (\sum int i; 0 <= i && i < people.length;
@ (\sum int j; 0 <= j && j < people.length &&
@ people[i].isLinked(people[j]); people[i].queryValue(people[j])));
@*/
对people
里的Person
进行双重循环遍历,如果任两人有关系,就增加valueSum
。
仔细想想,一般情况下大部分的遍历都是多余的,因为大部分情况下一个人不可能和其它所有人都有关系。因此,内层循环只需遍历某Person
的acquaintances
就行了。
这种问题不太容易在实现时发现,但容易在压力测试时发现。遗憾的是实现时并没有引起我的重视,导致 TLE 了。
五、梳理自己的作业架构设计,特别是图模型构建与维护策略
架构很简单,严格按照规格,基本没有自由发挥的余地。异常类有明显的共性,但此处就不折腾了。
图使用的是邻接表。
sendIndirectMessage()
方法需要通过 Dijkstra 算法求最短路。由于 Java 中的PriorityQueue
并没有用斐波那契堆实现,因此不支持堆上动态修改。如果重载的比较方法用到了位于外部的数据distance
,而distance
在堆以外的地方被修改,修改不会自动触发堆的重排,堆的性质就被破坏了。因此放进堆中的元素不能只有一个Person
,应该是一个按距离实现Comparable
接口的包含Person
和距离的Node
。
private int dijkstra(Person src, Person dst) {
HashMap<MyPerson, Integer> distances = new HashMap<>();
PriorityQueue<Node> unvisited = new PriorityQueue<>();
for (MyPerson person : people.values()) {
if (!person.equals(src)) {
distances.put(person, Integer.MAX_VALUE);
unvisited.offer(new Node(person, Integer.MAX_VALUE));
}
}
distances.put((MyPerson) src, 0);
unvisited.offer(new Node((MyPerson) src, 0));
while (!unvisited.isEmpty()) {
Node smallest = unvisited.poll();
MyPerson smallestPerson = smallest.getPerson();
int smallestDistance = smallest.getDistance();
if (distances.get(smallestPerson) < smallestDistance) {
continue;
}
for (Map.Entry<MyPerson, Integer> entry :
smallestPerson.getAcquaintances().entrySet()) {
int distance = smallestDistance + entry.getValue();
MyPerson person = entry.getKey();
if (distance < distances.get(person)) {
distances.put(person, distance);
unvisited.offer(new Node(person, distance));
}
}
}
return distances.get((MyPerson) dst);
}
以上就是本单元作业中我比较想说的,如有不对欢迎指正。