OO:第三单元总结博客

(1)实现规格采取的设计策略

  • 第三单元主要内容为JML规格的实现,官方包中给出了几个主要的接口,并规定了需要撰写的函数及其相应功能的JML规格。通过本单元的学习(主要是第一次作业对JML的入门),我初步接触了JML规格的设计方式,并逐渐熟悉的依照JML规格进行单元函数撰写的模式。除此之外,由于本单元作业中的社交网络、信息交互等内容涉及了许多图论相关的功能需求,同时对性能做出了较高要求,因此也了解了许多图论相关的算法,例如并查集、求最短路径的Dijkstra算法等,也算是额外的收获。
  • 在第一次作业中,由于对JML的基本语句不够熟悉,因此阅读JML的过程中理解十分困难,常常需要大量艰涩的阅读加之对函数名称的解谜式猜想才能勉强理解函数的实际功能,尤其是对于仅仅JML就逾五六十行的具有多个功能的复杂函数而言。如此书写下的设计结果就是,我的函数实现上基本上遵从了JML的流程与暗示,例如几乎全部涉及多个类型变量的存储采用了数组化的JML描述,受此影响,我也自然而然地将类似的变量设定为了ArrayList容器类型,这就造成了一定性能上的滞缓与弱化的对应关系,这一部分将在第三部分容器的选择与使用处详述。
  • 初步总结JML给我的印象来说,JML便是将一系列简单的如遍历、存在、判断等逻辑原语扩展为大段晦涩难懂的以离散数学符式为基础但又间杂计算机的逻辑符号的抽象派文字艺术,既不具备数学逻辑本身的约化与精炼,明确而清晰的传达出自己希望表达出的意义,又不具备纯粹语言描述的通俗性与泛用性,同时给予了设计者更大的自由度与创造尺域,不必苛求于钢印式的标准约束。
  • 也许JML的单元函数功能实现与测试的思想是极富创见与启发意义的,但在实现上采取了最繁琐且缺少人文关怀的方法。尽管完全体的JML具有极其严谨的描述规约,从理论上只要完全遵从JML的描述进行实现,将不会产生任何功能上的歧义或衍生任何bug。但事实上,JML的做法只是将bug的产生地点由设计层向转译层与实现层放逐,并未彻底规避程序谬误的根本问题,另一方面,反而由于描述链的极端复杂还导致了信息呈递过程中的耗散与偏差。简单地说,就是人为建构了理解上的壁垒,将bug的产生转移到由功能需求至JML规格描述转化及由JML规格至具体代码实现的过程中,是否通过这种方式能够达成所谓的无谬误设计,仍然是一个存疑的问题。
  • 在第二次及之后的作业中,完全遵照JML的描述来构建代码实现的弊端爆发式地体现出来了,大量强测或互测中的CTLE迎面撞来。尤其是各种JML原始描述模式中极为复杂化的函数,其时间复杂度甚至达到了O(n^4)或者O(n^3),而采用一定的空间资源置换是能够将其大幅优化时间性能的。因此,我在遵从原始描述翻译完JML语句并核定其正确性后,会再一次的重点查验所有含有for循环的代码处,检查并尝试优化该部分的时间复杂度,以此达到优化性能的目的,具体的几处例证可参见第四部分关于性能的详述。

