初探工程项目思维——OO 第三单元总结

0 、 0、 0标题的由来

​ 我认为第三单元其实是面对大型工程项目的一次入门级探索。

0.1 0.1 0.1 作业内容

​ 首先是作业内容本身。在这一单元中,我们需要维护一张(相对)大型的社交网络图,对于这张社交网络图我们需要提供随时增、删、查、改与其它一系列衍生的功能。其实我们就是这张社交网络图的后端开发人员,前面说的各类功能就是向前台提供的接口,在实际生产过程中我们也需要面对各类功能升级、架构迭代的需求。这些都有实际生产工程的影子——虽然比真正的大型工程还是差远了,但确实让我体会到了一个真实的后台开发人员所需要面对的各种基本事件。

​ 因此,我认为对于一个大型项目而言,一定代价范围内的预先计算是可接受的,前台需要真正调用的时间成本应当比后台付出的维护成本更加宝贵,这也是我将在 Part 2 中重点提及的动态维护思想。

0.2 0.2 0.2 函数模块

​ 其次是函数模块的开发。这一单元有一个很重要的内容——JML(Java Model Language),它是一种建模语言,可以向开发人员提供函数功能的约束,从而使得不同开发人员也能够通过统一的编程范式开发出合乎要求的功能。我认为这也是大型项目开发过程中必备的交流思想,因为真正的大型项目是绝非一人力所能开发而成的,往往需要数十个、数百个甚至成千的开发人员共同协作。那么,不同模块之间的接口通信就很重要了。

​ 但每个人的实现都不同,如何保证这种通信交流是可靠的?这就需要 JML 这样的契约式编程语言来进行约束了,相当于一种“包装”、一种“黑箱”,函数或模块的内部实现并不为他人所关心,开发人员只需要确保这个模块的行为、后果等是符合预期的就足够了。这正是契约式编程的产生由来。

0.3 0.3 0.3 单元测试

​ 最后是单元测试。自测的重要性我们从 OO pre 就开始强调了,这次引入的单元测试又是什么新东西?就我粗浅的理解而言,单元测试无非就是我们往常用的“针对模块测试”再加上这单元特有的“针对行为测试”。但其实往届、或者同届都有提过,我们的项目复杂度似乎根本不需要单元测试——最多那么几个复杂的函数模块需要测试一下,为什么课程组仍然强调单元测试这个工具理念呢?

​ 我认为,这还是要放回一开始工程思维的大框架下。虽然我们的这个“大型”项目其实挺小的,可以看作一个真正大型工程项目的【lite slim air 超级青春版】,但如果是为了探索这种工程项目思维,我们还是姑且把它当成一个足够庞大的开发目标。那么,在实际的生产开发过程中,其实是有一个专门的测试部门对各类模块进行功能、要求、异常等方面的测试的。显然,测试人员不会去白盒检查每一模块的具体实现,他们要做的是根据已有契约(例如 JML 编写的约束),针对性地进行测试,检查是否有不符合契约的情况出现。这样的单元测试,一方面能够减小测试人员的无效工作量,另一方面在出现问题的时候也能反过来帮助开发人员迅速地定位到问题所在。我想,这才是课程组让我们学习单元测试的主要目的。

1 、 1、 1契约式编程

1.1 1.1 1.1 什么是契约式编程

​ 契约式编程(Contract Programming)是一种设计思想,它强调在软件开发过程中使用【契约】来规范程序中的输入、输出、前置条件和后置条件。

​ 具体来说,契约指的是在程序中定义函数或方法的输入参数、输出结果以及函数的行为,在使用该函数或方法时必须遵守的约定。它描述了函数与调用者间的协议,将函数和调用者之间的责任划分清楚。契约式编程通常包括以下方面的内容,其中前三项(前置、后置、不变式)是契约式编程的核心:

  • 前置条件(Pre-condition):用于约束输入参数,只有满足某些必要条件,才能够正确地调用函数。
  • 后置条件(Post-condition):用于约束函数的执行结果
  • 不变式(Invariant):一般用于类或对象中,表示对象在生命周期内满足某些条件
  • 可赋值性(assignable):通常也称为副作用,用于规范函数调用的影响范围
  • 正常行为(normal behavior):函数在没有异常抛出的情况下应该具有的正常行为
  • 异常行为(exceptional behavior):函数在抛出某些异常时应该具有的特殊行为

