面向对象设计与构造第三单元总结
作业分析
题目简述
根据给出的 JML 规格实现并维护一个社交网络的模型,包括人、群组、网络和各种消息,还有各种异常。
涉及图论模型,并查集、最短路和最小生成树算法。
架构设计
按要求实现几个类,每个类里面都使用 HashMap 建立编号到对象的映射,方便查找。
大致如下:
//MyNetwork
private final HashMap<Integer, Person> people;
private final HashMap<Integer, Group> groups;
private final HashMap<Integer, Message> messages;
private final HashMap<Integer, Integer> emojiHeatMap;
//MyGroup
private final HashMap<Integer, Person> people;
//MyPerson
private final HashMap<Integer, Person> acquaintances;
private final HashMap<Integer, Integer> values;
//My____Exception
private static HashMap<Integer, Integer> idCnt;
另外实现了并查集、最小生成树、最短路三个工具类,以及两个辅助类:存储边的类和最短路时用于在堆里实现比较的 Node 类。
图模型构建和维护策略
这次的社交网络很明显是一个图的模型,是有边权的无向图。每个人可以看做结点,人和人的关系可以看做边,群组就是整个网络的子图。消息则是在网络里传递信息,和图关系不大。
边显然是稀疏的,因此我采用邻接表(用 ArrayList 实现)来存储边。加入一条边的时候,就存到两个点的邻接表中。
对于群组,只需要记录所有点即可,没有必要记录边。
对于消息,加入的时候存在 Network 的 HashMap 中,发送的时候维护 SocialValue,转移到对应的人的 ArrayList 里。
对图的维护基本按照 JML 来实现即可。特殊实现在下面的“性能问题”中提到。
性能问题及优化
作业中的数据范围不算大,但也不算很小,是 \(10^3-10^4\) 级别的。考虑到 Java 的运行速度和时间限制,能支持的最大复杂度为 \(O(n^2)\)。(不过测试中有些指令的次数有限制,可以适当放宽)
因此并不能每个操作都按 JML 暴力遍历或搜索来解决,有几个地方需要用数据结构和算法来优化。
1、并查集
在第九次作业中,出现了 query_circle 和 query_block_sum 两种指令。query_circle 就是询问两个点是否联通。query_block_sum 是询问图里的连通块数量。
考虑到题目中没有删边只有加边,这是很经典的并查集模型,只需要在加边的同时维护一下并查集即可。题目也没有特殊要求,因此没有必要按秩合并,可以直接路径压缩。总复杂度为 \(O(n \alpha (n))\)
query_circle 就是看两个点是否在同一并查集中。
在维护并查集的过程中还可以顺便维护 block_sum,加点的时候 +1,合并的时候 -1。这样可以 \(O(1)\) 回答 query_block_sum。
2、最小生成树
在第十次作业中,出现了 query_least_connection 指令。这个指令的含义是询问图中包含某个点的最小生成树的边权和。
在前面实现了并查集的基础上,可以直接实现 Kruskal 算法来维护最小生成树。由于加边是不断进行的,边集发生了变化,因此每次询问必须重新做一遍最小生成树。
Kruskal 需要对边排序。每次都做一遍快排不如加边的时候直接插入排序。
过程中需要维护每个连通块当前的边权和,这也可以在并查集中维护,把每个连通块的边权和存在并查集顶部的点即可,合并的时候就把两边加起来,再加上新加的边,存到新的顶部的点上。
排序总复杂度 \(O(n^2)\),qlc 单次复杂度 \(O(n\alpha(n))\)。
3、最短路
第十一次作业中出现了 send_indirect_message 指令,需要求消息的双方的最短路。
直接上堆优化 Dijkstra 即可。单次复杂度 \(O(n \log n)\)
4、query_group_value_sum
query_group_value_sum 这个指令一开始没有什么特别的,但是在第十次作业数据范围增大之后就需要优化了。含义是询问群组代表的子图的边权和。
现在不能暴力枚举两个点看是否有边来计算。一种方法是询问时枚举所有边,看是否两端都在子图里。另一种方法是加边的时候枚举所有群组,以及往群组里加点的时候枚举它的所有边,看是否会产生贡献,并对于每个群组实时维护 valueSum。
两种方法总复杂度都是 \(O(n^2)\)
Bug分析
三次作业均顺利通过强测和互测。找到同学的 bug 也都集中在上面提到的性能问题上。
有的同学觉得打标记延迟更新可以变快,实际上只要修改和查询交替出现还是会很慢。
有个唯一的坑点,是往群组里加人的时候上限为 1111,但这个 bug 没有出现在我互测房间中的任何一份代码中。
测试数据
手动测试
需要仔细阅读 JML 规格,找到一些可能注意不到导致出错的地方。比如群组人数 1111 的上限,getReceivedMessages 是返回最近四条等。
还要对 JML 中涉及的每个方法的每个异常进行充分的测试,保证都能够正确触发。
此外就是一些性能方面的问题,针对这几个容易出现性能问题的指令来有针对性的构造数据。比如造链,完全图,菊花图……
自动测试
大力随机即可。可以有针对性地控制每个指令的数量,全面测试。
测试异常可以让询问的随机范围略大于编号范围。
Network 扩展
题目描述
假设出现了几种不同的Person
Advertiser:持续向外发送产品广告
Producer:产品生产商,通过Advertiser来销售产品
Customer:消费者,会关注广告并选择和自己偏好匹配的产品来购买
- 所谓购买,就是直接通过Advertiser给相应Producer发一个购买消息
Person:吃瓜群众,不发广告,不买东西,不卖东西
如此Network可以支持市场营销,并能查询某种商品的销售额和销售路径等
请讨论如何对Network扩展,给出相关接口方法,并选择3个核心业务功能的接口方法撰写JML规格(借鉴所总结的JML规格模式)
扩展
Advertiser、Producer 和 Customer 可以设计为 Person 的子类,Advertisement 和 BuyMessage 可以设计为 Message 的子类。
发送广告:
/*@ public normal_behavior
@ requires containsMessage(id) && (getMessage(id) instanceof Advertisement);
@ assignable messages;
@ assignable people[*].messages;
@ ensures !containsMessage(id) && messages.length == \old(messages.length) - 1 &&
@ (\forall int i; 0 <= i && i < \old(messages.length) && \old(messages[i].getId()) != id;
@ (\exists int j; 0 <= j && j < messages.length; messages[j].equals(\old(messages[i]))));
@ ensures (\forall int i; 0 <= i && i < people.length && !getMessage(id).getPerson1().isLinked(people[i]);
@ people[i].getMessages().equals(\old(people[i].getMessages()));
@ ensures (\forall int i; 0 <= i && i < people.length && getMessage(id).getPerson1().isLinked(people[i]);
@ (\forall int j; 0 <= j && j < \old(people[i].getMessages().size());
@ people[i].getMessages().get(j+1) == \old(people[i].getMessages().get(j))) &&
@ people[i].getMessages().get(0).equals(\old(getMessage(id))) &&
@ people[i].getMessages().size() == \old(people[i].getMessages().size()) + 1);
@ also
@ public exceptional_behavior
@ signals (MessageIdNotFoundException e) !containsMessage(id);
@ signals (NotAdvertisementException e) !(getMessage(id) instanceof Advertisement);
@*/
public void sendAdvertisement(int id) throws
MessageIdNotFoundException, NotAdvertisementException;
生产商生产产品:
/*@ public normal_behavior
@ requires contains(producerId) && (getPerson(producerId) instanceof Producer);
@ assignable getProducer(producerId).productCount;
@ ensures getProducer(producerId).getProductCount(productId) ==
@ \old(getProducer(producerId).getProductCount(productId)) + 1;
@ also
@ public exceptional_behavior
@ signals (PersonIdNotFoundException e) !contains(producerId);
@ signals (NotProducerException e) !(getPerson(producerId) instanceof Producer);
@*/
public void produceProduct(int producerId, int productId) throws
PersonIdNotFoundException, NotProducerException;
发送购买消息(由于要经过 Advertiser,所以分为发送和接收两部分,分别代表 Customer-Advertiser/Advertiser-Producer,先付钱再发货):
/*@ public normal_behavior
@ requires containsMessage(id) && (getMessage(id) instanceof BuyMessage);
@ requires (getMessage(id).getPerson1() instanceof Customer) && (getMessage(id).getPerson2() instanceof Advertiser);
@ assignable messages;
@ assignable getMessage(id).getPerson1().money;
@ assignable getMessage(id).getPerson2().messages, getMessage(id).getPerson2().money;
@ ensures !containsMessage(id) && messages.length == \old(messages.length) - 1 &&
@ (\forall int i; 0 <= i && i < \old(messages.length) && \old(messages[i].getId()) != id;
@ (\exists int j; 0 <= j && j < messages.length; messages[j].equals(\old(messages[i]))));
@ ensures (\forall int i; 0 <= i && i < \old(getMessage(id).getPerson2().getMessages().size());
@ \old(getMessage(id)).getPerson2().getMessages().get(i+1) == \old(getMessage(id).getPerson2().getMessages().get(i)));
@ ensures \old(getMessage(id)).getPerson2().getMessages().get(0).equals(\old(getMessage(id)));
@ ensures \old(getMessage(id)).getPerson2().getMessages().size() == \old(getMessage(id).getPerson2().getMessages().size()) + 1;
@ ensures (\old(getMessage(id)).getPerson1().getMoney() ==
@ \old(getMessage(id).getPerson1().getMoney()) - ((BuyMessage)\old(getMessage(id))).getMoney() &&
@ \old(getMessage(id)).getPerson2().getMoney() ==
@ \old(getMessage(id).getPerson2().getMoney()) + ((BuyMessage)\old(getMessage(id))).getMoney());
@ also
@ public exceptional_behavior
@ signals (MessageIdNotFoundException e) !containsMessage(id);
@ signals (NotBuyMessageException e) !(getMessage(id) instanceof BuyMessage);
@ signals (NotCustomerException e) !(getMessage(id).getPerson1() instanceof Customer);
@ signals (NotAdvertiserException e) !(getMessage(id).getPerson2() instanceof Advertiser);
@*/
public void sendBuyMessage(int id) throws
MessageIdNotFoundException, NotBuyMessageException, NotCustomerException, NotAdvertiserException;
学习体会
本单元的难度并不大,主要是让我们了解规格化设计,熟悉基于 JML 的规格模式。
规格化设计是一种致力于保证程序正确性的方法,十分的严谨。我觉得我们在自己设计程序的时候也可以学着去使用规格化设计,能够提高思维的严谨性、降低出错的概率。
当然,代码并不能完全等价于规格,代码的核心在于架构设计,数据、容器等也需要自主选择。不过有了规格之后,能够对代码形成一定约束,提醒自己需要注意哪些地方。
这几次作业也带我们回顾了大一程设和数据结构里学到的一些知识,涉及了时间复杂度的计算。这让我更加体会到,在给定的规格下,内部的实现可以是多种多样的。