文章目录
BUAA OO 2024Unit3总结
00前言
写在前面
本单元的作业主要围绕规格化设计、实现与测试展开,核心实现是维护一个简单的社交关系网络。学习目标是理解并掌握JML
规格在面向对象设计中的作用和使用,并且掌握根据JML
语言来实现代码的能力,并能根据JML
来设计测试方法。所以本单元的作业架构已经由JML
规定好了,我们只需要根据规格选择合适的数据结构和算法来实现规格。
由于本单元写博客的ddl比之前短,所有本文会比之前更强调重点而非详细介绍全部实现细节。
关于我对本单元的其他个人想法放在了本文最后。
你需要一定了解的:
-
什么是
JML
?
-
什么是
Junit
? -
图基本知识
01针对本单元的测试过程
Question 1:什么是黑箱测试?白箱测试?
又叫黑盒测试和白盒测试。用我自己的话来说:
- 黑箱测试:不管程序内部如何实现的,通过尽可能的编造高覆盖率的测试点,只关心能不能输出正确的结果。
- 白箱测试:允许测试人员知道程序内部的实现逻辑,通过对程序的逻辑结构构造测试用例进行测试,关心的是程序中的每一步逻辑是否正确。
两者没有固定的高下之分,只能说各有利弊。黑箱测试面向需求构建测试点,简单易行的同时能更好的检测代码是否能覆盖需求,但不能保证代码覆盖率高(比如可能每次测试都运行了if
而没有运行else
,会导致测试覆盖率的降低);白箱测试能提供更高的代码覆盖率,但实现成本可能更高,并且可能无法覆盖所有错误。
Question 2: 多种测试的理解
- 单元测试:是针对软件设计的最小单位——程序模块进行正确性检验的测试工作,目的在于检测模块的功能、性能、设计约束等。模块可以小到一个方法,也可以大到一个
.java
文件。多个模块可以平行独立的实现单元测试。本单元每次作业中实现的Junit
测试均是对某一方法的单元测试。 - 功能测试:顾名思义,用于测试代码功能是否完善,能否涵盖所有的需求。例如本单元我搭建的自动化评测机,构造包含各种指令以及各种情况的样例,其实就是一种功能测试。
- 集成测试:也叫组装测试,是在单元测试基础上,将一些单元测试正确的模块组装到一起,测试模块间的接口以及逻辑关系,能否实现更大的需求。直到集成的范围扩大到整个程序或者项目。
- 压力测试:相比较功能测试追求全面,压力测试更追求极端。例如本次单元作业中,我曾针对
mr
指令构造了200个点的全连接图,然后删除所有的边:在这种相对极端的情境下“压力”代码,测试代码能否保持功能性的同时维持较高的性能。本次作业的强测中也多次出现压力测试的数据点(例如第一次就出现了300个点的全连接和删边,只能说oo给我们的压力比我想象中的还大555)。 - 回归测试:针对迭代开发或者软件维护阶段,对软件内容修改后,在保证新修改的部分能达成预期目的和性能的同时,不影响软件以前部分的正确性。
Question 3:数据构造策略
本单元主要采用了两类测试方法。
- 作业中完成的
Junit
测试实际上采用了白箱测试思路,但又是不完全的白箱——向我们透明的逻辑是JML
规格,而课程组代码的具体实现逻辑是完全不知晓的。尽管课程组保证不需要构造极端样例,但Junit
测试的测试样例还是要保证一定的覆盖率下限。这部分数据构造主要采取的是随机构造+特征构造的思路。例如第十次作业对qcs
指令进行单元测试,我先是随机构造一张图,采取的是取两点(组合数 C ( n , 2 ) C(n,2) C(n,2))然后1/2概率添加关系。这样的数据点不能涵盖有孤立点的情况(试想一下对于一个20个人的社交网路,通过这样的方式构造出一个孤立点的概率只有 1 2 19 ≈ 1.9 ∗ 1 0 − 6 \frac{1}{2}^{19} \approx 1.9 *10^{-6} 2119≈1.9∗10−6),因此需要再单独向网络添加几个人,以提高数据复杂度。 - 除此之外,自己搭建的自动化评测机则主要采取了黑箱测试的思路。这里我也用到了类似
Junit
的测试思路,只不过此处的特征构造变换成了压力测试。这使得我的代码在三次强测中没有出现TLE。 - 顺便一提,课程组提供的一些比较相等的方法是浅克隆方法,接口上又没提供深克隆方法的接口,于是乎在构造
Junit
测试样例时,我会构造两个一模一样的network
:network
和shadowNetwork
。一个是实验组,一个是对照组。
02浅析复杂度分析与规格实现
复杂度分析
本单元作业不是重点考察大家的算法,大家不需要在算法上花太多时间。——课程组
在本单元作业的实现过程中,往往最让同学们头疼的不是功能的实现,而是性能的保障。为什么前两单元没有重点考量的时间复杂度的问题在这一单元被着重考量了呢?一方面是指令数量提升到了10000条,进而导致图维护的复杂度提升,如果不进行一些优化那么很可能付出惨痛的性能代价;另一方面是课程组压力测试的存在,导致不能以简单的平均时间复杂度来衡量,而是要综合考虑最坏时间复杂度。最后,官方测评机的配置也是最大的X因素,很多人本地可能1s以内,但放到官方测评机上就7s8s甚至TLE了。
从经验之谈上来看,经常需要查找和删除的成员变量最好采用HashMap<>
或者HashSet<>
的结构,这样可以将这些操作的复杂度降到
O
(
1
)
O(1)
O(1)。如果需要考虑顺序并且对查找的要求不高的成员变量,就最好不要采用以上两种了,可以采用ArrayList<>
等(一般情况不推荐使用数组)。如果对排序有需求的还可以采用例如TreeMap<>
和PriorityQueue<>
等数据结构,这些结构的删除和插入可能是
O
(
l
o
g
n
)
O(logn)
O(logn)的。
对于方法的时间复杂度上,尽量不要出现 O ( n 2 ) O(n^2) O(n2)的操作(虽然本人没胆量挑战一下,但经验之谈是不要这样做),比这个更高的就更不用谈了。其次 O ( n ) O(n) O(n)级别的操作一般是可以接受的,比之更低的也更不用说。
降低时间复杂度的方法一是写出更优的算法,二是进行动态维护,三是以空间换时间。
规格实现
先说结论:要按照JML实现代码功能,但不要完全按照JML实现代码逻辑!!!
这一点要结合复杂度分析来看,例如第九次作业中的qts
操作,如果按照JML
语言来实现需要三层for
循环,从性能的角度毫无疑问是不能接受的。就我个人理解上,JML
只是规范了方法的功能、前置条件、后置影响等,但不限制你的实现思路。就好像《食品安全法》规范了食品生产活动的各种要求和标准,但并不会限制你怎么生产食品。
回过头来思考测试的过程,会发现JML
还为我们提供了测试的逻辑,方便我们进行白箱测试,这也是JML
的一大好处。
03第九次作业
图构建方法
本次作业要实现的network
类实际上就是一个图,图中的点是person
,边是两个person
间添加的关系。person
的关系在其内部用acquaintance
和value
维护。于是乎,我使用HashMap<>
作为核心容器,用personId
做索引,方便查找和删除操作。每当加人和加关系时就调用HashMap
的put
来实现,这样就不断构造出了一张图。
除此之外我还在network
中实现了另一数据结构——并查集的维护。这一结构被无数先辈所使用,因此我也采用了这一方法。且听下文分解。
主要维护的指令:qci、qbs、mr、qts
qci
该指令的目的可以理解为查询两个点之间是否可达。针对这一要求,主流的方法有以下两种:
- dfs搜索(或者bfs)
- 基于并查集的维护
我采用了路径压缩的并查集的思路。关于什么是并查集,网络上有许多介绍详细的博客(推荐),这里就不再赘述。
路径压缩是指在向上寻找最上端元素(以下称为祖先)的时候,将遍历路径上的元素指针指向祖先。
用我自己的话说,并查集就像一个个黑帮,黑帮里只有一个老大;每当黑帮合并时,大黑帮的老大会成为新黑帮的老大,而当我们需要判断两个人是不是属于同一个黑帮时,只需要看他们的老大是不是同一个人。在本次作业中,每当两个人之间建立关系,就选择一个人作为“老大”,另一个人成为他的“小弟”;如果其中一个人已经有“老大”了,那另一个人就自动加入这个“黑帮”;如果两个人都有各自的老大,那就发生并查集的合并。
@Override
public void addRelation(int id1, int id2, int value) {
/* exception*/
// ...
((MyPerson)persons.get(id1)).addLink(persons.get(id2), value);
((MyPerson)persons.get(id2)).addLink(persons.get(id1), value);
union(persons.get(id1), persons.get(id2));
// ...
}
public void union(Person a, Person b) {
// add person时,会让每个人的父节点指向自己
Person headA = getHead(a); // 获取a祖先
Person headB = getHead(b); // 获取b祖先
if (!headA.equals(headB)) { // “老大”不一样,需要合并
// 这里借鉴了“按秩合并”的思想
Person big = bigerSize(headA, headB);
Person small = smallerSize(headA, headB);
fatherMap.put(small, big); // 并查集合并
int newSize = sizeMap.get(big) + sizeMap.get(small);
sizeMap.put(big, newSize); // 更新并查集大小
sizeMap.remove(small); // 移除被合并的并查集
blockNum--; // 维护qbs
}
}
采用并查集的好处是qci的复杂度可以降到很低——最好情况下是 O ( 1 ) O(1) O(1)的,只需要比较两个点是否拥有共同的祖先。
@Override
public boolean isCircle(int id1, int id2) throws PersonIdNotFoundException {
if (!containsPerson(id1)) { throw new MyPersonIdNotFoundException(id1); }
if (!containsPerson(id2)) { throw new MyPersonIdNotFoundException(id2); }
Person person1 = persons.get(id1);
Person person2 = persons.get(id2);
return isSameSet(person1, person2);
}
qbs
该指令的目的是查询极大联通子图的个数(说实话JML
语言在描述功能上真不如自然语言精炼易懂)。由于采用了并查集,那么qbs也就不需要两层for
循环判断是不是isCircle
了(如果qci
和qbs
全部用JML
实现,可想而知qbs
复杂度一定会爆掉),直接动态维护blocknum
,需要执行qbs
时直接返回blocknum
的值即可。当新加入一个人时,blocknum++
;当并查集合并时,blocknum--
;当删关系时就需要判断删完关系后两个是否还在同一个并查集,是则不变,否则blocknum++
。时间复杂度
O
(
1
)
O(1)
O(1)。
mr
理论上mr并不复杂,其目的是修改关系大小value
,且当修改后关系<=0时删除原关系。由于之前容器全部采用HashMap
,因此删除操作复杂度很低,但由于采用了并查集,维护并查集反而成了负担。我采用了局部重建并查集的思路,即当删关系发生时,从任意一个点出发,以该点为祖先做dfs
重建并查集,再判断另一个点此时是否已经被纳入新并查集,如果不是,就再从该点出发重复上一个点的操作,重建新的并查集(同时维护blocknum++
)。
public void rebuild(Person person1, Person person2) {
fatherMap.put(person1, person1);
fatherMap.put(person2, person2);
sizeMap.put(person1, 0);
for (Integer id: persons.keySet()) { vs.put(id, false); } // vs是用于记录该点是否被搜索过
dfs(person1, person1); // 采用递归实现dfs
if (!vs.get(person2.getId())) { // 判断是否需要创建另一个并查集
sizeMap.put(person2, 0);
blockNum++;
dfs(person2, person2);
}
}
顺便一提,并查集的优化还有很多其他思路,例如“脏位”优化的并查集等(等查询时才重建)。基于脏位的设计上又分为记录受害祖先和不记录两种,如果不记录,那么就需要查询时重建全局并查集(因此几乎没什么优化意义);如果记录受害祖先,实现起来又有点复杂。由于第一次作业面对课程组的压力测试并没有出现TLE,后续我也没有进行这些优化,感兴趣的同学可以移步至其他学长博客。
qts
该指令的目的是查询三元环的个数。如果采用三层for,那无疑是不行的。因此采用动态维护的思路,每当加关系或者删关系时,就更新三元环个数tripleNum
,时间复杂度
O
(
1
)
O(1)
O(1)。
public void tripleUpdate(Person person1, Person person2, int offset) {
int size1 = ((MyPerson) person1).getAcquaintance().size();
int size2 = ((MyPerson) person2).getAcquaintance().size();
MyPerson less = size1 > size2 ? (MyPerson) person2 : (MyPerson) person1;
MyPerson other = less == person2 ? (MyPerson) person1 : (MyPerson) person2;
// 遍历熟人少的,降低遍历次数
for (Integer id : less.getAcquaintance().keySet()) {
if (id != person2.getId() && id != person1.getId()
&& persons.get(id).isLinked(other)) {
tripleNum += offset; // offset = 加关系? 1 : -1;
}
}
}
04第十次作业
本次作业新增了Tag类,作为Person
的一个属性,对于社交网路的图维护没有太多影响,因此直接介绍新增指令的实现。
主要维护的指令:qtvs、qtav、qba、qcs、qsp
qtvs
查询一个tag
里所有关系value
的总和。采用动态维护的策略,查询时直接返回valueSum
,时间复杂度
O
(
1
)
O(1)
O(1)。维护的地方主要有向关系中加人、删人,以及加关系和删关系。我维护了一个哈希表belong2Tag
,用于记录每个人属于哪些tag
。
private final HashMap<Integer, HashSet<Tag>> belong2Tag; // personId, 属于的tag
// 这里HashSet的类型使用Tag而非Integer,是因为tagId在Person内是独一无二的,但全局network并不保证
以加关系为例,遍历其中一人所属的tag
,判断另一个人是否同属于该tag
:如果是就更新该tag
的valueSum
。
@Override
public void addRelation(int id1, int id2, int value)
throws PersonIdNotFoundException, EqualRelationException {
// ...
// 更新tag中的valueSum
Iterator<Tag> iterator = belong2Tag.get(id1).iterator();
while (iterator.hasNext()) {
MyTag tag = (MyTag) iterator.next();
if (tag.isBeDel()) {
iterator.remove();
continue;
}
if (tag.hasPerson(persons.get(id2))) { tag.addValue(value); }
}
}
针对belong2Tag
的维护,当tag
被删除时,如果遍历所有的belong2Tag
那无疑复杂度过高,此时我的做法是在Tag
类中设置一个类似的“脏位”bedel
,当tag
从社交网络中永久删除时,置为真;只有当其被遍历到时,才把它从belong2Tag.get(id)
中删除。而当人从tag
中移除时,直接调用remove
方法即可。
qtav
查询一个tag
中所有人的年龄方差。采用动态维护的策略,使用概统课学到的
D
(
x
)
=
E
(
x
2
)
−
E
(
x
)
2
D(x) = E(x^2)-E(x)^2
D(x)=E(x2)−E(x)2,记录ageSum
和ageSquSum
,比较简单,
O
(
1
)
O(1)
O(1)的时间复杂度。
- 注意观察JML的描述,避免精度问题!
- 注意除0问题!
qba
查询一个人的最好朋友(按value
排序,value
相同按id
排序)。依旧采用动态维护的策略,每当添加关系时就更新bestId
和bestValue
。注意当关系大小改变时,也要及时更新,此处主要涉及分类讨论。
public void modifyValue(Person person, int value) {
int oldValue = this.value.get(person.getId());
this.value.put(person.getId(), oldValue + value);
// 更新bestAcq
if (person.getId() == bestAcqId) { // 更改的是bestAcq的关系
if (value < 0) { // 需要重新遍历查找bestAcq
bestAcqUpdate = true; // 设置查找位
bestAcqValue = Integer.MIN_VALUE;
} else {
bestAcqValue += value; // bestAcqId 不变,只增加bestAcqValue
}
} else {
/* 判断是否更新bestAcqId 和 bestAcqValue */
}
}
此处的查找位也是借鉴脏位的想法,当查找位==false
时直接返回结果,否则遍历查找。时间复杂度上最好情况
O
(
1
)
O(1)
O(1),最坏
O
(
n
)
O(n)
O(n)。
public int getBestAcqId() {
if (!bestAcqUpdate) {
return bestAcqId;
}
for (Map.Entry<Integer, Integer> entry : value.entrySet()) {
// 重新查找bestAcqId
}
bestAcqUpdate = false;
return bestAcqId;
}
qcs
目的是查询互为bestAcquaintance的组数,比较简单,直接遍历所有人,查询其bestAcq
的bestAcq
是否是自己就行。一层for
循环,最优情况
O
(
n
)
O(n)
O(n)的复杂度,最坏情况大于
O
(
n
)
O(n)
O(n)是完全可以接受的。记得结果需要除2。
qsp
目的是查询两点间的最短路径,但是是把整个图当做无权图来处理的。因此Dijkstra
算法就没必要了,这里更推荐广度优先搜索(bfs
)。同届很多同学使用了双向bfs或者01bfs等,我是直接使用了普通的bfs,并不会超时,性能是ok的。
05第十一次作业
本次作业主要增加了Message
类,以及四个子类信息,实现了send Message
的功能。同时network
中存储了所有表情以及对应的“热度”。我仍然采用哈希表来实现
e
m
o
j
i
I
d
→
e
m
o
j
i
H
e
a
t
emojiId \rightarrow emojiHeat
emojiId→emojiHeat以及
m
e
s
s
a
g
e
I
d
→
m
e
s
s
a
g
e
messageId \rightarrow message
messageId→message的对应。person
内部使用ArrayList
来存储接收到的消息(保证消息的有序性)。
主要维护的指令:dce、cn
dce
目的是删除冷门表情。我仍然采用了空间换时间的数据结构,使用类似belong2Tag
的结构构造了一个emoji2message
,表示某个emoji
被那些emojiMessage
使用。删除时我使用迭代器遍历emojiList
并删除热度小于limit的emoji,并记录下来被删除的emojiId。emojiList
更新完成后,再去遍历被删除的emoji对应的emoji2Message
的表项,将这些message从messages
中删除。
其实更简单的做法是不适用emoji2Message
这样的结构,而是遍历所有message
,如果是EmojiMessage
且其emojiId
不存在了(说明被删除了),那就移除该消息。这样虽然从遍历的角度可能比上面的方法要多,但实际下来强测也不会TLE。
cn
清空通知类消息。这一操作我是在Person
类中完成的,Network
只是调用了Person
相关方法。由于Perosn
类里面的消息列表我采用了ArrayList
,而其删除的复杂度最坏是
O
(
n
)
O(n)
O(n)的,因此我再一次采取了noticeMessageSet
这一结构来存储一个人的所有通知,并仿照Tag
类的beDel
,设置了Message
类的beDel
。然后cn
指令到来时,我将noticeMessageSet
中的消息全部设为beDel = true
而不去删除messages
中的消息。直到getReceivedMessages
被调用时,判断是否isBeDel
,是就跳过。可以在执行一次cn
后清空noticeMessageSet
,减少重复清空。注意我这样写的原因是,我没有任何方法调用了Person
类的getMessages
方法,否则这样做是不行的(因为从来没有真正删除过通知类消息)。
public List<Message> getReceivedMessages() {
List<Message> result = new ArrayList<>();
int index = messages.size() - 1;
while (index >= 0 && result.size() <= 4 && !messages.isEmpty()) {
MyMessage message = (MyMessage) messages.get(index);
if (message.isBeDel()) {
index--;
continue;
}
result.add(message);
index--;
}
return result;
}
06 bug修复与性能分析
本单元我的强测与互测均未出现任何bug。性能实现上几乎将所有方法的复杂度都降到了
O
(
n
2
)
O(n^2)
O(n2)以下,并且采用动态维护的策略,使得诸如qbs
、qts
、qtvs
等查询指令的复杂度降到
O
(
1
)
O(1)
O(1),原本的动态维护复杂度被分散到其他地方,对整体性能有较大帮助。多次采取以空间换时间的思想来解决问题,也是一个不错的思路(但要小心没准课程组来年整了个会爆内存的指令呢但那已经雨我无瓜了)。
07 浅谈Junit以及本单元的学习体会
上学期OOpre中就使用过Junit
,体验感很是不好,主要是要测试的方法很多,课程组又会要求一定的行数覆盖率和分支覆盖率,所以很头疼。这单元只需要测试指定方法的规格,体验感就好很多了。虽然是JML
是白箱的,但课程组代码的具体逻辑又是黑箱的,因此总有一种说不上的违和感,但又的确是在日常中很可能遇到的测试情况。我想这也是JML
规格的意义所在——为黑箱的代码提供白箱的测试方案。
测试时一是要构造覆盖率高的测试样例(见本文01部分Question3),二是要按照规格来测试代码逻辑,起到白箱测试的效果。
- 判断\result是否正确
- 判断\ensures是否正确
- 判断是否只有满足\requires才执行
- 判断是否只修改了\assignable,pure方法是否满足pure
- ······
本单元的学习相比前两单元还是容易不少的,但从学习目标上来看,我们真的需要JML
吗?JML
的优点很明显,但缺点也很明显,试想一下前两单元的方法全部用JML
表示,那无疑是灾难的。JML
的服务对象是规格化设计,我想或许这一单元的重点可能不是掌握JML
,而是掌握规格化设计、实现和测试。回到我前面《食品安全法》的例子,学习《食品安全法》的核心目的不是为了让你学会制定法律法规,而是为了让你更好的制造安全健康的食品,不是吗?