​ 我们接下来要讲的主角 JML 就是一种经典的契约式编程语言。除此以外,其实我们在 OS 填写函数中就已经接触过契约式编程的思想了,每个重要的函数都给出了相应的 Overview(对应我们说的函数行为)、Pre-Condition 前置条件、Post-Condition 后置条件、Return 返回结果等,这正是一种契约式编程的思想。

1.2 1.2 1.2 我们是否需要契约式编程?

​ 每位程序员的编程思路都是自由的、自然语言是自由的、产品经理的需求也是自由的……我们通常向往自由,但在工程开发的过程中,这些种种自由反而会极大地阻碍项目推进。标准,才是工程开发的唯一圭臬。

​ 契约式编程由此应运而生。契约,就是一套从项目初期制定、到中期开发、到后期测试都共同遵守的一套标准。创造制定模块功能的时候,契约式编程能够标准化地将需求变为可实现可运行的逻辑语言;中期开发完成模块功能的时候,程序员又能够最大限度地发挥创造力——只要满足契约所限定的输入、输出、作用等等即可;后期测试的时候,契约又能够反过来帮助测试人员快速制定对应的单元模块测试,也能够帮助开发人员迅速定位可能的问题所在。

​ 不过如果从原生定义而言,契约式编程是有一套自有规范的编程范式。我认为,对于工程设计而言,重要的是这种契约式编程的思想,而非一定要狭义的、原教旨的契约式编程。因此,可以说契约式编程思维对于大型项目的开发是必不可少的。

2 、 2、 2动态维护

​ 动态维护其实是数据库类(例如地图、档案库、我们这一单元的社交网络等)的一种后端维护思想,与之相对应的是直接查询(即查即用)。动态维护的理念核心是,允许以可接受范围内的后端运行代价换取前端接口调用的更高效率。

​ 举一个简单的例子:动态维护就像是我们打斗地主抓牌的时候,即时地对抓上来的牌进行大小的排序,无论你是怎么插入(从左到右扫一遍就是 O(n),人眼二分就是 O(logn),随便啦),总归是比直接无脑往手后面扔的 O(1) 要付出更多时间代价,但好处是你要出牌的时候可以更加便捷地打出你想出的牌。即查即用就是刚刚说的,抓上牌不管是什么就往手后面扔,但是要出牌的时候就需要花更多时间来找到你需要的牌。

​ 在上面的例子中,我们可以把抓牌的操作看作后端调度维护,出牌的操作看作前端调用。如果我们边抓牌边排序,后端(或者可以认为是大脑)的运算量有所上升,但是出牌的时候,前端的调用速度会快不少;而如果是抓牌的时候不管不顾,后端显然没什么运算量,但是出牌的时候后端就需要还债了、前端调用的速度也就更慢了。这就是动态维护和即查即用的主要区别。

​ 如果放回我们的作业,以 hw9 为例,添加两人的关系边的过程就像是抓牌,查询两人是否连通(不是邻接 isLinked,是 isCircle)就像是出牌。如果添加边的时候只是单纯地加入图、等到查询连通的时候再用 bfs 或 dfs 搜一遍,那么这就是即查即用;如果添加边的时候采用并查集维护,虽然每次都需要付出维护并查集的代价,但是查询连通的时候可以以相当小的时间复杂度得到结果,这就是动态维护。

2.1 2.1 2.1 为什么坚持动态维护?

​ 其实在本单元作业中,只要不是完全地按照 JML 规格暴力循环,无论是动态维护还是即查即用都不会爆 CPU 时间~~(反正没有性能分)~~,所以如果是为了完成单元作业,除却一些动态维护真的写起来更方便的地方,用编写复杂度更小的即查即用也未尝不可。例如 hw10 的 queryCoupleSum,以 O(n) 的代价查询也不是特别大的问题。

​ 但我仍然认为本单元的真实训练目标其实是我的标题所说的“工程项目思维”,我们需要假设我们开发的是一个大型社交网络数据库。那么从“以用户为本”的角度出发,显然付出一定的后端维护代价来使得前端调用、查询等操作效率更高是一个更合适的理念。

