Loading

OO第三单元总结

OO第三单元总结

〇、单元内容简述

本单元作业主要围绕JML展开。通过迭代实现已由JML规格定义好的类及其方法,实现一个提供增删查改等交互的简单社交关系模拟系统,同时对JML这种规格语言进行入门级的理解

一、设计策略综述

  • 规格本质上是程序设计者与实现者为了保证程序行为的正确性而缔结的一种契约。而我们的作业任务就是要求我们作为实现者,通过以JML定义好的规格实现设计者所要求的程序。

  • 但是规格也好、JML也罢,仅仅是一种约定、一种接口,在满足这些约定的基础上用户是可以相对自由地选择不同的容器、不同的算法甚至添加一些设计者不可见的属性或方法来完成具体实现的。

  • 因此我在具体实现类的规格时并不会急于实现,而是先全局观之,先理解规格中所定义的属性与方法的表层作用,将它们的作用以人能够轻易读懂的“大白话”表述(而不是离散1)。之后考虑要高效实现方法,需要类中的某些容器类属性需要怎样的特性,以及是否需要添加新容器、属性等

  • 上述问题思考的差不多以后,基本上类以及属性的具体实现已经定型,接下来就按照对容器特性的需求选择、添加容器(下文详述)之后按照自己对规格的理解,将规格中的类分为不可变的基础对象可变的功能对象。前者中的属性主要是静态不可变的,方法主要以构造方法、Getters、Setters为主,实现单一(如作业中的Person、Message类以及Exception类);后者则有很多功能性的方法,实现丰富(如作业中的主要服务类Network)。因此先实现前者,再实现后者,并在某些特殊的功能性方法中选择合适的算法予以实现,最终作业任务可以高效地完成。

  • 总之,对于实现者来说,虽然程序的架构已经设计完毕,但是这并不意味着可以“无脑”按照规格来办事——碰到属性就声明,碰到数组就ArrayList……这样简单粗暴显然是不行的,迟早会花费更多时间来重构才能满足规格。

二、测试方法与策略

测试主要有两个方面:对于规格的满足性实现的运行效率,以下分开来说。

1. 对于规格的满足性检验

  • 基本要求:既然要测试,首先就应该测试规格的基本要求以及正常条件下的行为检测。不过由于作业中的规格实际上并没有太复杂,所以正常条件由弱测就可以测试完毕。
    • 因此自测与互测时对于基本要求这一方面主要聚焦于实现自由度高的方法。如作业中Network类中的的isCircle方法、sendIndirectMessage方法等。前者要实现图中节点连通性的判断,后者则需要实现一个最短路径搜索方法。规格仅定义了它们的功能,而弱测基本没有进行完整的测试,因此在自测与互测时需要进行这些方法的正确性测试。不过这些数据点没有必要太多,过多只会空耗能量。
  • 特殊要求与边界条件:如果假设作业中官方给出的JML规格是足够完备的,那么测试程序时除了简单测试在正常条件下的运行外,最应该进行的就是特殊要求与边界条件的满足性检验。
    • 具体来说,在本单元作业中,规格对于exceptional_behavior的定义、对于Group类的addPerson方法中对于this.getSize()>=1111时的特殊要求等等,都是需要重点测试的特殊要求;此外对于如某些可增长的int型属性,以及运行过程中可能溢出的中间变量(如Dijkstra算法中需要计算的权值之和),都需要进行边界条件的测试。
    • 而这些其实可以采用单元测试的方法进行测试,而JUnit就是很方便的一个工具之一。不过实际上由于互测需要通过弱测才能参与,加之本单元作业的复杂度并不高,所以实际上这一工具无论是互测还是自测我并没有很好地进行利用。

2. 运行效率检测

  • 高时间复杂度算法:运行效率检测基本上都是白盒测试,因此不论是自测还是互测仅需要对高时间复杂度的方法进行针对性大数据集来Hack。思路很直观但有效,只需要写一个小脚本生成大数据集即可。
    • 如互测时看到同学的sendIndirectMessage方法是用dfs查找最短路径实现,可以通过稍微多一点的Person数量以及稍微复杂一点的Relation来测试;Group类中的queryValueSum方法是用双重循环查找(小丑竟是我自己),就可以通过一个Group中add多个Person之后多次qgvs来测试等等。

