BUAA-OO-第三单元(简单社交网络模型的实现)总结

OO第三单元总结

一、实现规格的设计策略及架构设计

1. 并查集维护isCircle()方法

isCircle需要我们对于关系网络给出任意两个节点是否连通的情况,在参考了助教学长的博客后,使用并查集+路径压缩的方法用于计算两个节点是否连通:

	private int find(int id) {
        if (relations.get(id) == id) {
            return id;
        }
        int ans = find(relations.get(id)); 
        relations.replace(id, ans); //路径压缩
        return ans;
    }

并在addRelation方法中维护relations Map

    public void addRelation(int id1, int id2, int value) throws
            MyEqualRelationException, MyPersonIdNotFoundException {
        ...
        int faId1 = find(id1);
        int faId2 = find(id2);
        if (faId1 != faId2) {
            this.blockSum -= 1;
        }
        relations.replace(faId1, faId2);
    }

当然选用高效的并查集也会但也会降低程序的可扩展性,即如果出现删边的操作并查集就失去了作用效果,这时就可能需要替换相应的算法。

2. DjistraSP维护的sendIndirectMessage()方法

sendIndirectMessage需要我们返回两个不直接相连的连同节点的最短路径,在参考了助教学长的博客后,使用优先队列模板类维护每次候选的节点集合,以relation的value值作为边的权重(有限队列排序的依据),求出最短路径,具体代码实现如下:

	public static int getShortestPath(Network n, Person personSend, Person personReceive) {
        PriorityQueue<Integer[]> heap = new PriorityQueue<>(Comparator.comparing(e -> e[2]));
        heap.add(new Integer[]{personSend.getId(), personSend.getId(), 0});
        HashMap<Integer, Integer> distances = new HashMap<>();
        while (!heap.isEmpty()) {
            Integer[] curEdge = heap.poll();
            if (distances.containsKey(curEdge[1])) {
                continue;
            }
            distances.put(curEdge[1], curEdge[2]);
            if (curEdge[1] == personReceive.getId()) {
                break;
            }
            for (Map.Entry<Integer, Integer> pairs :
                    ((MyPerson) n.getPerson(curEdge[1])).getAcquaintances().entrySet()) {
                if (!distances.containsKey(pairs.getKey())) {
                    heap.add(new Integer[]{curEdge[1], pairs.getKey(),
                            distances.get(curEdge[1]) + pairs.getValue()});
                }
            }
        }
        return distances.get(personReceive.getId());
    }

3. HashMap(id-value对)维护的各类查找

几乎所有的需要根据id查找的容器都选用了hashMap.

二、JML规格的测试方法

JUNIT手动构造的方法测试

在本次的三次作业中均使用Junit单元测试对Network的方法进行测试,并通过查看测试的代码覆盖情况,保证每行代码都得到了执行。这样即可以保证程序的基本正确性。局部展示如下:

public class MyNetworkTest {

    @Before
    public void before() throws Exception {
        System.out.println("test begin!");
    }

    @After
    public void after() throws Exception {
        System.out.println("test finished!");
    }

    @org.junit.Test
    public void addPerson() throws EqualPersonIdException {
        Network n = new MyNetwork();
        Person a = new MyPerson(1, "a", 1);
        n.addPerson(a);
        assert (n.contains(1));
    }
    
