Loading

北航面向对象设计与构造2021第三单元作业总结

一、实现规格要求所采取的设计策略和体会

本单元作业是通过 JML 规格来模拟实现一个社交网络。首先要读指导书,对作业有宏观认识。再读规格,如果比较简单,就直接翻译实现;如果比较复杂,就需要转化为自己的理解,然后再实现。

JML 作为一种形式化的规格建模语言,为体现严谨性,消除自然语言的歧义,势必会有啰嗦的地方。因此在实现时,我发现了一些共同之处,印象最深的就是这两条:

  1. 添加东西:旧的不能变,新的加进来,总数增加新加进来的数量。
  2. 删除东西:除了删掉的以外都不变,总数减少删除的数量。

使用容器时,添加和删除基本都是一行的事,而 JML 规格却需要啰啰嗦嗦写一大堆。因此可以发现 JML 规格并不实用。如果这样的规格占大多数,程序员可能会疲惫,看到类似的模式就写成添加或删除。如果规格有细微差别,则很容易会被忽视。

二、结合课程内容,整理基于 JML 规格来设计测试的方法和策略

很惭愧,因为各种原因,本单元作业并未针对规格进行详尽的测试。我知道Junit是很不错的工具,但我后来才知道 Python 中的cyaron可以生成图,而那时作业已经结束了qaq。

三、总结分析容器选择和使用的经验

这里就不再啰嗦数据结构那一套东西了,简言之:

  1. 对于这种靠id来唯一标识对象的情境中,尽量多用HashMap,因为增删改查时间复杂度均为\(O(1)\)
    • 如果需要根据idPerson,可以把id作为键,Person作为值;
    • 如果只需根据idPerson是否存在,或者查people人数,可以只用HashSet
    • 如果需要根据Person取一些特定值(如socialValue等),由于Person已保存id,仍然可以把id作为键,Person作为值;
    • 也可以在MyPerson中实现hash()方法,使MyPerson可以作为键,但由于官方接口方法签名中有些参数为Person类型,将MyPerson作为键需要强制类型转换,不太优雅;若Person的其它实现没有hash()方法,强制类型转换还可能会造成运行时错误。但出于方便考虑,acquaintances采用了这种方法。
  2. 对于需要在线性表首或中部添加或删除对象的情境中,尽量用LinkedList,因为在表首增删改查时间复杂度均为\(O(1)\),在中部增删可以仅改变指针所指对象,无需顺序移动后面的对象。

不用HashMap而用ArrayList的后果就是像我舍友一样 TLE >_<。

四、针对本单元容易出现的性能问题,总结分析原因

根据我的体会,出现性能问题有以下几点原因:

1. 高情商:自己懒,规格理解不到位,直接翻译 JML;低情商:被 JML 误导

规格仅限定功能,不限定实现方法。因此,实现时应遵循规格而非翻译规格,需要理解 JML 而非照搬 JML。

比如query_block_sum (qbs)命令对应的queryBlockSum()方法,JML 规格是这样写的:

/*@ ensures \result ==
  @          (\sum int i; 0 <= i && i < people.length &&
  @            (\forall int j; 0 <= j && j < i; !isCircle(people[i].getId(), people[j].getId()));
  @           1);
  @*/

而其中的isCircle()方法用于判断是否为环路,需要使用图的搜索算法(深度优先搜索(Depth-first search,DFS)和广度优先搜索(Breadth-first search,BFS)用邻接表时间复杂度均为\(O(V+E)\)(其中\(V\)为顶点数(即人数),\(E\)为边数(即关系数)),但由于是判断环路,DFS 更适合。DFS 用递归很简洁,但数据规模更大时需进行非递归改造)。

(还没正式学严格的复杂度分析理论,复杂度的具体值可能是错的,但大概意思应该是对的[手动捂脸])

如果直接翻译 JML,则该方法时间复杂度为\(O(N^3)\),这是无法接受的。因此必须改进算法。

注意到,从给定顶点进行 DFS 时,会遍历所有与其连通的顶点。因此可以用一个HashMap标记每次 DFS 到达的顶点(其实就是染色),queryBlockSum()方法中遍历people时,只对未访问过的顶点进行 DFS,并且每标记一次计数器就加一,最后返回这个计数器,即为连通块的个数。这样最坏时间复杂度为\(O(N^2)\),虽然好了一些,但还有风险。

