BUAA 2021春季学期OO课程第三单元(社交网络)工作总结
面向对象第三单元总结
单元整体情况总览
本单元中,我们基于课程组给定的JML描述实现了一个社交网络,其中主要包括对网络结构的构建和解析、消息系统的构建和通信。
在该社交网络中,以人员为节点,以人际关系为边,并为人际关系赋予一定权重;支持通过人际关系进行连通块查询、链接关系查询等;消息系统支持多种不同消息类型,包括普通消息、通知、表情、红包,并支持全局热门表情记录;支持基于最短路径算法的间接消息传递,允许连通但不直接联系的两人之间实现最短路径通讯;同时引入了“群”的概念,支持将多个人员添加至同一个群中并实现消息的一对多发送;支持全局异常情况统计的异常处理系统。
其中主要工程难点集中于:
- JML描述的理解:如何正确理解JML描述
- 接口优先的设计思想:以接口为函数参数而不是具体的类
其中主要技术难点集中于:
- 连通块数量查询:如何能够减小连通块查询的时间复杂度?
- 两人是否链接(即存在一条路径相联系):如何减小判断两人是否链接的时间复杂度?如何设计策略?
- 间接消息传递的最短路径计算:如何设计最短路径查询算法?
- 群聊信息统计的记忆化:如何防止群聊信息统计过程中多次重复计算?
实现规格中采取的具体策略
对于主要技术难点,我提出了以下的解决方法:
-
连通块数量查询与是否链接:采用基于并查集的查询策略。不属于同一连通块的两节点将属于两个不同的集合,每个集合中选出一个节点(本次我选出id最小者)作为该集合的标志领导。当两个节点进行相连,将查询其所属集合的lord是否一致,如果一致则说明其属于同一连通块,否则将这两个节点各自属于的集合进行合并,并选择两个lord间id最小的那个作为新的集合的lord。并查集被描述为Community类,其在网络中的数量即是连通块数量,而判断两个节点是否链接则改为查询其是否属于同一Community对象。
public class Community { private Community higher; private Person lord; private Integer amount; Community(Person lord, Integer amount) { this.lord = lord; this.amount = amount.intValue(); this.higher = null; } public Person getLord() { if (this.higher == null) { return this.lord; } else { return this.higher.getLord(); } } public void setHigher(Community higher) { this.higher = higher; } public Community getHighest() { if (this.higher == null) { return this; } else { return this.higher.getHighest(); } } public Integer getAmount() { return amount; } @Override public boolean equals(Object other) { if (other == this) { return true; } if (!(other instanceof Community)) { return false; } if (((Community) other).getLord().equals(this.getLord())) { return true; } else { return false; } } }
可以看到,我使用了树的数据结构来实现并查集。这使得每次查询lord都会不断向上层进行查找,直到找到最顶层的节点,其就是lord节点。树的结构也使得合并两个并查集的操作变得简洁明了。
-
间接消息传递的最短路径计算:采用堆优化的Dijkstra算法,并依赖本身网络系统中对于节点间关系的记录进行计算,在保持算法与规格的独立性的同时尽可能复用已有的函数。
public static int minPathWeight(Network network,Person srcPerson, Person dstPerson) { int srcId = srcPerson.getId(); int dstId = dstPerson.getId(); class IdToCurMinDis { private int personId; private int minDis; IdToCurMinDis(int personId, int minDis) { this.personId = personId; this.minDis = minDis; } } HashMap<Integer, Integer> idToMinDis = new HashMap<>(); PriorityQueue<IdToCurMinDis> priorityQueue = new PriorityQueue<>(Comparator.comparingInt(o -> o.minDis)); HashSet<Integer> visitedIdSet = new HashSet<>(); priorityQueue.add(new IdToCurMinDis(srcId, 0)); idToMinDis.put(srcId, 0); while (!priorityQueue.isEmpty()) { IdToCurMinDis cur = priorityQueue.poll(); if (cur.personId == dstId) { return idToMinDis.get(dstId); } if (visitedIdSet.contains(cur.personId)) { continue; } visitedIdSet.add(cur.personId); MyPerson curMyPerson = (MyPerson) network.getPerson(cur.personId); for (Integer linkedId : curMyPerson.getAllLinkedId()) { if (!visitedIdSet.contains(linkedId)) { int value; try { value = network.queryValue(cur.personId, linkedId); } catch (Exception e) { value = -1; e.printStackTrace(); } int newDisToLinkedId = idToMinDis.get(cur.personId) + value; if (idToMinDis.get(linkedId) == null || idToMinDis.get(linkedId) > newDisToLinkedId) { idToMinDis.put(linkedId, newDisToLinkedId); priorityQueue.add(new IdToCurMinDis(linkedId, newDisToLinkedId)); } } } } return -1;
可以看到,我在该Dijkstra算法中采用了Java内置的优先队列(基于堆实现),并设计了内置类IdToCurMinDis用于记录节点ID和其目前距离起点计算的最短距离。通过重载一个自己的比较函数来实现维护堆的顶端始终是当前所有节点中距离已遍历集合最近的节点。
-
群聊信息统计的记忆化:对不同信息的统计采用了两种方法
-
对平均年龄和年龄方差采用脏位(dirty)的方法,当一个群聊没有更新人员信息时,记忆下来上一次信息统计的结果并返回。
-
对社交值之和,采用动态增减的方法,建立群到人和人到群的关系,每次建立人际关系都检查当前两人是否属于同一群聊,如果有的话就将所在群聊的总社交值提高两倍的当前关系社交值;当对一个群聊进行人员增减时,查询是否和当前群内人相连,若相连则群聊的总社交值提高两倍的当前关系社交值,这样在查询总社交值时就不再需要遍历。
public void addRelation(int id1, int id2, int value) throws PersonIdNotFoundException, EqualRelationException { MyPerson person1 = (MyPerson) this.people.getOrDefault(id1, null); MyPerson person2 = (MyPerson) this.people.getOrDefault(id2, null); if (person1 == null) { throw new MyPersonIdNotFoundException(id1); } else if (person2 == null) { throw new MyPersonIdNotFoundException(id2); } else if (person1.isLinked(person2)) { throw new MyEqualRelationException(id1, id2); } Community community1 = person1.getCommunity().getHighest(); Community community2 = person2.getCommunity().getHighest(); MyPerson.linkWithValue(person1, person2, value); if (community1.equals(community2)) { //do nothing } else { Person lord1 = community1.getLord(); Person lord2 = community2.getLord(); Person lord3 = (lord1.getId() > lord2.getId()) ? lord2 : lord1; Community newCommunity = new Community(lord3, community1.getAmount() + community2.getAmount()); community1.setHigher(newCommunity); community2.setHigher(newCommunity); this.circledBlocks.remove(community1); this.circledBlocks.remove(community2); this.circledBlocks.add(newCommunity); } for (Integer groupId : person1.getAllGroupId()) { MyGroup inGroup = (MyGroup) this.getGroup(groupId); if (inGroup.hasPerson(person2)) { inGroup.setValueSum(inGroup.getValueSum() + 2 * value); } } }
-
基于JML规格设计测试的方法
根据往年学长们的总结,我选用了OpenJML来进行代码规格的检测(同时发现了课程组JML的很多问题),并使用JMLUnitNG以及JUnit4来进行测试。
OpenJML
OpenJML最基本的功能就是对JML注释的完整性进行检查。检查包括经典的类型检查、变量可见性与可写性等。通过命令行使用OpenJML时,可以通过-check
参数(该参数默认)指定类型检查。我使用OpenJML对实现的代码进行检查:
-
JML语法静态检查:给出JML语言上的语法错误,并不关心具体程序的可能问题
java -jar specs/openjml.jar -check myClass.java
-
程序代码静态检查:使用
-rac
选项可以执行运行时检查。给出程序中可能出现的潜在问题,并不关心JML语言的形式问题java -jar specs/openjml.jar -esc myClass.java
-
运行时检查:
生成一个新的.class文件,其中包含了运行时检查的assertion,在接下来进入运行和单元测试的时候将发挥作用
java -jar specs/openjml.jar -rac myClass.java
JMLUnitNG
使用JMLUnitNG根据JML语言来在动态情况下自动生成TestNG测试
-
基于JML生成测试文件:
java -jar ./specs/jmlunitng.jar myClass.java
-
利用OpenJML的RAC,生成含有运行时检查的特殊.class文件并替换原文件
java -jar ./specs/openjml.jar -d bin/ -rac myClass.java
-
运行TestNG测试
java -cp ./specs/jmlunitng.jar:bin myClass_JML_Test
JUnit
JUnit被整合至IDEA中,只需要下载插件JUnitGenerator V2.0即可。该插件支持JUnit 3和JUnit 4。通过设置,还可以管理JUnit生成的目标目录和包的导入格式,支持一键生成测试用的.class并可以直接运行。
JUnit用于逐个函数功能的测试,其提供完备的异常监测和断言检查。主要困难在于如何构造测试样例,实际上这也是本次作业的一大难点。
我通过根据JML对各种情况进行样例归类和设计,尽可能去覆盖一般、边界、异常三种情况。但是问题在于没有完备的检查机制来证明我构造的样例一定能覆盖所有可能的情况。
容器使用体会
HashMap 永远滴神!在查询时使用HashMap或HashSet总能解决大部分问题,其有效地减小了查询时的复杂度。其主要难点在于如何构造键值的对应关系以尽可能符合函数功能需要,而只要解决了这一点,就会发现程序的编写过程十分怡人,再也不用纠结于复杂度的问题。
PriorityQueue用于具有需要按一定顺序排列的需求的场景非常适合,其有效地将寻找最值的时间复杂度降到O(\(nlog_n\)),但是付出的代价就是每次插入都会引起堆结构的重组,因此适合用于对最值读取具有确定性需求的场景,比如在算法中或者查询需求显著的业务中。
性能问题分析
本次性能难点集中于:
-
两节点是否处于同一连通块以及连通块数量查询
- 性能问题:朴素的连通块查询需要进行O(\(n^2\))的遍历,而朴素的连通块数量查询需要O(\(n^3\))的遍历
- 优化:采用并查集使得我们的连通块查询需要最多O(\(n\)),而连通块数量查询为O(1)
-
间接消息传递的最短路径计算
- 性能问题:朴素的Dijkstra算法需要O(\(n^2\))
- 优化:采用堆优化后复杂度降为O(\(nlog_n\))
-
群聊总社交值的计算
- 性能问题:需要遍历所有成员的同时遍历其相连的所有成员,需要O(\(nlog_n\))
- 优化:采用动态增减的思想,平均每次插入为O(\(log_n\)),总的下来初始化需要O(\(nlog_n\)),但是使得查询只需要O(1)
本次第一、二次作业中,如果按照JML的规格去设计算法,则必然TLE。我在第二次成功Hack了一个未对连通块查询进行优化的同学,其采用了O(\(n^3\))的方法。
但是很微妙的是,第一、二次作业的限制最长时间是2s,而第三次作业限制最长时间是6s。这使得即使没有对Dijkstra进行堆优化也能在极限数据下成功过关,这也使得我第三次作业互测时完全没有从性能方面进行Hack的方法,只能观察他们对于函数的具体实现是否符合JML描述。这个过程极其痛苦,而且收益很低。
BUG分析
三次作业中,我只有第一次出现 BUG,该BUG来自于我没有在从HashMap中取出值后判空,导致NullPointerException。
事实上我在第一次作业就采用了JUnit进行函数功能测试,但是显然我构造的测试样例没有测试到这一问题,这也是为什么在接下来的作业中我没有继续使用JUnit,而是使用对拍器+OpenJML+JMLUnitingNG,因为我不再信任自己的样例构造能力。
这次教训是惨痛的,也促使我重新审视自己的代码规范性。在进行编程时遵一定需要循特定的顺序而不是按照思维逻辑的连贯性来编写。想当然地认为不会取出空值或是忘记了会取出空值,这都是危险的行为。一个优秀的程序员一定要时刻注意自己调用的函数返回值,即使确定返回不为空最好也要加上判空的逻辑。
当然,我们早就用上Java 8 了,为什么不用Optional呢(笑)?