关于第三单元的总结与反思 - Coekjan
规格实现设计策略与性能问题
- 首先肯定是通读所有接口的规格描述, 理解程序需求;
- 选择合适的数据结构;
- 存在对应关系的模型容器, 可以采用Java中的
Map
完成, 比如HashMap
. - 对于某些特定的方法, 需要寻找高效的数据结构:
- 第一次作业中, 有关连通分量的查询, 可以使用并查集(路径压缩).
- 第一次作业中, 有关姓名排行的查询, 可以使用二叉树(Size Balanced Tree),
虽然不使用也不会造成TLE.
- 存在对应关系的模型容器, 可以采用Java中的
- 选择合适的算法:
- 对于某些特定的方法, 需要寻找高效的算法:
- 第二次作业中, 有关组内年龄方差的查询, 需要对年龄和进行动态维护, 以降低算法复杂度. 甚至可以使用缓存方式来进一步降低开销.
- 第三次作业中, 有关图内最短路的查询, 需要使用带堆优化的Dijkstra算法, 否则会TLE.
- 对于某些特定的方法, 需要寻找高效的算法:
- 实现!
本次作业中没有出现性能问题, 主要就是控制每个方法的复杂度都低于 \(\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的能力也不能忽视.