又注意到,如果没有添加Person或关系,连续调用queryBlockSum()方法时,并不需要重复计算。因此可以用一个 Cache 变量保存上一次计算的连通块个数,一些辅助变量用来标记是否添加了Person或关系。只有当添加Person或关系后,才需重复计算。这样,若要重复计算,至少额外添加 1 条指令;且若总人数太少或关系太简单,queryBlockSum()方法也不会耗时太多。因此在对总命令条数和各命令条数设限的情况下,上述做法可以避免性能问题。

当然,还可以通过并查集来完成。

2. 本地未进行充分的压力测试,未发现性能问题

表面看上去都是\(O(N^2)\)的算法,实际上也有重大区别。比如query_group_value_sum (qgvs)命令对应的getValueSum()方法,JML 是这样写的:

/*@ ensures \result == (\sum int i; 0 <= i && i < people.length; 
  @          (\sum int j; 0 <= j && j < people.length && 
  @           people[i].isLinked(people[j]); people[i].queryValue(people[j])));
  @*/

people里的Person进行双重循环遍历,如果任两人有关系,就增加valueSum

仔细想想,一般情况下大部分的遍历都是多余的,因为大部分情况下一个人不可能和其它所有人都有关系。因此,内层循环只需遍历某Personacquaintances就行了。

这种问题不太容易在实现时发现,但容易在压力测试时发现。遗憾的是实现时并没有引起我的重视,导致 TLE 了。

五、梳理自己的作业架构设计,特别是图模型构建与维护策略

架构很简单,严格按照规格,基本没有自由发挥的余地。异常类有明显的共性,但此处就不折腾了。

MyGroup Main MyGroupIdNotFoundException MyPerson MyNetwork IdenticalPersonIdException IdenticalMessageIdException MyMessage MyRelationNotFountException MyEmojiMessage MyMessageIdNotFoundException MyEmojiIdNotFoundException MyRedEnvelopeMessage IdenticalEmojiIdException IdenticalGroupIdException MyPersonIdNotFoundException Node MyNoticeMessage IdenticalRelationException

图使用的是邻接表。

sendIndirectMessage()方法需要通过 Dijkstra 算法求最短路。由于 Java 中的PriorityQueue并没有用斐波那契堆实现,因此不支持堆上动态修改。如果重载的比较方法用到了位于外部的数据distance,而distance在堆以外的地方被修改,修改不会自动触发堆的重排,堆的性质就被破坏了。因此放进堆中的元素不能只有一个Person,应该是一个按距离实现Comparable接口的包含Person和距离的Node

private int dijkstra(Person src, Person dst) {
    HashMap<MyPerson, Integer> distances = new HashMap<>();
    PriorityQueue<Node> unvisited = new PriorityQueue<>();
    for (MyPerson person : people.values()) {
        if (!person.equals(src)) {
            distances.put(person, Integer.MAX_VALUE);
            unvisited.offer(new Node(person, Integer.MAX_VALUE));
        }
    }
    distances.put((MyPerson) src, 0);
    unvisited.offer(new Node((MyPerson) src, 0));
    while (!unvisited.isEmpty()) {
        Node smallest = unvisited.poll();
        MyPerson smallestPerson = smallest.getPerson();
        int smallestDistance = smallest.getDistance();
        if (distances.get(smallestPerson) < smallestDistance) {
            continue;
        }
        for (Map.Entry<MyPerson, Integer> entry :
                smallestPerson.getAcquaintances().entrySet()) {
            int distance = smallestDistance + entry.getValue();
            MyPerson person = entry.getKey();
            if (distance < distances.get(person)) {
                distances.put(person, distance);
                unvisited.offer(new Node(person, distance));
            }
        }
    }
    return distances.get((MyPerson) dst);
}

以上就是本单元作业中我比较想说的,如有不对欢迎指正。

posted @ 2021-06-01 20:46  人生就像一盘棋  阅读(78)  评论(0编辑  收藏  举报