北航OO第三单元总结

一. 架构设计

本单元学习的是JML与测试, 我们要写的代码是模拟社交关系, 由于JML基本已经把基本架构显示出来了, 所以我的架构设计并没有进行大的修改, 只是在此基础上为了性能优化及其实现, 增添了一些类和方法. 下面我准备从功能上阐述我的架构.

1. 图的构建

其实JML已经写出了图的大致架构, 我的代码实现了这种架构.
图大致采用邻接表的数据结构存储, MyNetwork类用来存储"链表头", 用一个容器HashMap来存储链表头, MyPerson类存储"链表", 用一个容器HashMap来存储链表, 至于为什么要用HashMap来存储, 是因为代码中出现了很多根据id查询的操作, 用HashMap可以大幅度优化

2. 计数器实现

本单元代码引入了异常, 异常需要记录每个id发生异常的次数, 这里新建一个类Count来管理计数是一个比较好的选择. 由于这里也需要大量通过id查询次数的操作, 所以要用HashMap来存储计数, 当某个id发生异常时, 对应修改HashMap即可

3. 计算类方法算法实现——规格与实现分离

本单元是一个比较追求性能的单元, 对于一些计算类方法, 如果严格按照JML所写, 时间复杂度较高, 很有可能超时. 规格只是说明了方法的要求和功能, 对于具体的实现并未约束, 于是我们可以规格与实现分离, 我们设计复杂度更低的算法来实现.

算法复杂度约束

由于指令条数有限(10000条), 时间限制在10s内, 如果是O( n 2 n^2 n2)的复杂度, 肯定会超时, 如果是O(nlogn)有点危险, 如果是O(n), 问题不大. 所以我们力求找到O(n)或是更快的算法. 还有两个可能用到的算法思想: 动态维护和脏位

isCircle 和 blockSum
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])));
/*@ ensures \result ==
     @         (\sum int i; 0 <= i && i < persons.length &&
     @         (\forall int j; 0 <= j && j < i; !isCircle(persons[i].getId(), persons[j].getId()));
     @         1);
     @*/

这里需要计算出两点是否联通以及连通块的数目, 这里有一个十分优秀的算法, 能动态维护连通块, 那就是并查集. 并查集算法本身不在这里赘述了, 这里说一说具体的实现

  1. 加点 将该点的根节点设为自己, 连通块数目加一
  2. 加边 设在v1, v2加了一条边e, 则将v1的根节点的根节点设为v2的根节点, 如果两点原本不在同一连通块(即两点根节点不相等), 则连通块数目减一
  3. 删边 设在v1, v2删了一条边e, 则用bfs算法遍历v1, 将遍历到的所有点的根节点设为v1, 若未遍历到v2, 说明v1和v2不联通, 连通块数目加一, 并用bfs算法遍历v2, 将遍历到的所有节点的根节点设为v2. 这里特别说明, 最好不要用dfs遍历, 实测强测是可能爆栈的, 虽然本次作业强测并未专门设置强测点hack.

并查集find操作用来查询根节点, 这里实现了路径压缩, 可以将isCircle操作优化到近乎O(1), 并未实现按秩合并, 主要是优化效果不明显还容易出bug, 不易扩展. blockSum由于我们动态维护了, 查询操作为O(1)复杂度
从架构设计角度, 最好新建一个类来管理并查集, 本次我架构失误, 将并查集融入到MyNetwork类中, 导致代码非常混乱

tripleSum
/*@ ensures \result ==
      @         (\sum int i; 0 <= i && i < persons.length;
      @             (\sum int j; i < j && j < persons.length;
      @                 (\sum int k; j < k && k < persons.length
      @                     && getPerson(persons[i].getId()).isLinked(getPerson(persons[j].getId()))
      @                     && getPerson(persons[j].getId()).isLinked(getPerson(persons[k].getId()))
      @                     && getPerson(persons[k].getId()).isLinked(getPerson(persons[i].getId()));
      @                     1)));
      @*/

这里我们要统计图中三元环的数量, 如果按照JML规格来写, 求三元环的复杂度是O( n 3 n^3 n3), 复杂度非常高. 实际上有在O( n n n\sqrt n nn )复杂度求完三元环算法, 不过根据前面的复杂度分析, 这种算法也是非常危险的算法. 于是, 我们选择动态维护算法.
动态维护只需要在加边和删边时进行, 遍历边的两个节点的公共节点, 公共节点的数目即是三元环数目 的改变量, 复杂度O(n), 可以接受. 由于动态维护, 查询操作时间复杂度为O(1).

