2021 OO 第三单元总结博客

2021 OO 第三单元总结博客

写在前面

JML单元总体是个比之前单元压力轻的单元,我只愿称之为不怕WA,就怕慢的单元。

在总结之前,先重温一下本单元的要求。我们需要实现一个社交关系模拟系统。可以通过各类输入指令来进行数据的增删查改等交互。

一 实现规格所采取的设计策略

面对JML规格,我采取的方式是先把代码中所有文件的规格通读一遍,并做一些简单的注释,将一些比较绕的规格翻译为自然语言,思考一下本类中想用的容器,再真正开始动手写代码。而写代码的时候,先实现异常类再实现正常情况的分支。

为了直观呈现设计过程,我们选取addMessage函数为例进行说明。首先在规格上补充一些中文注释,说明自己对JML的解读,把复杂的JML规格翻译为自然语言:

       /*@ public normal_behavior 这里是正常的行为
      @ requires !(\exists int i; 0 <= i && i < messages.length; messages[i].equals(message)) &&
      @           (message instanceof EmojiMessage) ==> containsEmojiId(((EmojiMessage) message).getEmojiId()) &&
      @            (message.getType() == 0) ==> (message.getPerson1() != message.getPerson2());
      @ assignable messages;
      @ ensures messages.length == \old(messages.length) + 1;
      @ ensures (\forall int i; 0 <= i && i < \old(messages.length);
      @          (\exists int j; 0 <= j && j < messages.length; messages[j] == (\old(messages[i]))));
      @ ensures (\exists int i; 0 <= i && i < messages.length; messages[i] == message);
      @ 这一大段描述的等价于把这个Message加入messages
      @ also
      @ public exceptional_behavior 从这里开始处理异常
      @ signals (EqualMessageIdException e) (\exists int i; 0 <= i && i < messages.length;
      @                                     messages[i].equals(message));
      @ 如果messages里已经有这个id了,就抛出MyEqualMessageIdException的异常,注意id是重的这个Message的id
      @ signals (EmojiIdNotFoundException e) !(\exists int i; 0 <= i && i < messages.length;
      @                                       messages[i].equals(message)) &&
      @                                       (message instanceof EmojiMessage) &&
      @                                       !containsEmojiId(((EmojiMessage) message).getEmojiId());
      @ 如果这个Message是EmojiMessage,但它的EmojiId没有在EmojiId的库里,就抛出MyEmojiIdNotFoundException,注意异常类的id是EmojiId
      @ signals (EqualPersonIdException e) !(\exists int i; 0 <= i && i < messages.length;
      @                                     messages[i].equals(message)) &&
      @                                     ((message instanceof EmojiMessage) ==>
      @                                     containsEmojiId(((EmojiMessage) message).getEmojiId())) &&
      @                                     message.getType() == 0 && message.getPerson1() == message.getPerson2();
      @ 如果这个Message的类型是0,但它包含的两个Person的id又是一样的,就抛出MyEqualPersonIdException,注意异常类的id是重复的这个Person的id
      @*/
    public void addMessage(Message message) throws
            EqualMessageIdException, EmojiIdNotFoundException, EqualPersonIdException;

在代码中将其实现时,我觉得先实现异常类,再实现正常逻辑类比较清晰:

@Override
public void addMessage(Message message) throws EqualMessageIdException,
EmojiIdNotFoundException, EqualPersonIdException {
    int messageId = message.getId();
    //先实现各个异常类
    if (this.messages.containsKey(messageId)) {
        throw new MyEqualMessageIdException(messageId);
    } else if (message instanceof EmojiMessage &&
               !containsEmojiId(((EmojiMessage) message).getEmojiId())) {
        throw new MyEmojiIdNotFoundException(((EmojiMessage) message).getEmojiId());
    } else if (message.getType() == 0 && message.getPerson1().equals(message.getPerson2())) {
        throw new MyEqualPersonIdException(message.getPerson1().getId());
    } else {
    //最后实现正常行为
        this.messages.put(messageId, message);
    }
}

二 基于JML规格的测试策略

我们可以采用白盒测试和黑盒测试两种测试策略。其中,白盒测试指根据逻辑验证代码,我们可以编写JUnit单元测试来验证自己的程序程序,课程组也为我们提供了相关教程;黑盒测试是靠跑大量数据来测试程序,主要可以依靠和同学们一起对拍实现。

2.1 白盒测试

测试时可以用到OpenJML、JMLUnitNG、JUnit4等工具。OpenJML可以对代码中的规格进行检测,这个工具链的环境配置相当磨人。JMLUnitNG可根据JML语言自动生成测试。JUnit是一个单元测试框架,继承TestCase类后可以进行自动测试。

