北航2021年面向对象第三单元作业总结:JML与图 buaa oo unit3

北航2021年面向对象第三单元作业总结:JML与图 buaa oo unit3

1. 本单元介绍

本单元要求实现一个社交关系模拟系统。可以通过各类输入指令来进行数据的增删查改等交互。

本单元要求:

基于规格的层次化设计
• 理解规格的概念
• 掌握方法的规格及其设计方法
• 掌握类的规格及其设计方法
• 掌握抽象层次下类规格之间的关系
• 掌握基于规格的测试方法

本单元自我评价:

  • 习得阅读JML规格并正确理解翻译为Java代码的能力
  • 入门借助JML规格进行单元测试,了解TDD思想
  • 入门编写简单JML规格
  • 掌握图的DFS遍历、连通分量计算、单源最短路径的算法实现,关注各种代码实现的性能,特别是时间复杂度
  • 进一步深入了解Java的HashMapPriorityQueue的源码

2. 我对JML的理解

规格:规格是使用规范语言表示方法、类外部可感知行为的一种东西,它的提出是为了保证代码质量--通过(1)把设计与实现相分离 (2)准确定义一个方法的行为,减少语言定义的二义性(3)以逻辑方式(与或非等的表达形式)来验证代码实现的正确性

JML是一种形式化语言(Java Modeling Language),用来表示规格,具体定义为面向JAVA的行为接口规格语言

可以通过阅读《JML Level 0手册》学习JML。

这样子的说明很抽象,举一个社交网络的例子:

