buaa面向对象第三单元
面向对象设计与构造第三单元
JML简介
JML(Java Modeling Language)是用于对Java
程序进行规格化设计的一种表示语言。规范的JML
语言描述了正确的Java
程序的功能性要求,但具体如何实现,以及实现的性能如何就交给了开发人员了。
JML
以javadoc
注释的方式来表示规格,有行注释和块注释两类。行注释以//@
开头;块注释以/@
开头,而每一行又以@
开头。
一个完整的方法规格包括正常行为normal_behavior
和异常行为exceptional_behavior
。多个行为之间用also
语句联系起来。
正常行为下定义的规格可包含前置条件、后置条件和副作用范围限定。
1、前置条件:通过requires
子句表示,后跟逻辑表达式A,表示该方法的这一分支行为执行前要求满足“A为真”。
2、后置条件:通过ensures
子句表示,后跟逻辑表达式B,表示该方法的这一分支行为执行后保证满足“B为真”。
3、副作用范围限定:使用assignable
和modifiable
,后跟变量列表或者谓词\nothing
, \everything
,表示在该方法的这一分支行为执行过程中所罗列的变量是允许修改的。
这就相当于开发者与调用者之间签订的契约。调用者使用该方法时必须满足开发者所要求的前置条件,而开发者所构造的函数必须满足调用者所提出的后置条件。
在写前置条件、后置条件时,可能还会用到如下关键词:
1、\result
:表示对于函数返回值。
2、\old(a)
:表示方法行为执行前的变量或参数a的值。
3、\exists v; exp1; exp2
:存在量词语句,表示存在满足条件exp1的变量v,满足条件exp2。
4、\forall v; exp1; exp2
:全称量词语句,表示所有满足条件exp1
的变量v
,都满足条件exp2
。
异常行为下定义的规格通常使用关键词signals
描述的语句来实现:
signals (Excepion e) (exp1)
软件测试方法
- 黑盒测试。之所以被称为黑盒测试是因为可以将被测程序看成是一个无法打开的黑盒,而工作人员在不考虑任何程序内部结构和特性的条件下,根据需求规格说明书设计测试实例,并检查程序的功能是否能够按照规范说明准确无误的运行。对于黑盒测试行为必须加以量化才能够有效的保证软件的质量。
- 白盒测试。其与黑盒测试不同,它主要是借助程序内部的逻辑和相关信息,通过检测内部动作是否按照设计规格说明书的设定进行,检查每一条通路能否正常工作。白盒测试是从程序结构方面出发对测试用例进行设计。其主要用于检查各个逻辑结构是否合理,对应的模块独立路径是否正常以及内部结构是否有效。
- 单元测试。单元测试主要是对该软件的模块进行测试,通过测试以发现该模块的实际功能出现不符合的情况和编码错误。由于该模块的规模不大,功能单一,结构较简单,且测试人员可通过阅读源程序清楚知道其逻辑结构,首先应通过静态测试方法,比如静态分析、代码审查等,对该模块的源程序进行分析,按照模块的程序设计的控制流程图,以满足软件覆盖率要求的逻辑测试要求。另外,也可采用黑盒测试方法提出一组基本的测试用例,再用白盒测试方法进行验证。若用黑盒测试方法所产生的测试用例满足不了软件的覆盖要求,可采用白盒法增补出新的测试用例,以满足所需的覆盖标准。
- 集成测试。集成测试通常要对已经严格按照程序设计要求和标准组装起来的模块同时进行测试,明确该程序结构组装的正确性,发现和接口有关的问题,集成测试具有承上启下的作用。在这一阶段,一般采用的是白盒和黑盒结合的方法进行测试,验证这一阶段设计的合理性以及需求功能的实现性。
bug修复
在本单元第一次作业当中出现了OKTEST的错误,其余两次作业均未出现问题。
心得体会
- JML规格的理论学习十分容易,语法要求只有一点点内容,很快就可以看完(所以剩下的时间都用来卷性能分了bushi)。所以不是很理解为什么要花整整一个月时间来学JML,不如缩短时间或者干脆踢出OO课程,多出来的时间多学点别的跟面向对象更加相关的内容。
- JML规格虽然可以使得对于软件功能的要求变得更加严谨,消除了歧义,但是瑜不掩瑕,在实现稍微复杂的功能的描述的时候十分臃肿,可读性极差,开发人员需要花大量的时间阅读规格理解规格,而事实上,JML规格已经几乎被时代淘汰,在网络上几乎无法搜索到相关资料,除了你航的OO博客。很难理解为什么你航OO课程组要让我们学这样一个已经处于淘汰边缘的规格,可能这就是世一大的实力吧。
- 这一单元课程组又有很多入典言论:《对算法的要求不高》《大家不要卷错了方向》。
条指令的数据规模摆在那里,又不给出具体的数据范围,每种指令条数的具体范围,却在那里说《大家不要卷错了方向》,这实在是与教育部说不希望中学生学习压力过大有异曲同工之妙,中学生不卷难道教育部给他们分配大学名额吗?我们不卷性能,这门课程的分数关系到GPA和保研名额,你们课程组会给我们送分吗?不过这也的确体现出自然语言描述的歧义性,课程组说对算法要求不高,但是这个“不高”无法量化,同学们并不知道这个“不高”到底是到什么程度,为了最大限度保证拿到分数,只能去卷性能。 - 如果课程组真心不想同学们卷性能,而是把心思放到规格学习上吗,其实这也很好解决,每到题目把强测数据点的详细数据范围公之于众就可以了。
- 所以这个单元要想保证处理完
条指令而不超时是需要用到很多算法知识的,后文我们就详细探讨一下这个单元作业中用到的算法。如果能完全应用下文提到的算法,那么才可以保证在 条指令内绝不超时。 - 首先需要指出的是我们在评估这次作业的时间复杂度的时候应该从指令条数入手分析,不需要纠结于更不能纠结于图算法中
的规模,因为 同时受到指令条数的制约,因此对着他们来分析毫无意义,如果能完全应用下文提到的算法,可以在 内处理所有指令,其中 代表输入指令条数。
并查集(用于维护不相交集合的森林)
基本介绍
并查集是用森林维护若干个互不相交的集合,在每个集合之中选择一个元素作为代表元,以这个代表元作为树根,同个集合中的其他元素都是此元素的后代结点。并查集有两个基本的操作,查询FIND(x)
,合并以x、y
为代表元的两个集合LINK(x,y)
。我们需要记录每个结点在森林中的父亲结点x.fa
,如果某个结点就是一棵树的根,那么我们约定这个结点的父结点就是他自己。对于LINK(x,y)
我们只需要简单的把x.fa
修改为y.fa
修改为FIND(x)
我们需要不停的向上遍历查找x.fa
直到x.fa==x
那么这时的x
就是要找的代表元。下面来看一个示例:
初始状态:
LINK(1,2)
:
LINK(1,3)
:
LINK(4,5)
:
LINK(1,4)
:
性能调优
如果是不做任何优化的基础版并查集,很可能会在LINK
的过程中出现所有结点被连接成一条链的情况,这样如果调用FIND
操作的开销是非常大的,单次操作的时间复杂度很可能会退化成
按秩合并
优化方法
注意到,FIND
操作的时间复杂度瓶颈在于结点到根结点路径的长度,于是我们可以在维护并查集的同时额外维护每个结点所在子树的深度而这个深度我们称为x
的秩x.rank
(为了方便起见,这篇文章中的rank
都是从LINK
的时候我们可以主动将rank
小的结点接在rank
大的结点下面,这样最坏情况下要优于将rank
大的结点接在rank
小的结点下面。
性能分析
那么我们这么做会有怎样的优化效果呢,我们有如下定理:在任意时刻,任意结点
- 首先对于初始状态显然成立。
FIND
操作不影响rank
和size
- 在执行了一次
LINK(x,y)
操作后,不失一般性,假设我们使 变为 ,那么有 ,且这次操作不影响 的 和 。考虑 的变化: ,那么 不变, 增大, 继续成立。 ,那么 增加 ,在操作之前有 ,故 原式继续成立。
因此单次操作的上界是
路径压缩
优化方法
在一次FIND(x)
操作中。我们会有一条向上查找直到根的路径,可以将路径上所有点的父结点都直接修改成根结点。
性能分析
我们都知道路径压缩加按秩合并的并查集有一个喜闻乐见的复杂度就是
一些约定
由于并查集的不同写法有细节上的差别会导致分析的过程或结果不同,我们在进行分析之前约定并查集按如下方式实现:
FIND(x):
IF x.fa == x
return x
x.fa = FIND(x.fa)
return x.fa
LINK(x,y):
IF x.rank < y.rank
x.fa = y
ELSE
y.fa = x;
if x.rank == y.rank
y.rank++
初始化:
for all x
x.fa = x
x.rank = 0
一个增长非常快的函数及其反函数
我们先定义如下函数以及他的反函数,然后观察一下他的增长速率。
其中
而
我们容易证明下面的式子:
可以看到
进行势能分析
我们用摊还分析中的势能技术来证明
我们把个时刻的森林都映射到一个实数上,用
令
假如
为了对并查集进行分析,我们还需要定义如下两个函数,这两个函数的定义域为不是根结点且
然后我们定义
- 性质
:结点的 有一个显而易见的性质,就是 单调递增,在 之后保持不变, 单调递增,且有 ,在 时不等式严格成立。 - 定理
:- 证明:
所以有所以有
- 证明:
- 定理
:- 证明:
所以有所以有
- 证明:
- 定理
:若 不是根结点,则 且若第 次操作改变了 或 ,则 。- 证明:
,则 不变。 变化, 不变,则 。这是因为 单调递增 不变, 变化,则 变化, 变化,则
- 证明:
- 定理
:LINK(x,y)
的摊还代价是 的- 证明:
- 此操作的真实代价是
的。 - 不失一般性,假设操作的结果是令
变为 - 势能发生变化的只有
以及操作前 的子结点。 - 由定理
我们知道操作前 的子结点的势能不会增加。由势能函数的定义我们知道 的势能会减少。 - 一次
LINK
最多使 增加 ,因此 的势能会增加 。 - 综上所述,
LINK(x,y)
的摊还代价是 的。
- 此操作的真实代价是
- 证明:
- 定理
:FIND(x)
的摊还代价是 的。- 证明:
- 假设
到根结点的路径长度为 ,即此次操作的真实代价是 的。 - 我们考虑这样一个结点
,他的 ,并且在他的祖先结点中(不包括根结点)存在一个结点 ,满足 -
由于路径压缩我们知道
所以由定理 我们知道 的势能至少下降 - 这样的
有 个,除了路径上的第一个结点、最后一个结点以及路径上 的最后一个结点。 - 因此这次操作之后,
至少下降 ,因此摊还代价是 的。
- 假设
- 证明:
至此,我们证明了
在本单元中的应用
query_circle
和query_block_sum
:查询无向图中两个点是否连通,以及查询图中连通块的数量。直接用并查集维护点的连通关系,每一次连边(a,b)操作先进行FIND(a)
和FIND(b)
判断他们是否属于一个连通块,若不属于,则进行LINK
,这时连通块数量会减一。modify_relation
:删边操作,并查集不能直接维护删边操作,遇到删边(a,b)的情况,我们需要一定程度的重构并查集,删边之后,从a开始dfs,把dfs得到的点的fa设置成a,对b也做相同的操作,同时注意维护连通块数量。
单源最短路径dijkstra
算法
算法实现
我们维护源点
接下来每一次,从集合
直到集合
算法正确性
我们证明每次从集合
首先说明一个显而易见的性质,如果点
假设某一次从集合
另外值得一提的是,通过上面的证明过程我们发现,dijkstra
只能适用于边权非负的情况,如果出现负权边则其不再适用,可以参考下面的简单例子:
性能优化
dijkstra
的复杂度瓶颈在于寻找最小值的速度,如果每次通过遍历集合
private static void dij(MyPerson p, MyNetwork net) {
PriorityQueue<Pair<Integer, Integer>> heap = new PriorityQueue<>(
Comparator.comparingInt(Pair::getKey));
DIS.clear();
FA.clear();
heap.add(new Pair<>(0, p.getId()));
DIS.put(p.getId(), 0);
while (!heap.isEmpty()) {
assert heap.peek() != null;
int yu = heap.peek().getValue();
assert heap.peek() != null;
int d = heap.peek().getKey();
heap.remove(heap.peek());
if (DIS.get(yu) < d) {
continue;
}
DIS.put(yu, d);
HashMap<MyPerson, Integer> nnd = ((MyPerson) net.getPerson(yu)).getAcquaintance();
for (MyPerson pp : nnd.keySet()) {
if (!DIS.containsKey(pp.getId()) || (DIS.get(pp.getId()) > d + nnd.get(pp))) {
FA.put(pp.getId(), yu);
DIS.put(pp.getId(), d + nnd.get(pp));
heap.add(new Pair<>(d + nnd.get(pp), pp.getId()));
}
}
}
}
在本次作业中的应用
query_least_moment
:查询经过给定结点
- 一个朴素的想法是,枚举
的所有连边,把这条边删掉,然后求出 到这条边的另一个端点的最短路径,再加上删掉的边长就得到了过指定边的最小环大小,这样把 的所有连边都遍历一遍就能最终得出最小环。但是这种方法需要做多遍 十分的低效,于是我们有了下面的进阶算法。 - 观察
的执行过程,我们可以发现,源点到所有点的最短路径构成一个树形结构,我们称之为最短路树。构造最小环我们很容易想到通过最短路径去构造,最短路树上的路径都是最短路径但他们显然无法构成环,因此我们还至少需要一条非树边。事实上,最小环上最多也只有一条非树边,给出一个简单的证明:如果存在一个最小环有多条非树边,那么这些非树边当中必然有一条 沟通 两个不同的子树,我们用 到 的树上路径和 到 的树上路径加上 来组成一个新环,这个新环一定不会比原环更长。因此我们可以通过遍历所有的非树边来求解最小环,这样我们就只需要一次 就可以完成。
示例代码:
private static void calBelong(int id, int opt) {
BELONG.put(id, opt);
if (TREE.get(id) != null) {
for (int i : TREE.get(id)) {
calBelong(i, opt);
}
}
}
public static int find(MyPerson p, MyNetwork net) {
dij(p, net);
TREE.clear();
for (int i : DIS.keySet()) {
if (i == p.getId()) {
continue;
}
TREE.computeIfAbsent(FA.get(i), k -> new ArrayList<>());
TREE.get(FA.get(i)).add(i);
}
for (int i : TREE.get(p.getId())) {
calBelong(i, i);
}
int ans = INF;
for (int i : p.getId2val().keySet()) {
if (i == p.getId() || FA.get(i) == p.getId()) {
continue;
}
ans = Integer.min(ans, DIS.get(i) + p.getId2val().get(i));
}
for (int i : DIS.keySet()) {
if (i == p.getId()) {
continue;
}
for (int j : ((MyPerson) net.getPerson(i)).getId2val().keySet()) {
if (Objects.equals(BELONG.get(i), BELONG.get(j)) || FA.get(i) == j) {
continue;
}
int w = ((MyPerson) net.getPerson(i)).getId2val().get(j);
ans = Integer.min(ans, DIS.get(i) + DIS.get(j) + w);
}
}
return ans;
}
小trick
我们都知道dijkstra
算法是用于求单源最短路径的,但其实这是因为我们关心源点是谁,如果我们不关心源点是谁的话他也可以用于求一类多源最短路径。比如把最小环问题推广到有向图上,那么我们第一步可以将
动态维护思想
动态维护思想精髓在于对于某个特定问题,我们不需要在每次询问时都重新求解,而是关注可能改变问题解的操作,计算这些操作对答案的影响。在本次作业中三元环计数问题就需要用这个思想解决。
query_triple_sum
三元环计数,影响三元环数量的只有加边和删边操作,改变的边是
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· Manus爆火,是硬核还是营销?
· 一文读懂知识蒸馏
· 终于写完轮子一部分:tcp代理 了,记录一下