北航OO课程 第三单元总结

北航OO课程 第三单元总结

前言

第三单元主要考察的是JML规格的理解和运用。与前两个单元的腥风血雨不同,由于有JML规格的参与,本单元在类和方法的设计上并没有表达式化简和电梯调度复杂,我们只需要在官方包的基础上实现我们自己的方法。然而,在经历了整个单元的学习后,我发现这个单元并没有口口相传的那样人畜无害;相反,本单元在算法和复杂度上对方法有着严格的要求,对不熟悉算法的我而言,这无疑是一个巨大的挑战。


第九次作业

设计要求

实现简单社交关系的模拟和查询,主要任务为通过实现MyPerson类和MyNetwork类来构建社交关系网络,并继承官方包里的若干异常类来进行异常的抛出。

架构设计
  • 数据存储选择HashMap容器

在作业要求的社交关系网中,常常需要进行通过personId获取person的查询操作,如果使用ArrayList进行存储,在每次查询时都需要对people进行一次遍历,时间复杂度为O(n);而如果使用HashMap进行存储,每次查询时只需要通过key直接获取对应的value,时间复杂度为O(1),时间复杂度得到了明显的降低。

private final ArrayList<Person> people;          // 使用ArrayList,多次遍历 
private final HashMap<Integer, Person> people;   // 使用HashMap,一次结束
  • 引入并查集来解决连通图的问题

本次作业的一个难点是isCircle方法判断两个点在图中是否连通,在数据结构的学习中我们遇到过很多这样的问题。而在本次作业中,我采用的是优化的并查集算法,在并查集算法的基础上,让每一个连通分支上的所有结点共用一个父亲结点,在isCircle方法中只需要判断两个结点的父亲结点是否相同即可,极大地降低了时间复杂度。

father.put(person.getId(), person.getId());   // addPerson方法中维护并查集
father.put(id2, father.get(id1));   // addRealtion方法中维护并查集
for (Map.Entry<Integer, Integer> entry: father.entrySet()) {
	if (entry.getValue() == id3) {
		father.put(entry.getKey(), father.get(id1));
    }
}
  • 对部分需要查询的变量进行动态维护

在本次作业中,qbs和qts无疑是最让人头疼的两个方法。采用并查集算法后,qbs可以通过简单的一次遍历解决,而qts则只能套三层for循环处理,时间复杂度为O(n³);为了降低时间复杂度,我采用设置全局变量triSum的方法,在每次addRelation时都进行一次三元环的判断,动态维护triSum的数值,在调用qts方法时直接返回triSum即可。同样地,对并查集的维护也是采用了这种动态维护的方法。

private int triSum = 0;
for (Person p: people.values()) {
	if (p.getId() != id1 && p.getId() != id2) {
		if (p.isLinked(p1) && p.isLinked(p2)) {
        	triSum++;
        }
    }
}
  • 异常类的实现

本次作业中出现了多个异常类,每个异常类都需要继承官方包中的一个父类并实现print方法,这些异常类的共同点较多,因此我选择外建一个Counter类并在异常类内部内置静态Counter变量,对每个异常类都使用一个静态的counter变量进行计数,最后的实现也是没有出任何问题的。

性能分析

本次作业出现的最大性能问题便是查询问题。由于这是本单元的第一次作业,我对于规格的理解还不够到位,只是浅显地认为要严格按照规格进行实现,于是便使用了时间效率极低的ArrayList和多重for循环,这也导致我的强测中出现了多个ctle;后面进行了算法改良之后,CPU运行超时的问题得到了有效的缓解。


第十次作业

训练目标

进一步实现社交关系模拟系统中的群组和消息功能,主要任务为在前一次作业的基础上,增加MyGroup类和MyMessage类,并增加相关的若干方法和若干异常类。

架构设计
  • 并查集处理删边问题

众所周知并查集这一算法最大的缺点便是不能删边,这也成了本次作业使用并查集的同学遇到的最大的难题。对于这个问题,我采用的是复杂度较高的一种算法,即在每次删边时将并查集清空,再根据所有已有的点和边重建并查集。这个方法相较于直接从所删边的两个端点出发进行dfs或bfs搜索修改并查集的算法而言,复杂度是比较高的,但由于我对于这两种算法不是很熟悉,这种方法反而是最适合我的方法。

private void rebuildFather() {  // 重建并查集
    for (int key : people.keySet()) {
        father.put(key, key);
    }
    for (int key1 : people.keySet()) {
        MyPerson person = (MyPerson) people.get(key1);
        int father1 = findRoot(key1);
        for (int key2 : person.getAcquaintance().keySet()) {
            int father2 = findRoot(key2);
            if (father2 != father1) {
                father.put(father2, father1);
            }
        }
    }
}
  • 在MyGroup类内部进行valueSum,ageSum的动态维护

