2024春OO课程三单元总结

姜涵章-21375212 学生 2024-05-16 21:03:37

@

2024春OO课程三单元总结

  • 测试过程
  • 不同的测试方法
  • 黑箱测试
  • 白箱测试
  • 单元测试 -> 集成测试
  • 功能测试
  • 压力测试
  • 回归测试
  • 数据构造策略
  • 架构设计
  • 基础并查集1.0
  • 带“脏位”的并查集2.0
  • 精确“脏位”并查集3.0
  • 维护策略
  • 性能问题
  • 对规格与实现分离的理解
  • Junit测试
  • 心得体会
  • 感谢

测试过程

不同的测试方法

黑箱测试

在测试中,把程序看作一个不能打开的黑盒子,在完全不考虑程序内部结构和内部特性的情况下,在程序接口进行测试,它只检查程序功能是否按照需求规格说明书的规定正常使用,程序是否能适当地接收输入数据而产生正确的输出信息。例如各种评测机,便是一种黑盒测试,即只给出输入和期待输出,并不考虑程序的内部结构。

白箱测试

白盒测试是一种测试用例设计方法,盒子指的是被测试的软件,白盒指的是盒子是可视的,即清楚盒子内部的东西以及里面是如何运作的。“白盒”法全面了解程序内部逻辑结构、对所有逻辑路径进行测试

白盒测试大体可以分为5个程度:

语句覆盖 -> 判定覆盖 -> 条件覆盖 -> 多条件覆盖 -> 修正条件判定覆盖

其发现错误的能力由弱到强,需要根据实际需要选择具体的测试方法,例如在一些国家军工产业,均采用的是覆盖能力最强的修正条件判定覆盖,此种方法测试代码繁琐,测试时间长,但发现错误能力很强。

单元测试 -> 集成测试

单元测试是针对软件的最小功能模块进行测试的方法。在这种测试中,开发人员会编写针对每个函数或方法的测试用例,以验证其在各种输入条件下的正确性。单元测试的目的是尽早发现和解决代码中的错误和缺陷,以确保软件的各个功能模块能够独立地正常工作。例如在oopre中为每个函数编写的junit测试,即为单元测试

集成测试是在单元测试之后,将各个功能模块组装在一起进行测试的方法。在集成测试中,测试人员会验证各个模块之间的接口和交互是否正常,并且整个系统能够正常协作。集成测试的目的是确保各个模块之间的集成能够正常工作,不会产生冲突和错误。

功能测试

功能测试是对整个软件系统的功能进行测试的方法。在功能测试中,测试人员会根据需求规格说明书编写测试用例,并通过输入不同的数据或操作软件的不同功能来验证软件是否满足设计要求。功能测试的目的是确保软件能够按照用户需求的要求正常运行,并能够正确地处理各种情况。我的理解就是对软件根据不同功能分类,分别用黑盒测试的方法测试各个功能。比如HW11强测的测试点2024_unit3_hw11_strong_1

压力测试

压力测试是对软件系统在高负载和大并发情况下进行测试的方法。在压力测试中,测试人员会模拟大量用户同时访问系统,并且增加系统负载,以评估系统的性能和稳定性。压力测试的目的是找出系统的瓶颈和性能问题,并且确定系统在高负载情况下的性能指标。比如HW11强测点的2024_unit3_hw11_strong_9和2024_unit3_hw11_strong_10

回归测试

回归测试是在软件系统进行修改或升级后,重新运行之前的测试用例以验证修改是否引入新的错误或导致原有功能出现问题的方法。回归测试的目的是确保在对软件进行修改或升级后,原有的功能和性能没有受到影响。回归测试一般会在每次修改或升级后进行,以确保软件的稳定性和质量。对于我们的作业而言,每次在作业迭代之后,我都会跑一下上一次作业的评测机,保证没有影响之前的功能

数据构造策略

我按照两种方式构造测试数据,分别进行不同的测试。

  1. 功能测试

此种构造方法,追求的是尽量覆盖所有指令,尽量覆盖代码的各个分支,去测试代码的基本功能是否实现有误,在数据生成时,我会采用随机策略生成指令。

  1. 压力测试

此种构造方法,追求的是尽量让关系图更复杂,让查询更困难,让运行时间尽量长,去测试代码的方法实现的复杂度是否过高,确保代码在高压下还能在规定时间内运行出结果。

一些问题及解决方法

  • 在ap,ar,att的时候,如果纯随机,会导致很少能真正的建边和建tag,而是报出异常

数据生成器的代码中可以同时也维护一个保存已生成的人之间的二维关系数组和tag数组,在生成ar和att类的指令时,根据保存的关系,去生成一定不报异常的指令

  • 查询操作同理,出现大量异常或大量查询结果为0,即“无效查询”

这种查询由于报出了异常或者去查询一个空图或空tag,很难真的测试出功能的正确性,所以在生成此类指令时,我也会根据保存的关系,尽量去查询一些tagSize大的,或是结点和关系比较多的子图

架构设计

我的架构优化过程如下

基础并查集1.0

