buaa面向对象第三单元

面向对象设计与构造第三单元

JML简介

JML(Java Modeling Language)是用于对Java程序进行规格化设计的一种表示语言。规范的JML语言描述了正确的Java程序的功能性要求,但具体如何实现,以及实现的性能如何就交给了开发人员了。

JMLjavadoc注释的方式来表示规格,有行注释和块注释两类。行注释以//@开头;块注释以/@开头,而每一行又以@开头。

一个完整的方法规格包括正常行为normal_behavior和异常行为exceptional_behavior。多个行为之间用also语句联系起来。

正常行为下定义的规格可包含前置条件、后置条件和副作用范围限定。

1、前置条件:通过requires子句表示,后跟逻辑表达式A,表示该方法的这一分支行为执行前要求满足“A为真”。

2、后置条件:通过ensures子句表示,后跟逻辑表达式B,表示该方法的这一分支行为执行后保证满足“B为真”。

3、副作用范围限定:使用assignablemodifiable,后跟变量列表或者谓词\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课程组要让我们学这样一个已经处于淘汰边缘的规格,可能这就是世一大的实力吧。
  • 这一单元课程组又有很多入典言论:《对算法的要求不高》《大家不要卷错了方向》。10000条指令的数据规模摆在那里,又不给出具体的数据范围,每种指令条数的具体范围,却在那里说《大家不要卷错了方向》,这实在是与教育部说不希望中学生学习压力过大有异曲同工之妙,中学生不卷难道教育部给他们分配大学名额吗?我们不卷性能,这门课程的分数关系到GPA和保研名额,你们课程组会给我们送分吗?不过这也的确体现出自然语言描述的歧义性,课程组说对算法要求不高,但是这个“不高”无法量化,同学们并不知道这个“不高”到底是到什么程度,为了最大限度保证拿到分数,只能去卷性能。
  • 如果课程组真心不想同学们卷性能,而是把心思放到规格学习上吗,其实这也很好解决,每到题目把强测数据点的详细数据范围公之于众就可以了。
  • 所以这个单元要想保证处理完10000条指令而不超时是需要用到很多算法知识的,后文我们就详细探讨一下这个单元作业中用到的算法。如果能完全应用下文提到的算法,那么才可以保证在10000条指令内绝不超时。
  • 首先需要指出的是我们在评估这次作业的时间复杂度的时候应该从指令条数入手分析,不需要纠结于更不能纠结于图算法中nm的规模,因为nm同时受到指令条数的制约,因此对着他们来分析毫无意义,如果能完全应用下文提到的算法,可以在O(n2log2n)内处理所有指令,其中n代表输入指令条数。

并查集(用于维护不相交集合的森林)

基本介绍

并查集是用森林维护若干个互不相交的集合,在每个集合之中选择一个元素作为代表元,以这个代表元作为树根,同个集合中的其他元素都是此元素的后代结点。并查集有两个基本的操作,查询x所在集合的代表元FIND(x),合并以x、y为代表元的两个集合LINK(x,y)。我们需要记录每个结点在森林中的父亲结点x.fa,如果某个结点就是一棵树的根,那么我们约定这个结点的父结点就是他自己。对于LINK(x,y)我们只需要简单的把x.fa修改为y或者把y.fa修改为x就可以完成维护,对于FIND(x)我们需要不停的向上遍历查找x.fa直到x.fa==x那么这时的x就是要找的代表元。下面来看一个示例:

初始状态:

1

2

3

4

5

LINK(1,2):

fa

1

2

3

4

5

LINK(1,3):

fa

fa

1

2

3

4

5

LINK(4,5):

fa

fa

fa

1

2

3

4

5

LINK(1,4):

fa

fa

fa

fa

1

2

3

4

5

性能调优

如果是不做任何优化的基础版并查集,很可能会在LINK的过程中出现所有结点被连接成一条链的情况,这样如果调用FIND操作的开销是非常大的,单次操作的时间复杂度很可能会退化成O(n)。所有我们提出以下两种启发式的优化策略。

