关于第三单元的总结与反思 - Coekjan

规格实现设计策略与性能问题

  1. 首先肯定是通读所有接口的规格描述, 理解程序需求;
  2. 选择合适的数据结构;
    • 存在对应关系的模型容器, 可以采用Java中的 Map 完成, 比如 HashMap .
    • 对于某些特定的方法, 需要寻找高效的数据结构:
      • 第一次作业中, 有关连通分量的查询, 可以使用并查集(路径压缩).
      • 第一次作业中, 有关姓名排行的查询, 可以使用二叉树(Size Balanced Tree), 虽然不使用也不会造成TLE.
  3. 选择合适的算法:
    • 对于某些特定的方法, 需要寻找高效的算法:
      • 第二次作业中, 有关组内年龄方差的查询, 需要对年龄和进行动态维护, 以降低算法复杂度. 甚至可以使用缓存方式来进一步降低开销.
      • 第三次作业中, 有关图内最短路的查询, 需要使用带堆优化的Dijkstra算法, 否则会TLE.
  4. 实现!

本次作业中没有出现性能问题, 主要就是控制每个方法的复杂度都低于 \(\mathcal{O}(n^2)\) .

基于JML的测试方法和策略

事实上, 笔者自己做测试时并没有结合JML, 也不会用形式化验证工具来做测试.

笔者一般是跟同学讨论自己对JML规格的理解, 如果有不一样的地方再进行详细的阅读.

测试的话, 笔者其实更注重自己实现的复杂数据结构的单元测试, 以及对拍测试.

单元测试

使用的是JUnit5. 笔者仅在第一次作业中进行了单元测试. 因为笔者只在第一次作业中造了自己的数据结构, 后两次都没有造数据结构了.

第一次作业中, 笔者重点测试了Size Balanced Tree, 发现了不少bug(QAQ手造轮子就是容易造出bug嘞). 下面是测试的片段, 主要是粗测了平衡树的"平衡"特性:

/**                 Random Insert           Sequence Insert
 * n                time of 1 << n (ms)     time of 1 << n (ms)
 * 4                9                       8
 * 10               12                      12
 * 15               46                      30
 * 20               926                     234
 * 21               1775                    1023
 * 22               4939                    2573
 * 23               10196                   5370
 * 24               24521                   7622
 * 25               54945                   19245
 **/
@org.junit.jupiter.api.Test
void size1() {
    SizeBalancedTree<Integer> sbt = new SizeBalancedTree<>();
    Random random = new Random();
    int count = random.nextInt(1 << 20) + 1024;
    System.out.println(count);
    for (int i = 0; i < count; ++i) {
        sbt.insert(random.nextInt(1024));
    }
    assertEquals(count, sbt.size());
}

@org.junit.jupiter.api.Test
void size2() {
    SizeBalancedTree<Integer> sbt = new SizeBalancedTree<>();
    int count = 1 << 20;
    for (int i = 0; i < count; ++i) {
        sbt.insert(i);
    }
    assertEquals(count, sbt.size());
}

怪, SBT的顺序插入还更快呢?

对拍测试

笔者采用的是随机构造为主, 人工构造为辅的方式进行测试.

说是随机构造, 其实是有针对性的:

  • 针对性测指令集中某一部分指令, 收缩id范围以制造冲突.

人工构造的数据主要是针对高复杂度部分的, 主要就是尽可能将数据量增大, 让算法的最坏情况发生.

有关容器的选择

HashMap 就是香啦.

个人认为, 还是多了解Java容器的底层实现吧:

  • ArrayList : 顺序存储容器.
  • LinkedList : 链式存储容器. 本次作业中, Message 需要进行头插, 链表头插就是快啦.
  • PriorityQueue : 优先队列. 本质上是小顶堆, Dijkstra算法可以使用这个容器来优化复杂度.
  • HashMap :
    • 首先进行 hash 操作, 如果发生哈希冲突, 则以拉链形式挂载其后.
    • 当链表超长时(阈值为8), 将转化为红黑树;
    • 当红黑树中结点较少时(阈值为6), 转化为链表.

