OO第三单元小结
总体架构
本单元架构并不复杂,主要包含关于Message
类及其子类的继承关系和对于12个抽象异常类的实现。
其中关于Message
类的层次架构主要源于规格的层次架构。在规格中,NoticeMessage, RedEnvelopeMessage, EmojiMessage
的规格接口继承自Message接口,进行了接口扩展,具有父接口所有的方法签名。对于实现类而言,子类具有父类全部属性,满足父类所有满足的不变式。规格的层次化设计指导了具体实现的层次化,二者的层次结构具有对应关系。
关于图模型
图的构建
本单元模拟的社交网络本身就是一个无向图模型。关于图中的数据和数据间的关系采用了邻接表的方式进行存储。
具体而言,MyPerson
类对应图中的节点,其中的属性acquaintance
为一个Person数组,存储了该节点所有的邻接节点,属性values
则代表了边的权。MyNetwork
类存储整个图的所有节点信息。
图的构建通过addPerson()
和addRelation()
完成,即向图中添加孤立节点,然后再添加该点和其他点间的边。图中节点的删除通过modifyRelation()
实现,通过修改图中已经存在的边的权,如果边的权变成非正值,则将该边从图中删除。
关于图的一些算法
-
深度优先搜索dfs
在
isCircle()
中使用深度优先搜索用来判断任意给定的两个人是否属于一个社交圈,即对应在图中的两个节点是否可达。public Boolean dfs(MyPerson start, MyPerson end) { Set<Person> visited = new HashSet<>(); ArrayList<Person> path = new ArrayList<>(); return start.dfs(end, visited, path); }
public boolean dfs(Person goal, Set<Person> visited, ArrayList<Person> path) { visited.add(this); // 已访问 path.add(this); if (this.id == goal.getId()) { return true; } for (Integer id : acquaintance.keySet()) { MyPerson neighbor = (MyPerson) (acquaintance.get(id)); if (!visited.contains(neighbor)) { // 未被访问过 if (neighbor.dfs(goal, visited, path)) { return true; } } } path.remove(this); // 回溯 return false; }
-
广度优先搜索bfs
在
queryShortestPath()
中获得两个人之间通过其他多少个人就可以认识,对应就是求图中任意两个节点间的最短路径。这里虽然是求最短路径,但是对应图中的路径实际上是没有权的,实际上也就是只需要经过的中间节点最少即可。在网上搜索后知道求图中最短路径的算法有dijkstra算法,floyd算法和dfs算法。其中dijkstra算法适用于单源最短路问题,即从图中的一个特定节点到其他所有节点的最短路径。floyd算法可以处理所有节点对间的最短路径,对于稠密图的复杂度较其他算法更优。针对这里的具体问题,使用bfs是最为简单且合适的。
public boolean bfs(int startId, int goalId, HashMap<Integer, Integer> pre) { Set<Integer> visited = new HashSet<>(); ArrayList<Person> queue = new ArrayList<>(); // bfs队列 visited.add(startId); queue.add(network.getPerson(startId)); while (!queue.isEmpty()) { MyPerson head = (MyPerson) queue.remove(0); // 队首 if (head.getId() == goalId) { return true; } HashMap<Integer, Person> adjs = head.getAcquaintance(); // 邻接顶点 for (Person psn : adjs.values()) { if (!visited.contains(psn.getId())) { visited.add(psn.getId()); queue.add(psn); pre.put(psn.getId(), head.getId()); // 将邻接顶点的前驱节点设置为队首 } } } return false; }
性能的优化
-
在
queryBolckSum()
中使用了并查集并查集:disjoint-set data structure。顾名思义,并查集是用来处理一些关于不相交集合的合并和查询问题。并查集的核心是对于一些不相交的集合,每个集合都有一个代表,合并和查询都设计到对于代表的访问。
在这个问题中,需要判断两个节点间的可达性,进而判断在一部分节点范围内的孤立点的数目。这个问题就对应了图中的极大连通子图,连通分支的问题。各连通分支没有交集,这也就对应了并查集的特点。
并查集的基本操作包含了集合的合并和对于给定元素查找属于哪个集合,但是并没有删除元素的操作,如果要对集合中的元素进行删除,则只能重新构建并查集。而对于图来说,由于图中的边是可以动态删除的,所以必然要涉及到集合中元素的删除这个操作。
在基本的并查集上进行优化,有了路径压缩和按秩合并的优化方法,在集合的合并和查找的过程中动态维护相应的数据。
private HashMap<Integer, Integer> parent; // id - 代表id private HashMap<Integer, Integer> rank; // id - 秩
初始化
public void djsInit(int id) { parent.put(id, id); // 指向自己 rank.put(id, 1); // 秩为1 }
带有路径压缩的查找
public int find(int id) { if (parent.get(id) == id) { return id; } else { parent.replace(id, find(parent.get(id))); return parent.get(id); } }
按秩合并
public void merge(int id1, int id2) { int pa1 = find(id1); int pa2 = find(id2); if (rank.get(pa1) <= rank.get(pa2)) { parent.replace(pa1, pa2); // 小秩 -> 大秩 } else { parent.replace(pa2, pa1); } if (rank.get(pa1).equals(rank.get(pa2)) && pa1 != pa2) { // 深度相同 rank.replace(pa2, rank.get(pa2) + 1); } }
-
bestAcquaintance缓存数据
由于在
queryCoupleSum()
需要在两重循环中调用getBestAcquaintance()
方法,如果每次都进行遍历查找对于性能会造成比较大的影响。所以在MyPerson类中添加了bestAcquaintance这个属性,访问直接读取即可。基本思路为在对图结构进行修改时,如果添加节点,会同步维护bestAcquaintance,如果删除边,判断如果删除的边对应的节点就是bestAcquaintance,就将该属性置位无效,否则同步维护bestAcquaintance。这种方式的好处是能够最大限度的避免做无用功,不会由于维护属性而增加修改图结构的方法时间复杂度,这就避免了频繁删边但不查找bestAcquaintance值却频繁更新属性带来的开销,只有需要且缓存无效时再遍历查找。
public void renewCache(int personId) { if (bestAcquaintance != null) { MyPerson psn = (MyPerson) acquaintance.get(personId); // 修改的人 if (personId == bestAcquaintance.getId()) { // 修改的是 bestAcquaintance // ... } else if (psn != null) { // 修改操作不是删除 // ... } } }
-
representation数据规格的具体实现
在规格中的数据抽象往往使用基本数组来表示,但对于具体的实现可以使用其他类型的数据结构进行表示,只要满足规格所需满足的的条件即可。
在对于Person,Tag,Message等对象的存储中,使用hashMap进行存储,使得在查找某个具体的对象时,只需要通过对象的键值就可以快速索引到对象而不用每次都遍历数组。特别地,在dfs算法中使用hashSet来存储已经访问过的节点,这样在判断一个节点的邻接节点是否在visited集合中的复杂度就得到了降低。
关于测试
Junit
本单元的Junit测试和先导课的Junit测试的很大的区别在于,由于有了规格明确的限定,使得Junit的测试的边界条件非常清晰,同时有效的覆盖程度可以大大提升。
我认为基于规格的测试的核心是覆盖到规格所涉及到的所有情况。由于Junit是基于方法的测试,而对于一个方法的规格而言,大体就分为前置条件,副作用,后置条件。所以测试的核心就变成了构造一个规格中的不同前置条件,并在每一种情况下去验证后置条件是否满足。当然还包括一些类似于方法本身属性如pure等的检查。
事实上,如果Junit测试只要能覆盖规格所要求的所有情况,那么这个方法的正确性就可以得到保障。也就是说代码的实现和规格是一致的。
黑盒测试和白盒测试
黑盒测试关注于软件的功能而不是内部结构或实现。在黑盒测试中,软件被视为一个不透明的盒子,只关注输入数据和预期的输出结果,而不考虑程序的内部逻辑。黑盒测试的主要目的是验证软件是否满足功能需求,是否按照规格说明书正确工作。
白盒测试关注于软件的内部结构和逻辑。对于白盒测试,测试者需要了解程序的内部工作原理,包括代码、数据结构、算法等。白盒测试的主要目的是验证代码的正确性,确保所有的代码路径都被测试到,并且检查逻辑错误、性能问题等。
所以这两种测试实际上是有不同的作用,可以相互补充,协同使用。我认为,对于大部分软件而言,应该更注重黑盒测试,也就是说软件的最终目的是完成相应的功能,更加侧重于用户角度功能的实现。而对于小部分的关键软件而言,除了黑盒测试,还需要进行白盒测试,不仅要保证基本功能的正确性,更要从代码层次来检测是否有隐藏的错误,也就是说代码的实现是否是真正正确的而不只是功能的正确性。但同时必然带来的就是测试代价的增大,所以很多情况下是在成本和正确性之间的折中。
具体的一些测试类型
单元测试:
单元测试是对软件中最小可测试单元进行检查和验证的过程。一般情况下,这些单元是单个函数、方法或类。目的是确保每个单独的代码单元都能按照预期工作。一般使用白盒测试技术。使用单元测试有利于尽早发现代码中的错误,同时缩小错误出现的范围。
功能测试:
功能测试是根据软件的功能需求来验证软件的功能是否符合预期的测试过程。实际上,功能测试一般就是使用黑盒测试。
集成测试:
集成测试是在单元测试之后,将多个单元组合在一起并测试它们之间的交互的过程。目的是发现单元接口之间的错误。集成测试可以是自顶向下、自底向上或混合的方式进行。集成测试可以通过构造不同的场景,综合测试不同的方法,即方法间的协同,相比于单元测试,更有利于发现错误。
压力测试:
压力测试是测试软件在极端或异常条件下的性能和稳定性的过程。它通常涉及对系统施加超出正常工作负载的压力,以评估系统的响应和恢复能力。压力测试主要是测试系统的稳定性和可靠性,对于一些高负荷的软件也可以发现软件的性能瓶颈。
回归测试:
回归测试是在软件修改后重新执行以前的测试用例,以确保现有功能没有因为新的更改而受到影响的过程。对于bug修复来讲,很常见的情况是修好了一个bug又有其他的bug产生。所以对于修复后的代码进行回归测试是保证其他功能正确性不被破坏的重要方式。数据构造
数据构造应该结合具体的测试方式来讨论。对于一般的黑盒测试来讲,大部分数据构造可以采用自动化的方式来完成,通过合理地设置数据的范围,基本可以覆盖大部分的情况。而对于一些边界情况的测试,则还需要根据具体情况手动构造特殊情况的数据。
学习体会
首先,我感觉在经过这一单元的学习之后,让自己在写代码的规范化上上了一个台阶。通过规格的约束,让每一个方法,每一个类的功能的边界变得清晰,每个方法都只关注与自己所需要实现的功能。举个例子,以前经常会遇到在一个方法中是否要先判断对象是否为null,经常出现的情况是,调用者已经确保参数一定不为null,但被调用者又额外检查,或者调用者不保证参数不为null,但被调用者又没有检查的情况。对于这种问题,通过规格就可以很好的解决。总之,规格的学习提高了我编写代码的逻辑思维的缜密程度。
针对这一单元的任务本身,思维难度变得友好了很多,就是说比较容易上手,而前面几个单元的任务则是不知道要干什么。当然,其中仍然包括许多需要思考和优化的地方。总的来说这一个单元在代码编写的体验上来说优于前两个单元。