3. Bug分析

  • 规格的满足性:我的作业中出现了两次相关bug。
    • 第一次是在第二次作业中的addToGroup方法的规格做了修改后,我没有及时理解规格而修改程序,导致不能正确处理this.getSize()>=1111时的情况,这是一个低级错误。
    • 第二次是在sendIndirectMessage中的最短路径查找算法Dijkstra算法中,我设定的不可到达时权重的默认值MAXINT为65535。简单分析就知道实际上这个值并不够——权重的值最大为1000,而人数最多可达到5000,因此权重之和(的上限)可以达到5000000——这是我没能把握好数据限制考虑不谨慎导致的bug。修改为0x7fffffff后改正(这个值其实很极限,不过MAXINT是作为常量存在的,其值不会增加,可以使用)。
  • 运行效率主要的bug就是出在运行效率上,这也是最难把握的bug点(也是我被hack的最惨的)。公测与互测中我一共出现了3次相关bug,而且第一次作业中被疯狂hack,刀刀必中。
    • 第一次是在第一次作业中,Network类中的queryBlockSum方法中出了问题。这里我犯了不动脑子的毛病,没有理解清楚BlockSum到底有什么意义,方法实现仅仅通过极其低效率的双重循环+dfs完成,导致疯狂被hack。后来明白了其连通分量个数的含义后,我采用冗余数据的简单方法,提高了该方法的效率,得以苟活一阵。
    • 第二次是在第二次作业中显现,实际上也是第一次作业的祖传问题——dfs的效率问题。冗余数据虽然可以提高queryBlockSum的效率,但是由于其核心仍然是采用dfs实现,只不过是把这个过程放到了addRelation处进行。因此当Person数量以及addRelation调用次数增多时,仍然会出现多次dfs查找的情况,进而造成ctle。之后我痛定思痛,百度后查找到了并查集算法,实现后解决。
    • 第三次则是在第三次作业中显现,实际上也是第二次作业中的祖传问题。主要是Group类中的一些查询以及修改操作的效率问题。如每次调用queryGroupVar时我都要通过循环计算一次方差、每次调用queryGroupValueSum时我都要双重循环查找是否有isCircle的同组人,再进行加和。这些都是我当时考虑欠妥,没有意识到冗余数据的优势导致的。之后进行冗余数据以及转移计算过程到别处,继而解决了相关bug。
  • 其他
    • 中测时出现过Runtime Error,与同学们讨论才知道是某处遍历容器并删除的操作发生问题——通过循环遍历容器来删除特定元素可能导致ConcurrentModificationException。容器的条件删除可以通过iterator.remove方法或是removeIf方法。这样便捷而保证不出错地完成任务。

三、运行性能的优化与保证

运行性能的优化主要由两方面来保证——容器算法

1. 容器

  • 容器选择:由于Java的官方容器各具特色,使用情景不同时其性能表现大相径庭。因此容器的选择首先会对程序性能以及具体实现有很大影响。
    • 而容器的选择首先要考虑的仍然是规格要求。这一要求可能不仅仅是对这一属性的直接要求,方法定义中的间接要求也是很重要的。
      • 如,在本单元作业中,Network类中的people属性的直接要求仅仅为一个not_nullPerson对象容器。但是实际上理解了后续Network类中与people属性有关的方法(如addPersoncontains等)就可以知道,people容器做的最多的是插入依键值查找操作,而Java中最擅长这一操作的容器就非HashMap莫属,因此就选择这一容器实现people容器。
      • 再如,本单元作业中,Person类中的messages属性的直接要求为一个not_nullMessage对象容器。但是同一类中的getReceivedMessages方法要求返回最近该Person类的实例所收到的4个Message对象的List实例。这其实对messages容器提出了两个要求:存放的元素能按照插入顺序有序排列能高效率生成List实例,因此考虑就采用ArrayList实现该容器。
  • 容器使用:经研讨课上大树助教的提醒,由于Java底层对于容器方法的实现有一定的特点,实际上容器的方法调用也会对程序性能造成影响,这种影响尤其在数据集大、操作多时会突出显现,进而增大CPU Time
    • HashMap容器为例。查看java.util包中的HashMap.java中可以发现,其containsKey操作调用了一次getNode方法,其实就是进行了一次哈希表查找,而get方法也调用了相同的方法,进行了一次哈希表查找。
    • 规格中Network类中有很多方法需要先检查people容器内是否存在这些id对应的Person对象,若存在,才通过people容器取出对应的Person实例对象进行操作。如addRelation方法需要先检查id1id2对应的Person对象是否存在,若存在,再对取出的p1p2进行添加关系操作。
    • 而这一方法的实现中,若先采用people.containsKey方法检查id1id2这些键是否存在,再通过people.get方法取出对象,则会导致对people对象进行了两次哈希表查找,这样显然会导致多一次无意义的哈希查找,平添CPU时间做了无用功。
    • 因此,需要针对这一底层实现进行优化——只调用一次people.get方法取出对象,若取出为null,则说明该idHashMap中没有对应,也即该Person不存在。

2. 算法

