301
社区成员
发帖
与我相关
我的任务
分享黑箱测试:只关注程序的输入和输出,而不考虑内部结构或实现细节,着重于验证程序功能的正确性,符合规格与实现分离的思想。OO 强测是基于 JML 的规格说明和性能要求,通过一系列测试用例来验证程序是否符合规格要求,是对所有程序的黑箱测试。
白箱测试:我们需要了解程序的内部结构、设计和实现细节,并基于代码或系统设计来设计测试用例,以验证程序内部逻辑的正确性。Junit 是我们进行白箱测试很好的辅助工具,可以根据衡量测试程序的分支覆盖率、行覆盖率、方法覆盖率等指标。OO 互测可以锻炼我们对别人代码进行白箱测试的能力~
在我看来,黑箱测试侧重于功能验证,而白箱测试侧重于结构验证,我们应该以两种测试相结合的方式来全面地评估和验证程序设计的正确性和有效性。
单元测试是针对程序中的最小可测试单元进行的测试,通常是函数、方法或类。通过验证每个单元的行为是否符合预期,在开发过程中可以尽早发现代码中的缺陷,并确保每个功能模块都能够独立地正常运行。
功能测试是验证程序的各项功能是否按照需求规格说明的要求正常工作的测试,测试样例需要覆盖到程序的所有功能,这对我们构造测试样例的能力提出了较高的要求。
集成测试是将单元或模块组装在一起,测试它们之间的交互是否正确的测试,确保系统在组件组合后的整体功能正确性。只独立地对每个组件进行单元测试往往是不够的,更多的 bug 存在于数据在不同方法、类之间交互传递的过程中。
压力测试是测试程序在负载超出正常范围时的性能和稳定性的测试,通过确定系统的瓶颈、性能问题和资源耗尽情况(比如 TLE、MLE),可以为系统的优化和调整提供指导。比如在强测中大量重复出现的 qtvs,就要求我们进行必要的性能优化。
回归测试是在对程序进行修改或添加新功能后,为确保修改不会破坏原有功能而重新运行的测试。符合开闭原则的设计可以减少在这方面出 bug 的可能性,每次作业迭代时,我都会用上一次强测的数据对本次作业进行回归测试。在 hw11 的互测中我发现部分同学的 bug 出自 hw10,可见回归测试的重要性。
本单元作业是基于图的人际关系网络,这对图的构建提出较高的要求,如果是完全随机的数据很有可能大部分构造出的是稀疏图,对此我先生成 100 个人,每两个人之间按照设定的概率生成边。
对于 tag,message 等信息随机生成的数据很有可能是无效数据,即只抛异常网络未发生变动,我会设定概率在预先生成的 100 个人中寻找 id。
对一些像 id、age 等基本数据类型的生成也需要覆盖边界值的情况,比如在 hw10 的强测中出现针对 id 相减溢出的测试。
JML 规格说明中有 O(n2) 或者 O(n3) 复杂度的方法,因此需要对其进行压力测试。我主要通过手搓数据的方式,比如生成 100 个人的完全图,把所有人加入到同一个 tag 中,大量重复调用 qtvs 等。

由于 JML 规格的限定,类层次的设计基本上变动较小,我主要增加了一个 DisjointSet 类,将并查集相关的维护查询操作与Network类解耦,此外还将大部分Runner类调用Network类中方法的具体实现放到了Tag、Person、Message类中,Network类主要负责检查输入的合法性,不合法则抛异常,这样Network类中的代码不会过于臃肿导致超过 CheckStyle 所要求的 500 行。
连通块的动态维护可以借助并查集来实现,向 Network 中加人时 blockSum+1,加关系时如果两点的父节点不同则 blockSum-1,isCircle()查询两点的可达性,即两点是否在同一个连通块中,等价于两点的父节点是否相同,通过路径压缩优化可以实现近乎 O(1) 的复杂度。
modify_relation 的加入增大了并查集维护的难度,已经路径压缩的并查集无法保存原始父节点信息,必须进行重建,我选择使用 bfs 分别从要删除关系的两点进行染色,更新与之相连的点的父节点,并且更新 blockSum。
但这样存在的问题是不进行任何并查集相关的查询操作时,每删除一条边都要 bfs 一遍,增加了不必要的 cpu 时间,导致在强测的一个点上,不断进行加边删边而不查询,相比于不使用并查集的性能差距非常大,这也让我思考重建的时机。
我选择使用脏位来标记查询时是否进行过删边操作,并将要删边进行 bfs 更新的两点存入 HashSet<Integer>中,如果 dirty 为 1 则需要进行重建。重建时分别对 HashSet 中的点进行 bfs 即可,选择 HashSet 是因为可以保证不存在重复的点,由于删边后 HashSet 中的点之间可能仍联通,所以实际的 bfs 次数平均要比 HashSet 中的点数要少。
private HashSet<Integer> backup = new HashSet<>();
public void rebuild() {
for (int id : backup) {
pp.remove(id); // 先移除并查集中要进行更新的点
}
for (int id : backup) {
if (pp.containsKey(id)) {
continue; // 如果该点已被之前的点更新过,则不需要再bfs了
}
bfs(id);
}
blockSum = 0; // 重建后需要遍历所有点更新blockSum
for (int i : pp.keySet()) {
if (pp.get(i) == i) {
blockSum++;
}
}
}
在加关系和删关系时可以遍历两人之间的熟人,动态维护 tripleSum。
MyPerson px = persons.get(id1);
MyPerson py = persons.get(id2);
for (Person person : px.getAcquaint().values()) {
if (person.isLinked(py)) {
tripleSum++; // or tripleSum--;
}
}
在向 tag 中加人删人时要动态维护 age 的和 ageSum 以及 age2 的和 ageSquareSum
利用方差公式:

