JML(Java Modeling Language) 是用于对 Java 程序进行规格化设计的一种表示语言。第三单元的重点在于训练读写 JML 和根据规格写程序的能力。
一、实现规格所采取的设计策略
1、首先导入JML规格中规定的所有需要实现的方法(IDEA根据接口可批量导入)。
先从总体上理解目前的类所需要实现的基本功能。
2、单独填写方法。
对于每个方法,分别按照其规格,严格独立设计,重点在于实现基本功能,使方法能够正常使用。
3、在所有方法的功能基本实现后,再从类总体的视角去协作各个方法。
如JML中规定为pure的方法可被其他方法调用;部分方法中相似的代码可以抽象为一个通用的方法,供其他方法调用,精简代码规模。
如Network类中,我设计了一个并查集relationMap,来方便查找两个人是否连通,这个并查集的查找在多个方法中需要用到,如addRelation()、isCircle()、queryBlockSum()等,于是我将查找并查集的功能整合到一个方法find()中。
public void addRelation(int id1, int id2, int value) ... {
...
relationMap.put(find(id2), find(id1));
}
public boolean isCircle(int id1, int id2) ... {
...
return find(id1) == find(id2);
}
public int queryBlockSum() {
...
root = find(integer);
...
}
4、优化类的性能。
(1)针对不同的应用需求,选择不同的容器,来提高对容器特定操作的效率。如对于查找需求大的数据,存储在HashMap中。
(2)第3 点中所说的采用并查集。
(3)优化结构,减少不必要的开销。
//方法1
public void sendMessage(int id) ... {
Message message = getMessage(id);
if (message == null) {
throw new MyMessageIdNotFoundException(id);
}
...
}
//方法2
public void sendMessage(int id) ... {
if (!containsMessage(id)) {
throw new MyMessageIdNotFoundException(id);
}
Message message = getMessage(id);
...
}
如上面的代码,方法1 的效率要高于方法2。因为方法2 相比方法1,方法2 的containsMessage()方法多对HashMap进行了一次查找。
二、基于JML规格的测试方法
基于 JML 规格的测试,可分为准备数据、准备场景两步
-
准备数据
-
前置条件涉及的数据和方法输入参数相组合可能出现的所有情况的划分
-
后置条件涉及的数据的测试数据
-
不变式和修改约束设计的数据的测试数据
-
-
准备场景
-
模拟使用者对象与被测对象的交互
-
测试场景对应着实际的场景功能
-
以上测试方法,对 JML 规格规定的方法进行严格测试,能很大程度上保证正确性。
而本次作业的测试,我主要是根据 Python 写的评测机进行对拍,是整体性地测试。
针对 JML 给出的前提条件限制,可用 Python 生成边界数据,用极端的数据来压力性测试程序的正确性以及性能,如 5000 条指令中包含 2000 条 sim。
但随机生成数据的强度毕竟有限,很难甚至不能覆盖到大部分情况,而且挂机跑评测机也很费时,导致我在本单元的测试很弱,以至于在第11次作业中出现非常低级的错误,指在 deleteColdEmoji() 方法中全部 return 0,这个问题我通过跑随机数据竟然测不出来,结果可想而知...
这单元让我明白了,总体上的随机测试的覆盖率真的低到可怜,单元测试是十分必要的。
三、容器的选择和使用
1、ArrayList和HashMap的选择
ArrayList 中只存储对象本身,而 HashMap 需要存储成对的对象,因此采用 ArrayList 占用的内存空间是要小于 HashMap。而根据对象的某一特征值 Key 来从容器中取出该对象时,HashMap 的效率是要远高于 ArrayList。HashMap 相比 ArrayList,在遍历容器中元素时,较为麻烦。
在本次作业中用到的大多数容器,都需要根据某个 key 值(如 Person、Group 的 id)来进行查找,所以我几乎所有容器都采用了 HashMap。我也曾将 HashMap 换成 ArrayList,发现与 HashMap 的效率其实差距不大。所以选择 ArrayList 还是 HashMap,仅凭个人习惯就行。
特别的是,Person 类中存储接收到的 Message 的容器,应该选择数组或 ArrayList。因为这个容器需要保持一定的顺序,此时用 HashMap 就不合适了。
2、PriorityQueue
PriorityQueue 是优先队列,作用是保证每次取出的都是队列中权值最小的,权值大小的评判可以通过构造比较器来实现。PriorityQue 的实现方式是二叉小顶堆。
在 Network.sendIndirectMessage() 中,我使用了 Dijkstra 算法来计算两个Person的最小连通路径,并将该部分算法放到了 dijkstra() 方法中。
private int dijkstra(int id1, int id2) {
HashMap<Integer, Boolean> visitedMap = new HashMap<>(); //ID -> visited
PriorityQueue<Node> queue = new PriorityQueue<>(new NodeComparator());
...
}
在这个方法中我用到了PriorityQueue,主要是用来实现对节点的路径长度的自动排序。若使用 ArrayList 或 HashMap 等,每次查找路径最小的符合条件的节点,都需要从头开始遍历每个节点或者实时手动排序,复杂度较高。而堆结构能将排序的复杂度大幅度降低,也就提高了效率。
四、提高性能的策略
1、优化算法,采用并查集
该优化主要针对 Network.isCircle() 和 Network.queryBlockSum()。isCircle() 的功能是判断图中的两个节点是否连通。queryBlockSum() 的主要功能是求图的最大连通子图个数。
如果对于这两个方法,采用深度优先或广度遍历,在数据少时效率还可以,但数据多时基本上会超时 TLE。而并查集能很方便且高效地确定两个节点是否连通。
为了实现并查集,我建立了一个 HashMap,如下:
private final HashMap<Integer, Integer> relationMap; //ID -> ID 并查集
relationMap 中存储的是与当前节点连通的某个节点的 ID,为了方便,不妨称当前节点指向某个节点。规定一个连通子图中有且仅有一个节点 P 则指向自身,其他节点都指向该连通子图中的其他节点。
并查集的查找函数 find() 如下:
private int find(int id) {
int findId = relationMap.get(id);
if (id == findId) {
return id;
} else {
while (findId != relationMap.get(findId)) {
findId = relationMap.get(findId);
}
relationMap.replace(id, findId);
return findId;
}
}
find() 的作用是查找 id 节点所在连通子图的唯一指向自身的特殊节点。若两个节点处在同一个连图子图中,当且仅当这两个节点调用 find() 后的返回值相等。在上面的 find() 中,还包含了并查集优化的代码,可提高重复查找的效率。
2、以空间换时间
该优化主要针对 Group.getVauleSum()。
按照我最初的实现,每次调用 getValueSum(),我都对存储 Person 的容器进行遍历求和。当数据数量很大或者反复多次调用 getVauleSum() 的时候,有可能会导致超时 TLE。
为了解决这个问题,我设置了一个 int 型的 valueSum 变量,只需每次在调用 addPerson() 和 delPerson() 对这个变量进行修改即可。
3、优化数据结构,采用PriorityQueue
该优化主要针对 Network.sendIndirectMessage(),详见上面的三、容器的选择和使用。
五、作业架构
本次作业的架构完全遵从 JML,除了为了实现功能而自建的 Node 和 NodeComparator 两个类。
UML图

