BUAA_OO第三单元总结
BUAA_OO第三单元总结
(1) 分析在本单元自测过程中如何利用JML规格来准备测试数据
与之前的作业不同,本单元的作业在测试环节有较大变化。一方面,类及相应的属性和方法数量较多,功能复杂,且各个方法相对独立,想测试不同的方法需要输入不同的数据,这意味着之前手动构造数据或随即生成大量数据的方法不能覆盖所有情况。另一方面,本单元作业有着严格的JML规格约束,对“正确性”的要求更高,判断输出的正确性所需的步骤更多。综合这两方面,本单元的作业应采取不一样的测试思路。为此,可以采取以下若干步骤,循序渐进,最大程度保证程序的正确性。
-
初步覆盖测试:借助
JUnit
等工具实现JUnit
单元测试是白盒测试的一种,对于每一个我们实现的类,都可以构造一个对应的测试类- 为每一个方法定义一个测试方法
- 运行测试并查看结果
- 由此可见,定义测试方法是最关键的步骤。首先,需要人为构造一些测试数据。其次,需要断言程序的输出是否符合JML规格的要求。这两个步骤都不能随意而为之。在构造测试数据时,需要构造以下三类数据:正常的数据、边界数据、异常的数据。既要测试正确数据的表现,又要测试是否能够如愿抛出异常。在断言时,需要根据JML规格的后置条件,设定适当的
assertEquals、assertSame、assertNull、assertTrue
等语句 - 需要特别注意的是,这种测试方法只能初步测试程序的正确性。对于一些功能相对简单的方法而言,它是较为有效的。但是对于一些功能较为复杂的方法而言,它就显得有些力不从心了。因此,还需进行后续的测试步骤。
-
初步随机测试:随机生成数据
- 这是以前最常见的测试方法:对程序进行无差别攻击,利用数据的数量抗衡程序的复杂度,试图找出可能存在的薄弱点。但需要稍加注意的是,由于一些方法之间存在逻辑上的依赖关系,在生成测试数据时需要注意输入指令之间的关联,不能完全“随机”。
- 判断正确性的方式与借助
JUnit
等工具实现的方式类似,只不过是改为批量处理
-
关键方法测试:压力测试
- 对于每次作业复杂度最高的方法,还需要进行单独测试。一方面是最基本的的正确性测试。此处可以借助
python
等其它工具另行编写一份较为简单但保证正确的代码以实现同样的功能,再随机生成一个图,分别喂入这两处,进行对拍。这里最重要的其实是图的生成。可以选择多种图进行测试,最后可以直接生成MN图
等等,再随机给每条边生成权值。 - 接着还需要进行压力测试。生成的数据需要大于指导书所保证的数据上限,观察CPU运行时间。如果超时或时间过长,可能需要思考还有哪些地方可以继续改进。
- 对于每次作业复杂度最高的方法,还需要进行单独测试。一方面是最基本的的正确性测试。此处可以借助
(2) 梳理本单元的架构设计,分析自己的图模型构建和维护策略
由于本单元三次作业是迭代开发的,故统一对图的构建和维护策略进行解释。
-
设计原则:
- 简洁!简洁!还是简洁! 不额外设置自己的类,不额外建立
Edge
等数据结构 - 高效!高效!还是高效! 动态维护、并查集、路径压缩、堆优化......尽最大努力优化复杂度及CPU耗时
- 又要简洁,又要高效,鱼与熊掌可以兼得吗?事实证明,这是完全可以的。
- 简洁!简洁!还是简洁! 不额外设置自己的类,不额外建立
-
图的结点:
MyPerson
本身。所有与结点有关的操作均整合其中。 -
图的边:底层逻辑是邻接表,该表正好位于
MyPerson
的acquaintance
中。 -
【连通分量的维护】
-
使用并查集算法。设置一个
HashMap<Integer, Integer> relations
,key
是结点的id
,value
是结点对应的代表元。查询两个人是否位于同一个连通分量,只需查询这两个人对应的value
是否相等。 -
最初的
key
和value
相等。在查询的过程中同时维护relations
本身private int find(int id) { if (this.relations.get(id) != id) { this.relations.put(id, find(this.relations.get(id))); } return this.relations.get(id); }
-
-
【最小生成树】
-
有两种不同的思路可以实现这一功能。一是动态维护最小生成树,二是每次单独计算最小生成树。
-
可以同时实现了这两种方法并对比时间
-
动态维护最小生成树:每次
addRelation
时进行计算。当添加的关系连接了两个连通分量时,可以直接合并这两个最小生成树,不复杂。但当添加的关系的两人位于同一个连通分量时,就需要进行进一步的处理。根据树的性质,此时,树中有且仅有一个圈,这个圈包含了刚刚新加入的那条边。找到这个圈,删除其中权值最大的边(若有多条可随机删除一条),剩下的便是新的最小生成树。这个算法看似思路比较简洁,但实际实现后复杂度并不低。整个过程最关键的是找圈的过程,不得不使用DFS
进行搜索。 -
动态维护的优缺点:
- 优点:动态维护结果,后续不管输入多少个
qlc
指令都不用计算,瞬间给出答案 - 缺点:每一次
addRelation
都要计算一次,每一次的计算时间都不短。而添加关系又是一个很常见的指令,故有可能超时。而且即使整个输入没有qlc
指令,还是要白白耗时进行动态维护。
- 优点:动态维护结果,后续不管输入多少个
-
每次单独计算最小生成树:通俗地说,就是每次处理
qlc
时,直接使用Prim
或Kruskal
算法,每次都是从头计算一遍。乍一听非常耗时,但实际表现并不糟糕。以Prim
为例,首先临时设置一个优先队列,用于存储加入的边。临时使用int[2]
表示边,包括终点和权值。使用person.getAcq()
获取“邻接表”,使用person.queryValueId
获取邻接边的权值。其余步骤便是普通的算法步骤。 -
单独计算的优缺点:
- 优点:只是在
qlc
时会进行计算,这有什么意义呢?上文也提及addRelation
是一个很常见的命令,在后续作业中也会继续用到,如果在这条指令上消耗大量的时间,很有可能超时,或者白白浪费很多时间 - 缺点:每一次都从头计算,不能利用之前辛辛苦苦运算得到的结果
- 优点:只是在
-
关键部分的代码:
... ... PriorityQueue<int[]> pq = new PriorityQueue<>(Comparator.comparingInt(o -> o[0])); pq.add(new int[]{0, id}); ... ... for (int idp : person.getAcq()) { w = person.queryValueId(idp); if (!mst.contains(idp)) { pq.add(new int[]{w, idp}); } } ... ...
-
-
【最短路径】
- 事实上,使用堆优化的
Dijkstra
算法即可,一些关键操作与最小生成树完全一致,仅仅是稍微改变了一些实现逻辑。故不再赘述。
- 事实上,使用堆优化的
(3) 按照作业分析代码实现出现的性能问题和修复情况
以下,将本次作业复杂度较高的指令一一列举
-
Group: public int getAgeVar()
- 讨论区已经给出了优化方法及计算公式:维护年龄的总和以及年龄总和的平方即可
-
Group: public int getValueSum()
- 在
addRelation、addToGroup、delFromGroup
中进行动态维护,注意乘2,2 * p.queryValue(person)
- 在
-
Network: public boolean isCircle, public int queryBlockSum
- 上文已经提到,使用并查集算法
-
Network: public int queryLeastConnection
- 最小生成树,上文已说明
-
Network: public int sendIndirectMessage
- 最短路径,上文已说明
本单元第三次作业没有出现CTLE
,而且最大用时能基本控制在5s
内,说明与树有关的直接计算的方法是可行的。然而之前的作业因为对qgvs
的优化不完全而导致被hack了,修复后用时正常。
(4) 请针对下列内容对Network进行扩展,并给出相应的JML规格
假设出现了几种不同的
Person
Advertiser
:持续向外发送产品广告Producer
:产品生产商,通过Advertiser
来销售产品Customer
:消费者,会关注广告并选择和自己偏好匹配的产品来购买 -- 所谓购买,就是直接通过Advertiser
给相应Producer
发一个购买消息Person
:吃瓜群众,不发广告,不买东西,不卖东西如此
Network
可以支持市场营销,并能查询某种商品的销售额和销售路径等。请讨论如何对Network
扩展,给出相关接口方法,并选择3个核心业务功能的接口方法撰写JML规格(借鉴所总结的JML规格模式)
- 首先,可以新建一个商品类,表示商品信息。其次,可以添加几种新的消息类型,分别表示购买或推销某种产品,其中可能包含商品类、购买数量、销售商、购买者等。从原则上讲,可以延用之前的与
message
有关的方法。 Producer
向某一Advertiser
发送推销message
。可以延用sendMessage
。但也可以换一种思路:生产商必然认识推销员,生产商向推销员推销自己的商品时,接收消息的对象可以是自己的熟人中的所有推销员。因此,生产商推销产品的过程可以分解为:首先通过addProduct
添加产品信息,再通过addMessage
添加推销信息,不妨规定这一信息的type为2。再通过sendMessage
向自己认识的所有推销员发送推销信息。Advertiser
向某一Group
发送推销message
。首先,通过addMessage
添加广告信息,不妨设这以消息的type为3。再通过sendMessage
向某一组员发送广告。Customer
购买产品,实际上是向Advertiser
发送消息,Advertiser
再将此消息传递给Producer
。首先通过addMessage
添加购买信息,再通过sendMessage
向推销员发送购买信息,之后触发推销员向生产商发送购买信息。- 查询某种商品的销售额,可以新设方法
queryProductSales(int id)
,返回产品的销售额。 - 查询某种商品的销售路径,可以新设方法
queryProductPath(int id)
,返回产品的生产商、购买者和他们之间的推销员信息。
[ JML规格 ]
【注:由于之前addMessage
和sendMessage
中的JML含有大量与新增内容无关的描述,为了使JML规格看起来更为简洁,下列方法中只单独列出与扩展内容有关的部分】
/* 这是生产商向特定销售员推销产品的方法 */
/*@ public normal_behavior
@ requires !(\exists int i; 0 <= i && i < messages.length; messages[i].equals(message)) &&
@ (message instanceof AdvertiseMessage) ==> containsProductId(((AdvertiseMessage) message).getProductId()) &&
@ (message.getType() == 2) ==> (message.getProducer() != message.getAdvertiser());
@ assignable messages;
@ ensures messages.length == \old(messages.length) + 1;
@ ensures (\forall int i; 0 <= i && i < \old(messages.length);
@ (\exists int j; 0 <= j && j < messages.length; messages[j].equals(\old(messages[i]))));
@ ensures (\exists int i; 0 <= i && i < messages.length; messages[i].equals(message));
@ also
@ public exceptional_behavior
@ signals (EqualMessageIdException e) (\exists int i; 0 <= i && i < messages.length;
@ messages[i].equals(message));
@ signals (ProductIdNotFoundException e) !(\exists int i; 0 <= i && i < messages.length;
@ messages[i].equals(message)) &&
@ (message instanceof AdvertiseMessage) &&
@ !containsEmojiId(((AdvertiseMessage) message).getProductId());
@ signals (EqualPersonIdException e) !(\exists int i; 0 <= i && i < messages.length;
@ messages[i].equals(message)) &&
@ ((message instanceof AdvertiseMessage) ==>
@ containsProductId(((AdvertiseMessage) message).getProductId())) &&
@ message.getType() == 2 && message.getProducer() == message.getAdvertiser();
@*/
public void addAdvertiseMessage(Message message) throws
EqualMessageIdException, ProductIdNotFoundException, EqualPersonIdException;
/* 这是销售员向组员发送推销信息的方法 */
/*@ public normal_behavior
@ requires containsMessage(id) && getMessage(id).getType() == 3 &&
@ getMessage(id).getGroup().hasPerson(getMessage(id).getAdvertiser());
@ assignable people[*].products, 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 Person p; \old(getMessage(id)).getGroup().hasPerson(p); \old(p.products.length) == p.products.length-1;
@ ensures (\forall Person p; \old(getMessage(id)).getGroup().hasPerson(p); (\exists int i; 0 <= i && i < p.products.length; products[i].equals(\old(((AdvertiseMessage)getMessage(id))).getProduct()));
@ ensures (\forall Person p; \old(getMessage(id)).getGroup().hasPerson(p); (\forall int i; 0 <= i && i < \old(p.products.length);(\exists int j; 0 <= j && j <= p.products.length;\old(p.products[i]).equals(p.products[j]) ));
@ ensures (\forall Person p; !\old(getMessage(id)).getGroup().hasPerson(p); \old(p.products.length) == p.products.length;
@ ensures (\forall Person p; !\old(getMessage(id)).getGroup().hasPerson(p); (\forall int i; 0 <= i && i < \old(p.products.length);(\exists int j; 0 <= j && j <= p.products.length;\old(p.products[i]).equals(p.products[j]) ));
@ also
@ public exceptional_behavior
@ signals (MessageIdNotFoundException e) !containsMessage(id);
@ signals (PersonIdNotFoundException e) containsMessage(id) && getMessage(id).getType() == 3 &&
@ !(getMessage(id).getGroup().hasPerson(getMessage(id).getAdvertiser()));
@*/
public void sendAdvertiseMessage(int id) throws
MessageIdNotFoundException, PersonIdNotFoundException;
/* 查询商品销售额的方法 */
/*@ public normal_behavior
@ requires containsProduct(id);
@ ensures \result == getProduct(id).getSales();
@ also
@ public exceptional_behavior
@ signals (ProductIdNotFoundException e) !containsProduct(id);
@*/
public /*@ pure @*/ int queryProductSales(int id) throws ProductIdNotFoundException;
(5) 本单元学习体会
-
本单元作业的大部分要求都是通过JML规格体现的,因此首先需要深入理解JML,包括但不限于相关语法、特性、注意事项等。阅读并JML不是一个简单的过程,需要仔细推敲其中的每一句话,不能忽略每一个条件。尤其是一些细节,一旦理解出现偏差很有可能导致大
bug
的出现。此外,对于一些复杂的方法,JML语言与自然语言会有很大的差别。例如最小生成树和最短路径的表述,很难在第一时间理解透彻。这就要求读者拥有一定的耐心和逻辑推理能力。 -
本单元对算法的要求较高,但好在涉及的都是一些较为经典的算法,有很多现有的资料可供参考。这不仅锻炼了同学们的编程能力和对数据结构课程的熟悉程度,还间接考察了同学们搜索资料并辨别真伪的能力。因为现有网络上的很多参考代码是错误的,不仅不能提供正确思路,反而可能会造成误导。从泥沙中淘金,也不是一件容易的事情。
-
本单元作业除了较为复杂的几个方法,其它的方法本身并不复杂,实现逻辑较为简单。但简单不意味着正确,正是因为看似只需“无脑翻译JML”,才可能导致一些细节上的疏漏或错误。因此,在完成作业时,需要自己阅读JML,反复与自己的实现过程进行比对,查看是否有不一致之处。细节决定成败。这次作业真正的失分之处可能并不是那些重量级的查询命令,而是其它不曾注意到的地方。例如我就因为在某一个数组初始化时少写了三个零而酿成大祸。