BUAA_OO_2021_第三单元总结

BUAA_OO_2021_第三单元总结

第三次作业的目标是本次作业最终需要实现一个社交关系模拟系统。可以通过各类输入指令来进行数据的增删查改等交互。不过其本质就是对JML的阅读理解+将指令时间复杂度压缩至\(o(n^2)\)的算法练习。

关于JML

JML (Java Modeling Language) 是用于对 Java 程序进行规格化设计的一种表示语言,是一种行为接口规格语言。

通过 JML 及其支持工具,不仅可以基于规格自动构造测试用例,并整合了 SMT Solver 等工具以静态方式来检查代码实现对规格的满足情况。

通过观察学长的博客,发现这玩意并不靠谱,OpenJML的文档里一半都是TODO,且配置起来很麻烦,且测试效果不佳,于是便没有去尝试配置相关工具链。

虽然很遗憾没能用上全自动测试工具,不过JML还是十分有意义的,设想假如没有JML,课程组如何向我们准确无误传达所有函数的设计规格且保证理解没有歧义?基本不可能。

测试方案

使用JUnit可以开展针对模块的单元测试,然而这些测试并不能由JML自动生成,而是要根据对规格的理解自己写,这就存在着两个问题:1. 如果对规格理解有误,在编写测试代码同样会出现错误。2. 在时间效率上并不能比得过传统的测试方案,构造数据生成器然后和其他同学对拍。考虑到这两个因素,我没有理由不选择传统的方式而放弃JUnit。

数据生成

在数据生成方面,我依然采用了完全随机方法,尝试构造可以覆盖所有代码的测试集。

第一单元时,为了克服大数定律带来的数据同质化,我对person数量规模进行了预先的极端化规约。具体方法如下:

首先随机生成一个PersonId范围p,p取[1, 100]的*极端分布,之后每一条ap指令都只能从[0, p)内随机取值,其他涉及到person id的指令将会从[0, p+1)内随机取值以保证能够触发PersonIdNotFoundException。

在第三单元数据生成时,为了对大家的Dijkstra算法进行较为全面的检验,我预先在数据的开头生成一个随机图,然后再进行其他指令的随机生成。该随机图有两个重要参数我进行了预先极端化规约:节点个数和平均度,节点个数依旧取[1, 100]的极端分布,平均度取[0.0, 5.0]的均匀分布,保证了可以测得Dijkstra在各种类型图中正常运行。

之后就是进行多次随机的信息发送指令,为保证发送成功率和发送异常率,我设置所有MessageId仅能在[1, 10]中取值,相当于是十个只能装下一个球的桶,之后的随机操作中会随机向桶里放球和从桶里取球,保证可以触发所有正常逻辑和异常逻辑。

不过我的数据最终还是被反向Hack了。究其原因,是我认为PersonId仅仅是标识,不参与运算,故认为int范围内所有值都是等价类,只使用了连续的正数作为PersonId。这为我带来了重大启示:等价类不是可以想当然进行简化的,作为测试人员应该考虑的更加全面,不要忽略“点炒饭的客人”

极端分布这个概念在我第二单元的时候已经应用,原理是将均匀分布映射用Logistic函数映射为两端概率密度高,中心概率密度低的极端分布,这样可以使得数据质量更高。

# 极端分布生成代码
def random_edge(m, M, r=5):
  def logistic(x):
    return 1 / (1 + (math.exp(-r * (x))))

  x = random.random() * 2 - 1
  M = M + 1
  k = (M - m) / (logistic(1) - logistic(-1))
  return math.floor(k * (logistic(x) - logistic(-1)) + m)

Logistic函数

极端分布

关于算法

声明:下文中提到某条指令复杂度为\(O(f(n))\),均是指第\(n+1\)条指令的复杂度,即这条指令前有\(n\)条其他指令。且均指最坏时间复杂度。

经过三个单元的实践,可以得出如下结论:对于10000条指令,单条指令\(O(nlog^\alpha n)\)的程序如果代码优化不佳,常数部分过大则会导致超时。对于5000条指令,\(O(n^2)\)是明确的分界线,只要低于它的就一定不会超时,只要不低于它的就一定会超时。所以我们的目标就是消灭\(O(n^2)\),优化\(O(nlog^\alpha n)\)