按秩合并

优化方法

注意到,FIND操作的时间复杂度瓶颈在于结点到根结点路径的长度,于是我们可以在维护并查集的同时额外维护每个结点所在子树的深度而这个深度我们称为x的秩x.rank(为了方便起见,这篇文章中的rank都是从0开始),这样每次LINK的时候我们可以主动将rank小的结点接在rank大的结点下面,这样最坏情况下要优于将rank大的结点接在rank小的结点下面。

性能分析

那么我们这么做会有怎样的优化效果呢,我们有如下定理:在任意时刻,任意结点x,有x.size2x.rank。其中x.size表示x所在子树的结点数量。证明如下:

  • 首先对于初始状态显然成立。
  • FIND操作不影响ranksize
  • 在执行了一次LINK(x,y)操作后,不失一般性,假设我们使x.fa变为y,那么有x.ranky.rank,且这次操作不影响xsizerank。考虑y的变化:
    • x.rank<y.rank,那么y.rank不变,y.size增大,y.size2y.rank继续成立。
    • x.rank=y.rank,那么y.rank增加1,在操作之前有x.size2y.ranky.size2y.rank,故x.size+y.size2y.rank+1原式继续成立。

因此单次操作的上界是O(log2n)容易证明这个上界是紧的。

路径压缩

优化方法

在一次FIND(x)操作中。我们会有一条向上查找直到根的路径,可以将路径上所有点的父结点都直接修改成根结点。

性能分析

我们都知道路径压缩加按秩合并的并查集有一个喜闻乐见的复杂度就是O(qα(n))其中α(n)增长十分缓慢,在实际应用中我们可以认为α(n)4q是操作次数。下面我们来看看这个结论是怎么来的。

一些约定

由于并查集的不同写法有细节上的差别会导致分析的过程或结果不同,我们在进行分析之前约定并查集按如下方式实现:

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
一个增长非常快的函数及其反函数

我们先定义如下函数以及他的反函数,然后观察一下他的增长速率。

