BUAA_OO_UNIT3
UNIT3
1. 设计策略
这几次作业的UML类图基本没什么用,就不放了,下面大概讨论需要仔细设计的一些方法。
1.1 第一次作业
对于\(Person~~Network\)中很多方法,直接按照规格照搬去实现即可,没有太大的难度。对于查询的,由于各种类在它自己所属的属性中不会出现相同的\(id\),故可以采用\(HashMap\)来进行快速查询。而对于性能瓶颈\(is\_cicle\)跟\(query\_block\_sum\),前者采用路径并查集,平均复杂度为\(O(\alpha (n))\);后者维护一个\(blockSum\),在每次\(addPerson\)时加一,并查集的集合合并时减一,做到\(O(1)\)应答。
1.2 第二次作业
新增的\(Message\)以及\(Person\)新增的方法都比较简明易懂,注意按照规格写出来就行,而\(Group\)中的\(getValueSum~~getAgeMean~~getAgeVar\)是比较主要的设计点。可以维护一个\(valueSum\),在每次往\(group\)里加人时遍历余下的人,若\(isLinked\)则加上两倍\(value\)即可;删去人也同理遍历即可。只要注意在外部\(addRelation\)时查看组内是否有人,及时维护即可。
对于后两者,平均值可以维护一个\(ageSum\),每次加减人都对它进行维护;方差则维护\(ageSquare\),在加减人时维护,推导一下公式便能\(O(1)\)应答,详细可见4.2节。
\(Network\)中对\(Message\)的操作比较容易实现,对\(Group\)的操作实质只是调用里面的函数。只要处理好异常即可保证正确性。
1.3 第三次作业
新增的\(EmojiMessage~~NoticeMessage~~RedEnvelopeMessage\)跟\(Message\)差别不大,\(Person\)新的方法就是白给,实现简单;\(Network\)注意到在\(addMessage\)跟\(sendMessage\)的规格有变更,及时更新即可,而新增方法主要难点在\(sendIndirectMessage\)求最短路径。
直接用\(Dijkstra\)复杂度是\(O(n^2)\),虽然CPU有6s但总觉得有可能爆,故采用了堆优化版本。新建类\(Element\),内含\(person\)的\(id\),跟源节点的距离\(distance\)。再覆写\(compareTo\)即可。
public class Element implements Comparable<Element> {...}
利用JAVA自带的\(PriorityQueue\)即可构造小顶堆。每次调用\(offer\)时不需要将原有的节点去除,只需要将新的节点加入,在\(poll\)时检查是否已访问,即可避免频繁删去节点花费大量时间。每次取出堆顶元素,标志为已访问,在读到目标节点时返回距离即可。时间复杂度\(O(elogn)\),\(e\)为边数,\(n\)是点数。
同时注意到在\(deleteColeEmoji\)时不要用\(for~each\)边遍历边删即可。
2. 测试的方法和策略
本单元如果没做好测试的话,不排除出现下面的情况。(中测出来挨打)
理想
现实
本单元在互测之前均采用白盒测试对功能模块进行测试、黑盒测试对时间复杂度较高的方法进行大量数据测试;在互测时采用黑盒测试,用大量数据进行测试。
本单元由于有规格,故白盒测试效果比较好,写起来Junit比较轻松。根据\(normal\_behavior\)与\(exceptional\_behavior\)的不同,写出测试正常功能以及异常抛出的不同测试。
2.1 白盒测试
2.1.1 \(normal\_behavior\)
对于前者,无非就是根据规格定义的不同路径进行检查。在某一条路径上检查时,必须注意是否符合前置条件,再利用构造出来的多种数据得到结果,用\(assert\)系列函数对后置条件进行检查即可。例如,对于\(message\)的形成,只有两条逻辑路径能够生成\(message\),对它进行测试,如
@Test
void getType() {
MyPerson person1 = new MyPerson(100, "Jack", 10);
MyPerson person2 = new MyPerson(100000, "Jack", 10);
MyGroup group = new MyGroup(159);
MyMessage messsage1 = new MyMessage(11, 177, person1, person2);
MyMessage message2 = new MyMessage(98, 87, person2, group);
//now test message1, which is the first constructor
assertEquals(messsage1.getType(), 0);
assertNull(messsage1.getGroup());
assertEquals(messsage1.getId(), 11);
assertEquals(messsage1.getSocialValue(), 177);
assertEquals(messsage1.getPerson1(), person1);
assertEquals(messsage1.getPerson2(), person2);
//now test message2, which is the second constructor
assertEquals(message2.getType(), 1);
assertNull(message2.getPerson2());
assertEquals(message2.getId(), 98);
assertEquals(message2.getSocialValue(), 87);
assertEquals(message2.getPerson1(), person2);
assertEquals(message2.getGroup(), group);
}
由于规格的逻辑路径非常清晰,故遍历所有逻辑路径并非难事。但值得注意的是,由于数据是手动构造,故不一定能保证在边界情况、大量数据情况下不会有问题,故需要构造多种数据。如对于\(person\)、\(message\)、\(group\)的\(id\),采用差别较大不重复的\(id\),有利于帮你查出来会不会传错了;而大量数据一般借助于黑盒测试。故只要有耐心,在写数据时再次认真地理解规格,保证功能在一般条件下的正确性并不难。
2.1.2 \(exceptional\_behavior\)
开始时,方法比较粗略。以\(network\)的\(addPerson()\)为例,进行抛出异常测试。
@Test(expected = MyEqualPersonIdException.class)
void addPerson() {
MyNetwork network = new MyNetwork();
MyPerson person = new MyPerson(100, "Jack", 10);
MyPerson person1 = new MyPerson(100000, "Jack", 10);
MyPerson person2 = new MyPerson(10000, "Jack", 10);
MyPerson person3 = new MyPerson(100, "Jack", 10);
network.addPerson(person);
network.addPerson(person1);
network.addPerson(person2);
network.addPerson(person3);//Error!
}
但由于无法看出内容是否正确,故之后采用人工看结果对抛出的异常进行检查,例如
@Test
void addPerson() {
MyNetwork network = new MyNetwork();
MyPerson person = new MyPerson(100, "Jack", 10);
MyPerson person1 = new MyPerson(100000, "Jack", 10);
MyPerson person2 = new MyPerson(10000, "Jack", 10);
MyPerson person3 = new MyPerson(100, "Jack", 10);
try {
network.addPerson(person);
} catch (MyEqualPersonIdException e) {
e.print();
}
try {
network.addPerson(person1);
} catch (MyEqualPersonIdException e) {
e.print();
}
try {
network.addPerson(person2);
} catch (MyEqualPersonIdException e) {
e.print();
}
try {
network.addPerson(person3);
} catch (MyEqualPersonIdException e) {
e.print();// epi-1, 100-1
}
}
这会在\(person3\)时抛出\(epi-1, 100-1\)。但是这样不利于比较规范的检查,在异常种类较多时人工看也很可能出错。故采用如下方法。
@Rule
public ExpectedException thrown = ExpectedException.none();
@Test
public void addPerson() throws MyEqualPersonIdException {
MyNetwork network = new MyNetwork();
MyPerson person = new MyPerson(100, "Jack", 10);
MyPerson person1 = new MyPerson(100000, "Jack", 10);
MyPerson person2 = new MyPerson(10000, "Jack", 10);
MyPerson person3 = new MyPerson(100, "Jack", 10);
network.addPerson(person);
network.addPerson(person1);
network.addPerson(person2);
thrown.expect(MyEqualPersonIdException.class);
thrown.expectMessage("epi-1, 100-1");
network.addPerson(person3);
}
对原有的\(exception\)进行适当修改,即可完成\(expectMessage\)的使用。这样的方法比较符合预期,可以在多次作业中进行移植,而不需要再次思考会抛出什么异常、输出信息可能是什么。
2.2 黑盒测试
黑盒测试就需要一点技巧了。权衡后,个人觉得卡CTLE成功的可能性比较大,故互测方向都是利用自测时构造的数据往卡CTLE进行。但是,如果直接用随机数据,显然很容易一大堆异常,没有什么有用的数据。故可以结束python的\(networkx\)。以最短路径算法为例一窥全豹。
import networkx as nx
from random import randint
def Dijk(PEOPLE, RELATION, QUERY):
f = open("result.txt", "w")
G = nx.Graph() #生成无向图
list = ''
for i in range(PEOPLE):
list += "ap {} {} {}\n".format(i, "CTLE!", randint(1, 200))
G.add_node(i) #加入人
for i in range(RELATION):
x = randint(0, people_num - 1)
y = randint(0, people_num - 1)
value = randint(0, 1000)
while (G.get_edge_data(x, y) != None or x == y): #避免之前已经有relation
x = randint(0, people_num - 1)
y = randint(0, people_num - 1)
list += "ar {} {} {}\n".format(x, y, value)
G.add_weighted_edges_from([(x, y, value)]) #加入relation
for i in range(QUERY):
x = randint(0, people_num - 1)
y = randint(0, people_num - 1)
find = 0
while True:
try:
find = nx.dijkstra_path_length(G, x, y)
break
except: #如果抛出异常, 说明不连通
x = randint(0, people_num - 1)
y = randint(0, people_num - 1)
list += "dijk {} {}\n".format(x, y)
f.write("{}\n".format(find))
f.close()
return list
其中,\(dijk\)是我修改\(runner\)后直接调用的\(network\)的最短路径算法。由此,可以方便的构造出大量有强度的数据。其他的使用方法也可参照\(networkx\)的说明,进行构造。但必须注意,黑盒测试完全无bug不代表你的程序完全无bug,相信这一点在以往的单元也有体会了。
通过这样的黑盒测试,在借助\(time()\)方法,可以在一定程度上测试你的程序跑得怎么样,进而对方法复杂度是否合理降低有一定的评判。(当然拿这个来hack也是很爽的)
2.3 其他测试
诸如\(OpenJML~~JMLUnitNG\)等直接作用于\(JML\)的工具,感觉不太完善,只能测一些相当简单的情况(还不如\(Junit\)手测)。按老师讲的,目前主流的工具也只能解决不太复杂的一阶二阶逻辑命题,也没带多大期待去测试就是了hh。
3. 容器选择和使用经验
3.1 第一次作业
根据规格,数组基本上都是以\(id\)的形式存储的,而我们的规格不允许出现相同的\(id\),故本次作业基本都使用了\(HashMap<Integer, ...>\)来进行存储。例如,对于\(person\)中的\(acquaintance\)以及\(nerwork\)中的\(people\),使用\(HashMap<Integer, Person>\)来加快查找。
3.2 第二次作业
根据新增的规格,\(person\)中\(messages\)以\(ArrayList<Message>\)形式存储,方便在\(get\_received\_messages\)时遍历;\(group\)的\(people\)和\(nerwork\)中新增的\(groups~~messages\),也以\(HashMap\)存储,加快查找。
3.3 第三次作业
新增的规格规定了\(emojiIdList~~emojiHeatList\)。对于前者,为了跟\(message\)的\(id\)进行关联,采用了\(HashMap<Integer, ArrayList<Integer>>\)来存储,前一个用来存\(emoji\)的\(id\),后一个用来存使用这个\(emoji\)的\(emojiMessage\)的\(id\),用来进行快速删除;后者用\(HashMap<Integer, Integer>\)来加快查找。
3.4 经验总结
总的来说,一般的容器都需要查找\(id\),此时使用\(HashMap\)来查找是比较快速而省事的;而对于某些特殊要求,如\(person\)的\(messages\)中的要求,对应使用\(ArrayList\)比较方便。
4.性能问题
4.1 第一次作业
第一次作业主要的性能瓶颈是\(is\_cicle\)跟\(query\_block\_sum\)。前者如果用DFS,时间复杂度倒也还行;后者如果按照规格写的从头到尾遍历再调用\(is\_circle\),时间复杂度一般都是爆\(O(n^2)\)的,CTLE也就板上钉钉了。
4.1.1
为了加快\(is\_cicle\),可以采用按秩合并、路径压缩的并查集加快对\(is\_circle\)的查询。个人感觉,并查集有点像离散二中所学的,采用集合中的某一个元素作为这个集合的代表,在进行集合间比较、归并等操作时,都拿代表元素来进行。由此构造出\(root()\),对于元素\(x\),\(root(x)\)为它的代表元素。
路径压缩比较好理解,形如下者。诚如上者所言,一个集合我们仅想要一个代表元素。但因为我们\(add\_relation\)加入\(root\)时的点不一定是代表元素,由此形成的链式结构不利于快速查找。故查找同时进行路径压缩。
int find(int son) {
if ((father = root(son)) != son) { //不是根节点, 进行路径压缩
return (root(son) = root(father));
} else { //是根节点, 返回
return son;
}
}
在进行集合\(A、B\)合并时,不妨假设其代表元素分别是\(x、y\),是让\(root(x) = y\)还是\(root(y) = x\)?显然,因为某些元素不一定能访问到,集合内部的构造都是树状,其高度为秩,让秩增大显然是不利于查询的。故由此构造出\(rank()\),对于代表元素\(x\),\(rank(x)\)代表它的秩。
void merge(int x, int y) {
if (rank(x) > rank(y)) root(y) = x; //x的秩小, 将y加入x
else if (rank(y) > rank(x)) root(x) = y; //y的秩小, 将x加入y
else { //相同, 将y加入x, x的秩加1
rank(x)++;
root(y) = x;
}
}
此时的秩因为路径压缩,不一定代表真正的高度,但终归还是有点用吧(大概)。
在对\(x、y\)进行\(add\_relation\)时,若\(find(x) == find(y)\),则不需要进行任何操作;否则\(merge(x, y)\)进行集合合并即可。
4.1.2
有了并查集,\(query\_block\_sum\)就会轻松很多。定义\(blockSum\),在\(add\_person\)时加1,集合合并时减1,即可做到\(O(1)\)应答。
4.2 第二次作业
第二次作业主要性能瓶颈为\(query\_group\_age\_var跟query\_group\_value\_sum\)。若按照规格实现,前者为\(O(n)\),后者为\(O(n^2)\)。
4.2.1
先对\(query\_group\_age\_mean\)进行优化。显然,维护一个\(ageSum\),在每次\(add\_to\_group\)加入人的\(age\),此时\(age_mean = ageSum / people.length\),即为\(O(1)\)应答。又由于\(0\le age \le 200, 0\le people \le 1111\),故$0 \le ageSum \le 222200 $,在int的表示范围内。
再优化\(query\_group\_age\_var\)。考虑到\((age - mean)^2 = age^2 - 2 * age * mean + mean^2\),而且我们有
而\(\sum_{i=0}^{people.length-1}age_i = ageSum\),故上述公式可写为
定义\(ageSquare\),在每次\(add\_to\_group\)加入\(age^2\)。求ageVar时,只需要\((ageSquare - 2 * mean * ageSum + mean^2 * people.length) / people.length\)即可。需要注意的在于,若将\(people.length\)提取出来,很可能因为内部为负数,向0取整导致与正确结果差1。又由于\(0\le age^2 \le 40000, 0\le people \le 1111\),故$0 \le ageSum \le 44440000 $,在int的表示范围内。
4.2.2
定义\(valueSum\),在每次\(add\_to\_group\)时对新加入的人、所有在组内的人进行遍历,若\(isLinked()\),则\(valueSum += 2 * queryValue()\)。由于\(0\le people \le 1111, 0 \le value \le 1000\),故\(0 \le valueSum \le 1111 * 1110 *1000\),即为0x4981_4A90 < 0x7FFF_FFFF,故在int范围内。由此\(O(n)\)维护,\(O(1)\)应答。
同时,注意必须在外部\(add\_relation\)时检查\(group\)是否含有这两个人,若有则必须加上二倍\(value\)。
4.3 第三次作业
主要性能瓶颈为\(send\_indirect\_message\)。求最短路径,可以采用堆优化的Dijkstra算法。新增一个类\(Element\),覆写\(compareTo()\)对\(distance\)进行比较;再利用JAVA自带的\(PriorityQueue\)即可实现小顶堆。
另一个可能不算瓶颈但有优化的,为\(delete\_cold\_emoji\)。构造\(HashMap<Integer, ArrayList<Integer>>\),在每次\(add\_message\)成功时,判断是否为\(emojiMessage\),是则将\(message\)的\(id\)加入\(emoji\)的\(id\)对应的\(ArrayList\)中。由此删除时,只需要根据\(ArrayList\)进行删除即可。需要注意的是,删除时不要用for-each,否则会报错,可以先存起来再删;同时,每次\(send\_message\)或\(send\_indirect\_message\)成功时,若为\(emojiMessage\),需要删去项。
4.4 性能问题的避免
一方面,通过往届师兄师姐们的博客,了解到很多很优秀的算法,进而得以实现,从而避免了性能问题;另一方面,无论是白盒的Junit还是黑盒极端大量数据下的测试,本单元做得都还行,故没有CTLE问题。
5. 图模型的构建与维护策略
架构设计已经集成在1、4两节中了,若在此再次赘述,大抵就是水字数了,不提。
5.1 图模型构建
emmmm个人感觉图模型实质上已经由规格限制定型了,只有少数几个诸如\(addRelation~~addToGroup\)等需要自己写一点东西,来保证符合规格描述的图模型。通过\(addPerson\)加点,\(addRelation\)加边,\(addGroup\)形成不同的组,基本上就这样。点的构造在\(Network\)中,边的构造分散在各个\(Person\)内部,从而形成最基本的关系网;而后又通过各种\(Message\)来发送信息,模拟关系网。
若要讨论构造的思想,则它已存在于规格中,再次论述没什么意思;若要讨论构造的方法,则已经在容器选择上加以讨论。容器选定后,实际构造基本板上钉钉了,便是将元素加入容器、从容器剔除,而这又是JAVA干的事。只有在容器选择上,会体现对图构造的一些思考,都是用\(HashMap\)来维护\(id\)不同的图内各种元素。
5.2 图模型维护
图模型的维护主要集中在对发送\(Message\)后\(Person\)的\(SocialValue\)的维护以及\(Group\)内部的维护。
前者比较常规。在忽略异常情况下,发送\(Message\)若\(type == 0\),需要注意加入\(Person2\)即可;而加\(SocialValue\)时,可以在\(Person\)内新增\(addSocialValue\)方法,在组内调用即可。
后者的维护实质上是对\(ageSum~~ageSquare~~valeSum\)等各种中间变量的维护。注意到这三者都不会超过\(int\)的表示范围,故大胆地用\(int\)表示即可;在\(addToGroup~~deleteFromGroup\)时及时遍历,相应增减便能维护好。同时,由于内部提前计算了\(valueSum\),无法注意到外部的\(addRelation\),故增加\(maintain\)方法供外部调用。当\(relation\)的两个\(person\)都在组内时,\(network\)调用该方法进行维护。
public class MyGroup implements Group {
...;
public void maintain(int value) {
valueSum += 2 * value;
}
}
6. 感想
这次\(JML\)作业相比前两个单元,还是有点水的,很多东西只要根据规格写好即可,需要仔细设计的地方其实并不多,做好\(JUnit\)测试基本就不会翻车(CTLE管不着)。每次写的时间跟测试时间差不多五五开,平均花费了十来小时,感觉还行(为解决OS的TOO LOW奠定了坚实的物质基础)。
总的来说,这单元还是让我学到了不少东西。\(JML\)是一种不存在二义性的语言,比自然语言要来得严谨,一方面体现了设计者的构思,另一方面也保证了理解上不存在误差。但既然本单元只有极少数的书写\(JML\)的作业,更多的是根据\(JML\)写设计,不妨考虑以后的作业多一些\(JML\)书写,抑或减少一次作业?

浙公网安备 33010602011771号