第一次作业

第一次作业可能会出问题的点有两个:

一个是query_circle指令(即查询一个无向图两端点是否联通),最标准的做法是使用DFS即可以达到\(O(v + e) = O(n)\)的复杂度,已经不会超时。不过我们可以使用更加优美的算法【并查集】来改进,根据网络上的资料,实现了路径压缩和按秩合并的并查集可以做到\(O(1 + log^{*}n)\)的均摊时间复杂度,几乎等同于\(O(1)\)

另一个是query_block_sum指令(查询联通分量的个数)如果用了并查集的话,维护一个blockSum变量,每次查询可以直接给出,如果没用的话应该也能写出\(O(n)\)复杂度的实时查询函数。

这个专栏对并查集已经有完整的讲解 :https://zhuanlan.zhihu.com/p/93647900/ 。不过在实现的过程中还存在一些小问题。

无优化并查集的最坏时间复杂度为O(n)

如果合并时固定让第一个对象的祖先连接到第二个对象的祖先,那么就可以用特殊的数据构造出一条链,例如输入

ar 1 2
ar 2 3
ar 3 4
...
ar n-1 n

就会产生这样的链:

1 -> 2 -> 3 -> 4 ... -> n

然后再查询1与2是否联通时,就要完整遍历这个链,时间复杂度是\(O(n)\)

仅使用路径压缩优化的并查集爆栈风险

还是对于这样一条链,

1 -> 2 -> 3 -> 4 ... -> n

此时如果对1进行路径压缩,那么就会生成一个深度为n的函数调用栈,存在爆栈的可能性。解决方案可以用循环代替递归,将路径中所有点放在一个List里,在找到祖先节点后从List中取出所有节点连接至祖先节点。我的解决方案是增加了一个按树的大小来合并,小树的祖先连接到大树的祖先,这样保证树的深度不会超过logn因此不会爆栈。

并查集使用HashMap来保存每个节点的父节点会造成额外开销

通常情况下HashMap的查询复杂度是\(O(1)\),不过在特殊构造的哈希碰撞的数据下,所有索引都被挤进同一个哈希桶,在Java1.8下发生哈希碰撞将会退化为红黑树,时间复杂度变为\(O(logn)\),我觉得正确的使用方式应该是将父节点的引用保存在节点的字段中,形成真正的链表,开销会稍微小一点。另外,如果是Java1.7,哈希碰撞将会退化成链表,时间复杂度变成\(O(n)\),这样的话,我肯定会为了安全考虑在本单元全部使用TreeMap 来获得稳定的\(O(logn)\)复杂度。

Bug分析

本次作业没有在对拍、中测、强测、互测中出现Bug。

在互测中,我发现了两个人共三个Bug,一个人因为qbs使用了双重循环达到了\(O(n^2)\)复杂度超时了。另一个人对算法时间管控不佳,加人时就达到了\(O(n^2)\)复杂度。另外,他对TreeSet会调用compareTo的原理不是很熟悉,错误的使用了TreeSet出现了正确性Bug。

第二次作业

第二次作业唯一要注意的是query_group_value_sum(查询一个图的所有边权和),这是一个二重遍历逻辑,如果直接照着JML翻译的话将会出现\(O(n^2)\)复杂度导致超时,解决办法是维护一个valueSum变量,并在每次加人减人和加关系时进行\(O(n)\)复杂度的更新。

另外,经过数学推导,query_group_age_mean和query_group_age_var两个指令也是可以做到\(O(1)\)维护\(O(1)\)查询的(具体方法是维护两个变量:年龄的和,年龄的平方和),写的时候必须注意整数截余除法不具有分配率,否则会因为精度问题WA掉。

储存在Person中的messages应该使用ArrayDeque或者LinkedList这两个在头部插入和查询均为\(O(1)\)的容器,或者用ArrayList逆着顺序存储。

Bug分析

本次作业没有在中测、强测、互测中出现Bug。第一版写成的\(O(n^2)\)复杂度的query_group_value_sum没有正确性问题,但是在优化时间的时候忘记考虑了加关系也会导致组内valueSum发生变化,在对拍阶段被及时发现。