tagValueSum
/*@ ensures \result == (\sum int i; 0 <= i && i < persons.length; 
      @          (\sum int j; 0 <= j && j < persons.length && 
      @           persons[i].isLinked(persons[j]); persons[i].queryValue(persons[j])));
      @*/

这里应该是决定你代码速度快慢的最关键的因素, 我在本次作业采用了比较巧妙但十分复杂的办法处理这个问题. 为了引入我的算法, 我先说明一般的算法.
由于按照JML的算法是O( n 2 n^2 n2)复杂度, 不可取, 于是我们很自然地想到动态维护算法
那什么时候需要动态维护呢? 有以下5种可能的情况(之后称这5种操作为"修改", 其信息称为"修改信息"):

  1. 往tag里加人
  2. 删除tag里的人
  3. 加边
  4. 修改边
  5. 删边

然后我们要找到会改变tagValueSum的tag, 进行第一次遍历, 然后为了判断Tag的tagValueSum是否会改变, 需要再遍历一次Tag中的所有人, 维护总体复杂度是O( n 2 n^2 n2), 看起来是非常危险的算法, 但实测是可以过的, 主要原因是在10000条指令的限制下, n比较小.
现在我们思考动态维护的优化.
复杂度较高主要是因为两次遍历造成的, 优化应该从遍历的数量去考虑.

  1. 利用HashMap, 需要类似HashMap<Integer, HashMap<Integer, HashSet< T a g Tag Tag>>>的数据结构, 这种方法并不能将复杂度降到O(n), 反倒增加了空间复杂度, 不是好算法
  2. 我们想想可不可以减少遍历次数, 遍历Tag是不是必须的?

这里便开始阐述我的算法了. 遍历Tag不是必须的, 因为不是所有的Tag都要进行tagValueSum的查询. 这里我们借用脏位的想法, 即延迟修改. 只要出现任何5种修改操作(不管与某个Tag是否有关), 都给所有Tag设置"脏位". 查询时, 如果脏位存在, 则需要进一步计算, 否则直接返回上一次计算结果.

这里出现了该算法最难理解的地方, 就是脏位存在如何进一步计算.

(1)修改信息

既然要进一步计算, 我们一定需要之前的修改信息.(这里插入一个比较重要的事情, 只用脏位而不动态维护也是不可取的, 因为可以构造修改一次查询一次的样例, 查询复杂度O( n 2 n^2 n2), 这样依旧可能TLE, 我们这里采用的相当于是动态维护加脏位的算法, 我们保存了上一次计算的结果, 当我们在下一次计算时就需要中间时刻的修改信息). 而且, 我们需要的上一次计算到这一次计算的所有修改信息, 上一次计算之前的修改信息不需要. 修改信息是需要某个类或某个容器保存的, 我采用的方法是新建Change类 来保存加边\改边\删边的信息, 这三个操作本质上都只是将两个人之间的关系修改某个value值, 可以视为一种操作.因此Change类可以这样构建

	private Person person1;
    private Person person2;
    private int change;

我们新建Changes类, 里面有ArrayList< C h a n g e Change Change> changes容器, 用于存储所有的Change
至于加人和删人, 我们可以在Tag里的特定HashSet< P e r s o n Person Person>容器保存, 分别记为apMap和dpMap, 具体作用之后再说

(2)修改的处理

当出现一个修改操作时, 为了方便, 我们要遵循一个不变式.

// in Tag.java
public int getSize() {
	return apMap.size() + personHashMap.size();	//personHashMap存的是上一次计算时Tag里存在的人
}
public boolean hasPerson(Person person) {
    int fid = person.getId();
    return personHashMap.containsKey(fid) || apMap.contains(person);
}

为了遵循这个不变式, 我们往Tag里加人时, 我们加进apMap当中, 往Tag里删人时, 我们看apMap是否存在该人, 若存在则删除apMap中对应的人, 但不将该人加入dpMap中. 否则删除personHashMap中的人, 并将该人加入到dpMap中.
这样我们保证了Tag中人数的不丢失, 以及加人和删人信息的不丢失, 非常巧妙
其他三种修改照样修改, 但需将对应的修改信息存入之前说的changes容器

(3)脏位的处理