2.2 2.2 2.2 动态维护在三次作业中的体现

2.2.1 2.2.1 2.2.1 hw9

​ hw9 中动态维护思想体现的最明显的就是连通关系 isCirclequeryBlockSum,以及三元环 queryTripleSum 了。

2.2.1.1 2.2.1.1 2.2.1.1 连通关系维护

​ 先说查询连通关系 isCircle,与之一同的还有查询连通块数量 queryBlockSum。考虑到需要维护连通关系,且 hw9 中只涉及增加关系边的操作,易用且高效的并查集显然是一个好选择。并查集的思想并不难懂,具体实现可以百度出许多优秀教程,我这里给出一个框架概念。

​ 并查集其实就是一片“关系森林”,其中有着不同的“关系树”。对于一张带权无向图(我们的社交网络就是),任意两个节点之间如果是连通的,那么它们两个会共享一个“连通父节点”,这个连通父节点只是一个标志,在实际的图中可能与这两个节点并不直接邻接,不过在并查集的“关系树”中,这个连通父节点就是二者的共同祖先(当然,也可能节点自己就是自己的祖先)。相对应的,如果两个节点不连通,它们就会拥有不同的”连通父节点“。因此我们可以归纳如下:

  • 查询两个节点是否连通 <–> 查询两个节点在并查集中是否拥有相同的祖先节点
  • 查询连通块数量 <–> 查询并查集中不同关系树的数目(或者说是不同祖先节点的数目)

​ 知道了上面的内容,我们写出一个并查集 DisjointSet 类后,只需要向外暴露出如下接口:

public class DisjointSet {
    // ...
    private int total;				// 维护不同关系树的数目
    
    public int getTotal() {			// 查询不同关系树的数目,用于 queryBlockSum
        return total;
    }
    
    public void addPerson(int id) {	// 添加新的人进入并查集
        /* ... */
    }
    
    public int getHead(int p) {		// 查询某一节点的父节点,用于 isCircle
        /* ... */
    }
}

​ 这里只给出向外暴露的接口,一些内部操作(如 union)并未给出。

2.2.1.2 2.2.1.2 2.2.1.2 三元环数目维护

​ 三元环如果简单地采用即查即用大概率(趋近于 100%)会爆时间复杂度。对于节点数为 V V V,边数为 E E E 的带权无向图,洛谷上有复杂度为 O ( E E ) O(E\sqrt{E}) O(EE ) 的离线操作板子(指路 P1989)。但当图逐渐稠密并趋近于完全图时,复杂度同样会达到 O ( V 3 ) O(V^3) O(V3) 的级别。

​ 我这里采用一种动态维护操作:在 network 中维护一个 tripleSum 变量,在每次添加边(addRelation)的时候,选择新增边的一个端点 A(可以根据其 acquaintances 的 size 来决定,选 size 更小的,从而遍历次数更少),对其每一 acquaintance 进行遍历、查询是否也为另一个端点 B 的 acquaintance,若是,则三元环个数 tripleSum 加一,否则不做额外操作。

public void addRelation(int id1, int id2, int value) {
    /* ... */
    for (Person person : p1.getAcquaintances()) {
        if (person != p2 && p2.isLinked(person)) {
            tripleSum++;
        }
    }
}
2.2.2 2.2.2 2.2.2 hw10

​ hw10 中,由于增加了 modifyRelation,从而涉及到了删边的操作。因此,需要动态维护的地方有:删边时的连通关系 isCirclequeryBlockSum,删边时的三元环 queryTripleSum,以及新增的最佳好友对 queryCoupleSum

2.2.2.1 2.2.2.1 2.2.2.1 删边时的连通关系维护

​ 非常遗憾,hw9 中简单又高效的并查集并不支持删除边的操作。原因在于并查集只保留了最关键的连通信息,两点之间的路径等更具体的信息在路径压缩的过程中丢失了(基于此,貌似有不进行路径压缩、支持撤销边的并查集,但我没研究)。

​ 为了支持删边的操作,我采用了一种更复杂的数据结构——Link-Cut-Tree,简称 LCT。LCT 能够实现 O(logn) 复杂度的增删查改操作,其具体的实现较为复杂,我这里只给出相应的接口。