在互测中,我发现了三个人共四个Bug,且均是超时所致。两个人是二重遍历query_group_value_sum,他们中的一个使用了缓存机制保存上次的valueSum,但是这只是对特殊数据有用,对减少时间复杂度没有作用。还有一个人写出了两个比较隐蔽的\(O(n^2)\),在阅读代码时被我发现,其中一个是用了HashMap.containsValue()方法,这是一个\(O(n)\)方法,再套一层复杂度就会超标。

第三次作业

第三次作业大家都已经有了经验,对于可能出现超时问题的send_indirect_message(查询无向图两点间最短路径),清一色地使用了堆优化Dijkstra算法,复杂度\(O(nlogn)\),其他指令即便代码复杂度再高也仅能达到\(O(nlog^2n)\)。对于6s的CPU时间上限和5000条指令上线,并没有能够造出超时。

Bug分析

本次作业没有在对拍、中测、强测、互测中出现Bug。

在互测中,我发现了两个人两个Bug,一个是正确性Bug,另一个是Dijkstra算法卡了死循环。而其他同学的程序,即便有些同学时间复杂度的常数部分很大,但是最终构造数据也仅跑到了CPU时间4.7s(显然10000条就会超时了)。这次作业能进A房的基本上都是没有问题的程序,我能找到两个Bug实属幸运。

这次互测读代码时特别留意了是否有人使用了HashMap.containsValue(),结果发现有3个人使用,不过这些containsValue的代码都不会被嵌套调用,所以并没有复杂度超标。这里要提醒同学们不要使用containsValue,查询HashMap一定要使用containsKey。

心得体会

本次作业需要的就是细心,对复杂冗长的JML规格不能略读和揣度,必须严谨地弄懂其描述的逻辑,按部就班地完成,不然就会“因为一个一个小数点的失误机毁人亡”。

至此,全部的互测任务已经结束了,在过去的三个月里,一共帮助了21名进入A房的同学找到了共24个Bug,除了一个多线程的Bug被发现后传点没有成功Hack掉之外,所有房间内的Bug均成功找到并Hack掉。之所以如此认真去找Bug,其实是因为受到了下面这则笑话的激励,让我觉得做一名测试工程师是极有趣的事情。通过构造极其***钻的数据让基本没问题的程序出现Bug,确乎是一件极有成就感的事。

一个测试工程师走进一家酒吧,要了一杯啤酒;
一个测试工程师走进一家酒吧,要了一杯咖啡;
一个测试工程师走进一家酒吧,要了0.7杯啤酒;
一个测试工程师走进一家酒吧,要了-1杯啤酒;
一个测试工程师走进一家酒吧,要了2^32杯啤酒;
一个测试工程师走进一家酒吧,要了一杯洗脚水;
一个测试工程师走进一家酒吧,要了一杯蜥蜴;
一个测试工程师走进一家酒吧,要了一份asdfQwer@24dg!&*(@;
一个测试工程师走进一家酒吧,什么也没要;
一个测试工程师走进一家酒吧,又走出去又从窗户进来又从后门出去从下水道钻进来;
一个测试工程师走进一家酒吧,又走出去又进来又出去又进来又出去,最后在外面把老板打了一顿;
一个测试工程师走进一家酒吧,要了一杯烫烫烫的锟斤拷;
一个测试工程师走进一家酒吧,要了NaN杯Null;
一个测试工程师冲进一家酒吧,要了500T啤酒咖啡洗脚水野猫狼牙棒奶茶;
一个测试工程师把酒吧拆了;
一个测试工程师化装成老板走进一家酒吧,要了500杯啤酒并且不付钱;
一万个测试工程师在酒吧门外呼啸而过;
一个测试工程师走进一家酒吧,要了一杯啤酒';DROP TABLE 酒吧;
测试工程师们满意地离开了酒吧。
然后一名顾客点了一份炒饭,酒吧炸了。

在熟练进行对他人程序进行Hack之后,我在自己写程序的时候也会敏感的意识到一些地方可能会出现重大问题,写完之后也可以以一个测试工程师的身份以***钻的角度审查自己的程序,于是乎我的代码水平也有了进一步长进。OO课程的互测机制是宝藏,相信它会成为我未来宝贵的财富!

posted @ 2021-05-27 19:39  春日野草  阅读(449)  评论(0编辑  收藏  举报