这也是该算法的巧妙之处
我们每次计算都要从changes拿修改信息, 我们可以记录此时changes数组的末尾索引, 这样我们就可以知道下次计算该从哪个修改信息开始计算, 如果计算时发现上一次记录的索引就是changes数组末尾, 说明两次计算之间未修改, 直接返回上一次计算结果. 否则两次计算之间可能发生了修改, 处理所有修改信息, 更新索引. 这种方法巧妙的实现了脏位

(4)修改的计算

在详细阐述修改的计算之前, 我们先搞清楚一件事: 在计算时, 对边的修改已经做完了, 加人和删人的操作也已经做完了, 我们只是还没处理valueSum的计算问题. 理解这个之后, 来思考这样的一个问题, 如何通过changes和apMap与dpMap的信息, 算出新的valueSum
想很清楚的讲完算法的原理和正确性是很难的, 为了减少阐述难度, 我直接给出算法实现, 然后抛出几个问题, 供大家思考
大体的处理顺序为: 先处理changes, 再处理dpMap, 最后处理apMap

  1. changes的处理: 只有change中的两人均在personHashMap或dpMap中, 才将valueSum += 2 * change
  2. dpMap的处理: 迭代dpMap中的每个人, 将该人与所有在personHashMap或dpMap中的人的所有边权的2倍减去, 将该人从dpMap中去除, 这步操作是O( n 2 n^2 n2)的, 看似这个算法会比较慢了, 实则不然, 由于指令的限制, 这里的n很小, 还有删边这个操作是所有算法共有的
  3. apMap的处理: 迭代apMap中的每个人, 将该人与所有在personHashMap中的人的所有边权的2倍加上,再将该人加入到personhashMap中, 迭代完成后将apMap清空

再思考这个算法的正确性时, 原则是不重不漏. 下面几个问题有助于大家思考这个算法.

  1. 为什么在changes处理时, 人必须在personHashMap或dpMap中, 而不是personHashMap或apMap
  2. 能不能更换三次处理的顺序
  3. 在dpMap处理时, 为什么要一个人一个人删去, 而不是所有处理完成后统一清空dpMap
  4. 在apMap处理时, 为什么要一个人一个人加上, 而不是所有处理完成后统一加上

注: 这个算法是笔者想出的十分抽象但是比较快省空间(经过几个代码对比后得出的结论)的算法, 如果下届的作业有类似的需求, 建议大家写一般算法就好, 写这个算法要理解要debug

tag_age_var

它的计算需要用到tag_age_mean

/*@ ensures \result == (persons.length == 0? 0:
      @          ((\sum int i; 0 <= i && i < persons.length; persons[i].getAge()) / persons.length));
      @*/
    public /*@ pure @*/ int getAgeMean();

    /*@ ensures \result == (persons.length == 0? 0 : ((\sum int i; 0 <= i && i < persons.length; 
      @          (persons[i].getAge() - getAgeMean()) * (persons[i].getAge() - getAgeMean())) / 
      @           persons.length));
      @*/
    public /*@ pure @*/ int getAgeVar();

其实JML的算法复杂度O(n)应该也不会超时, 但是它们的动态维护比较简单, 所以建议还是动态维护.
a g e _ m e a n = ( ∑ a g e ) / l e n g t h a g e _ v a r = ( ∑ a g e 2 − 2 ∗ a g e _ m e a n ∗ ∑ a g e + l e n g t h ∗ ( a g e _ m e a n ) 2 ) / l e n g t h age\_mean = (\sum age) / length\\ age\_var = (\sum age^2 - 2*age\_mean*\sum age+length*(age\_mean)^2)/length age_mean=(age)/lengthage_var=(age22age_meanage+length(age_mean)2)/length
所以动态维护年龄和以及年龄平方和即可.
有个易错点, 由于除法取整问题, 不能用 D ( X ) = E ( X 2 ) − ( E ( X ) ) 2 D(X)=E(X^2)-(E(X))^2 D(X)=E(X2)(E(X))2简化方差计算

best_acquaintance && coupleSum
ensures \result == (\min int bestId;
    @         (\exists int i; 0 <= i && i < getPerson(id).acquaintance.length &&
    @             getPerson(id).acquaintance[i].getId() == bestId;
    @             (\forall int j; 0 <= j && j < getPerson(id).acquaintance.length;
    @                 getPerson(id).value[j] <= getPerson(id).value[i]));
    @         bestId);
    
    /*@ ensures \result ==
    @         (\sum int i, j; 0 <= i && i < j && j < persons.length
    @                         && persons[i].acquaintance.length > 0 && queryBestAcquaintance(persons[i].getId()) == persons[j].getId()
    @                         && persons[j].acquaintance.length > 0 && queryBestAcquaintance(persons[j].getId()) == persons[i].getId();
    @                         1);
    @*/