​ 但需要注意的是,基础的 LCT 仅能维护无环图的动态连通性,如果想要支持我们的有环图,需要增加对子树的维护,具体可见[这一篇博客](洛谷P5247 动态图完全连通性 LCT解法 | searchstar (seekstar.github.io))。

public class LinkCutTree {
    /* ... */
    private int total;                              // 连通块数量

    public void addPerson(int id) {					// 添加新的人进入 LCT
        /* ... */
    }

    public boolean isConnected(int id1, int id2) {	// 判断 id1 和 id2 是否连通
        return (findRoot(id1) == findRoot(id2));	// findRoot 为内部实现,此处为查找是否有相同根节点
    }

    public void addRelation(int id1, int id2) {		// 建立关系 id1(person) & id2(person)
        /* ... */
    }

    public void deleteRelation(int id1, int id2) {	// 删除关系 <id1(person), id2(person)>
        /* ... */
    }

    public int getTotal() {							// 查询不同连通块的数目
        return total;
    }
}

​ LCT 和并查集本质上都是后台用于存储边关系的数据结构,因此,我们可以直接将 LCT 替换掉并查集,只需要暴露出相同的接口即可。

2.2.2.2 2.2.2.2 2.2.2.2 删边时的三元环数目维护

​ 删边时,只需要按照加边的反向操作即可。即,选取待删边的一个端点 A,遍历其 acquaintances 中是否有另一端点 B 的acquaintance,若有,则三元环数目减一,否则无操作。

public void modifyRelation(int id1, int id2, int value) {
    /* ... */
    // if need to deleteRelation:
    for (Person person : p1.getAcquaintances()) {
        if (person != p2 && p2.isLinked(person)) {
            tripleSum--;
        }
    }
}
2.2.2.3 2.2.2.3 2.2.2.3 最佳好友对维护

​ hw10 中还新增了一个 bestAcquaintance,即最大价值好友,我这里称为最佳好友,简写为 bA。对于某个人的 bA 单独进行维护显然很容易,只需要在添加新好友的时候维护一个 maxValue 和 currentBestAcquaintance,每次都进行比较、更新即可。如此一来,每次查询 bA 的复杂度就变成了 O(1)。

​ 主要难点在于最佳好友对数目的维护。最佳好友对的定义是:A 的 bA 为 B,B 的 bA 也为 A,则 A 和 B 为一对最佳好友对。一种最简单直接的想法就是要用的时候直接遍历一次所有人,复杂度为 O(n),但这就与我们的动态维护思想相违背了。

​ 我这里设计了一种储存模式:既然我们需要维护最佳好友对,那就直接储存最佳好友对。

​ 那么用什么数据结构储存这个最佳好友对呢?我们需要一个类似 Map 的结构,但是是双向的(即一一映射),根据任意一方都可以得到另一方,而不是只有 key 和 value 的单向映射。很自然地,我封装了一个 HashBiMap 类,其内部实现为两个 HashMap,再简单地实现一些增删查改的功能接口即可:

public class HashBiMap<T> {				// 此处出于实际应用限制了 Key 与 Value 类型一致
    private final HashMap<T, T> forwardMap;
    private final HashMap<T, T> backwardMap;

    public void put(T key, T value) {	// 放入一个数据对 <key, value>(实际是无序的)
        /* ... */
    }

    public T get(T key) {				// 根据一方数据,查找另一方数据
        /* ... */
    }

    public void remove(T key) {			// 根据一方数据,移除整个对
        /* ... */
    }

    public int getSize() {				// 返回 biMap 的大小,即最佳好友对的数目
        /* ... */
    }

    public boolean contains(T key) {	// 查找是否有 key 的数据对
        /* ... */
    }
}

​ 如此一来,我们可以即时存储所有最佳好友对,而需要查询最佳好友对的数目时,我们直接返回这张 biMap 的大小即可。

​ 可是说的容易,我们怎么即时更新这个储存容器呢?我们只需要在关系变动的时候进行更新即可,一旦产生关系的变化(addRelationmodifyRelation)就更新。具体如图:

​ 【picture_1】