使用JUnit时,相应的测试类需要我们自己编写。在每个@Test调用前会执行@Before,如:

@org.junit.Before
public void setUp() throws Exception {
    System.out.println("Test begin.");
}

每个@Test调用之后,会执行@After方法,如:

@org.junit.After
public void tearDown() throws Exception {
    System.out.println("Test end.");
}

我们可以编写自己的@Test函数对类进行测试:

@org.junit.Test
public void testIsCircle {
    MyPerson m0 = new MyPerson(121, "mjy", 90);
    MyPerson m1 = new MyPerson(132, "OO", 67);
    MyNetwork network = new MyNetwork();
    try {
        network.addPerson(m0);
    	network.addPerson(m1);
    } catch (EqualPersonIdException e) {
        e.print();
    }
    try {
        network.addRelation(121, 132, 9);
    } catch (PersonIdNotFoundException e) {
        e.print();
    } catch (EqualRelationException e) {
        e.print();
    }
    try {
        network.isCircle(121, 132);
    } catch (PersonIdNotFoundException e) {
        e.printStackTrace();
    }
    assertEquals(network.queryBlockSum(), 4);
}

利用JUnit工具可以编写出比较完备的测试数据,但这对思维逻辑的要求也很高,看起来和自己对着JML人工通读代码的成本类似。

2.2 黑盒测试

黑盒测试是基于数据的测试。这方面主要依靠与同学们一起对拍完成,在对拍的过程中的确发现了很多无脑bug和笔误bug,不禁令人感叹细心太重要了。

对拍时选择横向对比大家的输出,不一样的人就...(危)。测试时分为随机测试(正确性样例)、压力测试(主要针对可能会TLE的指令,代表为第九次作业中的isCirclequaryBlockValue;第十次作业中的quaryValueSumgetVar方法;第十一次作业的sendIndirectMessage方法等)、异常测试。真的不仅对出了笔误和粗心问题,还对出了TLE的问题......

我理解的黑盒测试中,我们主要依靠量变引起质变,进而提高测试的有效性。

三 容器的选择和使用

3.1 合理选择容器

合理选择容器意思是,不一定完全按照JML,也并不是有几个instance就该有几个容器,比如:

@ public instance model non_null int[] emojiIdList;
@ public instance model non_null int[] emojiHeatList;

而且后面deleteHotMessagr时要求保证emojiIdList、emojiHeatList畅度相同,出现这种规格时,我们可以直接用一个HashMap实现,即:

private final HashMap<Integer, Integer> emojiList;

3.2 尝试新容器

为在第三次作业中实现堆优化,本次作业是我第一次使用优先队列PriorityQueue。为了向容器传递我们排序的意愿,我新建了一个节点类,并重写了compareTo方法:

public class MyVector implements Comparable {
    private int personId;		//人员id
    private int dis;			//当前节点到初始节点的距离

    public MyVector(int id, int dis) {
        this.personId = id;
        this.dis = dis;
    }
    
    //重写compareTo方法
    @Override
    public int compareTo(Object o) {
        return this.dis - ((MyVector)o).getDis();	//对距离进行比较
    }
}

同时,也使用了一些优先队列自带的方法

  • isEmpty:判断当前队列是否为空
  • add():将元素放入队列(java已经将排序的实现都封装好了)
  • poll():返回队首元素,并将其在队列中删除

3.3 注意容器间方法的差异

这几次作业我都出现了性能问题,究其原因是对ArrayListHashMap的乱用,这次写遍历中都有contains,但时间复杂度却相差甚远。

  • HashMap.containsKey()是O(1)方法
  • HashMap.containsValue()是O(n)方法
  • ArrayList.contains()是O(n)方法

所以存放需要多次遍历的属性时,尽量让它成为HashMap的key值

四 性能问题分析

这一单元里,我们在写代码时要格外注意,不能允许任何一处出现O(n^2)及以上复杂度的方法,而这三次作业中的每一次都有需要注意的地方。

4.1 第九次作业

第九次作业的Network类中有两个方法需要我们格外注意,即isCirclequaryBlockSum。理论上dfs倒也不会超时,但交上前还是把我的憨批dfs改成了路径压缩并查集。

我们需要在MyNetwork类中新建一个属性,用以存放一个Person自己的id和与他连通的父亲的id:

private final HashMap<Integer, Integer> disjointset;

addPerson方法先令父亲的id和自己的id相同

this.disjointset.put(personId, personId);

addRelation方法中实现路径压缩,使同一个连通块中的Person的父节点是同一个id