对于best_acquaintance, 我们可以使用堆来维护, 在Java用TreeSet. 注意自定义比较器的使用, 不用return o1.getId()-o2.getId();这样的程序, 有可能溢出
对于coupleSum, 我们进行动态维护, 可以证明只有对边的修改会更改coupleSum, 且只会影响跟修改边的两个节点有关的couple数目, 所以我们可以在修改前令coupleSum减去两个节点的couple数, 修改后再加上两个节点的couple数, 这样就完成了动态维护

shortestPath

由于是无权图的最小路径查询, 直接使用bfs即可. 这里值得注意的是一个小小的优化, 就是可以保存之前计算过的最短距离, 因为bfs时算出了很多节点据起点的最短距离, 可以再利用, 这里用脏位算法确定保存的数据是否还有效, 只要发生了对边的修改就设置脏位

容器选择

对于需要大量增删改查的, 使用HashMap
需要保留顺序的, 还需要大量插入删除的, 用LinkedList

4. 架构视图

这里插入一句, 由于一开始的架构失误, 导致我MyNetwork的代码超过了500行, 解决方案是把一些方法挪到另外一个工具类Tool类里面
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

二. 测试

测试是本单元的重要内容之一, 良好的测试能提前测出一些bug

黑箱测试 && 白箱测试

黑箱测试和白箱测试是软件测试中的两种重要方法,它们分别针对软件内部结构和设计的不同视角进行测试,以评估其功能和性能。

黑箱测试(Black Box Testing):

概念:黑箱测试也称为功能测试或数据驱动测试,主要关注的是软件的外部表现,不考虑其内部结构。测试者只关心输入和输出,不关心程序的内部逻辑。
方法:这种测试通常基于需求规格说明书,通过向系统提供各种输入,观察和记录输出,检查是否符合预期。它假设系统内部是不可见的,测试者关注的是系统的功能行为。
优点:能够发现功能错误,独立于实现,适用于需求不清晰或难以理解的系统。
缺点:可能无法发现内部逻辑错误,对设计的理解要求较低。
白箱测试(White Box Testing):

概念:白箱测试,也称为结构测试或逻辑驱动测试,是对软件内部结构有深入理解的测试。测试者不仅检查输入和输出,还检查程序的内部逻辑和控制流程。
方法:测试者需要了解程序的结构,包括程序流程图、伪代码或源代码。通过检查程序的逻辑,验证每个步骤是否正确,以及数据路径是否覆盖了所有可能情况。
优点:能够发现内部逻辑错误,有助于保证设计的正确性,对软件质量有较高要求。
缺点:需要对软件有深入理解,对测试人员的技能要求较高,且可能需要源代码,这在实际开发中可能受限。
在实际的软件开发和测试过程中,通常会结合使用这两种方法,以达到全面的测试效果。黑箱测试用于验证软件功能,而白箱测试则用于验证软件的逻辑正确性和内部结构。

单元测试、功能测试、集成测试、压力测试、回归测试

单元测试、功能测试、集成测试、压力测试和回归测试是软件开发过程中常见的几种测试类型,它们各自关注软件的不同方面,确保软件质量:

单元测试(Unit Testing):

概念:单元测试是对软件中的最小可测试单元(如函数、方法或类)进行独立验证。目标是确保每个单元的代码逻辑正确无误。
方法:通常使用自动化工具,针对每个单元编写测试用例,验证其功能和行为。
优点:有助于发现代码错误,提高代码质量,降低集成测试的复杂性。
缺点:需要编写大量测试代码,对测试人员的技能要求较高。

功能测试(Functional Testing):

概念:功能测试关注软件的各个功能是否按照需求规格说明书或用户手册正确实现。它验证软件是否能完成预期的任务。
方法:测试人员模拟用户操作,检查软件的响应和结果。
优点:确保软件功能的完整性,与用户需求直接相关。
缺点:可能需要覆盖大量的功能点,测试工作量大。

集成测试(Integration Testing):

