2021 OO 第三单元总结博客
2021 OO 第三单元总结博客
写在前面
JML单元总体是个比之前单元压力轻的单元,我只愿称之为不怕WA,就怕慢的单元。
在总结之前,先重温一下本单元的要求。我们需要实现一个社交关系模拟系统。可以通过各类输入指令来进行数据的增删查改等交互。
一 实现规格所采取的设计策略
面对JML规格,我采取的方式是先把代码中所有文件的规格通读一遍,并做一些简单的注释,将一些比较绕的规格翻译为自然语言,思考一下本类中想用的容器,再真正开始动手写代码。而写代码的时候,先实现异常类再实现正常情况的分支。
为了直观呈现设计过程,我们选取addMessage函数为例进行说明。首先在规格上补充一些中文注释,说明自己对JML的解读,把复杂的JML规格翻译为自然语言:
/*@ public normal_behavior 这里是正常的行为
@ requires !(\exists int i; 0 <= i && i < messages.length; messages[i].equals(message)) &&
@ (message instanceof EmojiMessage) ==> containsEmojiId(((EmojiMessage) message).getEmojiId()) &&
@ (message.getType() == 0) ==> (message.getPerson1() != message.getPerson2());
@ assignable messages;
@ ensures messages.length == \old(messages.length) + 1;
@ ensures (\forall int i; 0 <= i && i < \old(messages.length);
@ (\exists int j; 0 <= j && j < messages.length; messages[j] == (\old(messages[i]))));
@ ensures (\exists int i; 0 <= i && i < messages.length; messages[i] == message);
@ 这一大段描述的等价于把这个Message加入messages
@ also
@ public exceptional_behavior 从这里开始处理异常
@ signals (EqualMessageIdException e) (\exists int i; 0 <= i && i < messages.length;
@ messages[i].equals(message));
@ 如果messages里已经有这个id了,就抛出MyEqualMessageIdException的异常,注意id是重的这个Message的id
@ signals (EmojiIdNotFoundException e) !(\exists int i; 0 <= i && i < messages.length;
@ messages[i].equals(message)) &&
@ (message instanceof EmojiMessage) &&
@ !containsEmojiId(((EmojiMessage) message).getEmojiId());
@ 如果这个Message是EmojiMessage,但它的EmojiId没有在EmojiId的库里,就抛出MyEmojiIdNotFoundException,注意异常类的id是EmojiId
@ signals (EqualPersonIdException e) !(\exists int i; 0 <= i && i < messages.length;
@ messages[i].equals(message)) &&
@ ((message instanceof EmojiMessage) ==>
@ containsEmojiId(((EmojiMessage) message).getEmojiId())) &&
@ message.getType() == 0 && message.getPerson1() == message.getPerson2();
@ 如果这个Message的类型是0,但它包含的两个Person的id又是一样的,就抛出MyEqualPersonIdException,注意异常类的id是重复的这个Person的id
@*/
public void addMessage(Message message) throws
EqualMessageIdException, EmojiIdNotFoundException, EqualPersonIdException;
在代码中将其实现时,我觉得先实现异常类,再实现正常逻辑类比较清晰:
@Override
public void addMessage(Message message) throws EqualMessageIdException,
EmojiIdNotFoundException, EqualPersonIdException {
int messageId = message.getId();
//先实现各个异常类
if (this.messages.containsKey(messageId)) {
throw new MyEqualMessageIdException(messageId);
} else if (message instanceof EmojiMessage &&
!containsEmojiId(((EmojiMessage) message).getEmojiId())) {
throw new MyEmojiIdNotFoundException(((EmojiMessage) message).getEmojiId());
} else if (message.getType() == 0 && message.getPerson1().equals(message.getPerson2())) {
throw new MyEqualPersonIdException(message.getPerson1().getId());
} else {
//最后实现正常行为
this.messages.put(messageId, message);
}
}
二 基于JML规格的测试策略
我们可以采用白盒测试和黑盒测试两种测试策略。其中,白盒测试指根据逻辑验证代码,我们可以编写JUnit单元测试来验证自己的程序程序,课程组也为我们提供了相关教程;黑盒测试是靠跑大量数据来测试程序,主要可以依靠和同学们一起对拍实现。
2.1 白盒测试
测试时可以用到OpenJML、JMLUnitNG、JUnit4等工具。OpenJML可以对代码中的规格进行检测,这个工具链的环境配置相当磨人。JMLUnitNG可根据JML语言自动生成测试。JUnit是一个单元测试框架,继承TestCase类后可以进行自动测试。
使用JUnit时,相应的测试类需要我们自己编写。在每个@Test调用前会执行@Before,如:
@org.junit.Before
public void setUp() throws Exception {
System.out.println("Test begin.");
}
每个@Test调用之后,会执行@After方法,如:
@org.junit.After
public void tearDown() throws Exception {
System.out.println("Test end.");
}
我们可以编写自己的@Test函数对类进行测试:
@org.junit.Test
public void testIsCircle {
MyPerson m0 = new MyPerson(121, "mjy", 90);
MyPerson m1 = new MyPerson(132, "OO", 67);
MyNetwork network = new MyNetwork();
try {
network.addPerson(m0);
network.addPerson(m1);
} catch (EqualPersonIdException e) {
e.print();
}
try {
network.addRelation(121, 132, 9);
} catch (PersonIdNotFoundException e) {
e.print();
} catch (EqualRelationException e) {
e.print();
}
try {
network.isCircle(121, 132);
} catch (PersonIdNotFoundException e) {
e.printStackTrace();
}
assertEquals(network.queryBlockSum(), 4);
}
利用JUnit工具可以编写出比较完备的测试数据,但这对思维逻辑的要求也很高,看起来和自己对着JML人工通读代码的成本类似。
2.2 黑盒测试
黑盒测试是基于数据的测试。这方面主要依靠与同学们一起对拍完成,在对拍的过程中的确发现了很多无脑bug和笔误bug,不禁令人感叹细心太重要了。
对拍时选择横向对比大家的输出,不一样的人就...(危)。测试时分为随机测试(正确性样例)、压力测试(主要针对可能会TLE的指令,代表为第九次作业中的isCircle和quaryBlockValue;第十次作业中的quaryValueSum,getVar方法;第十一次作业的sendIndirectMessage方法等)、异常测试。真的不仅对出了笔误和粗心问题,还对出了TLE的问题......
我理解的黑盒测试中,我们主要依靠量变引起质变,进而提高测试的有效性。
三 容器的选择和使用
3.1 合理选择容器
合理选择容器意思是,不一定完全按照JML,也并不是有几个instance就该有几个容器,比如:
@ public instance model non_null int[] emojiIdList;
@ public instance model non_null int[] emojiHeatList;
而且后面deleteHotMessagr时要求保证emojiIdList、emojiHeatList畅度相同,出现这种规格时,我们可以直接用一个HashMap实现,即:
private final HashMap<Integer, Integer> emojiList;
3.2 尝试新容器
为在第三次作业中实现堆优化,本次作业是我第一次使用优先队列PriorityQueue。为了向容器传递我们排序的意愿,我新建了一个节点类,并重写了compareTo方法:
public class MyVector implements Comparable {
private int personId; //人员id
private int dis; //当前节点到初始节点的距离
public MyVector(int id, int dis) {
this.personId = id;
this.dis = dis;
}
//重写compareTo方法
@Override
public int compareTo(Object o) {
return this.dis - ((MyVector)o).getDis(); //对距离进行比较
}
}
同时,也使用了一些优先队列自带的方法
isEmpty:判断当前队列是否为空add():将元素放入队列(java已经将排序的实现都封装好了)poll():返回队首元素,并将其在队列中删除
3.3 注意容器间方法的差异
这几次作业我都出现了性能问题,究其原因是对ArrayList和HashMap的乱用,这次写遍历中都有contains,但时间复杂度却相差甚远。
HashMap.containsKey()是O(1)方法HashMap.containsValue()是O(n)方法ArrayList.contains()是O(n)方法
所以存放需要多次遍历的属性时,尽量让它成为HashMap的key值
四 性能问题分析
这一单元里,我们在写代码时要格外注意,不能允许任何一处出现O(n^2)及以上复杂度的方法,而这三次作业中的每一次都有需要注意的地方。
4.1 第九次作业
第九次作业的Network类中有两个方法需要我们格外注意,即isCircle和quaryBlockSum。理论上dfs倒也不会超时,但交上前还是把我的憨批dfs改成了路径压缩并查集。
我们需要在MyNetwork类中新建一个属性,用以存放一个Person自己的id和与他连通的父亲的id:
private final HashMap<Integer, Integer> disjointset;
在addPerson方法先令父亲的id和自己的id相同
this.disjointset.put(personId, personId);
在addRelation方法中实现路径压缩,使同一个连通块中的Person的父节点是同一个id
int rootForId1 = this.getRoot(this.disjointset, id1);
int rootForId2 = this.getRoot(this.disjointset, id2);
this.disjointset.put(rootForId2, rootForId1);
其中,getRoot方法通过递归不断向上寻找。
public int getRoot(HashMap<Integer, Integer> disjointset, int id) {
int parent = disjointset.get(id);
if (parent != id) {
int root = this.getRoot(disjointset, parent);
disjointset.put(id, root);
//不要把上两句省略为disjointset.put(id, this.getRoot(disjointset, parent));
}
return disjointset.get(id);
}
实现如上维护后,isCircle和queryBlockSum都会变得非常简单:
isCircle方法中仅需要比较传入的两个id的父类是否相等queryBlockSum方法只需要搜索自己的父亲和自己的id完全一致的Person的个数
4.2 第十次作业
本次作业复杂度较高的地方可能为对group若干信息的查询,如valueSum、var等。我们采用在addPersonToGroup时对其O(1)或O(n)维护,而在真正的查询方法中直接O(1)查询。
首先在MyGroup类中新建需要查询的属性:
private int ageSum; //记录年龄总和,除以size以得到方差
private int ageMean; //年龄平均值
private int ageVar; //年龄方差
private int valueSum; //权值和
在addPerson方法中对上述属性进行维护:
@Override
public void addPerson(Person person) {
int personId = person.getId();
int age = person.getAge();
people.put(personId, person);
int size = people.size();
this.ageSum = this.ageSum + age; //维护年龄和
this.ageMean = this.ageSum / size; //计算得到方差,无需考虑size为0的情况
this.ageVar = 0; //重新计算方差和权值
for (Integer id : people.keySet()) {
this.ageVar = this.ageVar + (people.get(id).getAge() - this.ageMean) *
(people.get(id).getAge() - this.ageMean);
if (people.get(id).isLinked(person)) {
this.valueSum = this.valueSum + 2 * (people.get(id).queryValue(person));
}
}
this.ageVar = this.ageVar / size; //得到方差
}
在delPerson中反向做同样的事情,在次就不多贴一次代码了。
值得注意的是,添加关系也可能影响组里valueSum的值,因此需要在addRelation方法中对groups进行遍历:
for (Group group : groups.values()) {
((MyGroup) group).updateValueSum(person1, person2, value);
}
并在MyGroup中添加updateValueSum方法:
public void updateValueSum(Person person1, Person person2, Integer value) {
if (people.containsKey(person1.getId()) && people.containsKey(person2.getId())) {
this.valueSum = this.valueSum + 2 * value;
}
}
其实,计算方差还有复杂度更低的方法,就是再维护一个age2和的属性,利用方差与期望的关系公式:方差等于平方的期望减期望的平方。但存在一个问题,就是维护age2和时会爆int。但实际上,计算机的加减乘除都是通过二进制得到的,似乎原则上只要结果没有超过int的范围,在计算过程中爆int也是不会出错的(但我胆小还是牺牲一丢性能没有维护age2和,而是每次都重算方差)
4.3 第十一次作业
这次作业中sendIndirectMessage需要返回两个Person的最短路径,可使用Djsktra的堆优化算法将复杂度从O(n2)降低至O(nlogn),堆优化主要依靠优先队列实现,在博客的3.2部分已经进行了分析。
五 作业架构
感觉几次作业的架构大同小异,课程组已经给了非常完备的设计,我们只需要补充上一点细节,而补上的细节基本也对应着第四节性能分析的部分,因此这部分会说得比较简略。
第九次作业
主要依靠并查集路径压缩,实现isCircle和queryValueSum方法
第十次作业
主要使用前期维护,直接查找的形式
- 在
addPersonToGroup和delPersonFromGroup时维护ageSum、ageMean和ageVar属性 - 在
addPersonToGroup、delPersonFromGroup和addRelation时维护valueSum属性
第十一次作业
新建节点类MyVector,利用PriorityQueue实现Dijkstra的堆优化,查找最短路径,降低程序复杂度。
写在最后
没有性能分令人非常开心。这一单元最治疗我低血压的事突然变成了和同学对拍,bug突然就冒出来了啊hhh(衷心衷心感谢各位一起对拍的hxd!)。总体感觉,这一单元需要人非常细心,非常踏实,不然容易发生一失足成千古恨的事情。一句一句地阅读规格之后再把他们变成自己的代码,也真的会让人的心平静下来。

浙公网安备 33010602011771号