int rootForId1 = this.getRoot(this.disjointset, id1);
int rootForId2 = this.getRoot(this.disjointset, id2);
this.disjointset.put(rootForId2, rootForId1);

其中,getRoot方法通过递归不断向上寻找。

public int getRoot(HashMap<Integer, Integer> disjointset, int id) {
    int parent = disjointset.get(id);
    if (parent != id) {
        int root = this.getRoot(disjointset, parent);
        disjointset.put(id, root);
        //不要把上两句省略为disjointset.put(id, this.getRoot(disjointset, parent));
    }
    return disjointset.get(id);
}

实现如上维护后,isCirclequeryBlockSum都会变得非常简单:

  • isCircle方法中仅需要比较传入的两个id的父类是否相等
  • queryBlockSum方法只需要搜索自己的父亲和自己的id完全一致的Person的个数

4.2 第十次作业

本次作业复杂度较高的地方可能为对group若干信息的查询,如valueSum、var等。我们采用在addPersonToGroup时对其O(1)或O(n)维护,而在真正的查询方法中直接O(1)查询。

首先在MyGroup类中新建需要查询的属性:

private int ageSum;			//记录年龄总和,除以size以得到方差
private int ageMean;		//年龄平均值
private int ageVar;			//年龄方差
private int valueSum;		//权值和

addPerson方法中对上述属性进行维护:

@Override
public void addPerson(Person person) {
    int personId = person.getId();
    int age = person.getAge();
    people.put(personId, person);
    int size = people.size();
    this.ageSum = this.ageSum + age;			//维护年龄和
    this.ageMean = this.ageSum / size;			//计算得到方差,无需考虑size为0的情况
    this.ageVar = 0;							//重新计算方差和权值
    for (Integer id : people.keySet()) {
        this.ageVar = this.ageVar + (people.get(id).getAge() - this.ageMean) *
            (people.get(id).getAge() - this.ageMean);
        if (people.get(id).isLinked(person)) {
            this.valueSum = this.valueSum + 2 * (people.get(id).queryValue(person));
        }
    }
    this.ageVar = this.ageVar / size;			//得到方差
}

delPerson中反向做同样的事情,在次就不多贴一次代码了。

值得注意的是,添加关系也可能影响组里valueSum的值,因此需要在addRelation方法中对groups进行遍历:

for (Group group : groups.values()) {
    ((MyGroup) group).updateValueSum(person1, person2, value);
}

并在MyGroup中添加updateValueSum方法:

public void updateValueSum(Person person1, Person person2, Integer value) {
    if (people.containsKey(person1.getId()) && people.containsKey(person2.getId())) {
    	this.valueSum = this.valueSum + 2 * value;
    }
}

其实,计算方差还有复杂度更低的方法,就是再维护一个age2和的属性,利用方差与期望的关系公式:方差等于平方的期望减期望的平方。但存在一个问题,就是维护age2和时会爆int。但实际上,计算机的加减乘除都是通过二进制得到的,似乎原则上只要结果没有超过int的范围,在计算过程中爆int也是不会出错的(但我胆小还是牺牲一丢性能没有维护age2和,而是每次都重算方差)

4.3 第十一次作业

这次作业中sendIndirectMessage需要返回两个Person的最短路径,可使用Djsktra的堆优化算法将复杂度从O(n2)降低至O(nlogn),堆优化主要依靠优先队列实现,在博客的3.2部分已经进行了分析。

五 作业架构

感觉几次作业的架构大同小异,课程组已经给了非常完备的设计,我们只需要补充上一点细节,而补上的细节基本也对应着第四节性能分析的部分,因此这部分会说得比较简略。

第九次作业

主要依靠并查集路径压缩,实现isCirclequeryValueSum方法

第十次作业

主要使用前期维护,直接查找的形式

  • addPersonToGroupdelPersonFromGroup时维护ageSum、ageMean和ageVar属性
  • addPersonToGroupdelPersonFromGroupaddRelation时维护valueSum属性
第十一次作业

新建节点类MyVector,利用PriorityQueue实现Dijkstra的堆优化,查找最短路径,降低程序复杂度。

写在最后

没有性能分令人非常开心。这一单元最治疗我低血压的事突然变成了和同学对拍,bug突然就冒出来了啊hhh(衷心衷心感谢各位一起对拍的hxd!)。总体感觉,这一单元需要人非常细心,非常踏实,不然容易发生一失足成千古恨的事情。一句一句地阅读规格之后再把他们变成自己的代码,也真的会让人的心平静下来。

posted @ 2021-05-31 12:58  Yu_ji  阅读(80)  评论(0)    收藏  举报