OO Unit3 单元总结
Posted 邵博
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了OO Unit3 单元总结相关的知识,希望对你有一定的参考价值。
OO Unit3 单元总结
总结分析自己实现规格所采取的设计策略
- 采用合适的数据结构
规格中给出的数据规格往往用数组来存储变量,这种存储方式导致每次根据 id 查找对应的对象时,必须遍历整个数组,时间复杂度为 O(n)。
而根据本次作业中每个元素(Person, Group, Message) 的 id 都是唯一的,并且方法中大量通过 id 来查找特定对象的特点,我选择了使用 Map 来存储对象。而在所有的代码中,都没有对 id 进行排序的需要,所以采用时间复杂度是 O(1) 的 HashMap 可以显著提升程序性能并减小编码难度。 - 层次化设计
对于 network 中的一些方法(例如 sendMessage 方法),在 network 中实现很复杂,而且会降低代码可读性,而将其下放到 group 或 person 中进行具体实现,network 中只调用对应 group 或 person 的方法则可以简单的实现这些方法。 - 维护一些数据结构来减少时间复杂度
本次作业中的一些方法(如:queryBlockSum, queryGroupValueSum),如果直接进行实现,算法的时间复杂度较高,达到了 O(n^2)。在本次作业限时较短的条件下,很容易出现 TLE 的情况。而考虑到在我们的社交网络中,添加人/关系/组的操作应当少于查询操作,所以在添加人/关系/组时维护一些数据结构,如并查集,可以大大减少算法的时间复杂度。
结合课程内容,整理基于JML规格来设计测试的方法和策略
首先,可以利用 JUnit 插件来对一个或一组方法进行单元测试
- 对于简单方法,可以通过生成大量随机数据来进行验证,如本单元中的 compareName,queryNameRank 等方法
- 对于对前后文有一定依赖的方法,除了进行随机测试,还可以用手动构造边界样例的方式。如对 queryBlockSum 方法,可以构造一个链状图、散点图、完全图来进行测试。
其次,为了防止个人对规格理解不清楚而导致的正确性问题,我与同学组成了对拍小组,这样一来,只要有人对规格的理解是正确的,就可以帮助他人发现错误。
最后,在本单元的作业中,我们还应该考虑算法的效率。所以我针对作业中的几个“高危方法”设计的程序来生成高压测试,如对 qbs 和 qgvs 生成一个较为复杂的连通图,并大量执行相应查询指令。之后,用自己的程序来运行这些测试点,并记录运行时间。如果在本地可以在 1s 内完成,那么在测评机上也应该不会出现 TLE 错误。
总结分析容器选择和使用的经验
java 自带了大量的容器类,常用的容器有 List, Set, Map 三种。
- List
常用的 List 有 ArrayList, LinkedList, Vector 三种,他们的区别如下:
名称 | 类别 | 优点 | 缺点 |
---|---|---|---|
ArrayList | 可变长数组 | 适于随机访问 | 线程不安全 |
Vector | 可变长数组 | 适于随机访问,线程安全 | 慢于 ArrayList |
LinkedList | 双向链表 | 适于插入、删除 | 随机访问速度慢 |
List 类容器往往用于存储大量不和其他数据产生关联的数据。
往往用 ArrayList 和 Vector 存储可以通过下标进行随机访问的元素,由于 Vector 性能差于 ArrayList ,所以很少使用 Vector。对于插入、删除操作较多且随机访问较少或访问元素往往在数组前部的情况,用 LinkedList 可以加速插入、删除操作。
-
Set
Set 的特点是存储顺序于加入先后无关,且不允许有重复的元素。
常用的 Set 有 HashSet 和 TreeSet。
这两者的主要区别如下:- HashSet 存储的元素无序,TreeSet 存储的元素有序
- HashSet 访问元素的时间复杂度是 O(1),TreeSet 访问元素的时间复杂度是 O(log_2 n)
故对于不允许存在重复元素的数据,用 Set 存储占优。而采用 HashSet 还是 TreeSet 则是根据元素是否有排序需要来选择。
-
Map
Map 的特点是在两个元素 Key 和 Value 之间建立了联系,可以通过 Key 快速访问到 Value 的值。
HashMap 和 TreeMap 的区别与 HashSet 和 TreeSet 基本一致,在此不加赘述。
具体到容器类的选择,我主要考察一下两个方面:
-
数据的特点
如果存储的元素之间无关,且可能重复,那我一般选择 List 类
如果存储的元素不允许重复,那我一般选择 Set 类
如果储存的是两两相关的元素,我往往采用 Map 类 -
对性能的要求
在本次作业中,对性能的要求较为严格,那么我们应该选择访问起来时间复杂度尽可能小的容器
下面列举了一些容器常用方法的时间复杂度:方法 时间复杂度 ArrayList.contains() O(n) HashMap.containsKey() O(1) HashMap.containsValue() O(n) TreeMap.containsKey() O(log_2 n) 在计算自己程序的时间复杂度时,一定要考虑到使用的容器的对应方法的时间复杂度。在本次的互测时,就发现有人因为使用 HashMap 的 containsValue 方法而使程序时间复杂度达到 O(n^2) 而导致 TLE 的情况。
针对本单元容易出现的性能问题,总结分析原因,如果自己作业没有出现,分析自己的设计为何可以避免
这几次作业的特点是:
- 指令条数较多(5000 - 10000 条)
- 部分接口的直接实现方式算法复杂度较高
- CPU 时间限制较严格(前两次 2s 后一次 6s)
在这样的前提下,如果我们 “傻傻的” 依照 JML 的描述规格来实现方法,肯定会在强测或互测中遇到 TLE 的情况。
为了解决这样的性能问题,我采取了以下思路:
-
定位陷阱
在动手实现规格之前,我先阅读了全部的规格要求,简要的写出了初步的实现,并计算自己写的简单实现的算法时间复杂度,找出其中所有非 O(1),O(n) 的算法,被找出的这些方法就是本次作业的性能陷阱,需要进行优化。 -
具体问题具体分析
接下来我将一一分析本单元作业中的待优化函数 :(1) isCircle & queryBlockSum
之所以将这两个函数放在一起,是因为它们可以用相同的优化方式进行优化。
在本次作业中,我采用了邻接表来存储“边”,所以如果采用 BFS 或 DFS 搜索来实现 isCircle 方法的话,时间复杂度是 O(n),这样的时间复杂度其实并非不可接受,但它会导致 queryBlockSum 方法的时间复杂度至少达到 O(n^2),一旦实现中出现了 O(n^2) 算法,那么几乎可以肯定的是,程序会出现 TLE 的情况。
而优化的方式也很简单——由于所有的操作都不设计删边,所以可以通过在每次加入 Person 或 Relation 时维护一个并查集来实现。并查集的工作原理是开始时,每个节点单独作为一颗树的根节点,每次加人时,新增一棵树,并将 groupSum 加一;当加入关系(边)时,找到边两边的两个节点对应的根节点,如果两个根节点相同,则什么都不需要做,否则将其中一个节点连接到另一个上,并将 groupSum 减一。
如此维护并查集后,isCircle 方法只需要判断两个节点的根是否相同,queryBlockSum 则只需要返回 groupSum 的值。(对并查集进行一些优化后)两方法的时间复杂度分别为 O(log_2 n), O(1)。
最后再提一下并查集的优化方式:
1)在两颗树合并时将小树合并到大树的根上,防止树退化为链
2)每次查询根节点时,将经过的节点都连接到根上。(尽量不要使用递归,防止爆栈,不过这次作业5000条指令的限制使得爆栈不太可能)(2) queryGroupValueSum
这个方法规格给出的实现方法有两重循环,时间复杂度是 O(n^2),虽然一个组内只能有 1111 人,但经过实验,如果傻傻的采用这个方法,一定会出现 TLE。(我在互测中就找到了一个)
解决方案也很简单,在组内维护 valueSum 变量,每次加人,加边时都维护所有组中的这个变量。
我在互测时也发现有人使用了“记忆算法”,如果组内人不发生变换就不重新计算。这可以防止有人无脑 qgvs,但如果每次都加一次边再查询一次,就无能为力了,所以不要偷懒。(3) sendIndirectMessage
大家一看到这个方法的描述,肯定直接去看 dijkstra 算法了。这很正确,但是还有注意,应该对 dijkstra 算法进行堆优化,否则他的算法复杂度仍是 O(n^2) 仍会面临 TLE 的窘境。而通过使用小跟堆来存储 shortPath 则可以简化寻找最小值的消耗,使时间复杂度降低到 O(nlog_2 n)
-
最后提一个坑点
我在编码时还尝试优化了 Group 中的 getAgeMean 和 getAgeVar 方法,前者没有出现问题,而后者的结果则和和我对拍的人的结果有较大偏差,经过思考,我发现原因的规格中所有数都是 int 型变量,所以在进行除法时会进行向下取整,导致 与 不等价。为了得到准确的结果,必须采用前一种算法。
梳理自己的作业架构设计,特别是图模型构建与维护策略
本次作业中,我采用了一种类似邻接表法来存储图。具体实现是在每个 Person 中存所有与他有关系的 Person 的引用。选择这样的数据结构有利于渐变实现 dijkstra 算法——每次更新 distance 时,只需遍历上一个选定的人的所有临接点。
维护策略是,在每次加入人或边时,都调用相应辅助数据结构的加边/人方法对其进行维护。具体如下:
- 向 Network 中 addPerson 时,需要在并查集中加入新的根节点。
- 向 Network 中 addRelation 时,即需要在并查集中融合相应节点,又需要告知每一个group,如果边的两端都在 Group 中,那么需要将组内的 valueSum 增加两倍的边的 value 值
- 执行 addToGroup 时,需要遍历 Person 的相邻点,如果其相邻点在 group 中,那么就增加 valueSum 的值
- 执行 delFromGroup 时,需要遍历 Person 的相邻点,如果其相邻点在 group 中,那么就减少 valueSum 的值
以上是关于OO Unit3 单元总结的主要内容,如果未能解决你的问题,请参考以下文章