具体的类的架构,主要是关于使用的容器数量以及容器的类型。
MyPerson
private final HashMap<Integer, Integer> valueMap; //ID -> value
private final ArrayList<Message> messages;
valueMap 用来存储无向图边及其权值,messages 用来存储接收到的 Message。
MyGroup
private final HashMap<Integer, Person> personMap;
仅仅一个 personMap 用来存储 Group 中的成员。
MyNetwork
private final HashMap<Integer, Person> personMap; //ID -> Person
private final HashMap<Integer, Integer> relationMap; //ID -> ID 并查集
private final HashMap<Integer, Group> groupMap; //ID -> Group
private final HashMap<Integer, Message> messageMap; //ID -> Message
private final HashMap<Integer, Integer> emojiMap; //emojiId -> emojiHeat
全部采用 HashMap 是出于性能以及实际使用时的便利性考虑。
personMap、groupMap 、 messageMap 和 emojiMap 的功能聚焦在存储上。relationMap 是为并查集服务,详见四、提高性能的策略。
图模型构建与维护
Person 作为无向图的节点,存储与其他节点的连接边和权值。
Group 可看作无向图中节点的一个集合,以方便对集合中的节点进行批量操作。
Message 可看作是对图中节点进行操作的任务。
图的构建与维护的任务落在 MyNetwork 类。
-
addPerson、addGroup、addMessage 都是功能为新增的方法
-
addRelation(int id1, int id2, int value) 用来连接 id1 和 id2 两个节点,权值为 value
-
addToGroup(int personId, int groupId) 是将 personId 的节点放入 groupId 的集合中
-
delFromGroup(int personId, int groupId) 是将 personId 的节点从 groupId 的集合中删除
-
sendMessage(int id) 是对直接连通的两个节点进行操作
-
sendIndirectMessage(int id) 是对连通的两个节点进行操作
-
其他的方法多是对图的查询功能
六、心得体会
在本单元中,我熟悉了 JML 语言,掌握了通过规格来编写代码的能力,但我认为我在根据规格进行测试方面还存在漏洞,我还要进一步学习和掌握。
JML 给我感觉,是严谨但繁杂的。JML 的描述可以做到十分全面,覆盖到程序的各个方面,对程序设计和实现起到至关重要的指导作用。但有些时候,JML 显得过于繁杂。对于编写代码的程序员,信心满满打开电脑,入眼的是占满屏幕的一大页 JML,我猜心态肯定是有点崩溃的。一些情况下,明明一句大白话能够解决的问题描述,反而用一大串 JML,我想效果可能是适得其反的。
我想,对于 JML 等规格描述,或许应该适度使用,在保证严谨性的情况下,若能用大白话来增强其可读性,是最好不过的了。
posted on
浙公网安备 33010602011771号