其实现的功能如下:

  1. 增加关系(ar)时,merge两个并查集
  2. 删除关系(mr)时,通过dfs或bfs遍历,判断该并查集是否需要分裂为两个并查集

方法复杂度和优势分析:

  1. 并查集在进行增加和删除关系时,很方便的就维护了block_sum,qbs查询的复杂度为1
  2. qci时,只需判断两个人是否为同一祖先,复杂度也非常低(两人都进行过路径压缩则为O(1),否则为O(n))
  3. 在加快查找速度的前提下,略微牺牲了插入和删除操作(ap,mr)的复杂度

问题分析:

操作指令:ar,mr

查询指令:qbs,qci

**person.getAncestor()**:函数:返回person的祖先

这种方法对于每一次操作指令,都去维护并查集,保证了任意时刻block_sum的值和person.getAncestor()的都正确。但这种维护并不是必要的,只有当我们遇到查询指令的时候,我们才需要获得上述两个值。

举个例子,假如指令序列如下

ap 1 1 1
ap 2 2 2
ap 3 3 3
ar 1 2 1
ar 2 3 1
ar 1 3 1
mr 2 3 -2
qbs

我们可以发现,第4-6条指令都是操作指令,我们维护了三次并查集,但其实只有到第8条指令时,我们才需要获得一个正确的并查集,完全可以延迟维护,由此,便引出了带“脏位”的并查集2.0

带“脏位”的并查集2.0

这个算法很好理解, 在Mynetwork类中增加属性:boolean dirty

  • 遇到操作指令时,将dirty置true,只加(删)边,不维护并查集和block_sum
  • 遇到查询指令时,若dirty == true,则重建并查集,否则与并查集1.0做法一样

方法优势分析:

  • 操作指令查询指令==连续到来==的时候整体速度会较快

缺点分析:

  • 操作指令查询指令==交替到来==的时候并无优势
  • 并且,由于查询操作在遇到dirty==true的时候会无脑重建并查集,而重建并查集的复杂度高于维护并查集,会导致相当一部分指令序列甚至不如并查集1.0的速度。

所以,此算法并不推荐,因为我们不能臆想指令都是按照我们期望的顺序来的,但并不代表“脏位”的思想不可行,由此便引出了本篇分享的重点精确“脏位”并查集3.0

精确“脏位”并查集3.0

通过分析,并查集2.0的最大的问题是重建并查集的复杂度过高,且完全没有必要。其根源是因为我们每次遇到操作指令就无脑置脏位,且该脏位为全局脏位(指的是对整个network都有效,就好像整个网都被“污染”了)

举个例子,假设network有两个block,其并查集如下图,其中@代表结点名为a;**->代表儿子节点指向父亲节点;---代表两个结点互为acquaintance**(---并没有画全)

在这张图的基础上(假设现在脏位为 false,整个网都是”干净“的,即person.getAncestor()和block_sum均有效),其后的指令序列如下

ar 2 3 1
mr 1 2 -200
qci 4 5
qbs
qbs
mr 2 7 -200
qci 2 6

前两条为操作指令,第一条指令连接了结点2和结点3,第二条指令切断了结点1和结点2。从第一条指令结束后,按照并查集2.0的算法,应当置脏位,代表整个网均被污染。第三条查询指令来临时,由于网被污染,所以不得不重建并查集。

但是,我们分析可以发现,对于第一条指令而言,由于结点2和3的祖先相同,那么对于这两个结点的加边操作不会污染这张网,即block_sum不需修改,对于第二条指令而言,删除了结点1和结点2的关系,仅对所有祖先为结点为ancestor1的结点有影响,换句话说,只有一个祖先和其子孙所构成的子图被污染了,我们可以为network新增一个集合容器,名为dirtyAncestor,把此时被污染的祖先加入集合。

对于第三条查询操作,由于结点4和5的祖先为ancestor2,不在dirtyAncestor中,所以并不需要重建并查集,直接和并查集1.0一样,比较两个结点的ancestor是否相同即可

第四条qbs,由于询问的是block_sum的值,所以不得不重建并查集,但同样的,我们只需要重建祖先为ancestor1的结点即可,并不需要遍历所有的点。此时,所有的网又恢复到干净的状态,清空dirtyAncestor

那么第五条的qbs,由于网是干净的,直接返回block_sum的值即可

第六条, 删除结点2和7的联系,污染了祖先为ancestor1的子图,将ancestor1结点加入dirtyAncestor集合中

第七条,询问2和6结点是否有联系,此时我们发现,2的祖先为ancestor1,位于dirtyAncestor中,代表2处于污染状态(即此时2的祖先有可能不是ancestor1),而6的祖先为ancestor2,处于干净状态此时我们可以直接认为,2和6一定没有关系!不需要重建并查集!(此处可以思考一下原因)

总结而言,对于qbs操作,需要重建所有祖先结点处于dirtyAncestor中的结点,对于qci操作:

  1. 如果二者的祖先都是干净的,直接比较祖先是否相同
  2. 如果一个人的祖先干净,另一个不干净,则一定返回false
  3. 如果二者祖先都不干净,和qbs一样,部分重建并查集

