OO第三单元-JML
利用JML规格来准备测试数据
我对jml规格的理解,更像是:功能的外化。
比如,对于addGroup()
指令:
- 其功能简单来说就是:将
group
添加到Network
管理的groups
中 - jml的描述简单来说就是:
groups[i]
的变化和限制要满足哪些条件
在本单元中,我在自测的时候,主要还是以:
- 通过阅读每个方法的jml规格来理解任务实现的功能
- 针对功能进行测试
- 在针对功能进行测试的时候,每一条jml里的语句所反映的限制/情况/含义都应该考虑到。(在jml规格中,每一条
ensures
、每一条normal_behavior
或者exceptional_behavior
都是在给我们明确这个方法的功能。)
图模型构建和维护策略
第一次任务
对于qci id1 id2
指令,其意义是查询id1
和id2
是否能通过人与人之间的relation联系到一起。如果通过bfs/dfs进行遍历的话,其复杂度高达O(V^2)。
针对此,我使用了并查集的结构+启发式合并的优化。复杂度降为O(log(V))。
// 并查集相关
// 存放着所有根节点和其高度的对应关系,其size()就是blockSum
private final HashMap<Integer, Integer> rootId2height = new HashMap<>();
// 每个id与其父节点的映射关系map
private final HashMap<Integer, Integer> id2Fid = new HashMap<>();
如此一来,在ar
的时候,就需要:
// 如果两个人不在一棵树中,则将这两个人所在树合并成一棵
int f1id = getFatherId(p1.getId());
int f2id = getFatherId(p2.getId());
if (f1id != f2id) {
int h1 = rootId2height.get(f1id);
int h2 = rootId2height.get(f2id);
if (h1 < h2) {
// 如果id1所在树比较矮,那就把它合并到id2的树里面
id2Fid.put(f1id, f2id);
rootId2height.remove(f1id);
} else if (h1 > h2) {
// 如果id2所在树比较矮,那就把它合并到id1的树里面
id2Fid.put(f2id, f1id);
rootId2height.remove(f2id);
} else {
// 如果两棵树一样高,那就将id1所在树合并到id2所在树里面,并将id2所在树的height+1
id2Fid.put(f1id, f2id);
rootId2height.remove(f1id);
rootId2height.put(f2id, rootId2height.get(f2id) + 1);
}
}
其中,getFatherId()
为:
private int getFatherId(int id) {
int tid = id;
int fid = id2Fid.get(tid);
while (fid != tid) {
tid = fid;
fid = id2Fid.get(tid);
}
return fid;
}
第二次任务
在qlc id
中,需要查询id
所在的block的最小生成树。
public int queryLeastConnection(int id) throws PersonIdNotFoundException {
MyPerson p = people.getOrDefault(id, null);
if (p == null) {
throw new MyPersonIdNotFoundException(id);
}
TreeSet<Edge> edges = new TreeSet<>();
int mstEdgeNum = 0;
for (MyPerson p2 : people.values()) {
if (isCircle(id, p2.getId()) &&
id != p2.getId()) { // 表示不需要向edges中加入 自己和自己linked对应的边
p2.getAc().forEach(idt -> {
edges.add(new Edge(p2, people.get(idt)));
});
++mstEdgeNum;
}
}
int ret = 0;
// 此时,edges里面已经得到了id所在的连通图的所有边(按照value的顺序)
// arr:arrive,存放已经抵达的点。
// HashSet<Integer> arr = new HashSet<>();
// edgesSel: edges Selected,存放已经选中作为mst的边
// HashSet<Edge> edgesSel = new HashSet<>();
int nowEdgeNum = 0; // 当nowEdgeNum为mstEdgeNum时,MST就生成好了!
MyNetwork network = new MyNetwork();
for (Edge edge : edges) {
if (nowEdgeNum == mstEdgeNum) {
break;
}
MyPerson p1 = edge.getPmin().clone();
MyPerson p2 = edge.getPmax().clone();
if (!network.contains(p1.getId())) {
try {
network.addPerson(p1);
} catch (EqualPersonIdException e) {
e.printStackTrace();
}
}
if (!network.contains(p2.getId())) {
try {
network.addPerson(p2);
} catch (EqualPersonIdException e) {
e.printStackTrace();
}
}
// !edgesSel.contains(edge)
if (!network.isCircle(p1.getId(), p2.getId())) {
// 说明这条边被选中了!
// edgesSel.add(edge);
try {
network.addRelation(p1.getId(), p2.getId(), edge.queryValue());
} catch (EqualRelationException e) {
e.printStackTrace();
}
ret += edge.queryValue();
// System.err.println("now ret: " + ret);
++nowEdgeNum;
}
}
return ret;
}
第三次任务
在sim id
中,要寻找id这条消息的发送方到接收方的最短路径。我采用的是dijkstra算法+堆优化,复杂度为O((e + n)*log(n))。
public int sendIndirectMessage(int id) throws MessageIdNotFoundException {
if (!containsMessage(id) || getMessage(id).getType() == 1) {
throw new MyMessageIdNotFoundException(id);
}
MyPerson sender = (MyPerson) getMessage(id).getPerson1();
MyPerson recver = (MyPerson) getMessage(id).getPerson2();
try {
if (!isCircle(sender.getId(), recver.getId())) { // O(log(e))
return -1;
}
} catch (PersonIdNotFoundException e) {
e.printStackTrace();
}
TreeSet<Node> nodesNotSel = new TreeSet<>(); // 装着所有未被选中的Node
HashMap<Integer, Node> m = new HashMap<>(); // 存放该Network里面所有person的ID和Node的映射关系
people.keySet().forEach(i -> {
Node node = new Node(i,Integer.MAX_VALUE);
nodesNotSel.add(node);
m.put(i, node);
}); // O(n*log(n))
nodesNotSel.remove(new Node(sender.getId(),Integer.MAX_VALUE));
Node start = new Node(sender.getId(),0);
nodesNotSel.add(start);
m.put(sender.getId(), start);
int ret = 0;
while (true) { // O(e*log(n))
Node n = nodesNotSel.pollFirst();
if (recver.getId() == n.getId()) {
// 说明此时已经找到从sender到recver的最短路径了!
while (n.getId() != sender.getId()) { // 循环次数:O(n)
try {
ret += queryValue(n.getId(), n.getPreId());
} catch (PersonIdNotFoundException | RelationNotFoundException e) {
e.printStackTrace();
}
n = m.get(n.getPreId());
}
MyMessage msg = messages.remove(id);
((MyPerson) (msg.getPerson2())).recMsg(msg);
if (msg instanceof EmojiMessage) {
int eid = ((EmojiMessage) msg).getEmojiId();
emoji2heats.replace(eid, emoji2heats.get(eid) + 1);
}
return ret;
}
Node finalN = n;
people.get(n.getId()).getAc().forEach(id2 -> { //
try {
Node n2 = m.get(id2);
if (n2.getDis() > finalN.getDis() + queryValue(finalN.getId(), id2)) {
nodesNotSel.remove(n2); // 先取出来
n2.setDis(finalN.getDis() + queryValue(finalN.getId(), id2));
n2.setPreId(finalN.getId());
nodesNotSel.add(n2); // 修改后再放进去
}
} catch (PersonIdNotFoundException | RelationNotFoundException e) {
e.printStackTrace();
}
});
}
}
代码实现出现的性能问题和修复情况
第一次任务
互测bug
互测中,有两个同学的qbs复杂度为O(N^2).构造样例:
ap 1 p1 100
...
ap 600 p600 100
qbs
...
qbs
(加入600个人,再进行400次qbs)
这两位同学即会超时。
第二次任务
根据这次强测互测的要求说明,我估算出,stdin每次给出的指令引起的方法调用的时间复杂度要低于O(n^2)。
所以,在写完各个方法的代码后,我又对所有stdin指令进行了时间复杂度分析,如下所示。
n为节点数+边数(person数+relation数),n<=10000
m为group数,m<=20
"ap", "addPerson" , O(n) // 涉及到由所有邻居增加对应的valueSum
"ar", "addRelation" , O(logn+m) // 涉及到查找并查集父节点(O(logn)),以及共同组更新valueSum(O(m))
"qv", "queryValue" , O(1)
"qps", "queryPeopleSum" , O(1)
"qci", "queryCircle" , O(logn) // 涉及到查找并查集父节点
"qbs", "queryBlockSum" , O(1)
"ag", "addGroup" , O(1)
"atg", "addToGroup" , O(n) // 需要根据person更新ageSum,及其根据其所有邻居来更新valueSum
"dfg", "delFromGroup" , O(n) // 需要根据person更新ageSum,及其根据其所有邻居来更新valueSum
"qgps", "queryGroupPeopleSum" , O(1)
"qgvs", "queryGroupValueSum" , O(1)
"qgav", "queryGroupAgeVar" , O(n) // 方差要现算
"am", "addMessage" , O(1)
"sm", "sendMessage" , O(n) // 对于群聊消息,群内每一个人的socialValue要增加
"qsv", "querySocialValue" , O(1)
"qrm", "queryReceivedMessages" , O(1)
"qlc", "queryLeastConnection" , O(nlogn) // 先选出person所在block的所有边(这里又会涉及到qci),再将这些边按照Kruskal算法生成mst
此外,有几个值得提到的点:
- 执行添加指令的时间复杂度为O(1)时,执行查询指令的复杂度就为O(n^2)。此时,为了避免大量的查询指令导致TLE,需要在执行添加指令的时候进行一些处理(例如通过一些HashMap构建联系),这样在执行相关的查询指令时,时间复杂度就可以降到O(n)。
自测bug
1.
// stdin:
ap 1 p1 1
ap 2 p2 2
am 1 10 0 1 2
sm 1
sm 1
// acout:
Ok
Ok
Ok
rnf-1, 1-1, 2-1
rnf-2, 1-2, 2-2
// myout:
Ok
Ok
Ok
rnf-1, 1-1, 2-1
minf-1, 1-1
原因:
在sendMessage()
命令中,规格的描述是:
- 对于normal behavior,要将这个message移出messages
- 对于exceptional behavior,则无需移除
我则是:现将message移出messages,再进行判断。此时,若为exceptional behavior,我就直接throw new Exception了,却忘记,此时这个message不应该被取出messages。这样就会导致,则在下次sm时,会出问题
互测bug
bug1
// stdin
ag 1
ap 1 p1 1
atg 1 1
ap 2 p2 1
atg 2 1
ap 3 p3 1
atg 3 1
ap 4 p4 1
atg 4 1
...
ap 1111 p1111 1
atg 1111 1
qgvs 1
qgvs 1
...
qgvs 1
该hack样例为:向1个group里面加很多个人,再对该group进行很多次qgvs询问。
这为同学在getValueSum()
方法中选择了时间复杂度O(n^2)的算法(如下),故会tle。
public int getValueSum() {
int result = 0; //只有不同人之间的value
for (Person p1 :peopleMap.values()) {
for (Person p2:peopleMap.values()) {
if (p1.isLinked(p2)) {
result += p1.queryValue(p2);
}
}
}
return result;
}
bug2
// stdin
ag 1
ap 1 p1 1
atg 1 1
ap 2 p2 1
atg 2 1
ap 3 p3 1
atg 3 1
ap 4 p4 1
atg 4 1
...
ap 1111 p1111 1
atg 1111 1
qgav 1
qgav 1
...
qgav 1
该hack样例为:向1个group里面加很多个人,再对该group进行很多次qgav询问。
这为同学在getAgeVar()
中的for循环体内调用了时间复杂度为O(n)的getAgeMean()
方法,于是getAgeVar()
的时间复杂度为O(n^2),故会tle。
public int getAgeMean() {
if (people.size() == 0) {
return 0;
} else {
int sum = 0;
for (int i = 0; i < people.size(); i++) {
sum += people.get(i).getAge();
}
sum = sum / people.size();
return sum;
}
}
public int getAgeVar() {
if (people.size() == 0) {
return 0;
} else {
int sum = 0;
for (int i = 0; i < people.size(); i++) {
sum += (people.get(i).getAge() - getAgeMean()) *
(people.get(i).getAge() - getAgeMean());
}
sum = sum / people.size();
return sum;
}
}
第三次任务
e:边数
n:人数
g:组数
m:消息数
em:emoji种类
| add_person | ap | O(1)
| add_relation | ar | O(g + log(e)) ;p1和p2共同在的group的valueSum要更新;如果两个人不在一棵树中,则将这两个人所在树合并成一棵
| query_value | qv | O(1)
| query_people_sum | qps | O(1)
| query_circle | qci | O(log(e)) ;涉及到查询并查集父节点是否为同一个
| query_block_sum | qbs | O(1)
| add_group | ag | O(1)
| add_to_group | atg | O(e);涉及到由所有邻居增加对应的valueSum
| del_from_group | dfg | O(e);涉及到由所有邻居减去对应的valueSum
| query_group_people_sum | qgps | O(1)
| query_group_value_sum | qgvs | O(1)
| query_group_age_var | qgav | O(n);方差需要现算
| add_message | am | O(1)
| send_message | sm | O(n);对于群聊消息,要遍历群内的每个人(addSocialValue,以及判断是否为发红包)
| query_social_value | qsv | O(1)
| query_received_messages | qrm | O(1)
| query_least_connection | qlc | O(e*log(e));最小生成树
| add_red_envelope_message | arem | O(1)
| add_notice_message | anm | O(1)
| clean_notices | cn | O(m); 需要遍历该人的messages
| add_emoji_message | aem | O(1)
| store_emoji_id | sei | O(1)
| query_popularity | qp | O(1)
| delete_cold_emoji | dce | O(em + m); 需要遍历messages和emoji2heats
| query_money | qm | O(1)
| send_indirect_message | sim | O((e + n)*log(n)); dijkstra最短路径算法
互测bug
public void addMessage(Message message) throws
EqualMessageIdException, EmojiIdNotFoundException, EqualPersonIdException {
//a分支
if (containsMessage(message.getId())) {
throw new MyEqualMessageIdException(message.getId());
}
//b分支
else if (message instanceof EmojiMessage) {
int emojiId = ((EmojiMessage)message).getEmojiId();
if (!containsEmojiId(emojiId)) {
throw new MyEmojiIdNotFoundException(emojiId);
}
}
// c分支
else if (message.getType() == 0 && message.getPerson1() == message.getPerson2()) {
throw new MyEqualPersonIdException(message.getPerson1().getId());
}
messages.put(message.getId(), message);
}
如果进入a,b分支后并未抛出异常,而此时应该进入c分支。但是,上述代码在进入b分支后,就不会进入c分支了。从而引发bug。
修复办法:将上面代码中的c分支的else if
改为if
。
针对ppt内容对Network进行扩展
假设出现了几种不同的Person
- Advertiser:持续向外发送产品广告
- Producer:产品生产商,通过Advertiser来销售产品
- Customer:消费者,会关注广告并选择和自己偏好匹配的产品来购买 -- 所谓购买,就是直接通过Advertiser给相应Producer发一个购买消息
- Person:吃瓜群众,不发广告,不买东西,不卖东西
如此Network可以支持市场营销,并能查询某种商品的销售额和销售路径等
请讨论如何对Network扩展,给出相关接口方法,并选择3个核心业务功能的接口方法撰写JML规格(借鉴所总结的JML规格模式)
添加新的指令
add_producer id(int) name(String) age(int) price(int)
add_advertiser id(int) name(String) age(int)
add_customer id(int) name(String) age(int)
add_ad_message id(int) producer_id(int) type(int) advertiser_id(int) customer_id(int)|group_id(int)
add_buy_message id(int) customer_id(int) producer_id(int)
query_sales_quantity producer_id(int)
query_sales_distribution producer_id(int)
新建三个接口
// Producer.java
public interface Producer extends Person {
// public instance model price;
// public instance model non_null Customer[] customers;
// public instance model non_null int[] quantity;
//@ ensures \result == price;
public /*@ pure @*/ int getPrice();
}
// Advertiser.java
public interface Advertiser extends Person {
}
// Customer.java
public interface Customer extends Person {
// public instance model non_null Producer[] producers;
// public instance model non_null int[] quantity;
}
一些关键方法的jml更新(下图只包含更新的部分)
/* @ public normal_behavior
@ requires (contains(id) && getPerson(id) instanceof Producer);
@ ensures \result == (\sum int i; 0 <= i && i < ((Producer) getPerson(id)).quantity.length; quantity[i]);
@ public exceptional_behavior
@ signals (PersonIdNotFoundException e) !contains(id) || !(getPerson(id) instanceof Producer);
@*/
public /*@ pure @*/ int querySalesQuantity(int id) throws PersonIdNotFoundException;
/* @ public normal_behavior
@ requires (contains(id) && getPerson(id) instanceof Producer);
@ ensures \result.size() == ((Producer) getPerson(id)).customers.length;
@ ensures (\forall int i; 0 <= i && i < \result.size();
\result.containsKey(((Producer) getPerson(id)).customers[i].getId()) &&
\result.get(((Producer) getPerson(id)).customers[i].getId()) == ((Producer) getPerson(id)).quantity[i]
)
@ public exceptional_behavior
@ signals (PersonIdNotFoundException e) !contains(id) || !(getPerson(id) instanceof Producer);
@*/
public /*@ pure @*/ Map<Integer, Integer> querySalesDistribution(int id) throws PersonIdNotFoundException;
/* @ requires (getMessage(id) instanceof BuyMessage ==>
@ (getMessage(id).getPerson1() instanceof Customer && getMessage(id).getType() == 0 ==> getMessage(id).getPerson2() instanceof Producer));
@ signals (PersonIdNotFoundException e) (getMessage(id) instanceof BuyMessage ==>
@ (!(getMessage(id).getPerson1() instanceof Customer) || (getMessage(id).getType() == 0 ==> !(getMessage(id).getPerson2() instanceof Producer))
@ ));
@*/
public void addMessage(Message message) throws EqualMessageIdException, EmojiIdNotFoundException, EqualPersonIdException;
/* @ public normal_behavior
@ requires containsMessage(id) && getMessage(id).getType() == 0 &&
@ getMessage(id).getPerson1().isLinked(getMessage(id).getPerson2()) &&
@ getMessage(id).getPerson1() != getMessage(id).getPerson2();
@ ensures (\old(getMessage(id)) instanceof BuyMessage) ==>
@ (\old(getMessage(id)).getPerson1().getMoney() ==
@ \old(getMessage(id).getPerson1().getMoney()) - ((BuyMessage)\old(getMessage(id))).getProducer().getPrice() &&
@ \old(getMessage(id)).getPerson2().getMoney() ==
@ \old(getMessage(id).getPerson2().getMoney()) + ((BuyMessage)\old(getMessage(id))).getProducer().getPrice());
@*/
public void sendMessage(int id) throws
RelationNotFoundException, MessageIdNotFoundException, PersonIdNotFoundException;
单元学习体会
在完成作业的过程中,每次阅读jml时,我都感觉这些jml写得长而啰嗦。仔细读完一大段jml代码之后,会发现原来这个函数的功能其实就是一项很简单的操作。
对于涉及较复杂算法的某些方法(比如task1中的isCircle()
,task2中的queryLeastConnection()
,task3中的sendIndirectMessage()
),其jml更加冗长。我感受到了使用jml来描述方法功能,对一个方法的描述会非常具体、清楚、不产生歧义,但是相比用自然语言描述,给读者/程序员的理解难度增大了很多。
就像课上提到的,往往写jml要比写方法体本身更加复杂。本单元和前两个单元不同也体现于此。
- 前两个单元都是告诉你整个程序要实现的功能,让你自己去建立类、建立方法。难点在架构的建立。(前两个单元的讨论区里活跃着同学对于架构建立的各种思索和探讨)
- 本单元则是告诉你每一个方法的jml,让你通过阅读jml理解其功能,填充已经有的方法体即可。难点在对于jml的准确理解。(本单元的讨论区冷清了很多...因为正确的理解只有一种,似乎没有什么可交流讨论的)