与上次作业中的triSum类似,本次作业中对于每一个群组,都内置了两个全局变量,在addPerson,delPerson和Network类的addRelation,modifyRelation方法中,对两个变量进行动态维护。这里尤其要注意的是权值和,由于规格定义的特殊性,关系图中的每一条边的权值都要计算两次,因此在维护时也要进行*2的操作。

for (Person p: people.values()) {  // 在addPerson方法中
    if (person.isLinked(p)) {
        valueSum += 2 * person.queryValue(p);
    }
}
for (Group group : groups.values()) {  // 在addRelation方法中
    if (group.hasPerson(p1) && group.hasPerson(p2)) {
        ((MyGroup) group).addValueSum(2 * value);
    }
}
  • 对于qba方法在MyPerson类中进行动态维护

依然是动态维护,此处要注意的是mr方法降低value之后,需要重新寻找bestId,本人也是在这个地方由于考虑不完全导致bug的产生。动态维护已经说了很多,这里不再过多赘述。

  • qcs方法采用一遍遍历而非两遍遍历

这个方法的优化要着重感谢一下Doxel同学的帮助,我最初使用的是和规格相同的两层遍历,时间复杂度O(n²),在同学的帮助下顺利换成了时间复杂度为O(n)的算法,虽然只是一个小小的算法,给我带来的震撼是十分大的,让我清楚认识到自己和佬之间的差距有多大。

public int queryCoupleSum() {  // 一层遍历即可解决问题
    int sum = 0;
    for (int id1 : people.keySet()) {
        MyPerson p1 = (MyPerson) people.get(id1);
        int id2 = p1.getBestAcqId();
        MyPerson p2 = (MyPerson) people.get(id2);
        if (p2.getBestAcqId() == id1 && id2 != id1) {
            sum++;
        }
    }
    return sum / 2;
}
  • mr方法的细节十分众多

mr方法的实现过程中,由于对许多变量都需要进行动态维护,因此mr不仅仅是对关系的维护,还需要同时考虑person的bestId(qba),group的valueSum(qgvs),三元环的数量(qts),这里需要十分注重细节,不漏下任何一个可能改变的变量。

性能分析

本次作业的性能失分处主要是mr方法的删边,删边是并查集的天然短板,因此在mr方法的删边实现中遇到了很多的困难,bug频发,困难重重,而重建并查集的方法不当则会带来ctle的问题,每加一条边进行一次遍历,最后的时间性能肯定远远满足不了评测机的要求;最后在bug修复阶段通过添加root的方法,将这个问题解决,这也说明看似复杂度极高的重建并查集算法在本次作业的实现中是可行的。


第十一次作业

训练目标

进一步实现社交关系系统中不同消息类型以及相关操作,主要任务为在前两次作业的基础上,增加三个不同种类的消息类,并相应地增加和修改部分方法和异常类。

架构设计
  • 有关sendMessage方法的变化

在sm方法中分为人对人(私聊)和人对组(群发)两种形式,而在本次作业中两种形式都有红包、表情和消息三类信息,私聊的实现相对容易,对于群发,在发红包时需要注意发红包人money的变化,不要凭空生钱;还要注意不论哪种消息,群组内每个人的socialValue都是要发生变化的,群发相较于私聊更复杂,但如果仔细阅读过规格的描述,实现起来也并不困难。

  • 在deleteColdEmoji方法中的迭代器使用

在dce方法中需要在循环中对容器的内容进行删除操作,这是在第一单元作业中经常出现的问题,如果直接使用容器的remove方法,就会报java.util.ConcurrentModificationException异常。我们需要用到迭代器来对容器进行删除操作,这是java容器的使用需要十分注意的地方。

Iterator<Integer> iterEmoji = emojiList.keySet().iterator();
while (iterEmoji.hasNext()) {
	int key = iterEmoji.next();
    if (emojiList.get(key) < limit) {
        iterEmoji.remove();
    }
}  // dce方法中迭代器的使用一例
  • 在queryLeastMoments方法中采用讨论区大佬提供的魔改dijkstra算法

不得不说讨论区真的人才辈出,将最小环拆分成最短路径和次短路径的和的最小值,对最短路径和次短路径的维护都在一次dijkstra中实现,最需要注意的是在进行更新时,需要判断最短路径的出点和次短路径的出点,这也是这个算法思考量最大的地方。凭借这个算法,我成功通过了测试中的大部分qlm方法。写完这个方法之后。不禁感叹算法设计的精妙和想出算法的人的强大。

