301
社区成员
发帖
与我相关
我的任务
分享本次作业要求撰写的内容全部用📝️标出。
第三单元的任务是模拟社交单元网络。每次作业都会给出 JML 规格,要求同学们实现。其重点在于规格理解与代码实现,其难点在于性能优化。
第三单元分为以下三次迭代:
MyPerson 和 MyNetwork 类的诸多方法。MyPersonIdNotFoundException、MyEqualPersonIdException、MyRelationNotFoundException、MyEqualRelationException。Network#queryTripleSum() 编写 Junit 单元测试,对多份正确或错误的代码进行测试。MyPerson、MyNetwork、MyTag 类的诸多方法。MyAcquaintanceNotFoundException、MyEqualTagIdException、MyPathNotFoundException、MyTagIdNotFoundException。Network#queryCoupleSum() 编写 Junit 单元测试,对多份正确或错误的代码进行测试。MyPerson、MyNetwork、MyMessage、MyRedEnvelopeMessage、MyNoticeMessage、MyEmojiMessage 类的诸多方法。MyEmojiIdNotFoundException、MyEqualEmojiIdException、MyEqualMessageIdException、MyMessageIdNotFoundException。Network#deleteColdEmoji(int) 编写 Junit 单元测试,对多份正确或错误的代码进行测试。在本单元中,我每次作业的开发流程可以分为如下四步:
其中第三步是本单元的特色。
📝️纵观本单元的架构设计,模型和接口都已由 JML 限定。社交网络可以抽象为一张无向图,由 MyNetwork 类管理并负责所有操作;社交网络中的每个人可以抽象为图中的结点,其联系人可以使用邻接表维护,这些由 MyPerson 类管理;每个人可以对联系人进行分组,由 MyTag 类管理;每个人可以向单个联系人或组中所有联系人发送各式各样的消息,由 MyXxxMessage 类管理。在执行操作时,由 Runner 类调用 MyNetwork 类的各个方法,然后 MyNetwork 类再借助其他类的方法完成操作。
📝️虽然模型和接口已经被固定,但是算法复杂度方面仍有较大的改进空间:与线性表相比,哈希表具有更优的查询性能;与每次查询时计算相比,动态维护显然开销更小。接下来我来详细分析本单元的优化策略。
性能优化是本次作业的一大难点。
本单元数据量较大,且评测机限定运行时间为 10 秒,因此使用低效算法很容易在互测中被 hack、在强测中超时。这就要求我们对各种操作进行时间复杂度分析,并对超时算法进行优化。为了让我们的程序可以通过最极端数据的考验,我们只分析算法的最坏时间复杂度。
第一次迭代规定:指令条数不多于 10000 条,且初始建图(load_network)时结点数量不超过 300;后两次迭代规定:指令条数不多于 10000 条,且初始建图时结点数量不超过 100。设最终结点数量为 $n$,边数量为 $m$,则考虑最极端的情况,我们可以得出 $0 \leq n \leq 10000, 0 \leq m \leq 10000$。由于评测机限定运行时间为 10 秒,所以对于单次操作,$O(n), O(m), O(n+m)$ 等复杂度的算法是可以接受的;而 $O(n^2), O(nm), O(n^3)$ 等复杂度的算法是不可接受的。
以下是不同算法的时间复杂度:
| 指令 | 未优化 | 数据结构优化 | 三角形优化 | 并查集优化 |
|-|-|-|-|-|
| add_person | $O(n)$ | $O(1)$ | $O(1)$ | $O(1)$ |
| add_relation | $O(n)$ | $O(1)$ | $O(n)$ | $O(n + \alpha(n))$ |
| modify_relation | $O(n)$ | $O(1)$ | $O(n)$ | $O(m \alpha(n) + n)$ |
| query_value | $O(n)$ | $O(1)$ | $O(1)$ | $O(1)$ |
| query_circle | $O(n+m)$ | $O(n+m)$ | $O(n+m)$ | $O(\alpha(n))$ |
| query_block_sum | $\textcolor{red}{O(n^2 (n+m))}$ | $O(n+m)$ | $O(n+m)$ | $O(1)$ |
| query_triple_sum | $\textcolor{red}{O(n^4)}$ | $\textcolor{red}{O(n^3)}$ | $O(1)$ | $O(1)$ |
| load_network | 略 | 略 | 略 | 略 |
| load_network_local | 略 | 略 | 略 | 略 |
注:
MyNetwork 类的对应方法,Runner 类在执行某些操作时会调用多个 MyNetwork 类的方法。load_network 和 load_network_local 通过调用 add_person 和 add_relation 来建图,所以无需考虑它们的复杂度。由于第一次中测时大多数人都没有意识到性能优化的重要性,所以仅仅是复刻了 JML 的规格。在 JML 中,各种数据结构都被抽象为数组,于是大家都使用了数组来存储数据,给性能带来了较大影响。强测结果公布后,大家得分普遍不高,这才开始重视性能优化,并想出了各种优化方式。
ArrayList 查询速度过慢,改用 HashMap 查询更快。在 MyPerson 中,以 MyPerson 为键、边权为值存储邻接表;在 MyNetwork 中,以 id 为键、MyPerson 为值存储所有人。
除此之外,query_block_sum 中无需任取两点进行 DFS,只需不断查找未遍历的结点并进行 DFS 即可。
虽然时间复杂度已经得到了提升,但 query_triple_sum 仍然会导致超时。即使将上次查询的值缓存起来,一旦对图进行修改缓存数据就会失效。因此,我们需要一种更优的算法——动态维护三角形数量。
动态维护的原理并不复杂:当增加连接 $u$ 与 $v$ 的边时,若有 $c$ 个结点与 $u$、$v$ 都相邻,则三角形数量增加 $c$。当删除连接 $u$ 与 $v$ 的边时,若有 $c$ 个结点与 $u$、$v$ 都相邻,则三角形数量减少 $c$。
虽然此举让加边、删边的最坏复杂度达到了 $O(n)$,但查询复杂度降到了 $O(1)$,成功化解了危机。
| 指令 | 初级优化 | 中级优化 |
|---|---|---|
add_tag | $O(1)$ | $O(1)$ |
del_tag | $O(1)$ | $O(1)$ |
add_to_tag | $O(1)$ | $O(1)$ |
del_from_tag | $O(1)$ | $O(1)$ |
query_tag_value_sum | $\textcolor{red}{O(n^2)}$ | $O(m)$ |
query_tag_age_var | $O(n)$ | $O(n)$ |
query_best_acquaintance | $O(n)$ | $O(n)$ |
query_couple_sum | $\textcolor{red}{O(nm)}$ | $O(m)$ |
query_shortest_path | $O(n+m)$ | $O(n+m)$ |
基础优化对算法进行微调,从而让复杂度超标的点降至合理水平。
query_couple_sum 的朴素算法是枚举 Tag 中的每个人,求出他的 best acquaintance,然后再求他的 best acquaintance 的 best acquaintance,这种做法是 $O(nm)$ 的。这里的不合理之处在于,对于每个人来说,他的 best acquaintance 是不变的。但每次调用 Network#queryBestAcquaintance(int) 时都会重复计算,浪费时间。
如果我们提前把 $n$ 个人的 best acquaintance 求出来,然后再挨个对比,就能将复杂度降到 $O(m)$。为什么是 $O(m)$ 呢?这里要感谢吴旭飞大佬的分析:改进后的算法会遍历(Tag 中)每个人的所有邻居。如果从点的角度分析,我们无法确定每个点被遍历了多少次。但如果从边的角度分析,每条边都恰好被遍历了 $2$ 次(对于边 ${u,v}$,$u$ 在 query best acquaintance 时遍历一次,$v$ 在 query best acquaintance 时遍历一次),所以复杂度就是 $O(2m)$,也即 $O(m)$。
求出了改进后的复杂度,改进前的复杂度也就不难求出了。
query_tag_value_sum 的朴素算法是枚举 Tag 中的每两个人,并累加他们之间的 value,最坏情况下复杂度可以达到 $O(n^2)$。我们将其改为枚举 Tag 中的每个人,再遍历其所有邻居,对 value 进行累加。通过计算,我们发现这个小小的改动竟然可以让复杂度降到 $O(m)$!超时问题也就迎刃而解。
高阶优化对尽可能多的数据进行动态维护,从而进一步降低复杂度。query_tag_age_var 和 query_best_acquaintance 都是可以动态维护的。但是由于付出太多,收益太小,风险太大,我并没有进行尝试,也没有做复杂度分析。
| 指令 | 方法 | 初级优化 |
|---|---|---|
add_message | #addMessage(Message) | $O(1)$ |
send_message | #sendMessage(int) | $O(n)$ |
query_social_value | #querySocialValue(int) | $O(1)$ |
query_received_messages | #queryReceivedMessages(int) | $O(1)$ |
add_red_envelope_message | #addMessage(Message) | $O(1)$ |
add_notice_message | #addMessage(Message) | $O(1)$ |
clear_notices | #clearNotices(int) | $O(n)$ |
add_emoji_message | #addMessage(Message) | $O(1)$ |
store_emoji_id | #storeEmojiId(int) | $O(1)$ |
query_popularity | #queryPopularity(int) | $O(1)$ |
delete_cold_emoji | #deleteColdEmoji(int) | $O(n)$ |
query_money | #queryMoney(int) | $O(1)$ |
第三次迭代中没有复杂度超标的操作,所以无需优化。
在本单元中,规格与实现是分离的:JML 规格由课程组提供,但实现是由同学实现的。实现可能会出现各种性能问题,但 JML 始终是绝对的真理。
以下是我对各种测试概念的理解:
本单元的任务之一是为指定函数编写 JUnit 单元测试。评测机会用你的测试代码去测试多份正确或错误的代码实现,你的测试代码应该让正确的实现通过测试,并让错误的实现无法通过测试。但是,你无法查看这些代码实现的内容,只能通过加强测试手段来提升测试准确度。
编写 JUnit 单元测试可分为两大步骤:规格测试与构造数据。
在规格测试中,我们需要仔细阅读 JML 规格,并将其转化为测试代码。我们首先以 Network#queryTripleSum() 为例:
/*@ ensures \result ==
@ (\sum int i; 0 <= i && i < persons.length;
@ (\sum int j; i < j && j < persons.length;
@ (\sum int k; j < k && k < persons.length
@ && getPerson(persons[i].getId()).isLinked(getPerson(persons[j].getId()))
@ && getPerson(persons[j].getId()).isLinked(getPerson(persons[k].getId()))
@ && getPerson(persons[k].getId()).isLinked(getPerson(persons[i].getId()));
@ 1)));
@*/
public /*@ pure @*/ int queryTripleSum();
这个方法有两条规则,一是 pure,限定方法不得有任何副作用;二是 ensures,限定返回值应该如何计算。
对于前者,我们可以保存执行方法前后整个 MyNetwork 的所有变量。需要注意的是,MyNetwork#getPersons() 返回的是 person 数组的浅拷贝,所有调用方法后其值也会发生改变。此时我们就需要对其进行深拷贝。为此,我设计了 MyNetworkData 类,它可以调用 MyNetwork 和 MyPerson 类的一系列查询方法来获取所有数据,重建整个社交关系网,相当于是为 MyNetwork 做了一张“快照”。因此,直接对调用方法前后的“快照”进行比较即可。
对于后者,我们可以根据规格计算正确的返回值,然后与实际的返回值进行比较。
@Test
public void queryTripleSum() {
int expectedResult = 0;
... // 根据规格计算 expectedResult
final MyNetworkData oldMyNetworkData = new MyNetworkData(myNetwork);
int actualResult = myNetwork.queryTripleSum();
Person[] newPersons = myNetwork.getPersons();
final MyNetworkData newMyNetworkData = new MyNetworkData(myNetwork);
assertEquals(expectedResult, actualResult);
assertEquals(oldMyNetworkData, newMyNetworkData);
}
接下来我们再以 Network#deleteColdEmoji(int) 为例:
/*@ public normal_behavior
@ assignable emojiIdList, emojiHeatList, messages;
@ ensures (\forall int i; 0 <= i && i < \old(emojiIdList.length);
@ (\old(emojiHeatList[i] >= limit) ==>
@ (\exists int j; 0 <= j && j < emojiIdList.length; emojiIdList[j] == \old(emojiIdList[i]))));
@ ensures (\forall int i; 0 <= i && i < emojiIdList.length;
@ (\exists int j; 0 <= j && j < \old(emojiIdList.length);
@ emojiIdList[i] == \old(emojiIdList[j]) && emojiHeatList[i] == \old(emojiHeatList[j])));
@ ensures emojiIdList.length ==
@ (\num_of int i; 0 <= i && i < \old(emojiIdList.length); \old(emojiHeatList[i] >= limit));
@ ensures emojiIdList.length == emojiHeatList.length;
@ ensures (\forall int i; 0 <= i && i < \old(messages.length);
@ (\old(messages[i]) instanceof EmojiMessage &&
@ containsEmojiId(\old(((EmojiMessage)messages[i]).getEmojiId())) ==> \not_assigned(\old(messages[i])) &&
@ (\exists int j; 0 <= j && j < messages.length; messages[j].equals(\old(messages[i])))));
@ ensures (\forall int i; 0 <= i && i < \old(messages.length);
@ (!(\old(messages[i]) instanceof EmojiMessage) ==> \not_assigned(\old(messages[i])) &&
@ (\exists int j; 0 <= j && j < messages.length; messages[j].equals(\old(messages[i])))));
@ ensures messages.length == (\num_of int i; 0 <= i && i < \old(messages.length);
@ (\old(messages[i]) instanceof EmojiMessage) ==>
@ (containsEmojiId(\old(((EmojiMessage)messages[i]).getEmojiId()))));
@ ensures \result == emojiIdList.length;
@*/
public int deleteColdEmoji(int limit);
这个方法的规格看似很复杂,实际上一点也不简单。
先看返回值。为了确保返回值正确,我们仍然亲自实现一遍被测试方法,并将自己计算得到的返回值与实际返回值进行对比。再看副作用,本函数使用 assignable 和 ensures 来限定函数的副作用。由于一条一条比对过于麻烦,我的做法是:将修改数据的操作也亲自实现一遍,作为“标准答案”与实际进行对比。
public MyNetworkData filterEmojiHeat(int limit) {
ArrayList<MessageData> tempMessageData = new ArrayList<>();
ArrayList<EmojiData> tempEmojiData = new ArrayList<>();
HashSet<Integer> preservedEmojiIds = new HashSet<>();
for (EmojiData emoji : emojiData) {
if (emoji.emojiHeat >= limit) {
tempEmojiData.add(emoji);
preservedEmojiIds.add(emoji.emojiId);
}
}
for (MessageData message : messageData) {
if (message.emojiId == 0 || preservedEmojiIds.contains(message.emojiId)) {
tempMessageData.add(message);
}
}
return new MyNetworkData(
complete, personData, relationData, tempMessageData.toArray(new MessageData[0]),
tempEmojiData.toArray(new EmojiData[0])
);
}
有了上述工具函数,编写单元测试就十分容易了:
@Test
public void deleteColdEmoji() {
int limit = ...; // 计算 limit,详见下节
int[] emojiHeatList = myNetwork.getEmojiHeatList();
int expectedResult = 0;
... // 根据规格计算 expectedResult
MyNetworkData oldMyNetworkData = new MyNetworkData(myNetwork);
MyNetworkData expectedMyNetworkData = oldMyNetworkData.filterEmojiHeat(limit);
int actualResult = myNetwork.deleteColdEmoji(limit);
MyNetworkData newMyNetworkData = new MyNetworkData(myNetwork);
assertEquals(expectedResult, actualResult);
assertEquals(expectedMyNetworkData, newMyNetworkData);
}
构造测试数据可以分为手工构造数据和自动生成数据。
我采用的是手工构造数据的方法:手工构造出整张图,然后调用被测试函数进行测试。这种方法构造的数据量很少,因此我在第三次迭代时遇到了测试强度不足的情况,有些 JUnit 测试点最终没能通过。
还有一种方法是自动生成数据:像评测机一样生成各种指令,而并不仅仅是被测指令。这种方法生成的数据量非常大,但是实现也较为复杂。
本单元的重点是理解 JML 规格并将其转化为代码实现,难点在于根据图论算法进行时间复杂度优化。通过本单元的学习,我在这两方面的能力都有很大的提高。
本单元使用 JML 来表述代码规格,让大家对契约式开发有了基本的了解。
虽然今年课程组对第三单元进行了大刀阔斧的改革,但 JML 作为核心始终不变。因为没有 JML,就没有 OO 第三单元。虽然在改革过程中,指导书难免会出现小的错误,但 JML 始终是绝对的真理,伟大的 JML 永远正确!