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^2 - 2*age_i*mean + mean^2}\\ =\sum_{i=0}^{people.length-1}age_i^2 - 2*mean*\sum_{i=0}^{people.length-1}age_i + mean^2*people.length \]

​ 而\(\sum_{i=0}^{people.length-1}age_i = ageSum\),故上述公式可写为

\[\sum_{i=0}^{people.length-1}age_i^2 - 2*mean*ageSum + mean^2*people.length \]

​ 定义\(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\)书写,抑或减少一次作业?

posted @ 2021-05-27 19:41  _Winterfell  阅读(74)  评论(0)    收藏  举报