​ 如图所示,我们假设需要更改的边为 <A, B>,A 和 B 除了对方以外各自还有一个最大价值朋友 C 和 D(也可能没有,那么 C 和 D 就是 null)。对于一个端点而言,关系边的价值改变后,它可能面对下面四种情况:

  • 1:最大价值朋友仍然是【除对方外的最大价值朋友】
  • 2:最大价值朋友从【对方】变为【除对方外的最大价值朋友】
  • 3:最大价值朋友从【除对方外的最大价值朋友】变为【对方】
  • 4:最大价值朋友仍然是【对方】

​ 其中情况 2 和 3 是互斥的(一个要求该关系边价值下降、一个要求该关系边价值上升),那么我们可以整理一下、写出代码逻辑:

public void update(HashBiMap<Integer> biMap, int id1, int id2,
                       int old1, int old2, int new1, int new2) {
    /* id1 & id2: 待更新判断的两人 id
     * old1 & old2: 原有的最大价值朋友
     * new1 & new2: 更新后的最大价值朋友
     * getBestId(): 获取当前 id 对应的 person 的最大价值朋友 */
    
    if (new1 != old1 || new2 != old2) {		// 只有当更新前后最大价值朋友发生变化, 才需要更新
        if (new1 != old1) {			// id1 的最大价值朋友发生变化,则移除原有 id1 的最佳好友对
            biMap.remove(id1);
        }
        if (new2 != old2) {			// id2 的最大价值朋友发生变化,则移除原有 id2 的最佳好友对
            biMap.remove(id2);
        }
        if (new1 == id2 && new2 == id1) {	// 如果最佳好友都是【对方】
            // 断开 p1, p2 各自的 bestAcq(如果有),并添加 bestAcq <p1, p2>
            biMap.put(id1, id2);
        } else {	// 有至少一个人的最佳好友不是【对方】,即,有至少一个人的最佳好友是【除对方以外的最佳好友】
            // 情况 1: new1 != id2, A 的最佳好友是 C(即除对方以外的最佳好友)
            // 情况 1.1:new1.getBest() == id1,说明 C 的最佳好友也为 A,可添加最佳好友对
            if (new1 != id2 && new1.getBest() == id1) {
                biMap.put(id1, new1);
            }
            // 情况 1.2:new1.getBest() != id1,无法构成最佳好友对,跳过

            // 情况 2: new2 != id1, B 的最佳好友是 D(即除对方以外的最佳好友)
            // 情况 2.1:new2.getBest() == id2,说明 D 的最佳好友也为 B,可添加最佳好友对
            if (new2 != id1 && new2.getBest() == id2) {
                // new2.getBest() == id2,说明 D 的最佳好友也为 B,可添加最佳好友对
                biMap.put(id2, new2);
            }
            // 情况 2.2:new2.getBest() != id2,无法构成最佳好友对,跳过

            // 除此以外,都无法产生最佳好友对
        }
    }
}

​ 如此一来,我们便可以完成最佳好友对的动态维护了。每当 queryCoupleSum 的时候,我们可以直接获取 biMap.size() 返回结果。

2.2.3 2.2.3 2.2.3 hw11

​ hw11 所新增的功能中最受瞩目的,当然非 queryLeastMoments 莫属了。这个查询本质上是获取包含给定 id 的最小社交环路。最小环问题也已经有了一些较为成熟的解决方案,但无论如何,都是现查的,我想要动态维护怎么办?

​ 先给一个我的结论吧:这个需求很难实现动态维护。

​ 先介绍一下现查的算法大概思路:首先利用 Dijkstra 建立一棵源点的最短路径树(即源点到其余各点的最短路径作为树边),然后根据成环的特性、遍历可能的点并更新答案,最后留下的最小答案即为所求。(详见这篇博客,但原博客用了 SPFA 查询最短路)

public int getMinRing(int src) {	// src: source vertex's id
    // init
    HashMap<Integer, Integer> dis = new HashMap<>(4096);
    DisjointSet djs = new DisjointSet();
    
    // build shortest path tree
    dijkstra(src);
    
    // check rings
    int ans = INF;
    HashMap<Integer, Integer> acq = ((MyPerson) getPerson(src)).getAcquaintances();
    /* state 1 */
    /* state 2: i, j not in the same subtree */
    return ans;
}