    public void sendIndirectMessage() throws MessageIdNotFoundException, EqualPersonIdException, PersonIdNotFoundException, EqualRelationException, EmojiIdNotFoundException, EqualMessageIdException, EqualEmojiIdException {
        Network n = new MyNetwork();
        Person a = new MyPerson(1, "a", 1);
        Person b = new MyPerson(2, "b", 2);
        Person c = new MyPerson(3, "c", 3);
        Person d = new MyPerson(4, "d", 4);
        Person e = new MyPerson(5, "e", 5);
        n.addPerson(a);
        n.addPerson(b);
        n.addPerson(c);
        n.addPerson(d);
        n.addPerson(e);
        assert null == n.getPerson(6);
        assert (n.contains(1));
        assert (n.contains(2));
        assert (n.contains(3));
        assert (n.contains(4));
        assert (n.contains(5));
        n.addRelation(1, 2, 50);
        n.addRelation(1, 4, 50);
        n.addRelation(2, 5, 50);
        n.addRelation(5, 3, 50);
        n.addRelation(3, 4, 50);
        MyMessage a2b = new MyMessage(7, 20, a, b);
        n.addMessage(a2b);
        MyMessage a2c = new MyMessage(8, 20, a, c);
        n.addMessage(a2c);
        MyMessage a2d = new MyRedEnvelopeMessage(9, 100,  a, d);
        n.addMessage(a2d);
        assert n.getPerson(1).getMoney() == -100;
        assert n.getPerson(4).getMoney() == +100;
        EmojiMessage e1 = new MyEmojiMessage(15, 20, a, b);
        n.storeEmojiId(20);
        n.addMessage(e1);
        n.sendIndirectMessage(15);
        assert 1 == n.queryPopularity(20);
    }
...

三、性能相关的讨论

"惰性维护"与"积极维护"孰优孰劣?

在几次作业中最后均采取积极维护的测略,对于查询均值、方差、关系图结构的边权值和等等,均在相应的类中直接相应的成员变量,其查询值的方法中直接返回(或稍作修改)对应的变量的值。这种方法当然保证了强测点不会C/RTLE,但个人认为这样的方法并不一定是最优的,下面以图结构的边权值和的维护为例对这一点进行说明

在加关系时维护该组的图结构的边权值至少要保证:

在加关系时需要判断这两个人是否在一个组(有好多组)中,如果是,更新相应组的边权值

在拉取同互测组的代码后会发现,组中的人对于上述的实现有所不同,有人对各个组进行遍历查找是否添加关系的两个人是否在同一个组中,也有人对此维护了新的Map用以快速地查找,本人的实现中是在每个人中增加一个成员变量维护这个人所在的所有组的id,用以快速确定需要更新边权值的组...

在这种情况下,笔者不禁想发问,维护这么多东西真的值得吗?

答:一般情况下值得,对于以下的特殊数据就变得不值得:

a创建一个组
 向组中加人
 向组中加人
 ...(加了好多人)
jumpto a (重复上述循环多次)

组中的人加关系
组中的人加关系
...(加了好多关系)
查询各个组边权值和(仅少量次)

还有更加令人难受的可能:

a创建一个组
 向组中加人
 向组中加人
 ...(加了好多人)
jumpto a (重复上述循环多次)

组中的人加关系
组中的人加关系
...(加了好多关系)

delFromGroup person1 Group1
delF...把人基本上删的差不多了
查询各个组边权值和

其实笔者感觉这样的需求在实际生活中并不特殊,只要进行查询的频率很低就会出现类似的情况。

如果支持删除边的操作可以想见就更加难受了。

针对这种情况,笔者构造了另一种"惰性维护"的方法,即每次查询时对整个数据完整地进行计算,算出的结果缓存下来,标记缓存的值为有效,只有在相应的更新发生变化后标记此缓存无效,下一次查询时,如果缓存值有效则直接返回缓存值,如果无效则重新计算并标记为有效。具体来说:

public int getValueSum() {
    if (!this.needUpdate) {
        return this.valueSum;
    } else {
        this.valueSum = 0;
        for (Map.Entry<Integer, Person> entry1 : this.people.entrySet()) {
            for (Map.Entry<Integer, Person> entry2 : this.people.entrySet()) {
                this.valueSum += entry1.getValue().queryValue(entry2.getValue());
            }
        }
    }
    this.needUpdate = false;
    return this.valueSum;
}

而在其他更新的地方则标记this.needUpdate为true。

笔者对于这种设计与积极维护的设计构造较为特殊的数据进行压力测试,会发现两种设计的效率孰优孰劣会随着查询频率的变换而变化。

即上述两种方法各有优势,而具体谁占优则取决于实际的需求,如果实际的需求中查询的评率存在动态的规律性的变化,我们也许可以让程序自己察觉这种变化并自己选择更新的策略 (若好久没查询过或者集中查询就自动使用惰性维护的方式,若经常交替更新和查询就使用积极维护的方法) 。

四、bug分析

1. 公测的bug

三次作业的强测和互测共出现一个bug(比较低级但也因此导致了严重的后果) :异常类中,由于复制代码忘记更改,导致本应输出minf的地方给输出了pinf(从而红了一片)。究其根源是没有在Junit测试中的专门测试异常情况,没有做到百分百的代码覆盖率。

2. 自测的bug

1.规格理解的问题,在第一此作业中关于两个人是否是linked的官方JML如下:

@ ensures \result == (\exists Person[] array; array.length >= 2; 
@             		  array[0].equals(getPerson(id1)) && 
@                     array[array.length - 1].equals(getPerson(id2)) &&
@                      (\forall int i; 0 <= i && i < array.length - 1; 
@                      array[i].isLinked(array[i + 1]) == true));

这里由于array中的元素可以重复,因此对于一个包含有两个相同元素(人)的array,可知一个人自己和自己属于linked,需要在方法中特判。

2.求解方差的问题

利用方差公式时,由于整型变量直接丢掉了小数部分,所以不能用最简式:

\[D(x) = E(x^2) - E(x)^2 \]

直接计算,而需要将利用原始公式计算:

\[D(x) = \frac{1}{n}\Sigma(x-E(x))^2 = \frac{1}{n}(\Sigma x^2 -2E(x)\Sigma x +nE(x)^2) \]

具体改写后的方法如下:

public int getAgeVar() {
    if (this.people.size() == 0) {
        return 0;
    } else {
        return (this.ageSquareSum + this.people.size() * getAgeMean() * getAgeMean()
                - 2 * ageSum * getAgeMean()) / this.people.size();
    }
}

五、可能的改进方向

官方给出了所有我们需要实现的接口,在三次作业中,笔者按照接口一一对应相应的类,这样的做法只能说是应付作业。就像我们不能照搬JML暴力枚举的算法一样,我们内部类的构造同样应该与外部接口相分离。比如,对于图的算法单独开辟一个类,就可以使得在外部增加其他新的需求如允许两人拉黑(此时并查集就没用了)的时候给接口更换新的算法,而不是将分散在Network中的维护信息的部分一一修改,这样也许能使得程序更加符合开闭原则。也能更好地屏蔽我们的底层实现(不让理解每个方法接口的JML的人明白我们内部是怎么实现的)。

六、体会与感悟

JML是这个单元学习的重点。三次作业训练了我们理解JML接口规格,并根据规格写出正确的函数代码的能力。虽然笔者感觉这些JML接口规范的编写难度极大完全超过其对应的实现代码,而且似乎完全没用。但在研讨课的同学分享和老师的强调中被安利了一波传说中的"从数学上保证代码没有bug"的形式化验证镜中之月美好未来后,只好尝试接受JML的存在。当然,觉得JML没有用并不意味着学习JML没有用,在学习JML的过程中,将离散数学中的各种量词实际应用到函数的描述中巩固了我们离散数学的知识(糊~),同时子类继承父类的不变式和约束的强弱关系的理解,也使得我们更加深入理解子类和父类应该具有的关系,并体会到保证这样的关系后所带来的好处。这三次作业中,如何在正确地实现JML所规定的功能的基础上,改变内部的实际存储的对象结构和在这些对象上的维护操作从而提高性能,是我们中需要思考的主要问题。

就三次作业和测试本身而言,这个单元虽然实现难度较前两个单元有所下降,但过弱的弱测和中测导致失去了面向测评机的bug查找的机会还是容易翻车的。自己测试中出现的一些bug大部分是对JML的理解不当造成的。在测试方面,编写Junit单元测试并做到对所有代码的覆盖,能使得程序功能基本正确,且对于0-1的边界数据能够正确执行。另外应当对程序整体进行覆盖性黑盒测试,从而使得程序能够正确执行一般性的数据。

posted @ 2021-05-29 14:33  SunzeYi  阅读(214)  评论(0编辑  收藏  举报