由于评测机对于CPU Time的要求较高,评测机内存反而怎么也用不完,算法选择主要侧重于低时间复杂度,空间复杂度的要求则可以放宽。具体到作业中我主要采用了以下算法以及思想来提高性能(或是修复CTLE的bug5555)

  • 冗余数据:作为一个社交网络系统,查询功能算是大头。而要以低时间复杂度完成查询功能,冗余数据自然首先要考虑。它通过静态地保存需要频繁查询的量,在其发生改变时再更新,能够显著提高查询效率。本单元作业中的很多地方需要用这一方法才能规避CTLE(公测没测出来到互测也会有人帮你测出来的)
    • 具体的应用如:Network类中的queryBlockSum方法、Group类中的getAgeMean方法、getAgeVar方法、getValueSum方法等。
    • getValueSum方法为例,这一方法要求返回该Group中所有isLinkedPerson对象之间的Value之和。最基础的想法就是进行一个二重循环遍历Grouppeople容器,进行条件加和,得到最终结果。但是这样做的话每调用一次getValueSum方法,就会进行一次二重循环,多次调用则会导致CTLE。
    • 而将Group类中添加一个int属性valueSumGroup对象每“添加”一个Person对象时,就遍历一次Group中现存的所有Person对象,若某个Person对象与新增的Person对象isLinked,则valueSum更新为原值与两者之间的value值之和。
    • 然而,仅仅如此这还不够,若两Person在添加至同一Group之后产生了新“联系”。此时valueSum本应更新但若按照上述设计则并不会更新,进而导致WA。
    • 因此还需要在addRelation方法中添加相关语句,实现两个Person添加联系时查询两者是否位于同一Group,若是,则更新相应Group类中的valueSum值的功能。
    • 总的来说,虽然冗余数据能够提高查询效率,但是使用它要求我们考虑清楚所有涉及相关量的改变的操作,否则必然产生bug。
  • 并查集:作业中Network类中需要我们判断图中节点的连通性(isCircle方法),而能以低时间复杂度实现这一功能的算法非并查集莫属。
    • 本质上,并查集通过一个变量代表整个连通分量的方法,当有两个节点需要判断连通性时,仅需判断两个节点所在的连通分量的“代表节点”是否相同,即可判断两者是否连通。显然,这一算法比DFS、BFS更高效。
    • 不过,若网络要求可以删除节点,则并查集不能采用路径压缩算法,否则并查集失效,这也是需要注意的一点。
  • 堆优化的Dijkstra算法Network类中的sendIndirectMessage算法需要我们查找图中两节点之间的最短路径长度。本来我考虑用容易实现的Floyd算法(摸鱼是第一生产力),但是最终考虑到它O(\(n^3\))的时间复杂度以及要求邻接矩阵的图存储方式只好作罢。我就先实现了邻接表版的Dijkstra算法,之后凭借百度实现了堆优化,将时间复杂度压缩到极限的O(nlogn)。
    • 其实堆优化的实现很简单,尤其是在Java这样一个容器齐全的平台。只需要在Dijkstra算法的基础上通过一个优先队列PriorityQueue存储源节点到其他节点的最短距离以及对应节点的编号这样一个Pair。在Dijkstra算法中需要选择当前的最近节点时,这一队列出列的第一个未被访问的Pair中的节点标号即为所求。而这个优先队列本身由Java以堆实现并维护,不需要我们动手实现,可谓是事半功倍。

算法方面感觉只需要无脑采用最优算法即可(当然是在满足规格的同时也在自己能理解的范围内),毕竟没人能保证某个时间复杂度不是最优的算法一定能通过评测(以及互测)。

四、作业架构设计

我个人认为,本单元作业中JML规格对于类以及程序的架构设计已经比较完整,自由发挥的空间实际上并不多。因此我的架构设计主要也是规格本身的架构设计。

  • 图模型的构建与维护:规格中的图模型由Network类进行构建与维护。主要特点如下。
    • 类邻接表的设计
      • 主服务类Network存储一个节点表people(本质为节点类Person的容器),而节点表中的每个元素,即Person对象内部存储其边链表acquaintance以及边权重表value(本质分别为节点类Person的容器与权值Integer容器)。
      • 实际上这里用邻接矩阵是比较难以维护和使用的。因为节点Person对象的id并不连续,因此要使用邻接矩阵需要为每个节点额外分配连续的标号,此外由于实现“全局变量”会增加类之间的耦合程度,维护时还要不断进行传参才能实现对矩阵的访问,导致实现复杂。当前实现的类邻接表架构反而很好地平衡了类耦合度与访问开销。
    • 通过节点、边的添加构造图Network类提供了addPersonaddRelation方法用来构建图。前者用以在节点表people中添加节点,后者则将对应节点添加到节点的边链表中,实现边的添加。
    • 节点与边未提供删除操作这是对采用了并查集路径压缩算法的同学的仁慈,未考虑有人离开社交网络,或是某人与某人“断交”的情况,模型并未提供相关的维护操作。这样实际也降低了实现难度。
    • 提供一些比较常规的图查询操作:通过社交网络模拟程序中的一些查询功能,提供了对图的最短路径查找节点连通性判别等图论相关的查询操作。

由于整个单元的作业都是围绕来实现,其他方面的架构设计并不突出,故略去。

五、心得与体会

  1. 统揽全局,运筹帷幄:虽然程序的整体架构都已构造完毕,但具体实现还应在把握整体设计的基础之上。所以最好还是要先“通读全文”,再行coding。
  2. 尽量追求最佳性能:对于我这样的一个“算法白痴”,实现一些功能时还是尽量追求最佳性能的算法——既能学到新知识,也能降低TLE的概率,何乐而不为?
  3. 多多注意Java底层的一些实现:不但能直接观摩大牛的代码,更能提升对Java自带的容器等的理解,进而对自己的程序进行针对性优化。
posted @ 2021-05-31 11:02  4lex49  阅读(51)  评论(0编辑  收藏  举报