数据结构(上)

135 阅读59分钟

数据结构与算法概述

什么是数据结构?

数据结构介绍

简而言之,数据结构就是把数据元素按照一定关系组织起来的集合,用于组织和存储数据

数据结构总分为逻辑结构和物理结构两大类

逻辑结构

逻辑结构是从具体问题中抽象出来的模型,是抽象意义上的结构,按照对象中数据元素之间的相互关系进行分类的

逻辑结构又分为集合结构,线性结构,树形结构以及图形结构

集合结构

集合结构中的元素除了同属于一个集合之外,他们之间没有任何联系

线性结构

线性结构中的数据元素存在一对一的关系,具体到下图的例子里是一对应着二,二对应着三,三对应着四

树形结构

树形结构中的数据元素存在着一对多的层次关系,具体到下图的例子是A对于B,C,D,而B对应E,F,G

图形结构

图形结构的数据元素存在着多对多的关系,具体到下图的例子是5对应7,9,3,2

上面的结构都是逻辑结构

接着我们来讲物理结构

物理结构

物理结构是逻辑结构在计算机里的真正表达方式(又被成为映像),也可以叫存储结构。

常见的物理结构分为顺序存储结构与链式存储结构

顺序存储结构

把数据元素放到地址连续的存储单元里,其逻辑结构和物理关系式一致的,也就是说其存储的地址都是按一定量增加的,比如数组,其优缺点之前的笔记里有些,这里不再赘述

链式存储结构

把数据元素存放到任意的存储单元里,这组存储单元可以是连续的也可以是不连续的。此时,数据元素并不能反映元素间的逻辑关系,因此我们在链式结构里引进了一个指针存放数据元素的地址,这样通过地址就可以找到相关联元素的位置

我觉得这很类似于java里的链表,而指针就是java里的引用,当然这只是一个猜测,不保证对

现在我们学习完了数据结构的大体概述,现在我们来学习下算法

算法介绍

首先,什么是算法?

简单来说咱们写的代码啊,里面我们设置的条件那些就是算法

算法要很多种,但是一个优秀的算法应该追求以下两点

这两点分别是花最少的时间完成需求以及占最少的内存空间完成需求

值得一提的是,我们这里学习的数据结构是Java的数据结构

算法复杂度分析

我们研究算法的最终目的是如何花更少的时间,如何占用更少的内存去完成相同的需求

有关算法的时间耗费分析,我们称之为算法的时间复杂度分析

有关算法的空间损耗分析,我们称之为是算法的空间复杂度分析

现在我们先来研究下算法的时间复杂度分析

时间复杂度分析

算法的时间复杂度分析

算法的时间复杂度分析方法有两种,一种是事前分析估算方法,另一种是事后分析估算方法

事后分析估算法

事后分析估算方法

先来讲讲事后分析估算方法,请看代码

简而言之事前分析估算方法就是方法执行前获取一个当前时间,执行后获取一个当前时间,然后打印时间差,这样来获得方法运行需要的时间

这个方法很简单,但是这种方法缺陷很大,需要耗费大量时间去做一个测试程序,耗费时间高,因此一般不用

事前分析估算法

事前分析估算方法

事前分析估算方法是指在计算机编写程序前依据统计方法对算法进行估算,我们已知影响算法的运行时间的有以下原因

其中2和4是我们程序员所无法决定的,是无法改变的东西,因此我们不予理会,只用1和3进行分析

这样一个程序的运行时间就只依赖于算法的好坏和问题的输入规模了,如果算法还固定了,那么就只和输入规模有关系了

那么我们再次回到我们之前的求和案例,进行分析

先看第一种解法的代码

我们不难发现,这个代码真正执行的次数的总和为2n+3次,而用于计算结果的代码执行的次数则为n次

在看第二种解法的代码

而这个代码发真正执行次数的总和为3次,用于计算结果的代码执行了1次

由于n次执行显然比1次执行要慢,所以我们肯定会采用1次执行的代码好

这里我们不用总和来进行比较,而用核心的用于计算的代码进行比较,这是因为如果我们关注总和,用总和来进行比较的话,那光是分析总和到底有几次都有够折磨了,为了我们算法分析的渐变和统一,我们只关注其核心代码的执行次数,这里是进行了一个简化分析

我们分析一个算法的运行时间,最重要的是把核心操作的次数和输入规模关联起来

接下来我们来看下图

在本图里,n是我们的输入规模,f(n)是我们的运行时间,而f则是我们的代码核心运行次数

在这里,显然黄函数要优于红函数优于紫函数,我们写代码时,也要尽量追求我们的代码达到红函数和黄函数的函数关系,而不是紫函数

接下来我们来研究函数的渐变增长

先来看看概念

其实也不难

比方说当A1(2n+3)与B1(3n+1)时,易知当n>2时,y1恒大于y,此时我们就可以说当n>2时,算法A1的渐进增长小于算法B1的渐进增长,其中n是输入规模

我们由分析易知当N极大时,A1与B1几乎的曲线几乎一致

因此我们可以得出结论,随着输入规模的增大,算法的常数操作可以忽略不计

我们可以再举一个例子,比方说当A2(n^2)与B2(2*(n^2)+1),当我们的n很大很大时,最终他们的曲线都会十分接近,而他们不止是有着常数的不同,还有着还有着与最高次数项相乘的常数的不同

由此我们可以得出结论,随和输入规模的增大,与最高次项相乘的常数可以忽略

我们再举一个例子,比方说当A3(2n^2+3n+1),B3(2n^3+3n+1),我们不难发现当n大于2之后,B3的增长速率远高于A3

由此我们可以得出结论,最高次项指数大的,随着n的增长,结果也会变得增长特别快

再经过一些其他图像的分析和推导,我们得出一个结论是算法函数中n的最高次幂越小,算法效率越高

最后我们总结一下规则

大O记法

我们先来看看它的定义

或许你看不懂,但没关系,我也看不懂,看不懂就先放着,反正影响不大

在这里我们先记住两点就行了

第一点是,我们在大O记法里,明确了执行次数=执行时间

其次是,一般情况下,随着输入规模n的增大,T(n)增长最慢的算法为最优解

我们先来看看下面三个代码

首先是第一个代码

这个代码的总执行次数为3次

接着是第二个代码

第二个代码的总执行次数为n+3次

最后是第三个代码

第三个代码的总执行次数是n^2+2次

那么我们要如何用大O记法来表示上面三个算法的时间复杂度呢?

首先基于我们之前学习过的对函数渐进增长的分析,我们有以下三条规则可以使用

这样,第一个算法的总执行次数是3,由于只有一个常数,运用规则1,所以用大O记法表示为O(1)

第二个代码的总执行次数是(n+3)次,由于3是常数,因此3先改为1,又因此规则2,所以最终表示为O(n)

第三个代码的总执行次数是n^2+2次,同理其表示为O(n^2)

常见的大O阶

O(1)为常数阶,一般不涉及循环操作的都是常数阶

O(n)为线性阶,一般含有非嵌套循环涉及线性阶

O(n^2)为平方阶,一般嵌套循环属于这种时间复杂度

O(n^3)为立方阶,一般三重嵌套循环属于这种时间复杂度

来看看下面的代码

在这个代码里,我们可以设程序执行次数为x,即是假设有x个2相乘之后会跳出循环,则可得式子2^x=n;则可得x=log(2),其中x为程序执行的次数,即是我们平时用大O阶表示的n

则这个算法的时间复杂度是O(logn),之所以我们这里直接写log,是因为log的底数无论是多少,随着输入规模的增大,到最后都是趋于一致的,因此可以省略,这种大O阶叫对数阶

上述大O阶的复杂度从低到高分别为

一旦我们的复杂度倒数两位甚至更高,比如说平方阶或者立方阶时,即说明我们的算法效率很低,需要优化

函数调用的时间复杂度分析

实际我们写的算法里,我们常常会运用到函数,我们计算其时间复杂度时,肯定会不可避免的要对函数调用时进行分析,因此我们接下来学习函数调用的时间复杂度分析

请看代码

在上面的代码里我们的核心代码,第四行代码执行了n次,在方法里的代码都会执行一次,那么总和为n,而在方法里没有循环,则其为线性阶O(n)

上面的算法里,调用了n次方法,而在方法里又进行了n次循环,则代码的执行总次数为n^2,则其为平方阶O(n^2)

在上面的算法里,我们先调用了一次方法,在方法里的代码执行了n次,然后们调用了n次方法,在方法里的代码又执行了n次,接着我们跳出了方法之后又执行了一个嵌套循环,又执行n^2次,所以我们的方法总共执行2(n^2)+n

则其为平方阶O(n^2)

我们的算法总是有最好情况与最坏情况的,好的情况下第一次就找出来了,坏的情况下查找到最后还能查找出来,我们要写的算法必须要保证其在最坏情况下也能够高效率的运行,而不能整出只有在特定情况下才能运行良好的程序来

以后我们研究的算法的时间复杂度时,一般也是以最坏情况来分析的

空间复杂度分析

算法的空间复杂度分析

由于这里我们学习的Java的数据结构,因此这里我们主要先来讲讲Java内存占用情况

内存占用情况

首先是一些基本数据类型的内存占用

其次我们要知道,计算机访问内存的方式都是一次一个字节

字节,称之为Byte,简称为B,一个字节由8个无符号的二进制数组成,存储数值的范围为0-255,0指一个字节的二进制数全为零时,255则是指全为1时

一个引用,即是内存地址,需要8个字节表示

例如 Date date = new Date();,其中date作为引用需要8个字节

创建一个对象,对象本身需要16个字节用来保存对象的头信息

例如 new Date();需要占用16个字节

对于一般内存的使用,如果不够8个字节,都会被自动填充为8字节,简而言之就是创建的内存必须是8的倍数

请看下面的分析

但是对于java中的数组而言,由于被限定为对象,他们一般都会因为记录长度而需要额外的内存,一个原始数据类型的数组一般需要24个字节的头信息(16用于对象的开销,4字节用于保存长度,以及4个填充字节(即不满足8的倍数所进行的扩容的字节))

那么现在知道了上面的知识之后,我们正式来学习算法的空间复杂度分析

算法的空间复杂度分析

值得一提的是,算法的空间复杂度分析也是按照大O记法的规则来的

假设我们现在需要设计一个对指定数组进行反转并返回反转的内容的算法,那么请看我们设计的第一个算法

在这个算法里,我们一共申请了8个字节,循环内部的代码直接无视,那么用大O记法其空间复杂度为O(1);

在上图里,我们先申请了四个字节,然后再申请了n4个字节+数组需要的24字节,共申请了n4+28字节

则用大O记法为O(n);

那显然是第一个算法用空间少,那当然第一个算法好些是吧

但是其实对于java的开发而言,除非我们是做java的嵌入式开发的,否则我们很多时候都需要管空间复杂度的,因为现在计算机的空间都已经足够大了,这些多少影响真心不大

以后我们说算法的复杂度分析时,如果没有特别指出是空间的复杂度分析,默认都是时间的复杂度分析

简单排序

关于简单排序,直接上图吧

这里我们特别提一下API,API(Application Programming Interface)即应用程序接口,是一些预先定义的函数,或指软件系统不同组成部分衔接的约定

我个人觉得可以简单理解就是已经封装好的可以供给我们使用的方法,后面我们学习的时候都是要顺便看看它的API内部的,也就是看看方法里面具体是怎么实现的

Comparable接口

Comparable接口介绍

我们这里讲到了排序,排序的基本方法就是比较,Java中提供了一个Comparable接口用来定义排序规则,这里我们用一个案例来对Comparable接口进行简单的讲解

假设我们定义一个学生类,具有age和username两个属性,并通过Comparable接口提供比较规则

则我们可以先定义学生类如下