A(k,n)={n+1,k=0A(n+1)(k1,n),k0

其中A(n+1)(k1,n)=A(k1,A(n)(k1,n))在本文中我们约定以这种记号来表示函数迭代。

α(n)的定义如下:α(n)=min{k|A(k,1)n}

我们容易证明下面的式子:

A(0,n)=n+1
A(1,n)=A(n+1)(0,n)=2n+1
A(2,n)=A(n+1)(1,n)=(n+1)2n+11
A(3,1)=A(2,A(2,1))=A(2,7)=2047
A(4,1)=A(3,2047)A(2,2047)=220591

可以看到A(4,1)已经远远超过正常的数据范围,因此我们可以认为 α(n)4

进行势能分析

我们用摊还分析中的势能技术来证明O(qα(n))是按秩合并和路径压缩并查集时间复杂度的一个上界。

我们把个时刻的森林都映射到一个实数上,用Φq表示第q次操作后森林映射到的实数,这个映射关系称为森林的势函数。我们对每个森林中的结点x也定义一个势函数ϕq(x)Φq=xϕq(x)

sq表示第q次操作的实际代价,那么(1)q=1msq+ΦqΦq1=ΦmΦ0+q=1msq
假如ΦmΦ0那么(1)式左边部分就给出了一个时间复杂度的上界,我们只需要考虑每次操作引起的势能变化即可,这就是摊还分析中的势能技术。

为了对并查集进行分析,我们还需要定义如下两个函数,这两个函数的定义域为不是根结点且rank1的所有结点:
lv(x)=max{k|A(k,x.rank)x.fa.rank}
it(x)=max{k|A(k)(lv(x),x.rank)x.fa.rank}

然后我们定义ϕq(x)

ϕq(x)={x.rankα(n),xx.rank=0x.rank(α(n)lv(x))it(x),xx.rank1

  • 性质1:结点的rank有一个显而易见的性质,就是x.rank单调递增,在xx.fa之后保持不变,x.fa.rank单调递增,且有x.rankx.fa.rank,在xx.fa时不等式严格成立。
  • 定理10lv(x)<α(n)
    • 证明:A(0,x.rank)=x.rank+1x.fa.rank所以有0lv(x)

      A(α(n),x.rank)A(α(n),1)=n>x.fa.rank

      所以有lv(x)<α(n)
  • 定理21it(x)x.rank
    • 证明:A(lv(x),x.rank)x.fa.rank所以有1it(x)

      A(x.rank)(lv(x),x.rank)=A(lv(x)+1,x.rank)>x.fa.rank

      所以有it(x)x.rank
  • 定理3:若x不是根结点,则ϕq1(x)ϕq(x)且若第q次操作改变了lv(x)it(x),则ϕq1(x)ϕq(x)1
    • 证明:
      • x.rank=0,则ϕq(x)不变。
      • lv(x)变化,it(x)不变,则Δϕq(x)x.rank1。这是因为lv(x)单调递增
      • lv(x)不变,it(x)变化,则Δϕq(x)1
      • lv(x)变化,it(x)变化,则Δϕq(x)x.rank+(x.rank1)1
  • 定理4LINK(x,y)的摊还代价是O(α(n))
    • 证明:
      • 此操作的真实代价是O(1)的。
      • 不失一般性,假设操作的结果是令x.fa变为y
      • 势能发生变化的只有xy以及操作前y的子结点。
      • 由定理3我们知道操作前y的子结点的势能不会增加。由势能函数的定义我们知道x的势能会减少。
      • 一次LINK最多使y.rank增加1,因此y的势能会增加O(α(n))
      • 综上所述,LINK(x,y)的摊还代价是O(α(n))的。
  • 定理5FIND(x)的摊还代价是O(α(n))的。
    • 证明:
      • 假设x到根结点的路径长度为s,即此次操作的真实代价是O(s)的。
      • 我们考虑这样一个结点x0,他的rank1,并且在他的祖先结点中(不包括根结点)存在一个结点y,满足lv(x)=lv(y)
      • y.fa.rankA(lv(y),y.rank)A(lv(x),x.fa.rank)A(lv(x),A(it(x))(lv(x),x.rank))=Ait(x)+1(lv(x),x.rank)

        由于路径压缩我们知道x.fa.rank=y.fa.rank
        所以由定理3我们知道x的势能至少下降1
      • 这样的xmax{0,sα(n)2}个,除了路径上的第一个结点、最后一个结点以及路径上lv=0,1,,α(n)1的最后一个结点。
      • 因此这次操作之后,Φ至少下降max{0,sα(n)2},因此摊还代价是O(α(n))的。

至此,我们证明了O(qα(n))是按秩合并和路径压缩并查集时间复杂度的一个上界。

在本单元中的应用

  • query_circlequery_block_sum:查询无向图中两个点是否连通,以及查询图中连通块的数量。直接用并查集维护点的连通关系,每一次连边(a,b)操作先进行FIND(a)FIND(b)判断他们是否属于一个连通块,若不属于,则进行LINK,这时连通块数量会减一。
  • modify_relation:删边操作,并查集不能直接维护删边操作,遇到删边(a,b)的情况,我们需要一定程度的重构并查集,删边之后,从a开始dfs,把dfs得到的点的fa设置成a,对b也做相同的操作,同时注意维护连通块数量。

单源最短路径dijkstra算法

算法实现

我们维护源点s到每个点x的最短路径上界,记为dist(s,x),记mdis(s,x)sx的最短路径。初始时,把图中所有点都加入到集合S当中,令所有除s外的结点的dist(s,x)=inf,令dist(s,s)=0,创建一个空集合T

接下来每一次,从集合S中选取dist(s,x)最小的x,将x加入集合T,并从集合S中删除x,加入集合T即表示mdis(s,x)已经求得。然后对与结点x有连边的所有结点t都进行一次松弛操作,即令dist(s,t)=min{dist(s,t),dist(s,x)+w(x,t)}w(x,t)即表示xt这条边的权值。

直到集合S是空集,退出算法,s到所有结点的最短路径都已经求出。

算法正确性

我们证明每次从集合S中选取的dist(s,x)最小的x都满足dist(s,x)=mdis(s,x)

首先说明一个显而易见的性质,如果点x有一条最短路径{s,u1,u2,,um,x},那么,对任意1km,满足路径{s,u1,u2,,uk}是点uk的一条最短路径。

假设某一次从集合S中选取的点dist(s,x)mdis(s,x)且这是第一次出现这种情况。这说明存在一条路径满足mdis(s,x)=length<dist(s,x),这条路径上必然包含点yS/{x},我们假设y是第一个这样的点,设他在路径上的前驱是zzTz在加入T时肯定对y做了一次松弛操作,根据我们前面所说的性质,可以知道dist(s,y)=mdis(s,y)<dist(s,x),而又根据我们选取x的规则,dist(s,y)dist(s,x)这就出现了矛盾,因此我们可以证明算法的正确性。

另外值得一提的是,通过上面的证明过程我们发现,dijkstra只能适用于边权非负的情况,如果出现负权边则其不再适用,可以参考下面的简单例子:

-1

2

-5

1

2

3

性能优化

dijkstra的复杂度瓶颈在于寻找最小值的速度,如果每次通过遍历集合S来寻找最小值则效率是很低的。同意想到找最小值的过程我们可以用堆来实现,每次松弛操作之后就把得到的新值加入小根堆中,寻找dist最小的点就可以通过取堆顶元素来实现。完整的堆优化代码如下:

    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:查询经过给定结点s的最小环的长度。

  • 一个朴素的想法是,枚举s的所有连边,把这条边删掉,然后求出s到这条边的另一个端点的最短路径,再加上删掉的边长就得到了过指定边的最小环大小,这样把s的所有连边都遍历一遍就能最终得出最小环。但是这种方法需要做多遍dij十分的低效,于是我们有了下面的进阶算法。
  • 观察dij的执行过程,我们可以发现,源点到所有点的最短路径构成一个树形结构,我们称之为最短路树。构造最小环我们很容易想到通过最短路径去构造,最短路树上的路径都是最短路径但他们显然无法构成环,因此我们还至少需要一条非树边。事实上,最小环上最多也只有一条非树边,给出一个简单的证明:如果存在一个最小环有多条非树边,那么这些非树边当中必然有一条(u,v)沟通s两个不同的子树,我们用sv的树上路径和su的树上路径加上(u,v)来组成一个新环,这个新环一定不会比原环更长。因此我们可以通过遍历所有的非树边来求解最小环,这样我们就只需要一次dij就可以完成。

最短路树:

1

3

2

1

3

5

4

1

2

4

5

3

6

示例代码:

    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算法是用于求单源最短路径的,但其实这是因为我们关心源点是谁,如果我们不关心源点是谁的话他也可以用于求一类多源最短路径。比如把最小环问题推广到有向图上,那么我们第一步可以将s所有出边指向的点的dist设置为这条边长,然后将dist(s,s)设置成inf,之后就按照正常dij的流程做下去,最后实际上就相当于求了这些点到s的多源最短路径,就是最小环的答案。

动态维护思想

动态维护思想精髓在于对于某个特定问题,我们不需要在每次询问时都重新求解,而是关注可能改变问题解的操作,计算这些操作对答案的影响。在本次作业中三元环计数问题就需要用这个思想解决。

query_triple_sum三元环计数,影响三元环数量的只有加边和删边操作,改变的边是(u,v)则新增/新减的三元环必然包含uv我们只需要枚举与u有连边的点集合与v有连边的点集的交集即可。

posted @   clapp  阅读(163)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· Manus爆火,是硬核还是营销?
· 一文读懂知识蒸馏
· 终于写完轮子一部分:tcp代理 了,记录一下
点击右上角即可分享
微信分享提示