BUAA-OO-第三单元:规格化设计
前言
本单元的主题是“规格化设计”,要求我们学会理解JML
规格语言,并能基于规格编写代码实现相应的功能。
在课程的最初,我时常在想“JML
”是什么,它对我们平时写代码有什么作用。(嗯嗯,3次作业结束了,好像也不完全理解JML在平时写代码中的意义捏)好在,课程组再次贴心的为我们准备了预习大餐:JML(Level 0)使用手册
手册中介绍到,JML
是对JAVA
程序进行规格化设计的一种轻量级的形式化语言,它与Java
语言紧密结合,使得规格设计与Java
类型设计有机融合。规格有三种主要用法:开展规格化设计、针对已有代码实现书写规格,基于规格和代码实现设计覆盖性更好的自动化测试。
在我看来,课程组设置JML
单元的初衷是让我们俩了解规格化语言,在“面向规格编程”的过程中感受“契约编程”的魅力——高可靠性、高可复用性、便于测试。在三次迭代开发的作用中,课程组精心设计了一个社交网络系统,基于基本图论知识,运用JML
语言的描述对社交网络进行不同种类的修改和查询。我在实现本单元代码功能的时候阅读了“各式各样”的JML
,体会到了“接口式”、高度功能化的函数设计。
hw9
本次作业要求我们维护一个社交网络,并对其中的用户和他们之间的关系进行管理。大体来说分为Network
、Person
两个类和四种异常类。
Network
类:主要是维护整个社交网络的各种行为。可以往这个网络里面加用户,加关系,修改关系,查询关系等。此外,根据JML
的要求,社交网络要维护一个person
数组,数组中没有重复的用户。Person
类:主要是维护每个用户的信息,以及提供一些查询的接口。个人信息主要有id
,名字,年龄,朋友等。我们需要提供删除朋友和添加朋友的接口,并维护相应的信息。- 四种异常类:分别是用户
ID
重复异常、关系重复异常、用户不存在异常、关系不存在异常。
UML类图与分析
这次作业的框架是课程组直接给定的,我们只需要实现课程组的接口即可。分析上图可知,我们用MyNetwork
、MyPerson
和四个My...Exception
实现了课程组的接口和抽象类。
图模型构建和优化
数据结构
MyNetwork
类
维护了一个用户的HashMap
,一个标记和一个三元环个数。
public class MyNetwork implements Network {
private final HashMap<Integer, Person> persons; //用户Id和用户组成的HashMap
private int tag; //后面图算法用到了标签
private int tri; //记录三元环个数的变量
...
}
由于JML中有这样一句话:
/*@ invariant persons != null && (\forall int i,j; 0 <= i && i < j && j < persons.length; !persons[i].equals(persons[j]));*/
则就需要我们维护一个不重复的用户序列。题目中提到用户ID
互补相同,自然想到用ID
为键值查询用户,做到接近
O
(
1
)
O(1)
O(1)的效率。这个思想也被沿用到后面几次作业中。
MyPerson
类
维护了用户的各种信息,和两个HashMap
,分别是关系和价值。
public class MyPerson implements Person {
private final int id; //用户id
private final String name; //用户昵称
private final int age; //用户年龄
private final HashMap<Integer, Person> acquaintance; //用户关系
private final HashMap<Integer, Integer> value; //与其他用户关系的价值
...
}
图模型构建
稍加分析,我们可以看出本次作业的图是一个无向带权图。进行的所有操作就是加人、加边、删边、改权值、查价值、判连通性、统计连通块个数和统计三元环个数。
用最朴素的思路,每一次加边和删边的时候不加多余的维护,那么其实非查询操作都可以
O
(
1
)
O(1)
O(1)解决。每次判连通性和统计连通块个数的时候都直接用dfs
或bfs
进行搜索(还记得刚刚的tag
吗,这其实是一个动态增长的标记量,可以在
O
(
1
)
O(1)
O(1)的时间内判断某个用户在一次搜索中是否被访问到),统计三元环个数的时候直接三重循环判断。
我们来仔细分析一下以上暴力算法的时间复杂度。首先,判连通性和统计连通块个数,单次操作肯定小于
O
(
n
)
O(n)
O(n)。(
n
n
n代表当前社交网络中的人数)。其次,三元环个数统计,显然效率就是
O
(
n
3
)
O(n^3)
O(n3),如果优化得好一点就是
O
(
n
3
6
)
O(\frac{n^3}{6})
O(6n3)。如果出现极端数据,如先加5000个人,再查询5000次三元环个数,暴力算法就会TLE
。
优化
我们先考虑将整体单次指令的算法的复杂度降至 O ( n ) O(n) O(n)。不难发现,三元环个数的改变只发生在加边和删边的过程中。那么,其实我们每次加边的时候,就可以遍历所有其他用户,如果该用户与加边的两个用户都有关系,那么对三元环产生一次贡献。同理,删边就是对三元环产生一次负贡献。具体实现如下:
for (int id : persons.keySet()) { //from MyNetwork/addRelation
MyPerson person = (MyPerson) getPerson(id);
if (!person.equals(person1) && !person.equals(person2) &&
person1.isLinked(person) && person2.isLinked(person)) {
tri++;
}
}
这个优化可以说是本次作业中最为重要的优化,也是一个超越常数级别的优化。当然,除了这个优化之外,还有其他的一些小优化,以及一个涉及复杂数据结构的优化探索。。。
- 动态维护连通块:顾名思义,在加边和删边的时候同时维护不同连通块的信息。这样,在判联通性和统计连通块个数的时候可以做到
O
(
1
)
O(1)
O(1)的复杂度。但是相应的,
ar
和mr
操作的代价会提高。 - 并查集:这个思路与第一种思路比较类似,就是用并查集维护不同用户之间的关系。
But
,要是删边了并查集该怎么办呢?难不成我真写个Splay
?So
,mr
删边的时候还是要付出 O ( n ) O(n) O(n)的代价来维护破碎的并查集 Holm-de Lichtenberg-Thorup
分层图算法:动态图连通性。嗯嗯,就是它,事实上,这个算法单次操作时间复杂度为 O ( l o g 2 n ) O(log^2n) O(log2n),并且附带较大的常数。经过一番挣扎后(懒),还是放弃了这种实现方式。当然,用ETT
和LCT
也有类似的时间复杂度。
性能分析
根据上述图模型的构建和优化过程,我们已经得到了一个严格小于
O
(
n
)
O(n)
O(n)的图算法。根据题目中指令数量最多10000
条的条件,该算法的执行次数最多也就2.5e7
,可以在10s
的CPU时间跑完所有的测试点。所以本次代码并没有出现性能问题。
hw10
在上次作业的基础之上,加了一些新的指令和异常。在本次作业中,引入了Tag
的概念。放在现实生活中,其实可以理解成用户的不同群聊(不包含自己)。同一个用户可以创建不同的群聊,拉不同的朋友进不同的群。这个与我们的日常生活很像。
- 新增指令概述:首先是
Tag
相关的指令,加Tag
,删Tag
,把人加进Tag
,从Tag
中删除;其次是查询与该用户最铁的哥们儿(value
最大),并统计双方互为真爱粉(都是对方最铁哥们儿)的对数;最后是查询两个人之间的最近关系(最短路径)。 - 新增的四种异常:用户没朋友异常,两人没关系异常,没有该群聊异常,相同群聊异常。
UML类图与分析
由于异常较多,本次就忽略了与上次作业一样的异常。通过分析可以知道,本次作业指示多继承了一个Tag
类,其余并无变化。而增加的异常处理与第一次作业处理逻辑很像,可以模仿着来写。
图模型构建和优化
数据结构
MyNetwork
类
相比于hw9,本次中只新增了一个容器:
public class MyNetwork implements Network {
private final HashSet<MyTag> tagAll; //用于统计所有的tag,后面会详细介绍它的意义
...
}
MyPerson
类
增加了两个容器:
public class MyPerson implements Person {
...
private final HashMap<Integer, Tag> tags; //通过Id映射到不同的Tag
private final PriorityQueue<Integer> ocp = new PriorityQueue<>(new Comparator<Integer>() {
@Override //动态维护的优先队列,后面会详细介绍它的意义
public int compare(Integer o1, Integer o2) {
int comp = Integer.compare(value.get(o2), value.get(o1));
if (comp != 0) {
return comp;
} else {
return Integer.compare(o1, o2);
}
}
});
...
}
MyTag
类
包含tag
的id
,tag
中包含的人和valueSum
的值。
public class MyTag implements Tag {
private final int id; //该tag的id
private final HashMap<Integer, Person> persons; //tag中包含的人
private int sum; //Tag中valueSum的值
...
}
图模型构建
分析本次作业新增的众多指令,我们发现无向带权图的本质并没有发生变化,只是需要我们维护更多的信息。对于每一个用户,他可以将不同的朋友加入到不同的tag中。然后,便可以对每个tag查询不同的信息。此外,本次作业还需要查询与一个用户关系最好的用户的id,并记录两两最好关系的对数,以及需要查询两个用户之间的最短路径,这些都需要我们使用相应的图算法解决。
与上次作业一样,我们仍然从最朴素的算法出发。考虑不维护信息,所有查询操作都采用在线暴力查询的方式。
queryTagValueSum
:我们需要用一个二重循环遍历某个用户的某个Tag
,时间复杂度是 O ( n 2 ) O(n^2) O(n2)。queryTagAgeVar
:统计某个Tag
中的方差,用一重循环计算即可,时间复杂度 O ( n ) O(n) O(n)。queryBestAcquaintance
:寻找与某人关系最好的人,遍历所有他的朋友即可,时间复杂度 O ( n ) O(n) O(n)。queryCoupleSum
:寻找两两最好关系的对数。朴素的想法是调用queryBestAcquaintance
方法,本质是一个二重循环,时间复杂度是 O ( n 2 ) O(n^2) O(n2)。queryShortestPath
:寻找两个用户之间的最短路。直接广搜就可,时间复杂度小于 O ( n ) O(n) O(n)。
优化&&性能分析
在对朴素算法的分析中,已经有两个查询操作超过了
O
(
n
)
O(n)
O(n)的复杂度,所以我们要想办法提高这些方法的效率。我们最终的目的是让所有的指令单次执行时间复杂度不超过
O
(
n
)
O(n)
O(n),这样我们的时间复杂度就可以与hw9保持一致。要实现这个目的,我们主要是降低qtvs
和qcs
两个指令的复杂度。
-
仔细分析
qtvs
指令的执行过程,我们会发现,其实valueSum
是可以实现动态维护的。怎么说呢?改变valueSum
的指令只有四种,分别是ar
,mr
,addPersonToTag
和delPersonFromTag
。ar
和mr
的效力相同,都是通过改变边的权值来改变valueSum
。我们不妨把已经存在的Tag
用上述的tagAll
容器存储起来。那么在每次ar
和mr
的时候我们就可遍历tagAll
中所有的tag
,如果该tag
包含ar
或mr
相关联的两个人,那么valueSum
就 ± 2 × c h a n g e V a l u e ±2 \times changeValue ±2×changeValue即可。由于每次加Tag
也是需要指令数的,所以Tag
的总数不会超过10000
,根据细致的分析,该算法的最大执行次数也是2.5e7
。处理
addPersonToTag和delPersonFromTag
的操作类似,都是考虑把一个加入Tag
所带来的贡献,即 ± 2 × ∑ p e r s o n . q u e r y V a l u e ( p e r s o n s . g e t ( i ) ± 2\times\sum person.queryValue(persons.get(i) ±2×∑person.queryValue(persons.get(i)。其实仔细思考一下,这个优化并不难做。整体来看,只要我们执行四种指令的时候动态维护了
valueSum
,那么最终的查询复杂度就降为 O ( 1 ) O(1) O(1),而单次执行这四条指令的时间复杂度均不超过 O ( n ) O(n) O(n)。所以,总体的时间复杂度是 O ( n ) O(n) O(n)。 -
qtvs
进一步的优化:对于mr
和ar
指令,不妨考虑对每个用户维护他所在的tag
(在别人的群聊里)的集合。这样每次ar
或mr
的时候,就只需找到两人中tag
数量较少的一个遍历即可。我们可以设想一下极限情况下的场景,一定是两个人所在
tag
数量相同且总执行次数最多。根据一通理论分析,我得出的结论是,该算法最坏情况的时间复杂度是 O ( Q 2 12 ) O(\frac{Q^2}{12}) O(12Q2), Q Q Q为总的指令条数。(后面数据构造部分会详细介绍原理)需要指出的是,上文中的 n n n是
persons
中的总人数,它有可能与 Q Q Q同级。 -
对于
qba
和qcs
指令,其实做优化也比较容易。首先,显然能够改变bestAcquaintance
指令的只有ar
和mr
指令。对于某个用户来说,涉及到他的ar
和mr
指令无非就是改变他与另外一个用户的value
值。抽象一下,这就是修改权值,动态维护最大权值的经典问题。显然用一个优先队列就能轻松解决。在我的实现中,我用了PriorityQueue
,并重写compare
函数,来动态维护序列中的最大权值。当然,在与同学的交流中,我发现采用TreeSet
和TreeMap
的方式也同样可行。使用优先队列后,只需要每次在ar和mr指令的时候在优先队列中加
value
或删value
即可。每次qba
指令可以直接从堆顶取值,做到 O ( 1 ) O(1) O(1)的时间复杂度。而每次qcs
指令就只需要遍历persons
序列中所有的用户,然后用qba指令判断一些即可。时间复杂度 O ( n ) O(n) O(n)。 -
其他还可作为的优化,比如说加脏位,使用更快的容器实现,考虑强测的数据分布选择针对性算法等等。这些优化虽然不能在理论上提升时间复杂度,但能够在强侧中跑出较好的成绩。
通过上述的描述,我们发现:在本次JML
迭代作业中,虽然功能很多,但只需要我们把单次指令的时间复杂度降低到
O
(
n
)
O(n)
O(n)以下,并尽可能做更多的优化,还是能够《通过本次测试》的。(强测怎么还卡常啊,www~)
小插曲
没想到强测竟然T
了一个点,呜呜呜~。经过我一通分析,在代码里面找问题,发现自己在ar
和mr
指令上需要维护的东西太多,尤其是做了一个动态维护连通块的nt
负优化。导致我在ar
和mr
指令较多的时候,虽然时间复杂度是
O
(
n
)
O(n)
O(n),但还是跑得太慢。(有可能是因为常数太大了,再加上评测机在强测的时候压力很大,所以没能冲过去)
不过还好后来助教gg
重测了我T
的那个点,重测之后我在3s
之内跑过去了那个点(给负责任的助教gg
滑轨致谢~~~)
以后再也不写这种nt
的负优化了,在Bug
修复上试了试,把动态维护连通块去掉,我的CPU
时间没有一个超过2s
的,唉唉唉~~
hw11
在hw10的基础上,本次作业新加入了用户与用户发送各种信息,并动态维护一些权值的功能,同时能够根据情况引出不同的异常。其中,hw11引出的Message
这一概念,延续了我在hw10单元中理解的“群聊”概念。
- 新增指令概述:本次作业引入了红包,表情包,通知三种信息。用户可以添加不同的信息(可以理解为在手机中输入对应的信息),然后在私聊(
Type=0
)或群聊(Type=1
)中发送该信息,并引起参数的改变,如发红包会使得用户的Money
改变。此外,每次发表情包信息的过程也是给表情包投票的过程,为后面的deleteColdEmoji
方法做准备。 - 新增的四种异常:表情包不存在异常,消息不存在异常,相同消息异常和相同表情包异常。
UML类图与分析
为了方便展示,这里异常只展示了与上一次作业不相同的部分(异常个数实在是太多了叭😂)。可以看到,本次作业的框架沿袭了上一次的结构,添加了MyMessage
类和其他三个MyMessage
的子类。
值得注意的是,我将MyNetwork
中的部分与MyNetwork
中数据相关性不大的query
指令挪到了新建的MyQuery
类中。这是为什么呢?因为MyNetwork
类已经过于臃肿,数据和方法关联度不大,但是耦合度很高,很多不同功能的实现都搅合到一起。(啊啊,其实是MyNetwork
超了500
行,checkStyle
报错啦!)
为了解决这个问题,我把部分与MyNetwork
中数据相关性不大的query
指令单独分出来,使得MyNetwork
代码之间的耦合度大大降低,模块与模块之间的依赖关系大大减弱。例如,我将qsp
、qc
和qbs
指令所需要的BFS
算法集成到了一起,通过不同的传入参数,实现不同的功能。真正实现了“接口式”编程。
图模型构建和优化
数据结构
MyNetwork
类
public class MyNetwork implements Network {
private HashMap<Integer, Message> messages;
private HashMap<Integer, Integer> emojiList;
...
}
本次作业新增了messages
和emojiList
两个容器。message
的本质和person
并无差别,所以还是用老办法,用ID
映射message
,保证messages
容器中的message
两两不同。为了统计表情包的使用次数,一个统计emoji
个数的emojiList
也是必不可少的。因为表情的ID
是离散的,所以也需要一个HashMap
建立从表情包到使用次数的映射关系。
MyPerson
类
public class MyPerson implements Person {
...
private int socialValue;
private ArrayList<Message> messages;
private int money;
...
}
为了维护一个人在发消息过程中获得的社会价值(socialValue
)和money
,我们为每个用户维护一个socialValue
,一个money
信息。另外,在私聊通信(两个人之间发消息)时,接收那个人需要保持此message
,为之后的getReceivedMessages
方法做准备。
MyTag
类
无变化
MyMessage
类
按照JML中要求的参数设置即可
图模型构建&&性能分析
本次作业相比于上次来说更加的简单,也基本不存在效率上的问题。只需要按照JML
的要求实现相应的功能即可。为什么这么说呢?本次作业增加的所有指令,其本质都是围绕着“发送消息”。所以,我们只需要建立人和message
的关系即可。仔细阅读所有有关message
的方法,我们可以发现,只有clearNotices
和deleteColdEmoji
两个是非线性,且最多仅仅是
O
(
Q
)
O(Q)
O(Q)的复杂度。结合前两次作业的时间复杂度分析,这个时间我们是完全可以接受的。因此,本次作业不需要做过多的优化。
当然,一些小优化是可以尝试去做的,比如说用优先队列来动态维护EmojiList
,这样在删除limit
s的时候就直接删去一个前缀即可,不需要遍历全序列。
规格与实现分离
从定义来看,规格与实现分离是一种软件设计原则,它强调将软件的需求规格(即“做什么”)与具体的实现细节(即“如何做”)分开考虑。具体来说:
-
规格侧重于描述功能,有时候需要构造很多中间变量进行描述,其更重要的是将功能尽可能严谨全面地描述清楚。
就比如说以下代码:
/* from Network/isCircle @ 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]))); */
这段代码其本质就是描述存在一条从
id1
的用户到id2
的用户的一条路径,而为了更加简洁、严谨、清楚地表述这一信息,JML
语言引入了\exists
、\forall
、array
等中间变量。 -
实现侧重于功能的工程实现,有时没有必要照搬规格内容,而是采用自己的办法来实现规格所描述的功能,以实现更佳的性能。需要强调的是,规格化设计只是一种 “设计“,是一种”契约式“的要求。我们只要满足这个要求,可以按照自己的习惯,甚至实现一些规格以外的方法。
比如这对上述
isCircle
方法,我的实现是这样的:public boolean isLink(MyPerson cur, MyPerson to) { ArrayList<Person> list = new ArrayList<>(); list.add(cur); cur.setTag2(tag); while (!list.isEmpty()) { MyPerson person = (MyPerson) list.get(0); if (person == to) { return true; } for (Person person1 : person.getAcqArray()) { if (((MyPerson) person1).getTag2() != tag) { list.add(person1); ((MyPerson) person1).setTag2(tag); } } list.remove(0); } return false; } public boolean isCircle(int id1, int id2) throws PersonIdNotFoundException { if (!containsPerson(id1)) { throw new MyPersonIdNotFoundException(id1); } else if (!containsPerson(id2)) { throw new MyPersonIdNotFoundException(id2); } else { ++tag; return isLink((MyPerson) getPerson(id1), (MyPerson) getPerson(id2)); } }
分析以上代码可以发现,我的
isCircle
方法并没有简单复述JML
中的规格,而是采用了调用其他方法接口的方式实现。在这个方法的实现中,其实就已经实现了规格和实现的分离。
根据以上分析,可以得出结论:规格只是目的,实现才是方法。为了服务于我们的具体实现方法,我们可以按照实际需求对规格中的要求进行转化、调整。有以下几点例子:
- 改变规格中的存储方式。将传统的数组式存储,如
Persons[]
,改成HashMap
存储。这也是贯穿我代码始终的思想,符合按照实际需求(每个人的ID
独一无二)调整代码实现的结论。 - 改变规格中的实现方式。查询
queryBestAcquaintance
不再遍历所有的用户,而是用优先队列直接取出队首元素。这样的改变提高了程序的效率,满足了实际中的性能需求。 - 规格的方向在多个方法内实现。在刚刚分析性能优化的例子中,有大量的查询操作都依赖于
ar/mr
时的动态维护。所以,这些查询方法本身肯定不是规格实现的唯一部分,它们需要与其他方法协同完成业务的处理。
可以说,正是有了规格与实现分离的思想,使得处理机械的JML规格变得灵活、高效。因此,规格与实现分离式规格设计中不可或缺的一环。
测试过程
黑箱测试与白箱测试
根据老师上课的PPT
以及网上查阅的资料,并结合我的理解,可以给黑箱测试和白箱测试的概念下个定义:
- 黑箱测试:这里的“黑箱”指的是我们的程序,“黑”是指我们看不到里面,具体来说就是我们不知道程序里的各种各样的数据结构和方法是如何实现的。黑箱测试仅在程序接口进行测试,主要关注程序功能是否按照需求规格说明书的规定正常使用,以及程序是否能适当地处理输入数据并产生正确的输出信息。
- 白箱测试:“白箱”也是我们的程序,“白”是指我们可以将程序的内部看得一清二楚。在这个测试中,我们要对代码所用语言、内部逻辑、数据结构和算法等细节都要有清晰的理解,然后通过分类讨论的推理等方式推测出可能会在某方面有功能上的漏洞,以及对每个功能单元设计有针对性的数据进行单独的检验等。
知晓黑箱测试和白箱测试的含义后,就可以分析它们各自的优缺点了。
黑箱测试 | 白箱测试 | |
---|---|---|
优点 | 保护产权、封装复杂性、操作方便、测试效率高 | 覆盖率高、代码理解深入、可追溯性强、可检测安全漏洞 |
缺点 | 覆盖率低、难以定位缺陷、可能遗漏缺陷 | 测试成本高、测试难度大、难以模拟用户行为 |
为了博采众长,弥补黑、白箱测试的缺点,我认为应该采取“黑白箱协同测试”的策略。这个主要放在数据生成部分讲。
单元测试、功能测试、集成测试、压力测试与回归测试
我认为这是对测试的另一个基于不同层次的划分,“黑箱测试”和“白箱测试”是基于程序内部是否可见,而这四个测试则可以认为是基于测试的策略。
-
单元测试:用于测试代码中的最小可测试单元(通常是单个方法或函数),对其进行输入输出的正确性检验。
-
功能测试:验证软件系统是否满足用户需求,可以通过输入输出来检查整个程序的功能实现是否成功。
-
集成测试:在单元测试正确基础上,验证不同软件模块之间的交互和协作是否正确。
-
压力测试:评估系统在极端条件下的性能。这就是
OO
课程的强测环节,对程序不断施加越来越大的负载,来检查性能和稳定性。 -
回归测试:在代码进行修改(不管是对性能进行优化还是修复
bug
等)或者功能迭代之后重新测试之前的测试用例,以保证修改的正确性。
测试工具
在U3中,虽说没有性能分,但要保证强测能够通过,就一定要保证性能不出现问题。一开始我是采用System.currentTimeMillis()
来计时,后来发现这种方法太蠢了,非常难以分析哪个函数耗时较长。于是,我使用了Idea
自带的IntelliJ Profiler
来分析自己各个方法的CPU
时间和总时间。效果如下图:
可以看到上图的效果,该测试工具会将方法中耗时较长的部分标注出来,我们可以直观地看到哪个方法的耗时较多,方便我们做针对性的性能优化。
数据构造策略
加大数据规模,覆盖更多情况
数据规模是数据强度衡量的一个重要指标。一个只有几个用户和几条边的图是难以检查出一些较为复杂的bug
的。有时候,不知道如何提升数据覆盖面,加大数据规模就是不错的选择。虽然说强测限制了指令数不超过10000
次,但是不影响我们在本地测试的时候添加100000
条、1000000
条指令。
只要我们的大规模测试覆盖到了所有可能的指令,那么它的强度就不会太低。通过上文的IntelliJ Profiler
工具分析自己各个模块的耗时,我们就能知道自己的程序有可能在哪里会有性能问题。
当然,为了在互测中能够准确地刀中人,我发现了一个普遍的最大化时间复杂度的方式。考虑最大化某个耗时较长的查询操作的时间复杂度,它的复杂度是
O
(
n
g
)
O(n^g)
O(ng)(
n
n
n是persons
中的用户数量,
g
g
g是整数次幂)。设给它一次查询指令数是
k
k
k(因为有些指标能动态维护,要打破动态维护性质,
k
k
k不一定为
1
1
1,要视具体情况而定),则极限条件应该满足:
k
×
m
+
n
=
Q
k \times m+n=Q
k×m+n=Q(
Q
Q
Q为指令次数,本题是10000
),最大化
m
n
g
mn^g
mng。
由均值不等式得,当 n = ( 1 − 1 g ) Q n=(1-\frac{1}{g})Q n=(1−g1)Q时,时间复杂度取得最大值 ( g − 1 ) 2 k g 3 Q 3 \frac{(g-1)^2}{kg^3}Q^3 kg3(g−1)2Q3。
该公式在卡** T L E TLE TLE**上立了大功,当然,不同指令常数的不同,该最大值也可以乘以某个常数。
加强边界情况测试
相对于前两个单元来说,第三单元的细节更多,所以也有更多边界情况的数据。有时候,盲目地增加数据规模可能难以检测出一些特定的错误。为了严格地检测实现是否符合规格,我们可以加大边界情况测试力度。以下就是我在本次作业中想到的一些边界情况:
- 测试
personId<0
的情况。很多同学想当然的认为personId>=0
,所以,有时候person
不存在的时候,会想当然的把Id
设置为-1
。此时,如果添加一个personId
为-1
的人,就可以检测出这些边界情况。 - 让
personId
在int
范围的边界,可以卡掉一些自定义id
比较的实现。比如说id1=-2147483648,id2=2147483647
,那么采用id1-id2
就会爆int
,从而导致程序出错。 - 多次删除和加入同一条边,检测
ar
和mr
执行的正确性。
Junit
测试方法
Junit
是一个很好的单元测试框架。它可以简化Java
编程中的单元测试,使程序员能够编写可重复的测试来验证他们的代码是否按预期工作。可以说,本单元助教团队的一个最大的创新点,就是将之前的OkTest
测试改为了Junit
测试,激发了同学们自主构造数据的动力。
根据前面的描述,基于规格与实现分离的思想,我们在代码实现中是对规格进行了推理改编的,我们希望我们与规格不同的实现能符合规格。但实际中大家再严谨认真也难免百密一疏,这时我们需要测试来证明我们的实现是符合规格的。
于是,Junit
测试的作用就是让已经“分离”的规格和实现,保持最终一致。有了这个保证,我们就可以大胆地实现规格(可以采用各种优化,改写等等),再无后顾之忧。
那么Junit
测试是如何进行的呢?在我的理解中,
J
u
n
i
t
=
D
a
t
a
_
G
e
n
e
r
a
t
o
r
+
O
k
T
e
s
t
Junit=Data\_Generator+OkTest
Junit=Data_Generator+OkTest。
首先,我们通过检查输入输出一致性,以及一些规格的要求,保证业务实现与客户要求完全一致。这就是OkTest
要完成的任务。OKTest 方法本质就是完全一模一样地翻译 JML
规格描述,不得进行任何逻辑和数学推导。具体而言,就是针对下面几个规格进行检验:
/*@ assignable @*/
//逐一验证除了 assignable 中的实例变量之外其他实例变量是否在调用方法前后等值不变
/*@ ensures @*/
//需要依次逐一验证 ensures 的后置条件
/*@ invariant @*/
//验证实例变量是否不为 null
/*@ pure @*/
//比较在调用方法前后,实例变量的值是否没有变化
其次,我们需要生成数据来覆盖所有可能的情况(尽可能达到覆盖率100%
)。在实际的复杂工程项目中,是非常难以达到100%
的覆盖率的。为了简化单元测试的复杂度,助教在错误类型上给予了限制,所以我们只需要考虑单个方法实现过程中可能出现的问题(但是还是有好多要考虑呀😢😢)
我在做hw11的时候,就是由于遗漏了红包信息的数据,导致分支覆盖不全。即使其他的测试方法写得尽善尽美,都难以通过case4
测试点。(我猜测bug
出现在修改RedEnvelopeMessage
)
具体的Junit
评测方式是这样的,课程组会先准备一些有“明显”bug
的程序,来做Junit
测试的试金石。如果我们能够把这些带bug
的程序都检测出来,便能通过Junit
测试。
综上所述,Junit
测试本质就是保证编程的“契约”。它可以不管具体的实现逻辑,但它一定严格要求按照规格满足用户的需求。
建议
可以说,引入Junit
测试是课程组和助教们的一个大胆的创新。由于是第一年启用这个方案,所以可能会有较多的议论和质疑。我从内心里其实是比较支持这个方向的创新的。在真正的应用场景中,确实需要我们拥有构造不同数据进行测试的能力。为了让Junit
测试变得更加合理、科学,在这里我就我个人的感受,谈谈我的三点建议:
-
认真分析课程组
Junit
测试方式,其实是一个类似于“黑箱测试”的机制。我并不知道可能出现的bug
,但是我还是要把所有的bug
都测出来。这件事本身是一个很难做到完备的,所以课程组保证bug
“明显”。我们完全可以准备多一些
bug
程序,然后每次评测时随机抽出几个不同的bug
给同学们测试。如果同学们测试不通过,那么就公开bug
类型,让同学们知道是哪个地方覆盖得还不够全面(防止虚空debug
)。经过几轮之后,同学们就大概知道bug
可能出现的位置和类型,也会对Junit
测试有更深刻的理解。 -
每次测试结束后公开所有
bug
程序。在OO
课程群和私下与同学们讨论的过程中,我发现很多同学虽然通过了Junit
的测试,但是还是不知道为什么过。因此我建议,可以在本次作业结束后,公开所有的
bug
程序,让同学们看看本次代码中可能出现的错误。这样既可以防微杜渐,又提高了测试的透明度。 -
在做完以上工作的基础上,我们也可以考虑引入“集成测试”。现在的测试本质上还是单元测试,停留在工程测试的第一阶段。为了让同学们对工程测试有更深入的理解,我们完全可以引入简单的”集成测试“,测试方法之间协同工作的正确性。
闲话漫谈
做完了本单元的三次作业,我不禁想问自己一个问题。本次作业实现社交网络的现实意义是什么?如果我们单单只看JML
去实现我们的代码的话,有时就会陷入迷茫,为什么课程组要这么设计JML
呢?
经过一番思索和与同学的交流后,我找到了能说服自己的答案。沿着这个方向理解,很多方法的JML
也就说得通了。
首先,Network
是一张巨大的社交网络,里面由很多个用户组成,当然用户也有自己的各种信息。用户们可以互相添加好友,改变好友的权值(相当于两个人感情升温,或情断义绝)。
Tag
就可以理解成某个用户的群聊,这个群聊是独一无二的。某个用户也可以任意添加和删除群聊,也可以把任意一个人加入或踢出群聊。这也就不难理解Tag的操作了。有一天,这个用户突然好奇想要查看自己某个群聊中的年龄分布,所以他查了查自己某个群聊的年龄方差。
根据6度分隔理论,世界上每个人最多经历6层关系就可以与另一个人相关。在社交网络上,我们也可以衡量两个人之间关系究竟有多近,就可以直接查询连接他两的最短路径啦~
至于queryCoupleSum和queryBestAcquaintance
,其实就是用value
衡量两个人的关系,value
越大,关系越好,value
减到0
,两人恩断义绝。我们可以查询社交网络中与某个用户关系最好的人,也可以查询有多少对“金兰姐妹”。
最后就是Message
。发送信息在现实中确实可以是私聊或群发两种方式,对应Message
两种不同的Type
。而发红包和发通知,用红包的Money
大小和信息长度来增加人与人之间的感情,是不是也挺合理的捏(谁给我钱多,谁跟我聊天多,我就跟谁关系好)。而淘汰过气的表情包,这本来也是微信的一个内置功能。通过统计人们发信息中所含表情包的数量,不定时删除冷门表情包。
根据以上推敲,发现机械的JML
突然变得生动起来,这让我在实现的时候可以参照现实生活的场景,更好地理解函数的功能实现。
心得体会
OO
第三单元终于结~束~啦~
可以说,做完这单元,我的内心还是感慨万千。我接触一种全新的”契约式“编程概念,实现了一个大型社交网络系统,亲身实践了Junit
的测试,以及大二第一次在ACM之外写图论(虽然但是,其实图论还可以上点强度hhh)。
经过一个单元的学习,我认识到,”契约式“编程确实好处多多。它有助于减少错误、提高代码质量、方便与客户交互并促进可维护性。有了JML语言,本单元的所有方法实现都变得更加严谨、清晰。我也不需要像以前一样自己设计代码架构,而是直接按照JML实现相应功能即可。当然,我也意识到,规格实现不仅是实现功能,效率也是一个不可或缺的指标。一个高效、正确的业务代码实现,才是我们追求的目标。
总的来说,本单元学习还是收获颇丰的,极大锻炼了我阅读代码、实现代码和调试代码的能力,让我对java
的理解程度更上了一层楼。