(2)基于JML规格来设计测试的方法和策略

  • 讨论课上有其他同学分享了其使用python生成随机数据并与其他同学对拍以查访bug的经历,这确实是一种可行的测试策略,不过我一直到后几次作业的互测时构造高性能要求型数据时才采取了上述方式,因为即便能够随机构造大量的数据然而验证其正确性与问题溯源仍是十分困难的。
  • 本单元中提倡了JUnit这一灵活便捷的测试工具,能够快捷地用于对所写类或函数进行单元功能测试,JUnit能够快速地生成好测试框架,提供简便的测试功能,因此用于验证单个函数单元或一个类的总体正确性很有帮助。
  • 但不可避免地一个问题是,测试的成本相比于代码的撰写是近似等价的,也就是说,如果对于结构较为简单、功能比较单一的函数进行大量的测试会极大地拖慢时间效率,尽管此类操作严苛地确保了功能实现的正确性,避免了写完全部单元再总体测试带来的综合性问题,且难以定位具体的问题出现地点,但是整体效率或许并不如不进行单元测试的模式的。
  • 因此一个可行的思路是只针对部分重点函数进行测试,在JML规格实现过程中标注出一些理解上具有歧义或语意不明的函数,以及一些可能会有较高时间复杂度的函数,在测试时重点地关注并构造类似数据。典型的例证如前两次作业中计算连通块的queryBlockSum函数、Group类中的getAgeVar函数,最后一次作业中的sendIndirectMessage函数所需要并衍生的dijkstra函数,此类函数都是可能会导致时间爆炸的罪魁者,因此需要充分的测试其性能与正确性。
  • 在最后一次作业时,据说由于可能是最后一次互测了,赋予一定的纪念意义,加之东瀛的军刺已然迫近眉睫,因此暂时摒弃一向的和平主义思维,简单研究了一下python生成随机性的输入测试数据。总体来说还是比较方便的,不论是随机数的生成、文件输出的交互都很方便,不过暂时只会手动编排数据种类、条数与顺序,并放到Java程序中验证,远远达不到实现全自动评测机的程度。

(3)容器的选择与使用

  • 前文述及,在第一次作业中根据JML的暗示与引介,Network中全部采用的ArrayList作为存储Person、Value等数据,不论是在遍历时或者添加删除元素时效率并非最优,同时代码实现上也需手动遍历并判断id等方式才能实现查找、修改等操作。这产生了一定查找或索引性能的下降,例如寻找部分Person或Group的id时平均需要遍历多次才能获取到对应的Person或Group,从基础机制上导致了后续需要多次用到查找id功能部分函数性能下降,因此,从第二次作业开始,将所有容器都尽量改为了HashMap存储id与对应元素的键值对。
  • 这一修改不仅使得元素关系的对应更加紧密,同时也能够简化许多增删查找函数的实现方式。例如,在之前的ArrayList存储结构下,判断元素是否存在需要手动撰写遍历List与判断id是否相同的部分,但在HashMap实现下,仅需一句简单的containsKey(id)一行即可完成,极大地简化了函数的复杂程度,整体结构更加清晰明了。具体两种方式的比较可参照如下区别:
    private ArrayList<Person> people;
    private HashMap<Person, Person> rootMap;

    public boolean contains(int id) {
        for (Person person : people) {
            if (person.getId() == id) {
                return true;
            }
        }
        return false;
    }

    public Person getPerson(int id) {
        for (Person person : people) {
            if (person.getId() == id) {
                return person;
            }
        }
        return null;
    }
    private HashMap<Integer, Person> people;

    public boolean contains(int id) {
        return people.containsKey(id);
    }

    public Person getPerson(int id) {
        return people.get(id);
    }
 
  • 事实上,本单元测试中的性能要求使得如果完全字斟句酌地遵照JML的规格来写是一定会CTLE的,因此必须要求对JML描述时使用的方式作出非常反JML直觉的改动。因为JML在对于类似操作的描述方式是与初始的复杂遍历与判断的行为是近乎完全相同的。JML的实现似乎是从极为底层的角度来进行的阐释,仅仅用到了有限的数组等容器与存在、全部、任意等极为基础的逻辑描述方式,但从具有更高级的容器与封装更完备的Java角度来看,JML的阅读感受与实际上的代码实现之间蕴含着巨大的落差,在感受上就像是虚渺的理想与现实间存在着的巨大鸿沟。或者也可以说JML只是用一种极为繁琐的方式规定了基本的功能内容,而在设计上则未必需要遵照其来实现。
  • 不过HashMap也并非完美的解决方案,综合后续的一些图的结构与设计,部分算法还可能会用到HashSet、PriorityQueue或者含嵌套的HashMap等容器来辅助实现功能,以达到较高的性能水平,具体的容器选择上总体还是非常灵活的,并无一成之规择机应变即可。总的来说,如果给出建议的话,主要还是建议能用HashMap的地方尽量避免使用ArrayList容器。

(4)本单元易出现的性能问题与设计上的规避

第一次作业

  • 第一次作业的性能上基本只有一处需要注意的地方,即Network类里面queryBlockSum函数与isCircle的实现,首先在理解函数意思的情况下,推崇使用并查集的做法来建立并管理网络节点之间的连接关系,作为图论相关算法0基础的初涉者,也十分惊叹于并查集实现的优雅与精妙,同时原理并不难理解,网上的资料也比较丰富,挺难得的。印象里似乎使用bfs或者dfs之类的方法可能之后的作业里会有性能问题,这里贴一下使用并查集的方法中一系列的函数实现:
public boolean isCircle(int id1, int id2) throws PersonIdNotFoundException {
        if (contains(id1)) {
            if (contains(id2)) {
                updateRootMap();    //更新并查集
                Person x = find(getPerson(id1));
                Person y = find(getPerson(id2));
                return x.equals(y);
            } else {
                throw new MyPersonIdNotFoundException(id2);
            }
        } else {
            throw new MyPersonIdNotFoundException(id1);
        }
    }
private Person find(Person x) //查找根结点
    {
        Person r = x;
        while (r != rootMap.get(r)) {   //寻找根结点
            r = rootMap.get(r);
        }
        Person i;
        Person j;
        i = x;

        while (rootMap.get(i) != r) {  //路径压缩
            j = rootMap.get(i);
            rootMap.put(i, r);
            i = j;
        }
        return r;
    }

    private void join(Person root1, Person root2) //判断是否连通,不连通就合并
    {
        Person x = find(root1);
        Person y = find(root2);
        if (x != y) {        //如果不连通,就把它们所在的连通分支合并
            rootMap.put(x, y);
        }
    }
public int queryBlockSum() {
        int ans = 0;
        for (int i = 0; i < people.size(); i++) {
            boolean flag = true;
            for (int j = 0; j < i; j++) {
                try {
                    if (!isCircle(people.get(i).getId(), people.get(j).getId())) {
                        continue;
                    } else {
                        flag = false;
                    }
                } catch
                (PersonIdNotFoundException e) {
                    int x;
                }
            }
            if (flag) {
                ans++;
            }
        }
        return ans;
    }
  • 并查集的主体函数为find与join两部分,前者用于寻找当前节点的根节点,后者则建立起两个节点间的连接关系并进行路径压缩,具体的原理上可以自行搜索。在判断isCircle时只需判断两个节点的find找到的根节点是否相同即可判断出两个点是否连通,类似地queryBlockSum也只需按照JML的规格调用isCircle并判断即可,不过在第二次作业里如果仍这样实现会导致严重的性能问题,后续会继续介绍优化方案。
  • 另外值得一提的是此处的updateRootMap函数是一处败笔,以至于都无颜把这一部分代码贴上来,我在此处每次isCircle时均遍历了全部节点更新了一遍并查集,这是极为愚蠢的做法。一个比较理想的实现方案是仅仅在图中新加入关系时(即ar指令)才需更新并查集,或者仅仅标注并查集需更新的boolean标志位更为理想。

第二次作业

  • 第二次作业从核心上大幅提升了性能要求,因此凡是涉及到循环的地方都尽量将一些不需要重复计算的数存起来,每次调用前先检查是否需要更新该值,以减少循环计算量,这一准则放眼全部涉及for的函数皆准,例如本人在修复bug以提升性能时搜索了全部含有for的函数并尽量都做出上述修改。除此以外,主要有两个需要注意之处,同时也为性能Killer,分别为Group类中计算getAgeMean、getAgeVar、getValueSum的复杂度与Network类中对queryBlockSum的再一次优化。
  • 这里以getValueSum为例展示的为优化后的整体函数内容,布尔值valueSumAlreadyUpdated在计算完成后置为True,而当Group中新加入person或者Network中新增建立了Person之间的关系时则变为False,代表着目前存储的valueSum值需要重新计算,因此仅当需要更新时才重新计算部分值,算是一定意义上优化了性能。其余的getAgeMean与getAgeVar函数也是类似处理,具体需要想清楚哪些指令的改变或引入会导致值需要重新计算即可。具体地,getValueSum函数如下:
    private boolean ageMeanAlreadyUpdated;
    private int ageMean;
    private boolean valueSumAlreadyUpdated;
    private int valueSum;
    private boolean ageVarAlreadyUpdated;
    private int ageVar;

    public int getValueSum() {
        //todo Warning!: 此外,ar指令也可导致结果变化

        if (valueSumAlreadyUpdated) {
            return valueSum;
        }
        int ans = 0;
        for (int i = 0; i < people.size(); i++) {
            int tmpans = 0;
            for (int j = 0; j < people.size(); j++) {
                if (people.get(i).isLinked(people.get(j))) {
                    tmpans += people.get(i).queryValue(people.get(j));
                }
            }
            ans += tmpans;
        }

        valueSum = ans;
        valueSumAlreadyUpdated = true;

        return ans;
    }
  • 回到Network类中的queryBlockSum函数,本次优化中完全剔除了updateRootMap这一中心化的并查集更新操作,并类似上述思路设定了一个用于存储的blockSum变量,而将blockSum值的更新与并查集的合并分散在了每次addRelation过程中,这导致了queryBlockSum的计算将极为简单,仅需返回一个blockSum的值即可,复杂度大概是O(1),相较于O(n^4)复杂度的JML规格,可谓是极大幅度的提升。将addRelation函数实现展示如下(这里抛异常的逻辑有点混乱,应当可以优化的更清晰一些):
