北航2021年面向对象第三单元作业总结:JML与图 buaa oo unit3
北航2021年面向对象第三单元作业总结:JML与图 buaa oo unit3
1. 本单元介绍
本单元要求实现一个社交关系模拟系统。可以通过各类输入指令来进行数据的增删查改等交互。
本单元要求:
基于规格的层次化设计
• 理解规格的概念
• 掌握方法的规格及其设计方法
• 掌握类的规格及其设计方法
• 掌握抽象层次下类规格之间的关系
• 掌握基于规格的测试方法
本单元自我评价:
- 习得阅读JML规格并正确理解翻译为Java代码的能力
- 入门借助JML规格进行单元测试,了解TDD思想
- 入门编写简单JML规格
- 掌握图的DFS遍历、连通分量计算、单源最短路径的算法实现,关注各种代码实现的性能,特别是时间复杂度
- 进一步深入了解Java的
HashMap
与PriorityQueue
的源码
2. 我对JML的理解
规格:规格是使用规范语言表示方法、类的外部可感知行为的一种东西,它的提出是为了保证代码质量--通过(1)把设计与实现相分离 (2)准确定义一个方法的行为,减少语言定义的二义性(3)以逻辑方式(与或非等的表达形式)来验证代码实现的正确性
JML是一种形式化语言(Java Modeling Language),用来表示规格,具体定义为面向JAVA的行为接口规格语言
可以通过阅读《JML Level 0手册》学习JML。
这样子的说明很抽象,举一个社交网络的例子:
需求文档 vs JML表达
-
需求文档:要求实现一个方法
addRelation(int id1, int id2, int value)
,给两个人增加联系,这个联系有一个value
的属性。要求输入两个人的id与联系的value
值,如果人的id不存在,则抛出personNotFoundException
的异常;如果这两个人已经存在联系,则抛出EqualRelationException
的异常。 -
JML表达:使用JML表达
addRelation(int id1, int id2, int value)
,在逻辑上是非常严谨的,考虑了方法执行后的所有变化与不变,补充了文字表达里省略的东西,比如:添加联系后,person1, person2的其余联系会发生变化吗? -
有什么好处:开发人员可以准确(无二义地)理解
addRelation
的需求,保证代码质量;测试人员在开发人员完成代码前,就清楚((无二义地理解)该方法需要实现的功能,可以提前设计测试用例,准备测试,颇有一种TDD的思想(在测试部分会详细记录我对TDD及基于JML测试的思考)
测试用例:
TestCase1:
ap 0 p0 10
ap 1 p1 10
ar 0 1 5
预期:
OK
OK
OK
且ap, ar生效
TestCase2:
ar 1 1 5
预期:抛出personNotFoundException的异常
TestCase3:
ap 0 p0 10
ap 1 p1 10
ar 0 1 5
ar 0 1 6
预期:前3行命令生效,最后抛出EqualRelationException的异常
// 翻译JML后的代码实现
public void addRelation(int id1, int id2, int value)
throws MyPersonIdNotFoundException, MyEqualRelationException {
if (!contains(id1)) {
throw new MyPersonIdNotFoundException(id1);
} else if (!contains(id2)) {
throw new MyPersonIdNotFoundException(id2);
} else if (getPerson(id1).isLinked(getPerson(id2))) {
throw new MyEqualRelationException(id1, id2);
} else {
MyPerson person1 = people.get(id1);
MyPerson person2 = people.get(id2);
person1.addRelation(id2, person2, value);
person2.addRelation(id1, person1, value);
people.put(id1, person1);
people.put(id2, person2);
}
}
//JML下的addRelation方法规格
/*@ public normal_behavior
@ requires contains(id1) && contains(id2) && !getPerson(id1).isLinked(getPerson(id2));
@ assignable people;
@ ensures people.length == \old(people.length);
@ ensures (\forall int i; 0 <= i && i < \old(people.length);
@ (\exists int j; 0 <= j && j < people.length; people[j] == \old(people[i])));
@ ensures (\forall int i; 0 <= i && i < people.length && \old(people[i].getId()) != id1 &&
@ \old(people[i].getId()) != id2; \not_assigned(people[i]));
@ ensures getPerson(id1).isLinked(getPerson(id2)) && getPerson(id2).isLinked(getPerson(id1));
@ ensures getPerson(id1).queryValue(getPerson(id2)) == value;
@ ensures getPerson(id2).queryValue(getPerson(id1)) == value;
@ ensures (\forall int i; 0 <= i && i < \old(getPerson(id1).acquaintance.length);
@ \old(getPerson(id1).acquaintance[i]) == getPerson(id1).acquaintance[i] &&
@ \old(getPerson(id1).value[i]) == getPerson(id1).value[i]);
@ ensures (\forall int i; 0 <= i && i < \old(getPerson(id2).acquaintance.length);
@ \old(getPerson(id2).acquaintance[i]) == getPerson(id2).acquaintance[i] &&
@ \old(getPerson(id2).value[i]) == getPerson(id2).value[i]);
@ ensures getPerson(id1).value.length == getPerson(id1).acquaintance.length;
@ ensures getPerson(id2).value.length == getPerson(id2).acquaintance.length;
@ ensures \old(getPerson(id1).value.length) == getPerson(id1).acquaintance.length - 1;
@ ensures \old(getPerson(id2).value.length) == getPerson(id2).acquaintance.length - 1;
@ also
@ public exceptional_behavior
@ assignable \nothing;
@ requires !contains(id1) || !contains(id2) || getPerson(id1).isLinked(getPerson(id2));
@ signals (PersonIdNotFoundException e) !contains(id1);
@ signals (PersonIdNotFoundException e) contains(id1) && !contains(id2);
@ signals (EqualRelationException e) contains(id1) && contains(id2) &&
@ getPerson(id1).isLinked(getPerson(id2));
@*/
public void addRelation(int id1, int id2, int value) throws
PersonIdNotFoundException, EqualRelationException;
3. 单元设计策略与架构
本节回答博客作业要求的(1)(5)
(1)总结分析自己实现规格所采取的设计策略
(5)梳理自己的作业架构设计,特别是图模型构建与维护策略
3.1 图模型构建与维护策略
3.1.1 图论基础
本单元的图(社交网络),有以下几个特点:
- 无向图
- 图的变动有以下规律:
- 允许往图里加入点和边;
- 不允许删除点和边
- 不允许改变边的权值
3.1.2 设计什么类
主要的类为MyPerson
, MyGroup
,MyNetwork
,对应图论的点Node, 图Graph, 子图Subgraph的概念,根据JML规格而设计的
衍生的概念有:
relation | 无向边 |
---|---|
两人之间send indirect message,需要找到最短路 | 单源最短路问题 |
两人isLinked() |
点与点直接相连 |
两人isCircle() |
点与点之间的连通性 |
block | 连通分量 |
辅助(算法实现)的类有Components
,DijkstraSP
, TwoNodeSP
,用以实现判断点连通、计算连通分量数及计算两点间的最短路距离的功能,这是为了更高效地实现业务功能而设计的类
剩余的Emoji
和MyMessage
类是为了实现社交网络的业务功能--发送信息等而设的
3.1.3 图的存储与维护
- 存储
图的存储使用的是经典的邻接表结构
选择HashMap
来存储点的边集,是为了查找与添加时O(1)复杂度的方便
public class MyPerson {
private int id;
private HashMap<Integer, MyPerson> acquaintance; // 熟人集合 <id, Myperson>
private HashMap<Integer, Integer> value; //边权集合 <id, value>
}
public class MyNetwork {
private HashMap<Integer, MyPerson> people;
}
-
维护
概括起来,业务上的各种指令操作都是对图结构的增删查改
MyPerson
设计要点:利用
HashMap
结构存储点的邻接链表(查找和增加都是O(1)), 利用LinkedList
存储查收消息(为了满足接收时间由新到旧,消息从头到尾的存储要求)作业分析:
private int id; //独一无二的 id,可以作为key private String name; //姓名 private int age; //年龄 private int socialValue; private HashMap<Integer, MyPerson> acquaintance; // 熟人集合 <id, Myperson> private HashMap<Integer, Integer> value; //边权集合 <id, value> private LinkedList<Message> messagesQueue; //sendMessage to me, then my addFirst
-
有什么功能:
常规的查找:
getId()
,getName()
,getAge()
,getAcquaintance()
,equals(Object obj)
,int compareTo(Person p2)
,List<Message> getMessages()
,getSocialValue()
名字排序对类内的主要数据结构
HashMap<Integer, MyPerson> acquaintance
,HashMap<Integer, Integer> value
,LinkedList<Message> messagesQueue
的增删查改的维护,功能是熟人管理与收发消息管理 -
增:
void addRelation(int id, MyPerson person, int edgeValue)
(不属于接口方法) -
删 -- 不存在删除relation,删除熟人(断交),撤销message的操作
-
查
boolean isLinked(Person person)
,int queryValue(Person person)
查熟人relation的边权 ,List<Message> getReceivedMessages()
-
改
void addSocialValue(int num)
,void addMessage(Message newMessage)
,void addMoney(int num)
MyNetwork
设计要点:qbs, sim, ic, qgvs等方法
作业分析:
描述整个社交网络的情况,1个程序只会有构造一个Network
指令直接调用的是
MyNetwork
的方法。private HashMap<Integer, MyPerson> people; private HashMap<Integer, MyGroup> groups; private HashMap<Integer, MyMessage> messages; //<messageId, myMessage> private HashMap<Integer, Emoji> emojiMap; //<emojiId, Emoji> private static int componentChangeCounter; //缓存以提升性能 private static Components components; //缓存以提升性能 private static int dijkChangeCounter; //缓存以提升性能 private HashMap<Integer, DijkstraSP> dijkstraMap; //<sourceId, dijkstra> //缓存以提升性能 private static HashMap<NodePair, TwoNodeSP> shortestPathDisMap; //缓存以提升性能
有什么功能:
-
对people的增删查改
- 增:
void addPerson(Person person)
,void addRelation(int id1, int id2, int value)
- 删:不可删除
- 查:
contains()
,getPerson()
,int queryValue()
,int compareName()
,int queryPeopleSum()
,int queryNameRank()
,boolean isCircle()
,int queryBlockSum()
,querySocialValue()
,queryMoney()
- 改:在别的业务中可能会修改people's socialValue和 money
- 增:
-
对group的增删查改
- 增:
void addGroup(Group group)
,void addToGroup(int id1, int id2)
, - 删:
void delFromGroup(int id1, int id2)
- 查:
Group getGroup(int id)
,int queryGroupSum()
,int queryGroupPeopleSum(int id)
,int queryGroupValueSum(int id)
,int queryGroupAgeMean(int id)
,int queryGroupAgeVar(int id)
- 改:没有
- 增:
-
对message的增删查改
- 增:
void addMessage(Message message)
- 删:不存在直接删除的指令,但sim of sm时都会remove corresponding message
- 查:
boolean containsMessage()
,Message getMessage()
,List<Message> queryReceivedMessages()
- 改:
void sendMessage(int id)
,void sendIndirectMessage(int id)
- 增:
-
对Emoji的管理
- 增:
void storeEmojiId()
- 删:
int deleteColdEmoji()
- 查:
boolean containsEmojiId()
,int queryPopularity()
- 改:emoji的热度值会在send corresponding emoji Message时变化,不可直接修改热度值
- 增:
MyGroup
private int id; private HashMap<Integer, MyPerson> people; // <id, person>
设计要点:子图
重点为子图特征的高效计算,如qgvs
- 有什么功能:
常规的:
getId()
,getPeople()
,boolean equals(Object obj)
对类内的主要数据结构
HashMap<Integer, MyPerson> people
的-
增
addPerson(Person person)
、 -
删
void delPerson(Person person)
、void delPersonWithId(int personId)
(不属于接口方法) -
查
boolean hasPerson(Person person)
int getValueSum()
int getAgeMean()
int getAgeVar()
、int getSize()
-
改
void addSocialValueToAllPeople(int messageSocialValue)
(不属于接口方法)
MyMessage
private int id; // unique private int socialValue; //消息的社交值 private int type; //消息的种类,有 0 和 1 两个取值 private MyPerson person1; //sender private MyPerson person2; //receiver private MyGroup group; //消息的接收组
无设计要点 -- 业务功能类
有什么功能:
常规的查找:
getType()
,getId()
,getSocialValue()
,getPerson1()
,getPerson2()
,getGroup()
,equals(Object obj)
-
3.1.4 我的自学材料
我这个半桶子CS学生的自学材料
图论概念:点、边、图、树、连通等等(看了看B站的离散数学视频)
Princeton University经典算法课 Algorithms-part2
DFS : https://www.bilibili.com/video/BV1oK4y1V721?p=4
Component连通分量:https://www.bilibili.com/video/BV1oK4y1V721?p=6
单源最短路Dijkstra算法:https://www.bilibili.com/video/BV1oK4y1V721?p=21
4. 基于JML规格来设计测试的方法和策略
在测试的学习和实践上,我了解了TDD思想,入门了Junit的使用,并借助JML规格进行单元测试的设计
4.1 TDD思想与Junit
-
Developers write unit tests (NOT testers) and then code
虽然我不清楚敏捷开发下每个sprint的需求分析阶段,会不会要求output一套JML规格。但若有了一份规格,开发和测试就可以同时开启工作
-
Junit单元测试
"Make it green, then make it clean! "
坦诚来讲,独自开发与测试,4天一个迭代的紧张排期,采取单元测试覆盖每一个方法,并且参考JML的后置条件(
ensures
)检查所有发生变动的变量(assignable
),不太实际,所以在中测阶段,我采取重点方法的自测+构造极端测试用例检查性能的方案。但同时,也学习了Junit单元测试,run了一个demo,把基本的学习了。测试效果:不理想,都是强测的testcase帮我修复代码。3次作业一共有12个bug(太烂了)
4.2 强测bug情况统计
感谢强测对性能的“严格”要求,让我关注了算法时间复杂度与Java各种数据结构的底层实现逻辑与效率,具体的分析放在“容器分析”与“性能分析”两章
情况 | review | 发生次数 |
---|---|---|
代码复制粘贴后忘了修改变量名 | 多加弱测即可发现 | 2 |
qgvs超时 | 1、O(N^2) 2、增加缓存 3、给group 增加一个valueSum 的属性并实时维护 |
3 |
sim单源最短路算法超时 | 1、算法实现底层有小bug 2、增加缓存 | 2 |
qbs超时 | MyNetwork 增加一个Component 类的缓存变量 |
2 |
大整数相减造成int 越界 |
1、使用BigInteger (有意识但没有关注输入变量的数量级限制) |
4 |
5. 总结分析容器选择和使用的经验
-
HashMap底层
https://juejin.cn/post/6844903744711163911
hashMap可以理解为元素是链表的一个数组,相同hash value的元素都会链接在同一个链表中(hash value就是其所在的数组地址),当相同hash value的元素过多时,链表会衍生为红黑树以提升查询效率。
那hash value受什么影响呢?看源码:先得到key.hashCode()
那hashCode()怎么计算呢?不同的数据类型的hashCode()计算的方法不一样
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
ArrayList
, LinkedList
, HashMap
的的增删查改操作的时间复杂度
class Person, Group, Message, Emoji
都是有unique primary key 的id值,所以采用HashMap存储最好
Person
需要存储接受消息,并总是要返回最晚接收的前4条。因此用LinkedList比较好。
操作/类 | ArrayList | LinkedList | HashMap |
---|---|---|---|
add(E) | 直接尾部添加,O(1) | 直接尾部添加,O(1) | put(), 计算hashvalue寻址后直接添加,O(1) |
add(index, E) | 查询后添加,查询O(1);插入后该位置后面所有元素要后移,O(N) | 查询后添加,查询时间为O(N);插入为O(1) | 不存在 |
remove(index) | 查询O(1);删除后该位置后面所有元素要前移,O(N) | 查询后删除,查询时间为O(N);删除为O(1) | |
remove(object) | 查询遍历O(N),删除O(N) | O(N) 虽然链表删除操作是O(1)的操作,但实际的删除要先遍历查询O(N) | O(N),同左 |
get(index) | 查询时间复杂度O(1),但很少情况下是已知下标来查询的 | 循环遍历,时间复杂度O(N) | O(1) |
根据内容查找 | indexOf(),O(N) | 从头或者尾遍历,时间复杂度O(N) | containsKey() 或者 get(),O(1) |
修改 | 已知index则为O(1),已知对象则为O(N) | O(N) | 根据key修改O(1) |
-
为什么要重写equals与hashcode
一个类的对象要遵循下面4点
1、两个对象相等,则hashcode必然相等
2、两个对象不等,hashcode有可能相等
3、两个对象hashcode相等,不一定相等
4、两个对象hashcode不等,则一定不相等。
重写了equals方法后,如果不重写hashcode,则可能出现两个对象相等但hashcode不等。这就会导致,HashMap里面本来有这个key,但是你告诉我没有,导致了put操作成功
HashMap.put()在插入时判断: if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
-
自学链接记录
没找到系统的介绍源码的博客或者电子书,只能google查
https://juejin.cn/post/6844903505996546055 Arraylist
https://juejin.cn/post/6844903550129012743 Arraylist
https://blog.csdn.net/tuke_tuke/article/details/51588156 HashMap
http://blog.itpub.net/69973926/viewspace-2691598/ HashMap
https://blog.csdn.net/m0_37602827/article/details/100172976 优先队列的最小堆结构。这一篇足以
6. 重点方法的提升性能策略总结
"规格毕竟给的是抽象的、数学的、不考虑实现效率的方法。真正实现的方案有很多种,有好有坏,还可以增加规格没有提供的中间变量、类、方法等,来提升性能“
6.1 query_group_value_sum的性能提升
qgvs超时的问题在我的第二次和第三次作业中都出现了。
我的原始最低效的设计是直接翻译JML规格给的方法,来了一个O(N*N)的双重遍历:
public int getValueSum() {
int valueSum = 0;
Iterator<Map.Entry<Integer, MyPerson>> entries1 = people.entrySet().iterator();
while (entries1.hasNext()) {
Map.Entry<Integer, MyPerson> entry1 = entries1.next();
MyPerson person1 = entry1.getValue();
Iterator<Map.Entry<Integer, MyPerson>> entries2 = people.entrySet().iterator();
while (entries2.hasNext()) {
Map.Entry<Integer, MyPerson> entry2 = entries2.next();
MyPerson person2 = entry2.getValue();
if (person1.isLinked(person2)) {
valueSum += person1.queryValue(person2);
}
}
}
return valueSum;
}
我的初版解决方案(解决了2nd但没解决3rd)是,每次qgvs计算了valueSum后,缓存下valueSum。若下一次对同一个group进行qgvs,判断这个group的结构是否有变化(有无atg, ar, dfg),如果没有变化则直接读取缓存的valueSum
输出,有变化则重新计算。
这种解决方法在group的结构变化频繁时会失效——需要频繁的O(N*N)
的时间复杂度计算,这也是导致本次作业在strongTest4 超时的原因。
我的改动如下:group新增一个属性valueSum
, 每次对group改动时(atg, ar, dfg),则更新valueSum
的值。如此,每次update Value Sum
都是线性的时间复杂度,查询更是O(1)
的复杂度,可以解决超时的问题。
举一个atg
更新的例子:
void addPerson(Person person) {
for (熟人 in person.熟人集) {
if (熟人也在这个group) {
valueSum += 邻边.边权;
}
}
}
6.2 query_block_sum的性能提升
我的原始设计:计算连通块时,每一次query_block_sum
或者is_Circle()
时,都会重新DFS遍历整个network
,重新计算network
的连通块情况。即使我的dfs
是O(E+V)
的复杂度,qbs
太多次也会超时。
于是,我在class Network
设置了一个Component
连通块的缓存,每次要qbs
或者ic
时,都先判断Component
自上一次计算以来有无变化,包括:
-
有无add person
-
有无add relation
如果没有变化,则直接读缓存值,不用重新计算
6.3 send_indirect_message的性能提升
sim超时的问题,也就是单源最短路(Dijkstra)算法优化的问题。
不用优先队列优化:\(𝑶(|𝑽|^𝟐)\);使用优先队列优化:\(𝑶(|𝑬| ⋅ 𝐥𝐨𝐠|𝑽|)\)
体会:通过这个bug,让我更理解Java 堆相关数据结构底层增删查改的实现与性能,Awesome!
我的单源最短路算法,在松弛操作更新优先队列时出现了一些问题。
我利用优先队列结构存储每个结点的暂时的距离源点的最短路径值Pair {int nodeNo; int distence}
优先队列——最小堆的底层操作效率分析:
pq.contains(Object o)
是一个O(N),通过遍历整个堆的操作;pq.remove(Object o)
是一个O(N+logN)
的操作:先遍历查找,再删除Object o
,最后还要维护堆结构。改bug后,删掉pq.contains()
直接pq.remove()
,以维护松弛操作后的pq
,减少时间复杂度。
改动前
PriorityQueue<Pair> pq;
if (!pq.contains(oldPair)) {
pq.add(newPair);
} else {
pq.remove(oldPair);
pq.add(newPair);
}
-------------------------------
改动后
pq.remove(oldPairAdj); // if not contains, then remove nothing
pq.add(newPairAdj);
同时,为了remove()
的正确实现,我重写了Pair.equals()
(查看源码就会发现remove() --> indexOf() --> equals()
)。不重写,代码会有bug: remove不掉,每次都直接add,导致pq非常大,正确性更不可知)
Class Pair {
private int personId;
private int distTo;
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
} else if (getClass() != o.getClass()) {
return false;
}
Pair another = (Pair) o;
if (another.getPersonId() == personId) {
return true;
} else {
return false;
}
}
}
6.4 考虑容器底层原理的性能提升
在HashMap
里根据key
查找
HashMap<key, value> hashmap;
if (!hashmap.containsKey(key)) {
hashmap.put(key, object);
} else {
throw new Exception();
}
vs
HashMap<key, value> hashmap;
if (hashmap.get(key) == null) {
hashmap.put(key, object);
} else {
throw new Exception();
}
HashMap.containsKey()
底层和HashMap.get()
都是借助getNode()
实现的。当提供一个键值key
,让你查找map里是否已经插入该键值的元素的时候,要选用哪种方法,效率比较?
- 当值可以为null时,务必使用
containsKey()
。 - 当值不会为null时,两者都可行,前者2次查找,后者1次查找。虽然每次查找都是近乎O(1)的,但当查找操作/需求特别多的时候,后者快1倍的效率还是很诱人的。