一、写在前面
这一单元以“社会交际圈”为背景,通过三次作业的迭代开发,以短平快的方式快速上手JML
规格语言,并基于已有的规格编写业务代码,实现特定的功能。这也是我首次接触JML
(可能也是最后一次qwq)。
从完成任务的角度上看,本单元的架构设计无需自己花费心思,基于现有规格实现相应功能即可,因此迭代开发难度略小于前两个单元。但需要额外注意的是:规格仅仅是一种契约,对相同的规格可能有多种实现方法,不同方法有效率高低之分,所以在依照规格编写代码时也应特别注意复杂度问题(否则强测中很可能baobi
)。
此外,作业中还要求针对特定方法书写OK测试,本质上是“对于某个程序的执行结果,手动检验其是否符合规格要求”,和以往熟悉的“写代码,完成任务”有本质上的区别,因此对这部分的理解也是本单元学习的重要部分。
二、第九次作业
1. JML学习
在开始第九次作业前,我先系统学习了一下课程组下发的《JML
手册》,并将学习笔记分享在讨论区。
讨论:【分享】JML手册学习笔记 - 第九次作业 - 2023面向对象设计与构造 | 面向对象设计与构造 (buaa.edu.cn)
2. 架构设计
①基本要求
本次作业实现架构基本固定,大致可以分为以下几块:
- 异常类的实现
- 社交网络框架搭建(
MyPerson
、MyNetwork
类) qtsok
测试
②优化设计
实现规格要求的功能不难,重点在于效率优化,我主要从以下几个方面入手:
容器选取
起初我对规格的理解是非常刻板机械的,认为“规格要我做什么,我就得做什么”,于是我真的用最最普通的数组来存放所有人,但在后续代码编写中逐渐意识到问题:貌似使用JAVA
提供的ArrayList
、HashMap
等容器也可以达到同样的效果,而且操作更加方便!
于是我把数组换成了ArrayList
,很多要求直接调用容器自身的方法就可以满足。
但问题依然存在:当涉及大量频繁的查询操作时,每次都需要遍历容器,效率依然不够理想,所以最终选用的容器为HashMap
,键和值分别存放ID
和MyPerson
对象,大大方便了查询操作。
并查集的使用
在下一部分详细介绍。
3. 图模型的构建与维护
①并查集
作业中的isCircle
、queryBlockSum
和queryTripleSum
方法涉及图中独立分支数计算、三角形个数计算和两点间连通性的判断,可以使用并查集来对症下药。
有关并查集算法原理不多赘述,我参考了下面的文章:
算法学习笔记(1) : 并查集 - 知乎 (zhihu.com)
这种数据结构非常适合本次作业的要求,我将并查集单独封装成类,在类中使用HashMap
存放父子节点关系,用rank
存放各个节点的秩,用于按秩合并。同时实现add
、find
、merge
等基本方法。
public class DisjointSet {
private final HashMap<Integer, Integer> fa;//节点和对应的父节点
private final HashMap<Integer, Integer> rank;//节点和对应的秩
private int blockSum = 0;//直接作为独立分支的个数
public DisjointSet() {
this.fa = new HashMap<>(10000);
this.rank = new HashMap<>(10000);
}
public void add(int id) {
...
}
public int find(int id) {
...
}
public void merge(int id1, int id2) { //按秩合并
...
}
}
②优化算法
普通的并查集仍然有很多可以优化的地方,如路径压缩、按秩合并。
路径压缩
原理介绍仍可参考上面的文章,简单来说即在查询的过程中把沿途每个节点的父节点都设置为该分支的根节点,在find
方法内体现:
public int find(int id) { //路径压缩
int rep = id;//一开始代表元素为本身
while (rep != fa.get(rep)) { //循环获得id所在分支的代表元素
rep = fa.get(rep);//令rep迭代至顶层
}
int now = id;
while (now != rep) { //压缩路径,并将新对应关系放入fa中
int pre = fa.get(now);
fa.put(now, rep);
now = pre;
}
return rep;
//if (id == fa.get(id)) { //递归做法,可能会爆栈
// return id;
//}
//else {
// fa.put(id, find(fa.get(id)));
// return fa.get(id);
//}
}
按秩合并
由于我们在找出一个元素所在集合的代表元时需要递归地找出它所在的树的根结点,所以为了减短查找路径,在合并两棵树时要尽量使合并后的树的高度降低,所以要将高度低的树指向高度更高的那棵。我们将树的高度称为秩,合并时将 “小秩”集合的代表元的直接上级设为 “大秩”集合的代表元。
public void merge(int id1, int id2) { //按秩合并
int fa1 = find(id1);
int fa2 = find(id2);
if (fa1 == fa2 && id1 != id2) {
return;//如果父节点相同,根本就没有必要合并
}
int rank1 = rank.get(id1);
int rank2 = rank.get(id2);
if (rank1 < rank2) {
fa.put(fa1, fa2);//把秩较小的节点合并到秩较大的节点上
}
else {
if (rank1 == rank2) {
rank.put(fa1, rank1 + 1);//秩相同的话,随便合并一下
}
fa.put(fa2, fa1);
}
blockSum--;//分支数减一
}
4. 性能分析
本次作业复杂度分析如下图:
可见工具方法equalsResult
、renewTriSum
、equalsHashMap
和OKTest
方法(后面介绍)复杂度较高,因为这些方法中有较多for
循环和if
条件判断语句块,用于实现容器相等的判断。
5. bug分析
本次作业在强测中出现了两处bug
,一是对OKTest
方法理解不到位导致测试代码书写逻辑错误,二是测试不足,未在renewTriSum
方法中判断ID
对应的人是否存在。
本次作业虽然难度不大,但还是有很多细节需要注意,这次就因为测试不足吃了亏,导致强测只有70
分,警醒我课下代码的不可靠以及自行测试的重要性。
6. UML类图
本次作业的UML类图如下:
三、第十次作业
第十次作业新增了MyMessage
和MyGroup
类,并且需要自行实现额外5
个异常类,为modifyRelation
方法书写OK
测试。
1. 迭代实现(架构增设)
①基本要求
本次作业实现架构基本固定,大致可以分为以下几块:
5
个异常类的实现- 社交网络框架完善(新增
MyGroup
、MyMessage
类,MyNetwork
类新增多个方法) mrok
测试
②优化
Group中年龄的计算和维护
MyGroup
类中需要计算组内人年龄的总和、平均值和方差,以及valueSum
的值,每一次都从头开始计算显然复杂度太高,故我采用了动态维护的方法:在类中维护ageSum
、ageMean
、ageSquareSum
、valueSum
字段,每一次addPerson
和delPerson
时都对这些字段进行维护:
public void addPerson(Person person) {
for (Integer id : people.keySet()) { //遍历更新valueSum
if (people.get(id).isLinked(person)) {
valueSum += 2 * people.get(id).queryValue(person);//加上两倍的关系值
}
}
people.put(person.getId(), (MyPerson) person);
ageSum += person.getAge();//动态求总年龄
ageMean = ageSum / people.size();//动态维护平均年龄
ageSquareSum += person.getAge() * person.getAge();//平方总年龄
}
public void delPerson(Person person) {
people.remove(person.getId());
ageSum -= person.getAge();
if (people.size() > 0) { //size大于0时才可以计算平均值(为0不用算)
ageMean = ageSum / people.size();
}
else { //size == 0
ageMean = 0;
}
ageSquareSum -= person.getAge() * person.getAge();//平方总年龄
for (Integer id : people.keySet()) { //遍历更新valueSum
if (people.get(id).isLinked(person)) {
valueSum -= 2 * people.get(id).queryValue(person);//减去两倍的关系值
}
}
}
要注意的是,计算方差不能直接用E(X*X) - E(X)*E(X)
,因为ageMean
的计算是截除法,除不尽时会向下取整,所以不恒等,需要将其拆分后计算:
public int getAgeVar() { //D(X) = E(X*X)-E(X)*E(X)
if (people.size() == 0) { //没有人时,返回0
return 0;
}
return (ageSquareSum - 2 * ageMean * ageSum + people.size() * ageMean * ageMean)
/ people.size();
}
动态维护并查集
详见下一部分。
bestID的维护:
作业中的queryCoupleSum
方法要求出互为关系最好的人有多少对,朴素的做法是二重循环遍历,但很可能超时,于是我在MyPerson
类中维护了一个bestID
字段,每一次添加和删除关系时动态维护:
public void renewBestId(int id1, int id2, int value, boolean flag) { //更新某个人的bestId
//参数分别是原来的人、要和他建立关系的人、即将的关系值,标记参数(true代表加入,反之删除)
MyPerson person1 = people.get(id1);
if (flag) { //加入
if (person1.getBestValue() < value) { //如果新的值大于bestId,做变换
person1.setBestId(id2);
person1.setBestValue(value);
}
else if (person1.getBestValue() == value) { //如果等于,比较id大小
if (person1.getBestId() >= id2) {
person1.setBestId(id2);
}
}
}
else { //删除(保证删掉的是bestID)
int bestId = 0;
int bestValue = 0;
MyPerson person = getPerson(id1);
for (Integer item : person.getAcquaintance().keySet()) { //值当然要大于bestValue啊!
if (person.getAcquaintance().get(item).queryValue(person) > bestValue) {
bestId = item;
bestValue = person.getAcquaintance().get(item).queryValue(person);
}
else if (person.getAcquaintance().get(item).queryValue(person) == bestValue) {
bestId = (item < bestId) ? item : bestId;//选择较小的ID
}
}
getPerson(id1).setBestId(bestId);
getPerson(id1).setBestValue(bestValue);
}
}
2. 图模型运用
删除关系会破坏并查集,因此需要对所有可能产生影响的方法进行维护。
我借鉴了讨论区同学的思路分享,在每次删除边时清空并查集的元素并基于新的条件重建并查集,原文链接如下:
讨论:依据并查集,一种可行的删边方法 - 第十次作业 - 2023面向对象设计与构造 | 面向对象设计与构造 (buaa.edu.cn)
体现在代码中,我给并查集类增加了removeAndClear
和rebuild
两个方法,用于实现并查集的清空和重建:
public void removeAndClear(int id1, int id2) {
int low = Math.min(id1, id2);
int high = Math.max(id1, id2);//求出较小者和较大者
pairs.remove(new Pair<>(low, high));//清除键对
fa.clear();
rank.clear();
blockSum = 0;
}
public void rebulid(HashMap<Integer, MyPerson> people) {
for (Integer id : people.keySet()) { //把所有人加进去
this.add(id);
}
for (Pair pair : pairs) { //合并键对,重建并查集时输入true
this.merge((Integer) pair.getKey(), (Integer) pair.getValue(), true);
}
}
3. 性能分析
本次作业复杂度分析如下图:
可见除了OKTest方法(不可避免有大量分支判断)外,复杂度集中体现在更新bestID
和三角形数量的方法上,这两个方法内部都有较多for
循环嵌套if
条件判断语句。此外,modifyRelation
和sendMessage
两个方法复杂度较高,原因也在于复杂的逻辑判断。
4. bug分析
本次作业在强测中没有出现bug
,在互测中出现了较大问题。
我把每个人的初始bestID
都设为0,所属groupID
也是0
,这样可能会出现问题,因为正常人和组的编号完全可以是0
,无法处理该特殊情况。
本次作业前期测试较为充分,也帮助我发现了无数细小的bug
,动态维护容易出错的弊端也显露无疑,好在借助同学们的对拍器和评测机跑了数十万组数据,将这些隐藏的bug
基本薅了出来,在此感谢我亲爱的同学们!
5. UML类图
本次作业的UML
类图如下:
四、第十一次作业
1. 迭代实现(架构增设)
①基本要求
本次作业实现架构基本固定,大致可以分为以下几块:
- 新增
3
个异常类的实现 - 社交网络框架进一步完善(新增三个消息类,
MyNetwork
类新增多个方法) dceok
测试
②实现
三个Message类
这几个类都是继承自MyMessage
类,需要添加对应的成员变量和get
方法,并套用父类的构造方法:
public class MyRedEnvelopeMessage extends MyMessage implements RedEnvelopeMessage {
private final int money;
public MyRedEnvelopeMessage(int messageId, int luckMoney,
Person messagePerson1, Person messagePerson2) {
super(messageId, luckMoney * 5, messagePerson1, messagePerson2);
this.money = luckMoney;
}
public MyRedEnvelopeMessage(int messageId, int luckMoney,
Person messagePerson1, Group messageGroup) {
super(messageId, luckMoney * 5, messagePerson1, messageGroup);
this.money = luckMoney;
}
@Override
public int getMoney() {
return money;
}
}
新增表情池
使用一个HashMap
来存放表情的emojiID
和热度值:
private final HashMap<Integer, Integer> emojiHeatList = new HashMap<>(10000);
还有一些配套的操作方法,大部分都比较简单,要注意deleteCodeEmoji
方法,需要实现删除容器中某些特定对象,我们不能直接在for
循环中删除,会触发异常。因此使用一种高级的迭代删除方法(不知道叫啥名但看起来很厉害qwq):
public int deleteColdEmoji(int limit) { //删除冷门表情
emojiHeatList.entrySet().removeIf(entry -> entry.getValue() < limit);
messages.entrySet().removeIf(entry -> entry.getValue() instanceof EmojiMessage &&
!emojiHeatList.containsKey(((EmojiMessage) entry.getValue()).getEmojiId()));
return emojiHeatList.size();
}
clearNotices
方法同理。
最小环的求解
见下一部分。
2. 图模型运用
本次作业的queryLeastMoments
方法要求无向图中过某个点的最小环。
单源最短路径,首先想到的就是dijkstra
算法,但标准的算法复杂度较高,需要使用堆来优化。
我在Tools
类里设计了一个函数来实现该算法,返回HashMap
,键是personID
,值是出发点到该person
的最短路径长度。要注意的是,当key
等于出发点时,返回的就是环路径的长度。
public static HashMap<Integer, Integer> dijkstra(int id) {
clear();
//initial(id, people);
initial(id);//add
while (!heap.isEmpty()) { //当优先队列不为空时
Edge e = heap.poll();//取出来第一条边
int toId = e.getTo();//获得要去的点的ID
if (via.get(toId)) {
continue;
}
via.put(toId, true);//设为访问过
MyPerson person = people.get(toId);//获得这个人
for (Integer item : person.getAcquaintance().keySet()) { //遍历这个人的所有熟人
int loop;//一圈的长度
//不在一个分支上且分支不为0,说明可以形成一个三角形
if (!Objects.equals(branch.get(item), branch.get(toId)) && branch.get(item) != 0) {
loop = dis.get(toId) +
people.get(toId).queryValue(people.get(item)) + dis.get(item);//三条边之和4;
if (loop < dis.get(id)) {
dis.put(id, loop);//如果这个值小于当前值,表示发现了更小的圈,就替换
}
}
else if (item == id && len.get(toId) >= 2) { //已经走过了至少两个人,回到自身
loop = dis.get(toId) +
people.get(toId).queryValue(people.get(item));//获得一圈的值
if (loop < dis.get(id)) { //若这个值比现有的小
dis.put(id, loop);
}
}
int value = person.queryValue(people.get(item));//获得他们的关系值
//这个熟人没有被访问过且满足三角形条件(待定!)
if (!via.get(item) && dis.get(toId) + value < dis.get(item)) {
dis.put(item, dis.get(toId) + value);//更新距离值
heap.add(new Edge(item, dis.get(item)));//更新优先队列
branch.put(item, branch.get(toId));//让toID的熟人继承toID的分支号(连在同一个岔路上)
len.put(item, len.get(toId) + 1);//这个熟人到起点的长度在toID的基础上加一
}
}
}
return dis;
}
为了更好地表示“下一个节点”的结构,我封装了一个Edge
类,存放一条边的长度和终点ID
:
public class Edge implements Comparable<Edge> {
private final int to;
private final int distance;
public Edge(int to, int distance) {
this.to = to;
this.distance = distance;
}
public int getTo() {
return to;
}
@Override
public int compareTo(Edge o) {
return this.distance - o.distance;
}
}
当然该算法还有很多可以优化的思路,限于时间关系没能仔细探索,也导致了这次强测后果惨烈qwq。
3. 性能分析
本次作业复杂度如下:
可见复杂度主要体现在dijkstra
算法上,这也是我在本次作业的败笔,限于时间压迫没有找到很好的优化算法。
4. bug分析
本次作业在强测中出现了两处bug
,一是dceok
方法测试不足,导致有一个细节没注意到,在代码中没有判断。
二是qlm
算法复杂度过高,出现两处CTLE
,在我的努力下勉强解决了一处,在此感谢朱绍铭学长在讨论区对CTLE
的分析:
我的一个问题是:每一次进入dijkstra
都会新建好几个HashMap
,用过一次之后便不会再访问,而JAVA
的垃圾回收机制会不时清理不被引用和访问的对象,也会占用一定的CPU
资源,导致超时。
所以我把这几个容器的定义都放在了类中,作为类的成员(static
的),可以节省一些CPU
资源。这帮助我苟过了一个点,但另一个点实在是过不去,可能真的是算法问题吧。
5. UML类图
本次作业的UML类图如下:
五、测试相关
1. 测试过程分析
①黑箱测试与白箱测试
黑箱测试即不关系代码具体实现,通过投喂数据、对比输出的方式进行正确性检验;白箱测试即面向代码,进行有针对性的测试。
②对测试的理解
- 单元测试:类似评测,考察对单元要求理解是否充分。
- 功能测试:类似中测的前五个点,仅仅对单一指令功能是否正确进行测试,强度极低。
- 集成测试:类似强测的大部分点,将多种指令综合考察,数据量往往很大,测试范围广。
- 压力测试:类似强测中部分针对性强的点,为某条指令构造极端数据进行测试,对程序的效率和性能提出了很高的要求。
- 回归测试:类似
bug
修复测试,对修改后的代码再评测,对比前后结果。
③测试工具
在第二次作业中使用同学的对拍器和朋友对拍,帮助我发现了很多问题。第三次作业借用同学的评测机做了一些测试,也帮助我发现了程序运行过慢的问题。
④数据构造
在实现过程中遇到问题最多的地方,往往最容易发现问题。因此我针对作业中一些较难实现和维护的方法构造了几组数据,用于针对性测试,但效果并不理想。我也并没有掌握数据构造的思路,只是利用现有的评测机和对拍器做黑箱测试,这也是我目前的主要问题之一。
2. OKTest
本单元的三次作业都要求为某个特定的方法编写OKTest
代码,其目的在于严格用规格检验给定的执行结果是否正确。讨论区何立群同学的分享可谓一阵见血,纠正了我的理解偏差:
讨论: OKTEST:规格、实现方法和验证方法 - 第九次作业 - 2023面向对象设计与构造 | 面向对象设计与构造 (buaa.edu.cn)
在具体实现层面,第九次作业需要比较方法执行前后容器中的数据是否完全相等以及result
是否正确,涉及对HashMap
内容的比较和依照beforeData
和规格计算出“正确结果”,并将该“正确结果”和输入参数比较,很好地考察了对OKTest
的深入理解,我也是因为对后者的理解不够充分而出现问题。
第十次作业要求对具有较多分支的规格书写大规模OK
测试,涉及对是否触发异常的讨论。代码量超大,很容易出现意想不到的问题,经过本次练习,我对OK
测试的理解更进一步。
第十一次作业要求为具有较多细节的JML
书写OK
测试,经过前两次的洗礼,这次我写的很快,但还是由于不够细心而遗漏了一处细节,后续也没有进行充分测试,强测失分。
总之,OKTest
的编写可以看作是平时练习的逆过程,需要我们完成从“根据要求办事”到“检查一件事是否按要求来”的转变,也进一步深化了我对面向对象思想的理解。
六、心得体会
1. 对规格的理解
起初我死板地认为,规格就是要我们去严格地、一字不差地去遵守和实现的。然而在讨论区同学的分享和老师助教的引导下,我渐渐明白,规格仅仅是一种契约,我们只要保证自己的代码符合规格的全部要求就可以,而不必完全按照规格的描述去写。也就是说,具体落实到代码上,我们完全可以选用自己喜欢的方式去实现(当然也要注意效率问题,符合规格不一定能通过评测),正所谓“规格与实现分离”。
2. 学习体会
本单元我学习了由契约式设计而延伸出来的 JML
代码,契约式设计是一种基于信任机制权利义务均衡机制的设计方法学, JML
源自于契约式设计的需要,并掌握了基于 JML
的规格模式及基于 JML
规格的测试与验证,令我受益匪浅。
平心而论,由于本单元不需要我们亲自设计整体架构,面对的挑战性和要克服的问题相较于前两单元较少,也并没有很好地锻炼我们的架构设计和代码迭代能力,对JML
的学习也仅仅停留在阅读层面,还远谈不上掌握。
此外,作业中涉及到的一些图论算法对我这种小白还是有些困难,我还需不断努力,争取在第四单元取得更大的进步。