算法分析:

  • 最后这种算法通过分析可以发现,不论是任何一种指令序列,其整体遍历的点数都小于等于并查集1.0,并且在操作指令和查询指令连续出现时表现尤为出色(例如最开始ln一个100个结点的完全图时,操作复杂度均为O(1))整体速度也优于前两种。

缺点:

  • 实现起来较为复杂,强测对于性能要求并没有那么高,并不一定要追求最快的算法。

维护策略

查询复杂度为O(1)的指令:qv,qsv,qrm,qp,qm,qci,qbs

对于这类指令,由于原本的查询复杂度就是O(1),所以不做任何维护,直接查询,值得一说的是,qci和qbs原本的复杂度为O(n),由于使用了并查集,所以复杂度降为O(1)

查询复杂度为O(n)的指令:qtav,qba,qsp

  • qtav, 我维护了tag中的person的年龄和,年龄平方和,将查询复杂度变为O(1),同时保持维护的复杂度也为O(1)
  • qba,我在person类中维护了一个容器,如下
private final TreeMap<Integer, TreeSet<Integer>> ids

Treemap的key为该person的熟人的value值,Treemap的value值为这个熟人里的value值等于刚刚提到的key的所有熟人id,由于treeMap和treeset的有序性,用如下方式取得的一定是该person的bestAcquaintance

int bestAcquaintanceId = ids.lastEntry().getValue().first();

其复杂度为O(logN)

  • qsp,直接采用bfs,复杂度为O(n)

查询复杂度为O(n2)的指令:qtvs,qcs

  • qtvs,直接维护,在addPerson, delPerson,addAcquaintance,delAcquaintance时维护,方式如下
public void addPerson(Person person) {
        //...
        for (MyPerson myPerson1 : persons.values()) {
            valueSum += 2 * myPerson.queryValue(myPerson1);
        }
}
public void delPerson(Person person) {
    //...
    for (MyPerson myPerson1 : persons.values()) {
        valueSum -= 2 * myPerson.queryValue(myPerson1);
    }
}
public void addAcquaintance(MyPerson myPerson, int value) {
    //...
    for (Pair<Integer, MyTag> fatherTag : fatherTags) {
        if (myPerson.containsFatherTag(fatherTag.getKey(), fatherTag.getValue())) {
            fatherTag.getValue().addValueSum(value);
        }
    }
}
public void delAcquaintance(MyPerson myPerson) {
    //...
    for (Pair<Integer, MyTag> fatherTag : fatherTags) {
        if (myPerson.containsFatherTag(fatherTag.getKey(), fatherTag.getValue())) {
            fatherTag.getValue().addValueSum(-value);
        }
    }
}
  • qcs,同样采用维护的方式,在addRelation和modifyRelation的前后,根据此次操作涉及的两个点相关的coupleSum的变化更新qcs即可

查询复杂度为O(n3)的指令:qts

qts的维护复杂度为O(n) 远低于查询复杂度,果断维护。方法很简单,addRelation和modifyRelation时查找两个person的共同熟人数量,让tripleSum的值加(减)这个数量即可

性能问题

  1. 频繁的插入person,删除关系,增加关系时,由于要维护很多量,保证并查集有效,反而可能降低性能,所以我想出了上面提到的“精准脏位并查集”,确实得到了不错的优化

对规格与实现分离的理解

我认为将这二者分离的最大优势便在于,当我的代码出现bug,或者出现性能问题时,我只需要考虑如何修改我的实现,而规格是绝对不变的,很利于重构。

再者,规格相对于自然语言,能更准确的描述每个功能的细节,有效避免了自然语言所带来的歧义。

最后,规格的语法和java的语法也很类似,为我们在编写代码的时候也提供了参考,加快了我们的编码速度

Junit测试

如何利用规格信息来更好的设计实现Junit测试,以及Junit测试检验代码实现与规格的一致性的效果

规格包含三个部分即前置条件,副作用范围,后置条件

构造测试用例的时候,样例要尽量覆盖所有满足前置条件的用例

检查正确性的时候,既要检查后置条件,也要检查副作用有没有超出影响范围

只要保证了以上两点,就能构造出较为完备的测试数据和检测程序

心得体会

使用规格来写代码具有多重优势。首先,规格提供了清晰的指导,使得代码的预期行为更易于理解和阅读,从而减少了歧义,提高了准确性和一致性。其次,规格作为代码的文档,有助于提高代码的可维护性,降低了后续维护成本,并减少了引入新bug的风险。此外,规格可以用作测试用例的基础,帮助开发人员编写更有效的测试,从而提高了代码的可靠性和稳定性。最后,规格促进了团队之间的协作和沟通,使得团队成员能够更好地理解彼此的工作并共同实现目标。综上所述,使用规格来写代码是提高代码质量、可维护性和可靠性的重要方法之一。

感谢

感谢同学提供的灵感和帮助,感谢助教们悉心维护课程网站,随时回答同学的问题,和认真讲解的老师

...全文
46 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

301

社区成员

发帖
与我相关
我的任务
社区描述
2023年北航面向对象设计与构造
学习 高校
社区管理员
  • YannaZhang
  • CajZella
  • C_ecelia
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