private void update(int dist, int u, int v, HashMap<Integer, Path> paths, int flag) {
    if (dist + getPerson(u).queryValue(getPerson(v)) < paths.get(v).getDist1()) {
        if (paths.get(u).getOrigin1() != paths.get(v).getOrigin1()) {
            paths.get(v).setDist2(paths.get(v).getDist1());
            paths.get(v).setOrigin2(paths.get(v).getOrigin1());
        } // 有更短的加入,v的最短路径维护v的次短路径
        paths.get(v).setDist1(dist + getPerson(u).queryValue(getPerson(v)));
        paths.get(v).setOrigin1(paths.get(u).getOrigin1()); // 维护v的最短路径
    } else if (dist + getPerson(u).queryValue(getPerson(v)) < paths.get(v).getDist2()) {
        if (paths.get(u).getOrigin1() != paths.get(v).getOrigin1() && flag == 0) {
            paths.get(v).setDist2(dist + getPerson(u).queryValue(getPerson(v)));
            paths.get(v).setOrigin2(paths.get(u).getOrigin1()); // u的最短路径维护v的次短路径
        } else if (paths.get(u).getOrigin2() != paths.get(v).getOrigin1() && flag == 1) {
            paths.get(v).setDist2(dist + getPerson(u).queryValue(getPerson(v)));
            paths.get(v).setOrigin2(paths.get(u).getOrigin2()); // u的次短路径维护v的次短路径
        }
    }
}
性能分析

本次作业的性能失分点还是在qlm方法,尽管使用了魔改dijkstra算法,在最大程度上降低了方法的时间复杂度,但由于没有进行堆优化,在数据量较大时,比如强测的测试点4和9,还是会出现ctle的情况;进行堆优化处理后,算法的时间复杂度有明显的下降,测试点也自然会顺利通过。


关于测试

作业的测试过程
黑箱测试与白箱测试

黑箱测试是从一种从软件外部对软件实施的测试,是对软件功能进行的专门的测试,只考虑软件的输入和输出结果而不考虑其内部结构和实现方法如何,是软件功能检测的一种强有力的方法;白箱测试则相反,是知道软件内部工作过程,通过测试来检测软件内部动作是否按照规格说明书的规定正常进行,检验程序中的每条通路都按照规格严格执行,不考虑执行的结果以及软件的功能如何。

我们作业的过程中可以将黑箱测试和白箱测试相结合,提高测试的效率。

对各类测试的理解

单元测试(Unit Test),是指对软件中的最小可测试单元进行检查和验证。

功能测试(Functional Test), 是在规定的一段时间内运行软件系统的所有功能,以验证这个软件系统有无严重错误。

集成测试 (Integration Test),是在单元测试的基础上,将所有模块按照设计要求组装成为子系统或系统,进行联合测试。

压力测试 (Stress Test),是指系统不断施加越来越大的负载(并发,循环操作,多用户,网络流量)的测试。

回归测试 (Regression Test),是指修改了旧代码后,重新进行测试以确认修改没有引入新的错误或导致其他代码 产生错误。

测试工具的使用

本单元作业中我尝试使用Junit测试工具,但因其过于复杂且效率不高故放弃,我主要使用的还是同学开发的评测机,在评测机的帮助下我找出了很多的bug。

数据构造的策略

大规模随机生成数据方便且快捷,但debug较为困难;而自己针对特殊情况构造的数据容易debug,但费时费力。因此我主要采用的是多次小规模随机生成数据的方法,在这种方法无法找到更多bug时再采用大规模随机生成数据和精细化数据构造结合的方法,从大规模的数据中抽丝剥茧,从中找到bug原因后再手动构造数据测试。

OK测试方法

OK测试的主要目的是检验代码实现和规格的一致性,通过机械化地翻译规格来进行代码实现正确性的检验,这个测试过程要严格按照规格来做,不需要考虑任何有关时间复杂度和算法的问题。只要依照规格一行一行地进行正确性验证,OKTest方法的实现并不困难。

在本单元作业的实现中,由于部分类和方法过于冗余,影响了代码风格的检查,因此需要将这些类和方法拆分成更小的子类和子方法,OKTest即是如此。这种行为本身的意义并不大,但为了整体的代码风格,在OKTest类中最好还是要根据代码块的功能进行子方法的拆分。


心得体会

规格单元到此结束,经历整整一个月的学习之后,我感觉规格这单元并没有想象中那样容易。虽然学的是规格,在大部分时间都在卷算法、卷时间复杂度,感觉上有些本末倒置,但也无可厚非。

不管怎样,规格是程序设计过程中的一个强有力的工具,是代码实现的“说明书”,规格为具体的代码实现提供了模板,让代码的实现有迹可循,为代码的实现提供了遍利。

至于说规格与实现分离的思想,更多的是在算法上对方法的时间复杂度进行优化,用更优的算法跑出更快的程序,这也是程序员为之努力的终极目标。

规格这单元,尽管与面向对象关系不大,但在程序设计的过程中提供了一种思想,一种代码实现要首先满足功能的思想,一种可以基于功能进行测试的思想,这是我在本单元最大的收获。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值