面向对象第三单元总结
北航OO第三单元(JML)总结
本单元的整体任务是学习阅读JML规格,达成契约式编程,最终根据接口的JML规格实现一个多人聊天系统的核心类。
一、测试数据的准备
这一单元的测试就是对于几个指令的测试,对于一些比较简单的方法例如查找总人数,就几乎不可能出错。所以测试数据主要聚焦在为了复杂度而做过优化的方法和相对复杂的方法。三次作业中主要有:queryGroupValueSum、queryBlockSum、queryLeastConnection和sendIndirectMessage。
第一个的生成方法是:随机加入person,随机创建组并将现有person加入组内,对每个组查询,三者交替生成。后三个的生成方法是:在数据生成器维护一个图和并查集,首先创建很多person作为节点,之后随机地将person连接,边数不超过节点数的2倍。之后询问块数和最小生成树,以及对在一个并查集内的person询问最短路。
在评测方面就是采用对拍,跑自己的和别人的程序,并检测输出是否一致。
二、架构设计和容器的选择
本次作业的主要架构就是Network的图结构。由于Person中本来就需要维护与他有关系的Person,所以形成了一个天然的邻接表结构:结点Person直接存储在Network中,邻接表作为Person的一个属性,可以由get方法获得。
容器选择
由于几乎每个方法都需要快速查找到名字对应的Person、Group,并且保证了不重名。所以使用name作为key的HashMap是最好的数据结构,可以在O(1)时间查找到对应的对象。此外,Person类获取Message是获取最后的Message,所以最好的方法是让存储Message的List每次添加的时候头插,最后选出前四个即可。由此receivedMessage最好使用头插复杂度最低的链表也就是LinkedList结构。
异常处理
异常处理因为需要统一的统计每个异常的触发次数,我使用单例模式构建了Counter类,每当异常类进入构造函数,就会调用Counter的方法统计,并且返回print方法应该输出的字符串加以存储。
算法设计
根据指令条数和时间限制,在本次作业中,只要复杂度小于\(O(n^2)\)就能避免TLE,但是也有一部分方法如果完全根据JML来写会导致超时,这里主要介绍那一部分方法。
queryGroupAgeVar
这个是遇到的第一个需要优化的指令,也是最好优化的一个。如果根据JML每次都计算一次平均age就会导致复杂度到达\(O(n^2)\)超时。解决方法也有很多:维护一个总年龄、在进入方法的时候计算一次平均年龄之后存储、多次使用等等。这个方法很简单,但主要在第一次作业不容易意识到。
queryGroupValueSum
这个指令因为只查找组内的人之间构成的关系,不能在Person内维护。考虑按照JML规格需要进行二重遍历导致复杂度过高,选择了均摊复杂度的方式,在Group内维护一个valueSum。这个值初始化为0并在每次group添加人(addPerson)或删除人(deletePerson)时更新。也就是将\(O(n^2)\)复杂度的指令,均摊到了添加n个人,每个人\(O(n)\)上,降低了最差情况的复杂度(先添加很多人之后疯狂询问)。
isCircle与queryBlockSum
和教程一致,这里采用了循环形式的路径压缩的并查集进行处理。循环是为了避免链状数据的爆栈可能性,路径压缩是为了防止在特殊数据退化成链最终达到\(O(n^2)\)的最差情况。
queryLeastConnection
采用了堆优化的prim算法,就是维护了一个PriorityQueue,每次遍历一个节点的时候将他的所有相邻节点压进堆中。同时需要一个堆元素,重写compare方法,提供给优先队列使用。这里是将边权值和结点一起传入,利用边权值进行排序。
int ans = 0;
PriorityQueue<HeapElement> queue = new PriorityQueue<>();
HashSet<Person> alreadyAdd = new HashSet<>();
MyPerson firstPerson = (MyPerson) people.get(id);
alreadyAdd.add(firstPerson);
HashMap<Person, Integer> tempAcquaintance = firstPerson.getAcquaintance();
for (Map.Entry<Person, Integer> entry : tempAcquaintance.entrySet()) {
MyPerson tempMyPerson = (MyPerson) entry.getKey();
HeapElement heapElement = new HeapElement(tempMyPerson, entry.getValue());
queue.add(heapElement);
}
while (!queue.isEmpty()) {
HeapElement heapElement = queue.poll();
MyPerson nowMyPerson = heapElement.getPerson();
if (alreadyAdd.contains(nowMyPerson)) {
continue;
}
alreadyAdd.add(nowMyPerson);
ans += heapElement.getDistance();
tempAcquaintance = nowMyPerson.getAcquaintance();
for (Map.Entry<Person, Integer> entry : tempAcquaintance.entrySet()) {
MyPerson tempMyPerson = (MyPerson) entry.getKey();
heapElement = new HeapElement(tempMyPerson, entry.getValue());
queue.add(heapElement);
}
}
return ans;
sendIndirectMessage
采用了堆优化的dijkstra算法,堆元素和上述构建类似,就是把传入的权值换成了当前距离。同时因为如果一个结点出现在优先队列的第一位,那么他一定被找到了最短路。由此实现了中途停止,只要发现当前poll获得的结点是目标结点,则直接break并将当前路径长度作为最终长度。
PriorityQueue<HeapElement> nearestQueue = new PriorityQueue<>();
HashMap<Person, Integer> distance = new HashMap<>();
nearestQueue.add(new HeapElement(person1, 0));
HashSet<MyPerson> already = new HashSet<>();
int nowDistance = 0;
while (!nearestQueue.isEmpty()) {
HeapElement nearest = nearestQueue.poll();
nowDistance = nearest.getDistance();
MyPerson nearestPerson = nearest.getPerson();
if (nearestPerson.getId() == person2.getId()) {
break;
}
if (already.contains(nearestPerson)) {
continue;
}
already.add(nearestPerson);
HashMap<Person, Integer> acquaintance = nearestPerson.getAcquaintance();
for (Map.Entry<Person, Integer> entry : acquaintance.entrySet()) {
MyPerson nowPerson = (MyPerson) entry.getKey();
int updateDistance = nowDistance + entry.getValue();
if (!distance.containsKey(nowPerson)) {
distance.put(nowPerson, updateDistance);
nearestQueue.add(new HeapElement(nowPerson, updateDistance));
} else if (distance.get(nowPerson) > updateDistance) {
distance.put(nowPerson, updateDistance);
nearestQueue.add(new HeapElement(nowPerson, updateDistance));
}
}
}
sendMessage0(message);
return nowDistance;
清除特定Message相关指令
加强for中不能删除,而如果使用二重循环删除就会导致超复杂度。这里有很多种方法解决,ArrayList的removeIf方法、使用迭代器规避for等等。我是用的方法是放弃List或Map的final属性,新建一个,同时随着遍历将不符合删除条件的内容按顺序放入新容器,最终改变容器的引用。
三、bug与hack
在三次强测和互测中,我均没有被发现bug.
课下测试中,在hw10找到了bug:queryGroupValueSum是查找组内关系的和,我一开始理解成了查找组内的人每人所有关系的和的和。并在这时提出了上述均摊复杂度的方法。在hw11被中测测出了MyEmojiIdNotFoundException传入参数错误的问题(这个确实太容易错了也)
在互测中我仅有hw10成功hack了4个人每人一次,他们的错误非常一致,就是在hw9中的queryGroupValueSum采用了和JML规格一样的二重遍历写法。
四、Network扩展
需要增加/修改的类和方法
首先Advertiser、Producer、Customer都是Person的子类,要扩展对应的构造函数。
Advertiser需要有向外发送广告给所有消费者的方法。
Producer需要与Advertiser相互关联,有对应的Advertiser。
Customer需要有方法通过发送的广告反求Advertiser并且通过Advertiser找到对应的Producer来购买商品的方法。
Message需要一个一对多的广告子类Advertise,额外存了商品信息和发送人信息。
新增一个商品类Product,属性有id标志一种产品、价格price,被Producer拥有,Producer可以制造对应的Product。
新增异常,没有收到相应商品的广告NotNoticeProductException(int id)
三个JML描述
购买商品
/*@ public normal_behavior
@ requires contains(customerId) && (\exist int i; 0 <= i && i < getPerson(customerId).receivedProductInfo.length;
@ getPerson(customerId).receivedProductInfo[i].getProductId() == productId)
@ && getPerson(getPerson(customerId).receivedProductInfo.get(productId).getProducerId).haveProduct();
@ assignable getPerson(customerId).receivedProductInfo;
@ ensures getPerson(customerId).receivedProductInfo.length == \old(getPerson(customerId).receivedProductInfo.length) - 1;
@ ensures (\forall int i; 0 <= i && i < \old(getPerson(customerId).receivedProductInfo.length)
@ && getPerson(customerId).receivedProductInfo[i].getProductId()!=productId;
@ (\exists int j; 0 <= j && j < getPerson(customerId).receivedProductInfo.length;
@ getPerson(customerId).receivedProductInfo[j].getProductId() == \old(productList[i].getProductId())));
@ ensures (\forall int i; 0 <= i && i < getPerson(customerId).receivedProductInfo.length;
@ getPerson(customerId).receivedProductInfo[i].getProductId() != productId);
@ ensures (getPerson(customerId).getMoney = \old(getPerson(customerId).getMoney) - getPerson(customerId).receivedProductInfo.get(productId).getPrice);
@ ensures (getPerson(getPerson(customerId).receivedProductInfo.get(productId).getProducerId).getMoney =
@ \old(getPerson(getPerson(customerId).receivedProductInfo.get(productId).getProducerId).getMoney) -
@ getPerson(customerId).receivedProductInfo.get(productId).getPrice);
@ ensures (getPerson(getPerson(customerId).receivedProductInfo.get(productId).getProducerId).getProductNum =
@ \old(getPerson(getPerson(customerId).receivedProductInfo.get(productId).getProducerId).getProductNum) - 1;
@ also
@ public exceptional_behavior
@ signals (PersonIdNotFoundException e) !contains(customerId);
@ also
@ public exceptional_behavior
@ signals (NotNoticeProductException e) contains(customerId) && (\forall int i; 0 <= i && i < getPerson(customerId).receivedProductInfo.length;
@ getPerson(customerId).receivedProductInfo[i].getProductId() != productId);
@*/
public void buyProduct(int customerId, int productId) throw PersonIdNotFoundException, NotNoticeProductException;
关注广告
/*@ public normal_behavior
@ requires message instance of Advertise
@ assignable receivedProductInfo, messages;
@ ensures receivedProductInfo.length == \old(receivedProductInfo.length) + 1;
@ ensures messages.length == \old(messages.length) - 1;
@ ensures (\forall int i; 0 <= i && i < \old(receivedProductInfo.length)
@ && receivedProductInfo[i].getProductId()!=productId;
@ (\exists int j; 0 <= j && j < receivedProductInfo.length; productList[j].getProductId() == \old(receivedProductInfo[i].getProductId())));
@ ensures (\exists int i; 0 <= i && i < receivedProductInfo.length; productList[i] == m.getProduct())
@ ensures (\forall int i; 0 <= i && i < \old(messages.length)
@ && messages[i].getMessageId()!=productId && messages[i] != m;
@ (\exists int j; 0 <= j && j < messages.length; messages[j].getMessageId() == \old(messages[i].getMessageId())));
@*/
public void concernAdvertise(Message m);
拥有产品
//@ ensures \result == productCount > 0;
public /*@ pure @*/ boolean haveProduct();
五、学习体会
在接触JML之前,我试图尝试看过往届的博客,但是却几乎完全看不懂,一度认为JML是一种很神奇的编程方式。现在学习也尝试了JML,可以说JML其实就是一种编程规范,作为契约式编程,完全信任提供的接口传入数据的规约,并做出相应操作。
这三次的作业难度相比前两个单元确实简单了许多,只要确定好准备采用的数据结构和算法就能够较为顺利的完成任务。同时经过繁多的JML阅读,也逐渐体会到了JML在表达复杂问题精确性上的优势。
还是有一点和预期不符的吧,一个是居然没有工具可以直接通过输入JML的方式去测试我执行方法之后的正确性,感觉是对于JML如此精确的表述的一种浪费(?本来期待可以有自动的数据检查器的。第二是JML在保证精确度的前提下对于可读性的牺牲非常大,一个用自然语言表述很简单的行为会让JML写一大片。

浙公网安备 33010602011771号