官方解释:
数据结构是一门研究非数值计算的程序设计问题中的操作对象,以及他们之间的关系和操作等相关问题的学科。
大白话:
数据结构就是把数据元素按照一定的关系组织起来的集合,用来组织和存储数据
传统上,我们可以把数据结构分为逻辑结构和物理结构两大类。
逻辑结构是从具体问题中抽象出来的模型,是抽象意义上的结构,按照对象中数据元素之间的相互关系分类,也是
我们后面课题中需要关注和讨论的问题。
a.集合结构:集合结构中数据元素除了属于同一个集合外,他们之间没有任何其他的关系。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vloCjchP-1681958360316)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930324.png)]
b.线性结构:线性结构中的数据元素之间存在一对一的关系
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-urTNfnpI-1681958360319)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930326.png)]
c.树形结构:树形结构中的数据元素之间存在一对多的层次关系
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YMq2XnQZ-1681958360320)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930329.png)]
d.图形结构:图形结构的数据元素是多对多的关系
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cPNwPc28-1681958360321)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930330.png)]
逻辑结构在计算机中真正的表示方式(又称为映像)称为物理结构,也可以叫做存储结构。常见的物理结构有顺序
存储结构、链式存储结构。
顺序存储结构:
把数据元素放到地址连续的存储单元里面,其数据间的逻辑关系和物理关系是一致的 ,比如我们常用的数组就是
顺序存储结构。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yfujYDJn-1681958360322)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930331.png)]
顺序存储结构存在一定的弊端,就像生活中排时也会有人插队也可能有人有特殊情况突然离开,这时候整个结构都
处于变化中,此时就需要链式存储结构。
链式存储结构:
是把数据元素存放在任意的存储单元里面,这组存储单元可以是连续的也可以是不连续的。此时,数据元素之间并
不能反映元素间的逻辑关系,因此在链式存储结构中引进了一个指针存放数据元素的地址,这样通过地址就可以找
到相关联数据元素的位置。
官方解释:
算法是指解题方案的准确而完整的描述,是一系列解决问题的清晰指令,算法代表着用系统的方法解决问题的策略
机制。也就是说,能够对一定规范的输入,在有限时间内获得所要求的输出。
**大白话:**根据一定的条件,对一些数据进行计算,得到需要的结果。
研究算法的最终目的就是如何花更少的时间,如何占用更少的内存去完成相同的需求。
我们要计算算法时间耗费情况,首先我们得度量算法的执行时间,那么如何度量呢?
事后分析估算方法:
比较容易想到的方法就是我们把算法执行若干次,然后拿个计时器在旁边计时,这种事后统计的方法看上去的确不
错,并且也并非要我们真的拿个计算器在旁边计算,因为计算机都提供了计时的功能。这种统计方法主要是通过设
计好的测试程序和测试数据,利用计算机计时器对不同的算法编制的程序的运行时间进行比较,从而确定算法效率
的高低,但是这种方法有很大的缺陷:必须依据算法实现编制好的测试程序,通常要花费大量时间和精力,测试完
了如果发现测试的是非常糟糕的算法,那么之前所做的事情就全部白费了,并且不同的测试环境(硬件环境)的差别
导致测试的结果差异也很大。
public static void main(String[] args) {
long start = System.currentTimeMillis();
int sum = 0;
int n=100;
for (int i = 1; i <= n; i++) {
sum += i;
}
System.out.println("sum=" + sum);
long end = System.currentTimeMillis();
System.out.println(end-start);
}
事前分析估算方法:
在计算机程序编写前,依据统计方法对算法进行估算,经过总结,我们发现一个高级语言编写的程序程序在计算机
上运行所消耗的时间取决于下列因素:
1.算法采用的策略和方案;
2.编译产生的代码质量;
3.问题的输入规模(所谓的问题输入规模就是输入量的多少);
4.机器执行指令的速度;
由此可见,抛开这些与计算机硬件、软件有关的因素,一个程序的运行时间依赖于算法的好坏和问题的输入规模。
如果算法固定,那么该算法的执行时间就只和问题的输入规模有关系了。
我么再次以之前的求和案例为例,进行分析。
需求:
计算1到100的和。
第一种解法:
//如果输入量为n为1,则需要计算1次;
//如果输入量n为1亿,则需要计算1亿次;
public static void main(String[] args) {
int sum = 0;//执行1次
int n=100;//执行1次
for (int i = 1; i <= n; i++) {//执行了n+1次
sum += i;//执行了n次
}
System.out.println("sum=" + sum);
}
第二种解法:
//如果输入量为n为1,则需要计算1次;
//如果输入量n为1亿,则需要计算1次;
public static void main(String[] args) {
int sum = 0;//执行1次
int n=100;//执行1次
sum = (n+1)*n/2;//执行1次
System.out.println("sum="+sum);
}
因此,当输入规模为n时,第一种算法执行了1+1+(n+1)+n=2n+3次;第二种算法执行了1+1+1=3次。如果我们把
第一种算法的循环体看做是一个整体,忽略结束条件的判断,那么其实这两个算法运行时间的差距就是n和1的差
距。
为什么循环判断在算法1里执行了n+1次,看起来是个不小的数量,但是却可以忽略呢?我们来看下一个例子:
需求:
计算100个1+100个2+100个3+…100个100的结果
代码:
public static void main(String[] args) {
int sum=0;
int n=100;
for (int i = 1; i <=n ; i++) {
for (int j = 1; j <=n ; j++) {
sum+=i;
}
}
System.out.println("sum="+sum);
}
上面这个例子中,如果我们要精确的研究循环的条件执行了多少次,是一件很麻烦的事情,并且,由于真正计算和
的代码是内循环的循环体,所以,在研究算法的效率时,我们只考虑核心代码的执行次数,这样可以简化分析。
我们研究算法复杂度,侧重的是当输入规模不断增大时,算法的增长量的一个抽象(规律),而不是精确地定位需要
执行多少次,因为如果是这样的话,我们又得考虑回编译期优化等问题,容易主次跌倒。
我们不关心编写程序所用的语言是什么,也不关心这些程序将跑在什么样的计算机上,我们只关心它所实现的算
法。这样,不计那些循环索引的递增和循环终止的条件、变量声明、打印结果等操作,最终在分析程序的运行时间
时,最重要的是把程序看做是独立于程序设计语言的算法或一系列步骤。我们分析一个算法的运行时间,最重要的
就是把核心操作的次数和输入规模关联起来。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pT5IrW7a-1681958360323)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930332.png)]
概念:
给定两个函数f(n)和g(n),如果存在一个整数N,使得对于所有的n>N,f(n)总是比g(n)大,那么我们说f(n)的增长渐近
快于g(n)。
概念似乎有点艰涩难懂,那接下来我们做几个测试。
测试一:
假设四个算法的输入规模都是n:
算法A1要做2n+3次操作,可以这么理解:先执行n次循环,执行完毕后,再有一个n次循环,最后有3次运算;
算法A2要做2n次操作;
算法B1要做3n+1次操作,可以这个理解:先执行n次循环,再执行一个n次循环,再执行一个n次循环,最后有 1 次运算。
算法B2要做3n次操作;
那么,上述算法,哪一个更快一些呢?
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-epnF5Whb-1681958360324)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930333.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ur7s2OLG-1681958360325)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930334.png)]
通过数据表格,比较算法A1和算法B1:
当输入规模n=1时,A1需要执行5次,B1需要执行4次,所以A1的效率比B1的效率低;
当输入规模n=2时,A1需要执行7次,B1需要执行7次,所以A1的效率和B1的效率一样;
当输入规模n>2时,A1需要的执行次数一直比B1需要执行的次数少,所以A1的效率比B1的效率高;
所以我们可以得出结论:
当输入规模n>2时,算法A1的渐近增长小于算法B1 的渐近增长
通过观察折线图,我们发现,随着输入规模的增大,算法A1和算法A2逐渐重叠到一块,算法B1和算法B2逐渐重叠
到一块,所以我们得出结论:
随着输入规模的增大,算法的常数操作可以忽略不计
测试二:
假设四个算法的输入规模都是n:
算法C1需要做4n+8次操作
算法C2需要做n次操作
算法D1需要做2n^2次操作
算法D2需要做n^2次操作
那么上述算法,哪个更快一些?
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GMe11OXM-1681958360325)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930335.png)]
通过数据表格,对比算法C1和算法D1:
当输入规模n<=3时,算法C1执行次数多于算法D1,因此算法C1效率低一些;
当输入规模n>3时,算法C1执行次数少于算法D1,因此,算法D2效率低一些,
所以,总体上,算法C1要优于算法D1.通过折线图,对比对比算法C1和C2:
随着输入规模的增大,算法C1和算法C2几乎重叠
通过折线图,对比算法C系列和算法D系列:
随着输入规模的增大,即使去除n^2前面的常数因子,D系列的次数要远远高于C系列
因此,可以得出结论:
随着输入规模的增大,与最高次项相乘的常数可以忽略
测试三:
假设四个算法的输入规模都是n:
算法E1: 2n^2+3n+1;
算法E2: n^2
算法F1: 2n^3+3n+1
算法F2: n^3
那么上述算法,哪个更快一些?
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gALThtrJ-1681958360326)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930336.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nCyWP4zR-1681958360327)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930338.png)]
通过数据表格,对比算法E1和算法F1:
当n=1时,算法E1和算法F1的执行次数一样;
当n>1时,算法E1的执行次数远远小于算法F1的执行次数;
所以算法E1总体上是由于算法F1的。
通过折线图我们会看到,算法F系列随着n的增长会变得特块,算法E系列随着n的增长相比较算法F来说,变得比较
慢,所以可以得出结论:
最高次项的指数大的,随着n的增长,结果也会变得增长特别快
测试四:
假设五个算法的输入规模都是n:
算法G: n^3;
算法H: n^2;
算法I: n:
算法J: logn
算法K: 1
那么上述算法,哪个效率更高呢?
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pQFM4oft-1681958360327)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930339.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SVqCZQZq-1681958360328)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930340.png)]
通过观察数据表格和折线图,很容易可以得出结论:
算法函数中n最高次幂越小,算法效率越高
总上所述,在我们比较算法随着输入规模的增长量时,可以有以下规则:
1. 算法函数中的常数可以忽略;
2. 算法函数中最高次幂的常数因子可以忽略;
3. 算法函数中最高次幂越小,算法效率越高。
定义:
在进行算法分析时,语句总的执行次数T(n)是关于问题规模n的函数,进而分析T(n)随着n的变化情况并确定T(n) 的量级。算法的时间复杂度,就是算法的时间量度,记作:T(n)=O(f(n))。它表示随着问题规模n的增大,算法执行时间 的增长率和f(n)的增长率相同,称作算法的渐近时间复杂度,简称时间复杂度,其中f(n)是问题规模n的某个函数。
在这里,我们需要明确一个事情:执行次数=执行时间
用大写O()来体现算法时间复杂度的记法,我们称之为大O记法。一般情况下,随着输入规模n的增大,T(n)增长最慢的算法为最优算法。
下面我们使用大O表示法来表示一些求和算法的时间复杂度:
算法一:
public static void main(String[] args) {
int sum = 0;//执行1次
int n=100;//执行1次
sum = (n+1)*n/2;//执行1次
System.out.println("sum="+sum);
}
算法二:
public static void main(String[] args) {
int sum = 0;//执行1次
int n=100;//执行1次
for (int i = 1; i <= n; i++) {
sum += i;//执行了n次
}
System.out.println("sum=" + sum);
}
算法三:
public static void main(String[] args) {
int sum=0;//执行1次
int n=100;//执行1次
for (int i = 1; i <=n ; i++) {
for (int j = 1; j <=n ; j++) {
sum+=i;//执行n^2次
}
}
System.out.println("sum="+sum);
}
如果忽略判断条件的执行次数和输出语句的执行次数,那么当输入规模为n时,以上算法执行的次数分别为:
算法一:3次
算法二:n+3次
算法三:n^2+2次
如果用大O记法表示上述每个算法的时间复杂度,应该如何表示呢?基于我们对函数渐近增长的分析,推导大O阶
的表示法有以下几个规则可以使用:
1.用常数1取代运行时间中的所有加法常数;
2.在修改后的运行次数中,只保留高阶项;
3.如果最高阶项存在,且常数因子不为1,则去除与这个项相乘的常数;
所以,上述算法的大O记法分别为:
算法一:O(1)
算法二:O(n)
算法三:O(n^2)
一般含有非嵌套循环涉及线性阶,线性阶就是随着输入规模的扩大,对应计算次数呈直线增长,例如:
public static void main(String[] args) {
int sum = 0;
int n=100;
for (int i = 1; i <= n; i++) {
sum += i;
}
System.out.println("sum=" + sum);
}
上面这段代码,n=100,也就是说,外层循环每执行一次,内层循环就执行100次,那总共程序想要从这两个循环
中出来,就需要执行100*100次,也就是n的平方次,所以这段代码的时间复杂度是O(n^2).
一般嵌套循环属于这种时间复杂度
public static void main(String[] args) {
int sum=0,n=100;
for (int i = 1; i <=n ; i++) {
for (int j = 1; j <=n ; j++) {
sum+=i;
}
}
System.out.println(sum);
}
上面这段代码,n=100,也就是说,外层循环每执行一次,内层循环就执行100次,那总共程序想要从这两个循环
中出来,就需要执行100*100次,也就是n的平方次,所以这段代码的时间复杂度是O(n^2).
一般三层嵌套循环属于这种时间复杂度
public static void main(String[] args) {
int x=0,n=100;
for (int i = 1; i <=n ; i++) {
for (int j = i; j <=n ; j++) {
for (int j = i; j <=n ; j++) {
x++;
}
}
}
System.out.println(x);
}
上面这段代码,n=100,也就是说,外层循环每执行一次,中间循环循环就执行100次,中间循环每执行一次,最
内层循环需要执行100次,那总共程序想要从这三个循环中出来,就需要执行100*100*100次,也就是n的立方,所以这段代码的时间复杂度是O(n^3).
对数,属于高中数学的内容,我们分析程序以程序为主,数学为辅,所以不用过分担心。
int i=1,n=100;
while(i<n){
i = i*2;
}
由于每次i*2之后,就距离n更近一步,假设有x个2相乘后大于n,则会退出循环。由于是2^x=n,得到x=log(2)n,所
以这个循环的时间复杂度为O(logn);
对于对数阶,由于随着输入规模n的增大,不管底数为多少,他们的增长趋势是一样的,所以我们会忽略底数。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ciLmaMn7-1681958360329)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930341.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-U7aqE66O-1681958360329)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930342.png)]
一般不涉及循环操作的都是常数阶,因为它不会随着n的增长而增加操作次数。例如:
public static void main(String[] args) {
int n=100;
int i=n+2;
System.out.println(i);
}
上述代码,不管输入规模n是多少,都执行2次,根据大O推导法则,常数用1来替换,所以上述代码的时间复杂度
为O(1) 下面是对常见时间复杂度的一个总结:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SbcYCKSv-1681958360330)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930343.png)]
他们的复杂程度从低到高依次为:
O(1)
根据前面的折线图分析,我们会发现,从平方阶开始,随着输入规模的增大,时间成本会急剧增大,所以,我们的
算法,尽可能的追求的是O(1),O(logn),O(n),O(nlogn)这几种时间复杂度,而如果发现算法的时间复杂度为平方
阶、 立方阶或者更复杂的,那我们可以分为这种算法是不可取的,需要优化。
之前,我们分析的都是单个函数内,算法代码的时间复杂度,接下来我们分析函数调用过程中时间复杂度。
案例一:
public static void main(String[] args) {
int n=100;
for (int i = 0; i < n; i++) {
show(i);
}
}
private static void show(int i) {
System.out.println(i);
}
在main方法中,有一个for循环,循环体调用了show方法,由于show方法内部只执行了一行代码,所以show方法的时间复杂度为O(1),那main方法的时间复杂度就是O(n)
案例二:
public static void main(String[] args) {
int n=100;
for (int i = 0; i < n; i++) {
show(i);
}
}
private static void show(int i) {
for (int j = 0; j < i; i++) {
System.out.println(i);
}
}
在main方法中,有一个for循环,循环体调用了show方法,由于show方法内部也有一个for循环,所以show方法
的时间复杂度为O(n),那main方法的时间复杂度为O(n^2)
案例三:
public static void main(String[] args) {
int n=100;
show(n);
for (int i = 0; i < n; i++) {
show(i);
}
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
System.out.println(j);
}
}
}
private static void show(int i) {
for (int j = 0; j < i; i++) {
System.out.println(i);
}
}
在show方法中,有一个for循环,所以show方法的时间复杂度为O(n),在main方法中,show(n)这行代码内部执行
的次数为n,第一个for循环内调用了show方法,所以其执行次数为n^2,第二个嵌套for循环内只执行了一行代码,
所以其执行次数为n2,那么main方法总执行次数为n+n2+n2=2n2+n。根据大O推导规则,去掉n保留最高阶
项,并去掉最高阶项的常数因子2,所以最终main方法的时间复杂度为O(n^2)。
从心理学角度讲,每个人对发生的事情都会有一个预期,比如看到半杯水,有人会说:哇哦,还有半杯水哦!但也
有人会说:天哪,只有半杯水了。一般人处于一种对未来失败的担忧,而在预期的时候趋向做最坏的打算,这样即
使最糟糕的结果出现,当事人也有了心理准备,比较容易接受结果。假如最糟糕的结果并没有出现,当事人会很快
乐。 算法分析也是类似,假如有一个需求:
有一个存储了n个随机数字的数组,请从中查找出指定的数字。
public int search(int num){
int[] arr={11,10,8,9,7,22,23,0};
for (int i = 0; i < arr.length; i++) {
if (num==arr[i]){
return i;
}
}
return -1;
}
最好情况:
查找的第一个数字就是期望的数字,那么算法的时间复杂度为O(1)
最坏情况:
查找的最后一个数字,才是期望的数字,那么算法的时间复杂度为O(n)
平均情况:
任何数字查找的平均成本是O(n/2)
最坏情况是一种保证,在应用中,这是一种最基本的保障,即使在最坏情况下,也能够正常提供服务,所以,除非
特别指定,我们提到的运行时间都指的是最坏情况下的运行时间。
计算机的软硬件都经历了一个比较漫长的演变史,作为为运算提供环境的内存,更是如此,从早些时候的512k,经
历了1M,2M,4M…等,发展到现在的8G,甚至16G和32G,所以早期,算法在运行过程中对内存的占用情况也
是 一个经常需要考虑的问题。我么可以用算法的空间复杂度来描述算法对内存的占用。
1.基本数据类型内存占用情况:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nDQntRXP-1681958360331)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930344.png)]
2.计算机访问内存的方式都是一次一个字节
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jxjBnsGw-1681958360331)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930345.png)]
3.一个引用(机器地址)需要8个字节表示:
例如: Date date = new Date(),则date这个变量需要占用8个字节来表示
4.创建一个对象
比如new Date(),除了Date对象内部存储的数据(例如年月日等信息)占用的内存,该对象本身也有内存开销,每个对象的自身开销是16个字节,用来保存对象的头信息。
5.一般内存的使用,如果不够8个字节,都会被自动填充为8字节:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NADeP2yw-1681958360332)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930346.png)]
6.数组的特殊性
java中数组被被限定为对象,他们一般都会因为记录长度而需要额外的内存。
一个原始数据类型的数组一般需要24字节的头信息(16个自己的对象开销,4字节用于保存长度以及4个填充字节)再加上保存值所需的内存。
了解了java的内存最基本的机制,就能够有效帮助我们估计大量程序的内存使用情况。
算法的空间复杂度计算公式记作:S(n)=O(f(n)),其中n为输入规模,f(n)为语句关于n所占存储空间的函数。
案例:
对指定的数组元素进行反转,并返回反转的内容。
解法一:
public static int[] reverse1(int[] arr){
int n=arr.length;//申请4个字节
int temp;//申请4个字节
for(int start=0,end=n-1;start<=end;start++,end--){
temp=arr[start];
arr[start]=arr[end];
arr[end]=temp;
}
return arr;
}
解法二:
public static int[] reverse2(int[] arr){
int n=arr.length;//申请4个字节
int[] temp=new int[n];//申请n*4个字节+数组自身头信息开销24个字节
for (int i = n-1; i >=0; i--) {
temp[n-1-i]=arr[i];
}
return temp;
}
忽略判断条件占用的内存,我们得出的内存占用情况如下:
算法一:
不管传入的数组大小为多少,始终额外申请4+4=8个字节;
算法二:
4+4n+24=4n+28;
根据大O推导法则,算法一的空间复杂度为O(1),算法二的空间复杂度为O(n),所以从空间占用的角度讲,算法一要 优于算法二。 由于java中有内存垃圾回收机制,并且jvm对程序的内存占用也有优化(例如即时编译),我们无法精确的评估一个java程序的内存占用情况,但是了解了java的基本内存占用,使我们可以对java程序的内存占用情况进行估算。
由于现在的计算机设备内存一般都比较大,基本上个人计算机都是4G起步,大的可以达到32G,所以内存占用一般情况下并不是我们算法的瓶颈,普通情况下直接说复杂度,默认为算法的时间复杂度。
但是,如果你做的程序是嵌入式开发,尤其是一些传感器设备上的内置程序,由于这些设备的内存很小,一般为几kb,这个时候对算法的空间复杂度就有要求了,但是一般做java开发的,基本上都是服务器开发,一般不存在这样的问题。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PPqi5AkW-1681958360332)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304142116636.png)]
由于我们这里要讲排序,所以肯定会在元素之间进行比较。规则的。在实际应用中,我们往往有需要比较两个自定义对象大小的地方。而这些自定义对象的比较,就不像简单的整型数据那么简单,它们往往包含有许多的属性,我们一般都是根据这些属性对自定义对象进行比较的。所以Java中要比较对象的大小或者要对对象的集合进行排序,需要通过比较这些对象的某些属性的大小来确定它们之间的大小关系。
一般,Java中通过接口实现两个对象的比较,比较常用就是Comparable接口和Comparator接口。首先类要实现接口,并且使用泛型规定要进行比较的对象所属的类,然后类实现了接口后,还需要实现接口定义的比较方法(compareTo方法或者compare方法),在这些方法中传入需要比较大小的另一个对象,通过选定的成员变量与之比较,如果大于则返回1,小于返回-1,相等返回0。
那么这两个接口有什么区别呢:
一般简单的回答可以这么说:
1)首先这两个接口一般都是用来实现集合内的排序,comparable还可以用于两个对象大小的比较。
2)Comparable接口在java.lang包下面。里面有一个compareTo(T)接口方法。当一个类需要比较的时候,需自行实现Comparable接口的CompareTo方法。当调用集合排序方法的时候,就会调用对象的compareTo()方法来实现对象的比较。
3)Comparator接口在java.util包下面。Comparator是一个比较器接口,一般单独定义一个比较器实现该接口中的比较方法compare();在集合sort方法中传入对应的比较器实现类。一般使用匿名内部类来实现比较器。
4)Comparator相对于Comparable来说更加的灵活,耦合度低。
下面通过两个例子来简单说一下这两个接口的区别!
Comparable
1.定义一个学生类User,具有年龄age和姓名username两个属性,并通过Comparable接口提供比较规则
package com.ynu.Java版算法.U2_排序.T1_简单排序.S1_Comparable接口介绍;
public class User implements Comparable<User>{
private String name;
private int age;
public User() {
}
public User(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
// compareTo 接口返回值大于0 传入的o排序时候放在前面 (前大后小)
// 所以假如要升序排列 新进一个元素如果比当前年龄小还要在前面,就要让返回值大于0 那么就用this.age - o.age
// 假如要降序序排列 新进一个元素如果比当前年龄小还要排在后面,就要让返回值小于0 那么就用o.age - this.age
@Override
public int compareTo(User o) {
return this.age-o.age; //升序排列 (当前升后降 当前对象在减号前面是升序,反之降序)
}
@Override
public String toString() {
return "User{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
package com.ynu.Java版算法.U2_排序.T1_简单排序.S1_Comparable接口介绍;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<User> users = new ArrayList<>();
users.add(new User("lh",27));
users.add(new User("lmj",25));
users.add(new User("ybh",24));
users.add(new User("czx",26));
System.out.println(users);
//排序 需要传入集合的对象实现了Comparable接口
Collections.sort(users);
System.out.println(users);
}
}
Comparator
package com.ynu.Java版算法.U2_排序.T1_简单排序.S2_Compareto接口介绍;
import java.util.Comparator;
public class MyComparator implements Comparator<User> {
@Override
public int compare(User o1, User o2) {
return o1.getAge() - o2.getAge(); //根据年龄升序排列 把o1作为当前对象
}
}
package com.ynu.Java版算法.U2_排序.T1_简单排序.S2_Compareto接口介绍;
public class User {
private String name;
private int age;
public User() {
}
public User(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public void setName(String name) {
this.name = name;
}
@Override
public String toString() {
return "User{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
package com.ynu.Java版算法.U2_排序.T1_简单排序.S2_Compareto接口介绍;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<User> users = new ArrayList<>();
users.add(new User("lmj",25));
users.add(new User("ybh",24));
users.add(new User("czx",27));
users.add(new User("lh",23));
System.out.println(users);
// 传入Comparator接口实现类 用自定义的规则排序
Collections.sort(users,new MyComparator());
System.out.println(users);
}
}
Comparator接口与Comparable接口不同的是:
① Comparator位于包java.util下,而Comparable位于包java.lang下。
② Comparable接口将比较代码嵌入需要进行比较的类的自身代码中,而Comparator接口在一个独立的类中实现比较。
③ comparator接口相对更灵活些,因为它跟接口实现的类是耦合在一起的,可以通过换比较器来换不同的规则进
行比较,即如果前期类的设计没有考虑到类的Compare问题而没有实现Comparable接口,后期可以通过
Comparator接口来实现比较算法进行排序,并且为了使用不同的排序标准做准备,比如:升序、降序。
④ Comparable接口强制进行自然排序,而Comparator接口不强制进行自然排序,可以指定排序顺序。
⑤ 换一种说法,简单的说:
Comparable:使user类具有自比较的能力,可以让自己跟同类型的数据做比较;
Comparator:就是一个比较器,像一个第三方,传入两个对象,让比较器去判断谁大谁小;
在我们的程序中,排序是非常常见的一种需求,提供一些数据元素,把这些数据元素按照一定的规则进行排序。比如查询一些订单,按照订单的日期进行排序;再比如查询一些商品,按照商品的价格进行排序等等。所以,接下来我们要学习一些常见的排序算法。
在java的开发工具包jdk中,已经给我们提供了很多数据结构与算法的实现,比如List,Set,Map,Math等等,都是以API的方式提供,这种方式的好处在于一次编写,多处使用。我们借鉴jdk的方式,也把算法封装到某个类中, 那如果是这样,在我们写java代码之前,就需要先进行API的设计,设计好之后,再对这些API进行实现。
就比如我们先设计一套API如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RR2VQzgc-1681958360333)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930347.png)]
然后再使用java代码去实现它。
冒泡排序(Bubble Sort),是一种计算机科学领域的较简单的排序算法。
需求:
排序前:{4,5,6,3,2,1}
排序后:{1,2,3,4,5,6}
排序原理:
比较相邻的元素。如果前一个元素比后一个元素大,就交换这两个元素的位置。
对每一对相邻元素做同样的工作,从开始第一对元素到结尾的最后一对元素。最终最后位置的元素就是最大
值。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CpkCirCH-1681958360334)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930348.png)]
冒泡排序API设计:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MLXG3wYu-1681958360334)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930349.png)]
冒泡排序的代码实现:
package com.ynu.Java版算法.U2_排序.T2_冒泡排序;
import java.util.Comparator;
public class Bubble {
// 对数组内的元素进行升序
public static void sort(Comparable[] arr){
for (int i = arr.length-1; i >= 0; i--) {
for (int j = 0; j < i; j++) {
if (greater(arr[j],arr[j+1])){
exch(arr,j,j+1);
}
}
}
}
// 判断v是否大于w
private static boolean greater(Comparable v,Comparable w){
return v.compareTo(w)>0;
}
// 交换数组中i,j索引处的值
private static void exch(Comparable[] a,int i,int j){
Comparable temp = a[i];
a[i] = a[j];
a[j] = temp;
}
}
package com.ynu.Java版算法.U2_排序.T2_冒泡排序;
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
// 数字数组
// Integer类实现过Comparable接口 可以自然排序
Integer[] arr = {4, 5, 6, 3, 2, 1};
System.out.println("排序前"+Arrays.toString(arr));
Bubble.sort(arr);
System.out.println("排序后"+Arrays.toString(arr));
}
}
冒泡排序的时间复杂度分析 冒泡排序使用了双层for循环,其中内层循环的循环体是真正完成排序的代码,所以,
我们分析冒泡排序的时间复杂度,主要分析一下内层循环体的执行次数即可。
在最坏情况下,也就是假如要排序的元素为{6,5,4,3,2,1}逆序,那么:
元素比较的次数为:
(N-1)+(N-2)+(N-3)+…+2+1=((N-1)+1)*(N-1)/2=N^2/2-N/2;
元素交换的次数为:
(N-1)+(N-2)+(N-3)+…+2+1=((N-1)+1)*(N-1)/2=N^2/2-N/2;
总执行次数为:
(N2/2-N/2)+(N2/2-N/2)=N^2-N;
按照大O推导法则,保留函数中的最高阶项那么最终冒泡排序的时间复杂度为O(N^2).
选择排序是一种更加简单直观的排序方法。
需求:
排序前:{4,6,8,7,9,2,10,1}
排序后:{1,2,4,5,7,8,9,10}
线性表是最基本、最简单、也是最常用的一种数据结构(逻辑结构)。一个线性表是n个具有相同特性的数据元素的有限序列。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LDCOn2an-1681958360335)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930350.png)]
前驱元素:
若A元素在B元素的前面,则称A为B的前驱元素
后继元素:
若B元素在A元素的后面,则称B为A的后继元素
**线性表的特征:数据元素之间具有一种“一对一”**的逻辑关系。
第一个数据元素没有前驱,这个数据元素被称为头结点;
最后一个数据元素没有后继,这个数据元素被称为尾结点;
除了第一个和最后一个数据元素外,其他数据元素有且仅有一个前驱和一个后继。
如果把线性表用数学语言来定义,则可以表示为(a1,…ai-1,ai,ai+1,…an),ai-1领先于ai,ai领先于ai+1,称ai-1是ai的
前驱元素,ai+1是ai的后继元素。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gPdZggZj-1681958360336)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930351.png)]
线性表的分类:
线性表中数据存储的方式可以是顺序存储,也可以是链式存储,按照数据的存储方式不同,可以把线性表分为顺序
表和链表。
顺序表是在计算机内存中以数组的形式保存的线性表,线性表的顺序存储是指用一组地址连续的存储单元,依次存储线性表中的各个元素、使得线性表中再逻辑结构上响铃的数据元素存储在相邻的物理存储单元中,即通过数据元素物理存储的相邻关系来反映数据元素之间逻辑上的相邻关系。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3AXTm4dY-1681958360337)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930352.png)]
顺序表API设计:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Rv95Zs9s-1681958360337)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930353.png)]
顺序表的代码实现:
package com.ynu.Java版算法.U4_线性表.T1_顺序表.S1_顺序表实现;
import org.omg.CORBA.Object;
//顺序表代码
public class SequenceList<T> {
// 存储元素的数组
private T[] eles;
// 记录当前顺序表的元素个数
private int N;
// 构造方法
public SequenceList(int capacity) {
// 初始化这个内部数组
eles = (T[])new Object[capacity];
// 初始化大小
N = 0;
}
// 判断当前线性表 是否为空
public boolean isEmpty(){
return N == 0;
}
//获取线性表的长度
public int length(){
return N;
}
// 获取指定位置的元素
public T get(int i){
if (i < 0 || i >= N){
throw new RuntimeException("当前元素不存在");
}
return eles[i];
}
// 向线型表中添加元素t 末尾添加
public void insert(T t){
if (N == eles.length){
throw new RuntimeException("当前表已满了");
}
eles[N++] = t;
}
//在i元素处插入元素t
public void insert(int i,T t){
if (i==eles.length){
throw new RuntimeException("当前表已满");
}
if (i<0 || i>N){
throw new RuntimeException("插入的位置不合法");
}
for (int index = N; index > i ; index--) {
eles[index] = eles[index-1]; // i之后的元素后移
}
//把t放到i位置处
eles[i]=t;
//元素数量+1
N++;
}
//删除指定位置i处的元素,并返回该元素
public T remove(int i){
if (i<0 || i>N-1){
throw new RuntimeException("当前要删除的元素不存在");
}
T result = eles[i]; //记录当前移除的元素
for (int index = i; index < N-1 ; index++) {
eles[index] = eles[index+1];
}
N--;
return result;
}
//查找t元素第一次出现的位置
public int indexOf(T t){
if(t==null){
throw new RuntimeException("查找的元素不合法");
}
for (int i = 0; i < N; i++) {
if (eles[i].equals(t)){
return i;
}
}
return -1;
}
}
// 测试代码
一般作为容器存储数据,都需要向外部提供遍历的方式,因此我们需要给顺序表提供遍历方式。
在java中,遍历集合的方式一般都是用的是foreach循环,如果想让我们的SequenceList也能支持foreach循环,则需要做如下操作:
1.让SequenceList实现Iterable接口,重写iterator方法;
2.在SequenceList内部提供一个内部类SIterator,实现Iterator接口,重写hasNext方法和next方法;
代码:
package com.ynu.Java版算法.U4_线性表.T1_顺序表.S1_顺序表遍历;
import java.util.Iterator;
//顺序表代码
public class SequenceList<T> implements Iterable<T>{
// 存储元素的数组
private T[] eles;
// 记录当前顺序表的元素个数
private int N;
// 构造方法
public SequenceList(int capacity) {
// 初始化这个内部数组
eles = (T[])new Object[capacity];
// 初始化大小
N = 0;
}
// 判断当前线性表 是否为空
public boolean isEmpty(){
return N == 0;
}
//获取线性表的长度
public int length(){
return N;
}
// 获取指定位置的元素
public T get(int i){
if (i < 0 || i >= N){
throw new RuntimeException("当前元素不存在");
}
return eles[i];
}
// 向线型表中添加元素t 末尾添加
public void insert(T t){
if (N == eles.length){
throw new RuntimeException("当前表已满了");
}
eles[N++] = t;
}
//在i元素处插入元素t
public void insert(int i,T t){
if (i==eles.length){
throw new RuntimeException("当前表已满");
}
if (i<0 || i>N){
throw new RuntimeException("插入的位置不合法");
}
for (int index = N; index > i ; index--) {
eles[index] = eles[index-1]; // i之后的元素后移
}
//把t放到i位置处
eles[i]=t;
//元素数量+1
N++;
}
//删除指定位置i处的元素,并返回该元素
public T remove(int i){
if (i<0 || i>N-1){
throw new RuntimeException("当前要删除的元素不存在");
}
T result = eles[i]; //记录当前移除的元素
for (int index = i; index < N-1 ; index++) {
eles[index] = eles[index+1];
}
N--;
return result;
}
//查找t元素第一次出现的位置
public int indexOf(T t){
if(t==null){
throw new RuntimeException("查找的元素不合法");
}
for (int i = 0; i < N; i++) {
if (eles[i].equals(t)){
return i;
}
}
return -1;
}
//将一个线性表置为空表
public void clear(){
N=0;
}
@Override
public Iterator<T> iterator() {
return new MyIterator();
}
class MyIterator implements Iterator<T>{
// 当前遍历的位置
private int position;
public MyIterator() {
this.position = 0;
}
@Override
public boolean hasNext() {
return position < N;
}
@Override
public T next() {
return eles[position++];
}
}
}
package com.ynu.Java版算法.U4_线性表.T1_顺序表.S1_顺序表遍历;
public class Main {
public static void main(String[] args) {
SequenceList<String> list = new SequenceList(10);
list.insert("ybh");
list.insert("lmj");
list.insert("czx");
list.insert("lh");
for (String s : list) {
System.out.println(s);
}
}
}
在之前的实现中,当我们使用SequenceList时,先new SequenceList(5)创建一个对象,创建对象时就需要指定容
器的大小,初始化指定大小的数组来存储元素,当我们插入元素时,如果已经插入了5个元素,还要继续插入数
据,则会报错,就不能插入了。这种设计不符合容器的设计理念,因此我们在设计顺序表时,应该考虑它的容量的
伸缩性。
考虑容器的容量伸缩性,其实就是改变存储数据元素的数组的大小,那我们需要考虑什么时候需要改变数组的大
小?
1.添加元素时:
添加元素时,应该检查当前数组的大小是否能容纳新的元素,如果不能容纳,则需要创建新的容量更大的数组,我
们这里创建一个是原数组两倍容量的新数组存储元素。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BNnVGtYj-1681958360338)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930354.png)]
2.移除元素时:
移除元素时,应该检查当前数组的大小是否太大,比如正在用100个容量的数组存储10个元素,这样就会造成内存空间的浪费,应该创建一个容量更小的数组存储元素。如果我们发现数据元素的数量不足数组容量的1/4,则创建一个是原数组容量的1/2的新数组存储元素。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-G2s2JOsa-1681958360339)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930355.png)]
package com.ynu.Java版算法.U4_线性表.T1_顺序表.S2_顺序表的容量可变;
import java.util.Arrays;
import java.util.Iterator;
//顺序表代码
public class SequenceList<T> implements Iterable<T>{
// 存储元素的数组
private T[] eles;
// 记录当前顺序表的元素个数
private int N;
// 构造方法
public SequenceList(int capacity) {
// 初始化这个内部数组
eles = (T[])new Object[capacity];
// 初始化大小
N = 0;
}
// 判断当前线性表 是否为空
public boolean isEmpty(){
return N == 0;
}
//获取线性表的长度
public int length(){
return N;
}
// 获取指定位置的元素
public T get(int i){
if (i < 0 || i >= N){
throw new RuntimeException("当前元素不存在");
}
return eles[i];
}
// 向线型表中添加元素t 末尾添加
public void insert(T t){
//元素已经放满了数组,需要扩容
if (N == eles.length){
revise(eles.length*2);
}
eles[N++] = t;
}
//在i元素处插入元素t
public void insert(int i,T t){
if (i<0 || i>N){
throw new RuntimeException("插入的位置不合法");
}
if (N==eles.length){
revise(eles.length*2);
}
for (int index = N; index > i ; index--) {
eles[index] = eles[index-1]; // i之后的元素后移
}
//把t放到i位置处
eles[i]=t;
//元素数量+1
N++;
}
//删除指定位置i处的元素,并返回该元素
public T remove(int i){
if (i<0 || i>N-1){
throw new RuntimeException("当前要删除的元素不存在");
}
T result = eles[i]; //记录当前移除的元素
for (int index = i; index < N-1 ; index++) {
eles[index] = eles[index+1];
}
N--;
if (N>0 && N < eles.length / 4){
revise(eles.length/2);
}
return result;
}
//查找t元素第一次出现的位置
public int indexOf(T t){
if(t==null){
throw new RuntimeException("查找的元素不合法");
}
for (int i = 0; i < N; i++) {
if (eles[i].equals(t)){
return i;
}
}
return -1;
}
//将一个线性表置为空表
public void clear(){
N=0;
}
@Override
public Iterator<T> iterator() {
return new MyIterator();
}
class MyIterator implements Iterator<T>{
// 当前遍历的位置
private int position;
public MyIterator() {
this.position = 0;
}
@Override
public boolean hasNext() {
return position < N;
}
@Override
public T next() {
return eles[position++];
}
}
// 改变容量
public void revise(int newSize){
// 记录旧数组
T[] temp = eles;
// 创建新数组
eles = (T[])new Object[newSize];
//把旧数组中的元素拷贝到新数组
for (int i = 0; i < N; i++) {
eles[i] = temp[i];
}
}
// 返回线性表表的容量
public int capacity(){
return eles.length;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < N; i++) {
sb.append(eles[i]+" ");
}
return sb.toString();
}
}
package com.ynu.Java版算法.U4_线性表.T1_顺序表.S2_顺序表的容量可变;
public class Main {
public static void main(String[] args) {
SequenceList<String> list = new SequenceList(2);
System.out.println("当前集合的容量为:"+list.capacity());
list.insert("ybh");
list.insert("lmj");
list.insert("czx");
list.insert("lh");
System.out.println("现在集合的容量为:"+list.capacity());
list.insert(4,"aa");
list.insert(4,"aa");
list.insert(4,"aa");
list.insert(4,"aa");
list.insert(4,"aa");
System.out.println(list);
System.out.println("现在集合的容量为:"+list.capacity());
list.remove(1);
list.remove(1);
list.remove(1);
list.remove(1);
list.remove(1);
list.remove(1);
list.remove(1);
System.out.println(list);
System.out.println("现在集合的容量为:"+list.capacity());
}
}
get(i): 不难看出,不论数据元素量N有多大,只需要一次eles[i]就可以获取到对应的元素,所以时间复杂度为O(1);
insert(int i,T t): 每一次插入,都需要把i位置后面的元素移动一次,随着元素数量N的增大,移动的元素也越多,时间复杂为O(n);
**remove(int i)*每一次删除,都需要把i位置后面的元素移动一次,随着数据量N的增大,移动的元素也越多,时间复 杂度为O(n);
由于顺序表的底层由数组实现,数组的长度是固定的,所以在操作的过程中涉及到了容器扩容操作。这样会导致顺
序表在使用过程中的时间复杂度不是线性的,在某些需要扩容的结点处,耗时会突增,尤其是元素越多,这个问题
越明显。
java中ArrayList集合的底层也是一种顺序表,使用数组实现,同样提供了增删改查以及扩容等功能。
1.是否用数组实现; 是
2.有没有扩容操作; 有
3.有没有提供遍历方式; 有
之前我们已经使用顺序存储结构实现了线性表,我们会发现虽然顺序表的查询很快,时间复杂度为O(1),但是
增删的效率是比较低的,因为每一次增删操作都伴随着大量的数据元素移动。
这个问题有没有解决方案呢?有,我们可以使用另外一种存储结构实现线性表,链式存储结构。
链表是一种物理存储单元上非连续、非顺序的存储结构,其物理结构不能只管的表示数据元素的逻辑顺序,
数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列的结点(链表中的每一个元素称为结点)
组成,结点可以在运行时动态生成。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kZ5UbvgJ-1681958360340)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930356.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UDFLYyL6-1681958360341)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930357.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mMlgggRE-1681958360342)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201025785.png)]
那我们如何使用链表呢?按照面向对象的思想,我们可以设计一个类,来描述结点这个事物,用一个属性描述这个
结点存储的元素,用来另外一个属性描述这个结点的下一个结点。
结点API设计:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-B8a8nKao-1681958360343)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930360.png)]
结点类实现:
package com.ynu.Java版算法.U4_线性表.T2_链表.S1_节点类实现;
public class Node<T> {
// 数据域
public T elem;
// 指针域
public Node next;
public Node(T elem, Node next) {
this.elem = elem;
this.next = next;
}
}
package com.ynu.Java版算法.U4_线性表.T2_链表.S1_节点类实现;
public class Main {
public static void main(String[] args) {
// 构建节点
Node<Integer> first = new Node<Integer>(11, null);
Node<Integer> second = new Node<Integer>(13, null);
Node<Integer> third = new Node<Integer>(12, null);
Node<Integer> fourth = new Node<Integer>(8, null);
Node<Integer> fifth = new Node<Integer>(9, null);
//生成链表
first.next = second;
second.next = third;
third.next = fourth;
fourth.next = fifth;
// 遍历链表
Node curr = first;
while (curr!=null){
System.out.println(curr.elem);
curr = curr.next;
}
}
}
单向链表是链表的一种,它由多个结点组成,每个结点都由一个数据域和一个指针域组成,数据域用来存储数据, 指针域用来指向其后继结点。链表的头结点的数据域不存储数据,指针域指向第一个真正存储数据的结点。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BiRzGVzZ-1681958360344)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930361.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QbLALQpf-1681958360344)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930362.png)]
package com.ynu.Java版算法.U4_线性表.T2_链表.S2_单向链表;
import java.util.Iterator;
public class LinkList<T> implements Iterable<T>{
// 头结点
private Node head;
// 记录链表长度
private int N;
public LinkList() {
head = new Node(null, null);
N = 0;
}
// 清空链表
public void clear() {
head.item = null;
head.next = null;
}
// 判断链表是否为空
public boolean isEmpty() {
return N == 0;
}
// 获取线性表中元素的个数
public int length() {
return N;
}
// 获取链表中第i个元素的值 头结点不是索引为0的节点
public T get(int i) {
if (i < 0 || i >= N) {
throw new RuntimeException("参数非法");
}
Node curr = head;
for (int j = 0; j <= i; j++) {
curr = curr.next;
}
return curr.item;
}
// 向链表末尾添加元素
public void insert(T t) {
Node n = head;
while (n.next != null) {
n = n.next;
}
Node newNode = new Node(t, null);
n.next = newNode;
N++;
}
//向指定位置i处,添加元素t
public void insert(int i, T t) {
if (i < 0 || i >= N) {
throw new RuntimeException("参数非法");
}
Node pre = head;
for (int j = 0; j < i; j++) {
pre = pre.next;
}
Node newNode = new Node(t, pre.next);
pre.next = newNode;
N++;
}
//删除指定位置i处的元素,并返回被删除的元素
public T remove(int i) {
if (i < 0 || i >= N) {
throw new RuntimeException("位置不合法");
}
//寻找i之前的元素
Node pre = head;
for (int index = 0; index <= i - 1; index++) {
pre = pre.next;
}
//当前i位置的结点
Node curr = pre.next;
//前一个结点指向下一个结点,删除当前结点
pre.next = curr.next;
//长度-1
N--;
return curr.item;
}
//查找元素t在链表中第一次出现的位置
public int indexOf(T t){
Node n = head;
for (int i = 0;n.next!=null;i++){
n = n.next;
if (n.item.equals(t)){
return i;
}
}
return -1;
}
@Override
public Iterator<T> iterator() {
return new LIterator();
}
private class LIterator implements Iterator<T>{
private Node n;
public LIterator() {
this.n = head;
}
@Override
public boolean hasNext() {
return n.next!=null;
}
@Override
public T next() {
n = n.next;
return n.item;
}
}
private class Node {
// 数据域
T item;
// 指针域
Node next;
public Node(T item, Node next) {
this.item = item;
this.next = next;
}
}
}
package com.ynu.Java版算法.U4_线性表.T2_链表.S2_单向链表;
public class Main {
public static void main(String[] args) {
LinkList<String> list = new LinkList<>();
list.insert("张三");
list.insert("李四");
list.insert("王五");
list.insert("赵六");
// 增强for遍历
for (String s : list) {
System.out.println(s);
}
//测试length方法
System.out.println(list.length());
System.out.println("-------------------");
//测试get方法
System.out.println(list.get(2));
System.out.println("------------------------");
//测试remove方法
String remove = list.remove(1);
System.out.println(remove);
System.out.println(list.length());
System.out.println("----------------");;
for (String s : list) {
System.out.println(s);
}
}
}
双向链表也叫双向表,是链表的一种,它由多个结点组成,每个结点都由一个数据域和两个指针域组成,数据域用来存储数据,其中一个指针域用来指向其后继结点,另一个指针域用来指向前驱结点。链表的头结点的数据域不存储数据,指向前驱结点的指针域值为null,指向后继结点的指针域指向第一个真正存储数据的结点。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vvhBuCH9-1681958360345)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930363.png)]
按照面向对象的思想,我们需要设计一个类,来描述结点这个事物。由于结点是属于链表的,所以我们把结点类作
为链表类的一个内部类来实现
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uUya6RY6-1681958360346)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930364.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9fdyDLEH-1681958360347)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930365.png)]
package com.ynu.Java版算法.U4_线性表.T2_链表.S3_双向链表;
import java.util.Iterator;
public class TowWayLinkList<T> implements Iterable<T>{
// 首节点 首节点不放数据
private Node head;
// 尾结点 尾结点是要放数据的
private Node last;
// 链表的长度
private int N;
public TowWayLinkList() {
last = null; // 尾结点初始化
head = new Node(null,null,null); // 首节点初始化
N=0;
}
// 空置线性表
public void clear(){
last = null;
head.next = last;
head.pre = null;
head.item = null;
N = 0;
}
//获取链表长度
public int length(){
return N;
}
//判断链表是否为空
public boolean isEmpty(){
return N==0;
}
//从尾部插入元素t
public void insertLast(T t){
// 如果此时链表为空
if (last==null){
last = new Node(t,head,null);
head.next = last;
}else {
Node newNode = new Node(t,last,null);
last.next = newNode;
last = newNode;
}
//长度加1
N++;
}
//从头部插入元素t
public void insertFirst(T t){
// 如果此时链表为空
if (last==null){
last = new Node(t,head,null);
head.next = last;
}else {
Node newNode = new Node(t,head,head.next);
head.next = newNode;
head.next.pre = newNode;
}
//长度加1
N++;
}
//向指定位置i处插入元素t
public void insert(int i ,T item){
if (i<0 || i>= N){
throw new RuntimeException("插入位置不合法");
}
Node pre = head;
for (int index = 0; index < i; index++) {
pre = pre.next;
}
//当前结点
Node curr = pre.next;
//构建新结点
Node newNode = new Node(item,pre,curr);
curr.pre = newNode;
pre.next = newNode;
N++;
}
//获取指定位置i处的元素
public T get(int i){
if (i<0 || i>=N){
throw new RuntimeException("位置不合法");
}
Node n = head;
for (int index = 0; index <= i; index++) {
n = n.next;
}
return n.item;
}
//找到元素t在链表中第一次出现的位置
public int indexOf(T t){
Node n= head;
for (int i=0;n.next!=null;i++){
n = n.next;
if (n.next.equals(t)){
return i;
}
}
return -1;
}
//删除位置i处的元素,并返回该元素
public T remove(int i){
if (i<0 || i>=N){
throw new RuntimeException("位置不合法");
}
//寻找i位置的前一个元素
Node pre = head;
for (int index = 0; index <i ; index++) {
pre = pre.next;
}
//i位置的元素
Node curr = pre.next;
//i位置的下一个元素
Node curr_next = curr.next;
pre.next = curr_next;
curr_next.pre = pre;
//长度-1;
N--;
return curr.item;
}
//获取第一个元素
public T getFirst(){
if (isEmpty()){
return null;
}
return head.next.item;
}
//获取最后一个元素
public T getLast(){
if (isEmpty()){
return null;
}
return last.item;
}
@Override
public Iterator<T> iterator() {
return new MyIterator();
}
class MyIterator implements Iterator<T>{
private Node n = head;
@Override
public boolean hasNext() {
return n.next!=null;
}
@Override
public T next() {
n = n.next;
return n.item;
}
}
class Node{
// 数据域
public T item;
// 上一个指针
public Node pre;
// 指针域 下一个指针
public Node next;
public Node(T item, Node pre, Node next) {
this.item = item;
this.next = next;
this.pre = pre;
}
}
}
package com.ynu.Java版算法.U4_线性表.T2_链表.S3_双向链表;
public class Main {
public static void main(String[] args) {
TowWayLinkList<String> list =new TowWayLinkList<>();
list.insertLast("杨炳辉");
list.insertLast("路梦娟");
list.insertLast("陈志鑫");
list.insertLast("刘辉");
list.insertFirst("哈哈");
for (String s : list) {
System.out.println(s);
}
System.out.println("======================");
list.insert(2,"不知道");
for (String s : list) {
System.out.println(s);
}
}
}
存储货物或供旅客住宿的地方,可引申为仓库、中转站 。例如我们现在生活中的酒店,在古时候叫客栈,是供旅客休息的地方,旅客可以进客栈休息,休息完毕后就离开客栈。
我们把生活中的栈的概念引入到计算机中,就是供数据休息的地方,它是一种数据结构,数据既可以进入到栈中, 又可以从栈中出去。
栈是一种基于先进后出(FILO)的数据结构,是一种只能在一端进行插入和删除操作的特殊线性表。它按照先进后出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶,需要读数据的时候从栈顶开始弹出数据(最后一个数据被第一个读出来)。
我们称数据进入到栈的动作为压栈,数据从栈中出去的动作为弹栈。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-s9SdlM45-1681958360347)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930366.png)]
package com.ynu.Java版算法.U4_线性表.T3_栈.S1_栈的数组实现;
import java.util.Iterator;
public class ArrayStack<T> implements Iterable<T>{
// 用数组存放数据
private T[] datas;
// 栈的大小
private int maxSize;
//栈顶
private int top;
// 构造方法 指定大小的栈
public ArrayStack(int maxSize) {
this.top = -1;
this.maxSize = maxSize;
datas = (T[])new Object[this.maxSize];
}
// 返回栈的大小
public int length(){
return top+1;
}
//判断栈是否满
public boolean isFull() {
return top == maxSize - 1;
}
//p判断栈空
public boolean isEmpty() {
return top == -1;
}
//入栈
public void push(T data) {
//先判断栈是否满
if (!isFull()) {
datas[++top] = data;
} else {
System.out.println("栈满");
}
}
//出栈
public T pop() {
//先判断栈是否为空
if (isEmpty()) {
//抛出异常
throw new RuntimeException("栈空");
}
T val = datas[top--];
return val;
}
@Override
public Iterator<T> iterator() {
return new MyIterator();
}
class MyIterator implements Iterator<T>{
private int n = top;
@Override
public boolean hasNext() {
return n!=-1;
}
@Override
public T next() {
return datas[n--];
}
}
}
package com.ynu.Java版算法.U4_线性表.T3_栈.S1_栈的数组实现;
public class Main {
public static void main(String[] args) {
ArrayStack<String> stack = new ArrayStack<>(10);
// 栈是先进后出的
stack.push("abc");
stack.push("def");
stack.push("ghi");
stack.push("jkl");
// 遍历栈
for (String s : stack) {
System.out.println(s);
}
// 出栈元素
String pop = stack.pop();
System.out.println(pop);
// 栈的大小
System.out.println(stack.length());
}
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yGCTdnD8-1681958360348)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930367.png)]
package com.ynu.Java版算法.U4_线性表.T3_栈.S2_栈的链表实现;
import java.util.Iterator;
public class LinkStack<T> implements Iterable<T> {
//记录首结点
private Node head;
//栈中元素的个数
private int N;
public LinkStack() {
head = new Node(null, null);
N = 0;
}
//判断当前栈中元素个数是否为0
public boolean isEmpty() {
return N == 0;
}
//把t元素压入栈
public void push(T t) {
Node oldNext = head.next;
Node node = new Node(t, oldNext);
head.next = node;
//个数+1
N++;
}
//弹出栈顶元素
public T pop() {
Node oldNext = head.next;
if (oldNext == null) {
return null;
}
//删除首个元素
head.next = head.next.next;
//个数-1
N--;
return oldNext.item;
}
//获取栈中元素的个数
public int length(){
return N;
}
@Override
public Iterator<T> iterator() {
return new SIterator();
}
private class SIterator implements Iterator<T> {
private Node n = head;
@Override
public boolean hasNext() {
return n.next != null;
}
@Override
public T next() {
Node node = n.next;
n = n.next;
return node.item;
}
}
private class Node {
public T item;
public Node next;
public Node(T item, Node next) {
this.item = item;
this.next = next;
}
}
}
队列是一种基于先进先出(FIFO)的数据结构,是一种只能在一端进行插入,在另一端进行删除操作的特殊线性表,它
按照先进先出的原则存储数据,先进入的数据,在读取数据时先读被读出来。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0Ea7sbZ3-1681958360349)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930368.png)]
package com.ynu.Java版算法.U4_线性表.T4_队列.S1数组实现队列;
import java.util.Iterator;
public class ArrayQueue<T> implements Iterable<T> {
private int maxSize;
private int front;//头
private int rear;//尾
private T[] arr;
public ArrayQueue(int maxSize) {
this.maxSize = maxSize;
arr = (T[]) new Object[maxSize];
front = -1;
rear = -1;
}
//判断队列满
public boolean isFull() {
return (rear+1) == maxSize;
}
//判断队列kong
public boolean isEmpty() {
return rear == front;
}
//添加数据
public void enqueue(T n) {
if(isFull()) {//判满
System.out.println("队列满");
return;
}
arr[++rear] = n;
}
//出队列
public T dequeue() {
if(isEmpty()) {//判空
throw new RuntimeException("队列空");
}
return arr[++front];
}
public void showQueue() {
if(isEmpty()) {
System.out.println("队列空");
return;
}
for(int i = front; i < rear; i++) {
System.out.printf("arr[%d] = %d\n",i,arr[i]);
}
}
//显示队列头 但不移出队列
public T headQueue() {
if(isEmpty()) {
throw new RuntimeException("队列空");
}
return arr[front+1];
}
@Override
public Iterator<T> iterator() {
return new MyIterator();
}
class MyIterator implements Iterator<T>{
private int f = front;
private int r = rear;
@Override
public boolean hasNext() {
return f!=r;
}
@Override
public T next() {
return arr[++f];
}
}
}
package com.ynu.Java版算法.U4_线性表.T4_队列.S1数组实现队列;
public class Main {
public static void main(String[] args) {
ArrayQueue<String> queue = new ArrayQueue<>(20);
queue.enqueue("杨炳辉");
queue.enqueue("路梦娟");
queue.enqueue("哈哈哈");
for (String s : queue) {
System.out.println(s);
}
}
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uW16ktTr-1681958360350)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930369.png)]
package com.ynu.Java版算法.U4_线性表.T4_队列.S2_链表实现队列;
import java.util.Iterator;
public class LinkQueue<T> implements Iterable<T>{
//记录首结点
private Node head;
//记录最后一个结点
private Node last;
//记录队列中元素的个数
private int N;
public LinkQueue() {
head = new Node(null,null);
last=null;
N=0;
}
//判断队列是否为空
public boolean isEmpty(){
return N==0;
}
//返回队列中元素的个数
public int size(){
return N;
}
//向队列中插入元素t
public void enqueue(T t){
if (last==null){
last = new Node(t,null);
head.next=last;
}else{
Node oldLast = last;
last = new Node(t,null);
oldLast.next=last;
}
//个数+1
N++;
}
//从队列中拿出一个元素
public T dequeue(){
if (isEmpty()){
return null;
}
Node oldFirst = head.next;
head.next = oldFirst.next;
N--;
if (isEmpty()){
last=null;
}
return oldFirst.item;
}
private class Node{
public T item;
public Node next;
public Node(T item, Node next) {
this.item = item;
this.next = next;
}
}
@Override
public Iterator<T> iterator() {
return new QIterator();
}
private class QIterator implements Iterator<T>{
private Node n = head;
@Override
public boolean hasNext() {
return n.next!=null;
}
@Override
public T next() {
Node node = n.next;
n = n.next;
return node.item;
}
}
}
package com.ynu.Java版算法.U4_线性表.T4_队列.S2_链表实现队列;
public class Main {
public static void main(String[] args) {
LinkQueue<String> queue = new LinkQueue<>();
queue.enqueue("a");
queue.enqueue("b");
queue.enqueue("c");
queue.enqueue("d");
for (String str : queue) {
System.out.print(str+" ");
}
System.out.println("-----------------------------");
String result = queue.dequeue();
System.out.println("出列了元素:"+result);
System.out.println(queue.size());
}
}
Deque是一个双端队列接口,继承自Queue。
Deque的实现类是LinkedList、ArrayDeque、LinkedBlockingDeque,其中LinkedList是最常用的。
Deque是Queue的子接口。
Deque有两个比较重要的类:ArrayDeque和LinkedList
建议使用栈时,用ArrayDeque的push()和pop()方法;
使用队列时,使用ArrayDeque的add()和remove()方法。
addFirst(): 与 add() 相反
addLast(): 与 add() 相同
removeFirst(): 与 remove() 相同
removeLast(): 与 remove() 相反
符号表最主要的目的就是将一个键和一个值联系起来,符号表能够将存储的数据元素是一个键和一个值共同组成的
键值对数据,我们可以根据键来查找对应的值。 (Java中的Map为符号表,Map中的每一个键值对Entry就是数据)。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dwxrvBya-1681958360351)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930370.png)]
符号表中,键具有唯一性。
符号表在实际生活中的使用场景是非常广泛的,见下表:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jXYLdigu-1681958360351)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930371.png)]
结点类:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pFAAdGbE-1681958360352)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930372.png)]
符号表类:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-j9WJrqpU-1681958360353)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930373.png)]
package com.ynu.Java版算法.U5_符号表.T1_符号表;
public class SymbolTable<Key,Value> {
// 记录首节点
private Node head;
// 记录符号表中元素的个数
private int N;
public SymbolTable() {
// 头结点不存数据 只是用来标记这个符号表的头部
head = new Node(null,null,null);
N = 0;
}
// 获取符号表的大小
public int size(){
return N;
}
// 往符号表中插队键值对
// 键存在,覆盖值
// 键不存在,则添加进去
public void put(Key key,Value value){
Node n = head;
// 遍历查找所有元素 键存在,覆盖值
while (n.next!=null){
n = n.next;
if (n.value.equals(value)){
n.value = value;
return;
}
}
// 遍历好了没有相同的键,往后加入元素
Node newNode = new Node(key,value,null);
n.next = newNode;
N++;
}
// 根据键获取值
public Value get(Key key){
Node n = head;
// 遍历符号表
while (n.next!=null){
n = n.next;
if (n.key.equals(key)){
return n.value;
}
}
return null;
}
// 删除键为key的键值对
public void delete(Key key){
Node n = head;
Node pre = null;
while (n.next!=null){
pre = n;
n = n.next;
if (n.key.equals(key)){
pre.next = n.next;
N--;
return;
}
}
}
class Node {
// 键
public Key key;
// 值
public Value value;
// 下一个节点
public Node next;
public Node(Key key, Value value, Node next) {
this.key = key;
this.value = value;
this.next = next;
}
}
}
package com.ynu.Java版算法.U5_符号表.T1_符号表;
public class Main {
public static void main(String[] args) {
SymbolTable<Integer,String> map = new SymbolTable<>();
map.put(1,"ybh");
map.put(2,"lmj");
map.put(3,"czx");
System.out.println(map.size());
// 根据键获取元素值
String s = map.get(1);
System.out.println(s);
// 根据键删除元素值
map.delete(1);
System.out.println(map.size());
System.out.println(map.get(1)); // 返回null
}
}
刚才实现的符号表,我们可以称之为无序符号表,因为在插入的时候,并没有考虑键值对的顺序,而在实际生活中,有时候我们需要根据键的大小进行排序,插入数据时要考虑顺序,那么接下来我们就实现一下有序符号表。
要根据键排序,键必须是Comparable的子类或实现类
Key extends Comparable<Key>
package com.ynu.Java版算法.U5_符号表.T2_有序符号表;
import java.util.Iterator;
public class OrderSymbolTable<Key extends Comparable<Key>,Value> {
// 记录首节点
private Node head;
// 符号表中元素个数
private int N;
public OrderSymbolTable() {
N = 0;
head = new Node(null,null,null);
}
// 放入元素
public void put(Key key,Value value){
// 记录当前节点
Node curr = head.next;
// 记录之前节点
Node pre = head;
//1.如果key大于当前结点的key,则一直寻找下一个结点 降序排序
while (curr!=null && key.compareTo(curr.key)>0){
pre = curr;
curr = curr.next;
}
// 退出循环后 curr之前位置就是新节点应该进去的位置
//2.如果当前结点curr的key和将要插入的key一样,则替换
if (curr!=null && curr.key.equals(key)){
curr.value = value;
return;
}
//3.如果当前结点curr的key和将要插入的key不一样,则在curr之前添加新节点
Node newNode = new Node(key,value,curr);
pre.next = newNode;
}
// 根据键获取元素值
public Value get(Key key){
Node n = head;
while (n.next!=null){
n = n.next;
if (n.key.equals(key)){
return n.value;
}
}
return null;
}
//删除符号表中键为key的键值对
public void delete(Key key){
Node n = head;
Node pre = null;
while (n.next!=null){
pre = n;
n = n.next;
if (n.key.equals(key)){
pre.next = n.next;
N--;
return;
}
}
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
Node curr = head;
while (curr.next!=null){
curr = curr.next;
sb.append(curr.key+":"+curr.value+" ");
}
return sb.toString();
}
class Node{
//键
public Key key;
//值
public Value value;
//下一个结点
public Node next;
public Node(Key key, Value value, Node next) {
this.key = key;
this.value = value;
this.next = next;
}
}
}
package com.ynu.Java版算法.U5_符号表.T2_有序符号表;
public class Main {
public static void main(String[] args) {
// Integer实现过Comparable接口
OrderSymbolTable<Integer,String> map =new OrderSymbolTable<>();
map.put(10,"ybh");
map.put(1,"czx");
map.put(5,"lh");
map.put(2,"lmj");
// 成功按照键进行排序了
System.out.println(map);
// 1:czx 2:lmj 5:lh 10:ybh
}
}
Java中与上面两个集合对应的是HashMap和LinkedHashMap
HashMap
和 LinkedHashMap
使用场景
一般情况下,在Map
中插入、删除和定位元素,HashMap
是最好的选择。如果需要元素输出的顺序和输入的相同,就需要选择LinkedHashMap
了。
复习一下HashMap的实现原理:
之前我们实现的符号表中,不难看出,符号表的增删查操作,随着元素个数N的增多,其耗时也是线性增多的,时间复杂度都是O(n),为了提高运算效率,接下来我们学习树这种数据结构。
树是我们计算机中非常重要的一种数据结构,同时使用树这种数据结构,可以描述现实生活中的很多事物,例如家
谱、单位的组织架构、等等。
树是由n(n>=1)个有限结点组成一个具有层次关系的集合。把它叫做“树”是因为它看起来像一棵倒挂的树,也就
是说它是根朝上,而叶朝下的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9fYnDLat-1681958360354)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930374.png)]
树具有以下特点:
1.每个结点有零个或多个子结点;
2.没有父结点的结点为根结点;
3.每一个非根结点只有一个父结点;
4.每个结点及其后代结点整体上可以看做是一棵树,称为当前结点的父结点的一个子树;
结点的度:
一个结点含有的子树的个数称为该结点的度;
叶结点:
度为0的结点称为叶结点,也可以叫做终端结点
分支结点:
度不为0的结点称为分支结点,也可以叫做非终端结点
结点的层次:
从根结点开始,根结点的层次为1,根的直接后继层次为2,以此类推
结点的层序编号:
将树中的结点,按照从上层到下层,同层从左到右的次序排成一个线性序列,把他们编成连续的自然数。
树的度:
树中所有结点的度的最大值
树的高度(深度):
树中结点的最大层次
森林:
m(m>=0)个互不相交的树的集合,将一颗非空树的根结点删去,树就变成一个森林;给森林增加一个统一的根
结点,森林就变成一棵树。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-43ysTX7I-1681958360355)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930375.png)]
孩子结点:
一个结点的直接后继结点称为该结点的孩子结点
双亲结点(父结点):
一个结点的直接前驱称为该结点的双亲结点
兄弟结点:
同一双亲结点的孩子结点间互称兄弟结点
二叉树就是度不超过2的树(每个结点最多有两个子结点)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WoFLX0NC-1681958360356)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930376.png)]
满二叉树:
一个二叉树,如果每一个层的结点树都达到最大值,则这个二叉树就是满二叉树。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Fd2dJefZ-1681958360357)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930377.png)]
完全二叉树:
叶节点只能出现在最下层和次下层,也就是说除了最下层别的层都是满的,并且最下面一层的结点都集中在该层最左边的若干位置的二叉树。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NkpLxZoE-1681958360359)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930378.png)]
根据对图的观察,我们发现二叉树其实就是由一个一个的结点及其之间的关系组成的,按照面向对象的思想,我们
设计一个结点类来描述结点这个事物。
结点类API设计:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-P5e8vU4R-1681958360360)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930379.png)]
代码实现:
package com.ynu.Java版算法.U6_树的入门.T4_二叉查找树的创建.S1_二叉树的节点类;
// 二叉树的节点类
// 存放的是键值对
public class Node<Key,Value> {
//存储键
public Key key;
//存储值 -- 值是私有的 不能直接 对象+点 访问
private Value value;
//记录左子结点
public Node left;
//记录右子结点
public Node right;
public Node() {
}
public Node(Key key, Value value, Node left, Node right) {
this.key = key;
this.value = value;
this.left = left;
this.right = right;
}
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IDm4Vwep-1681958360361)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930380.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UnA6L94e-1681958360361)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201026701.png)]
查询方法get实现思想:
从根节点开始:
如果要查询的key小于当前结点的key,则继续找当前结点的左子结点;
如果要查询的key大于当前结点的key,则继续找当前结点的右子结点;
如果要查询的key等于当前结点的key,则树中返回当前结点的value。
删除方法delete实现思想:
找到被删除结点;
找到被删除结点右子树中的最小结点minNode
让被删除结点的左子树成为最小结点minNode的左子树,让被删除结点的右子树成为最小结点minNode的右子树
让被删除结点的父节点指向最小结点minNode
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-A1LIHAk4-1681958360362)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930382.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ArZQkHXF-1681958360363)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930383.png)]
二叉树的增删改查都和递归有关系。
package com.ynu.Java版算法.U6_树的入门.T4_二叉查找树的创建.S2_二叉查找树API设计;
public class BinaryTree<Key extends Comparable<Key>,Value> {
//记录根结点
private Node root;
//记录树中元素的个数
private int N;
// 获取树种元素的个数
public int size(){
return N;
}
//向当前的树x中添加key-value,并返回添加元素后新的树
public void put(Key key,Value value){
root = put(root,key,value);
}
// 增
//向指定的树x中添加key-value,并返回添加元素后新的树
public Node put(Node node,Key key,Value value){
// 这就是递归的出口
if (node==null){
N++; //树的节点个数加1
return new Node(key,value,null,null);
}
int cmp = key.compareTo(node.key);
if (cmp>0){
//新结点的key大于当前结点的key,继续找当前结点的右子结点
// 下一层递归
node.right = put(node.right, key, value);
}else if (cmp < 0){
//新结点的key小于当前结点的key,继续找当前结点的左子结点
// 下一层递归
node.left = put(node.left,key,value);
}else {
//新结点的key等于当前结点的key,修改值
node.value = value;
}
return node;
}
//查询当前树中指定key对应的value
public Value get(Key key){
return get(root,key);
}
//从指定的树x中,查找key对应的值
public Value get(Node node,Key key){
// 递归出口
if (node==null){
return null;
}
int cmp = key.compareTo(node.key);
if (cmp>0){
//如果要查询的key大于当前结点的key,则继续找当前结点的右子结点;
return get(node.right,key);
}else if (cmp<0){
//如果要查询的key小于当前结点的key,则继续找当前结点的左子结点;
return get(node.left,key);
}else {
//如果要查询的key等于当前结点的key,则树中返回当前结点的value。
return node.value;
}
}
//删除树中key对应的value
public void delete(Key key){
root = delete(root,key);
}
//删除指定树中key对应的value
public Node delete(Node node,Key key){
if (node==null){
return null;
}
int cmp = key.compareTo(node.key);
if (cmp>0){
//新结点的key大于当前结点的key,继续找当前结点的右子结点
return delete(node.right,key);
}else if (cmp<0){
//新结点的key小于当前结点的key,继续找当前结点的左子结点
return delete(node.left,key);
}else {
//新结点的key等于当前结点的key,当前x就是要删除的结点
//1.如果当前结点的右子树不存在,则直接返回当前结点的左子结点
if (node.left==null){
return node.right;
}
//2.如果当前结点的左子树不存在,则直接返回当前结点的右子结点
if (node.right==null){
return node.left;
}
// 都不存在其实就是返回空了 无需单独判断
// 3.左右节点都存在找到右子树中的最小节点替换当前节点
// 3.1 去寻找右子树的最小节点
Node pre = node; // 记录最小节点的父节点
Node minNode = node.right;
while (minNode.left!=null){
pre = minNode;
minNode = minNode.left;
}
//3.2 删除右子树的最小节点并且替换到当前节点
pre.left = null;
minNode.left = node.left;
minNode.right = node.right;
node = minNode;
N--;
}
return node;
}
private class Node {
//存储键
public Key key;
//存储值 -- 值是私有的 不能直接 对象+点 访问
private Value value;
//记录左子结点
public Node left;
//记录右子结点
public Node right;
public Node() {
}
public Node(Key key, Value value, Node left, Node right) {
this.key = key;
this.value = value;
this.left = left;
this.right = right;
}
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
return "BinaryTree{}";
}
}
package com.ynu.Java版算法.U6_树的入门.T4_二叉查找树的创建.S2_二叉查找树API设计;
public class Main {
public static void main(String[] args) {
BinaryTree<Integer,String> binaryTree = new BinaryTree<>();
binaryTree.put(10,"ybh");
binaryTree.put(9,"ybh");
binaryTree.put(12,"lmj");
binaryTree.put(7,"czx");
binaryTree.put(25,"lh");
binaryTree.put(11,"lh");
System.out.println(binaryTree.size());
binaryTree.delete(12);
System.out.println(binaryTree.size());
}
}
在某些情况下,我们需要查找出树中存储所有元素的键的最小值,比如我们的树中存储的是学生的排名和姓名数
据,那么需要查找出排名最低是多少名?这里我们设计如下两个方法来完成:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-42k7VtF9-1681958360363)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930384.png)]
很多情况下,我们可能需要像遍历数组数组一样,遍历树,从而拿出树中存储的每一个元素,由于树状结构和线性
结构不一样,它没有办法从头开始依次向后遍历,所以存在如何遍历,也就是按照什么样的搜索路径进行遍历的问
题。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-y5Sr4UEl-1681958360364)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930386.png)]
我们把树简单的画作上图中的样子,由一个根节点、一个左子树、一个右子树组成,那么按照根节点什么时候被访
问,我们可以把二叉树的遍历分为以下三种方式:
前序遍历;
先访问根结点,然后再访问左子树,最后访问右子树
中序遍历; 中序遍历二叉查找树得到的是升序序列
先访问左子树,中间访问根节点,最后访问右子树
后序遍历;
先访问左子树,再访问右子树,最后访问根节点
如果我们分别对下面的树使用三种遍历方式进行遍历,得到的结果如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-E6QrWxUu-1681958360364)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930387.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Z3ncxDI6-1681958360365)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930388.png)]
我们在6.4中创建的树上,添加前序遍历的API:
public Queue
:使用前序遍历,获取整个树中的所有键
private void preErgodic(Node x,Queue
:使用前序遍历,把指定树x中的所有键放入到keys队列中实现过程中,我们通过前序遍历,把每个结点的键取出,放入到队列中返回即可。
// 前序遍历指定的树
private void preErgodic(Node node){
if (node==null){
return;
}
queue.add(node.key); //遍历当前节点
// 遍历左子树
if (node.left!=null){
preErgodic(node.left);
}
// 遍历右子树
if (node.right!=null){
preErgodic(node.right);
}
}
我们在6.4中创建的树上,添加前序遍历的API:
public Queue
:使用中序遍历,获取整个树中的所有键
private void midErgodic(Node x,Queue
:使用中序遍历,把指定树x中的所有键放入到keys队列中 。
// 中序遍历整棵树
public Queue<Key> midErgodic(){
queue.clear();
midErgodic(root);
return queue;
}
// 中序遍历整棵树
public void midErgodic(Node node){
if (node==null){
return;
}
if (node.left!=null){
midErgodic(node.left);
}
queue.add(node.key);
if (node.right!=null){
midErgodic(node.right);
}
}
我们在6.4中创建的树上,添加前序遍历的API:
public Queue
:使用后序遍历,获取整个树中的所有键
private void afterErgodic(Node x,Queue
:使用后序遍历,把指定树x中的所有键放入到keys队列中。
// 后序遍历这整棵树
public Queue<Key> afterErgodic(){
queue.clear();
afterErgodic(root);
return queue;
}
public void afterErgodic(Node node){
if (node==null){
return;
}
if (node.left!=null){
afterErgodic(node.left);
}
if (node.right!=null){
afterErgodic(node.right);
}
queue.add(node.key);
}
所谓的层序遍历,就是从根节点(第一层)开始,依次向下,获取每一层所有结点的值,有二叉树如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kjqeCcEo-1681958360366)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930389.png)]
那么层序遍历的结果是:EBGADFHC
我们在6.4中创建的树上,添加层序遍历的API:
public Queue
实现步骤:
1.创建队列,存储每一层的结点;
2.使用循环从队列中弹出一个结点:
2.1 获取当前结点的key;
2.2 如果当前结点的左子结点不为空,则把左子结点放入到队列中
2.3 如果当前结点的右子结点不为空,则把右子结点放入到队列中
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ee1l4Tdm-1681958360366)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930390.png)]
// 层次遍历
public Queue<Key> layerErgodic(){
Queue<Key> keys = new LinkedList<>();
Queue<Node> nodes = new LinkedList<>();
nodes.add(root);
while (!nodes.isEmpty()){
Node x = nodes.poll();
keys.add(x.key);
if (x.left!=null){
nodes.add(x.left);
}
if (x.right!=null){
nodes.add(x.right);
}
}
return keys;
}
需求:
给定一棵树,请计算树的最大深度(树的根节点到最远叶子结点的最长路径上的结点数);
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aAV4hUWU-1681958360367)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201017975.png)]
上面这棵树的最大深度为4。
实现:
我们在6.4中创建的树上,添加如下的API求最大深度:
public int maxDepth()
: 计算整个树的最大深度
private int maxDepth(Node x)
: 计算指定树x的最大深度
// 计算整个树的最大深度
public int maxDepth(){
return maxDepth(root);
}
// 计算指定树x的最大深度
private int maxDepth(Node x){
//1.如果根结点为空,则最大深度为0;
if (x==null){
return 0;
}
int maxL = 0;
int maxR = 0;
//2.计算左子树的最大深度;
if (x.left!=null){
maxL = maxDepth(x.left);
}
//3.计算右子树的最大深度;
if (x.right!=null){
maxR = maxDepth(x.right);
}
//4.当前树的最大深度=左子树的最大深度和右子树的最大深度中的较大者+1
return maxL>maxR?maxL+1:maxR+1;
}
package com.ynu.Java版算法.U6_树的入门.T6_二叉树的层次遍历;
import java.util.Queue;
public class Main {
public static void main(String[] args) {
BinaryTree<Integer, String> bt = new BinaryTree<>();
bt.put(1,"A");
bt.put(5,"A");
bt.put(4,"A");
bt.put(6,"A");
bt.put(2,"A");
bt.put(3,"A");
bt.put(8,"A");
bt.put(7,"A");
// 树的高度
System.out.println(bt.maxDepth());
}
}
堆是计算机科学中一类特殊的数据结构的统称,堆通常可以被看做是一棵完全二叉树的数组实现。
堆的特性:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8TR2BbdA-1681958360367)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930392.png)]
它通常用数组来实现。
具体方法就是将二叉树的结点按照层级顺序放入数组中,根结点在位置1,它的子结点在位置2和3,而子结点的子结点则分别在位置4,5,6和7,以此类推。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-obleih88-1681958360368)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201017816.png)]
如果一个结点的位置为k,则它的父结点的位置为[k/2],而它的两个子结点的位置则分别为2k和2k+1。这样,在不使用指针的情况下,我们也可以通过计算数组的索引在树中上下移动:从a[k]向上一层,就令k等于k/2,向下一层就令k等于2k或2k+1。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-44k3TN4W-1681958360368)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930394.png)]
堆是用数组完成数据元素的存储的,由于数组的底层是一串连续的内存地址,所以我们要往堆中插入数据,我们只能往数组中从索引1处开始,依次往后存放数据,但是堆中对元素的顺序是有要求的,每一个结点的数据要大于等于它的两个子结点的数据,所以每次插入一个元素,都会使得堆中的数据顺序变乱,这个时候我们就需要通过一些方法让刚才插入的这个数据放入到合适的位置。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-f692CE6Y-1681958360369)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201018553.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vu9AbHpR-1681958360370)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930396.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cmB2n5ek-1681958360371)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930397.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zxMlmNT0-1681958360372)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930398.png)]
所以,如果往堆中新插入元素,我们只需要不断的比较新结点a[k]和它的父结点a[k/2]的大小,然后根据结果完成数据元素的交换,就可以完成堆的有序调整。
由堆的特性我们可以知道,索引1处的元素,也就是根结点就是最大的元素,当我们把根结点的元素删除后,需要有一个新的根结点出现,这时我们可以暂时把堆中最后一个元素放到索引1处,充当根结点,但是它有可能不满足堆的有序性需求,这个时候我们就需要通过一些方法,让这个新的根结点放入到合适的位置。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wF0Mtzr9-1681958360373)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930399.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oihkihrw-1681958360373)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930400.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FLjDiwf7-1681958360374)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930401.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PjhchBbV-1681958360375)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930402.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FjJmh5nO-1681958360376)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201018412.png)]
package com.ynu.Java版算法.U7_堆.T1_堆的实现_大顶堆;
public class Heap<T extends Comparable<T>> {
// 存储堆中的元素 数组存储元素
private T[] items;
// 堆的大小
private int N;
public Heap(int maxSize) {
items = (T[]) new Comparable[maxSize+1];
N = 0;
}
// 判断堆中索引i处的元素是否小于索引j处的元素
private boolean less(int i, int j){
return items[i].compareTo(items[j]) < 0;
}
//交换堆中i索引和j索引处的值
private void exch(int i,int j){
T temp = items[i];
items[i] = items[j];
items[j] = temp;
}
//往堆中插入一个元素
public void insert(T t){
items[++N] = t;
swim(N); // 上浮新插入的元素
}
//使用上浮算法,使索引k处的元素能在堆中处于一个正确的位置
private void swim(int k){
//如果已经到了根结点,就不需要循环了
while (k > 1){
//比较当前结点和其父结点
if (less(k/2,k)){
//父结点小于当前结点,需要交换
exch(k/2,k);
}
k = k/2;
}
}
//删除堆中最大的元素,并返回这个最大元素
public T deleteMax(){
T max = items[1];
// 交换索引1和索引N处的元素
exch(1,N);
// 删除最后的元素
items[N--] = null;
// 下层
sink(1);
return max;
}
private void sink(int i) {
//如果当前已经是最底层了,就不需要循环了
while (2*i <= N){
// 找到子节点中的较大者
int max;
if (2*i+1<=N){ // 存在右子节点
max = less(2*i,2*i+1)?2*i+1:2*i;
}else { // 不存在右子节点
max = 2*i;
}
//比较当前结点和子结点中的较大者,如果当前结点不小,则结束循环
if (!less(i,max)){
break;
}
exch(i,max);
i = max;
}
}
}
给定一个数组:
String[] arr = {"S","O","R","T","E","X","A","M","P","L","E"}
请对数组中的字符按从小到大排序。
实现步骤:
构造堆;
得到堆顶元素,这个值就是最大值;
交换堆顶元素和数组中的最后一个元素,此时所有元素中的最大元素已经放到合适的位置;
对堆进行调整,重新让除了最后一个元素的剩余元素中的最大值放到堆顶;
重复2~4这个步骤,直到堆中剩一个元素为止。
API设计:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mGlGPlfz-1681958360377)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930404.png)]
堆的构造,最直观的想法就是另外再创建一个和新数组数组,然后从左往右遍历原数组,每得到一个元素后,添加
到新数组中,并通过上浮,对堆进行调整,最后新的数组就是一个堆。
上述的方式虽然很直观,也很简单,但是我们可以用更聪明一点的办法完成它。创建一个新数组,把原数组
0length-1的数据拷贝到新数组的1length处,再从新数组长度的一半处开始往1索引处扫描(从右往左),然后
对扫描到的每一个元素做下沉调整即可。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8JsccFdw-1681958360377)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930405.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JcxzI7Rb-1681958360378)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930406.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UPxilx71-1681958360379)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930407.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ix7bMFg7-1681958360379)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201019835.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9Avb8rox-1681958360380)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930409.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jV3RChn4-1681958360381)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930410.png)]
堆构造完毕,堆有序。
对构造好的堆,我们只需要做类似于堆的删除操作,就可以完成排序。
将堆顶元素和堆中最后一个元素交换位置;
通过对堆顶元素下沉调整堆,把最大的元素放到堆顶(此时最后一个元素不参与堆的调整,因为最大的数据已经到了数组的最右边)
重复1~2步骤,直到堆中剩最后一个元素。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-q3cNfrwB-1681958360382)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930412.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HvYszcke-1681958360383)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930413.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AfveQK0N-1681958360384)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930414.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-B5fDhFxr-1681958360384)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930415.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HQONyg1P-1681958360385)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930416.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fnedZPDM-1681958360385)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930417.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JTGTzqD0-1681958360386)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930418.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1gXTyq6y-1681958360387)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930419.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FNtwjP61-1681958360388)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930420.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ye8jbjwf-1681958360388)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930421.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cWL1fMch-1681958360389)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930422.png)]
结果,已排序
package com.ynu.Java版算法.U7_堆.T3_堆排序;
//堆排序代码
public class HeapSort {
//对source数组中的数据从小到大排序
public static void sort(Comparable[] source){
//1.创建一个比原数组大1的数组
Comparable[] heap = new Comparable[source.length + 1];
//2.构建堆 -- 大顶堆了
createHeap(source,heap);
//3.堆排序
//3.1定义一个变量,记录heap中未排序的所有元素中最大的索引
int N = heap.length - 1;
while (N!=1){
//3.2交换heap中索引1处的元素和N处的元素 当前索引为1的元素已经是最大的元素了
exch(heap,1,N);
N--;
sink(heap,1,N);
}
//4.heap中的数据已经有序,拷贝到source中
System.arraycopy(heap,1,source,0,source.length);
}
//根据原数组source,构造出堆heap
// String也是实现过Comparable接口的类,所以可以传进来。 是多态的应用。
private static void createHeap(Comparable[] source,Comparable[] heap){
//1.把source中的数据拷贝到heap中,从heap的1索引处开始填充
System.arraycopy(source,0,heap,1,source.length);
//2.从heap索引的一半处开始倒叙遍历,对得到的每一个元素做下沉操作
for (int i = (heap.length - 1) / 2; i > 0 ; i--) {
sink(heap,i,heap.length-1);
}
}
//判断heap堆中索引i处的元素是否小于索引j处的元素
private static boolean less(Comparable[] heap,int i,int j ){
return heap[i].compareTo(heap[j]) < 0;
}
//交换heap堆中i索引和j索引处的值
private static void exch(Comparable[] heap,int i,int j){
Comparable temp = heap[i];
heap[i] = heap[j];
heap[j] = temp;
}
//在heap堆中,对target处的元素做下沉,范围是0~range
private static void sink(Comparable[] heap,int target,int range){
// 没有子节点了就退出循环
while (2*target <= range){
//1.找出target结点的两个子结点中的较大值
int max = 2*target;
if (2*target +1 <= range){ // 存在右子节点
if (less(heap,2*target,2*target + 1)){
max = 2*target + 1;
}
}
//2.如果当前结点的值小于子结点中的较大值,则交换
if (less(heap,target,max)){
exch(heap,target,max);
}
//3.更新target的值
target = max;
}
}
}
package com.ynu.Java版算法.U7_堆.T3_堆排序;
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
String[] arr = {"S", "O", "R", "T", "E", "X", "A", "M", "P", "L", "E"};
HeapSort.sort(arr);
System.out.println(Arrays.toString(arr));
}
}
Java中PriorityQueue通过二叉小顶堆实现,可以用一棵完全二叉树表示。PriorityQueue位于Java util包中,实际上这个队列就是具有“优先级”。既然具有优先级的特性,那么就得有个前后排序的“规则”。所以其接受的类需要实现Comparable 接口。该队列线程安全,不允许null值,入队和出队的时间复杂度是O(log(n))。
PriorityQueue 默认是小根堆,大根堆需要重写比较器。对与大根堆,就要借助于comparator比较器,来实现大根堆。
实现方法有两种
第一种: 匿名内部类
PriorityQueue<Integer>bigHeap=new PriorityQueue<>(new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o2-o1;
}
});
第二种: lambda表达式
PriorityQueue<Integer> bigHeap = new PriorityQueue<>((o1, o2) -> o2 - o1);
堆排序:
package com.ynu.Java版算法.U7_堆.T4_API实现堆排序;
import org.junit.Test;
import java.util.Arrays;
import java.util.Comparator;
import java.util.PriorityQueue;
public class Main {
// 1. 大顶堆 实现升序
@Test
public void test() {
String[] arr = {"B", "D", "C", "F", "E", "G", "H", "J", "I", "K", "A"};
// 构建空大顶堆
PriorityQueue<String> heap = new PriorityQueue<>(new Comparator<String>() {
@Override
public int compare(String o1, String o2) {
return o2.compareTo(o1);
}
});
// 把元素放进大顶堆
for (String s : arr) {
heap.offer(s);
}
// 堆排序 —— 升序
int k = arr.length - 1;
int size = heap.size();
for (int i = 0; i < size; i++) {
arr[k--] = heap.poll();
}
System.out.println(Arrays.toString(arr));
}
// 1. 小顶堆 实现升序
@Test
public void test1() {
String[] arr = {"B", "D", "C", "F", "E", "G", "H", "J", "I", "K", "A"};
// 1.构建空的小顶堆
PriorityQueue<String> heap = new PriorityQueue<>((o1,o2)->o1.compareTo(o2));
// 2.元素放进小顶堆
for (String s : arr) {
heap.offer(s);
}
// 3.堆排序 升序排列
int k = 0;
int size = arr.length;
for (int i = 0; i < size; i++) {
arr[k++] = heap.poll();
}
System.out.println(Arrays.toString(arr));
}
}
普通的队列是一种先进先出的数据结构,元素在队列尾追加,而从队列头删除。在某些情况下,我们可能需要找出队列中的最大值或者最小值,例如使用一个队列保存计算机的任务,一般情况下计算机的任务都是有优先级的,我们需要在这些计算机的任务中找出优先级最高的任务先执行,执行完毕后就需要把这个任务从队列中移除。普通的队列要完成这样的功能,需要每次遍历队列中的所有元素,比较并找出最大值,效率不是很高,这个时候,我们就可以使用一种特殊的队列来完成这种需求,优先队列。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qG0jDsKN-1681958360389)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930423.png)]
优先队列按照其作用不同,可以分为以下两种:
最大优先队列:
可以获取并删除队列中最大的值
最小优先队列:
可以获取并删除队列中最小的值
我们之前学习过堆,而堆这种结构是可以方便的删除最大的值,所以,接下来我们可以基于堆去实现最大优先队列。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uzIlprJB-1681958360390)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201021200.png)]
package com.ynu.Java版算法.U8_优先队列.T1_最大优先队列;
//最大优先队列代码
public class MaxPriorityQueue<T extends Comparable<T>> {
//存储堆中的元素
private T[] items;
//记录堆中元素的个数
private int N;
public MaxPriorityQueue(int capacity) {
items = (T[]) new Comparable[capacity+1];
N = 0;
}
// 判断堆中的索引i处的元素是否小于索引j处的元素
private boolean less(int i,int j){
return items[i].compareTo(items[j]) < 0;
}
// 交换索引i,j处的元素
private void exch(int i,int j){
T temp = items[i];
items[i] = items[j];
items[j] = temp;
}
// 插入节点
public void insert(T t){
items[++N] = t; // ++N 保证是索引从1开始的
swim(N);
}
// 每次删除最大值
public T deleteMax(){
T max = items[1];
exch(1,N);
items[N--] = null;
sink(1);
return max;
}
// swim上浮算法,使索引k处的元素上浮到正确位置
private void swim(int k){
while (k > 1){
if (less(k,k/2)){ // 父节点大于当前节点 退出循环
break;
}
exch(k,k/2);
k = k/2;
}
}
// sink下沉算法,使索引k处的元素能够处于正确位置
private void sink(int k){
while (2*k <= N){
// 找到子节点的较大者
int max = 2*k;
if (2*k + 1 <= N){ // 存在右子节点
if (less(2*k,2*k+1)){
max = 2*k+1;
}
}
//比较当前结点和子结点中的较大者,如果当前结点不小,则结束循环
if (!less(k,max)){
break;
}
exch(k,max);
k = max;
}
}
public int size(){
return N;
}
public boolean isEmpty(){
return N==0;
}
}
package com.ynu.Java版算法.U8_优先队列.T1_最大优先队列;
public class Main {
public static void main(String[] args) {
MaxPriorityQueue<String> queue = new MaxPriorityQueue<>(20);
queue.insert("A");
queue.insert("D");
queue.insert("C");
queue.insert("E");
queue.insert("G");
queue.insert("H");
queue.insert("I");
while (!queue.isEmpty()){
String max = queue.deleteMax();
System.out.println(max);
}
// 输出剩余大小 应该是0了
System.out.println(queue.size());
}
}
最小优先队列实现起来也比较简单,我们同样也可以基于堆来完成最小优先队列。
我们前面学习堆的时候,堆中存放数据元素的数组要满足都满足如下特性:
1.最大的元素放在数组的索引1处。
2.每个结点的数据总是大于等于它的两个子结点的数据。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-y9PxCnmj-1681958360391)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930425.png)]
其实我们之前实现的堆可以把它叫做最大堆,我们可以用相反的思想实现最小堆,让堆中存放数据元素的数组满足
如下特性:
1.最小的元素放在数组的索引1处。
2.每个结点的数据总是小于等于它的两个子结点的数据。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-D8CDamDC-1681958360391)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930426.png)]
这样我们就能快速的访问到堆中最小的数据。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PbiXL7dh-1681958360392)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930427.png)]
package com.ynu.Java版算法.U8_优先队列.T2_最小优先队列;
public class MinPriorityQueue<T extends Comparable> {
private T[] items;
private int N;
public MinPriorityQueue(int capacity) {
items = (T[]) new Comparable[capacity+1];
}
// 判断索引i处的元素是否小于索引j处的元素
private boolean less(int i,int j){
return items[i].compareTo(items[j])<0;
}
public boolean isEmpty(){
return N==0;
}
// 交换索引i和索引j处的元素
private void exch(int i,int j){
T temp = items[i];
items[i] = items[j];
items[j] = temp;
}
//往堆中插入一个元素
public void insert(T t){
items[++N] = t;
swim(N);
}
// 删除队列中的最小值
public T delMin(){
T min = items[1];
exch(1,N);
items[N] = null;
N--;
sink(1);
return min;
}
// swim(k)
private void swim(int k){
while (k>1){
if (less(k,k/2)){
exch(k,k/2);
}
k = k/2;
}
}
// sink(k)
private void sink(int k){
while (2*k <= N){
int min = 2*k;
if (2*k+1 <= N){
if (!less(2*k,2*k+1)){
min = 2*k+1;
}
}
if (less(k,min)){
break;
}
exch(k,min);
k = min;
}
}
public int size() {
return N;
}
}
package com.ynu.Java版算法.U8_优先队列.T2_最小优先队列;
public class Main {
public static void main(String[] args) {
MinPriorityQueue<String> minPriorityQueue = new MinPriorityQueue<>(20);
minPriorityQueue.insert("D");
minPriorityQueue.insert("H");
minPriorityQueue.insert("I");
minPriorityQueue.insert("K");
minPriorityQueue.insert("A");
minPriorityQueue.insert("B");
minPriorityQueue.insert("C");
System.out.println(minPriorityQueue.size());
while (!minPriorityQueue.isEmpty()){
System.out.println(minPriorityQueue.delMin());
}
}
}
在之前实现的最大优先队列和最小优先队列,他们可以分别快速访问到队列中最大元素和最小元素,但是他们有一个缺点,就是没有办法通过索引访问已存在于优先队列中的对象,并更新它们。为了实现这个目的,在优先队列的基础上,学习一种新的数据结构,索引优先队列。接下来我们以最小索引优先队列举列。
步骤一:
存储数据时,给每一个数据元素关联一个整数,例如insert(int k,T t),我们可以看做k是t关联的整数,那么我们的实现需要通过k这个值,快速获取到队列中t这个元素,此时有个k这个值需要具有唯一性。 最直观的想法就是我们可以用一个T[] items数组来保存数据元素,在insert(int k,T t)完成插入时,可以把k看做是 items数组的索引,把t元素放到items数组的索引k处,这样我们再根据k获取元素t时就很方便了,直接就可以拿到items[k]即可。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2N2ZRqSn-1681958360393)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930428.png)]
步骤二:
步骤一完成后的结果,虽然我们给每个元素关联了一个整数,并且可以使用这个整数快速的获取到该元素,但是, items数组中的元素顺序是随机的,并不是堆有序的,所以,为了完成这个需求,我们可以增加一个数组int[]pq,来保存每个元素在items数组中的索引,pq数组需要堆有序,也就是说,pq[1
]对应的数据元素items[pq[1]]
要小于等于pq[2]和pq[3]
对应的数据元素items[pq[2]]
和items[pq[3]]
。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9i3PyRFB-1681958360393)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930429.png)]
步骤三:
通过步骤二的分析,我们可以发现,其实我们通过上浮和下沉做堆调整的时候,其实调整的是pq数组。如果需要对items中的元素进行修改,比如让items[0]=“H”。那么很显然,我们需要对pq中的数据做堆调整,而且是调整 pq[9]中元素的位置。但现在就会遇到一个问题,我们修改的是items数组中0索引处的值,如何才能快速的知道需要挑中pq[9]中元素的位置呢?
最直观的想法就是遍历pq数组,拿出每一个元素和0做比较,如果当前元素是0,那么调整该索引处的元素即可, 但是效率很低。
我们可以另外增加一个数组,int[] qp,用来存储pq的逆序。例如:
在pq数组中:pq[1]=6;
那么在qp数组中,把6作为索引,1作为值,结果是:qp[6]=1;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RiabPhCS-1681958360394)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201021752.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-29Hom7Ez-1681958360394)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201021283.png)]
package com.ynu.Java版算法.U8_优先队列.T3_索引优先队列;
public class IndexMinPriorityQueue<T extends Comparable<T>> {
// 存储堆中的元素
public T[] items;
//保存每个元素在items数组中的索引,pq数组需要堆有序
private int[] pq;
//保存qp的逆序,pq的值作为索引,pq的索引作为值
private int[] qp;
// 记录堆中元素的个数
private int N;
// 获取索引index处的值
public T get(int index){
return items[index];
}
public IndexMinPriorityQueue(int capacity) {
items = (T[]) new Comparable[capacity+1];
pq = new int[capacity + 1]; // 因为是从索引为1处开始存储 所以需要capacity + 1
qp = new int[capacity + 1];
N = 0;
for (int i = 0; i < qp.length; i++) {
//默认情况下,qp逆序中不保存任何索引
qp[i] = -1;
}
}
//获取队列中元素的个数
public int size() {
return N;
}
//判断队列是否为空
public boolean isEmpty() {
return N == 0;
}
//判断堆中索引i处的元素是否小于索引j处的元素
private boolean less(int i, int j) {
//先通过pq找出items中的索引,然后再找出items中的元素进行对比
return items[pq[i]].compareTo(items[pq[j]]) < 0;
}
//交换堆中i索引和j索引处的值
private void exch(int i,int j){
// 先交换pq数组中的值
int temp = pq[i];
pq[i] = pq[j];
pq[j] = temp;
// 更新qp数组中的值
qp[pq[i]] = i;
qp[pq[j]] = j;
}
//判断k对应的元素是否存在
public boolean contains(int k){
return qp[k] != -1;
}
//最小元素关联的索引 minIndex items[minIndex]的值就是最小值
public int minIndex(){
return pq[1];
}
//使用上浮算法,使索引k处的元素能在堆中处于一个正确的位置
private void swim(int k){
while (k > 1){
if (less(k,k/2)) {
exch(k,k/2);
}
k = k/2;
}
}
//使用下沉算法,使索引k处的元素能在堆中处于一个正确的位置
private void sink(int k){
//如果当前结点已经没有子结点了,则结束下沉
while (2*k <= N){
int min = 2*k;
if (2*k +1 <=N && less(2*k+1,2*k)){
min = 2*k+1;
}
//如果当前结点的值比子结点中的较小值小,则结束下沉
if (less(k,min)){
break;
}
exch(k,min);
k = min;
}
}
//往队列中插入一个元素,并关联索引i
public void insert(int i,T t){
//如果索引i处已经存在了元素,则不让插入
if (contains(i)){
throw new RuntimeException("该索引已经存在");
}
// 个数加一
N++;
// 把元素放进items数组
items[i] = t;
// 使用pq存放i这个索引
pq[N] = i;
qp[i] = N;
//上浮items[pq[N]],让pq堆有序
swim(N);
}
//删除队列中最小的元素,并返回该元素关联的索引
public int delMin(){
int minIndex = pq[1];
// 交换pq索引1处的值和N处的值
exch(1,N);
//删除pq中索引N处的值
qp[pq[N]] = -1;
//删除items中的最小元素
items[minIndex] = null;
// 元素数量减一
N--;
//对pq[1]做下沉,让堆有序
sink(1);
return minIndex;
}
//删除索引i关联的元素
public void delete(int i){
// 找出i在pq中的索引
int k = qp[i];
// 把pq中索引k处的值和索引N处的值交换
exch(i,N);
// 删除qp中索引pq[N]处的值
qp[pq[N]] = -1;
// 删除索引pq中索引N处的值
pq[N] = -1;
//删除items中索引i处的值
items[i] = null;
//元素数量-1
N--;
//对pq[k]做下沉,让堆有序
sink(k);
//对pq[k]做上浮,让堆有序
swim(k);
}
//把与索引i关联的元素修改为为t
public void changeItem(int i, T t) {
//修改items数组中索引i处的值为t
items[i] = t;
//找到i在pq中的位置
int k = qp[i];
//对pq[k]做下沉,让堆有序
sink(k);
//对pq[k]做上浮,让堆有序
swim(k);
}
}
package com.ynu.Java版算法.U8_优先队列.T3_索引优先队列;
public class Main {
public static void main(String[] args) {
String[] arr = {"S", "O", "R", "T", "E", "X", "A", "M", "P", "L", "E"};
IndexMinPriorityQueue<String> indexMinPQ = new IndexMinPriorityQueue<>(20);
//插入
for (int i = 0; i < arr.length; i++) {
indexMinPQ.insert(i,arr[i]);
}
System.out.println(indexMinPQ.size());
//获取最小值的索引
System.out.println(indexMinPQ.minIndex());
//测试修改
indexMinPQ.changeItem(0,"Z");
// 从小到大遍历
while(!indexMinPQ.isEmpty()){
System.out.print(indexMinPQ.get(indexMinPQ.minIndex())+" ");
indexMinPQ.delMin();
}
}
}
之前我们学习过二叉查找树,发现它的查询效率比单纯的链表和数组的查询效率要高很多,大部分情况下,确实是这样的,但不幸的是,在最坏情况下,二叉查找树的性能还是很糟糕。 例如我们依次往二叉查找树中插入9,8,7,6,5,4,3,2,1这9个数据,那么最终构造出来的树是长得下面这个样子:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AN2Qozs5-1681958360395)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201022257.png)]
我们会发现,如果我们要查找1这个元素,查找的效率依旧会很低。效率低的原因在于这个树并不平衡,全部是向左边分支,如果我们有一种方法,能够不受插入数据的影响,让生成的树都像完全二叉树那样,那么即使在最坏情况下,查找的效率依旧会很好。
平衡树(Balance Tree,BT) 指的是,任意节点的子树的高度差都小于等于1。
为了保证查找树的平衡性,我们需要一些灵活性,因此在这里我们允许树中的一个结点保存多个键。确切的说,我们将一棵标准的二叉查找树中的结点称为2-结点(含有一个键和两条链),而现在我们引入3-结点,它含有两个键和三条链。 2-结点和3-结点中的每条链都对应着其中保存的键所分割产生的一个区间。
一棵2-3查找树要么为空,要么满足满足下面两个要求:
2-结点:
含有一个键(及其对应的值)和两条链,左链接指向2-3树中的键都小于该结点,右链接指向的2-3树中的键都大于该结点。
3-结点:
含有两个键(及其对应的值)和三条链,左链接指向的2-3树中的键都小于该结点,中链接指向的2-3树中的键都 位于该结点的两个键之间,右链接指向的2-3树中的键都大于该结点。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UXQwRHpF-1681958360396)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201022048.png)]
将二叉查找树的查找算法一般化我们就能够直接得到2-3树的查找算法。要判断一个键是否在树中,我们先将它和根结点中的键比较。如果它和其中任意一个相等,查找命中;否则我们就根据比较的结果找到指向相应区间的连接,并在其指向的子树中递归地继续查找。如果这个是空链接,查找未命中。
对于H的命中查找
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2xXm0HNI-1681958360396)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201022240.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-y5CDfVfx-1681958360397)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201022967.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gYZMTcED-1681958360397)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201022132.png)]
往2-3树中插入元素和往二叉查找树中插入元素一样,首先要进行查找,然后将节点挂到未找到的节点上。2-3树之所以能够保证在最差的情况下的效率还很高的原因在于其插入之后仍然能够保持平衡状态。如果查找后未找到的节点是一个2-结点,那么很容易,我们只需要将新的元素放到这个2-结点里面使其变成一个3-结点即可。但是如果查找的节点结束于一个3-结点,那么可能有点麻烦。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WvKkPMcV-1681958360398)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201022254.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2UfgrF2K-1681958360398)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201022794.png)]
假设2-3树只包含一个3-结点,这个结点有两个键,没有空间来插入第三个键了,最自然的方式是我们假设这个结点能存放三个元素,暂时使其变成一个4-结点,同时他包含四条链接。然后,我们将这个4-结点的中间元素提升,左边的键作为其左子结点,右边的键作为其右子结点。插入完成,变为平衡2-3查找树,树的高度增加1。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Xy2mCbzr-1681958360399)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201022410.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SHDgwEWl-1681958360399)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201022651.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-W7cL1ktz-1681958360400)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201022493.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kZfPjI5y-1681958360401)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201022441.png)]
当我们插入的结点是3-结点的时候,我们将该结点拆分,中间元素提升至父结点,但是此时父结点是一个3-结点, 插入之后,父结点变成了4-结点,然后继续将中间元素提升至其父结点,直至遇到一个父结点是2-结点,然后将其变为3-结点,不需要继续进行拆分。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ajpg7CUC-1681958360401)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201022135.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TccQUOON-1681958360402)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201022527.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-I16rMHDM-1681958360402)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201022649.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Obf7yoh6-1681958360403)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201022571.png)]
当插入结点到根结点的路径上全部是3-结点的时候,最终我们的根结点会编程一个临时的4-结点,此时,就需要将根结点拆分为两个2-结点,树的高度加1。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Fpg4BQVB-1681958360403)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201022070.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yp7TUt1Z-1681958360404)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201023809.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HJhTDFak-1681958360406)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201023872.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-340P8pUU-1681958360407)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201023326.png)]
通过对2-3树插入操作的分析,我们发现在插入的时候,2-3树需要做一些局部的变换来保持2-3树的平衡。 一棵完全平衡的2-3树具有以下性质:
任意空链接到根结点的路径长度都是相等的。
4-结点变换为3-结点时,树的高度不会发生变化,只有当根结点是临时的4-结点,分解根结点时,树高+1。
2-3树与普通二叉查找树最大的区别在于,普通的二叉查找树是自顶向下生长,而2-3树是自底向上生长。
直接实现2-3树比较复杂,因为:
需要处理不同的结点类型,非常繁琐;
需要多次比较操作来将结点下移;
需要上移来拆分4-结点;
拆分4-结点的情况有很多种;
2-3查找树实现起来比较复杂,在某些情况插入后的平衡操作可能会使得效率降低。但是2-3查找树作为一种比较重
要的概念和思路对于我们后面要讲到的红黑树、B树和B+树非常重要。
我们前面介绍了2-3树,可以看到2-3树能保证在插入元素之后,树依然保持平衡状态,它的最坏情况下所有子结点都是2-结点,树的高度为lgN(N是节点个数),相比于我们普通的二叉查找树,最坏情况下树的高度为N,确实保证了最坏情况下的时间复杂度,但是2-3树实现起来过于复杂,所以我们介绍一种2-3树思想的简单实现:红黑树。 红黑树主要是对2-3树进行编码,红黑树背后的基本思想是**用标准的二叉查找树(完全由2-结点构成)和一些额外的信息(替换3-结点)来表示2-3树。**我们将树中的链接分为两种类型:
**红链接:**将两个2-结点连接起来构成一个逻辑上的3-结点;
**黑链接:**则是2-3树中的普通链接。
确切的说,我们将3-结点表示为由一条左斜的红色链接(两个2-结点其中之一是另一个的左子结点)相连的两个2-
结点。这种表示法的一个优点是,我们无需修改就可以直接使用标准的二叉查找树的get方法。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XSzjaId3-1681958360408)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201023788.png)]
红黑树是含有红黑链接并满足下列条件的二叉查找树:
红链接均为左链接;
没有任何一个结点同时和两条红链接相连;
该树是完美黑色平衡的,即任意空链接到根结点的路径上的黑链接数量相同;
下面是红黑树与2-3树的对应关系:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oYGRseKA-1681958360409)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201023257.png)]
因为每个结点都只会有一条指向自己的链接(从它的父结点指向它),我们可以在之前的Node结点中添加一个布尔类型的变量color来表示链接的颜色。如果指向它的链接是红色的,那么该变量的值为true,如果链接是黑色的,那么该变量的值为false。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WN8ZpJMZ-1681958360409)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201023713.png)]
API设计:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YfhkJ2Nv-1681958360410)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930455.png)]
package com.ynu.Java版算法.U9_树的进阶.T1_红黑树;
public class Node<Key,Value> {
//存储键
public Key key;
//存储值
private Value value;
//记录左子结点
public Node left;
//记录右子结点
public Node right;
//由其父结点指向它的链接的颜色
public boolean color;
public Node(Key key, Value value, Node left,Node right,boolean color) {
this.key = key;
this.value = value;
this.left = left;
this.right = right;
this.color = color;
}
}
在对红黑树进行一些增删改查的操作后,很有可能会出现 红色的右链接或者两条连续红色的链接 \textcolor{red}{红色的右链接或者两条连续红色的链接} 红色的右链接或者两条连续红色的链接,而这些都不满足红黑树的定义,所以我们需要对这些情况通过旋转进行修复,让红黑树保持平衡。
当某个结点的左子结点为黑色,右子结点为红色,此时需要左旋。
**前提:**当前结点为h,它的右子结点为x;
左旋过程:
让x的左子结点变为h的右子结点:h.right=x.left;
让h成为x的左子结点:x.left=h;
让h的color属性变为x的color属性值:x.color=h.color; 就是原先指向x的链接变成指向h
让h的color属性变为RED:h.color=true;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oh0EFviK-1681958360411)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201023465.png)]
当某个结点的左子结点是红色,且左子结点的左子结点也是红色,需要右旋
**前提:**当前结点为h,它的左子结点为x;
右旋过程:
让x的右子结点成为h的左子结点:h.left = x.right;
让h成为x的右子结点:x.right=h;
让x的color变为h的color属性值:x.color = h.color;
让h的color为RED;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-S439umYy-1681958360411)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202304201023324.png)]
一棵只含有一个键的红黑树只含有一个2-结点。插入另一个键后,我们马上就需要将他们旋转。
其实就是颜色为黑的节点需要连接元素了
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fq7MtSn4-1681958360412)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930458.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9Q9lb7su-1681958360413)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930459.png)]
用和二叉查找树相同的方式向一棵红黑树中插入一个新键,会在树的底部新增一个结点(可以保证有序性),唯一区别的地方是我们会用红链接将新结点和它的父结点相连。如果它的父结点是一个2-结点,那么刚才讨论的两种方式仍然适用。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rZUpmyUQ-1681958360413)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930460.png)]
当一个结点的左子结点和右子结点的color都为RED时,也就是出现了临时的4-结点,此时只需要把左子结点和右子结点的颜色变为BLACK,同时让当前结点的颜色变为RED即可。(向上分裂操作)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DM7CLyPk-1681958360414)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930461.png)]
这种情况有可以分为三种子情况:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FFzym83L-1681958360415)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930462.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NsT3PD57-1681958360416)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930463.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KA0eWw6C-1681958360417)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930464.png)]
之前我们介绍结点API的时候,在结点Node对象中color属性表示的是父结点指向当前结点的连接的颜色,由于根结点不存在父结点,所以每次插入操作后,我们都需要把根结点的颜色设置为黑色。
假设在树的底部的一个3-结点下加入一个新的结点。前面我们所讲的3种情况都会出现。指向新结点的链接可能是3-结点的右链接(此时我们只需要转换颜色即可),或是左链接(此时我们需要进行右旋转然后再转换),或是中链接(此时需要先左旋转然后再右旋转,最后转换颜色)。颜色转换会使中间结点的颜色变红,相当于将它送入了父结点。这意味着父结点中继续插入一个新键,我们只需要使用相同的方法解决即可,直到遇到一个2-结点或者根结点为止。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iludrOBK-1681958360418)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930465.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jF1Q2dZp-1681958360419)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930466.png)]
我们已经学习了二叉查找树、2-3树以及它的实现红黑树。2-3树中,一个结点做多能有两个key,它的实现红 黑树中使用对链接染色的方式去表达这两个key。接下来我们学习另外一种树型结构B树,这种数据结构中,一个结点允许多于两个key的存在。 B树是一种树状数据结构,它能够存储数据、**对其进行排序并允许以O(logn)**的时间复杂度进行查找、顺序读取、插入和删除等操作。
B树中允许一个结点中包含多个key,可以是3个、4个、5个甚至更多,并不确定,需要看具体的实现。现在我们选择一个参数M,来构造一个B树,我们可以把它称作是M阶的B树,那么该树会具有如下特点:
每个结点最多有M-1个key,并且以升序排列;
每个结点最多能有M个子结点;
根结点至少有两个子结点,也就是至少有一个Key;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GR2R89dZ-1681958360420)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930467.png)]
在实际应用中B树的阶数一般都比较大(通常大于100),所以,即使存储大量的数据,B树的高度仍然比较小,这样在某些应用场景下,就可以体现出它的优势。
若参数M选择为5,那么每个结点最多包含4个键值对,我们以5阶B树为例,看看B树的数据存储。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cUMcpHsW-1681958360421)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930468.png)]
优先填满下层的大节点,再向上分裂。 \textcolor{red}{优先填满下层的大节点,再向上分裂。} 优先填满下层的大节点,再向上分裂。
在我们的程序中,不可避免的需要通过IO操作文件,而我们的文件是存储在磁盘上的。计算机操作磁盘上的文件是通过文件系统进行操作的,在文件系统中就使用到了B树这种数据结构。
磁盘能够保存大量的数据,从GB一直到TB级,但他的读取速度比较慢,因为涉及到机器操作,读取速度为毫秒级。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CjGXrP6o-1681958360422)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930469.png)]
磁盘由盘片构成,每个盘片有两面,又称为盘面 。盘片中央有一个可以旋转的主轴,他使得盘片以固定的旋转速率 旋转,通常是5400rpm或者是7200rpm,一个磁盘中包含了多个这样的盘片并封装在一个密封的容器内 。盘片的每个表面是由一组称为磁道同心圆组成的 ,每个磁道被划分为了一组扇区 ,每个扇区包含相等数量的数据位,通常是512个子节,扇区之间由一些间隙隔开,这些间隙中不存储数据 。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-A5GIPiV0-1681958360423)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930470.png)]
由于存储介质的特性,磁盘本身存取就比主存慢很多,再加上机械运动耗费,因此为了提高效率,要尽量减少磁盘 I/O,减少读写操作。 为了达到这个目的,磁盘往往不是严格按需读取,而是每次都会预读,即使只需要一个字节,磁盘也会从这个位置开始,顺序向后读取一定长度的数据放入内存。这样做的理论依据是计算机科学中著名的局部性原理:当一个数据被用到时,其附近的数据也通常会马上被使用。由于磁盘顺序读取的效率很高(不需要寻道时间,只需很少的旋转时间),因此预读可以提高I/O效率。
页是计算机管理存储器的逻辑块,硬件及操作系统往往将主存和磁盘存储区分割为连续的大小相等的块,每个存储块称为一页(1024个字节或其整数倍),预读的长度一般为页的整倍数。主存和磁盘以页为单位交换数据。当程序要读取的数据不在主存中时,会触发一个缺页异常,此时系统会向磁盘发出读盘信号,磁盘会找到数据的起始位置并向后连续读取一页或几页载入内存中,然后异常返回,程序继续运行。
文件系统的设计者利用了磁盘预读原理,将一个结点的大小设为等于一个页(1024个字节或其整数倍),这样每个结点只需要一次I/O就可以完全载入。那么3层的B树可以容纳1024*1024*1024差不多10亿字节数据,如果换成二叉查找树,则需要30层!假定操作系统一次读取一个节点,并且根节点保留在内存中,那么B树在10亿个数据中查找目标值,只需要小于3次硬盘读取就可以找到目标值,但红黑树需要小于30次,因此B树大大提高了IO的操作效率。
B+树是对B树的一种变形树,它与B树的差异在于:
非叶结点仅具有索引作用,也就是说,非叶子结点只存储key,不存储value,这样就可以存储更多的键了。B树既存放索引,也存放数据,这样如果数据的量很大就会导致B树的一个大节点存放不了太多键值对从而使树高增高,降低查找速度;
树的所有叶结点构成一个有序链表,可以按照key排序的次序遍历全部数据。也方便了范围查找啊
若参数M选择为5,那么每个结点最多包含4个键值对,我们以5阶B+树为例,看看B+树的数据存储。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yAUQp2gv-1681958360424)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930471.png)]
B+ 树的优点在于:
由于B+树在非叶子结点上不包含真正的数据,只当做索引使用,因此在内存相同的情况下,能够存放更多的
key。
B+树的叶子结点都是相连的,因此对整棵树的遍历只需要一次线性遍历叶子结点即可。而且由于数据顺序
排列并且相连,所以便于区间查找和搜索。而B树则需要进行每一层的递归遍历。
B树的优点在于:
由于B树的每一个节点都包含key和value,因此我们根据key查找value时,只需要找到key所在的位置,就能找到 value,但B+树只有叶子结点存储数据,索引每一次查找,都必须一次一次,一直找到树的最大深度处,也就是叶子结点的深度,才能找到value。
在数据库的操作中,查询操作可以说是最频繁的一种操作,因此在设计数据库时,必须要考虑到查询的效率问题, 在很多数据库中,都是用到了B+树来提高查询的效率; 在操作数据库时,我们为了提高查询效率,可以基于某张表的某个字段建立索引,就可以提高查询效率,那其实这个索引就是B+树这种数据结构实现的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uv29sXxV-1681958360425)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930472.png)]
执行 select * from user where id=18
,需要从第一条数据开始,一直查询到第6条,发现id=18,此时才能查询出目标结果,共需要比较6次;
只需要比较两次
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ULcXa9Zf-1681958360426)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930473.png)]
执行 select * from user where id>=12 and id<=18
,如果有了索引,由于B+树的叶子结点形成了一个有序链表, 所以我们只需要找到id为12的叶子结点,按照遍历链表的方式顺序往后查即可,效率非常高。
并查集是一种树型的数据结构 ,并查集可以高效地进行如下操作:
查询元素p和元素q是否属于同一组
合并元素p和元素q所在的组
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aztXt9F9-1681958360426)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930474.png)]
并查集也是一种树型结构,但这棵树跟我们之前讲的二叉树、红黑树、B树等都不一样,这种树的要求比较简单:
每个元素都唯一的对应一个结点;
每一组数据中的多个元素都在同一颗树中;
一个组中的数据对应的树和另外一个组中的数据对应的树之间没有任何联系;
元素在树中并没有子父级关系的硬性要求;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MiUtKOjK-1681958360427)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930475.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4cBNsiIN-1681958360428)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930476.png)]
初始情况下,每个元素都在一个独立的分组中,所以,初始情况下,并查集中的数据默认分为N个组;
初始化数组eleAndGroup;
把eleAndGroup数组的索引看做是每个结点存储的元素,把eleAndGroup数组每个索引处的值看做是该结点
所在的分组,那么初始化情况下,i索引处存储的值就是i
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gIlLiOO1-1681958360428)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930477.png)]
如果p和q已经在同一个分组中,则无需合并
如果p和q不在同一个分组,则只需要将p元素所在组的所有的元素的组标识符修改为q元素所在组的标识符即
可
分组数量-1
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-P6iFE0oQ-1681958360429)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930478.png)]
package com.ynu.Java版算法.U10_并查集.T1_并查集实现;
public class Main {
public static void main(String[] args) {
// 创建10个元素的并查集
UF uf = new UF(10);
System.out.println(uf.count());
// 把1,9合并到4
uf.union(1,4);
uf.union(9,4);
// 合并2,3两组 把5合并到3
uf.union(2,3);
System.out.println(uf.count());
// 合并5,3两组 把5合并到3
uf.union(5,3);
// 输出3组的所有元素
System.out.println(uf.findAll(3));
// 把4合并到3
uf.union(4,3);
System.out.println(uf.findAll(3));
}
}
package com.ynu.Java版数据结构.U10_并查集.T1_并查集实现;
import java.util.ArrayList;
import java.util.List;
public class UF {
// 记录节点元素和该元素所在分组的标识
private int[] eleAndGroup;
// 记录并查集中数据的分组个数
private int count;
//初始化并查集
public UF(int N) {
//初始情况下,每个元素都在一个独立的分组中,所以,初始情况下,并查集中的数据默认分为N个组
this.count = N;
//初始化数组
eleAndGroup = new int[N];
// 把eleAndGroup数组的索引看做是每个结点存储的元素,把eleAndGroup数组每个索引处的值看做是该
// 结点所在的分组,那么初始化情况下,i索引处存储的值就是i
for (int i = 0; i < eleAndGroup.length; i++) {
eleAndGroup[i] = i;
}
}
// 获取当前并查集中有多少个分组
public int count(){
return count;
}
// 判断并查集中元素p和元素q是否在同一分组中
public boolean connected(int p,int q){
return find(p)==find(q);
}
// 查找元素p所在分组的标识符
public int find(int p){
return eleAndGroup[p];
}
// 把元素p和元素q所在的分组合并
// p合并到q上
public void union(int p,int q){
if (connected(p,q)){
System.out.println("p,q本来就在一个分组");
return;
}
int pGroup = find(p);
int qGroup = find(q);
for (int i = 0; i < eleAndGroup.length; i++) {
if (eleAndGroup[i]==pGroup){
eleAndGroup[i] = qGroup;
}
}
// 分组数量减1
count--;
}
// 查找某一组的所有元素
public List<Integer> findAll(int g){
List<Integer> res = new ArrayList<>();
for (int i = 0; i < eleAndGroup.length; i++) {
if (eleAndGroup[i]==g){
res.add(i);
}
}
return res;
}
}
如果我们并查集存储的每一个整数表示的是一个大型计算机网络中的计算机,则我们就可以通过connected(int p,int q)来检测,该网络中的某两台计算机之间是否连通?如果连通,则他们之间可以通信,如果不连通,则不能通信,此时我们又可以调用union(int p,int q)使得p和q之间连通,这样两台计算机之间就可以通信了。 一般像计算机这样网络型的数据,我们要求网络中的每两个数据之间都是相连通的,也就是说,我们需要调用很多次union方法,使得网络中所有数据相连,其实我们很容易可以得出,如果要让网络中的数据都相连,则我们至少要调用N-1次union方法才可以,但由于我们的union方法中使用for循环遍历了所有的元素,所以很明显,我们之前实现的合并算法的时间复杂度是O(N^2),如果要解决大规模问题,它是不合适的,所以我们需要对算法进行优化。
为了提升union算法的性能,我们需要重新设计fifind方法和union方法的实现,此时我们先需要对我们的之前数据结构中的eleAndGourp数组的含义进行重新设定:
1.我们仍然让eleAndGroup数组的索引作为某个结点的元素;
2.eleAndGroup[i]的值不再是当前结点所在的分组标识,而是该结点的父结点;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0BYrNO6g-1681958360430)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930479.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-78d4e58h-1681958360431)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930480.png)]
1.判断当前元素p的父结点eleAndGroup[p]是不是自己,如果是自己则证明已经是根结点了;
2.如果当前元素p的父结点不是自己,则让p=eleAndGroup[p],继续找父结点的父结点,直到找到根结点为止;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SbyBEcAM-1681958360432)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930481.png)]
找到p元素所在树的根结点
找到q元素所在树的根结点
如果p和q已经在同一个树中,则无需合并;
如果p和q不在同一个分组,则只需要将p元素所在树根结点的父结点设置为q元素的根结点即可;
分组数量-1
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-whKDCVvd-1681958360432)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930482.png)]
package com.ynu.Java版算法.U10_并查集.T2_UF_Tree算法优化;
public class UF_Tree {
// 记录并查集中数据的分组个数
private int count;
// 记录元素和组别
private int[] eleAndGroup;
// 初始化元素个数为N的并查集
public UF_Tree(int N) {
eleAndGroup = new int[N];
count = N;
for (int i = 0; i < eleAndGroup.length; i++) {
eleAndGroup[i] = i;
}
}
// 获取组的个数
public int getCount(){
return count;
}
// 判断两个元素是否属于用一个组
public boolean connected(int p,int q){
return find(p)==find(q);
}
// 查找元素属于的组
public int find(int p){
while (true){
if (p==eleAndGroup[p]){
return p;
}
p = eleAndGroup[p];
}
}
// 合并元素所在的组 把p合并到q所在的组中去
public void union(int p,int q){
if (connected(p,q)){
System.out.println("本来就在一组无需合并");
return;
}
int pRoot = find(p); // 找到p所在的组
int qRoot = find(q); // 找到q所在的组
eleAndGroup[pRoot] = qRoot; // 把p所在的组合并到q所在的组
count--; // 组数减一
}
}
我们优化后的算法union,如果要把并查集中所有的数据连通,仍然至少要调用N-1次union方法,但是,我们发现union方法中已经没有了for循环,所以union算法的时间复杂度由O(N^2)变为了O(N)。 但是这个算法仍然有问题,因为我们之前不仅修改了union算法,还修改了find算法。我们修改前的find算法的时间复杂度在任何情况下都为O(1),但修改后的find算法在最坏情况下是O(N):
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tVeFvQdu-1681958360433)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930483.png)]
UF_Tree中最坏情况下union算法的时间复杂度也较高,其最主要的问题在于最坏情况下,树的深度和数组的大小一样,如果我们能够通过一些算法让合并时,生成的树的深度尽可能的小,就可以优化find方法。 之前我们在union算法中,合并树的时候将任意的一棵树连接到了另外一棵树,这种合并方法是比较暴力的,如果我们把并查集中每一棵树的大小记录下来,然后在每次合并树的时候,把较小的树连接到较大的树上,就可以减小树的深度。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0A1t5TMY-1681958360434)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930484.png)]
只要我们保证每次合并,都能把小树合并到大树上,就能够压缩合并后新树的路径,这样就能提高find方法的效率。为了完成这个需求,我们需要另外一个数组来记录存储每个根结点对应的树中元素的个数,并且需要一些代码调整数组中的值。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KjS97mDt-1681958360434)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930485.png)]
package com.ynu.Java版算法.U10_并查集.T3_路径压缩;
public class UF_Tree_Weighted {
//记录结点元素和该元素所的父结点
private int[] eleAndGroup;
//存储每个根结点对应的树中元素的个数
private int[] sz;
//记录并查集中数据的分组个数
private int count;
//初始化并查集
public UF_Tree_Weighted(int N) {
//初始情况下,每个元素都在一个独立的分组中,所以,初始情况下,并查集中的数据默认分为N个组
this.count = N;
//初始化数组
eleAndGroup = new int[N];
sz = new int[N];
//把eleAndGroup数组的索引看做是每个结点存储的元素,把eleAndGroup数组每个索引处的值看做是该结点的父结点,那么初始化情况下,i索引处存储的值就是i
for (int i = 0; i < N; i++) {
eleAndGroup[i] = i;
}
//把sz数组中所有的元素初始化为1,默认情况下,每个结点都是一个独立的树,每个树中只有一个元素
for (int i = 0; i < sz.length; i++) {
sz[i] = 1;
}
}
//获取当前并查集中的数据有多少个分组
public int count() {
return count;
}
//元素p所在分组的标识符
public int find(int p) {
while (true) {
//判断当前元素p的父结点eleAndGroup[p]是不是自己,如果是自己则证明已经是根结点了;
if (p == eleAndGroup[p]) {
return p;
}
//如果当前元素p的父结点不是自己,则让p=eleAndGroup[p],继续找父结点的父结点,直到找到根结点为止;
p = eleAndGroup[p];
}
}
//判断并查集中元素p和元素q是否在同一分组中
public boolean connected(int p, int q) {
return find(p) == find(q);
}
//把p元素所在分组和q元素所在分组合并
public void union(int p, int q) {
//找到p元素所在树的根结点
int pRoot = find(p);
//找到q元素所在树的根结点
int qRoot = find(q);
//如果p和q已经在同一个树中,则无需合并;
if (pRoot == qRoot) {
return;
}
//如果p和q不在同一个分组,比较p所在树的元素个数和q所在树的元素个数,把较小的树合并到较大的树上
if (sz[pRoot] <= sz[qRoot]) {
eleAndGroup[pRoot] = qRoot;
//重新调整较大树的元素个数
sz[qRoot] += sz[pRoot];
} else {
eleAndGroup[qRoot] = pRoot;
sz[pRoot] += sz[qRoot];
}
//分组数量-1
count--;
}
}
package com.ynu.Java版算法.U10_并查集.T3_路径压缩;
public class Main {
public static void main(String[] args) {
UF_Tree_Weighted uf_tree_weighted = new UF_Tree_Weighted(10);
// 合并1,2到3
uf_tree_weighted.union(1,3);
uf_tree_weighted.union(2,3);
System.out.println(uf_tree_weighted.count());
//合并 4 ,7到8
uf_tree_weighted.union(4,8);
uf_tree_weighted.union(7,8);
System.out.println(uf_tree_weighted.count());
//合并3组到9
uf_tree_weighted.union(3,9);
//查找1所在的组 应该是3
System.out.println(uf_tree_weighted.find(1));
}
}
某省调查城镇交通状况,得到现有城镇道路统计表,表中列出了每条道路直接连通的城镇。省政府“畅通工程”的目标是使全省任何两个城镇间都可以实现交通(但不一定有直接的道路相连,只要互相间接通过道路可达即可)。问最少还需要建设多少条道路?
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TTmtpi6i-1681958360435)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930486.png)]
总共有20个城市,目前已经修改好了7条道路,问还需要修建多少条道路,才能让这20个城市之间全部相通?
解题思路:
1.创建一个并查集UF_Tree_Weighted(20);
2.分别调用union(0,1),union(6,9),union(3,8),union(5,11),union(2,12),union(6,10),union(4,8),表示已经修建好的
道路把对应的城市连接起来;
3.如果城市全部连接起来,那么并查集中剩余的分组数目为1,所有的城市都在一个树中,所以,只需要获取当前
并查集中剩余的数目,减去1,就是还需要修建的道路数目; 每修一条路,就少一个分组。
代码:
package com.ynu.Java版算法.U10_并查集.T4_畅通工程;
import com.ynu.Java版算法.U10_并查集.T3_路径压缩.UF_Tree_Weighted;
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<List<Integer>> paths = new ArrayList<>();
List<Integer> path1 = new ArrayList<>();
path1.add(0);
path1.add(2);
List<Integer> path2 = new ArrayList<>();
path2.add(6);
path2.add(9);
List<Integer> path3 = new ArrayList<>();
path3.add(3);
path3.add(8);
List<Integer> path4 = new ArrayList<>();
path4.add(5);
path4.add(11);
List<Integer> path5 = new ArrayList<>();
path5.add(2);
path5.add(12);
List<Integer> path6 = new ArrayList<>();
path6.add(6);
path6.add(10);
List<Integer> path7 = new ArrayList<>();
path7.add(4);
path7.add(8);
paths.add(path1);
paths.add(path2);
paths.add(path3);
paths.add(path4);
paths.add(path5);
paths.add(path6);
paths.add(path7);
UF_Tree_Weighted uf = new UF_Tree_Weighted(20);
for (List<Integer> path : paths) {
Integer v = path.get(0);
Integer w = path.get(1);
uf.union(v,w);
}
System.out.println("还需要"+ (uf.count() - 1 )+"条道路");
}
}
在现实生活中,有许多应用场景会包含很多点以及点点之间的连接,而这些应用场景我们都可以用即将要学习的图这种数据结构去解决。
地图:
我们生活中经常使用的地图,基本上是由城市以及连接城市的道路组成,如果我们把城市看做是一个一个的点
,把道路看做是一条一条的连接
,那么地图就是我们将要学习的图这种数据结构
。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ctlw2ufp-1681958360435)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250921577.png)]
电路图:
下面是一个我们生活中经常见到的集成电路板,它其实就是由一个一个触点组成,并把触点与触点之间通过线进行连接,这也是我们即将要学习的图这种数据结构的应用场景。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wT8sUkoI-1681958360436)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930487.png)]
**定义:**图是由一组顶点
和一组能够将两个顶点相连的边
组成的
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Xifak79f-1681958360437)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930488.png)]
特殊的图:
自环:即一条连接一个顶点和其自身的边;
平行边:连接同一对顶点的两条边;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GdjOBCVG-1681958360438)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930489.png)]
图的分类:
按照连接两个顶点的边的不同,可以把图分为以下两种:
无向图:边仅仅连接两个顶点,没有其他含义;
有向图:边不仅连接两个顶点,并且具有方向;
相邻顶点:
当两个顶点通过一条边相连
时,我们称这两个顶点是相邻
的,并且称这条边依附于这两个顶点
。
度:
某个顶点的度
就是依附于该顶点的边的个数。
子图:
是一幅图的所有边的子集(包含这些边依附的顶点)组成的图;
路径:
是由边顺序连接的一系列的顶点组成
环:
是一条至少含有一条边且终点和起点相同
的路径
连通图:
如果图中任意一个顶点都存在一条路径到达另外一个顶点,那么这幅图就称之为连通图
连通子图:
一个非连通图由若干连通的部分组成,每一个连通的部分都可以称为该图的连通子图
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4n4g1Pae-1681958360439)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250924013.png)]
要表示一幅图,只需要表示清楚以下两部分内容即可:
图中所有的顶点;
所有连接顶点的边;
常见的图的存储结构有两种:邻接矩阵和邻接表
使用一个V*V的二维数组int[V][V] adj
,把索引的值看做是顶点;
如果顶点v和顶点w相连,我们只需要将adj[v][w]
和adj[w][v]
的值设置为1,否则设置为0即可。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3huLNBuD-1681958360439)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250924603.png)]
很明显,邻接矩阵这种存储方式的空间复杂度是V^2的,如果我们处理的问题规模比较大的话,内存空间极有可能不够用。
使用一个大小为V的队列数组 Queue[V] adj
,把索引看做是顶点;
每个索引处adj[v]
存储了一个队列,该队列中存储的是所有与该顶点相邻的其他顶点。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-L3eezAJy-1681958360440)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930490.png)]
很明显,邻接表的空间是线性级别的,所以后面我们一直采用邻接表这种存储形式来表示图。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Qof1YmBc-1681958360441)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930491.png)]
package com.ynu.Java版算法.U11_图的入门.T1_无向图.S1_图的实现;
import java.util.LinkedList;
import java.util.Queue;
public class Graph {
// 顶点数目
private final int V;
// 边的数目
private int E;
// 邻接表
private Queue<Integer>[] adj;
public Graph(int v) {
// 初始化顶点的数量
this.V = v;
// 初始化边的数量
E = 0;
// 初始化邻接表
adj = new Queue[V];
for (int i = 0; i < adj.length; i++) {
adj[i] = new LinkedList<Integer>();
}
}
//获取顶点数目
public int getV(){
return V;
}
//获取边的数目
public int getE(){
return E;
}
// 向图中添加一条边 v-w 连接v,w顶点
// v顶点的链表上添加w w的顶点上添加v
public void addEdge(int v,int w){
// 把w添加到v的链表中,这样顶点v就多了一个相邻点w
adj[v].offer(w);
//把v添加到w的链表中,这样顶点w就多了一个相邻点v
adj[w].offer(v);
//边的数目自增1
E++;
}
//获取和顶点v相邻的所有顶点
public Queue<Integer> adj(int v){
return adj[v];
}
}
在很多情况下,我们需要遍历图,得到图的一些性质,例如,找出图中与指定的顶点相连的所有顶点,或者判定某
个顶点与指定顶点是否相通,是非常常见的需求。
有关图的搜索,最经典的算法有深度优先搜索和广度优先搜索,接下来我们分别讲解这两种搜索算法。
所谓的深度优先搜索,指的是在搜索时,如果遇到一个结点既有子结点,又有兄弟结点,那么先找子结点,然后找
兄弟结点。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FwtvP9Kr-1681958360441)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930492.png)]
package com.ynu.Java版算法.U11_图的入门.T1_无向图.S2_图的搜索.A1_深度优先搜索;
import java.util.LinkedList;
import java.util.Queue;
public class DepthFirstSearch {
// 标记数组。 索引代表顶点,值表示当前顶点是否已经被搜索过
private boolean[] marked;
// 记录有多少个顶点与s顶点相通
private int count;
// 记录遍历的结果
private Queue<Integer> list = new LinkedList<>();
public DepthFirstSearch(Graph graph) {
marked = new boolean[graph.V()];
}
// 构造深度优先搜索对象,使用深度优先搜索找出G图中与s顶点相通的所有顶点。从s节点开始遍历整个图。
public DepthFirstSearch(Graph graph,int s) {
marked = new boolean[graph.V()];
// 深度优先遍历
dfs(graph,s);
// 如果遍历完,marked全部为true。说明是graph是一个连通图
}
// 使用深度优先搜索找出G图中与v顶点相通的所有顶点
public void dfs(Graph graph,int v){
list.offer(v);
marked[v] = true; // v访问过,同时也表明s与v是相通的
//获取顶点v的领接表
Queue<Integer> adjV = graph.adj(v);
//遍历顶点v的领接表,往下搜索 一个节点领接表上的所有节点就算是兄弟节点
for (Integer w : adjV) {
if (!marked[w])
dfs(graph,w);
}
// 能连通的节点数加1
count++;
}
// 判断顶点w与顶点s是否相通
public boolean marked(int w){
return marked[w];
}
// 判断是否是连通图
public boolean isLianTong(){
for (boolean b : marked) {
if (b==false){
return false;
}
}
return true;
}
// 获取遍历结果
public void printGraph(){
while (!list.isEmpty()){
System.out.print(list.peek() + " ");
marked[list.poll()] = false;
}
}
}
package com.ynu.Java版算法.U11_图的入门.T1_无向图.S2_图的搜索.A1_深度优先搜索;
public class Main {
public static void main(String[] args) {
// 创建有10个节点的图
Graph g = new Graph(10);
g.addEdge(0,1);
g.addEdge(1,2);
g.addEdge(2,3);
g.addEdge(3,4);
g.addEdge(4,5);
g.addEdge(5,6);
g.addEdge(6,7);
g.addEdge(7,8);
g.addEdge(8,9);
g.addEdge(9,1);
// 输出边的个数 10
System.out.println(g.E());
// 从1节点开始遍历图
DepthFirstSearch depthFirstSearch = new DepthFirstSearch(g);
depthFirstSearch.dfs(g,0);
depthFirstSearch.printGraph();
System.out.println();
// 从0开始深度遍历图
depthFirstSearch.dfs(g,0);
depthFirstSearch.printGraph();
System.out.println();
// 从3开始遍历图
depthFirstSearch.dfs(g,3);
depthFirstSearch.printGraph();
}
}
所谓的深度优先搜索,指的是在搜索时,如果遇到一个结点既有子结点,又有兄弟结点,那么先找兄弟结点,然后
找子结点。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-z3uct7Pa-1681958360442)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930493.png)]
package com.ynu.Java版算法.U11_图的入门.T1_无向图.S2_图的搜索.A2_广度优先搜索;
import java.util.LinkedList;
import java.util.Queue;
public class BreadthFirthSearch {
// 标记数组 标记是否遍历过该节点
private boolean[] marked;
// 存储结果的队列
private Queue<Integer> res = new LinkedList<>();
// 辅助队列:等待遍历的队列。 类似二叉树的层序遍历,需要一个队列就行帮助。
private Queue<Integer> waitSearch;
//记录有多少个顶点与s顶点相通
private int count;
public BreadthFirthSearch(Graph graph) {
marked = new boolean[graph.V()];
waitSearch = new LinkedList<>();
}
public BreadthFirthSearch(Graph graph, int s) {
waitSearch = new LinkedList<>();
bfs(graph,s);
}
public void bfs(Graph graph,int s){
waitSearch.offer(s);
while (!waitSearch.isEmpty()){
Integer w = waitSearch.poll();
res.offer(w); // 访问该节点
marked[w] = true;
// 获取w的邻接表
Queue<Integer> adjW = graph.adj(w);
for (Integer i : adjW) {
if (!marked[i])
waitSearch.offer(i); // 放进辅助队列
}
}
}
// 判断顶点w与顶点s是否相通
public boolean marked(int w){
return marked[w];
}
// 判断是否是连通图
public boolean isLianTong(){
for (boolean b : marked) {
if (b==false){
return false;
}
}
return true;
}
// 获取遍历结果
public void printGraph(){
while (!res.isEmpty()){
System.out.print(res.peek() + " ");
marked[res.poll()] = false;
}
}
}
某省调查城镇交通状况,得到现有城镇道路统计表,表中列出了每条道路直接连通的城镇。省政府“畅通工程”的目标是使全省任何两个城镇间都可以实现交通(但不一定有直接的道路相连,只要互相间接通过道路可达即可)。目前的道路状况,9号城市和10号城市是否相通?9号城市和8号城市是否相通?
下面是对数据的解释:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-geQsLZxT-1681958360443)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930494.png)]
package com.ynu.Java版算法.U11_图的入门.T1_无向图.S2_图的搜索.畅通工程;
import org.junit.Test;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
public class Main {
@Test
public void test(){
List<List<Integer>> paths = new ArrayList<>();
paths.add(Arrays.asList(0,1)); // 连通0,1
paths.add(Arrays.asList(6,9));
paths.add(Arrays.asList(3,8));
paths.add(Arrays.asList(5,11));
paths.add(Arrays.asList(2,12));
paths.add(Arrays.asList(6,10));
paths.add(Arrays.asList(4,8));
// 9号和10号城市是否相通
System.out.println(isConnected(20,paths,9,10));
System.out.println(isConnected1(20,paths,9,10));
// 9号和8号城市是否相通
System.out.println(isConnected(20,paths,9,8));
System.out.println(isConnected1(20,paths,9,8));
// 5号和11号城市是否相通
System.out.println(isConnected(20,paths,5,11));
System.out.println(isConnected1(20,paths,5,11));
}
// 1.使用深度优先遍历
public boolean isConnected(int nums,List<List<Integer>> paths,int i,int j){
// 构建大小为20的图 表示20个城市 0-19号城市
Graph graph = new Graph(20);
boolean[] marked = new boolean[20];
// 加边
for (List<Integer> path : paths) {
Integer v = path.get(0);
Integer w = path.get(1);
graph.addEdge(v,w);
}
// 深度优先遍历
// i号城市对应的索引为i-1
// j城市对应的索引为j-1
dfs(graph,i,marked);
return marked[j];
}
// 从v节点开始深度优先搜索
public void dfs(Graph graph,int v,boolean[] marked){
marked[v] = true;
// 获取v节点的邻接表
Queue<Integer> adjV = graph.adj(v);
for (Integer i : adjV) {
if (!marked[i]){
dfs(graph,i,marked);
}
}
}
// 2.使用广度优先遍历
public boolean isConnected1(int nums,List<List<Integer>> paths,int i,int j){
// 构建大小为20的图 表示20个城市 0-19号城市
Graph graph = new Graph(20);
boolean[] marked = new boolean[20];
// 加边
for (List<Integer> path : paths) {
Integer v = path.get(0);
Integer w = path.get(1);
graph.addEdge(v,w);
}
bfs(graph,i,marked);
return marked[j];
}
// 从v节点开始深度优先搜索
public void bfs(Graph graph,int v,boolean[] marked){
// 辅助队列
Queue<Integer> queue = new LinkedList<>();
queue.offer(v);
while (!queue.isEmpty()){
Integer top = queue.poll();
marked[top] = true;
// 获取邻接表
Queue<Integer> adj = graph.adj(top);
for (Integer i : adj) {
if (!marked[i]){
queue.offer(i);
}
}
}
}
}
在实际生活中,地图是我们经常使用的一种工具,通常我们会用它进行导航,输入一个出发城市,输入一个目的地城市,就可以把路线规划好,而在规划好的这个路线上,会路过很多中间的城市。这类问题翻译成专业问题就是:
从s顶点到v顶点是否存在一条路径?如果存在,请找出这条路径。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TWlfR4Pt-1681958360444)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930495.png)]
例如在上图上查找顶点0到顶点4的路径用红色标识出来,那么我们可以把该路径表示为 0-2-3-4。
我们实现路径查找,最基本的操作还是得遍历并搜索图,所以,我们的实现暂且基于深度优先搜索来完成。其搜索的过程是比较简单的。我们添加了edgeTo[]整型数组,这个整型数组会记录从每个顶点回到起点s的路径。 如果我们把顶点设定为0,那么它的搜索可以表示为下图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dRfQhnJz-1681958360444)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303250930496.png)]
根据最终edgeTo的结果,我们很容易能够找到从起点0到任意顶点的路径; 只不过这个路径是反着的,需要再倒序遍历一下,可以借助栈。
代码:
package com.ynu.Java版算法.U11_图的入门.T1_无向图.S2_图的搜索.A4_路径查找;
import org.junit.Test;
import java.util.*;
public class Main {
@Test
public void test(){
List<List<Integer>> paths = new ArrayList<>();
paths.add(Arrays.asList(0,2)); // 连通0,1
paths.add(Arrays.asList(0,1));
paths.add(Arrays.asList(2,1));
paths.add(Arrays.asList(2,3));
paths.add(Arrays.asList(2,4));
paths.add(Arrays.asList(3,5));
paths.add(Arrays.asList(3,4));
paths.add(Arrays.asList(0,5));
// 从0-5的所有路径
System.out.println(findPath(6, paths, 0, 5));
// 从0-5的最短路径
System.out.println(findPath1(6, paths, 0, 5));
// 从1-5的所有路径
System.out.println(findPath(6, paths, 1, 5));
// 从1-5的最短路径
System.out.println(findPath1(6, paths, 1, 5));
}
// 1.使用深度优先遍历 -- 能查出所有路径
public List<List<Integer>> findPath(int nums,List<List<Integer>> paths,int i,int j){
List<List<Integer>> res = new ArrayList<>(); // 所有路径结果
LinkedList<Integer> tempPath = new LinkedList<>(); // 某一条路径
// 1. 构建大小为20的图 表示20个城市 0-19号城市
Graph graph = new Graph(20);
boolean[] marked = new boolean[20];
// 加边
for (List<Integer> path : paths) {
Integer v = path.get(0);
Integer w = path.get(1);
graph.addEdge(v,w);
}
// 2.深度优先遍历
marked[i] = true;
tempPath.add(i);
dfs(res,tempPath,graph,i,j,marked);
return res;
}
/**
*
* @param res
* @param path
* @param graph
* @param start 起点
* @param des 终点
* @param marked
*/
public void dfs(List<List<Integer>> res,LinkedList<Integer> path,Graph graph, int start, int des,boolean[] marked){
// 到达目的地
if (!path.isEmpty() && path.getLast()==des){
res.add(new LinkedList<>(path));
return;
}
//获取v的邻接表
Queue<Integer> adjV = graph.adj(start);
for (Integer j : adjV) {
if (!marked[j]){
path.add(j);
marked[j] = true;
dfs(res, path, graph, j, des, marked);
// 回溯
path.removeLast();
marked[j] = false;
}
}
}
// 2.使用广优先遍历 一定是最短路径
public List<Integer> findPath1(int nums,List<List<Integer>> paths,int start,int des){
List<Integer> res = new ArrayList<>();
// 大小为nums的标记数组 记录是否遍历过
boolean[] marked = new boolean[nums];
// 构建图
Graph graph = new Graph(nums);
for (List<Integer> path : paths) {
Integer v = path.get(0);
Integer w = path.get(1);
graph.addEdge(v,w);
}
int[] edgeTo = new int[nums]; // edgeTo[]整型数组,这个整型数组会记录从每个顶点回到起点i的路径。很多地方是写为prev数组
// 比如edge[j] = i 表示要到j,前一个节点是i
Arrays.fill(edgeTo,-1);
bfs(graph,edgeTo,start,des,marked); // 广度优先遍历
// 去寻找到des要经过的路径
int j = des;
res.add(j);
while (edgeTo[j]!=-1 && edgeTo[j]!=des){
res.add(edgeTo[j]);
j = edgeTo[j];
}
//由于寻找是按照反着的顺序来的,所以需要把res倒序过来
Collections.reverse(res);
return res;
}
public void bfs(Graph graph,int[] edgeTo,int start,int end,boolean[] marked){
// 辅助队列
Queue<Integer> queue = new LinkedList<>();
queue.offer(start);
while (!queue.isEmpty()){
Integer top = queue.poll();
marked[top] = true;
if (top==end){
return;
}
// 获取邻接表
Queue<Integer> adjV = graph.adj(top);
for (int j : adjV) {
if (!marked[j]){
edgeTo[j] = top;
queue.offer(j);
}
}
}
}
}
在11.3.6遍历的时候使用广度优先遍历
在实际生活中,很多应用相关的图都是有方向性的,最直观的就是网络,可以从A页面通过链接跳转到B页面,那么a和b连接的方向是a->b,但不能说是b->a,此时我们就需要使用有向图来解决这一类问题,它和我们之前学习的无向图,最大的区别就在于连接是具有方向的,在代码的处理上也会有很大的不同。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zVo7S6PO-1681958360445)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303261033743.png)]
定义:
有向图是一副具有方向性的图,是由一组顶点和一组有方向的边组成的,每条方向的边都连着一对有序的顶点。
出度:
由某个顶点指出的边的个数称为该顶点的出度。
入度:
指向某个顶点的边的个数称为该顶点的入度。
有向路径:
由一系列顶点组成,对于其中的每个顶点都存在一条有向边,从它指向序列中的下一个顶点。
有向环:
一条至少含有一条边,且起点和终点相同的有向路径。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HDCTtB9G-1681958360446)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303261045492.png)]
一副有向图中两个顶点v和w可能存在以下四种关系:
没有边相连;
存在从v到w的边v—>w;
存在从w到v的边w—>v;
既存在w到v的边,也存在v到w的边,即双向连接;
理解有向图是一件比较简单的,但如果要通过眼睛看出复杂有向图中的路径就不是那么容易了。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RqatRaFw-1681958360446)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303261047465.png)]
类名 | Digraph |
---|---|
构造方法 | Digraph(int V):创建一个包含V个顶点但不包含边的有向图 |
成员方法 | 1.public int V():获取图中顶点的数量 2.public int E():获取图中边的数量 3.public void addEdge(int v,int w):向有向图中添加一条边 v->w 4.public Queue adj(int v):获取由v指出的边所连接的所有顶点 5.private Digraph reverse():该图的反向图 |
成员变量 | 1.private final int V: 记录顶点数量 2.private int E: 记录边数量 3.private Queue[] adj: 邻接表 |
在api中设计了一个反向图,其因为有向图的实现中,用adj方法获取出来的是由当前顶点v指向的其他顶点,如果能得到其反向图,就可以很容易得到指向v的其他顶点。
package com.ynu.Java版算法.U12_图的进阶.T1_有向图;
import java.util.LinkedList;
import java.util.Queue;
/**
* @author ybh
* @date 2023.03.26 10:56
*/
public class Digraph {
// 记录顶点的数量
private int V;
// 边的数量
private int E;
// 邻接表
private Queue<Integer>[] adj;
public Digraph(int V) {
this.V = V;
E = 0;
// 邻接表的大小应该和顶点数量相同
this.adj = new Queue[V];
// 初始化邻接表的空队列
for (int i = 0; i < adj.length; i++) {
adj[i] = new LinkedList<>();
}
}
// 获取顶点数量
public int getV() {
return V;
}
//获取边的数目
public int E() {
return E;
}
//向有向图中添加一条边 v->w
public void addEdge(int v, int w) {
//由于有向图中边是有向的,v->w 边,只需要让w出现在v的邻接表中,而不需要让v出现在w的邻接表中
//无向图的话两个都要加
adj[v].add(w);
//边的数目自增1
E++;
}
//获取由v指出的边所连接的所有顶点
public Queue<Integer> adj(int v) {
return adj[v];
}
//该图的反向图
private Digraph reverse() {
//创建新的有向图对象
Digraph r = new Digraph(V);
//遍历0~V-1所有顶点,拿到每一个顶点v
for (int v = 0; v < V; v++) {
//得到原图中的v顶点对应的邻接表,原图中的边为 v->w,则反向图中边为w->v;
for (Integer w : adj(v)) {
r.addEdge(w, v);
}
}
return r;
}
}
在现实生活中,我们经常会同一时间接到很多任务去完成,但是这些任务的完成是有先后次序的。以我们学习java学科为例,我们需要学习很多知识,但是这些知识在学习的过程中是需要按照先后次序来完成的。从java基础,到jsp/servlet,到ssm,到springboot等是个循序渐进且有依赖的过程。在学习jsp前要首先掌握java基础和html基础,学习ssm框架前要掌握jsp/servlet之类才行。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dRtPaODm-1681958360447)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303261716484.png)]
为了简化问题,我们使用整数为顶点编号的标准模型来表示这个案例:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SeRqpa4c-1681958360447)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303261142113.png)]
此时如果某个同学要学习这些课程,就需要指定出一个学习的方案,我们只需要对图中的顶点进行排序,让它转换为一个线性序列,就可以解决问题,这时就需要用到一种叫拓扑排序的算法。
拓扑排序:
给定一副有向图,将所有的顶点排序,使得所有的有向边均从排在前面的元素指向排在后面的元素,此时就可以明确的表示出每个顶点的优先级。下列是一副拓扑排序后的示意图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ThEpD9t3-1681958360448)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303261142080.png)]
能进行拓扑排序的必然是一个无环的图。
如果学习x课程前必须先学习y课程,学习y课程前必须先学习z课程,学习z课程前必须先学习x课程,那么一定是有问题了,我们就没有办法学习了,因为这三个条件没有办法同时满足。其实这三门课程x、y、z的条件组成了一个环:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oq2jGIY5-1681958360448)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303261143982.png)]
因此,如果我们要使用拓扑排序解决优先级问题,首先得保证图中没有环的存在。
类名 | DirectedCycle |
---|---|
构造方法 | DirectedCycle(Digraph G):创建一个检测环对象,检测图G中是否有环 |
成员方法 | 1.private void dfs(Digraph G,int v):基于深度优先搜索,检测图G中是否有环 2.public boolean hasCycle():判断图中是否有环 |
成员变量 | 1.private boolean[] marked: 索引代表顶点,值表示当前顶点是否已经被搜索 2.private boolean hasCycle: 记录图中是否有环 3.private boolean[] onStack:索引代表顶点,使用栈的思想,记录当前顶点有没有已经处于正在搜索的有向路径上 |
在API中添加了onStack[] 布尔数组,索引为图的顶点,当我们深度搜索时:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6387eAg3-1681958360449)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303261235139.png)]
代码:
package com.ynu.Java版算法.U12_图的进阶.T1_有向图;
import java.util.LinkedList;
import java.util.Queue;
/**
* @author ybh
* @date 2023.03.26 10:56
*/
public class Digraph {
// 记录顶点的数量
private int V;
// 边的数量
private int E;
// 邻接表
private Queue<Integer>[] adj;
public Digraph(int V) {
this.V = V;
E = 0;
// 邻接表的大小应该和顶点数量相同
this.adj = new Queue[V];
// 初始化邻接表的空队列
for (int i = 0; i < adj.length; i++) {
adj[i] = new LinkedList<>();
}
}
// 获取顶点数量
public int V() {
return V;
}
//获取边的数目
public int E() {
return E;
}
//向有向图中添加一条边 v->w
public void addEdge(int v, int w) {
//由于有向图中边是有向的,v->w 边,只需要让w出现在v的邻接表中,而不需要让v出现在w的邻接表中
//无向图的话两个都要加
adj[v].add(w);
//边的数目自增1
E++;
}
//获取由v指出的边所连接的所有顶点
public Queue<Integer> adj(int v) {
return adj[v];
}
//该图的反向图
private Digraph reverse() {
//创建新的有向图对象
Digraph r = new Digraph(V);
//遍历0~V-1所有顶点,拿到每一个顶点v
for (int v = 0; v < V; v++) {
//得到原图中的v顶点对应的邻接表,原图中的边为 v->w,则反向图中边为w->v;
for (Integer w : adj(v)) {
r.addEdge(w, v);
}
}
return r;
}
}
package com.ynu.Java版算法.U12_图的进阶.T2_拓扑排序;
import com.ynu.Java版算法.U12_图的进阶.T1_有向图.Digraph;
/**
* @author ybh
* @date 2023.03.26 13:41
*/
public class DirectedCycleTest {
public static void main(String[] args) {
// 创建有4个顶点的图
Digraph digraph = new Digraph(4);
digraph.addEdge(0,1);
digraph.addEdge(1,2);
digraph.addEdge(2,3);
digraph.addEdge(3,0);
DirectedCycle cycle = new DirectedCycle(digraph);
System.out.println("图"+ (cycle.hasCycle()?"有环":"无环"));
}
}
如果要把图中的顶点生成线性序列其实是一件非常简单的事,之前我们学习并使用了多次深度优先搜索,我们会发现其实深度优先搜索有一个特点,那就是在一个连通子图上,每个顶点只会被搜索一次,如果我们能在深度优先搜索的基础上,添加一行代码,只需要将搜索的顶点放入到线性序列的数据结构中,我们就能完成这件事。
类名 | DepthFirstOrder |
---|---|
构造方法 | DepthFirstOrder(Digraph G):创建一个顶点排序对象,生成顶点线性序列; |
成员方法 | 1.private void dfs(Digraph G,int v):基于深度优先搜索,生成顶点线性序列 2.public Stack reversePost():获取顶点线性序列 |
成员变量 | 1.private boolean[] marked: 索引代表顶点,值表示当前顶点是否已经被搜索 2.private Stack reversePost: 使用栈,存储顶点序列 |
在API的设计中,我们添加了一个栈reversePost用来存储顶点,当我们深度搜索图时,每搜索完毕一个顶点,把该顶点放入到reversePost中,这样就可以实现顶点排序。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pO6OjRcf-1681958360450)(https://gitee.com/yangbhui/cloudimg/raw/master/img/202303261723093.png)]
package com.ynu.Java版算法.U12_图的进阶.T2_拓扑排序.S2_深度优先顶点排序;
import com.ynu.Java版算法.U12_图的进阶.T1_有向图.Digraph;
import java.util.Queue;
import java.util.Stack;
/**
* @author ybh
* @date 2023.03.26 17:25
*/
public class DepthFirstOrder {
//索引代表顶点,值表示当前顶点是否已经被搜索
private boolean[] marked;
//使用栈,存储顶点序列
private Stack<Integer> reversePost;
//创建一个检测环对象,检测图G中是否有环
public DepthFirstOrder(Digraph digraph) {
//创建一个和图的顶点数一样大小的marked数组
this.marked = new boolean[digraph.V()];
this.reversePost = new Stack<>();
// 遍历搜索图的每个节点
for (int i = 0; i < digraph.V(); i++) {
if (!marked[i]){
//如果当前顶点没有搜索过,则搜索
dfs(digraph,i);
}
}
}
//基于深度优先搜索。
private void dfs(Digraph digraph, int v) {
//把当前顶点标记为已搜索
marked[v] = true;
//遍历v顶点的邻接表,得到每一个顶点w
Queue<Integer> adj = digraph.adj(v);
for (Integer w : adj) {
//如果当前顶点w没有被搜索过,则递归搜索与w顶点相通的其他顶点
if (!marked[w]){
dfs(digraph,w);
}
}
//当前顶点已经搜索完毕,让当前顶点入栈
reversePost.push(v);
}
//获取顶点线性序列
public Stack<Integer> reversePost(){
return reversePost;
}
}
前面已经实现了环的检测以及顶点排序,那么拓扑排序就很简单了,基于一幅图,先检测有没有环,如果没有环,则调用顶点排序即可。
API设计:
类名 | TopoLogical |
---|---|
构造方法 | TopoLogical(Digraph G):构造拓扑排序对象 |
成员方法 | 1.public boolean isCycle():判断图G是否有环 2.public Stack order():获取拓扑排序的所有顶点 |
成员变量 | 1.private Stack order: 顶点的拓扑排序 |
代码:
package com.ynu.Java版算法.U12_图的进阶.T2_拓扑排序.S2_深度优先顶点排序;
import com.ynu.Java版算法.U12_图的进阶.T1_有向图.Digraph;
import java.util.Queue;
import java.util.Stack;
/**
* @author ybh
* @date 2023.03.26 17:25
*/
public class DepthFirstOrder {
//索引代表顶点,值表示当前顶点是否已经被搜索
private boolean[] marked;
//使用栈,存储顶点序列
private Stack<Integer> reversePost;
//创建一个检测环对象,检测图G中是否有环
public DepthFirstOrder(Digraph digraph) {
//创建一个和图的顶点数一样大小的marked数组
this.marked = new boolean[digraph.V()];
this.reversePost = new Stack<>();
// 遍历搜索图的每个节点
for (int i = 0; i < digraph.V(); i++) {
if (!marked[i]){
//如果当前顶点没有搜索过,则搜索
dfs(digraph,i);
}
}
}
//基于深度优先搜索。
private void dfs(Digraph digraph, int v) {
//把当前顶点标记为已搜索
marked[v] = true;
//遍历v顶点的邻接表,得到每一个顶点w
Queue<Integer> adj = digraph.adj(v);
for (Integer w : adj) {
//如果当前顶点w没有被搜索过,则递归搜索与w顶点相通的其他顶点
if (!marked[w]){
dfs(digraph,w);
}
}
//当前顶点已经搜索完毕,让当前顶点入栈
reversePost.push(v);
}
//获取顶点线性序列
public Stack<Integer> reversePost(){
return reversePost;
}
}