package algorithm.sort;
//让学生类继承Comparable接口并使用泛型将Comparable接口的类型限定在Student里
public class Student implements Comparable<Student>{
    private String username;
    private int age;
​
    public String getUsername() {
        return username;
    }
​
    public void setUsername(String username) {
        this.username = username;
    }
​
    public int getAge() {
        return age;
    }
​
    public void setAge(int age) {
        this.age = age;
    }
​
    @Override
    public String toString() {
        return "Student{" +
                "username='" + username + ''' +
                ", age=" + age +
                '}';
    }
    
    @Override
    public int compareTo(Student o) {
        return this.getAge()-o.getAge();
    }
    //重写之后的比较方法两个简单的年龄相减
}
​

从上面的代码我们可以看出对于实现了Comparable接口类的类来说,是要重写compareTo方法的,具体重写方式按自己的需求来,比如我们这里是要判断其大小,则可以简单使用减法来进行比较

然后我们用测试类来试试

package algorithm.sort;
​
public class test {
    public static void main(String[] args) {
        Student s1 = new Student();
        s1.setUsername("张三");
        s1.setAge(18);
​
        Student s2 = new Student();
        s2.setUsername("李四");
        s2.setAge(20);
​
        Comparable max = getMax(s1,s2);
        System.out.println(max);
        //Student{username='李四', age=20}
    }
    public static Comparable getMax(Comparable c1,Comparable c2){
        int result = c1.compareTo(c2);
        if(result>=0){
            return c1;
        }else {
            return c2;
        }
    }
}
​

在这里测试类里我们定义了返回类型Comparable引用数据类型的静态方法,然后里面调用了重写过的CompareTo方法,定义了比较返回age最大的比较结果的类型

最后我们也的确返回了我们所需要的对象,这就是我们Comparable一个接口的使用,未来我们所有的排序很多时候都要用Comparable对对象进行排序

三种简单排序

冒泡排序

学完了Comparable接口之后,我们来学习冒泡排序法

冒泡排序法的原理在JavaSe里就已经讲解过了,这里不多赘述

简单原理就是拿右边的值与左边的一一比较,谁大就放右边,比完之后比倒数第二个,这样直到全部比完为止

接着我们来看看冒泡排序的API设计

在冒泡排序的的Bubble类里有三个静态方法,分别是对数组内的元素进行排序的sort方法,然后是用于判断v是否大于w的greater方法,最后是用于交换两处索引值的exch方法

现在让我们来去分别实现这三个办法,可以写出代码如下

package algorithm.sort;
​
public class Bubble {
    public static void sort(Comparable[] a){
        for (int i = a.length-1; i > 0; i--) {
            for (int j = 0; j < i; j++) {
                if(greater(a[j],a[j+1])){
                    exch(a,j,j+1);
                }
            }
        }
    }
​
    private static boolean greater(Comparable v, Comparable w) {
        return v.compareTo(w)>0;
    }
​
    private static void exch(Comparable[] a,int i,int j){
        Comparable temp;
        temp = a[i];
        a[i] = a[j];
        a[j] = temp;
    }
}
​

然后我们再写一个测试类来测试该方法

package algorithm.sort;
​
import java.util.Arrays;
​
public class test {
    public static void main(String[] args) {
        Integer[] arr = {4,5,6,3,2,1};
        Bubble.sort(arr);
​
        System.out.println(Arrays.toString(arr));
        //[1, 2, 3, 4, 5, 6]
    }
}
​
​

这里我们之所以传入了一个Integer类型,是因为我们写的Bubble类是规定了我们要Comparable数据类型的数组,而在Integer类里是实现了Comparable接口的,因此这里可以利用多态机制

接着我猜想之所以我们没有对CompareTo方法进行重写而又可以正确比较,是因为这里的CompareTo方法其实是调用了Integer里的CompareTo方法

冒泡排序的时间复杂度分析

这里我们要用最坏的情况来进行判断,对于这个算法而言,最坏的情况就是数组逆序的情况,逆序的数组要求每一次都进行比较

通过分析我们不难知道本算法的核心代码在于7,8行的比较与交换这两行代码,确认了方法内的函数没有循环之后,我么就可以只比较这两个方法执行的次数了

首先通过分析我们易知比较的次数为(n-1)+(n-2)+(n-3)....3+2+1=n^2/2-n/2次,而交换的次数也是这么多次,所以总执行次数为n^2-n次

用大O记法表示为O(n^2),显然,这种算法是需要优化的

选择排序

显然,我们需要另外一种更加好的方法来对数组进行排序,所以现在我们就来学习选择排序。它的原理同样在JavaSe里已经讲过了,所以这里就不再赘述,直接贴结论

看看用图进行的演示

简单来说就是先假定第一个索引处的值为最小值,然后拿着第一个索引和其他索引进行比较,如果对面更小,那么就将最小值的索引更改位置,接着到结束再把最小值放到第一个位置,然后从第二个位置开始重复执行上述过程

接着我们来看看选择排序法的API设计

我们通过API的指引,可以实现选择排序的方法如下

package algorithm.sort;
​
public class Selection {
    public static void sort(Comparable[] a){
        for (int i = 0; i <= a.length-2; i++) {
            int minIndex = i;
            for (int j = i+1; j < a.length; j++) {
                if(greater(a[minIndex],a[j])){
                    minIndex=j;
                }
            }
            exch(a,i,minIndex);
        }
    }
​
    private static boolean greater(Comparable v, Comparable w) {
        return v.compareTo(w)>0;
    }
​
    private static void exch(Comparable[] a,int i,int j){
        Comparable temp;
        temp = a[i];
        a[i] = a[j];
        a[j] = temp;
    }
}
​

同样的我们要对其进行时间复杂度的分析,这里外层循环完成了数据交换,内层循环完成了数据比较,所以我们分别统计数据交换次数与数据比较次数

如图所示

其时间复杂度用大O阶表示仍为O(N^2);仍然是需要改进的算法,因此我们接下来要学习新的排序算法,因为前两个都不适用

插入排序

接下来我们来学习插入排序,先来看插入排序的理论原理

简单来说就类似于扑克牌,我们拿到一张牌之后先将该牌从右边往左边比较,一旦我找到一张牌比我们要插入的牌还要小,那么我们就算是找到正确的插入位置了,将其插入就可以了。这里是默认了我们的牌已经是排序好了的,所以我们可以用使用这种插入排序,我们实际运用插入排序的时候,也是要将数据分成已排序和未排序两组并进行比较的

现在我们来看看其原理

现在让我们来分析其图示实现

在这里我们将所有的元素分为两组,已排序的和未排序的,这里我们先将下标0的数值为4的数据默认为已排序的,然后将往右的剩余数据全部视为未排序的,接着我们拿未排序的下标为1数值为3的未排序数组里的第一个来进行比较,发现它的确比4小,于是我们将3与4交换位置,用于后续已经没有数据了,所以此时第一轮的交换就完成了

第二轮交换我们去下标为2数值为2的数据作为待插入数据,然后我们与4进行比较,比较完之后发现2比4小,于是2和4交换位置,接着我们拿下标为2数值为2的数据与3进行比较,然后再次交换位置,同理交换完成

接着到第三轮循环,第三轮循环我们拿下标为3,数值为10的数据与已排序的数据进行比较,我们发现10与4大,那显然对于已排序的数据而言,后面肯定比4还小了,于是我们不用比了,直接把10放在原位就完了

这样不断循环往复一下子就到了第六轮循环了,此时我们拿下标为6,数值为5的的数据与前面的数据进行比较,比12小,于是和12交换位置,在拿此时下标为5数据为5的数值与10进行比较,同理再次交换位置,然后拿下标为4数值为5的数据与4进行比较,发现5比4大,那就直接保持原位

到最后下标为7数值为6的数据排序完之后,数组里就只剩下排序完的数据了,此时就是排序完成了

学习完原理及图示实现之后,我们来看看插入排序的API设计

接着我们用算法来实现其方法

package algorithm.sort;
​
public class Insertion {
    public static void sort(Comparable[] a){
        //i=1是未排序数组的初始下标,未排序数组最大能到达数组最后一个数组,因此是<a.length
        for (int i = 1; i < a.length; i++) {
            //定义j=i,代表要未排序数组的第一位,用于后续比较,且该指针总指向这个待插入数据
            for (int j = i; j > 0 ; j--) {
                //将待插入数据与已排序数据进行比较,若小于则交换
                if(greater(a[j-1],a[j])){
                    exch(a,j-1,j);
                    //若大于直接结束循环
                }else {
                    break;
                }
            }
        }
    }
​
    private static boolean greater(Comparable v, Comparable w) {
        return v.compareTo(w)>0;
    }
​
    private static void exch(Comparable[] a,int i,int j){
        Comparable temp;
        temp = a[i];
        a[i] = a[j];
        a[j] = temp;
    }
}
​

其实综上我们也不难看出对于插入排序而言就是拿未排序数组的第一位对未排序数组里的一个个数据进行冒泡排序,不同的是我们每一次冒泡排序的结束条件有两个,一个是要比较的值已经无了,另一个是要比较的值比待插入的值还要小

现在我们来分析下插入排序的时间复杂度,显而易见的,我们要找到其核心代码比较和更换值的代码的运行次数,当然,这里建立在最坏情况下的分析

最后我们根据大O推导法则,保留最高阶项,去除常数因子,其时间复杂度为O(N^2);这样的排序方法仍然不符合我们的需求,因为当输入规模很大时,其需要的时间还是太久了,因此我们还要学习其他的排序方法

我们之前所学习的排序都是简单排序,他们的时间复杂度都是O(N^2);当输入规模增大时其耗费时长都会特别长,没什么意义,因此我们接下来要学习高级排序,用于高效率的解决输入规模较大时的排序问题

三种高级排序

接下来我们就要学习高级排序了,首先我们来学习希尔排序,它可以简单理解为是插入排序的改良版本

希尔排序

我们先来看看希尔排序的原理

其实希尔排序的实质就是先将数组的所有元素进行分组,分完组之后对每一组的元素分别进行插入排序,插入排序完成之后我们再进行分组,接着再进行插入排序,这样不断循环往复,每次分组的数值也在变小,当分组的数组足够小时,我们就相当于是在对一个部分有序数组进行插入排序,此时要进行运算次数就很少了,可以达到我们的目标

我们来看看它的图示

首先我们可以看到我们有一个数组长度为10,最后下标为9的数组,首先我们可以看到我们的h为5,那么我们就将数组按照这个规则将数组的元素从第一个开始分,分为五组,每组两个,然后对其进行插入排序,比如第一组的{9,4},进行插入排序之后就变成{4,9},这里利用插入排序,将数组本身分为两组,一组未排序一组已排序,这里未排序的是9,已排序的是4,比较发现4比9小,因此进行了交换,同时由于数组只有两个,因此不需要再进行对其他数组的比较、

当我们将h=5的排序执行完毕之后,我们就得到了一个部分排序数组,接着我们将h=2,再次进行分组,将数组分为两组,一组五个,其中一组为{4,2,5,8,5},接着对其进行插入排序,将4默认设置为已排序数组,将2,5,8,5默认为未排序数组,接着用未排序数组的第一个对已排序数组尽心分别的一个个的比较,首先我们将2与4进行比较,发现4小于2,于是4与2交换位置,接着拿5与4比较,发现5大于4,于是直接停止循环。接着8与5比较也是同理,然后8与5比较,8与5交换,再拿交换位置的5与5比较,发现并不是小于关系,于是直接停止循环

最后h=1也是同理,经过这三次排序之后,我们最后就能得到一个排序完全的数组

那么这个h要怎么决定呢?每次减少多少,以及最开始是从多大开始的呢?这里面其实是有规则的,规则如图

比方说我们这里的数组长度是10,那么按照规则,我们就可以确定其h的最初长度是7。但问题是,最开始我们明明搞得是5啊,为啥这里又是7呢?其实这里的5只是为了我们方便理解这个方法而人为设置的,实际上内部运作的时候最开始的长度就是7

然后每次减少的长度为减少到它的一般,那么最开始的长度为7,第二次就为3,第三次就为1

学习完希尔排序的原理之后,我们来看看希尔排序的API设计

然后我们就可以实现它的算法如下

package algorithm.sort;
​
public class Shell {
    public static void sort(Comparable[] a){
        //根据a的长度,确定h的初始值
        int h=1;
        while (h<a.length/2){
            h=h*2+1;
        }
        //希尔排序
        while (h>=1){
            //排序
            //2.1找到待插入的元素
            for (int i = h; i < a.length; i++) {
                //2.2把待插入的元素插入到有序数组中
                for (int j = i; j >= h ; j-=h) {
                    //待插入的元素是a[j],比较a[j]和a[j-h]
                    if(greater(a[j-h],a[j])){
                        //交换元素
                        exch(a,j-h,j);
                    }else {
                        //待插入的元素的已经找到了合适的位置,结束循环;
                        break;
                    }
                }
            }
            //减小h的值
            h=h/2;
        }
    }
​
    private static boolean greater(Comparable v, Comparable w) {
        return v.compareTo(w)>0;
    }
​
    private static void exch(Comparable[] a,int i,int j){
        Comparable temp;
        temp = a[i];
        a[i] = a[j];
        a[j] = temp;
    }
}
​

这里我们先确定h的长度,所以用了一个while循环来先确定其最大长度

接着我们构建一个h不断变为其一半的while循环代表多次执行循环,最小的循环为h=1,我们通过分析发现,每一个分组的第一组的第二个元素的下标必然等于h,如在第一次排序里的4,其下标为5,第二次排序里的2,其下标就为2。因此我们可以构建第一个for循环,该for循环的第一个代表了每一组的第二个元素的下标,随后不断增加到最后一个下标

然后在该for循环里我们再建立一个for循环,第二个for循环则是以第一个for循环所代表的下标从左每次下标减少h并对其进行选择排序,每一个第二个for循环的结束代表着一个选择排序的一次循环的结束,外层for循环的结束则代表着第一趟分组的排序的结束,while循环的结束就代表着排序的正式结束

反正就套娃其实,嗯套就完了

本来我们应该照例来用大O记法来分析下希尔排序的时间复杂度的,这是事前分析方法,但是呢,因为希尔排序比较复杂,这里面涉及很多的高深莫测的数学问题,这对于我们目前这个阶段的学习来说实在是太痛苦了,为了避过这一步,我们使用时候分析方法来分析其时间复杂度

利用IO流,放入了一个10万到1的逆序数组,用插入排序来做需要三万毫秒多,而用希尔排序只需要30毫秒,谁快谁慢高下立判了属于是,因此当我们涉及大量计算时,我们还是使用高级排序法比较好,低级排序法费拉不堪

归并排序

在讲归并排序之前我们应该要先对递归有所了解,但是递归我们已经了解过了,所以不讲了

归并排序的核心思想在于拆,排,排,合。我们先来看看其原理

先将一个数组不断拆分,拆分到一个一个为止,然后不断合并,合并的同时不断排序,最后合并出来的一个数组就是排序好的数组,我们可以看看其图示

接下来我们来看看其API设计

可以看到,有三个方法是跟以前我们学习的方法是一模一样的,除了名字之外。这三个方法分别是sort(1),exch,less方法,接着在Merge方法里还有两个独特的方法,这个方法分别是sort的重载方法(2)与merge方法

并且在这个方法里还有一个成员变量,这个成员变量是一个辅助数组

该方法的执行过程是通过将数组分组,分组之后再排序,排序之后,我们要进行归并,那我们这个归并过程的是如何实现的呢?其原理又是如何的呢?我们先来讲讲,先看看其前提

这里我们先分好了组,且调用了两次sort方法将两个数组本身进行了排序,最后我们要调用merge方法进行归并,就是合并,并且要合成排序好的合并,那我们要怎么办呢?我们可以通过三个自定义的指针以及辅助数组来实现该方法

其原理是我们让指针p1指向左子组,指针p2指向右子组,接着定义一个指针i指向辅助数组assist,然后我们重复进行p1与p2的大小的判断,如果p1小,那么就将p1指向的元素放到指针i指向的元素中,同时将p1与i指针都向后移动一位,反之则是移动p2与i,若p1或者p2有其中一个到达了其尾端,那么就不要再进行比较,直接将另一个指针的元素按顺序放到assist中就可以了

注意这里是先将左子组与右子组排序好之后我们才可以这样做的,如果没有事先排序好,上面的方法是不成立的

最后放在我们的辅助数组assist中的数组就是我们排序好的数组,最后我们要做的事情就是将辅助数组里的元素一个个拷贝到原数组对应的位置中去

那么我们可以实现其方法如下

package algorithm.sort;
​
public class Merge {
​
    //成员变量的辅助数组,未初始化
    private static Comparable[] assist;
​
    public static void sort(Comparable[] a){
        //先初始化辅助数组assist,创造一个具体对象,其长度为原数组的长度
        assist = new Comparable[a.length];
        //定义lo变量与hi变量,分别记录数组中的最小与最大索引
        int lo=0;
        int hi=a.length-1;
        //调用sort重载方法完成对数组a中从索引lo到hi的元素排序
        sort(a,lo,hi);
    }
​
    private static boolean less(Comparable v, Comparable w) {
        //与之前的方法不同的是这里是<号,比较谁小
        return v.compareTo(w)<0;
    }
​
    private static void exch(Comparable[] a,int i,int j){
        Comparable t = a[i];
        a[i] = a[j];
        a[j] = t;
    }
​
    private static void sort(Comparable[] a, int lo, int hi){
        //先进行安全性校验,确保传入的索引合法
        if(hi<=lo){
            return;
        }
​
        //对lo与hi之间的数据分为两组
        int mid=lo+(hi-lo)/2; //    lo=5,hi=9 mid=7
​
        //对分组之后的数组进行分别排序,这里对前数组进行排序
        sort(a,lo,mid);
        //这里对后数组进行排序
        sort(a,mid+1,hi);
        //上面是使用了递归的方法
​
        //最后将两个组中的数据进行归并
        merge(a,lo,mid,hi);
    }
​
    private static void merge(Comparable[] a,int lo,int mid,int hi){
        //先定义三个指针
        int i=lo;//定义i=lo,让i指针指向对应的起始位置,而不是只会在开头
        int p1=lo;
        int p2=mid+1;
        
        //遍历,移动p1和p2指针,比较对应索引的值,找出小的那个并放到辅助数组的对应索引处
        while (p1<=mid && p2<=hi){
            //比较对应索引处的值
            if(less(a[p1],a[p2])){
                assist[i++]=a[p1++];
            }else {
                assist[i++]=a[p2++];
            }
        }
        
        //如果p1的指针没有走完,那么顺序移动p1指针,把对应元素放到辅助数组的对应索引处
        while (p1<=mid){
            assist[i++]=a[p1++];
        }
        //如果p2的指针没有走完,那么顺序移动p2指针,把对应元素放到辅助数组的对应索引处
        while (p2<=hi){
            assist[i++]=a[p2++];
        }
        //把辅助数组中的元素拷贝到原数组中
        for (int index=lo;index<=hi;index++){
            a[index]=assist[index];
        }
    }
}
​

Tips:使用Arrays.toString();方法可以直接打印数组内的值,可以省去for循环

接下来我们照例来分析下其方法的时间复杂度,我们先来看看用来分析其时间复杂度的演示图

接着我们我们来看看其时间复杂度的理论分析

最后我们可以得出答案,其时间复杂度就是O(nlogn);这个显然是比简单排序法的效率要高得多的

但是,对于归并排序而言,不断地使用递归的方法是有导致内存溢出的可能的,而且由于在归并排序里是创造了一个辅助数组来完成排序的,因此也比其他的方法需要更多的空间,是一种典型的用空间效率来换时间效率的方法

归并排序和希尔排序的效率是差不多的,那我们排序时应该选择哪一种方法来排序呢?这个不用急,等我们后续讲到了排序的稳定性之后,这个问题就会迎刃而解了

快速排序

我们先来看看其理论定义吧

简而言之,就是对一个数组选择一个数作为分解值然后分组,将大于等于其的数据放到数组右边,反之则放左边,然后对左右两侧的数组做同样的流程,最后分到不能再分的时候,我们再进行合并,不断合成,最后形成一个排序好的数组

比方说对于下面的图示而言

我们直接选第一个数组6作为分界值,然后把数组分为左右两组,接着对左右两组继续进行同样的流程,把每个数组再细分,由于右边分界值为9的数组右边是没有比他更大的数值的,因此其分界值右边是没有元素的,然后我们继续分,这第三次就是最后一次分组了,因为这时候已经分到只剩下2个元素了,没法继续拆了,此时剩下的三个就是分好序的拆分数组,然后我们再讲分界值对应合并,最后我们就可以得到我们想要的排好序的数组了

学完了理论之后,接下来我们来学习下快速排序的API设计

我们可以看出,其实其API设计和归并排序似乎很像,但是不同的是,我们这里有一个partition方法,这个方法的作用是在于对数组a中,从索引lo到索引hi之间的元素尽心个分组,并返回分组界限返回的索引,我们先来重点讲解下这个方法

先来看看其原理吧

简单来说,就是我们先定义两个指针,第一个指针指向数组的头索引,第二个指针指向数组的末索引的下一位,然后先从右往左搜索,找到比分界值小的元素并让指针指向该元素,接着左指针往右搜索,找到比分界值大的元素并让指针指向该元素,若此时左指针仍然小于右指针,则交换左右指针的值并继续在从交换位置重复此过程,直到左指针大于或等于右指针时交换分界值与两指针共同指向的值,然后结束循环,当然,左指针或者右指针到了数组结尾的时候也要结束循环

用演示图来演示则是如下所示

我们这里将6作为分界值,右指针找到了5这个元素比边界值小,此时右指针指向5

然后左指针开始遍历,左指针找到比分界值大的值为7,然后此时左右指针还不满足左指针大于右指针的情况,所以交换两指针所指向的值,然后重复进行上述动作,又交换了9和4

最后两个指针都指向了3,此时交换6与3,这样数组就完成了分组了

由图我们可以实现该方法如下

package algorithm.sort;

public class Quick {

    public static void sort(Comparable[] a){
        int lo = 0;//定义数组的头索引
        int hi = a.length-1;//定义数组的末索引
        sort(a,lo,hi);//调用重载的给数组排序的方法
    }

    private static boolean less(Comparable v, Comparable w) {
        //与之前的方法不同的是这里是<号,比较谁小
        return v.compareTo(w)<0;
    }

    private static void exch(Comparable[] a,int i,int j){
        Comparable t = a[i];
        a[i] = a[j];
        a[j] = t;
    }

    private static void sort(Comparable[] a, int lo, int hi){
        //安全性校验
        if(hi<=lo){
            return;
        }
        //对数组中lo索引到hi索引处的元素进行分组(左子组和右子组)
        int partition = partition(a,lo,hi);//返回分组分界值所在的索引,注意是分界值位置变换后的索引
        //让左子组有序
        sort(a,lo,partition-1);//-1是为了从分界值前一位作为终止索引
        //让右子组有序
        sort(a,partition+1,hi);//+1是为了从分界值后以为作为起始索引
    }

    private static int partition(Comparable[] a,int lo,int hi){
        //确定分界值
        Comparable key = a[lo];//分界值就是数组头位置的值,lo传入的就是数组头索引
        //定义两个指针,分别指向待切分数组的最小索引处和最大索引处的下一个位置
        int left=lo;
        int right=hi+1;

        //切分
        while (true){
            //从右往左遍历,移动right指针,找到一个比分界值小的元素后停止
            while (less(key,a[--right])){
                //使用less(key,a[--right);方法,这样让右指针找到比key小的元素之后结束此循环
                //使用--right是为了让右指针先移动到对应位置在进行比较
                if(right==lo){//如果右指针到了首位置,就结束循环
                    break;
                }
            }

            //从左往右遍历,移动left指针,找到一个比分界值大的元素后停止
            while (less(a[++left],key)){
                if(left==hi){//如果左指针到了末位置,就结束循环
                    break;
                }
            }
            //判断left>=right,若为真,则证明扫描完毕,结束循环,反之交换元素
            if(left>=right){
                break;
            }else {
                exch(a,left,right);
            }
        }
        //交换分界值
        exch(a,lo,right);
        return right;//返回边界值的所在位置的索引
    }
}

快速排序和归并排序的区别

无论是快速排序还是归并排序,其本质都是一种分治算法。不同的是,归并排序是将数组本身分为两个子数组然后分别排序,接着将有序的子数组进行排序之后再进行归并从而让整个数组有序。而快速排序的方法本质是当两个数组都有序时,整个数组就自然有序了。

其次,归并调用发生在处理整个数组之前,我们是先递归,递归的同时处理数组,同时我们总是把数组分成两半,取中间值。而快速排序则反过来,在快速排序里,切分数组的位置取决于数组的内容,而且我们是先处理数组内容,后再进行递归。

快速排序的时间复杂度分析

接着照例来分析下快速排序的时间复杂度分析,对于快速排序而言,我们分析其时间复杂度我们要分为三种情况来分析

首先是最优情况,在最优情况下,快速排序的效率和递归排序是差不多的,每一次切分的数组正好将数字等分,此时其时间复杂度为O(nlogn);

而在最坏情况下,每次切分都是当前序列中的最大数和最小数,这样就得总共切分n次,所以最坏情况下,其时间复杂度为O(n^2);

而在平均情况下,其时间复杂度为O(nlogn);这里是用数学归纳法来证明的,涉及到许多数学知识点,我们这里就直接给答案了,反正讲了也听不懂不是,没必要

排序的稳定性

那么到此为止,我们常见的六中排序法就已经讲完了,那么我们在具体情况时应该选择哪种方法呢?

其实排序方法是分为稳定排序法与不稳定排序法的,这就是排序的稳定性

那怎么判断排序方法是否具有稳定性呢?我们不妨先来看看稳定性的定义

然后我们用图示来说明一下

简单来说就是,如果一个数组里有两个相同的数字,那么稳定的排序就应该要保证让他们排序之后,这两个数组仍然保持原来的前后位置而不会变化,那这样有什么意义呢?

他的意义其实就如下所示

简单来说就是如果只是需要一次排序就可以了的数组,那么我们应该尽量选择效率高的,如果是需要多次排序的,那么应该要选择稳定的排序方法

常见算法的稳定性

冒泡排序,插入排序以及归并排序是稳定的,而选择排序,希尔排序以及快速排序是不稳定的

我个人猜想是如果是小数据量的,我们尽量选择冒泡排序或者是插入排序

如果是大数据量又不需要多次排序,那就选择希尔排序

如果是大数据量又需要多次排序,那么选择归并排序比较好

图的相关概念

在正式讲解图之前,我们要先了解下图的相关概念,这个其实在离散里就已经学习过了,所以图的相关概念我们随便讲讲就差不多得了,重点是如何在代码里实现图

首先图分为有向图和无向图,有向图就是指两个结点之间的线有方向,而无向图是没有方向的,有向图只能够按照其方向来说谁连接谁,而无向图正说反说都是允许的

图还有自环和平行边的特殊情况,但是这些情况我们数据结构里不讲,那我们就不学,离散里有讲就是,当时折磨我老久了

最后我们来看看关于图的定义的解释图吧

图的相关术语

在正式学习图之前,我们要学习关于图的相关术语,大多都已经在离散里学过了,所以这里简单过过就差不多得了

连通图的相关术语

图的存储结构

接着我们来说说我们在计算机里如何表示图,在计算机里图的存储结构有两种,分别是邻接矩阵和邻接表

领接矩阵

先来讲讲邻接矩阵吧,由于离散数学里学过了,这里随便讲讲

邻接矩阵聊表示无向图的方式的问题在于其空间复杂度是O(N^2);,当数据量很大时要占用太多的空间,因此我们一般不采用这种方式来表达邻接矩阵

领接表

接着来说说邻接表的方式,邻接表是使用一个数组和队列结合的方式来表示无向图的,首先创造一个大小为V的队列数组,数组里的每一个对象都是一个队列,数组的索引表示对应的结点,而每个索引里存放的就是与该结点连通的其他结点,由于这里表示的是无向图,因此队列里存放的顺序无关紧要

其空间需求就比邻接矩阵的方式要小得多,因此我们以后都采用邻接表的存储形式来表示图

值得一提的是,用邻接表的形式来计算边时会导致边多计算一遍,因此我们通常如果用邻接矩阵来计算边的个数,是要将其除去一半的

无向图

首先明确一点我们这里实现的是无向图而不是有向图,有向图我们以后再实现,先来看看其API设计

这里我们创建一个数组,数组里存放队列,队列里存放Integer类型的对象,根据API我们可以实现其代码如下(注意这里用的队列数据结构是我们之前创建好的数据结构,不是java中自带的)

那么根据API我们可以实现其代码如下

package algorithm.sort;
​
public class Graph {
    //顶点数目
    private final int V;
    //边的数目
    private int E;
    //邻接表
    private Queue<Integer>[] adj;
    public Graph(int V) {
        //初始化顶点数量
        this.V = V;
        //初始化边的数量,最开始没有边,只有顶点,因此赋予0
        this.E = 0;
        //初始化邻接表,创建对应大小的队列数组
        this.adj = new Queue[V];
​
        for (int i = 0; i < adj.length; i++) {
            adj[i] = new Queue<Integer>();
        }
    }
​
    //获取顶点数目
    public int V(){
        return V;
    }
​
    //获取边的数目
    public int E(){
        return E;
    }
​
    //向图中添加一条边v-w
    public void addEdge(int v,int w){
        //无向图的边没有方向,一条边既可以说是从A到B也可以说是从B到A
        //因此我们要令A出现在B的邻接表中,也要让B出现在A的邻接表中
        //这样才能算是将一条边成功添加,所谓邻接表其实就是队列
        adj[v].enqueue(w);//将w添加到v的队列中
        adj[w].enqueue(v);//将v添加到w的队列中
    }
​
    //获取和顶点V相邻的所有顶点
    public Queue<Integer> adj(int v){
        return adj[v];//返回顶点V的队列,其队列中存放与其相邻的所有结点
    }
​
}

深度优先搜索

我们在实现完图之后,我们如果想要找到任意一个图中的一个结点的所有相邻结点,那我们应该怎么办呢?这时我们就需要给我们的程序提供搜索方法,有关图的搜索,最经典的算法有深度优先搜索和广度优先搜索

深度优先搜索指的是我们的算法在搜索时如果遇到一个结点既有子节点又有兄弟结点,那么其会优先找子节点,然后找兄弟结点。这样讲可能不好理解,请看演示图

比如说我们想要查找和顶点0的所有邻接节点,那么首先进入0的队列中搜索,第一个搜索到6,发现6自身有子节点0,4,而且又有兄弟结点2,1,5,由于我们是深度优先搜索,因此我们先进入6的子节点中搜索,搜索到0,因为0已经搜索过了,所以不用再次搜索,因此跳过0搜索4,此时4又有子节点,5,6,3,按照深度优先搜索先进入5搜索,而5中又有.........最后全部搜索到最后一个结点的子节点之后就不断返回

显然,这应该要使用递归来去实现,事实上,经常出现在力扣上的题目都是以深度优先搜索为思路的题目都是运动到了递归的

接着我们就来实现深度优先搜索的代码,先来看看其API设计

由于在无向图中边时没有方向的,因此结点是可能互相出现在不同结点的相邻结点里的,在我们的存储方式里的体现就是两个队列中的结点可能会互相出现,比如说5出现在4中,4出现在5中,但是我们进行搜索时肯定是不要进行重复搜索的,因此我们要提供一个方法来让我们程序来确定这个节点还有没有必要进行搜索

我们这里用一个布尔类型的数组boolean[V] marked,所以代表顶点,对应的值若为true说明该顶点已经被搜索,无需再搜索,反之则标记为false

那么根据其API设计我们可以构造深度优先搜索的代码如下

package algorithm.sort;
​
public class DepthFirstSearch {
    //索引代表顶点,值表示当前顶点是否已经被搜索
    private boolean[] marked;
    //记录有多少个顶点与s顶点相通
    private int count;
​
    /*
     * 由于我们在构造方法里调用了找出图中所有结点的dfs方法,所以在测试
     * 类里我们一旦创建了深度优先搜索对象,那么我们就会立刻在对象中产生
     * 我们传入的要查找结点的连通结点数量
     */
    //构建深度优先搜索对象并使用深度优先搜索找出G图中s顶点的所有相邻顶点
    public DepthFirstSearch(Graph G,int s){
        //初始化marked数组,由于该数组索引代表结点的值,因此其长度与结点
        //数量一致,所以此处将无向图的结点数量传入
        this.marked = new boolean[G.V()];
        //初始化跟顶点s相通的顶点数量,默认为0
        this.count=0;
        //this.count=0;
        //调用找出指定顶点所有相邻顶点的方法
        dfs(G,s);
    }
​
    //使用深度优先搜索找出G图中v顶点的所有相通顶点
    private void dfs(Graph G,int v){
        //先将传入的v顶点标记为已搜索
        marked[v] = true;
​
        for (Object w:G.adj(v)) {
            //判断当前w顶点有没有被搜索过,若没有被搜索过
            //则递归调用dfs方法进入其子节点中进行深度搜索
            if(!marked[(int) w]){
                dfs(G, (Integer) w);
                //count++;
            }
        }
​
        /*
         * 代码能执行到此,说明对于起码对于一个相邻结点的判断已经完成了
         * 而且其必然为v的相同顶点,因此相通顶点数量要+1,但说实话我个人
         * 觉得应该要放到if那里才对,然后将初始化的连通数量改为1就行了。
         * 之所以改为1没问题是任何结点都必然和他自己连通,不过放在外面
         * 即使一开始初始化为0最终也没问题,但我觉得不好理解,这里为了
         * 和它的课件保持同步就不做修改了
         */
        //相通顶点数量+1
        count++;
    }
​
    //判断w顶点与s顶点是否相通
    public boolean marked(int w){
        return marked[w];
    }
​
    //获取与顶点s相通的所有顶点的总数
    public int count(){
        return count;
    }
}
​

广度优先搜索

所谓广度优先搜索,其实和深度优先搜索反过来,如果一个搜索一个结点时既有兄弟结点,又有子节点,那么就先找兄弟结点,再找子节点,请看图示

同样我们要找0结点的所有相通结点,那么此时按照广度优先搜索的话就先进入记录0结点的队列寻找其子节点6,2,1,5,找完之后进入队列中的子节点中去寻找其结点,此时就是进入6结点的队列寻找0,4,0已经搜索过了因此跳过,然后找到4,接着进入4里寻找.......

其实我们之前学习过的二叉树的层序遍历其实就是利用广度优先搜索进行的

还记得我们之前说过广度优先搜索的特点吗?就是有兄弟结点就先去搜索兄弟结点,之后再去搜索子节点,那么在层序遍历里,我们最开始的E结点只有子节点BG,那么就将BG压入到队列中,然后进入B中寻找,到了B发现B既有子节点AD,又有兄弟结点G,所以先找到B,将子节点AD压入之后,因为队列遵从先进先出原则,因此G先弹出,同样压入FH,由于没有兄弟结点了,所以进入A中寻找,然后又是重复过程。但在这个过程中我们能够明显看到就是我们是先遍历其兄弟结点,接着再遍历其子节点的

那么我们在无向图里实现广度优先搜索的算法时,也可以参照层序遍历

广度优先搜索的实现

同样我们先来看看广度优先搜索的方法的API设计

同样,我们这里也创造一个辅助队列用于完成广度优先搜索,那么我们就可以构造代码如下

package algorithm.sort;
​
import java.util.WeakHashMap;
​
public class BreadthFirstSearch {
    //索引代表顶点,值表示当前顶点是否已经被搜索
    private boolean[] marked;
    //记录有多少个顶点与s顶点相通
    private int count;
    //用来存储待搜索邻接表的结点的队列
    private Queue<Integer> waitSearch;
​
    /*
     * 由于我们在构造方法里调用了找出图中所有结点的dfs方法,所以在测试
     * 类里我们一旦创建了深度优先搜索对象,那么我们就会立刻在对象中产生
     * 我们传入的要查找结点的连通结点数量
     */
    //初始化深度优先搜索对象并使用深度优先搜索找出G图中s顶点的所有相邻顶点
    public BreadthFirstSearch(Graph G, int s){
        //初始化marked数组,由于该数组索引代表结点的值,因此其长度与结点
        //数量一致,所以此处将无向图的结点数量传入
        this.marked = new boolean[G.V()];
        //初始化跟顶点s相通的顶点数量,默认为0
        this.count=0;//this.count=0;
        //初始化队列
        this.waitSearch = new Queue<Integer>();
        //调用找出指定顶点所有相邻顶点的方法
        bfs(G,s);
    }
​
    /*
     * 说实话,我觉得这个方法这样设计其实是没有体现出广度优先搜索的特性的,但是其也能
     * 完成需求,如果要广度优先搜索应该是先对兄弟结点进行遍历再遍历子节点,而这里采用
     * 递归的方式完成,那实际上代码会进入先进入到子节点中遍历,我认为这是不合理的。
     */
    //使用深度优先搜索找出G图中v顶点的所有相通顶点
    private void bfs(Graph G,int v){
        //先将传入的v顶点标记为已搜索
        marked[v] = true;
        //让顶点v压入队列并,此时顶点v处于待搜索状态
        waitSearch.enqueue(v);
        //构建循环,如果队列不为空,则从队列中弹出待搜索元素进行搜索
        while (!waitSearch.isEmpty()){
            //弹出一个待搜索的顶点
            Integer wait = waitSearch.dequeue();
            //遍历wait顶点的邻接表
            for (Object w:G.adj(wait)) {
                if(!marked((Integer) w)){
                    bfs(G, (Integer) w);
                    //count++;
                }
            }
        }
​
        /*
         * 代码能执行到此,说明对于起码对于一个相邻结点的判断已经完成了
         * 而且其必然为v的相同顶点,因此相通顶点数量要+1,但说实话我个人
         * 觉得应该要放到if那里才对,然后将初始化的连通数量改为1就行了。
         * 之所以改为1没问题是任何结点都必然和他自己连通,不过放在外面
         * 即使一开始初始化为0最终也没问题,但我觉得不好理解,这里为了
         * 和它的课件保持同步就不做修改了
         */
        //相通顶点数量+1
        count++;
    }
​
    //判断w顶点与s顶点是否相通
    public boolean marked(int w){
        return marked[w];
    }
​
    //获取与顶点s相通的所有顶点的总数
    public int count(){
        return count;
    }
}

畅通工程2

那么学习完了图的内容之后,我们接下来来讲一个案例的解决来加深印象,请看题目

那我们的思路其实很简单,我们先按照题目构建一个对应的图,然后再创建一个搜索对象,接着调用搜索对象里的判断结点之间是否联通的方法就完了

那么我们可以构建其代码如下

没什么特别需要解释的,这个代码,并查集那里已经说过一遍了,我就不多提了

路径查找

上一节里我们实现了无向图的数据结构以及深度优先搜索和广度优先搜索的代码,那么这一节里,我们来实现无向图的路径查找,就是让我们能够在一个指定的无向图里寻找到任意两个结点的一个相通的路径的方法

那么要完成这个需求,我们首先得相出一个表示两个结点间的相对路径的方法

比方说在下图中

如果我们想表示出0到4之间的结点路径,那么我们就可以用0-2-3-4,这样用结点来表示它。这个时候有同学可能会问,但是这个路径不止一条啊,我们要找哪条啊?其实在当前的学习阶段,我们不要求找出最小的那条,只要找出来了就行了,以后学习了加权图时候我们再来讲找出最小路径的方法

接下来我们来看看其API设计

那么在API设计中我们的这个edgeTo数组究竟是用来干什么的呢?其实其本身会按照规则存储图中的结点,而我们可以通过这个数组来查找到我们所需要的路径。这样说可能不好理解,请看下图演示

比方说我们这里以0点为起点,那么我们就先搜索0的相邻结点2,搜索到2之后,由于该数组中索引表示对应结点,其值表示对应结点的上一个结点的值,因此我们在该数组的索引2中存储0,0索引什么都不储存,因为0是起点。接着我们再通过2定位到1,在索引1处存入2,此时我们发现0索引处已经遍历过了,因此停止,接着搜索2的子节点3,将索引3处的值改为2

接着我们在3中定位到5和4,这里两个步骤一起做,所以索引4,5都存储3。那么到此为止所有的结点都便利完了,这样我们就成功得到了我们想要的数组,我们可以通过这个数组来得到两个任意顶点到起点之间的路径,比方说我们想要找5到起点之间的路径,我们就先定位到数组的索引5,然后发现其上一个结点是3,那么定位到索引3,发现其上一个结点是2,在定位到索引2,发现其上一个结点是0,定位到索引0发现啥也没有,就说明到起点了。那么最终我们就能得到我们的路径,该路径为0-2-3-5

还有就是这里我们是利用深度优先搜索来实现路径查找的,有兴趣的可以自己用广度优先搜索试试

那么根据设计我们可以构造代码如下

package algorithm.sort;
​
public class DepthFirstPaths {
    //索引代表顶点,值表示当前顶点是否已经被搜索
    private boolean[] marked;
    //用值来代表起点
    private int s;
    //索引代表顶点,值代表从起点s到当前顶点路径上的最后一个顶点
    private int[] edgeTo;
​
    //初始化深度优先搜索对象,调用深度优先搜索找出G图中起点为s的所有路径
    public DepthFirstPaths(Graph G,int s){
        //初始化marked数组
        this.marked = new boolean[G.V()];
        //初始化起点
        this.s = s;
        //初始化edgeTo数组,长度也为结点数量
        this.edgeTo = new int[G.V()];
        //调用方法找出G图中起点能到达的顶点
        dfs(G,s);
    }
​
    //使用深度优先搜索找出G图中v顶点的所有相邻顶点
    private void dfs(Graph G,int v){
        //把v表示为已搜索
        marked[v] = true;
​
        //遍历顶点v的邻接表,拿到每个相邻的顶点,继续递归搜索
        for (Object w:G.adj(v)) {
            //如果顶点w没有被搜索才继续进行递归搜索
            if(!marked[(int) w]){
                edgeTo[(int) w] = v;//将到达w顶点的上一个顶点记录在数组中
                dfs(G, (Integer) w);
            }
        }
    }
​
    //判断w顶点和s顶点是否存在路径
    public boolean hasPathTo(int v){
        //这里我们直接通过有没有被搜索过来判断其有没有路径,所以代码不用改动
        //这个其实也很好理解,因为只要一被搜索,就说明能从起点到该结点,就说明
        //有路径,如果起点都无法到达该结点,那肯定没有路径啊
        return marked[v];
    }
​
    //找出从起点s到顶点v的路径(就是该路径经过的顶点)
    public Stack<Integer> pathTo(int v){
        if(!hasPathTo(v)){
            //如果该点与起点不存在路径,那就没必要寻找路径
            return null;
        }
        //创造栈对象用于保存路径中的所有顶点
        Stack<Integer> path = new Stack<>();
​
        //通过循环,从顶点v开始一直往前找,直到起点为止,此处是从终点往起点遍历
        //这个for循环的构造非常妙,可以的话就记起来
        for (int x = v; x != s; x = edgeTo[x]) {
            //每次循环将x结点的值压栈
            path.push(x);
        }
        //将起点压入栈中,因为遍历到最后我们没有遍历起点
        path.push(s);
        return path;//返回存储路径的栈对象,相当于返回路径
    }
}

有向图

学习完了无向图之后,我们接着来学习有向图,所谓有向图,其实有无向图中点与点之间的边多了方向而已,我们先来学习下有向图的相关术语吧

有向图相关术语

不多谈啊,这个定义都是适记性的知识,没啥特别值得讲的。而一副有向图中两个顶点v和w可能存在以下四种关系

那么接着我们当然就要来实现有向图了,先来看看有向图的API设计

这里的添加边的方法addEdge的设计思路和无向图有一点不同,那就是在无向图里我们既可以说是v指向w,也可以说是w指向v,但是在有向图里我们只能说是v指向w,因此我们只让w出现在v的邻接表中,而不让v出现在w的邻接表中,这与无向图里让两个点互相出现在对方的邻接表中有所不同

其次是这里我们又一个获取该图的反向图的reverse方法,这个方法的作用我我听不太懂,总之可以确定其作用是为了便于有向图的使用

那么综上我们可以构造代码如下

package algorithm.sort;
​
public class Digraph {
    //顶点数目
    private final int V;
    //边的数目
    private int E;
    //邻接表
    private Queue<Integer>[] adj;
​
    //构造方法
    public Digraph(int V){
         //初始化顶点数量
        this.V = V;
        this.E = 0;
        //初始化邻接表
        this.adj = new Queue[V];
        for (int i = 0; i < adj.length; i++) {
            adj[i] = new Queue<Integer>();
        }
​
    }
​
    //获取顶点数目
    public int V(){
        return V;
    }
​
    //获取边的数目
    public int E(){
        return E;
    }
​
    //向有向图中添加一条边v->w
    public void addEdge(int v, int w){
         //只让顶点w出现在顶点v邻接表中
        adj[v].enqueue(w);
        //边数+1
        E++;
    }
​
    //获取由v指出的边所链接的所有顶点
    public Queue<Integer> adj(int v){
        return adj[v];
    }
​
    /*
     * 获取该图的反向图无非要做两点,第一点是将创造一个有向图对象用于
     * 记录该反向图,第二点是实现边的反向指向。实现反向指向的方法很简
     * 单,遍历原有向图的每一个顶点的邻接表,每找到一个顶点在该顶点里
     * 就说明该顶点对邻接表里的顶点有指向,我们在反向图里只要让邻接表
     * 里的顶点指向该代表该邻接表的顶点就可以了
     */
    //该图的反向图
    private Digraph reverse(){
        //创建和原图有相同顶点的有向图对象
        Digraph r = new Digraph(V);
        //遍历所有顶点
        for (int v = 0; v < V; v++) {
            //遍历所有顶点的邻接表
            for (Object w: adj[v]) {
                //令邻接表内的顶点指向代表该邻接表的顶点
                r.addEdge((Integer) w,v);
            }
        }
        //返回我们所创建的反向图
        return r;
    }
}
​

拓扑排序

在学习拓扑排序之前,我们首先当然要对拓扑排序进行一个介绍,什么是拓扑排序呢?我们可以以一个java路线图来进行说明

上图就是我们的一个简略的java简单的路线图,这里我们必然是先学java基础,后面再来学习我们所需要的框架那些,我们无法先学框架,因为我们只有先学了java基础,才能看得懂框架的内容,因此这里的路线图是有优先级的,越左边的内容优先级越高,我们可以将其简化为下图所示

此时如果有一个同学想要直观地知道学习这些课程的顺序,要求课程按线性排列,那么我们就需要对图进行排序,这里就用得到我们的拓扑排序

那么现在我们就可以确定拓扑排序的作用了,拓扑排序能够将有向图的所有顶点排序,使得所有有向边均从前面的元素指向排在后面的元素,此时就可以明确表示出每个顶点的优先级,请看下图

那么上图就是我们经过拓扑排序之后得到的线形图,可以看到此时我们的优先级是从上往下依次递减的,而且前面的元素均指向后面的元素

检测有向环

但是我们使用拓扑排序前有一件事情需要注意,就是我们的有向图里是不能有环的,因为有环的有向图其优先级是无法判别的,我们的拓扑排序的使用是必须要保证我们的有向图里无环才可以使用的,因此我们要先设计检测有向图是否有环的方法

我们可以来看看其API设计

在实现这个API之前,我们先来讲讲我们是如何利用栈的思想构建onStack数组来确定有向图中是否有环的具体过程,请看下图

可以看到我们的onStack数组的原始状态是全为false的,此时代表还没有任何一个结点被检测过,我们首先从3开始搜索,那么3进栈,将索引3的值赋值为true,同理继续搜索0,将索引0赋为true,然后最后我们最搜索到0出,会发现此时0已经是true了,那么此时就停止遍历,立刻结束方法并判断该有向图中有环就可以了

我们这里不要求我们找出环的起点,也不要求我们找出有向图有几个环,我们只要能够确定其有环就可以了

那么综上我们可以构造代码如下

package algorithm.sort;
​
public class DirectedCycle {
    //索引代表顶点,值代表当前顶点是否已经被搜索
    private boolean[] marked;
    //记录图中是否有环
    private boolean hasCycle;
    //索引代表顶点,使用栈的思想,记录当前顶点是否已经处于正在搜索的有向路径上
    private boolean[] onStack;
​
    /*
     * 这里之所以要传入每一个顶点作为入口是因为我们的有向图可能是一个非联通图
     * 此时不传入所有顶点作为入口查找的话可能会查找不到有向环,而每次都要进行
     * 当前顶点是否已经被搜索的判断是为了避免重复搜索的情况,可以提升我们的程
     * 序的运行效率
     */
    //创建一个检测环对象,检测图G中是否有环
    public DirectedCycle(Digraph G){
        //初始化marked数组
        this.marked = new boolean[G.V()];
        //初始化hasCycle
        this.hasCycle = false;
        //初始化onStack数组
        this.onStack = new boolean[G.V()];
​
        //找到图中每一个顶点,让每一个顶点都作为入口调用dfs方法进行搜索
        for (int v = 0; v < G.V(); v++) {
            //如果当前顶点没有被搜索过则调用dfs进行搜索
            if(!marked[v]){
                dfs(G,v);
            }
        }
    }
​
    /*
     * 这个方法我不理解的是为什么最后要将顶点出栈,在我的演示里我认为即使不出栈
     * 也是没有任何问题的,但是在视频里又没有测试环节,所以我也不能够测试最后的
     * 这个出栈代码有什么必要性,所以先这样的,以后时间我自己再构造测试代码试试
     * 试了下,删除会造成空指针异常,给我整不会了
     */
    //利用深度优先搜索检测图G中是否有环
    private void dfs(Digraph G, int v){
        //把顶点v标记为已搜索
        marked[v] = true;
        //把当前顶点进栈
        onStack[v] = true;
        //进行深度搜索
        for (Object w: G.adj(v)) {
            //若当前顶点w没有被搜索过,则递归调用dfs方法进行深度优先搜索
            if(!marked[(int) w]){
                dfs(G, (Integer) w);
            }
​
            //若当前顶点已经在栈中,证明之前已经搜索过了,现在又要再搜索一次,说明有环
            if(onStack[(int) w]){
                hasCycle = true;
                return;//检测到环就立刻结束方法
            }
        }
        //把当前顶点出栈
        onStack[v] = false;
    }
​
    //判断有向图中是否有环
    public boolean hasCycle(){
        return hasCycle;
    }
}

顶点排序

在实现拓扑排序之前,我们可以先对拓扑排序进行分解,我们可以轻易地看到,进行拓扑排序时我们应该先将顶点按照顺序从上往下排列,这其实就是顶点排序,我们在实现拓扑排序前,应该要先实现顶点排序,我们先来看看顶点排序的API设计

我们这里进行顶点排序要用到栈的思想,接下来我们来具体讲讲我们是如何利用栈的思想来实现顶点排序的,请看下图

我们以0位顶点,先利用深度优先搜索不断定位其子节点,先定位到5,由于5没有任何的子节点,也没有兄弟结点,因此5先入栈,同时回到4,4中除了5之外也没有任何的子节点和兄弟结点,因此4入栈,接着回到2,2除了4这个子节点外没有其他的子节点,因此2入栈,接着到2的兄弟结点3,3入栈,接着回到0,0的两个子节点都遍历完了,则0入栈,此时以0为起点的深度优先搜索就完了,接着我们用1结点作为起点进行深度优先搜索,由于其子节点3已经搜索过了,因此我们将1结点入栈(这里其实还有2,3,4,5一个个作为起点的过程,不过我们省略了,因为这些结点都已经搜索过了,即使把他们当做起点进行搜索,到最后也是会无法进入搜索方法的),至此,我们的所有结点就入栈完毕了,而且是与我们的当初拓扑排序的优先级队列是一样的(不过我觉得这里有BUG,万一我是先放1的话那不是不符合了吗?只能说其应该是默认从小到大开始放令结点作为起点进行搜索吧)

综上我们可以构造代码如下

package algorithm.sort;
​
public class DepthFirstOrder {
    //索引代表顶点,值表示当前顶点是否已经被搜索
    private boolean[] marked;
    //使用栈,存储顶点序列
    private Stack<Integer> reversePost;
​
    //创建一个检测环对象,检测图G中是否有环
    public DepthFirstOrder(Digraph G){
        //初始化marked数组
        this.marked = new boolean[G.V()];
        //初始化reversePost栈
        this.reversePost = new Stack<Integer>();
​
        //遍历图中的每一个顶点并令其作为入口完成深度优先搜索
        for (int v = 0; v < G.V(); v++) {
            //若没被搜索过则作为起点调用深度首先搜素方法
            if(!marked[v]){
                dfs(G,v);
            }
        }
    }
​
    //基于深度优先搜索将顶点排序,排序后顶点放入栈中
    private void dfs(Digraph G, int v){
        //标记当前v已经被搜索
        marked[v] = true;
        for (Object w: G.adj(v)) { 
            //如果当前顶点w没有搜索,则递归调用dfs方法进行搜索
            if(!marked[(int) w]){
                dfs(G, (Integer) w);
            }
        }
        //遍历完该点的邻接表后令顶点v进栈
        reversePost.push(v);
    }
​
    //获取顶点线性序列
    public Stack<Integer> ReversePost(){
        return reversePost;
    }
}
​

拓扑排序实现

那么经过了前面的学习,现在我们要实现拓扑排序就比较简单了,首先我们先判断一个有向图有没有环,若无环我们则调用顶点排序就可以了。

那么我们来看看拓扑排序的API设计

那么根据API我们可以构造如下代码

package algorithm.sort;
​
public class TopoLogical {
    //顶点的拓扑排序
    private Stack<Integer> order;
​
    //构造拓扑排序对象(构造方法)
    public TopoLogical(Digraph G) {
        //创建一个检测有向环的对象
        DirectedCycle cycle = new DirectedCycle(G);
        //如果没有环则进行顶点排序——创造一个顶点排序对象
        if(!cycle.hasCycle()){
            DepthFirstOrder depthFirstOrder = new DepthFirstOrder(G);
            //将排序好的栈赋给order
            order = depthFirstOrder.reversePost();
        }
    }
​
    //判断图G是否有环
    private boolean isCycle(){
        //若其不为null则说明if语句成功执行了,则说明没有环
        return order==null;
    }
​
    //获取拓扑排序的所有顶点
    public Stack<Integer> order(){
        //返回排序好的栈对象
        return order;
    }
}

最终经过测试发现我们构造的代码没有任何问题

加权无向图

所谓加权无向图,简而言之就是给无向图的边加上距离或金钱属性,使我们的边具有属性,比方说,两个城市之间是连通的那么就有边,我们可以给边加上距离属性,那么就可以通过这个属性来计算距离,最终给用户导出一个从一个城市到另一个城市的最佳路径

在这里我们加上的属性就是权重,具体的解释请看下图

那么由于我们这里边时有权重的,那么我们表示边时就要用一个对象来去表示,当然我们要去构造这么一个类出来,让我们来看看其API设计

那么根据API设计我们可以实现加权边的代码如下

package algorithm.sort;
//加权边需要比较,因此要实现Comparable接口并提供比较方法
public class Edge implements Comparable<Edge>{
    private final int v;//顶点一
    private final int w;//顶点二
    private final double weight;//当前边的权重

    //通过顶点v和w以及权重weight构造一个边对象(构造方法)
    public Edge(int v,int w, double weight) {
        this.v = v;
        this.w = w;
        this.weight = weight;
    }

    //获取边的权重值
    public double weight(){
        return weight;
    }

    //获取边上的一个点
    public int either(){
        return v;
    }

    //获取边上除了顶点vertex外的另外一个顶点
    public int other(int vertex){
        //如果是v顶点则返回w,不是则返回v
        if(vertex==v){
            return w;
        }else {
            return v;
        }
    }

    //重写父类方法提供比较规则
    @Override
    public int compareTo(Edge that) {
        int cmp;//记录比较的结果
        if(this.weight()>that.weight()){
            //如果当前边的权重大,则让cmp=1
            cmp = 1;
        }else if(this.weight()<that.weight()){
            //如果当前边的权重大,则让cmp=-1
            cmp = -1;
        }else {
            //一样大则令cmp=0
            cmp = 0;
        }
        return cmp;
    }
}

那么实现了加权边之后,现在我们来实现加权无向图,先来看看其API设计

这里值得一提的是我们这里的泛型不是Interger类型的,而是我们自己定义的加权无向边Edge类型的,这是因为在加权无向图里的边都是加权无向边,而加权无向边并不是单纯的Interger类型的,而是由用户自己定义的属性的,因此这里泛型里填写Edge

那么我们可以构造代码如下

package algorithm.sort;

public class EdgeWeightedGraph {
    //顶点总数
    private final int V;
    //边的总数
    private int E;
    //邻接表,这里存储的对象是Edge,这里邻接表内不再存储另一个结点的值,而是存储边的权重
    //同样的,相同的权重也会出现在边所连接的两个顶点上
    private Queue<Edge>[] adj;

    //创造一个含有V个顶点的空加权无向图
    public EdgeWeightedGraph(int V){
        //初始化顶点数量
        this.V = V;
        //初始化边的数量
        this.E = 0;
        //初始化邻接表
        this.adj = new Queue[V];

        for (int i = 0; i < adj.length; i++) {
            adj[i] = new Queue<Edge>();
        }
    }

    //获取图中顶点的数量
    public int V() {
        return V;
    }

    //获取图中边的数量
    public int E() {
        return E;
    }

    /*
     * 这里我们创造加权无向边的方式是先创建Edge对象边,一条边里有两个结点
     * 然后在这个方法里获取到这两个结点,再将这两个结点分别加入到其邻接表
     * 中,相当于我们是先创建了两个结点和属性,然后这里是将结点连起来并
     * 赋予其属性值
     */
    //向加权无向图中添加一条边e
    public void addEdge(Edge e) {
        //加权无向图还是无向图,因此添加方式区别不大
        int v = e.either();//获得一个点
        int w = e.other(v);//获得另一个点

        adj[v].enqueue(e);
        adj[w].enqueue(e);

        //边的数量+1
        E++;
    }

    //获取和顶点v关联的所有边
    public Queue<Edge> adj(int v) {
        return adj[v];
    }

    /*
     * 此处我们令边只添加一次的方法是比较两个结点的值,如果一个结点值小于另一个则添加
     * 如果不小于则不添加。这个方法的实现前提是每一个结点的值都必不相同且按照顺序递增
     * 我猜测我们这里把<改成>也是可以实现我们的需求的
     */
    //获取加权无向图的所有边
    public Queue<Edge> edges() {
        //创建一个队列对象用于存储所有加权边
        Queue<Edge> allEdges = new Queue<>();

        //遍历图中每一个顶点的邻接表,每一个顶点的邻接表中存储了该顶点关联的每一条边
        //因为这是无向图,所以同一条边会出现在它关联的两个顶点中,而我们只需要让一条
        //边记录一次就可以了
        for (int v = 0; v < V; v++) {
            //遍历v顶点的邻接表,找到每一条和v关联的边
            for (Object e: adj(v)) {
                Edge E = (Edge) e;
                if(E.other(v)<v){
                    allEdges.enqueue(E);
                }
            }
        }
        return allEdges;
    }
}

加权有向图

学习完加权无向图之后,接着我们来学习加权有向图,与加权无向图一样的,我们同样也要先构造表示加权有向图的加权边的代码

我们先来看看其API设计

根据其API设计我们可以构造代码如下

package algorithm.sort;

public class DirectedEdge {
    private final int v;//起点
    private final int w;//终点
    private final double weight;//当前边的权重

    //通过顶点v和w,以及权重weight值构造一个边对象
    public DirectedEdge(int v,int w,double weight){
        this.v = v;
        this.w = w;
        this.weight = weight;
    }

    //获取边的权重值
    public double weight(){
        return weight;
    }

    //获取有向边的起点
    public int from(){
        return v;
    }

    //获取有向边的终点
    public int to(){
        return w;
    }
}

细看发现这代码居然还比加权无向边的还简单了不少,可还行。注意加权无向边需要比较,所以代码多,有向边没有提供比较规则就是

那么现在,我们就来实现加权有向图的数据结构,先来看看其API设计

这里加权有向图的加权边就不能跟无向图一样令加权值出现在两个结点的邻接表中了,而是只能出现一个,这里我们规定所有权值都要放在起点的结点中

那么我们可以构造其代码如下

package algorithm.sort;

public class EdgeWeightedDigraph {
    //顶点总数
    private final int V;
    //边的总数
    private int E;
    //邻接表
    private Queue<DirectedEdge>[] adj;

    //创建一个含有V个顶点的空加权有向图
    public EdgeWeightedDigraph(int V){
        //初始化顶点数量
        this.V = V;
        //初始化边的数量
        this.E = 0;
        //初始化邻接表
        this.adj = new Queue[V];

        for (int i = 0; i < adj.length; i++) {
            adj[i] = new Queue<DirectedEdge>();
        }
    }

    //获取图中顶点的数量
    public int V() {
        return V;
    }

    //获取图中边的数量
    public int E() {
        return E;
    }

    //向加权有向图中添加一条边e
    public void addEdge(DirectedEdge e) {
        //边e是有方向的,因此不能两个结点都添加权值
        //我们将权值只添加到起点,表示边的权值同时表示方向
        int v = e.from();//获取起点
        adj[v].enqueue(e);//在对应结点的邻接表上添加权值
        E++;
    }

    //获取由顶点v指出的所有边
    public Queue<DirectedEdge> adj(int v) {
        return adj[v];
    }

    //获取加权有向图的所有边
    public Queue<DirectedEdge> edges() {
        //遍历图中的每一个顶点,再遍历其中的邻接表,将其添加到队列中,最后队列中会含有图的所有边
        Queue<DirectedEdge> allEdges = new Queue<>();
        for (int v = 0; v < V; v++) {
            for (Object edge: adj[v]) {
                allEdges.enqueue((DirectedEdge) edge);
            }
        }
        return allEdges;
    }
}

最短路径

刚才我们学习了加权有向图,那么现在基于加权有向图,我们来学习最短路径,最短路径也就是在一副加权有向图中找出从一个顶点到另外顶点的权重最小的路径,这里就回到我们最开始的路径最小的问题了,这里的加权不但可以表示路径,还可以表示时间,我想未来时候实际运动我们的路径上的权值不止一个,会有时间,距离等多个加权值,我们可以通过这些加权值利用最短路径找出最合理的一条路来

比方说在上图的加权有向图中,从0到6的路径里,红色标记的路径是我们目标的最短路径,这里指的是路径上边的权值之和是最小的

接着我们来学习最短路径的性质

首先,路径具有方向性,也就是说,上图中红色的路径我们能是说0-6的路径,不能说成是6-0的路径,其次是权重不是单指举例,也可以指距离,时间,花费等内容,权重最小指他们某一个总和最低。

这里我们为了简化问题因此只考虑连通图,最后最短路径不一定是唯一的,从一个顶点到另外到另外一个顶点的权重最小的路径可能有多条,这里我们只要求找出其中一条就可以了

接着我们再来学习下最短路径树,先看定义

比方说我们定义0为根结点,那么0之后对应的结点都是及其边所构成的树会存在路径,而其路径里都是其最小权重的路径

接着我们来看看其API设计

我们看到我们这里有一个松弛概念,那到底什么是松弛呢?我们先来解决这个问题,先来看看其概念吧

这里v,w是两个顶点,而s是根结点,其实简单来说所谓松弛就是比较两个路径的总权重,看谁更小,谁更小就将其值更新到我们保存最小边的对应成员变量中,来看看图示

这里我们先假设存放最短路径树的edgeTo[]成员变量先存储了下面的黑色路径为最短路径,然后我们的distTo[]存放对应结点到根结点的路径的总权重,此时左图中我们要松弛vw,那么就要比较从根结点s到v再到w的总权重与当前存储的总权重的大小,可以看到最短路径的总权重为3.3,之后我们要松弛的边的总权重为4.4,那么我们要松弛的边比最小路径的总权重还要大,那么我们就忽略vw,不更新数据

而在右图中要松弛的边的总权重仍然为4.4,但是最短路径的总权重却变成了7.2,此时我们的最小路径大于要松弛的边,因此就要进行松弛,那么就要更新edgeTo[]的数据和distTo[]的数据,将edgeTo[w]的数据从原来的最短路径的边对象改成从v到w的边对象,然后我们从将从s根结点到w结点的最小权重改为更新后的最短路径的总权重,即4.4,也就是distTo[w]=4.4

上面我们学习的是边的松弛,学习完了边的松弛之后我们来学习顶点的松弛,那么什么是顶点的松弛呢?其实顶点的松弛就是将一个顶点的所有边进行松弛,当一个顶点的所有边都松弛完了,我们就说顶点松弛完毕了,用代码语言来说就是如果我们要松弛某个顶点,那么只要将其邻接表中的所有边都松弛完,我们就算是将整个顶点给松弛完了

最后我们来看看假设我们设置起点为0,那么来看看其松弛的图示

假设我们设置0位根结点,那么此时也是要用到切分定理的,我们将0设置为根结点的同时默认此时我们的最小路径树了只有0,其他结点都为其他集合,我们从加权边中最小的值开始定位加权边,比如说我们的0结点有两个加权边,分别是0-2和0-4,最后我们发现0-2小,那么就对0-2的边进行松弛,松弛之后将0-2也加入最短路径树中,然后我们的最小生成树就有两个加权边,分别是0-4和2-7,此时同样选择最小的边进行松弛,这样不断周而复始,最终能够构造出一个最短路径树出来

假设我们要找6的最短路径,那么就用pathTo存放某一结点的上一个结点指向其的结点的数组,从中找到6的上一个结点为3,接着是7,2,0,然后定位到0是根结点,因此不用再继续定位,那么最后我们的起点0到6的最短路径就找到了,其为0-2-7-3-6

最终我们实现的这个算法叫Dijkstra算法

那么综上我们可以构造代码如下

package algorithm.sort;

public class DijkstraSP {
    //索引代表顶点,值表示从根结点s到当前结点的最短路径上的最后一条边
    private DirectedEdge[] edgeTo;
    //索引代表顶点,值表示从根结点s到当前结点的最短路径的总权重
    private double[] distTo;
    //存放树中顶点与非树中顶点之间的有效横切边
    private IndexMinPriorityQueue<Double> pq;

    //根据一副加权有向图G和根结点s,创造一个计算根结点s的最短路径树对象
    public DijkstraSP(EdgeWeightedDigraph G,int s){
        //初始化edgeTo
        this.edgeTo = new DirectedEdge[G.V()];
        //初始化distTo
        this.distTo = new double[G.V()];
        for (int i = 0; i < distTo.length; i++) {
            distTo[i] = Double.POSITIVE_INFINITY;
        }
        //初始化pq
        this.pq = new IndexMinPriorityQueue<>(G.V());

        //全部初始化完毕之后要执行查找以根结点s为起点的最短路径树

        //默认以顶点s进入到最短路径树中
        distTo[s] = 0.0;//起点到起点没有边,因此赋值0.0,代表没有权值
        pq.insert(s,0.0);

        //遍历pq,给全部结点进行松弛
        while (!pq.isEmpty()){
            relax(G,pq.delMin());
        }
    }

    //松弛图G的顶点v
    private void relax(EdgeWeightedDigraph G,int v){

        for (Object edge:G.adj(v)) {
            //获取到该边的终点w
            int w = ((DirectedEdge)edge).to();
            //如果指定顶点的总权重与边权重之和小于当前记录的到该结点的最小权重
            if(distTo(v)+((DirectedEdge) edge).weight()<distTo(w)){
                //则更新对应顶点的最小权重和的数据以及到该顶点的最小权重边
                distTo[w] = distTo[v]+((DirectedEdge) edge).weight();
                edgeTo[w] = (DirectedEdge) edge;

                //判断pq中是否已经存在另一个结点w
                if(pq.contains(w)){
                    //若存在则更新其当前最小路径树的有效横切边的数据,其现实动作可以理解为
                    //改变了该横切边,使该横切边变成总权重更小的横切边
                    pq.changeItem(w,distTo(w));
                }else {
                    //若不存在则直接将点和该最小横切边插入
                    pq.insert(w,distTo(w));
                }
            }
        }
    }

    //获取从顶点s到顶点v的最短路径的总权重
    public double distTo(int v){
        return distTo[v];
    }

    //判断从顶点s到顶点v是否可达
    public boolean hasPathTo(int v){
        return distTo[v]<Double.POSITIVE_INFINITY;
    }

    //查询从起点s到顶点v的最短路径中所有的边
    public Queue<DirectedEdge> pathTo(int v){
        //判断从顶点s到顶点v是否可达,如果不可达,直接返回null
        if(!hasPathTo(v)){
            return null;
        }
        //创建队列对象
        Queue<DirectedEdge> allEdges = new Queue<>();

        while (true){
            DirectedEdge e = edgeTo[v];
            //当e为起点时,没有任何边指向它,其edgeTo里存放的值是null
            //因此当我们的e为null时,说明已经到了根结点
            if(e==null){
                break;
            }
            allEdges.enqueue(e);

            v = e.from();
        }
        return allEdges;
    }
}

值得一提的是,作为最后的一个数据结构的代码,这个代码的解释并不多,这是因为,大部分解释和前面都是雷同的,很容易理解,我们都学习到最后一章了,不用注释看懂这个代码也得是我们的基本功了,因此这里不写注释,以后也好用来当做给自己的一个测试

最小生成树

我们不妨先来看看最小生成树的定义,先搞明白到底什么是最小生成树

最小生成树定义

首先图的生成树是其一颗含有所有顶点的无环连通子图,这里有三个重点,一是含有所有顶点,二是无环,三是连通子图,最小生成树则是一副加权无向图中一颗权值之和最小的生成树,比方说在这里,红色边与其连接的顶点组成了该图的最小生成树

同时为了便于我们的理解,我们这里做两个约定,一是我们的图只考虑连通图,二是我们的图的每一条边的权重是不能相同的。这两个约定的出现主要是为了便于我们理解和实现最小生成树的数据结构

切分定理

那我们要如何从一副图中找到其最小生成树呢?这就要用到切分定理

在我们正式去实现最小生成树的数据结构之前,我们必须先学习切分定义,而如果我们要学习切分定理,我们就要先来学习一些概念上的知识,首先是关于最小生成树的性质

接着我们要先了解一些相关术语代表的意思,首先是切分和横切边

切分指的是将所有顶点按照某种规则将其分为两个非空无交集的集合

横切边指的是连接像个属于不同顶点的横切边,比方说在上图里,黑色集合和白色集合是两个非空无交集的集合,而上图里的黑色线就都属于横切边

那么学习完了上面的知识之后,我们现在正式来学习下什么是切分定理

所谓切分定理,即是在一副加权图中,给定任意的切分,那么其横切边中权重最小的边必然属于图中的最小生成树。这里值得注意的是,权重最小的边不一定是所有横切边中唯一属于图的最小生成树的边

比方说在上图中ef,虽然f在这一次的切分中不是最小生成树的边,但是在其他的切分中,其就有可能是最小生成树的边

那么学习完切分定理之后,我们来学习由切分定理为基础的构造的贪心算法

我本来想写一大堆关于这个贪心算法的分析的,但是我认真看了之后我属实整不明白他的切分规则到底是怎么设置的,似乎它是在瞎几把切,反正大体思路是看整个子图,先找到未有红色标记的边,然后观察整个图,用一定的规则将其切分成两个集合(每次切分时不用管前面所定义的集合),最后我们找到权重最小的边,这个边就是其最小生成树的边,最后我们发现我们所标注的红边已经把所有的点都连接起来的时候,我们的贪心算法就完成了,此时我们就找到了最小生成树,包含V-1条边

计算最小生成树的方法有很多种,但无论是哪种都可以看做是贪心算法的特殊情况,这些算法的不同在于保存切分和判定权重最小的方式

那么接下来我们来学习第一种计算最小生成树的方法,Prim算法

该算法的原理是先将图中的任意一个结点当做最小生成树的一个结点,然后切分定理将该结点集合与其他结点集合找出权重最小的横切边,将该横切边连接的另一个结点也当做是最小生成树的一部分,然后继续进行上面所说的切分过程,直到最小生成树里连接了所有的边

光说定义可能不好理解,我们直接来看图吧

比方说在这里,利用Prim算法先将1结点视为最小生成树的一个结点,而其他的则是非最小生成树的结点,那么其横切边就容易找到,假设权重最小的横切边是1-2之间的边,那么改变就是最小生成树的边,将该边连接的两个结点都视为是最小生成树的一部分,然后继续进行切分,此时又会产生横切边,假设产生的最小横切边是2-3之间的边,那么同样将该边连接的结点视为最小生成树的一部分,与之前的一部分共同组成新的最小生成树,然后继续切分.......

Prim算法

接下来让我们来看看其API设计

其中edgeTo表示当前顶点和最小生成树的最短边,因为可能存在当前顶点与最小生成树存在多个边的情况,比如对于前一张图的w,我们假设其连接了两个最小生成树的顶点,那么此时我们这里的edgeTo数组的值就是保存当前顶点和最小生成树直接的最短边,其中索引表示顶点

而第四个索引优先队列的存在意义是存放树中与非树中顶点的有效横切边,由于其是索引优先队列,因此我们能利用其数据结构的特性来达到快速找出最小权重的横切边

学习完了上面的内容之后,我们再来学习下Prim算法的实现原理,请看下图

8指的是总结点树,16指的是总边数,这里初始化默认0位最小生成树的唯一结点,那么和0相连的边就是横切边,此时我们将对应的结点与最小生成树的边加入到索引优先队列中

那么接下来我们找出其中最小的边,显然是0-7的边,那么其必然是最小生成树的边,那么我们就将该边和该边的结点添加到最小生成树中,其他的就不是最小生成树的边了,那么此时由于0-7这条边我们已经加入到最小生成树中了,其已经不是横切边了,因此我们需要将0-7的边从索引优先队列中消除,然后我们对新的生成子树做重复的上述动作给我们的索引优先队列完成添加,但此时我们要注意的是,此时2结点和4结点都有两条指向最小生成树的链接,此时我们要判断这两条连接的大小,我们只将小的连接的边加入到索引优先队列中,最后完成添加,然后继续重复上述动作,直到完成添加

这里使用索引优先队列的缘故是我们不但要查找处最小权重,还需要对权重值进行修改,因此用能够完成修改的索引优先队列

那么综上我们可以构造代码如下

package algorithm.sort;

public class PrimMST {
    //索引代表顶点,值表示当前顶点和最小生成树的最短边
    //该成员变量有后面用于获取最小生成树的重要作用
    private Edge[] edgeTo;
    //索引代表顶点,值表示当前顶点和最小生成树的最短边的权重
    private double[] distTo;
    //索引代表顶点,如果当前顶点已在最小生成树中则标记为true,反之则为false
    private boolean[] marked;
    //存放树中顶点与非树中顶点的有效横切边
    private IndexMinPriorityQueue<Double> pq;

    //根据一副加权无向图,创造最小生成树创建计算对象(构造方法)
    public PrimMST(EdgeWeightedGraph G){
        //初始化edgeTo
        this.edgeTo = new Edge[G.V()];
        //初始化distTo
        this.distTo = new double[G.V()];
        for (int i = 0; i < distTo.length; i++) {
            //赋予结点的权值为最大,便于后面不断去更新最小的权值
            distTo[i] = Double.POSITIVE_INFINITY;
        }
        //初始化marked,成员变量默认全部赋值为false
        this.marked = new boolean[G.V()];
        //初始化pq
        pq = new IndexMinPriorityQueue<Double>(G.V());

        //默认让0进入到最小生成树中,此时0结点不关联任何边,因此其权值赋值为0.0
        distTo[0]=0.0;
        pq.insert(0,0.0);
        /*
         * 遍历索引最小优先队列,拿到最小横切边对应的顶点并将该顶点加入到最小生成树中
         * 这里调用delMin()方法会自动删除最小的索引并且返回被删除的索引,而索引代表
         * 结点,因此visit();方法这样构造代码可以理解为队列里执行了出队列的动作,并
         * 且同时将弹出的元素,也就是结点,和我们要寻找最小生成树的加权无向图一并传给
         * 了visit方法
         */
        while (!pq.isEmpty()){
            visit(G, pq.delMin());
        }
    }

    /*
     * 这里上面构建了while循环所以可以重复调用visit方法,每调用一次就会从队列中删除掉最小的结点
     * 但是在visit方法中,又会往pq中添加最小生成树的对应点和横切边,因此其能够实现不断调用,最后
     * 成功生成了最小生成子树时,不断调用visit方法的结果都不会再执行往pq中增添新结点和新边的代码
     * 最后其能够成功结束这个循环,然后此时保存在edgeTo中的边就是我们所需要的最小生成树的边,索引
     * 代表对应的结点
     */
    //将顶点v添加到最小生成树中,并且更新数据
    private void visit(EdgeWeightedGraph G,int v){
        //将该顶点标记为已加入最小生成树中的状态
        marked[v] = true;
        //将该顶点加入最小生成树,此处的e表示是对应的v结点的边,随着遍历的进行,其所代表的边也在不断变化
        for (Object e: G.adj(v)) {
            //获取e边上的另外一个顶点w(当前顶点是v)
            int w = ((Edge)e).other(v);
            //判断另外一个顶点是否已经在树中,若已经在树中则跳过添加
            if(marked[w]){
                continue;
            }
            //代码执行到此说明不在树中
            //判断边e的权重是否小于从w顶点到树中已经存在的最短边的权重,之所以用w,是因为这里的w就是
            //e边所对应的结点,对于第一次添加的结点而言distTo[w]自然是最大的,但是如果是第二次添加
            //就可能存在distTo[w]比起更大或更小的情况,这里你如果更小,那么就说明有必要更新最小权值
            //以及最小边
            if(((Edge) e).weight()<distTo[w]){
                //小于则更新最短边为该边
                edgeTo[w] = (Edge) e;
                //更新最小权值为该权值
                distTo[w] = ((Edge) e).weight();
                //判断pq中是否已经存放了该点的横切边
                if(pq.contains(w)){
                    //若已经存放则改变对应横切边的权值
                    pq.changeItem(w,((Edge) e).weight());
                }else {
                    //没有则添加新点到pq中
                    pq.insert(w,((Edge) e).weight());
                }
            }
        }
    }

    //获取最小生成树的所有边
    public Queue<Edge> edges() {
        //创建队列对象
        Queue<Edge> allEdges = new Queue<>();
        //遍历edgeTo数组,拿到每一条边,如果不为null,则添加到队列中,edgeTo里存放的是edge对象
        //是程序员自己定义的对象,如果没有赋值的话,调用构造方法时默认赋值为null,因此我们这里可以
        //采用这种方式进行边的添加
        for (int i = 0; i < edgeTo.length; i++) {
            if(edgeTo[i]!=null){
                allEdges.enqueue(edgeTo[i]);
            }
        }
        return allEdges;
    }
}

kruskal算法

那么我们在学习完了prim算法来查找加权无向图的最小生成树之后,现在我们来学习另外一种查找最小生成树的算法,kruskal算法。在学习kruskal算法之前,我们同样的也要先了解该算法的原理

这里讲似乎不太明了,但是我们可以用演示和与prim算法的对比来直观感受kruskal算法

简而言之,prim算法时将一个无向加权图的一个结点默认为最小生成树,然后利用该结点遍历完整个树,每次切分添加一条边最终构造出整个最小生成树。而kruskal算法则是会将整个无向加权图视作一个森林,无向图中有几个结点,里面就有几棵树,而kruskal算法每一次处理都会将两棵树连接成一棵树,这个过程是通过将两棵树看做两个集合,然后运用切分定理每次找出最小横切边,然后将两个树利用这条边结合成一棵树,这样不断周而复始最终构造出一个最小生成树

那么现在我们来看看kruskal算法的API设计

在实现其数据结构之前,我们先来看看其实现原理

简单来说,kruskal算法的实现原理是先用最小优先队列存储所有的加权边,然后每次取出其最小边和该边的两个顶点,然后通过并查集判断其是否连通,若联通则跳过,反之则将两节点添加入并查集中形成一棵树,然后将该边加入到mst中,最终mst中存储的边就是最小生成树的所有加权边

我们不妨来看看图示

这里我们先将最小加权边0.16联立,判断其是否在并查集上,也就是判断是否已经在一棵树上了,不是,那我们就将这两点都加入到并查集中,就相当于是把两棵树合并成一颗,然后同样的过程添加0.17第二小的加权边,也是移除了0.16之后最小的加权边,用同样的过程将其添加

依葫芦画瓢,我不多讲这里

最后我们添加玩0.4加权边以及62两个结点之后,剩下的三个加权边都会因为在并查集判断里无法通过而不加入

那么最后综上我们可以构造其代码如下

package algorithm.sort;

public class KruskalMST {
    //保存最小生成树的所有边
    private Queue<Edge> mst;
    //索引代表顶点,使用uf.connect(v,w)来判断两结点是否在同一棵树中
    //若不在则调用uf.union(v,w)将顶点v所在的树与w所在的树合并
    private UF_Tree_Weighted uf;
    //存储图中所有的边,使用最小优先队列,按照权重进行排序
    private MinPriorityQueue<Edge> pq;

    //根据一副加权无向图,创造最小生成树计算对象
    public KruskalMST(EdgeWeightedGraph G){
        //初始化mst
        this.mst = new Queue<Edge>();
        //初始化uf,由于uf存储的是边,因此这里指定的大小是无向图中边的数量
        this.uf = new UF_Tree_Weighted(G.V());
        //初始化pq,注意我们的最小优先队列是利用堆实现的,而堆中是把数组的第一个位置舍弃不用了
        //因此理论上我们应该要在指定的空间上+1的,但是这里不加也可以,因为我们最小优先队列的
        //源码里就已经先进行过+1的操作了
        this.pq = new MinPriorityQueue<>(G.E());
        //将图中的所有边存储到pq中,这里的添加操作也是后续while能够持续运作的前提
        for (Object e:G.edges()) {
            pq.insert((Edge) e);
        }

        //遍历pq队列,每次拿到权重最小的边并进行添加动作

        //pq不为空时继续循环很好理解,但是为什么这里要求mst存放的最小生成树的边不大于其结点数
        //-1呢?这是因为对于任何一个加权无向图而言,如果其有最小生成树,那么其最小生成树的边的
        //数量就正好等于其结点的数量-1,因此这里加上这个条件,可以提高我们程序的运行效率,避免
        //重复无意义的运算
        while (!pq.isEmpty()&&mst.size()<G.V()-1){
            //找到权重最小的边
            Edge e = pq.delMin();
            //找到改变的两个顶点
            int v = e.either();
            int w = e.other(v);

            //若两个顶点已经在同一个树中则跳过添加动作
            if (uf.connect(v,w)){
                continue;
            }

            //代码执行到此说明两顶点不在同一颗树中,执行添加动作
            uf.union(v,w);//执行将两棵树合并的动作

            //令添加的边e进入到mst的队列中
            mst.enqueue(e);
        }
    }

    //获取最小生成树的所有边
    public Queue<Edge> edges() {
        return mst;
    }
}

说实话,这可比prim算法好理解多了,代码也好写多了