概念:集成测试是在单元测试通过后,将各个模块或组件组合在一起进行测试,检查它们之间的交互是否正确。
方法:通常在开发环境中进行,验证模块间的接口和数据流。
优点:发现系统级错误,确保模块间的协同工作。
缺点:需要设计和执行复杂的测试场景,对测试设计和执行能力要求较高。

压力测试(Stress Testing):

概念:压力测试是为了评估软件在极端条件或超出正常负载下的性能和稳定性。
方法:通过模拟大量用户或数据,测试软件的响应时间、资源消耗等。
优点:发现系统在高负载下的问题,确保系统稳定性。
缺点:可能需要专门的硬件和资源,且结果可能难以量化。

回归测试(Regression Testing):

概念:回归测试是在修改或添加新功能后,重新运行所有已通过的测试,以确保修改没有破坏已有的功能。
方法:对比修改前后的测试结果,确保修改没有引入新的错误。
优点:保护已实现的功能,减少回归错误。
缺点:需要维护测试历史,确保有足够的测试用例覆盖所有变更。
这些测试类型通常在软件开发的不同阶段交织进行,以确保软件的质量和稳定性。

数据构造与JUnit测试

本单元需要构造JUnit测试来测试queryTripleSum, queryCoupleSum, deleteColdEmoji方法
首先为了测出代码的bug, 我们需要全面的数据构造. 这里我采用了JUnit参数化测试.参数化测试大体结构如下图所示, 参数化测试能控制测试组数, 随机化样例构造, 十分方便

@RunWith(Parameterized.class)
public class PrimeNumberCheckerTest {
    private Boolean expectedResult;
    private Integer inputNumber;
    private PrimeNumberChecker primeNumberChecker;

    @Before
    public void initialize() {
        primeNumberChecker = new PrimeNumberChecker();
    }

    public PrimeNumberCheckerTest(Integer inputNumber, Boolean expectedResult) {
        this.inputNumber = inputNumber;
        this.expectedResult = expectedResult;
    }

    @Parameters
    public static Collection primeNumbers() {
        return Arrays.asList(new Object[][] { { 2, true }, { 6, false }, { 19, true }, { 22, false }, { 23, true } });
    }

    @Test
    public void testPrimeNumberChecker() {
        System.out.println("Parameterized Number is : " + inputNumber);
        assertEquals(expectedResult, primeNumberChecker.validate(inputNumber));
    }
}

首先为了进行参数化测试, 需要在测试类开头加入@RunWith(Parameterized.class)
每组测试样例之前可能进行初始化操作, 在初始化方法前加上@Before
我们需要为每一组数据构造样例, 在构造样例方法前加上@Parameters, 方法开头必须是public static Collection的, 我们将我们的样例存在Object[][]数组里面, 第一维是测试编号, 第二维是参数列表(即测试类的成员变量实例), 然后将数组转化为Collection
在测试方法前加入@Test, 测试用断言测试

本段元的数据构造与JUnit测试

我们知道满足JML规格信息的方法一定是正确的, JML提供了很多布尔条件, 这正好能与JUnit测试相配合, 只要我们用JUnit测试断言出这些条件为真, 那么代码就是正确的.
我们现在来说一说样例的构造, 即Network的构造.
由于我们要判断出来pure条件是否为真, 所以我们应当拷贝出两个一样的Network, 一个Network调用方法, 然后比对两Network对应pure的部分, 这里的拷贝可以不直接深拷贝, 只要我们构造出两个Network, 然后进行同样的操作即可. 对应的Person和Tag同理.
接下来我们采用随机策略进行构造, 由于很多操作是类似的, 这里我只说明"人"层面上的, 即加人, 加边, 改边, 删边操作
首先随机出人数, 然后构造两组等同数量的人(调用参数一样的构造方法即可), 加到两Network中
然后对于每个可能的边, 随机判断要不要加边
如果要改边的话, 只要在遍历每个边, 随机判断要不要修改即可

保证样例的全面性

第二次作业时, 可能要考虑稀疏图和稠密图的事情, 这里只需调加边的随机参数即可
第三次作业时, 要往messages加入所有种类的Message

三. 单元体会与总结

本单元真的锻炼了我做测试的能力, 学习了参数化测试这一重要技能. 我也了解了JML等规格语言, 知道了已知规格该如何写代码, 如何做测试. 本单元还让我温习了很多算法内容, 尤其是图论方面的.

  • 12
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值