​ 这里用堆优化的 Dijkstra 来建立最短路径树,比起 SPFA 的优势在于更稳定、不容易被构造数据卡掉(堆优化的 Dijkstra 复杂度为 O ( m l o g n ) O(mlogn) O(mlogn) ,SPFA 被卡到最坏情况是 O ( n m ) O(nm) O(nm) ,并且卡 SPFA 有一套成熟的方法、并不算难,在没有负权边的情况下堆优化的 Dijkstra 会更好)。

​ 所以说回来,为什么 qlm 并不算完全适合动态维护呢?原因在于 qlm 会极大地受到 ar/mr 的影响,无论 ar/mr 与某些点有无关系,由于影响了整个连通块内的所有最短路,因此若要动态维护,需要对连通块内所有点都进行一次最小环数据的更新,且这个更新的复杂度不可能低(涉及到全连通块的每个点的最短路更新)。再看看我们之前的 qci 查询两点连通性、qbs 查询连通块数量、qcs 查询最佳好友对数目等功能,都不会因为连通块内的其它无关点的更新、而引起可能的数据变化,从而避免许多无谓的更新。因此,qlm 很难有合适的动态维护方法。

​ 此外,就像前文讲到的工程思维,我个人感觉 qlm 的应用场景是不如 qciqbs 等贴近实际的——似乎很难想到适用的应用场景,而且更加贴近图的算法考察,似乎与本单元的训练目的有所参差。

3 、 3、 3测试

3.1 3.1 3.1 黑箱测试 & 白箱测试

  • 黑箱测试:相当于把待测试函数/模块看作一个内部不可见(对应着看不见内部实现)、但可以看见外部接口(对应着可以进行输入输出反馈)的黑箱。这种测试是在进行新模块检测或大规模功能检验时常用的手段,它的特点是实现简单(只用调用接口判断输入输出)、测试效率高(可以批量化、并行式的进行测试),但无法准确定位 bug 所在。在本次作业中,黑箱测试往往用于初步的正确性检测。
  • 白箱测试:当黑箱测试反馈出被测模块有 bug 的时候,就需要白箱测试来精准定位 bug 所在了。白箱测试,就如字面上是黑箱测试的反义一样,实际操作也和黑箱测试恰恰相反。白箱测试是对代码人工进行功能分析,通过代码逻辑判断功能模块是否实现正确。白箱测试往往用于黑箱测试后的小规模精准测试。

3.2 3.2 3.2 各类测试手段

3.2.1 3.2.1 3.2.1 单元测试、功能测试、集成测试

​ 单元测试是指对软件中的最小可测试单元进行验证,通常用于测试对系统没有依赖关系的小块代码。一般来说,我们会对一个类中的方法进行单元测试,但是当方法体过大的时候,我们也可以对方法中具有独立性的代码块进行测试,例如分成正常、异常多类情况进行检验。具体测试时,我们可以用 JUnit 进行验证,如以下是一个测试加法单元的示例:

public class MyMathTest {
    @Test
    public void testAddition() {
        MyMath math = new MyMath();			// 待测类
        int result = math.addition(2, 2);	// 获取类中待测方法的结果
        assertEquals(4, result);			// 断言检测
    }
}

​ 功能测试则是对有一定规模的模块或组件进行的测试,这些模块往往有一个或多个单元组成。功能测试的核心是测试接口的正确性,可以理解成我们用 OkTest 检测规格。不过由于对单元/模块的划分不同,所以单元测试和功能测试在不同测试人员理念中定义有所重叠。

​ 集成测试,顾名思义是对一个相对独立、庞大的系统进行的测试,主要目的在于检验这个独立系统中内部各组件、模块的交互是否正确。反映到我们的作业中,可以理解为对 network 进行测试和检查。这一般是在开发后期进行的,因为集成测试需要依赖于内部各模块和组件的完整功能与接口完备。

3.2.2 3.2.2 3.2.2 压力测试 & 回归测试

​ 不同于单元测试、功能测试、集成测试可以相对独立地进行检验,压力测试和回归测试均需建立在全体系统功能和接口已经初步开发完备的基础上,否则不具备压力测试和回归测试的意义。

​ 压力测试很好理解,类似于我们的强测,用大量的、高并发的、临界的数据对系统进行请求与检验。这往往是检验系统可靠性的高效手段,同时也是真实开发中用于探测系统可接受最大压力限度的方法。

