面向对象第三单元博客作业

仇志轩-22373135 学生 2024-05-19 17:49:25

本次作业要求撰写的内容全部用📝️标出。

课程要求

第三单元的任务是模拟社交单元网络。每次作业都会给出 JML 规格,要求同学们实现。其重点在于规格理解与代码实现,其难点在于性能优化。

第三单元分为以下三次迭代:

  1. 第一次迭代有以下三项任务:
    1. 根据 JML 规范,实现 MyPersonMyNetwork 类的诸多方法。
    2. 实现 4 个具有计数功能的异常类:MyPersonIdNotFoundExceptionMyEqualPersonIdExceptionMyRelationNotFoundExceptionMyEqualRelationException
    3. Network#queryTripleSum() 编写 Junit 单元测试,对多份正确或错误的代码进行测试。
  2. 第二次迭代有以下三项任务:
    1. 根据 JML 规范,实现 MyPersonMyNetworkMyTag 类的诸多方法。
    2. 实现 4 个具有计数功能的异常类:MyAcquaintanceNotFoundExceptionMyEqualTagIdExceptionMyPathNotFoundExceptionMyTagIdNotFoundException
    3. Network#queryCoupleSum() 编写 Junit 单元测试,对多份正确或错误的代码进行测试。
  3. 第三次迭代有以下三项任务:
    1. 根据 JML 规范,实现 MyPersonMyNetworkMyMessageMyRedEnvelopeMessageMyNoticeMessageMyEmojiMessage 类的诸多方法。
    2. 实现 4 个具有计数功能的异常类:MyEmojiIdNotFoundExceptionMyEqualEmojiIdExceptionMyEqualMessageIdExceptionMyMessageIdNotFoundException
    3. Network#deleteColdEmoji(int) 编写 Junit 单元测试,对多份正确或错误的代码进行测试。

开发流程

在本单元中,我每次作业的开发流程可以分为如下四步:

  1. 根据 JML 规格,实现朴素算法。
  2. 消除 Checkstyle 问题。
  3. 优化算法。
  4. 消除警告,清理代码。

其中第三步是本单元的特色。

📝️纵观本单元的架构设计,模型和接口都已由 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_networkload_network_local 通过调用 add_personadd_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)$

中级优化

基础优化对算法进行微调,从而让复杂度超标的点降至合理水平。

  1. 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)$。

    求出了改进后的复杂度,改进前的复杂度也就不难求出了。

  2. query_tag_value_sum 的朴素算法是枚举 Tag 中的每两个人,并累加他们之间的 value,最坏情况下复杂度可以达到 $O(n^2)$。我们将其改为枚举 Tag 中的每个人,再遍历其所有邻居,对 value 进行累加。通过计算,我们发现这个小小的改动竟然可以让复杂度降到 $O(m)$!超时问题也就迎刃而解。

高级优化

高阶优化对尽可能多的数据进行动态维护,从而进一步降低复杂度。query_tag_age_varquery_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 类,它可以调用 MyNetworkMyPerson 类的一系列查询方法来获取所有数据,重建整个社交关系网,相当于是为 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);

这个方法的规格看似很复杂,实际上一点也不简单。

先看返回值。为了确保返回值正确,我们仍然亲自实现一遍被测试方法,并将自己计算得到的返回值与实际返回值进行对比。再看副作用,本函数使用 assignableensures 来限定函数的副作用。由于一条一条比对过于麻烦,我的做法是:将修改数据的操作也亲自实现一遍,作为“标准答案”与实际进行对比。

    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 永远正确

参考资料

...全文
175 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

301

社区成员

发帖
与我相关
我的任务
社区描述
2023年北航面向对象设计与构造
学习 高校
社区管理员
  • YannaZhang
  • CajZella
  • C_ecelia
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