OO Unit 3 Summary
OO第三单元的训练目标是规格化程序设计。
简而言之,课程组首先使用JML(Java Modeling Language)代替自然语言进行需求描述,随后我严格依据规格的各种条件以及副作用要求,在此基础上加以算法和数据结构层面的优化。如何实现符合规格的高性能程序,是本单元重点关注的问题。
架构设计
本单元的训练目标是依照规格进行程序设计,课程组通过接口和抽象类的形式,给出待实现的方法,并使用JML进行规格描述。而我们的任务是创建新的类来实现接口或继承抽象类,完成其中的方法。
- 对于接口,使用implements关键字:
public class MyPerson implements Person
- 对于抽象类,使用extends关键字:
public class EqualRelation extends EqualRelationException
Emoji类需要实现课程组给出的接口EmojiMessage中的方法,而EmojiMessage又继承了Message接口,因此Emoji类需要实现Message接口中的方法。我使Emoji类继承由我实现的MyMessge类,节省了大量的代码量。
public interface EmojiMessage extends Message // 课程组官方包
public class MyMessage implements Message
public class Emoji extends MyMessage implements EmojiMessage
我建立了一个异常计数器类Counter,用于统计与特定人关联的异常次数。在异常类中定义静态属性计数器: private static final Counter counter = new Counter();
,可以在每次新建异常对象时,正确存储并获取历史异常次数。
public class Counter {
private final HashMap<Integer, Integer> exceptionTimes;
public Counter() {
exceptionTimes = new HashMap<>();
}
public int getTimes(int id) {
if (exceptionTimes.computeIfPresent(id, (key, value) -> value + 1) == null) {
exceptionTimes.put(id, 1);
}
return exceptionTimes.get(id);
}
}
由于需要实现的类以及它们之间的层次关系已经确定,在整体架构上没有太大发挥的空间,因此我们的工作重点在于如何优化方法的实现。其中的一个关键是如何构建并维护社交网络这一图的各种信息。
以下分别从各次作业的需求入手,介绍图的优化思路。
Hw9
在Network类中,JML给出了类的属性Person[] people,即社交网络中人的集合,若不假思索翻译规格,我们可以使用容器ArrayList进行存储。
但实际实现中,我发现经常需要通过person的id寻找特定的人,使用ArrayList时只能通过遍历对比id的方式进行查找,时间复杂度为 O ( n ) O(n) O(n)。因此,我使用HashMap来存储人的信息,key为id,value为Person对象,通过id可以直接找到对应的Person对象。由于Network类中的方法普遍基于Person的id进行操作,可以节省大量时间,并且十分简洁。
private final HashMap<Integer, Person> people;
public boolean contains(int id) {
return people.containsKey(id);
}
public Person getPerson(int id) {
return people.getOrDefault(id, null);
}
contains()和getPerson()方法的时间复杂度均为 O ( 1 ) O(1) O(1)。
并查集
并查集是一种树形的数据结构,用于处理不相交集合的合并问题。并查集维护了一个由多个不相交集合组成的集合族,每个集合通过一个代表元素来标识。它的基本操作有合并和查找。
- 查找
用于确定元素所属的集合,可通过根节点判断两个元素是否属于同一个集合,即它们之间的连通性。通常使用递归或迭代的方式实现,时间复杂度为 O ( log n ) O(\log n) O(logn)。
这里使用了路径压缩,即在查找的过程中,将路径上的所有节点直接连接到根节点上,从而减少树的高度。
public int find(int id) {
int root = id;
Stack<Integer> stack = new Stack<>();
while (root != parent.get(root)) {
stack.push(root);
root = parent.get(root);
}
while (!stack.isEmpty()) {
parent.put(stack.pop(), root); // 路径压缩
}
return root;
}
- 合并
将两个不相交的集合合并为一个集合。按秩合并是指将高度较小的树连接到高度较大的树上,避免了树的深度过大。这里使用了一个HashMap height来存储每个根节点的高度,高度为0表示该节点为叶子节点。
public void union(int id1, int id2) {
int root1 = find(id1);
int root2 = find(id2);
if (root1 == root2) {
return;
}
int height1 = height.get(root1);
int height2 = height.get(root2);
if (height1 < height2) { // 按秩合并
parent.put(root1, root2);
height.remove(root1);
} else {
if (height1 == height2) {
height.put(root1, height1 + 1);
}
parent.put(root2, root1);
height.remove(root2);
}
}
在每次建立关系时维护并查集后,可以迅速解决查询连通性的isCircle()方法,查询连通块的数量的queryBlockSum()方法。可见,并查集在解决图的连通性问题上十分优雅。
public boolean isCircle(int id1, int id2) {
return disjointSet.find(id1) == disjointSet.find(id2);
}
public int queryBlockSum() {
return disjointSet.getHeight().size();
}
TripleSum
TripleSum是指社交网络中存在三个人,它们两两之间认识,统计这样三人组的数量。一种暴力搜索的方法是遍历每个人的朋友,再遍历其所有的朋友,若其与最初的人认识,则三人组的数量加一。但这样的实现时间复杂度为 O ( n 3 ) O(n^3) O(n3),显然不符合性能要求。
因此我采用了动态维护TripleSum的方法。在addRelation()方法中,当a和b建立关系时,遍历a的所有朋友,若其与b认识,则三人组的数量加一。这样,在每次建立关系时动态维护,可以将时间复杂度降为 O ( n ) O(n) O(n)。
HashMap<Integer, Integer> acq1 = getPerson(id1).getAcquaintances();
for (int id : acq1.keySet()) {
HashMap<Integer, Integer> acq = getPerson(id).getAcquaintances();
if (acq.containsKey(id2)) {
tripleSum++;
}
}
Hw10
本次作业中新增了群组功能,与people同理,使用HashMap存储群组信息,可通过group的id快速查找到对应的群组: private final HashMap<Integer, Group> groups;
modifyRelation()
本次作业出现了社交网络中关系变动的需求,涉及到关系亲密度的变化和删除关系,而删除关系是并查集极难处理的问题。
因此我把每条边的信息备份,每次建立关系时将其加入,删除关系时将其移除。这样,每次删除关系后,只需要遍历备份中的所有边,重新建立并查集即可。
public void rebuild(int id1, int id2) {
height.clear();
for (int id : parent.keySet()) {
parent.put(id, id);
height.put(id, 0);
}
backup.remove(new Pair<>(id1, id2));
for (Pair<Integer, Integer> pair : backup) {
union(false, pair.getKey(), pair.getValue());
}
}
删除关系后同样需要维护TripleSum,遍历时将三人组的数量减一即可。
CoupleSum
CoupleSum是指社交网络中存在两个人,他们互为最好朋友,统计这样的人的数量。由于关系在不断变化,动态维护社交网络的该信息较为复杂,且维护时的遍历操作无法避免。因此我采用了JML的朴素写法,遍历每个人,若其与其最好朋友互为最好朋友,则数量加一。时间复杂度为 O ( n 2 ) O(n^2) O(n2)。
public int queryCoupleSum() {
int ans = 0;
for (int id : people.keySet()) {
Person person = getPerson(id);
if (person.getAcquaintances().isEmpty()) {
continue;
}
int acq = person.getBestAcq();
if (getPerson(acq).getBestAcq() == id) {
ans++;
}
}
return ans / 2;
}
虽然CoupleSum没有动态维护,但每个人的最好朋友信息仍可以在关系变动时动态维护,逻辑较为清晰。
BestAcquaintance
在Person类中,我新增了一个属性bestAcq,用于存储该人的最好朋友的id,便于Network类对CoupleSum的查询。
- 关系变好
适用于新建立关系或关系的亲密度增加。
若变动关系的亲密度大于原有的最好朋友关系的亲密度,则更新该人的最好朋友信息 - 关系变坏
有删除关系或关系的亲密度减少两种可能。
若变动的关系为该人的最好朋友关系,则重新遍历所有朋友,找到亲密度最大的关系,更新最好朋友信息。若不为该人的最好朋友关系,则不需要维护。
public void findBestAcq(int type, int id, int value) {
if (type == 1) { // 关系变好
if (value > bestValue || (value == bestValue && id < bestAcq)) {
bestAcq = id;
bestValue = value;
}
} else { // 关系变坏
if (id == bestAcq) {
int max = 0;
int minId = 0;
for (int key : acquaintances.keySet()) {
int now = acquaintances.get(key);
if (now > max || (now == max && key < minId)) {
minId = key;
max = now;
}
}
bestAcq = minId;
bestValue = max;
}
}
}
Hw11
使用HashMap存储表情包信息,键值对为<id, heat>,可通过表情包的id快速查找到对应的表情包的使用次数: private final HashMap<Integer, Integer> emojis;
LeastMoments
求社交网络中经过某个人的最小环,边的权值即为关系的亲密度。我使用SPFA算法求出该源点到其余点的最短路,时间复杂度为 O ( n 2 ) O(n^2) O(n2),符合性能要求。
一个环需要三条不同的边,因此可分两种情况由最短路求出最小环:
- 额外边包含源点,最小环由额外边和一条长度≥2的最短路构成
- 额外边不包含源点,最小环由额外边和两条最短路构成
public int leastMoments(int id) {
int ans = inf;
int index = match.get(id);
spfa(index);
for (int i : matrix.get(index).keySet()) { // 额外边包含源点
if (parent[i] != i) {
ans = Math.min(ans, getValue(index, i) + dis[i]);
}
}
for (int i = 0; i < num; i++) { // 额外边不包含源点
for (int j : matrix.get(i).keySet()) {
if (i != index && j != index && findRoot(i) != findRoot(j)) {
ans = Math.min(ans, dis[i] + dis[j] + getValue(i, j));
}
}
}
return ans;
}
性能优化
JML规格只给出了方法的基本限制,其常使用循环等写法来约束方法前后的变化,若直接照搬,会导致程序性能较差。因此,需要依据规格的各种条件以及副作用要求,在此基础上加以算法和数据结构层面的优化。
- 数据结构优化
我常用的方法是优化数据的存储结构,选用容器HashMap替代JML中的数组写法,设置key为id,使得数据的查找、插入、删除等操作更加高效。 - 算法优化
JML中涉及到图的方法,通常使用多层遍历的暴力搜索方法,只保证了正确性,牺牲了性能,也不够简洁。因此,我的优化思路是将静态算法变为动态维护,或优化静态算法的时间复杂度。具体来说我使用了SPFA算法求最短路,并查集求连通块,动态维护等方法,使得程序更加高效。
因此,我们应该认识到,JML不代表具体的实现方式,在实现时不能机械地翻译JML,而是将其作为需求的一种描述,在符合其约束的条件下进行高性能的实现。
OKTest
OK测试方法对于检验代码实现与规格的一致性起着重要的作用。通过使用OK测试方法,开发人员可以更加可靠地确保代码与规格的一致性,提高软件质量。
由于OKTest的方法不需使用Network类中的属性,我将其与Network类分离,单独新建一个OK测试类,作为静态方法存放。
qts
全称为queryTripleSumOKTest,对应查询三人组数量的queryTripleSum()方法。
由于原方法是pure方法,不改变类的任何属性,因此首先检查方法前后是否产生副作用。随后严格根据规格写法,采用JML中时间复杂度为 O ( n 3 ) O(n^3) O(n3)的暴力搜索方法,与正确输出进行比较。
mr
全称为modifyRelationOKTest,对应变动关系的modifyRelation()方法。
首先检查方法是否需要抛出异常但并未抛出,正常完成了方法。随后检查方法是否满足规格要求,即检查JML的后置条件ensure,按JML代码的行进行检查,若ensure条件不满足,返回最先不满足的行数,若满足,返回0。
dce
全称为deleteColdEmojiOKTest,对应删除冷门表情的deleteColdEmoji()方法。
该方法无需抛出异常,其余部分与mr同理,按行检查JML并返回错误行数即可。
心得体会
JML和使用自然语言的需求描述区别较大,通过本单元的大量练习,我培养了阅读JML的能力,能够快速理解JML的含义,在此过程中主动学习了一些图论中的算法,受益匪浅。
同时,我也学会了如何使用JML来描述需求,如何使用JML来检验代码实现与规格的一致性,对软件工程的需求分析和测试方法有了更深刻的认识。