BUAA_OO_第三单元总结
1. 概述
这个单元的主要任务就是根据官方包接口给出的规格来实现相应的方法,从而构建一个社交网络系统。
其中有3个主要的类:Network, 负责社交网络上一切活动的管理;Group, 管理社交网络上的群组;Person,表示社交网络上单独的人。
最终的实现效果是通过Network类向外提供的方法,实现添加人、建立两人之间的连接、将人添加入群组、查询两人是否直接或间接地连接、查询人的数量、查询群组中人的数量、发送信息、查询每个人的社交值等操作。
2. 利用JML规格来准备测试数据
我们在测试时,需要根据JML规格中行为的不同,为每一种行为准备一种测试数据。
如上图,该方法的规格中定义了一种normal_behavior和3种exceptional_behavior,就需要为每一种behavior都准备相应的测试用例。
2.1 JUnit基本用法
我使用JUnit来编写单元测试。JUnit是一种定义测试代码的框架,能够使用自己手造的测试用例来测试数据。不同于手搓控制台输入数据,JUnit测试可以直接调用代码中的显式方法,通过验证其返回值是否符合预期的方式来验证代码的正确性,代码无需支持控制台交互。因此,我们甚至可以为项目里一些很小的工具类部署JUnit测试,而无需造评测机或者是写控制台输入输出。但是,要注意,为了保证测试的效果,代码需要满足覆盖率的要求。
IDEA本身就内置了JUnit,我们可以很方便的为一个类生成JUnit测试类:
右键单击你要测试的类,点击生成(或者直接Alt+Insert),然后依次点击JUnit,JUnit4(如上图),就可以生成一个JUnit测试类啦。
JUnit以注解的方式声明一个函数是哪个类型的操作,一个典型的JUnit测试类包含以下部分:
- @before: 每个测试函数开始时都要进行的操作,一般用来创建要测试的类的实例。
- @After: 每个测试函数结束时都要进行的操作,一般用来销毁所测试的类。
- @Test: 表示一个测试用例。
一个典型的测试方法(@Test)应当包含对被测试类的方法调用和结果检验,如下是对MyNetwork类中queryPeopleSum的测试:
@Test
public void testQueryPeopleSum() throws Exception {
//TODO: Test goes here...
network.addPerson(new MyPerson(1, "None", 21));
network.addPerson(new MyPerson(3, "None", 21));
network.addPerson(new MyPerson(5, "None", 21));
network.addPerson(new MyPerson(2, "None", 21));
Assert.assertEquals(network.queryPeopleSum(), 4);
Assert.assertThrows(EqualPersonIdException.class, () -> network.addPerson(new MyPerson(5, "None", 21)));
Assert.assertThrows(EqualPersonIdException.class, () -> network.addPerson(new MyPerson(2, "None", 21)));
network.addPerson(new MyPerson(6, "None", 21));
network.addPerson(new MyPerson(67, "None", 21));
Assert.assertEquals(network.queryPeopleSum(), 6);
}
2.2 JUnit的断言
JUnit是通过断言来判断方法正确性的。断言即是对某个方法运行后的返回值或类的新状态的预测。若测试方法中有断言未通过,则表示方法未通过测试,存在bug。
要使用断言,需要引入org.junit.Assert类。
若不想在使用断言时都带着Assert.前缀,可以用import static org.junit.Assert.*;引入,这样,可直接使用assertEquals而无需带Assert.前缀。
常用的断言有如下几个:
- Assert.assertEquals(A, B); 断定A和B相等(equals)
- Assert.assertTrue(boolean); 断定布尔表达式为真
- Assert.assertFalse(boolean); 断定布尔表达式为假
- Assert.assertThrows(异常类的class, lambda表达式); 断定之后的lambda表达式会抛出某个异常
assertThrows用法示例:
assertThrows(RelationNotFoundException.class, () -> network.queryValue(0, 7));
2.3 总结
总而言之,JUnit是一个有用的工具,为我们提供了一套标准的单元测试解决方案。但与评测机方案相比,JUnit也存在不能测试大批量数据、测试代码繁琐的缺点。一种可行的方案是使用JUnit测试项目中的局部类,用自动评测机测试代码的整体行为,这样在具体的类中JUnit能测试该类的corner case,自动评测机能保证项目整体工作正常。
3. 架构设计
第三单元的一些接口只要根据JML规格去写就好了,稍微复杂的点就是图论的相关算法。第一次作业需要用并查集查询两点之间的关系;第二次作业需要求最小生成树;第三次作业需要求最短路径。
JAVA的类机制可以自然而然地建图,对象与对象的连接可以很方便地用邻接表实现。如下可以实现一个Person节点:
public class Person {
private int data;
public HashMap<Person, Integer> acquiantanceMap;
...
}
这样的实现不需要额外的数据结构,而且能够实现很高的存储效率。
4. 性能问题和修复情况
4.1 Network的queryGroupValueSum()方法:获取某个群组中的人相连边权的总和。
考虑到要查询Group中每两个人之间的权值,因此,不能从Person类入手,因为某一个Person的所有熟人不一定都在当前Group中,没办法记忆化存储。
所以我在提交时直接在Group中实现queryValueSum,具体操作是直接遍历Group中的每一对,然后查询他们之间是否连接,若连接,将他们的连接值加入到总和中。但是这样的操作使得每一次查询的时间复杂度为O(n^2)。考虑到Group中的最大人数为1111,qgvs操作的上限为2000以上,因此在互测中被hack烂了。
每一次查询时都进行如此大规模的计算显然是行不通了,我又想到可以把计算边权和的操作放到每一个改变Group中valueSum的操作中,这样把复杂度分摊,也不至于做一些重复的操作。涉及到改变Group的valueSum的操作有:addRelation, delfromGroup, addtoGroup.
在Network的addRelation操作进行时,枚举每一个Group,若当前Group同时含有建立连接的两个Person,则将Group的权值加上value*2(A->B和B->A的权值都会被记入到valueSum中)。由于Group的数目不多,因此时间复杂度近似在O(1)。
在Group的addtoGroup操作时,将新加的人与之前就存在在Group的人之间逐一查询关系,将存在的连接值的二倍(原因同上)加入到总和中。这样每次操作的时间复杂度为O(n),可以接受。
在Group的delfromGroup操作时,将要删除的人与之后保留的人之间逐一查询关系,把总和valueSum减去存在的连接值的二倍。时间复杂度为O(n), 与addtoGroup正好互为逆操作。
其他查询的操作,也可以把计算的任务分摊到平时每一次的更改或静态初始化过程中,由于在更改时修改查询值与在查询时计算查询值的操作几乎一致,而查询时存在反复计算固定值的无用过程。因此综合来看,密集查询操作下,更改时更新查询值比查询时才计算的时间利用率更高。
4.2 Network的queryBlockSum操作:查询图中的连通块数目
我之前的实现是将queryBlockSum操作交给并查集类RelationController, 在每次求连通块数目时都遍历一遍所有节点,其中每一个节点都有一个father, 相同的father表示两个节点处于一个连通块,不同的father不是所处的连通块不同,找出不同father的个数即为连通块数目。时间复杂度为O(n).
但时间复杂度还可以进一步降低为O(1)。方法是:在并查集类中维护一个变量blockSum。在addPerson时将blockSum加上1, 在addRelation时判断添加关系的两人人之前是否属于同一个连通块,若不是,将blockSum减去1。这样,每次更新的时间复杂度为O(1)。最后,在queryBlockSum时直接将维护的blockSum返回即可,时间复杂度为O(1)。
4.3 附注
求最小生成树、最短路径等操作,改变局部值所产生的影响比较复杂,就不适合在更改时更新查询值。
5. 对Network的扩展
假设出现了几种不同的Person
- Advertiser:持续向外发送产品广告
- Producer:产品生产商,通过Advertiser来销售产品
- Customer:消费者,会关注广告并选择和自己偏好匹配的产品来购买 -- 所谓购买,就是直接通过Advertiser给相应Producer发一个购买消息
- Person:吃瓜群众,不发广告,不买东西,不卖东西
如此Network可以支持市场营销,并能查询某种商品的销售额和销售路径等 请讨论如何对Network扩展,给出相关接口方法,并选择3个核心业务功能的接口方法撰写JML规格(借鉴所总结的JML规格模式)
5.1 Network引入的新方法
所推销的产品Product可以作为一种对象储存在Network中,其中包含属性Name(产品名), Id(唯一编号), price(价格)。
广告Advertisement,可以看做是一种通知消息,其中包含属性productId(所推销产品的Id)、producerId(生产商的Id),其信息内容就是广告的推销内容。
购买消息PurchaseMessage,可以继承自普通消息,其中的person1和person2分别是消费者和生产商,并增加属性productId(所购买产品的Id)。
所支持的新方法如下:
- 添加Advertiser、Producer、Custumor都可以用之前的addPerson来实现。
- addProduct(Product product): 增加一种产品。
- addProductToProducer(int product, int producerId): 为生产商新增一种可生产的产品种类。
- requestAdvertisement(int producerId, int productId, int advertiserId):
生产商请求广告商为自己的产品productId发送广告,要求广告商与生产商有直接连接。
要求producer确实在生产产品product,且product现在不在advertiser的推销列表。
-
sendAdvertisement(Advertisement ad):
类似于一般的消息,广告商可向某个人或者某个群组发送广告消息。要求广告商与人有直接连接,或者广告商在群组内。 -
purchaseProduct(PurchaseMessage purchaseMsg):
消费者向广告商发送某个产品的购买信息,要求消费者与广告商之间有直接连接,消费者要买的东西在广告商的推销列表中,且广告商有相对应的生产者有直接连接。
- List<List
> queryProductSalePath(int productId):
查询某种商品的所有销售路径。
- int queryProductSaleAmount(int productId):
查询某种商品的销售额。
5.2 核心业务功能的JML规格
addProduct 添加产品
/*@ public instance model non_null Product[] products;
@ public instance model non_null int[] saleAmount;
@*/
/*@ public normal_behavior
@ requires !(\exists int i; 0 <= i && i < products.length; products[i] == product);
@ assignable products;
@ ensures products.length == \old(products.length) + 1;
@ ensures (\exists int i; 0 <= i && i < products.length; products[i] == product);
@ ensures (\forall int i; 0 <= i && i < \old(products.length);
@ (\exists int j; 0 <= j && j < products.length; \old(products[i]) == products[j] ) )
@ also
@ public exceptional_behavior
@ signals (EqualProductIdException e) (\exists int i; 0 <= i && i <= products.length;
@ product[i].equals(product))
@ */
public void addProduct(Product product) throws EqualProductIdException;
purchaseProduct 购买产品
/*@ public normal_behavior
@ requires ((purchaseMsg.getPerson1() instanceof Consumer) && (purchaseMsg.getPerson2() instanceof Advertiser)
@ contains(purchaseMsg.getPerson1().getId()) && contains(purchaseMsg.getPerson2().getId())
@ && (purchaseMsg.getPerson1().isLinked(purchaseMsg.getPerson2()))
@ && (\exists int i; 0 <= i && i < products.length; purchaseMsg.productId == products[i].id)
@ && (\exists int i; 0 <= i && i < purchaseMsg.getPerson2().productList.length; purchaseMsg.getPerson2().productList[i].id == purchaseMsg.productId) );
@ assignable purchaseMsg.getPerson1().money, purchaseMsg.getPerson2().money;
@ ensures (purchaseMsg.getPerson1().money == \old(purchaseMsg.getPerson1().money) - getProduct(purchaseMsg.productId).price));
@ ensures (purchaseMsg.getPerson2().money == \old(purchaseMsg.getPerson2().money) + getProduct(purchaseMsg.productId).price));
@ public normal_behavior
@ requires ((purchaseMsg.getPerson1() instanceof Consumer) && (purchaseMsg.getPerson2() instanceof Advertiser)
@ contains(purchaseMsg.getPerson1().getId()) && contains(purchaseMsg.getPerson2().getId())
@ && (\exists int i; 0 <= i && i < products.length; purchaseMsg.productId == products[i].id))
@ && !(\exists int i; 0 <= i && i < purchaseMsg.getPerson2().productList.length; purchaseMsg.getPerson2().productList[i].id == purchaseMsg.productId) );
@ assignable \nothing;
@ also
@ public exceptional_behavior
@ signals (PersonIdNotFoundException e) (!contains(purchaseMsg.getPerson1().getId()) || !contains(purchaseMsg.getPerson2().getId()));
@ signals (ProductIdNotFoundException e) (\forall int i; 0 <= i && i < products.length; purchaseMsg.productId != products[i].id);
@ signals (ConsumerIdNotFoundException e) !(purchaseMsg.getPerson1() instanceof Consumer);
@ signals (AdvertiserIdNotFoundException e) !(purchaseMsg.getPerson2() instanceof Advertiser);
@ signals (RelationNotFoundException e) (!purchaseMsg.getPerson1().isLinked(purchaseMsg.getPerson2()));
@*/
void purchaseProduct(PurchaseMessage purchaseMsg) throws PersonIdNotFoundException, ProductIdNotFoundException,
ConsumerIdNotFoundException, AdvertiserIdNotFoundException, RelationNotFoundException;
queryProductSalePath 查询产品的销售路径
/*@ public normal_behavior
@ requires (\exists int i; 0 <= i && i < products.length; purchaseMsg.productId == products[i].id);
@ assignable \nothing;
@ ensures (\forall Person[] path; path.length == 3 && (path[0] instanceof Producer)
@ && (path[1] instanceof Advertiser)
@ && (path[2] instanceof Consumer)
@ && (\exists int i; 0 <= i && i < path[0].productList.length; path[0].productList[i].id == productId)
@ && (\exists int i; 0 <= i && i < path[1].productList.length; path[1].productList[i].id == productId);
@ (\exists int i; 0 <= i && i <= \result.length;
@ (\forall int j; 0 <= j && j <= \result.get(i).length; \result.get(i).get(j) == path[j] ) );
@ ensures (\forall int i; 0 <= i && i < \result.length; \result.get(i).length == 3 && (\result.get(i)[0] instanceof Producer)
@ && (\result.get(i)[1] instanceof Advertiser)
@ && (\result.get(i)[2] instanceof Consumer)
@ && (\exists int i; 0 <= i && i < \result.get(i)[0].productList.length; \result.get(i)[0].productList[i].id == productId)
@ && (\exists int i; 0 <= i && i < \result.get(i)[1].productList.length; \result.get(i)[1].productList[i].id == productId));
@ ensures (\forall int i; 0 <= i && i < \result.length;
@ (\forall int j; i < j && j < \result.length;
@ \result.get(i).length != \result.get(j).length
@ || (\result.get(i).length == \result.get(j).length
@ && (\exists int k; 0 <= k && k < \result.get(i).length; \result.get(i).get(k) != \result.get(j).get(k)))));
@ public exceptional_behavior
@ signals (ProductIdNotFoundException e) !(\exists int i; 0 <= i && i < products.length; purchaseMsg.productId == products[i].id);
*/
List<List<Person>> queryProductSalePath(int productId) throws ProductIdNotFoundException;
6. 本单元学习体会
-
学完本单元后,我感觉JML是一种非常好用地形式化描述我们需求的工具,这种方法摆脱了文字描述的二义性,能非常准确地传达需求的内容,也方便使用形式化工具对代码进行测试。之后,如果某个场合需要很详细、准确地描述需求,可以首选JML规格语言。
-
对代码时间复杂度的判定不能只靠自己的本地测试或者评测机随机测试,而是要逐个查看自己的外部方法,按照题目给出的限制逐一比对复杂度,不能有侥幸心理。我就是以为queryGroupValueSum方法不会被调用很多次才放松了警惕,实现的代码时间复杂度为O(n^2),导致最后互测被hack超时好几次。
-
在这个单元我也实现了很多经典图论算法,如最短路、最小生成树、并查集等等,对这些算法的实现更加熟悉了。