OO第三单元作业总结
OO第三单元作业总结
实现规格所采取的设计策略
在实现JML规格的过程中,我采取的策略是:
- 首先宏观阅读指导书,同时简要阅读代码,将各个类的承接关系理清,从比较小的、没有使用其他类的类,如
Person
,开始编写代码,进而查看比较大的、使用了其他类的类,如Network
- 在实现方法的过程中,首先将规格中要求的各种正常情况和异常情况抽离出来,同时检查规格中规定的各种情况是否完备
- 按照规格中异常情况的顺序(这一步很重要!),先编写异常情况,保证处理正常情况时数据一定是合乎前置条件的
- 按照规格所规定的的各种正常情况进行代码编写,在此过程中进一步考虑容器的选择和性能的优化
总体来说,大部分方法都是比较简单的方法,基本上直接按照规格来编写即可,但是由于CPU时间的限制,在编写较复杂的函数时还需要考虑时间复杂度的限制。这也就是说,在按照规格编写方法时,如果规格中出现O(n2)复杂度的代码,就需要加以分析,想想如何降低时间复杂度,而不能无脑抄规格。也就是说,当写完代码回头来看时,如果出现了O(n2)复杂度的代码块,是需要高度警惕的,这一部分代码很有可能让CPU时间超时。
而对于需要自己改写的方法,不仅要注意其时间复杂度的控制(尤其是要注意JAVA自带容器方法的复杂度),更应该谨慎地编写以保证改写的方法与规格是完全等价的
基于JML规格来设计测试的方法和策略
关于JUNIT和openjml的使用
在本单元的对程序的测试中,我并没有使用JUNIT和openjml。一个原因是虽然课程组在大力推荐,但是往年博客上的学长们都对其并不看好,许多博客中都提到JUNIT只能对如2147483647等“平凡”的极端样例进行测试,但是在实际情况中极端样例需要根据不同的代码来设计,JUNIT在这一需求上并不智能,最后极端样例还是只能自己手动构造。另一原因是单元测试在现行条件下基本无用。虽然课程组已经清晰地说明了单元测试在实际生产中的重要性,即测试工程师会专门编写单元测试对别人的代码进行测试,但是在OO的自己编写、自己测试的生产模式下,单元测试就基本失去了其意义。
基于对拍机的测试样例设计策略
虽然没有单元测试,但是对于程序的测试还是必不可少的。由于本单元的性质与前两单元不同,即正确输出结果唯一,使得简单的对拍机成为测试程序的首选。拥有了对拍机之后,唯一需要考虑的就是如何设计测试样例了。
我编写的测试样例以检测正确性为主,从其实质来说,与单元测试有些相似,都是基于JML进行功能的测试,因此样例的强度稍弱。但与单元测试的不同之处在于,整体的黑盒测试更有利于指令和指令之间的交互,因而能够更好地保证实现的正确性。
总体来说,我对于指令的测试仍然是从简单到复杂。也就是说,我会首先对ap
和ar
等基本指令进行全面地测试,进而再测试各种有关于“写”的指令,例如ag
、atg
、am
、sm
,最终会对各种查询指令进行检测,如qbs
、qci
等指令。
具体来说,我将指令大致分为几类,每一类都是一条修改指令加上几条查询指令,对其进行功能性测试
# 如下是ap、qps、qv
def ap():
data = []
for i in range(2000):
id = i // 2
name = "".join(random.sample(STR, random.randint(1, 10)))
ins1 = "ap " + str(id) + " " + name + " " + str(random.randint(0,200)) + "\n"
ins2 = "qps\n"
ins3 = "qv " + str(random.randint(0, i // 2)) + " " + str(random.randint(0, i // 2)) + "\n"
data.append(ins1)
data.append(ins2)
data.append(ins3)
data.append(ins1)
data.append(ins2)
return data
# 如下是ar、qv
def ar():
data = []
for i in range(100):
id = i
name = "".join(random.sample(STR, random.randint(1, 10)))
ins = "ap " + str(id) + " " + name + " " + str(random.randint(0,200)) + "\n"
data.append(ins)
for i in range(120):
for j in range(i):
id1 = i
id2 = j
check1 = "qv " + str(id1) + " " + str(id2) + "\n"
check2 = "qv " + str(id2) + " " + str(id1) + "\n"
ins = "ar " + str(id1) + " " + str(id2) + " " + str(random.randint(0, i + j)) + "\n"
data.append(ins)
data.append(check1)
data.append(check2)
ins = "ar " + str(id1) + " " + str(id2) + " " + str(random.randint(0, i + j)) + "\n"
data.append(ins)
data.append(check1)
data.append(check2)
ins = "ar " + str(id1) + " " + str(id2) + " " + str(random.randint(0, i + j)) + "\n"
data.append(ins)
data.append(check1)
data.append(check2)
data.append("qps\n")
return data
容器选择和使用的经验
-
HashMap
在本单元作业中,我使用了许多HashMap
来代替规格中要求的数组,原因在于每一个Person
都有一个独一无二的id,这很符合HashMap
的设计思想,可以通过id进行实例的查找。
@ public instance model non_null Person[] acquaintance;
@ public instance model non_null int[] value;
例如,在上述规格中描述的两个数组,完全可以使用HashMap<Integer, Integer>
来进行代替,而HashMap
的containsKey
方法查找开销为O(1),与数组的查找开销相同,并没有因为容器的更换而增大时间复杂度。
所有使用的HashMap
如下
Person.java
private HashMap<Integer, Integer> acquaintance; /* HashMap<personId,value> */
Group.java
private HashMap<Integer, Person> people; /* HashMap<personId,person> */
Network.java
private HashMap<Integer, Person> people; /* HashMap<personId,person> */
private HashMap<Integer, Group> groups; /* HashMap<groupId,group> */
private HashMap<Integer, Message> messages; /* HashMap<messageId,message> */
private HashMap<Integer, Integer> emojis; /* HashMap<emojiId, emojiHeat> */
private HashMap<Integer, Integer> tree; /* HashMap<id, parent> */
/* <结点id, 结点的父节点id> */
private HashMap<Integer, Integer> size; /* HashMap<id, size> */
/* <结点id, 以本节点为祖先结点的结点个数> */
-
ArrayList
由于指令sm
和qrm
都对于message的顺序有要求,因此使用了ArrayList
作为规格中数组的代替,并且使用头插来进行指令的实现。
-
PriorityQueue
这一容器主要用于获得一个小顶堆,从而能够对dijkstra算法进行堆优化。在Java的各种容器中,具有小顶堆性质的不仅仅有PriorityQueue
,例如TreeSet
和TreeMap
都是可以实现小顶堆的。我最终选择PriorityQueue
的原因在于,首先我在堆优化中只需要弹出堆的第一个元素,因此查找是不必要的,排除TreeMap
;其次TreeSet
可以对集合中的元素进行排序,是一个有序的集合,但是算法中只需要小顶堆性质,将元素全部排序可能带来额外的时间开销。基于上述两点,我最终选择了PriorityQueue
常见的性能问题和避免方法
-
qci
与qbs
指令按照最简单的想法,
qci
可以使用深度优先遍历算法,这样的时间复杂度为O(n),而qbs
如果按照规格直译,其时间复杂度会达到O(n2),因此需要使用并查集算法进行优化,使用两个HashMap
进行路径压缩+size优化,指令的复杂度可以达到O(1);当然,在使用并查集时如果不使用按秩压缩或者按大小压缩,有爆栈的危险 -
qgam
与qgav
指令直接按照规格计算是可行的,两条指令都可以控制在O(n)复杂度。如果通过缓存
ageMean
和ageSquare
可以将复杂度控制到O(1)级别,但是需要注意维护缓存,尤其是在加边时,如果涉及到的两个人在同一个群中,需要对缓存进行更新;除此之外,需要注意的是整除精度的问题,如果轻易对规格中的公式进行变换,可能出现精度不一致而出错的情况 -
sim
指令按规格直译,指令的复杂度可以达到O(n2),这是不能接受的,由于没有负权边,因此目前最快的算法是堆优化dijkstra算法,可以使用JAVA中现有的容器
PriorityQueue
模拟小顶堆,这样可以将复杂度降到O(nlogn);需要特别注意的是容器本身的时间复杂度,小心如ArrayList的查找和删除,这些都是O(n)复杂度,如果被放在了循环里,那基本就凉了
架构设计
图模型建构
图的模型建构以Person为结点,Relation的存储采用acquaintance带来的天然的邻接表,value作为边的权重
但是,如果仅仅使用一种图的组织结构不足以应对多样的查询指令,因此可以考虑同时采用多种图的组织结构,并同时维护;在本单元中,我还采用了下述两种结构
-
对于查询连通块指令,采取并查集算法,破坏图的点与点之间的连接关系,而只保留点与点之间的连通关系
-
对于查询最短路径长指令,采取堆优化的dijkstra算法,增加一个
Vertex
类来存储personId和当前距起点距离distance
维护策略
维护策略主要集中在ar
和qgav
上
-
在
ar
中,需要注意对于各个Group
的valueSum
进行维护 -
在
qgav
中,需要在Group加人和删除人时都要进行维护
除此之外,还需要考虑对于多种图的组织结构的维护,如下是并查集的组织和维护
// 并查集组织形式
private HashMap<Integer, Integer> tree; /* HashMap<id, parent> */
private HashMap<Integer, Integer> size; /* HashMap<id, size> */
// 并查集在增加人时的维护,采取size优化
int root1 = getRootParent(tree, id1);
int root2 = getRootParent(tree, id2);
if (size.get(id1) >= size.get(id2)) {
tree.put(root2, root1);
size.put(root1, size.get(root1) + size.get(root2));
} else {
tree.put(root1, root2);
size.put(root2, size.get(root1) + size.get(root2));
}
// 并查集中查的部分,采取路径压缩算法
private int getRootParent(HashMap<Integer, Integer> tree, int personId) {
int parent = tree.get(personId);
if (parent != personId) {
tree.put(personId, getRootParent(tree, parent));
}
return tree.get(personId);
}