需求文档 vs JML表达

  • 需求文档:要求实现一个方法addRelation(int id1, int id2, int value),给两个人增加联系,这个联系有一个value的属性。要求输入两个人的id与联系的value值,如果人的id不存在,则抛出personNotFoundException的异常;如果这两个人已经存在联系,则抛出EqualRelationException的异常。

  • JML表达:使用JML表达addRelation(int id1, int id2, int value),在逻辑上是非常严谨的,考虑了方法执行后的所有变化与不变,补充了文字表达里省略的东西,比如:添加联系后,person1, person2的其余联系会发生变化吗?

  • 有什么好处:开发人员可以准确(无二义地)理解addRelation的需求,保证代码质量;测试人员在开发人员完成代码前,就清楚((无二义地理解)该方法需要实现的功能,可以提前设计测试用例,准备测试,颇有一种TDD的思想(在测试部分会详细记录我对TDD及基于JML测试的思考)

测试用例:
TestCase1:
ap 0 p0 10
ap 1 p1 10
ar 0 1 5
预期:
OK
OK
OK
且ap, ar生效

TestCase2:
ar 1 1 5
预期:抛出personNotFoundException的异常

TestCase3:
ap 0 p0 10
ap 1 p1 10
ar 0 1 5
ar 0 1 6
预期:前3行命令生效,最后抛出EqualRelationException的异常
// 翻译JML后的代码实现
public void addRelation(int id1, int id2, int value)
            throws MyPersonIdNotFoundException, MyEqualRelationException {
        if (!contains(id1)) {
            throw new MyPersonIdNotFoundException(id1);
        } else if (!contains(id2)) {
            throw new MyPersonIdNotFoundException(id2);
        } else if (getPerson(id1).isLinked(getPerson(id2))) {
            throw new MyEqualRelationException(id1, id2);
        } else {
            MyPerson person1 = people.get(id1);
            MyPerson person2 = people.get(id2);
            person1.addRelation(id2, person2, value);
            person2.addRelation(id1, person1, value);
            people.put(id1, person1);
            people.put(id2, person2);
        }
    }
//JML下的addRelation方法规格
	/*@ public normal_behavior
      @ requires contains(id1) && contains(id2) && !getPerson(id1).isLinked(getPerson(id2));
      @ assignable people;
      @ ensures people.length == \old(people.length);
      @ ensures (\forall int i; 0 <= i && i < \old(people.length);
      @          (\exists int j; 0 <= j && j < people.length; people[j] == \old(people[i])));
      @ ensures (\forall int i; 0 <= i && i < people.length && \old(people[i].getId()) != id1 &&
      @     \old(people[i].getId()) != id2; \not_assigned(people[i]));
      @ ensures getPerson(id1).isLinked(getPerson(id2)) && getPerson(id2).isLinked(getPerson(id1));
      @ ensures getPerson(id1).queryValue(getPerson(id2)) == value;
      @ ensures getPerson(id2).queryValue(getPerson(id1)) == value;
      @ ensures (\forall int i; 0 <= i && i < \old(getPerson(id1).acquaintance.length);
      @         \old(getPerson(id1).acquaintance[i]) == getPerson(id1).acquaintance[i] &&
      @          \old(getPerson(id1).value[i]) == getPerson(id1).value[i]);
      @ ensures (\forall int i; 0 <= i && i < \old(getPerson(id2).acquaintance.length);
      @         \old(getPerson(id2).acquaintance[i]) == getPerson(id2).acquaintance[i] &&
      @          \old(getPerson(id2).value[i]) == getPerson(id2).value[i]);
      @ ensures getPerson(id1).value.length == getPerson(id1).acquaintance.length;
      @ ensures getPerson(id2).value.length == getPerson(id2).acquaintance.length;
      @ ensures \old(getPerson(id1).value.length) == getPerson(id1).acquaintance.length - 1;
      @ ensures \old(getPerson(id2).value.length) == getPerson(id2).acquaintance.length - 1;
      @ also
      @ public exceptional_behavior
      @ assignable \nothing;
      @ requires !contains(id1) || !contains(id2) || getPerson(id1).isLinked(getPerson(id2));
      @ signals (PersonIdNotFoundException e) !contains(id1);
      @ signals (PersonIdNotFoundException e) contains(id1) && !contains(id2);
      @ signals (EqualRelationException e) contains(id1) && contains(id2) &&
      @         getPerson(id1).isLinked(getPerson(id2));
      @*/
    public void addRelation(int id1, int id2, int value) throws
            PersonIdNotFoundException, EqualRelationException;

3. 单元设计策略与架构

本节回答博客作业要求的(1)(5)

(1)总结分析自己实现规格所采取的设计策略
(5)梳理自己的作业架构设计,特别是图模型构建与维护策略

3.1 图模型构建与维护策略

3.1.1 图论基础

本单元的图(社交网络),有以下几个特点:

  • 无向图
  • 图的变动有以下规律:
    • 允许往图里加入点和边;
    • 不允许删除点和边
    • 不允许改变边的权值

3.1.2 设计什么类

主要的类为MyPerson, MyGroup,MyNetwork,对应图论的点Node, 图Graph, 子图Subgraph的概念,根据JML规格而设计的

衍生的概念有:

relation 无向边
两人之间send indirect message,需要找到最短路 单源最短路问题
两人isLinked() 点与点直接相连
两人isCircle() 点与点之间的连通性
block 连通分量

辅助(算法实现)的类有Components,DijkstraSP, TwoNodeSP,用以实现判断点连通、计算连通分量数及计算两点间的最短路距离的功能,这是为了更高效地实现业务功能而设计的类

剩余的EmojiMyMessage类是为了实现社交网络的业务功能--发送信息等而设的

3.1.3 图的存储与维护

  • 存储

图的存储使用的是经典的邻接表结构

选择HashMap来存储点的边集,是为了查找与添加时O(1)复杂度的方便

public class MyPerson {
	private int id;
	private HashMap<Integer, MyPerson> acquaintance; // 熟人集合 <id, Myperson>
    private HashMap<Integer, Integer> value; //边权集合 <id, value>
}

public class MyNetwork {
    private HashMap<Integer, MyPerson> people;
}
  • 维护

    概括起来,业务上的各种指令操作都是对图结构的增删查改

    MyPerson

    设计要点:利用HashMap结构存储点的邻接链表(查找和增加都是O(1)), 利用LinkedList存储查收消息(为了满足接收时间由新到旧,消息从头到尾的存储要求)

    作业分析:

    private int id; //独一无二的 id,可以作为key
    private String name; //姓名
    private int age; //年龄
    private int socialValue;
    private HashMap<Integer, MyPerson> acquaintance; // 熟人集合 <id, Myperson>
    private HashMap<Integer, Integer> value; //边权集合 <id, value>
    private LinkedList<Message> messagesQueue; //sendMessage to me, then my addFirst
    
    • 有什么功能:

      常规的查找:getId(), getName(), getAge(), getAcquaintance(), equals(Object obj) ,int compareTo(Person p2), List<Message> getMessages(), getSocialValue()名字排序

      对类内的主要数据结构HashMap<Integer, MyPerson> acquaintance, HashMap<Integer, Integer> value, LinkedList<Message> messagesQueue的增删查改的维护,功能是熟人管理与收发消息管理

    • 增:void addRelation(int id, MyPerson person, int edgeValue)(不属于接口方法)

    • 删 -- 不存在删除relation,删除熟人(断交),撤销message的操作

    • boolean isLinked(Person person), int queryValue(Person person)查熟人relation的边权 ,List<Message> getReceivedMessages()

    • void addSocialValue(int num) , void addMessage(Message newMessage), void addMoney(int num)

    MyNetwork

    设计要点:qbs, sim, ic, qgvs等方法

    作业分析:

    描述整个社交网络的情况,1个程序只会有构造一个Network

    指令直接调用的是MyNetwork的方法。

    private HashMap<Integer, MyPerson> people;
    private HashMap<Integer, MyGroup> groups;
    private HashMap<Integer, MyMessage> messages; //<messageId, myMessage>
    private HashMap<Integer, Emoji> emojiMap; //<emojiId, Emoji>
    private static int componentChangeCounter; //缓存以提升性能
    private static Components components; //缓存以提升性能
    private static int dijkChangeCounter; //缓存以提升性能
    private HashMap<Integer, DijkstraSP> dijkstraMap; //<sourceId, dijkstra> //缓存以提升性能
    private static HashMap<NodePair, TwoNodeSP> shortestPathDisMap; //缓存以提升性能
    

    有什么功能:

    • 对people的增删查改

      • 增:void addPerson(Person person), void addRelation(int id1, int id2, int value)
      • 删:不可删除
      • 查:contains()getPerson(), int queryValue(), int compareName(), int queryPeopleSum(), int queryNameRank(), boolean isCircle(), int queryBlockSum(), querySocialValue(), queryMoney()
      • 改:在别的业务中可能会修改people's socialValue和 money
    • 对group的增删查改

      • 增:void addGroup(Group group),void addToGroup(int id1, int id2),
      • 删:void delFromGroup(int id1, int id2)
      • 查:Group getGroup(int id), int queryGroupSum(), int queryGroupPeopleSum(int id), int queryGroupValueSum(int id), int queryGroupAgeMean(int id)int queryGroupAgeVar(int id)
      • 改:没有
    • 对message的增删查改

      • 增:void addMessage(Message message)
      • 删:不存在直接删除的指令,但sim of sm时都会remove corresponding message
      • 查:boolean containsMessage(), Message getMessage()List<Message> queryReceivedMessages()
      • 改:void sendMessage(int id),void sendIndirectMessage(int id)
    • 对Emoji的管理

      • 增:void storeEmojiId()
      • 删:int deleteColdEmoji()
      • 查:boolean containsEmojiId(), int queryPopularity()
      • 改:emoji的热度值会在send corresponding emoji Message时变化,不可直接修改热度值

    MyGroup

    private int id;
    private HashMap<Integer, MyPerson> people; // <id, person>
    

    设计要点:子图

    重点为子图特征的高效计算,如qgvs

    • 有什么功能:

    常规的:getId(), getPeople(), boolean equals(Object obj)

    对类内的主要数据结构HashMap<Integer, MyPerson> people

    • addPerson(Person person)

    • void delPerson(Person person)void delPersonWithId(int personId)(不属于接口方法)

    • boolean hasPerson(Person person) int getValueSum() int getAgeMean() int getAgeVar()int getSize()

    • void addSocialValueToAllPeople(int messageSocialValue)(不属于接口方法)

    MyMessage

    private int id; // unique
    private int socialValue; //消息的社交值
    private int type; //消息的种类,有 0 和 1 两个取值
    private MyPerson person1; //sender
    private MyPerson person2; //receiver
    private MyGroup group; //消息的接收组
    

    无设计要点 -- 业务功能类

    有什么功能:

    常规的查找:getType(), getId(), getSocialValue() , getPerson1() , getPerson2(), getGroup(), equals(Object obj)

3.1.4 我的自学材料

我这个半桶子CS学生的自学材料

图论概念:点、边、图、树、连通等等(看了看B站的离散数学视频)

Princeton University经典算法课 Algorithms-part2

DFS : https://www.bilibili.com/video/BV1oK4y1V721?p=4

Component连通分量:https://www.bilibili.com/video/BV1oK4y1V721?p=6

单源最短路Dijkstra算法:https://www.bilibili.com/video/BV1oK4y1V721?p=21

4. 基于JML规格来设计测试的方法和策略

在测试的学习和实践上,我了解了TDD思想,入门了Junit的使用,并借助JML规格进行单元测试的设计

4.1 TDD思想与Junit

image-20210529150000439

  • Developers write unit tests (NOT testers) and then code

    虽然我不清楚敏捷开发下每个sprint的需求分析阶段,会不会要求output一套JML规格。但若有了一份规格,开发和测试就可以同时开启工作

  • Junit单元测试

    "Make it green, then make it clean! "

    坦诚来讲,独自开发与测试,4天一个迭代的紧张排期,采取单元测试覆盖每一个方法,并且参考JML的后置条件(ensures)检查所有发生变动的变量(assignable),不太实际,所以在中测阶段,我采取重点方法的自测+构造极端测试用例检查性能的方案。但同时,也学习了Junit单元测试,run了一个demo,把基本的学习了。

    测试效果:不理想,都是强测的testcase帮我修复代码。3次作业一共有12个bug(太烂了)

4.2 强测bug情况统计

感谢强测对性能的“严格”要求,让我关注了算法时间复杂度与Java各种数据结构的底层实现逻辑与效率,具体的分析放在“容器分析”与“性能分析”两章

情况 review 发生次数
代码复制粘贴后忘了修改变量名 多加弱测即可发现 2
qgvs超时 1、O(N^2) 2、增加缓存 3、给group增加一个valueSum的属性并实时维护 3
sim单源最短路算法超时 1、算法实现底层有小bug 2、增加缓存 2
qbs超时 MyNetwork增加一个Component类的缓存变量 2
大整数相减造成int越界 1、使用BigInteger(有意识但没有关注输入变量的数量级限制) 4

5. 总结分析容器选择和使用的经验

  • HashMap底层

    https://juejin.cn/post/6844903744711163911

    hashMap可以理解为元素是链表的一个数组,相同hash value的元素都会链接在同一个链表中(hash value就是其所在的数组地址),当相同hash value的元素过多时,链表会衍生为红黑树以提升查询效率。

    那hash value受什么影响呢?看源码:先得到key.hashCode()

    那hashCode()怎么计算呢?不同的数据类型的hashCode()计算的方法不一样

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

ArrayList, LinkedList, HashMap的的增删查改操作的时间复杂度

class Person, Group, Message, Emoji都是有unique primary key 的id值,所以采用HashMap存储最好

Person需要存储接受消息,并总是要返回最晚接收的前4条。因此用LinkedList比较好。

操作/类 ArrayList LinkedList HashMap
add(E) 直接尾部添加,O(1) 直接尾部添加,O(1) put(), 计算hashvalue寻址后直接添加,O(1)
add(index, E) 查询后添加,查询O(1);插入后该位置后面所有元素要后移,O(N) 查询后添加,查询时间为O(N);插入为O(1) 不存在
remove(index) 查询O(1);删除后该位置后面所有元素要前移,O(N) 查询后删除,查询时间为O(N);删除为O(1)
remove(object) 查询遍历O(N),删除O(N) O(N) 虽然链表删除操作是O(1)的操作,但实际的删除要先遍历查询O(N) O(N),同左
get(index) 查询时间复杂度O(1),但很少情况下是已知下标来查询的 循环遍历,时间复杂度O(N) O(1)
根据内容查找 indexOf(),O(N) 从头或者尾遍历,时间复杂度O(N) containsKey() 或者 get(),O(1)
修改 已知index则为O(1),已知对象则为O(N) O(N) 根据key修改O(1)

6. 重点方法的提升性能策略总结

"规格毕竟给的是抽象的、数学的、不考虑实现效率的方法。真正实现的方案有很多种,有好有坏,还可以增加规格没有提供的中间变量、类、方法等,来提升性能“

6.1 query_group_value_sum的性能提升

qgvs超时的问题在我的第二次和第三次作业中都出现了。

我的原始最低效的设计是直接翻译JML规格给的方法,来了一个O(N*N)的双重遍历:

    public int getValueSum() {
        int valueSum = 0;
        Iterator<Map.Entry<Integer, MyPerson>> entries1 = people.entrySet().iterator();
        while (entries1.hasNext()) {
            Map.Entry<Integer, MyPerson> entry1 = entries1.next();
            MyPerson person1 = entry1.getValue();
            Iterator<Map.Entry<Integer, MyPerson>> entries2 = people.entrySet().iterator();
            while (entries2.hasNext()) {
                Map.Entry<Integer, MyPerson> entry2 = entries2.next();
                MyPerson person2 = entry2.getValue();
                if (person1.isLinked(person2)) {
                    valueSum += person1.queryValue(person2);
                }
            }
        }
        return valueSum;
    }

我的初版解决方案(解决了2nd但没解决3rd)是,每次qgvs计算了valueSum后,缓存下valueSum。若下一次对同一个group进行qgvs,判断这个group的结构是否有变化(有无atg, ar, dfg),如果没有变化则直接读取缓存的valueSum输出,有变化则重新计算。

这种解决方法在group的结构变化频繁时会失效——需要频繁的O(N*N)的时间复杂度计算,这也是导致本次作业在strongTest4 超时的原因。

我的改动如下:group新增一个属性valueSum, 每次对group改动时(atg, ar, dfg),则更新valueSum的值。如此,每次update Value Sum都是线性的时间复杂度,查询更是O(1)的复杂度,可以解决超时的问题。

举一个atg更新的例子:

void addPerson(Person person) {
    for (熟人 in person.熟人集) {
        if (熟人也在这个group) {
            valueSum += 邻边.边权;        
            }
    }
}

6.2 query_block_sum的性能提升

我的原始设计:计算连通块时,每一次query_block_sum或者is_Circle()时,都会重新DFS遍历整个network,重新计算network的连通块情况。即使我的dfsO(E+V)的复杂度,qbs太多次也会超时。

于是,我在class Network设置了一个Component连通块的缓存,每次要qbs或者ic时,都先判断Component自上一次计算以来有无变化,包括:

  1. 有无add person

  2. 有无add relation

如果没有变化,则直接读缓存值,不用重新计算

6.3 send_indirect_message的性能提升

sim超时的问题,也就是单源最短路(Dijkstra)算法优化的问题。

不用优先队列优化:\(𝑶(|𝑽|^𝟐)\);使用优先队列优化:\(𝑶(|𝑬| ⋅ 𝐥𝐨𝐠|𝑽|)\)

体会:通过这个bug,让我更理解Java 堆相关数据结构底层增删查改的实现与性能,Awesome!

我的单源最短路算法,在松弛操作更新优先队列时出现了一些问题。

我利用优先队列结构存储每个结点的暂时的距离源点的最短路径值Pair {int nodeNo; int distence}

优先队列——最小堆的底层操作效率分析:

pq.contains(Object o) 是一个O(N),通过遍历整个堆的操作;pq.remove(Object o)是一个O(N+logN)的操作:先遍历查找,再删除Object o,最后还要维护堆结构。改bug后,删掉pq.contains() 直接pq.remove(),以维护松弛操作后的pq,减少时间复杂度。

改动前
PriorityQueue<Pair> pq;
if (!pq.contains(oldPair)) {
	pq.add(newPair);
} else {
	pq.remove(oldPair);
	pq.add(newPair);
}
-------------------------------
改动后
pq.remove(oldPairAdj); // if not contains, then remove nothing
pq.add(newPairAdj);

同时,为了remove()的正确实现,我重写了Pair.equals()(查看源码就会发现remove() --> indexOf() --> equals())。不重写,代码会有bug: remove不掉,每次都直接add,导致pq非常大,正确性更不可知)

Class Pair {
	    private int personId;
        private int distTo;
        
        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            } else if (getClass() != o.getClass()) {
                return false;
            }
            Pair another = (Pair) o;
            if (another.getPersonId() == personId) {
                return true;
            } else {
                return false;
            }
        }
}

6.4 考虑容器底层原理的性能提升

HashMap里根据key查找

HashMap<key, value> hashmap;
if (!hashmap.containsKey(key)) {
	hashmap.put(key, object);
} else {
	throw new Exception();
}

vs

HashMap<key, value> hashmap;
if (hashmap.get(key) == null) {
	hashmap.put(key, object);
} else {
	throw new Exception();
}

HashMap.containsKey()底层和HashMap.get()都是借助getNode()实现的。当提供一个键值key,让你查找map里是否已经插入该键值的元素的时候,要选用哪种方法,效率比较?

  • 当值可以为null时,务必使用containsKey()
  • 当值不会为null时,两者都可行,前者2次查找,后者1次查找。虽然每次查找都是近乎O(1)的,但当查找操作/需求特别多的时候,后者快1倍的效率还是很诱人的。
posted @ 2021-05-31 14:04  糯米鸡呀呀呀呀  阅读(194)  评论(2编辑  收藏  举报