2022面向对象设计与构造第三单元总结
一、测试策略
本单元作业我基本的测试策略就是单元测试 + 情况覆盖
- 单元测试:使用JUnit工具,对每一个类建立一个测试类,然后再在测试类内编写各方法对应的测试方法。
- 情况覆盖:由于本单元需要实现的接口都由JML描述,所以在编写测试方法时,只需要覆盖各方法的JML中写明的各种情况即可。比如对于Network接口中的storeEmojiId方法,它的JML描述为:
/*@ public normal_behavior
@ requires !(\exists int i; 0 <= i && i < emojiIdList.length; emojiIdList[i] == id);
@ assignable emojiIdList, emojiHeatList;
@ ensures (\exists int i; 0 <= i && i < emojiIdList.length; emojiIdList[i] == id && emojiHeatList[i] == 0);
@ ensures emojiIdList.length == \old(emojiIdList.length) + 1 &&
@ emojiHeatList.length == \old(emojiHeatList.length) + 1;
@ ensures (\forall int i; 0 <= i && i < \old(emojiIdList.length);
@ (\exists int j; 0 <= j && j < emojiIdList.length; emojiIdList[j] == \old(emojiIdList[i]) &&
@ emojiHeatList[j] == \old(emojiHeatList[i])));
@ also
@ public exceptional_behavior
@ signals (EqualEmojiIdException e) (\exists int i; 0 <= i && i < emojiIdList.length;
@ emojiIdList[i] == id);
@*/
public void storeEmojiId(int id) throws EqualEmojiIdException;
由JML可知,这个方法有两种执行的可能,一种是emojiIdList不存在输入的id,此时将id加入emojiIdList,并将对应的emojiHeatList置零。另一种是emojiIdList存在输入的id,此时则会抛出EqualEmojiIdException异常。基于上面的复习,我编写了以下的测试方法:
@org.junit.jupiter.api.Test
void storeEmojiIdTest() throws Exception {
network.storeEmojiId(1);
System.out.println(network.containsEmojiId(1));
network.storeEmojiId(1);
}
前两行是为了测试第一种情况,第三行是为了测试第二种情况。若方法实现无误,则测试方法执行之后应该会先输出一个true,然后再抛出一个EqualEmojiIdException异常。
当然,这个测试方法并不完备,它并没有全面覆盖JML中的每一个ensure语句,我只是以它举例说明测试的基本思路。
二、架构设计
由于本单元作业的基础架构已由官方包决定,所以下面主要分析高时间复杂度方法的优化策略。
2.1 第一次作业
2.1.1 MyNetwork类isCircle()方法的优化
- 优化前:
- 算法:深度优先搜索。
- 时间复杂度:O(n!)
- 优化策略:
使用并查集,用树来组织并查集,在MyPerson类中添加一个father属性,表示该对象在并查集树中的父节点,初始值为自身。当调用addRelation方法时,首先判断该relation两端的person在不在同一个并查集树中。若在,则直接添加,否则合并两个树。合并的原则是:将较高的那棵树的根节点作为新树的根节点,另一棵树的根节点作为新树根节点的子节点。 - 优化后时间复杂度:
addRelation时间复杂度不变,isCircle时间复杂度降为O(log2n),整体时间复杂度大大降低。
2.1.2 MyNetwork类queryBlockSum()方法的优化
- 优化前:
- 算法:二重循环 + isCircle
- 时间复杂度:O(n^2*log2n)
- 优化策略:
为MyPerson类设置一个minIndex属性,表示该MyPerson对象所在的连通分量中所有MyPerson对象在people数组中的最小下标值。对象刚加入Network时,minIndex设为此时该对象的下标。当不同连通分量合并时,新的根节点的minIndex值设为原连通分量两个根节点的最小minIndex值。而查询一个MyPerson对象的block时,只需要找到该对象所在连通分量的根节点,查询根节点的minIndex值,将起与此对象的下标值相比较,若两者相等,则说明该对象之前的所有MyPerson对象都不与其连通,block为真。 - 优化后时间复杂度:
addRelation时间复杂度不变,queryBlockSum时间复杂度降为O(log2n),整体时间复杂度大大降低。
2.2 第二次作业
2.2.1 MyNetwork类queryLeastConnection()方法的优化
- 优化前:
- 算法:普通的Kruskal算法
- 时间复杂度:O(mn)
- 优化策略:
建立一个Relation类,用来存储Network中的关系(即图的边),再在MyNetwork中增加一个connectGroups属性,用来存储各连通分量根节点和分量边集合的一一映射。当addRelation时,首先判断该relation两端的person在不在同一个连通分量中。若在,则直接将新的relation加进对应的connectGroups项,否则合并两个根节点对应的relation集合,再把新的relation加进去。这样,使用Kruskal算法时就可以先直接得到该点所处的连通分量的所有边,然后使用该边集进行快排。此外,同样可以将并查集应用到Kruskal算法连通分量的检验中,具体实现不再赘述。 - 优化后时间复杂度:
addRelation时间复杂度不变,queryLeastConnection的时间复杂度降为O(mlog2n),整体时间复杂度明显降低。
2.2.2 MyGroup类getValueSum()方法的优化
- 优化前:
- 算法:二重循环 + isLinked
- 时间复杂度:O(n^3)
- 优化策略:
为MyPerson类设置一个inGroups属性,它是一个列表,表示该对象所在的所有群组。同时为MyGroup设置一个valueSum属性。当向群组加人时,先把该群组的id加进该person的inGroups列表,然后遍历该person的所有熟人,如果熟人也在该群组里,则将valueSum加上二倍的权值,从群组删除人时同理。特别地,当addrelation时,如果两端的person存在公共群组,则将这些群组的valueSum都加上二倍权值。同时,为了尽可能地减小时间复杂度,我将能使用Hashset/hashmap的地方都做了替换。 - 优化后的时间复杂度:
考虑到指导书给出的限制:一个person所在是群组最多为20个,所以可以认为addRelation时间复杂度不变,addToGroup和delFromGroup的时间复杂度增加到O(n),getValueSum的时间复杂度降为O(1),整体时间复杂度大大降低。
2.3 第三次作业
2.3.1 MyNetwork类sendIndirectMessage()方法的优化
- 优化前:
- 算法:普通的单源点最短路径算法(Dijkstra算法)
- 时间复杂度:O(n^2 + m)
- 优化策略:
使用小根堆来维护Dest数组,降低寻找最小值操作的时间复杂度。 - 优化后时间复杂度:
sendIndirectMessage的时间复杂度降为O((m + n) * log2n),考虑到点和边的实际添加方式,所以时间复杂度实际上是从O(n^2)降为了O(nlog2n),整体时间复杂度明显降低。
三、代码性能问题
本单元我仅在第一次作业出现过一次性能问题,就是前文已述的queryBlockSum()方法,此处不再赘述。
四、Network扩展
4.1 基本扩展思路
新建MyAdvertiser、MyProducer、MyCustomer类,分别用来表示Advertiser、Producer、Customer,并在MyNetwork中建立advertisers、producers、customers属性。为了实现这些新增人物的特殊属性,MyAdvertiser应该有一个存储当前正在推送广告的产品id属性和雇主id属性,以及customers和producers的一个副本;MyProducer的属性应该包含产品的id、价格、销售量,以及customers的一个副本;MyCustomer的属性应该有自己收到的所有广告组成的列表。同时实现这三个类内部以及MyNetwork的相关方法。
4.2 相关接口方法
- MyAdvertiser
- getter-setter方法
- sendAdvertise方法:向消费者发送广告(产品id)
- sendBuyingMessage方法:消费者通过它向生产商发送购买需求
- MyProducer
- getter-setter方法
- MyCustomer
- getter-setter方法
- buy方法:向广告商发送购买需求
- MyNetwork
- setAdvertiser方法:为生产商绑定一个广告商
- advertise方法:让广告商向所有消费者发送一轮广告
- buyProduct方法:让消费者购买一定数量的某产品
- queryProductSales方法:查询产品销售额
- queryProductPath方法:查询产品销售路径
- 其它的get*、contains*查询方法,add*增添方法
4.3 核心业务接口JML
选择MyNetwork的前三个核心业务功能接口
- setAdvertiser方法:
/*@ public normal_behavior
@ requires (containsProducer(producerId) && containsAdvertiser(advertiserId));
@ assignable getAdvertiser(advertiserId).productId, getAdvertiser(advertiserId).producerId;
@ ensures getAdvertiser(advertiserId).productId == getProducer(producerId).productId;
@ ensures getAdvertiser(advertiserId).producerId == producerId;
@ also
@ public exceptional_behavior
@ signals (ProducerIdNotFoundException e) !containsProducer(producerId)
@ signals (AdvertiserIdNotFoundException e) (containsProducer(producerId) && !containsAdvertiser(advertiserId));
@*/
public void setAdvertiser(int producerId, int advertiserId);
- advertise方法:
/*@ public normal_behavior
@ requires containsAdvertiser(advertiserId);
@ assignable customers[*].receivedAdvertise;
@ ensures (\forall int i; i >= 0 && i < customers.length;
@ (\forall int j; j >= 0 && j < \old(customers[i].receivedAdvertise.length);
@ (\exist int k; k >= 0 && k < customers[i].receivedAdvertise.length; \old(customers[i].receivedAdvertise)[j] == customers[i].receivedAdvertise[k])));
@ ensures (\forall int i; i >= 0 && i < customers.length;
@ (\exist int j; j >= 0 && j < customers[i].receivedAdvertise.length; customers[i].receivedAdvertise[j] == getAdvertiser(advertiserId).productId));
@ ensures (\forall int i; i >= 0 && i < customers.length;
@ customers[i].receivedAdvertise.length = \old(customers[i].receivedAdvertise).length + 1);
@ also
@ public exceptional_behavior
@ signals (AdvertiserIdNotFoundException e) !containsAdvertiser(advertiserId));
@*/
public void advertise(int advertiserId);
- buyProduct方法:
/*@ public normal_behavior
@ requires (containsCustomer(customerId) && getCustomer(customerId).contains(productId) && amount >= 0);
@ assignable getProducer(productId).sales;
@ ensures getProducer(productId).sales == \old(getProducer(productId).sales) + getProducer(productId).price * amount;
@ also
@ public exceptional_behavior
@ signals (CustomerIdNotFoundException e) !containsCustomer(customerId));
@ signals (ProductIdNotFoundException e) (containsCustomer(customerId) && !getCustomer(customerId).contains(productId));
@ signals (AmountException e) (containsCustomer(customerId) && getCustomer(customerId).contains(productId) && amount < 0);
@*/
public void buyProduct(int customerId, int productId, int amount);
五、学习体会
本单元的学习给我带来了两大体会:
- 一是考虑问题一定要全面。JML是一个严谨性很高的语言,能囊括一个方法的各种情况,但凡具体实现与JML有丝毫偏差,都会产生意想不到的错误。因此,在学习JML的过程中,我的考虑问题的思维也不免变得更加严谨、全面。
- 而是性能与功能同样重要。在过去的代码实践中,我一直都只关注自己代码的功能是否符合要求,很少去思考算法是否最优、时间复杂度能否减小、是否存在冗余操作等问题。本单元的学习让我意识到了性能的重要性,也为我培养了一些降低时间复杂度的基本思维。