架构设计

类的组织

关于类的组织, 笔者仅在第三次JML作业中使用了一些相关的实现继承技巧:

功能性

功能需求越多, 数据结构也就越复杂, 越多.

这里的图模型其实并不复杂, 大部分方法都是在进行查询.

需要维护的地方, 就是增加结点, 增加边; 组内增加结点, 增加边的情况.

真没什么策略可言QAQ, 就是应维护的就维护, 可以不维护的就不维护.

关于BUG

三次强测与互测中均无bug. 本地搭建了数据生成机与自动评测机, 与几位同学进行了对拍. 在测试过程中发现了一些问题, 其中绝大多数都是因为JML理解有误.

计组时的做的数据生成机与自动评测机到现在还能用呢! 因为当时设计数据机与评测机的时候, 考虑了相当的可扩展性, 只需进行指令集和参数范围的修改, 就可以直接复用.

关于HACK

主要针对高时间复杂度方法的HACK.

作业 指令 描述
1 qbs 查询图中连通分支数目
2 qgar 查询组内成员年龄的方差
3 sim 查询图中两点间的最短路径

如果不做优化, 以上方法都有可能达到 \(\mathcal{O}(n^2)\) 的时间复杂度, 导致TLE.

但事实上, 笔者仅在第一次JML作业时hack了一个使用DFS来搜寻连通分支数的同学(这里应该用并查集路径压缩啦), 后面两次作业都没有发现高复杂度写法了.

然而, 第二次有同屋者发现了一个WA. 笔者由于专注于测试高复杂度部分而忽略了低复杂度部分的正确性, 所以没发现, 有点遗憾.

想对课程组说的

Runner 中长长的 if-else 不会很掉性能嘛? 也很不美观啊...

掉性能是说, 根据指令跳转到相应的处理函数的过程大概是 \(\mathcal{O}(n\times l)\) 的( \(n\) 是指 if-else 分支总数, \(l\) 是指令字符串的平均长度). 因为这相当于查询了一遍链表.

考虑这样的写法:

public class Runner {
    private final Map<String, Consumer<String[]>> instrMap
        = new HashMap<>(/* Number of Commands */);

    {
        instrMap.put("ap", this::addPerson);
        instrMap.put("ar", this::addRelation);
        // ...
    }

    public void run() {
        String cmd;
        String[] args;
        // ...
        while (in.hasNextLine()) {
            cmd = in.nextLine();
            args = cmd.split("\\s+");
            // use regex to adapt as many styles as possible.
            if (instrMap.contains(args[0])) {
                instrMap.get(args[0]).accept(args);
            } else {
                // ... panic or alert
                System.out.println("QAQ: Invalid Command!");
            }
        }
    }

    private void addPerson(String[] args) {
        // ... should handle any Exception!
    }

    private void addRelation(String[] args) {
        // ... should handle any Exception!
    }

    // ...
}

这样编写时, 由指令查询具体方法, 只需要常数的时间(HashMap), 而且十分简洁.

心得体会

本单元主要的学习目标是JML的阅读与面向接口编程.

个人认为, 在学习阅读JML的过程中, 仅靠阅读JML来写出程序是必要的. 但是在实际开发中, 还应当是JML结合自然语言, 以及形式化语言(如第一单元的形式化文法)来描述程序功能为佳. 今后若有高效的形式化验证工具, JML规格(或其他建模语言)将成为评判程序正确与否的重要手段, 因此编写JML的能力也不能忽视.

\[\begin{aligned} &用户需求\rightarrow设计软件文档(自然语言)\\ \rightarrow&形式化建模与编写\texttt{JML}\rightarrow开发与迭代\rightarrow测试与验证 \end{aligned} \]

posted @ 2021-05-27 15:41  Coekjan  阅读(231)  评论(0编辑  收藏  举报