public void addRelation(int id1, int id2, int value) throws
            PersonIdNotFoundException, EqualRelationException {
        if (contains(id1)) {
            if (contains(id2)) {
                if (!getPerson(id1).isLinked(getPerson(id2))) {
                    //todo
                    MyPerson p1 = (MyPerson) getPerson(id1);
                    MyPerson p2 = (MyPerson) getPerson(id2);
                    p1.addLink(p2, value);
                    p2.addLink(p1, value);

                    if (find(id1) != find(id2)) {   //不同集变得联通
                        blockSum--;
                    }
                    join(id1, id2);    //并查集合并
                    nameRank.clear();
                    updateGroupValueSumBoolean(id1, id2);     //更新两个Group中的计算vs的boolean

                } else {
                    throw new MyEqualRelationException(id1, id2);
                }
            } else {
                throw new MyPersonIdNotFoundException(id2);
            }
        } else {
            throw new MyPersonIdNotFoundException(id1);
        }
    }

第三次作业

  • 第三次作业引入了多种Message的具体类型,包括Emoji、Notice、RedEnvelope等,唯一的高性能要求处大概是sendIndirectMessage时所需的查找两节点间的最短路径,最开始提交的版本采用的为复杂度O(n^2)的Dijkstra算法,并未考虑对当前节点非连通点的过滤,似乎性能上存在问题,之后改为了含优先队列priorityQueue的优化版Dijkstra,才使得时间降下来,最终版本如下:
public int dijkstra(Message m) {
        Person p1 = m.getPerson1();
        Person p2 = m.getPerson2();

        HashMap<Integer, Integer> result = new HashMap<>();
        HashMap<Integer, Integer> existArray = new HashMap<>();
        PriorityQueue<Node> priorityQueue = new PriorityQueue<>();
        result.put(p1.getId(), 0);
        priorityQueue.add(new Node(p1.getId(), 0));
        while (true) {
            Node node = priorityQueue.poll();
            if (existArray.containsKey(node.getId())) {
                continue;
            }
            existArray.put(node.getId(), node.getDis());
            if (node.getId() == p2.getId()) {
                break;
            }
            int nodeDis = node.getDis();
            HashMap<Integer, Integer> values =
                    ((MyPerson) (getPerson(node.getId()))).getAcqvalue();
            for (int personId : values.keySet()) {
                if (existArray.containsKey(personId)) {
                    continue;
                }
                int oldDis = result.getOrDefault(personId, Integer.MAX_VALUE);
                if (nodeDis + values.get(personId) < oldDis) {
                    int newDis = nodeDis + values.get(personId);
                    result.put(personId, newDis);
                    priorityQueue.add(new Node(personId, newDis));
                }
            }
        }
        return existArray.get(p2.getId());
    }

(5)架构设计梳理,图模型构建与维护策略

第一次作业

  • 第一次的Network中变量的定义非常简单,纯粹摹仿JML给出的规格建立了一个List类型的people,rootmap则是使用并查集的考虑下建立的一个辅助容器,用于存储每个Person及其上级root之间的对应关系。因此在维护上也十分简单,增删Person时只需对people进行add或remove等操作,查找则是遍历people并判断等;rootMap则是用与并查集相关的join与find函数来进行维护。
    private ArrayList<Person> people;
    private HashMap<Person, Person> rootMap;