由于取整精度问题,公式不能化为最简式,需要部分展开:
ageVar = (ageSquareSum - 2 * ageSum * ageMean + n * ageMean * ageMean) / n;
动态维护可以实现查询时 O(1) 的复杂度。
valueSum 是我个人认为整个单元最难维护,但又必须要维护的变量,如果单纯按照 JML 实现复杂度会达到 O(n2) ,在强测中一定会 CTLE。
由于不同人的 tagId 可能相同,对应两个不同的 tag 实例,所以无法通过 tagId 来索引到唯一的 tag,因此需要先通过 personId 来索引。为了保存人与其被加入到的所有 tag 之间映射关系,我使用了多层嵌套的 HashMap:
private HashMap<Integer, HashMap<Integer, HashSet<Integer>>> tags = new HashMap<>();
需要进行维护的时机,必须考虑全面:
维护这样复杂的数据结构十分困难,在研讨课上我学习到了一种更好的查找 tag 的方式,而且不需要单独存 tag:由于只有两人有关系才能被加入到 tag 中,所以只需要遍历熟人的 tag !
我使用自定义比较器的 TreeSet 来维护 best_acquaintance,每次动态维护的复杂度为 O(log n)
Comparator<Integer> cmp = (o1, o2) -> (!Objects.equals(values.get(o1), values.get(o2))) ?
(values.get(o2).compareTo(values.get(o1))) : (o1.compareTo(o2));
这里需要使用 Java 内置的 compareTo 方法,两者直接相减会导致溢出。
qba 查询复杂度降低到 O(1) ,使 qcs 的复杂度最多为 O(n) ,这里设置脏位或动态维护都是可以的。
可以先使用原来的 isCircle 方法判断两点的连通性,如果连通再使用 bfs 即可,由于图中边的权值均为 1,没有动态维护的 bfs 便可以满足性能要求。
这里主要涉及到 person 中 message 存储容器的选择,qrm 要求读取最新收到的消息,如果不考虑删除操作,将新收到的消息加入到 ArrayList 的尾部,从尾部向前读取即可,不需要按照 JML 所描述的加入到头部,这也体现了规格与实现分离的思想。
而 cn 指令就要求频繁的删除,ArrayList 删除元素涉及到元素的整体移动,性能损失较大,而 LinkedList 删除和队头插入操作的复杂度均为O(1) ,更有优势。此外注意要用迭代器的方式进行遍历删除,可以使用removeIf()。
public void clearNotices() {
if (noticeCnt == 0) { // 可以在加入消息时动态维护NoticeMessage的数量
return;
}
Iterator<Message> it = messages.iterator();
while (it.hasNext()) {
if (it.next() instanceof NoticeMessage) {
it.remove();
noticeCnt--; // 维护noticeCnt
if (noticeCnt == 0) {
return; // 无需继续遍历
}
}
}
}
通过上一节的动态维护策略,我在本单元的强测和互测中未出现 bug,下面列举一些我在互测中发现的性能问题:
在 HashMap 中 containsKey() 的时间复杂度为 O(1) ,而 containsValue() 的时间复杂度为O(n) ,如果使用查找 value 的方式可能会造成潜在时间复杂度的增加。
当没有对单个方法进行较好的动态维护时,方法嵌套会导致时间复杂度增加,并且不易被发现,如 qcs 嵌套 qba、getAgeVar 嵌套 getAgeMean。如 getAgeVar 和 getAgeMean,看似 JML 中每个方法的复杂度为 O(n) ,实际上当两者嵌套时会造成 O(n2) 的复杂度。
强测中对性能的一定要求使我认识到规格与实现分离的重要性。
规格定义了数据内容和方法规约,是代码实现的依据,也是分工明确的体现。在我看来,规格的编写者只是使用一种便于描述功能的方式来编写规格,方便实现者来理解规格,把数据容器、算法等的选择和权衡交给实现者,允许实现的多样性。而实现者要从规格的描述中明确所要实现的功能和约束条件,绝不是对照规格来机械的实现代码。
由于我们的代码是按照 JML 编写的,Junit 测试基于规格可以实现对代码覆盖率更高的测试。在规格的约束下可以确保测试的有效性和正确性,排除了无效测试以及不合法的数据。
在数据构造策略的基础上,我们需要严格针对每一条 requires、assignable、ensures 涉及到的数据进行检查,对于 signals 需要检查是否准确抛出异常。normal_behavior 还需要我们通过数据覆盖所有的条件分支。
对于一个 pure 方法,调用方法前后的状态应该一致,需要更加严格地检查所有相关数据。对于引用数据会涉及深浅克隆的问题,必要时我们需要深克隆原始状态进行比较。
本单元的主题是基于规格的层次化设计,学习目标为入门级 JML 规格理解与代码实现。
通过本单元的学习可以感受到,课程组对我们编写 JML 的能力要求不高,更多地是理解并实现 JML 所描述的功能。我认为重点不在于 JML 这门语言本身,而在于对规格、契约式设计的深刻理解,理解规格在数据、方法、类层次间的体现,以及基于规格进行测试的优势所在。