目录
queryCoupleSum()和queryBestAcquaintance(int id)
对于单元测试、功能测试、集成测试、压力测试、回归测试的理解。
前言
第三单元有关JML规格的学习告一段落,具体考察了对于JML规格语言的理解,并且要求能够按照规格描述进行程序的编写。当然,也要具备一定的JML书写能力。
本单元的具体任务因为是有规格描述的,架构和设计方面没有难度,所以难度不是很高。但是如果想在测试中拿到分数,还得在优化上下足功夫。
第九次作业
本次作业任务可以简单概括为
- 根据JML规格定义实现自己的Person和Network类
- 根据要求实现四个异常类继承相应的抽象异常类,完成相应信息的记录和print()
- 为 Network 类中的 queryTripleSum 方法编写 Junit 单元测试
主要考核的内容是JML规格语言的理解,根据规格描述编写程序的能力,以及根据JML设计test的方法。
UML
在架构上,我就是按照课程组建议的方式进行,新增了union类进行相关方法的辅助优化。
这次作业如果仅仅是实现则相对简单,所以主要介绍一下相关的优化设计。
优化设计
容器选择
实现JML要求的功能,实际上全部都使用ArrayList或者数组存储数据就能够做到。
但由于效率问题考量以及对于已知信息(每个person的ID是独一无二的)的充分利用,采用HashMap进行存储不仅能将查询效率从O(n)降低到O(1),并且能够充分利用已知信息,提高使用效率。
在MyNetwork中的persons存储数据结构如果使用HashSet存储则也比使用ArrayList效率高一些。
如在MyPerson类和Union类中的相关属性定义。
public class MyPerson implements Person {
private final HashMap<Integer, Person> acquaintance;
private final HashMap<Integer, Integer> value;
//...
}
public class Union {
private final HashMap<Integer, Integer> parent;
private final HashMap<Integer, ArrayList<Integer>> graph;
//...
}
BlockSum以及isCircle()
由于这两个定义是息息相关的,故放在一起说明。
首先我们根据其相关JML规格的定义,可以用通俗的语言模糊地概括一下,如果ID为id1的人和ID为id2的人都存在并且这两个人之间可以通过有限人数相连。而BLockSum则是相互之间不连通的块的总数。
第一遍完成的时候是无脑根据JML描述进行,实现了基本功能。后来得到高人提醒,才发现这样强测会爆,于是便进行了优化。
首先尝试了传统并查集。
并查集
并查集(Disjoint Set Union,简称DSU)是一种用于处理集合合并和查找问题的数据结构。它主要支持两种操作:合并(Union)和查找(Find)。
- 初始化: 在开始时,每个元素都是一个独立的集合,它的代表元素是它自己。
- 查找(Find): 查找一个元素所属的集合,通常是找到该集合的代表元素。这可以通过递归或迭代实现。查找的目的是找到元素所属的集合,并且在查找的过程中路径压缩,即将查找路径上的每个节点都直接连接到代表元素,以减小树的深度,提高后续操作的效率。
- 合并(Union): 将两个集合合并为一个集合。这可以通过将其中一个集合的代表元素指向另一个集合的代表元素来实现。在合并时,通常会考虑两个集合的大小,将小的集合合并到大的集合中,以保持树的平衡。
并查集通常用于解决一些与集合合并相关的问题,例如连通性问题、最小生成树算法中的Kruskal算法等。
但是由于并查集本身几乎无法处理removeRelation的操作(当然可以重建以达到目的),所以在不懈的尝试后,我缴械了。
后面由我舍友的启发,我换了一种类并查集的算法。
块集(草率的名字)
方法的主要思想与并查集类似,但是存储方式相对于传统的并查集更加具体清晰,但是当然相对传统的并查集会慢一点。为了契合面向对象的思想,新建了一个类Union以实现功能。
public class Union {
private final HashMap<Integer, Integer> parent;//id->属于的块号,里面只存了有在块中的
private final HashMap<Integer, ArrayList<Integer>> graph;//块号->里面的人的id
private int blockSum;//即BlockSum
private int blockCnt;//有效的块的数量
//...
public void addEdge(int id1, int id2) {
//当两个人之间addRelation时调用的方式
//如果两个id已经属于两个不同的块,则将两个块合二为一
//如果一个id属于一个块另一个不属于任何一个块(即孤立点),则将这个id加入这个块
//如果两个id均不属于任何块,则创建一个块并加入这两个id
}
public boolean sameBlock(int id1, int id2) {
//isCircle调用这个方法
//直接判断是否属于一个块即可(当然两id相同也按要求返回true)
}
public void removeEdge(MyPerson person1, MyPerson person2) {
//检查删了之后是否还是一个块中,即调用check
//如果不是则调用divide
}
public boolean check(MyPerson person1, MyPerson person2, ArrayList<Person> newBlock) {
//检查两个人如果removeRelation之后是否仍能相连
//若不能相连,则用newBlock返回一个包含person1的新块
//便利查询即可,这是难以避免的
//笔者采用的是dfs
}
public void divide(int graph, ArrayList<Person> newGraph) {
//blockSum++;blockCnt++;
//再创建一个包含person2的新块
}
}
ThripleSum
首先也是通过JML进行理解,定义可以简单理解为三个人之间以三个关系连成一个三角形。
这个数目依据前面的经验,必然也是不能查询的时候现搜的,所以也是使用动态维护的方法。但是其本身并没有blockSum那般复杂,只需要在进行relation操作的时候判断一下是否需要加减就好。
for (Person person : person1.getAcquaintance().values()) {
if (person.isLinked(person2)) {
//tripleSum++;或者tripleSum--;
}
}
Test
本次作业还要求了为Network
类中的queryTripleSum
方法编写 Junit 单元测试。
单元测试是对软件中最小可测试单元进行测试的过程。通常,单元测试针对程序的单个函数、方法或模块进行测试,以确保其行为符合预期。
而针对这个方法,我采用的是黑箱白箱结合的方法。
黑箱测试(Black Box Testing):
- 这种测试方法侧重于验证软件的功能,而不考虑其内部结构或工作原理。测试人员只关注输入和输出,以确认软件是否按预期工作,而不了解其内部逻辑。
白箱测试(White Box Testing):
- 这种测试方法涉及检查软件的内部结构、设计和代码。测试人员了解软件的内部逻辑,以验证其是否按照规范和预期运行。
通俗一点来说,就是要判断结果的正确的同时,还要根据JML描述判断里面的每一个ensures条件。
这次作业中就相对简单,因为是一个pure的函数,检查结果正确的同时只需要检查persons集有没有被改变就好了。
数据生成我使用的随机生成,生成的时候也许注意这几点:
- 避免数据量看起来大,但其实一堆都是无效数据的情况
- 避免群人数不够多的情况
- 避免图稠密但是图的规模小的情况
- 要稠密图和稀疏图的情况都存在
第十次作业
本次作业的任务经笔者概括为以下几点:
- 新建myTag类,并且根据JML规格描述完成其中的属性和方法。
- 根据JML规格描述完成MyPerson与MyNetwork中有关myTag中的操作。
- 新增四个异常类。
- 在MyNetwork中新增queryBestAcquaintance和queryCoupleSum操作并编写相关测试,同时新增求熟人的最短路径的方法。
本次作业在第九次作业的基础上除了继续考察JML外,还考察了相关程序编写时优化的取舍。
UML
本次画图时,就去掉了官方包中的具体内容避免图过于庞大。
加上了这次的内容和上次因为太过庞大而去掉的官方包的接口。
实现方案
myTag类相关新增内容以及四个异常类若是简单实现难度都不大,故实现方式在此不赘述。在实现方面就简单讲讲最小路径的寻取。
queryShortestPath
这里我就沿用了上一次的union数据结构进行处理。
因为本次作业要求相当于是在不带权重的图中寻求路径,所以BFS即广度优先搜索是一个不错的选择
广度优先搜索(Breadth-First Search)
一种图形搜索算法,用于在图形数据结构(如树或图)中找到特定节点(或状态)到另一个节点(或状态)的最短路径。BFS从根节点开始,在距离根节点的顺序下逐层扩展搜索,直到找到目标节点或搜索完整个图形。
工作方式如下:
- 选择起始节点,将其标记为已访问,并将其加入队列。
- 从队列中取出一个节点,并检查它是否是目标节点。如果是,则搜索结束;如果不是,则将其未访问的邻居节点加入队列,并标记为已访问。
- 重复步骤2,直到队列为空或找到目标节点。
BFS保证了在无权图中找到的路径是最短的,因为它首先探索与起始节点距离为1的所有节点,然后是距离为2的节点,依次类推。由于它的搜索过程是逐层进行的,所以它找到的路径长度是逐层增加的,因此找到的第一个目标节点的路径长度一定是最短的。
在BFS的基础上采用脏位记录已探求距离,可以在动态的情况下提高效率。
也看到有同学使用双向BFS以提高效率,但实际上也是取决于数据的特点才能够提高效率,并且双向的时候不是很好设置脏位,所以单向BFS应该就已经符合要求了,复杂度是O(N)。
优化方案
本次增加的内容相关的优化如下:
myTag
getAgeVar()以及getAgeMean()
这两个笔者采用了动态维护和缓存相结合。
因为总值很简单就实现了动态维护,平均值直接O(1)可以取出。
而对于方差,由于平均值一变就要重新维护,所以如果简单采用动态维护可能是比较低效的。而且,正常计算也只是O(n)也不是不能接受。笔者就简单加了个缓存,并配了一个脏位记录是否改变,防止重复查询带来的耗时。
getValueSum()
这个可以采用动态维护和缓存的方式进行优化。
对于动态维护的实现:
- 由于person属于tag和tag包含person实际上没有什么关联(至少通过JML规格描述并没有体现关联),所以在person中要新增一个数据结构存储此person属于的tag。
- 在每次涉及到value变化的时候都要对于每个tag进行遍历更新即可
对于缓存的实现即查询的时候再进行重新计算。
笔者对于这两种方式都进行了尝试,但是仔细思考之后并且观察了第一次强测的指令并不存在超多查询来爆破程序的,觉得并没有太大的必要进行这样的优化。而且指令主要是增调关系,所以因为这个动态维护也要进行遍历,可能优化后会慢不少。于是笔者就采用最正常的计算模式并加了个缓存(这个缓存的是否改变的判断可能在MyPerson加一个如下的容器会方便一点),强测亲测也是够用的。
public class MyPerson implements Person {
private final HashMap<Integer, Tag> tagsForNotify;
//...
public void addForNotify(Tag tag) {
tagsForNotify.put(tag.getId(),tag);
}
public void deleteForNotify(Tag tag) {
tagsForNotify.remove(tag.getId());
}
public void setChange() {
for (Tag tag : tagsForNotify.values()) {
MyTag tag1 = (MyTag) tag;
tag1.setChangedVS();
}
}
//...
}
把这三个方法加对地方就没问题。
queryCoupleSum()和queryBestAcquaintance(int id)
这个对于本次作业应该是一个主要的优化内容,因为有方式可以实现完全正优化。
笔者在MyPerson中换了一下value的存储数据机构:
private final TreeMap<Integer, TreeSet<Integer>> valueIdTree;
这样的话,MyPerson中取的时候如下即可:
public int queryBestId() {
if (isSolitary()) {
return this.id;
} else {
return valueIdTree.lastEntry().getValue().first();
}
}
MyNetwork中的便利也就降到了O(N),天真的我以为可以了。
但经过强测的检验证明还是不行(非要卷优化是吧)。
最后debug阶段增设了双缓存(即这两个方法都进行缓存)还是不行。好好好,这么玩是吧。
没想到当初偷的懒还是要做,最后还是通过动态维护解决的。
对于coupleSum的动态维护也有个偷懒的建议,因为如果要考虑改变关系前后的影响,有些过于复杂了,这也是我当时偷懒的借口。但是如果你进行局部的遍历找总数,关系图变化前记录一次,变化后记录一次,差值也就是总coupleSum的变化。当然偷懒也是有要注意的地方的,要好好考虑涉及到的人和关系以及其中有没有可能抵消,还有就是孤立点会指向自己,还有就是局部遍历的时候也要注意重复问题。
Test
这次测试我还是采用了黑箱测试,遇到并解决的问题如下:
- 要设计两组测试,我是设计了稠密图和稀疏图各30组,只有稠密图的话检验不出所有问题。
- 要控制数据量,由于本次测试方法如果实现不当会很慢,所以要控制人数和关系数量。
其实亲测黑箱测试数据没有必要开很大就能有效。
当然本地也可试试给自己开极限数据压力测试,也可以给别的方法写点测试,真能出问题哦。
第十一次作业
本次作业新增迭代内容可概括为如下:
- 完成MyMessage、MyEmojiMessage、MyNoticeMessage、MyRedEnvelopeMessage这几个新的有关message的类。
- 新增四个异常类。
- 新增tag, person, network中有关message的属性和方法。
本次作业作业似乎没有什么明显的难点(主要是没有需要复杂的算法优化了,而是将难点转向了JML方面,比如我们见到了sendMessage这种让人眼前一黑的巨长JML)。
UML
本次去掉了接口和官方包内容还有异常,能更清晰的展示结构
问题实现
本次作业中,基本没有任何实现难度,都只需要按照JML规格的描述添加即可,能做的可能就是选择一个合适的存储容器就好了。
JML规格在本次作业中尤其的长,所以简单概述一下如何高效看JML。
- 属性相关的描述,建议还是结合相关方法选择最为合适的容器或者数组进行存储,而不是JML说的是啥就用啥存。
- 对于是否可以为null还有其他的一些全局约束要留个心眼,以免在方法实现时图省事不小心违反。
- 对于方法的编写,首先实现异常部分,这个好实现。然后看一下requires有没有除了异常意外的其他要求(一般没有)。接着对于ensures部分要一个个一个看,对于每一条可以自己总结一下这一条用自然语言该怎么理解,然后逐步实现。有时候一条ensures五六行,这种就要层层看,从外层向里剖析。
- 还有就是可以根据命名进行合理猜测,亲测高效。(但是不要只猜不仔细看JML,会寄)
Test
本次测试的方法是deleteColdEmoji,有关message的一个方法。
注意以下几点:
- 无关紧要的部分可以简化,如只用两个人互相发消息即可,毕竟目的只是为了改变popularity。
- 因为有关emojiMessage,所以一开始要storeMessage,建议存连续id,send的时候也方便。
- 准备message的时候要把所有的message都加一遍,确保类型的齐全。
- 发消息的时候,随机数尽量要大,因为有概率发送失败,我取的基数是一半。
- 在验证的时候,要对着JML描述一行一行的比对,虽然你会觉得有些ensures完全是无聊,但是还是要检验,因为课程组提供的一些错误代码就是专门干这些事的。
总结
测试
接下来总结一下本单元的测试过程。
对于黑箱测试和白箱测试的理解
我们在本单元的测试中都是采用黑箱白箱测试结合的方式进行的。
黑箱测试(Black Box Testing):
- 这种测试方法侧重于验证软件的功能,而不考虑其内部结构或工作原理。测试人员只关注输入和输出,以确认软件是否按预期工作,而不了解其内部逻辑。
白箱测试(White Box Testing):
- 这种测试方法涉及检查软件的内部结构、设计和代码。测试人员了解软件的内部逻辑,以验证其是否按照规范和预期运行。
在作业中的实现,通俗一点来说,就是要判断结果的正确的同时,还要根据JML描述判断里面的每一个ensures条件是否满足,同时如果是pure方法则要保证不会改变任何变量。
对于单元测试、功能测试、集成测试、压力测试、回归测试的理解。
单元测试(Unit Testing):
- 单元测试是对软件中最小可测试单元进行测试的过程。通常,单元测试针对程序的单个函数、方法或模块进行测试,以确保其行为符合预期。
- 我们本次作业中课程组要求的完成对于某一个方法的测试就是属于单元测试。
功能测试(Functional Testing):
- 功能测试是验证软件的功能是否按照规范要求的测试过程。测试人员根据软件的需求规格文档执行测试,确保每个功能都能正常工作。
- 对于最终答案的判断就属于功能测试。
集成测试(Integration Testing):
- 集成测试是在将各个模块或组件整合为一个完整的系统后进行的测试过程。其目的是验证这些组件在集成后是否能够协同工作,并且系统功能是否正常。
- 这个在本次作业中虽然没有涉及,但是课程组也推荐自己尝试。这种情况下可能要考虑一下覆盖率的问题,让测试更为可靠。
压力测试(Stress Testing):
- 压力测试是评估系统在负载超出正常范围时的性能表现的测试过程。通过模拟高负载条件,测试人员可以确定系统在压力下的稳定性和性能表现。
- 这次作业中我在本地经常进行压力测试,在数据量爆炸的情况下看看自己的代码是否能经受得住。
回归测试(Regression Testing):
- 回归测试是在对软件进行更改或修复后执行的测试过程,旨在确保新的更改不会破坏现有功能。测试人员重新运行先前的测试用例,以确认新的更改没有引入新的错误或破坏现有功能。
- 我们的BUG修复工作实际上就是回归测试,核心就是在debug时确保不引入新的bug。
测试工具
我主要使用的是两个测试工具。
第一个是自己本地编写的junit测试。这个可以自己进行数据构造,方便针对性的找出问题。也可以本地压力测试,模拟强测环境等都很方便。
第二个是使用了同学编写的评测机。评测机则更像是个集成测试,确实能够帮忙发现很多bug,很是方便。但是性能问题以及针对性数据构造可能还是自己编写更为合适。
数据构造的策略
策略还是很重要的,要充分的考虑数据的各种可能性。而且也不能一味的用庞大数据,这样也会忽视了小数据的问题。总结为以下几点。
- 避免数据量看起来大,但其实一堆都是无效数据的情况
- 避免群人数不够多的情况
- 避免图稠密但是图的规模小的情况
- 要稠密图和稀疏图的情况都存在
架构设计
本单元的架构设计较为简单,实现了一个简易的社交网络。而我除了课程组要求的类就多实现了一个union类便于我进行图的构建和维护。
我的架构设计和对于图模型构建和维护策略我都详细的在每次作业中进行叙述了,这里不再赘述。
规格与实现分离
由于性能问题及其修复情况我在每次作业中都进行了详细描述,这里指谈谈我对于规格与实现分离的理解。
本单元的任务都是根据课程组的规格描述来完成,但是在实现的过程中会发现如果傻乎乎地用规格描述里的数据结构或者是实现方式,性能会特别低,无法满足要求。所以在实现的过程中要保证规格描述的同时还要进行自己的最优实现方式,才能达到性能和规格都实现。
所以可以理解为实现的过程与规格分离,但是实现的结果要满足规格描述。
Junit测试
检验代码实现和规格一致性
用王旭老师上课的话讲,JML规格描述“天然地契合”junit测试。Junit测试能够很好地检验代码实现和规格一致性。具体来讲就是,JML规格描述中的话我们可以一条一条的在Junit中进行检验,每一个ensures转化为Junit中的一个检验,就能够很好的保证代码和规格的一致性。
改进建议
我认为这个方式很能够加深我们对于规格的理解和运用。
所以我建议将该部分的比重加大,比如可以部分加入强测,或者加入覆盖率的要求等等。
学习体会
首先对于本单元的核心内容JML。
我并没有觉得JML是个很无用的东西,或者说是一个复杂无趣的设定。我认为JML是大有裨益的。JML规格描述规定了某些方法和类的标准格式,并且列出了前置条件、后置条件、副作用等限制,帮助我们条理更加清晰地分析某个方法或类需要实现的目的,以及相关操作的约束,逻辑性很强,正确性拉满。
当然有一些被诟病的问题,如ensures阅读难度大且有很多显然的东西。首先针对于阅读难度大这个问题,我觉得课程组已经做了一些优化,就例如可以在描述里面使用方法来表述以省去大量的重复部分。至于显然的部分,虽然在我们的任务中确实显然,因为正常人的实现不会有这些东西,但是在复杂工程中,这些发生的概率一点都不低。
总而言之,尽管JML抽象难懂,但是总之是难度不达。至于必要性,解释应如老师所言,“6系培养的是全方面的人才”。