第二次作业

  • Network中新增加了许多定义,本次作业中将所有存储方式都修改为了HashMap类型,groups与messages都是类似people的id与元素的键值对的存储方式,idRootMap也该为了<Integer, Integer>类型,由Person改为了其对应的id,似乎能够比按Person的方式节约空间与提升一定效率。nameRank与blockSum是优化性能时以空间换时间的缓存产物之一,将计算好的nameRank等值存入其中,使得减少计算时间与计算量。维护策略上也并无稀奇可述之处,与上次大体类似。最终定义的图模型相关结构如下:
    private HashMap<Integer, Person> people;
    private HashMap<Integer, Integer> idRootMap;
    private HashMap<Integer, Group> groups;
    private HashMap<Integer, Message> messages;
private HashMap<Integer, Integer> nameRank; private int blockSum;

第三次作业

  • 依照JML规格要求增加了存储emoji的id及其对应heat的emojiList,另外,根据最初版的Dijkstra的实现,增加了一个嵌套HashMap用于存储p1与p2两个节点之间的value,即valueMap,两个索引的Integer分别为两点(Person)的id,dijkstraCalculated则是最初设想存储哪些点被dijkstra计算过且并未发生修改的容器,判断是否可以从valueMap中读取值而不用再次计算。后二者的维护则相应的在ar时分别清空dijkstraCalculated,每完成一次dijkstra时将当前计算的节点加入dijkstraCalculated并将valueMap更新。
    private HashMap<Integer, Person> people;
    private HashMap<Integer, Integer> idRootMap;
    private HashMap<Integer, Group> groups;
    private HashMap<Integer, Message> messages;
    private HashMap<Integer, Integer> emojiList;    //todo id -> heat

    private HashMap<Integer, Integer> nameRank;
    private int blockSum;
    private HashMap<Integer, HashMap<Integer, Integer>> valueMap;   //p1, p2, values

    private ArrayList<Integer> dijkstraCalculated;

(6)心得体会

  • 在开始JML单元之前,研读过往届学长学姐们总结第三单元的博客,我观察到JML曾经被归纳为图论算法单元,即大量函数需要用到图论相关的算法,不论是计算连通性的各类优先搜索算法或是计算最短路径的Dijkstra算法,由于并无OI相关竞赛的经历,这类算法仅仅在两个学期前的数据结构课尾巴上浅尝辄止地接触过,当时在C语言中的实现基本依赖原封不动地借鉴讲义与ppt的实现,并不具备独立编写出图论算法的能力,本次借助社交网络图这一次作业的机会,夯实并巩固了图论相关算法的实现,受益匪浅。
  • 通读近年来第三单元的博客总结上的演变,不难发现今年的Message及相应的Emoji、RedEnvelope、Notice等扩展部分都是新增加的特色之处;包括第一单元或是第二单元,都有在尝试着根据以往的反馈进行作业内容的调整与优化。与相近的各大核心专业课程相比,感觉OO课是罕有的每一年都在逐步改进的课程,积极地博采众长而优化课程体验与结构,真正重视教学与反馈意见并落实于,参与课程的实践体会中感觉饱含人文关怀。总的来说希望OO课程组继续开拓与创制,给每一个参与过课程的人带来优秀的体验。
  • 相比于上一单元新增部分的任务量失调,本单元每次作业的任务量的递进部分较为合理,而且几大图论算法是随着每一次作业在逐步地引进,颇有精心设计过的感觉。但是既然本单元披着JML的外壳,希望能够在一开始注明清楚JML规格只是一个“底线”而非“上限”,必不可完全遵照JML来进行代码的实现,必须要对其加以改进与优化才能够满足对性能的要求,例如中测数据极弱而强测极强的第二次作业就是一个鲜明的例证。
  • 另外就是一些希望改进的小地方了,例如Network类最后实现完毕后已经超过了500行,已经不符合CheckStyle了,也许可以考虑将People、Group、Message等每一部分分拆出一些行数更少的类出来,这样也会更加符合设计原则;一些方法的JML规格写的实在太臃肿了,这样一来也会导致撰写JML规格时容易出现错漏之处,而在阅读时也体验极差,究其原因还是在于单个方法所需实现的功能太多,可以考虑将一些功能拆分为独立的方法来实现,以减少上述情况。

 

posted @ 2021-05-27 20:09  Cauthsche  阅读(49)  评论(0)    收藏  举报