张凯歌 20373067
自测过程
在本单元中,我依旧和同学相互合作,有同学负责进行数据生成,我负责对拍程序撰写。然后每次作业之后,都进行自动化测试。这个单元同前几个单元一样,黑盒测试不能完全有效的覆盖所有情况,需要针对 JML 中的描述针对性的构造特殊样例,保证测试的完全覆盖。
课下测试
JUnit
-
JUnit的测试需要基于对于JML完全理解的基础上
比如这次JML中有限制群组上限为1111人,getReceivedMessages返回最近的4条消息等。这些条件都是不太容易发现的,如果对于JML阅读不仔细,那么这些条件就可能无法被测试到,从而使用JUnit达不到预期效果
-
需要提前构造好数据
构造数据需要在编写代码之前构造。之前没有建立起这样的习惯。
课下强测
这一单元的测试和前两个单元不太一样。这个单元的测试比较固定,确定输入就可以确定输出,所以在测试上可以无脑对拍。在本单元中,我和wxg、ghy、lsz、qs同学一起完成了测试程序的编写和自动化测试部分。其中数据生成部分由wxg同学编写。在数据生成器的编写上,采用了常量池和随机数的经典做法。常量池保证了边界数据范围、随机数保证了测试数据覆盖性。数据生成器中的函数对应着一条指令。数据生成的过程,本质上是根据随机数调用函数产生指令的过程。在此基础上,我们可以编写相应的代码,生成完全图、单组的图等等特殊数据,确保数据强度,和程序鲁棒性。
边界数据
边界数据的构造一般是通过阅读JML和对于随机生成的数据进行手动重新构造完成的。第一点,就像刚刚提到的,随机生成的数据可能不能很好的保证覆盖到JML规定的所有行为,比如1111人和getReceivedMessages。此时就需要根据JML进行手动构造。第二点,根据随机生成的数据进行手动重新构造也是一种边界数据产生的途径。这种方法常见于前两个单元。随机数据的生成中对于某些侧重点的指令,比如qgvs,在测试的过程中可能就会出现TLE或者WA的问题,此时就可以通过分析随机数据生成的结果,手动构造针对性更强,强度更高的数据。
互测构造
在三次互测中,我表现的比较佛。一方面是JML规定了实现的方式,所以大家在实现的过程中都大同小异。当我在阅读他人的代码的时候,很难发现他人的问题。另一方面是卡人的数据都是我们课下造好了的,所以可以在互测的过程中直接使用。
图模型构建和维护策略
作业中的结构
作业的背景是一个社交网络,但是如果仔细阅读JML会发现,整个系统中person就是一个个节点,relation是点之间的边。而在Network中的各个方法都是维护这个图的方法。比如addRealtion就是在点之间加边;isCircle就是检查两个点之间的连通性;queryBlockSum就是查询这个图中连通块个数;queryLeastConnection是求某个点组成的最小生成树;sendIndirectMessage求出两个点之间最短路等等。
算法总结
并查集
并查集是一种树形的数据结构,用于处理一些不相交集合的合并和查询问题。并查集通常包含两种方法,分别是查找和合并。查找是查询两个元素是否在同一个集合中;合并是将两个不相交的集合合并成一个集合。在作业中,可以将person作为一个个点,然后relation作为边,将所有认识的人看成一个集合。这样查询两个人是否认识,就是查询两个人是否在一个集合中;而addRelation中添加两个人的认识关系,就是将AB两个人分别所在的集合合并起来,这正好对应了并查集中的查找和合并操作。使用并查集后,可以简化queryBlockSum和isCircle的查询速度。具体的实现方法就是采用”代表元“法,就是每个集合选择一个固定的元素作为整个集合的代表元素。
这里给出一种经典的C++语言实现并查集的方法,可以作为Java语言实现参考:
// 1. 并查集的存储
int fa[SIZE];
// 2. 并查集的初始化
// 假设有 n 个元素,起初所有元素各自构成一个独立的集合,即有n棵1个点的树。
for (int i = 1; i <= n; i++) fa[i] = i;
// 3. 并查集的 Get 操作
// 若x是树根,则x就是集合的代表,否则递归访问fa[x]直到根节点
int get(int x) {
if (x == fa[x]) return x;
return fa[x] = get(fa[x]);
}
// 4. 并查集的 Merge 操作
// 合并元素x和元素y所在的集合,等价于让x的树根作为y的树根的子节点
void merge(int x, int y) {
fa[get(x)] = get(y);
}
并查集的算法中还有按秩合并等优化方法,但是经过实测优化程度不大。所以有兴趣自己学吧。
克鲁斯卡尔算法(最小生成树)
queryLeastConnection这个函数是求某个点可达的所有点组成的最小生成树。在这个方面有prim算法和kruskal算法可选。最后我选择了kruskal算法,因为我维护了边的序列而且实现了判断两个两个点是否联通的简单方法。这个算法的思想是将所有边按照权重大小排序,从小到大选择依次选择每条边,如果这条边加入最小生成树的答案中没有使当前图出现回路即可。如果这个点所在的连通集有n个点,则根据定义,他的最小生成树一定有n-1条边。优化方法:判断加边后是否出现回路——维护一个并查集,并查集表示和某个点联通的所有点集,只要待加入的边两端的点不在同一个点集内即可;边按照权重排序——维护一个有序的边序列,这部分可以使用二分法找到合适的位置插入。这里给出一个C++经典实现
struct rec {int x, y, z;} edge[MAX];
bool operator <(rec a, rec b) {
return a.z < b.z;
}
int kruskal() {
int ans = 0;
sort(edge + 1, edge + m + 1);
for (int i = 1; i <= n; i++) fa[i] = i;
for (int i = 1; i <= m; i++) {
int x = get(edge[i].x);
int y = get(edge[i].y);
if (x == y) continue;
fa[x] = y;
ans += edge[i].z;
}
return ans;
}
迪杰斯特拉算法(最短路)
sendIndirectMessage这个方法所求的是两个点之间的最短路。求单源最短路这个问题的模型是,给定一张有向图,(x,y,z)表示从点x到点y长度为z的有向边。这个问题中经典的方法有Dijkstra方法,这是一个贪心算法,它适用于所有边都是非负数的图。当边长z都是非负数时,全局最小值不可能被其他点更新,故选出的最短的节点x必然满足:dist[x]已经是起点到x的最短路,不断选择最短路就能对全局所有点更新最短路。这里由于求两个特定点之间的最短路,因此可以使用剪枝方法:如果当前找到的点就是id2,当即退出。这里给出一个经典的C++算法
priority_queue<pair<int, int>> q;
void dijkstra() {
memset(d, 0x3f, sizeof(d));
memset(v, 0, sizeof(v));
d[1] = 0;
q.push(make_pair(0, 1));
while (q.size()) {
int x = q.top().second; q.pop();
if (v[x]) continue;
v[x] = 1;
for (int i = head[x]; i; i = next[i]) {
int y = ver[i], z = edge[i];
if (d[y] > d[x] + z) {
d[y] = d[x] + z;
q.push(make_pair(-d[y], y));
}
}
}
}
这里使用了大根堆,并且插入负数变成小根堆。Java中含有堆容器,但是这里可以使用二分法就如上面一样,无非就是维护一个有序的集合。
性能问题和修复策略
容器使用
JML之提供一个参考的数据存储容器,可以使用ArrayList,也可以根据实际需要选择不同的容器。由于每个人的id不同,我们可以选择HashMap存储每个人的id到Person的关系,来达到快速访问。
Network
private final HashMap<Integer, Person> people;
private final HashMap<Integer, Group> groups;
private int qbsAns = 0;
//private final ArrayList<Message> messages;
private final HashMap<Integer, Message> messages;
//private HashMap<Person, Person> fathers = new HashMap<>();
private final HashMap<Integer, Person> fathers = new HashMap<>();
private final ArrayList<Edge> edges = new ArrayList<>();
private final HashMap<Integer, Integer> emojiMessages = new HashMap<>();
private final HashMap<Integer, Integer> dist = new HashMap<>();
Group
private final int id;
private final HashMap<Integer, Person> people;
private int valueSum = 0;
private int ageSum = 0;
Person
private final int id;
private final String name;
private final int age;
private int socialValue;
private int money;
private final HashMap<Integer, Person> acquaintance; // id - person
private final HashMap<Person, Integer> value; // person - value
private final ArrayList<Message> messages = new ArrayList<>();
代码编写和维护
在代码编写的时候随时维护答案,维护边集有序、维护并查集
例如qbs指令查询当前有多少个联通块,这样可以在,addPerson的时候、addRelation的时候更新qbs的值;
边集有序维护的方法:
使用二分法,找到新边的合适位置,在插入进去,用时O(logn + n)
public static void addEdge(ArrayList<Edge> arrayList, Edge edge) {
int l = 0;
int r = arrayList.size();
int mid;
int value = edge.getValue();
while (l < r) {
mid = (l + r) / 2;
if (value > arrayList.get(mid).getValue()) {
l = mid + 1;
} else {
r = mid;
}
}
arrayList.add(l, edge);
}
并查集维护方法:
//private Person findFather(HashMap<Person, Person> fa, Person person) {
private Person findFather(HashMap<Integer, Person> fa, Person person) {
if (fa.get(person.getId()) == person) {
return person;
} else {
Person father = findFather(fa, fa.get(person.getId()));
//fa.put(person, father);
fa.put(person.getId(), father);
return father;
}
}
Network 扩展
扩展要求
假设出现了几种不同的Person
-
Advertiser:持续向外发送产品广告
-
Producer:产品生产商,通过Advertiser来销售产品
-
Customer:消费者,会关注广告并选择和自己偏好匹配的产品来购买 -- 所谓购买,就是直接通过Advertiser给相应Producer发一个购买消息
-
Person:吃瓜群众,不发广告,不买东西,不卖东西
如此Network可以支持市场营销,并能查询某种商品的销售额和销售路径等 请讨论如何对Network扩展,给出相关接口方法,并选择3个核心业务功能的接口方法撰写JML规格(借鉴所总结的JML规格模式)
扩展方法
框架:Advertiser、Producer和Customer继承自Person;增加Advertisement和Product,继承自Message;Customer中增加偏好,偏好用一个32位的数表示,每一位代表一种爱好;Product中同样包含着一个32位的int类型的数,表示产品的具有的属性。当产品的属性和顾客的偏好有交集时,顾客会购买产品。
增加异常:
方法1:Advertiser发送广告
/*
@ public normal_behavior
@ requires (\exists int i; 0 <= i && i < people.length; people[i].getId() == id && people[i] instanceof Advertiser);
@ ensures getPerson(id).advertisements.length == \old(getPerson(id).advertisements.length) - 1;
@ ensures (\forall int i; 0 <= i && i < people.length; (getPerson(id).isLinked(people[i])) ==> (people[i].messages.length == \old(people[i].messages.length) + 1 && people[i].messages[0] == \old(getPerson(id).advertisements[0]) && (\forall int j; 1 <= j && j < people[i].messages.length; people[i].messages[j] == \old(people[i].messages[j - 1]))));
@ ensures (\forall int i; 0 <= i && i < people.length; !(getPerson(id).isLinked(people[i])) ==> (people[i].messages.length == \old(people[i].messages.length && (\forall int j; 0 <= j && j < people[i].messages.length; people[i].messages[j] == \old(people[i].messages[j]))));
@ public exceptional_behavior
@ signals (PersonIdNotFoundException e) (\forall int i; 0 <= i && i < people.length; people[i].getId() != id || (people[i].getId() == id && !people[i] instanceof Advertiser));
*/
public void sendAdvertisement(int id) throws PersonIdNotFoundException;
方法2:查询销量
/*
@ public normal_behavior
@ requires (\exists int i; 0 <= i && i < people.length; people[i].getId() == id && people[i] instanceof Producer);
@ requires (\exists int i; 0 <= i && i < getPerson(id).products.length; getPerson(id).products[i].getId() == productId);
@ \results == getPerson(id).getProduct(productId).money * (\sum int i; 0 <= i && i < people.length; (\exists int j; 0 <= j && j < people[i].products.length; prople[i].products[j].equals(getPerson(id).getProduct(productId))));
@ public exceptional_behavior
@ signals (PersonIdNotFoundException e) !(\exists int i; 0 <= i && i < people.length; people[i].getId() == id && people[i] instanceof Producer)
@ public exceptional_behavior
@ signals (ProductNotFoundException e) !(\exists int i; 0 <= i && i < getPerson(id).products.length; getPerson(id).products[i].getId() == productId);
*/
public int querySalaryValue(int id, int productId) throws PersonIdNotFoundException, ProductIdNotFoundException;
方法3:顾客根据广告购买产品
/*
@ public normal_behavior
@ requires (\exists int i; 0 <= i && i < people.length; people[i].getId() == id && people[i] instanceof Customer);
@ requires (\forall int i; 0 <= i && i < getPerson(id).advertisemens.length; getPerson(id).money < advertisements[i].money || getPerson(id).preferInfo & advertisements[i].productInfo == 0 || (\exists int j; 0 <= j && j < \old(getPerson(id).products.length); \old(getPerson(id).products[j]).equals(getPerson(id).advertisements[i].product)));
@ requires \old(getPerson(id).money) == getPerson(id).money + (\sum int i; 0 <= i && i < \old(getPerson(id).advertisements.length); (getPerson(id).advertisement.contains(\old(getPerson(id).advertisements[i]))) ==> \old(getPerson(id).advertisements[i]).money);
@ signals (PersonIdNotFoundExeception e) !(\exists int i; 0 <= i && i < people.length; people[i].getId() == id && people[i] instanceof Customer);
*/
public void purchase(int id) throws PersonIdNotFoundException, ProductIdNotFoundException;
学习体会
规格化设计:本单元中主要学习了什么是规格化设计和契约式设计。规格化设计就是定义一个开发人员必须遵守的规约。对于一个类来说,类包含数据规格和方法规格。
-
数据规格:类所管理的数据内容,及其有效性条件(invariant,constraint)
-
invariant:任何时刻数据内容都必须满足的约束条件
-
constraint:任何时刻对于数据内容的修改都必须满足的条件
-
-
方法规格:类所规定的操作,权利 + 义务 + 注意事项,包括前置条件 + 后置条件 + 副作用
-
前置条件:规定了开发人员可以依赖的初始条件
-
后置条件:开发人员必须保证的需求满足结果
-
规格化设计中,比较重要的是契约式编程,这部分可以看附录B
基于JML的规格模式:规格是对于数据和方法方方面面的约束,规格模式是对于规格的设计。在这方面JML是一种规格设计的模型语言。
-
JML定义了许多描述规格的方法和语法,具体内容可以参考附录C
-
JML写好了可以清楚明白的传递规格的定义和编写者思想,没有自然语言的二义性,因为一切条件和限制都清清楚楚的写到规格定义中了,通过阅读JML任何人都可以或者规格的定义,即使一个人最开始不知道什么是“最短路”,什么是”连通图“,通过反复阅读JML最终也能够理解;但是JML同样也有缺点,就是比较复杂,这一点从助教团队不断的修改指导书也能看出来,刚刚Network扩展任务中我也感受到了这一点,短短几句话就能说明白的事情,往往需要写很多JML。
-
而且我认为JML具有一种依赖性,它把自然语言和抽象的规格说明分割开了。用自然语言说明一个方法的时候,可能暗含一些条件,不用说大家都能明白的那种;但是在JML中,这些应说未说的条件需要全部写出来,这对于编写者是一个挑战,因为一些理所应当、稀疏平常的条件不太容易一下子想起来。当阅读者阅读到JML的时候,又会切断自然思考的模式,就着JML推测编写者的含义,这时候如果一些条件没写出来,很容易出现读者”较真“的情况,或者在讨论区问一些看似没有必要问的问题,这一点在三四单元都很容易出现,所以第四单元助教甚至开了一个精华帖子。这种”较真“的情况恰恰说明了JML隔绝了人们使用自然语言的思考。
附录A JUnit 测试方法
-
确定我们要实现的契约
require
count <= capacity
not key.empty
ensure
has (x)
item (key) = x
count = old count + 1
invariant
0 <= count
count <= capacity -
确定 JUnit 测试点
invariant count <= capacity
invariant 0 <= count