​ 回归测试则是指在对系统某些功能或模块进行修改后,对被修改功能涉及到的所有方法、模块等进行检验。例如,我们在作业中如果更改了 addPerson 的方法,我们可能需要对网络和群组都进行相应的检查。而对于一些显然不会涉及到的功能,我们是不必要检查的。不过,如果我们在设计的时候模块之间的耦合度很高,难以厘清不同模块之间的交联关系,那么我们做任何改动,要做的检查工作都会更多,这也是为什么我们反复强调高内聚/低耦合的一大原因。

3.3 3.3 3.3 测试策略

​ 还是经典的采用评测机进行黑盒测试,重在参数的设置与调整

​ 例如,生成图时我们可以设置一个量 density 表示图的稀疏程度,比如设为 0 ~ 10,为 0 时则为无边图,为 10 时则为完全图,中间的映射可以自由选择,我是直接作了线性映射,即:
m a x L i n e s F o r A d d R e l a t i o n = 1 2 n ( n − 1 )   ×   ( d e n s i t y ÷ 10 × 100 % ) maxLinesForAddRelation=\frac{1}{2}n(n-1)\,\times\,(density\div10\times100\%) maxLinesForAddRelation=21n(n1)×(density÷10×100%)
​ 这样我们通过调整 addRelation 的最大生成行来控制图的稠密程度。

​ 此外,我们还可以对指令的生成多少进行调控。例如在 hw11 时,我们希望更多地测试到 qlm,但又不想失去随机性。那么我们可以利用 python 的 random.choice 可以根据 hashmap 的键值进行不同权重的选择的特点,进行函数构造。如:

funcs = {			# 用于抽选的指令表
    'add_per': 1,
    'add_rel': 1,
    # ...
    'query_least_moment': 50,
    # ...
}

rand_func = random.choices(list(funcs.keys()), weights=list(funcs.values()))[0]	# 选到的指令
globals()[rand_func]()	# 调用

​ 我们通过给 qlm 赋上更大的权重,以让它在随机抽取中更容易被抽到,从而达成我们的目的。

​ 此外,我们还可以特地考虑一些边界情况,例如 id 是整个 int 范围,那我们就要测试边界值、负值、零值。于是我们可以建立一个边界性质的 id_list,在生成 id 的时候从中抽取,例如:

# 产生非正数 id
id_list = [random.randint(-100, -1) for _ in range(900)] + [-2147483648] * 50 + [0] * 50
random.shuffle(id_list)  # 打乱

# 从 id_list 中随机返回一个数
def gen_rand_id():
    return random.choice(id_list)

​ 还有更多的调参部分,主要目的都是在于更好地进行压力测试。白箱测试其实不太适用于主动检验,更适合 debug 时定位 bug 所在。

4 、 4、 4bug & debug

  • hw9:

  • hw10:

    • Link-Cut-Tree 对环的维护考虑错误
    • 解决方案:引入对子树的维护,使得 LCT 支持环
  • hw11:

    • dceOkTest 中将 entry 的相等疏忽性地简化为了 key 的相等
    • 解决方案:加上value相等的判断(key、value均相等<=>entry相等)

5 、 5、 5心得体会

​ 其实要说的很多心得已经在 Part 0 中说完了,如果要做一个最后的总结,我觉得在这一单元中,我们的角色从以前的单纯的编程者,变成了一个开发项目中的多个角色——根据产品经理编写统一约束范式的契约开发员(写 JML,但我们这里其实没有太多体现)、根据契约进行模块编写的开发人员(理解 JML 并编写对应函数功能),以及根据契约约束进行单元测试的测试人员。虽然这一单元的难度比起前两个单元有所降低,但我觉得无论是设计的复杂度,还是项目的全局观感,都第一次让我有了一种“工程项目”意识。

​ 不过,对于具体实现来说,我认为要么可以给出更精细的数据范围(如同实际生产中对大多数情况下的压力预估),要么减弱算法的考量程度。如果说这一单元的核心是项目工程思维的话,那么算法的重要性是要靠后的。

​ 总结而言,我觉得这一单元其实比起前两个单元,更像是在一个新的方向上对我们进行训练。所以,虽然代码难度比起前两个单元有所降低,但是我认为训练的充实程度不必前两个单元少。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值