一.二叉平衡树
1.二叉搜索树的概念
二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:
1.若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
2.若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
3.它的左右子树也分别为二叉搜索树
2.二叉搜索树的查询性能分析
对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数,即结点越深,则比较次数越多
最优情况下,二叉搜索树为完全二叉树,其平均比较次数为:log2(N)
最差情况下,二叉搜索树退化为单支树,其平均比较次数为:N
根据上述的分析我们发现,当一棵二叉搜索树退化为单分支树时,它查询数据的性能优势就不存在了,那么,我们能否进行改进,使其无论是以什么方式、顺序进行插入数据时,在查询数据时的性能都是二叉搜索树的最佳性能?为了达到这个目的,我们就引入了二叉平衡树的概念
3.二叉平衡树的概念
二叉平衡树本质上是一种特殊的二叉搜索树(BST),核心目的是:避免树退化成链表,保证查询、插入、删除都稳定在 O (log n)
二叉平衡树包括AVL树(严格平衡)和红黑树(弱平衡)
3.1 AVL树
为了避免一棵二叉搜索树退化为一棵单支树,数学家们提出了一个方法:当向二叉搜索树中插入新结点后,如果能保证每个结点的左右子树高度之差的绝对值不超过 1(需要对树中的结点进行调整),即可降低树的高度,从而减少平均搜索长度。
一棵AVL树或者是空树,或者是具有以下性质的二叉搜索树:
1.它的左右子树都是AVL树
2.左右子树高度之差(简称平衡因子)的绝对值不超过1(-1/0/1)
AVL树是一棵高度平衡的二叉搜索树,如果一棵AVL树有n个结点,其高度可保持在O(log2(N)) ,搜索时间复杂 度O(log2(N))。
由于我们要时刻判断一颗AVL树是否是高度平衡的,为了维护的方便,我们在AVL树的结点的定义时要维护一个平衡因子(bf),通过bf的值来判断一棵AVL树是否高度平衡
平衡因子的大小= 右子树的高度 - 左子树的高度
具体的定义内容如下:
static class TreeNode{
public int val;
public int bf;//平衡因子
public TreeNode left;//该结点的左节点
public TreeNode right;//该结点的右结点
public TreeNode parent;//该结点的父节点
public TreeNode(int val){
this.val=val;
}
}
3.1.1 AVL树的插入
AVL树就是一棵添加了要维护的平衡因子的二叉搜索树,所以,AVL的插入过程与二叉搜索树的插入过程类似,都是在原来的树中找到合适的位置进行插入,不过,再插入成功后,AVL树还要进行从插入节点到根结点自下而上的平衡因子的调整。
当自下到上调整并检查平衡因子后,若发现有结点平衡的平衡因子大于1或小于-1(即该结点的bf值为 2 或 -2 ),我们就要进行对这棵AVL树的结构的调整,即对这棵AVL树进行旋转
AVL树的旋转
根据插入结点的位置不同,AVL树的旋转分为四种情况:左单旋、右单旋、左右双旋、右左双旋。
1.右单旋
要发生右单旋的情况如下图所示

旋转中的重要的点是要理解各个结点的指向关系的改变
我们将失衡结点作为参数parent传入,先定义好该失衡结点的左节点subL和subL的右结点subLR(待会会对这三个结点的指向进行调整),还要记录下parent的父节点pParent,若pParent不为空则说明parent不是根结点还要进行pParent左子树(或右子树)的指向的调整,若pParent为空则说明parent结点即为根结点那么就要进行根结点的重新指向,最后再将parent结点和subL结点的平衡因子置为0即可完成右单旋
具体调整过程如下代码所示:
private void rotateRight(TreeNode parent){//右单旋
TreeNode subL=parent.left;
TreeNode subLR=subL.right;
subL.right=parent;
parent.left=subLR;
//subLR为空时无需执行这一步
if(subLR!=null) {
subLR.parent = parent;
}
//必须先记录下parent的parent节点,防止在修改指向后找不到该节点
TreeNode pParent=parent.parent;
parent.parent=subL;
//判断parent是否为根节点
if(parent==root){
root=subL;
root.parent=null;
}else {
if(pParent.left==parent){
pParent.left=subL;
}else {
//pParent.right==parent
pParent.right=subL;
}
subL.parent=pParent;
}
//调节平衡因子
parent.bf=0;
subL.bf=0;
}
2.左单旋
要发生左单旋的情况如下图所示

左单旋的原理与右单旋类似,只需参照上文将左右对调即可
具体的调整步骤如下代码所示:
private void rotateLeft(TreeNode parent){//左单旋
TreeNode subR=parent.right;
TreeNode subRl=subR.left;
subR.left=parent;
parent.right=subRl;
//subRL为空时无需执行这一步
if(subRl!=null){
subRl.parent=parent;
}
//必须先记录下parent的parent节点,防止在修改指向后找不到该节点
TreeNode pParent=parent.parent;
parent.parent=subR;
//如果parent直接为根节点,则使根节点指向subR
if(root==parent){
root=subR;
root.parent=null;
}else {
if(pParent.left==parent){
pParent.left=subR;
}else {
//pParent.right==parent
pParent.right=subR;
}
subR.parent=pParent;
}
//旋转完毕后再调节平衡因子
subR.bf=parent.bf=0;
}
3.左右双旋
要发生左右双旋的情况如下图所示

如第一幅图,当新结点插入在较高子树的右侧时,我们在一番尝试后可以发现,无论是简单的左单旋或是右单旋,都不能将该AVL树重新调整为平衡的状态。这时,我们就要对该失衡的AVL树进行左右双旋(即先对30这个结点进行左旋再对60这个结点进行右旋)。在旋转前,我们依旧是要先记录下parent结点的右结点subR和subR的左结点subRL,并记录下subRL的平衡因子(bf)的值(最终调整parent、subR、subRL这三个结点的平衡因子时要根据subRL的平衡因子的值分两种情况进行修改),然后再直接调用之前写的左单旋和右单旋的方法即可
左右双旋的代码实现如下:
private void rotateRL(TreeNode parent){
TreeNode subR=parent.right;
TreeNode subRL=subR.left;
int bf=subRL.bf;
rotateRight(parent.right);
rotateLeft(parent);
//调节平衡因子
if(bf==-1){
parent.bf=-1;
subR.bf=0;
subRL.bf=0;
}else if(bf==1){
parent.bf=0;
subR.bf=1;
subRL.bf=0;
}
}
4.右左双旋
要右左双旋的情况如下图所示

右左双旋的原理和左右双旋的原理类似,只需将左右对调一下即可,这里不过多赘述
右左双旋的代码如下
private void rotateRL(TreeNode parent){
TreeNode subR=parent.right;
TreeNode subRL=subR.left;
int bf=subRL.bf;
rotateRight(parent.right);
rotateLeft(parent);
//调节平衡因子
if(bf == 1) {
parent.bf = -1;
subR.bf = 0;
subRL.bf = 0;
}else if(bf == -1){
parent.bf = 0;
subR.bf = 1;
subRL.bf = 0;
}
}
总结: 新节点插入后,假设以parent为根的子树不平衡,即parent的平衡因子为2或者-2,分以下情况考虑
1. parent的平衡因子为2,说明parent的右子树高,设parent的右子树的根为subR
当subR的平衡因子为1时,执行左单旋
当subR的平衡因子为-1时,执行右左双旋
2. pParent的平衡因子为-2,说明parent的左子树高,设parent的左子树的根为subL
当subL的平衡因子为-1是,执行右单旋
当subL的平衡因子为1时,执行左右双旋
parent与其较高子树节点的平衡因子时同号时单旋转,异号时双旋转
3.1.2 AVL树的性能分析
AVL树是一棵绝对平衡的二叉搜索树,其要求每个节点的左右子树高度差的绝对值都不超过1,这样可以保证查询时高效的时间复杂度,即O(log2(N)) 。但是如果要对AVL树做一些结构修改的操作,性能则非常低下,比如:插入时要维护其绝对平衡,旋转的次数比较多,更差的是在删除时,有可能一直要让旋转持续到根的位置。因此:如果需要一种查询高效且有序的数据结构,而且数据的个数为静态的(即不会改变),可以考虑AVL树,但一个结构经常修改,就不太适合。
3.2 红黑树
红黑树是一种二叉搜索树,但它在每一个结点上都添加了一个存储位表示结点的颜色,可以是红色或黑色。通过对任何一条从根到叶子的路径上各个结点着色方式的限制,红黑树确保没有一条路径会比其他路径长出俩倍,因而是接近平衡的。
3.2.1 红黑树的性质
1. 最长路径做多是最短路径的2倍(最短路径全为黑色结点,最远路径为红黑交替)
2. 每个结点不是红色就是黑色
3. 根节点是黑色的
4. 如果一个节点是红色的,则它的两个孩子结点是黑色的【没有2个连续的红色节点】
5. 对于每个结点,从该结点到其所有后代叶结点的简单路径上,均包含相同数目的黑色结点
6. 每个叶子结点都是黑色的(此处的叶子结点指的是空结点)
3.2.2 红黑树结点的定义
红黑树的结点的定义相较于AVL树来说,只是多了关于结点颜色的定义,而一个结点的初始颜色默认为红色(由上述的性质5可知,对于每个节点到其后代的简单路径上的黑色节点的数目是相同的,如果在红黑树结点的定义中默认结点的颜色为黑色的话,当一个新的结点插入时必然会破环性质5从而导致要对该红黑树进行调整,效率极低)
具体的定义如下代码所示
static class RBTreeNode{
public RBTreeNode left;
public RBTreeNode right;
public RBTreeNode parent;
public int val;
public COLOR color;
public RBTreeNode(int val){
this.val=val;
//创建的新节点默认为红色
this.color=COLOR.RED;
}
}
3.2.3 红黑树的插入
红黑树是一种特殊的二叉搜索树,所以,在插入新的结点时,红黑树先按照二叉搜索树的方式进行结点的插入,然后再按照红黑树特有的规则去检查该结点插入后红黑树的性质是否被破坏,由于新插入的结点默认为红色,所以检查时会有以下两种情况:
1.父节点是黑色,那么此时并没有违反红黑树的性质,不用做任何修改
2.父节点是红色,那么此时就是两个红色结点直接相连,违反了上述的性质4,需要对该红黑树进行调整(要分情况进行调整)
定义:cur为当前节点,p为父节点,g为祖父节点,u为叔叔节点
情况一:cur为红,p为红,g为黑,u存在且为红

情况二:cur为红,p为红,g为黑,u不存在/u为黑,cur是p的左结点(调整过程中会出现的情况)

情况三:cur为红,p为红,g为黑,u不存在/u为黑,cur是p的右结点(调整过程中会出现的情况)

如图中所示,情况三先是对p结点进行左旋再交换p、cur的后就转化为了情况二,然后我们再按情况二进行处理即可
注:上述情况只说明了 p 是 g 的左结点,u 是 g 的右结点的情况,而研究 p 是 g 的右结点,u 是 g 的左结点的情况只需将上述的左右互换即可
代码实现插入过程:
public boolean insert(int val){
//先按二叉搜索树的插入方式来进行新节点的插入
RBTreeNode node=new RBTreeNode(val);
if(root==null){
root=node;
root.color=COLOR.BLACK;
return true;
}
RBTreeNode parent=null;
RBTreeNode cur=root;
while (cur!=null){
if(cur.val<val){
parent=cur;
cur=cur.right;
} else if (cur.val==val) {
return false;
}else {
parent=cur;
cur=cur.left;
}
}
if(parent.val<val){
parent.right=node;
}else {
parent.left=node;
}
node.parent=parent;
cur=node;
//接下来调整颜色
while(parent!=null&&parent.color==COLOR.RED){
RBTreeNode grandfather=parent.parent;
if(parent==grandfather.left){
RBTreeNode uncle=grandfather.right;
if(uncle!=null&&uncle.color==COLOR.RED){
parent.color=COLOR.BLACK;
uncle.color=COLOR.BLACK;
grandfather.color=COLOR.RED;
//继续向上修改颜色
cur=grandfather;
parent=cur.parent;
}else {
//uncle==null || uncle.color==black
//情况三
if(cur==parent.right){
rotateLeft(parent);
RBTreeNode tmp=parent;
parent=cur;
cur=tmp;
}
//经过if中的变化,情况三转化为了情况二
//情况二
//这里的情况可以看板书
rotateRight(parent);
grandfather.color=COLOR.RED;
parent.color=COLOR.BLACK;
}
}else {
//parent==grandfather.right
//相当于parent=grandfather.left的镜像修改,左改为右,右改为左
RBTreeNode uncle = grandfather.left;
if(uncle != null && uncle.color == COLOR.RED) {
parent.color = COLOR.BLACK;
uncle.color = COLOR.BLACK;
grandfather.color = COLOR.RED;
//继续向上修改
cur = grandfather;
parent = cur.parent;
}else {
//uncle不存在 或者 uncle是黑色的
//情况三:
if(cur == parent.left) {
rotateRight(parent);
RBTreeNode tmp = parent;
parent = cur;
cur = tmp;
}//情况三变成了情况二
//情况二
rotateLeft(grandfather);
grandfather.color = COLOR.RED;
parent.color = COLOR.BLACK;
}
}
}
root.color=COLOR.BLACK;
return true;
}
3.3 AVL树和红黑树的比较
AVL树和红黑树都是高效的平衡二叉树,增删改查的时间复杂度都是O(log2(N)),不过,AVL树为了追求绝对的平衡导致在插入和删除结点时需要大量的旋转,效率较低,而红黑树不追求绝对的平衡,在插入和删除结点时并不一定需要旋转,更加稳定,所以,在实际的应用中红黑树的使用场景更多。
二.位图
1.位图的概念
位图,就是用每一位(bit)来表示某种存在状态,适用于海量数据,整数,数据没有重复的场景,通常用来判断某个数据是否存在
2.位图的特点
2.1 省空间,一个int能存储32个数字的存在状态
2.2 判断时间快,时间复杂度能达到O(1)
2.3 适用场景,海量数据,只关心一个数据是否存在而非存在多少次
3. 位图的实现
3.1 前置操作
在进行位图其他操作的实现之前,我们要进行一定的前置操作,比如定义一个byte数组作为位图并定义默认容量和给定容量的构造方法等等
代码实现
public byte[] elem;
public int usedSize;
public MyBitSet(){
this.elem=new byte[1];
}
public MyBitSet(int n){
this.elem=new byte[n/8+1];
}
3.2 设置一个数字存在
一个字节(byte)中有8个比特位(bit),所以,在位图中设置一个数字(n)存在时,我们要先找到该数字在byte数组中对应的下标(arrayIndex)和在一个byte中对应的bit的位置(bitIndex),方便我们定位要修改的地方。确定arrayIndex只需我们将n/8,所得的结果就是其所在byte数组的下标,而确定bitIndex只需我们将n%8,所得的结果就是其所在的byte中的bit的位置,然后我们再将该位置的bit中的值置为1即可(运用 | 位运算符和 1<< bitIndex 完成)
代码实现
public void set(int val){
if(val<0){
throw new IndexOutOfBoundsException();
}
int arrayIndex=val/8;
if(arrayIndex>elem.length-1){
elem= Arrays.copyOf(elem,arrayIndex+1);
}
int bitIndex=val%8;
elem[arrayIndex]|=(1<<bitIndex);
usedSize++;
}
3.3 查找一个数字是否存在
查找一个数字是否存在的操作也是要先得到该数字的arrayIndex下标和bitIndex位置方便我们定位该数字在位图中的位置,然后我们在检查该位置的bit中的值是否为1(运用 & 位运算符和 1 << bitIndex 完成)
代码实现
public boolean get(int val){
if(val<0){
throw new IndexOutOfBoundsException();
}
int arrayIndex=val/8;
if(arrayIndex>elem.length-1){
return false;
}
int bitIndex=val%8;
if((elem[arrayIndex]&(1<<bitIndex))!=0){
return true;
}
return false;
}
3.4 将一个数字重新设为不存在
将一个数字重新设为不存在的操作也是要先得到该数字的arrayIndex下标和bitIndex位置方便我们定位该数字在位图中的位置,然后再将该位置的bit中的值设为0(运用 & 、~、<< 位运算符和 1<<bitIndex 完成)
代码实现
public void reSet(int val){
if(val<0){
throw new IndexOutOfBoundsException();
}
int arrayIndex=val/8;
if(arrayIndex> elem.length-1){
return;
}
int bitIndex=val%8;
elem[arrayIndex]&=~(1<<bitIndex);
usedSize--;
}
经过以上的实现,我们就简单地实现了一个属于自己的位图
三.布隆过滤器
布隆过滤器是一种空间效率极高的概率型数据结构,它通过多个哈希函数将一个数据映射到一个位图当中,用来快速判断 “某个元素一定不存在,还是可能存在”,这样既提高了查询的效率也大大节省了空间
布隆过滤器的插入过程如下图演示

像这样,“baidu”这个字符串就通过三个哈希函数映射到了位图当中。
1.布隆过滤器的查找
布隆过滤器在插入数据时已经将该数据哈希出来的位置的比特位的值置为1,所以我们在查询一个数据是否存在时,就可以以如下方式进行:分别计算每个哈希值对应的比特位置存储的是否为零,只要有一个为零, 代表该元素一定不在哈希表中,否则可能在哈希表中。
注意:布隆过滤器在判断一个数据存在时可能会发生误判(即假阳性,因为其他数据哈希出的哈希值可能与我们要查找的数据的哈希值存在重合,会出现由于其他数据的存在导致我们要查询的数据的哈希值的比特位均为1,但实际上我们要查询的数据不存在的情况),但是,如果布隆过滤器判断一个数据不存在时那么这个数据肯定不存在
2.布隆过滤器的删除
布隆过滤器器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素(因为几个元素之间会存在哈希值重合的情况)
一种支持删除的方法:将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储空间的代价来增加删除操作。
3.布隆过滤器的优点
1. 增加和查询元素的时间复杂度为:O(K)(K为哈希函数的个数,一般比较小),与数据量大小无关
2. 哈希函数相互之间没有关系,方便硬件并行运算
3. 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
4. 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
5. 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
6. 使用同一组散列函数的布隆过滤器可以进行交、并、差运算
4.布隆过滤器的缺陷
1. 有误判率,即存在假阳性,即不能准确判断元素是否在集合中
2. 不能获取元素本身
3. 一般情况下不能从布隆过滤器中删除元素
4. 如果采用计数方式删除,可能会存在计数回绕问题
四.并查集
1.并查集的概念
在一些应用问题中,需要将n个不同的元素划分成一些不相交的集合。开始时,每个元素自成一个单元素集合,然后按一定的规律将归于同一组元素的集合合并。在此过程中要反复用到查询某一个元素归属于那个集合的运算。适合于描述这类问题的抽象数据类型称为并查集(union-find set)。
简单来说,并查集就是专门用来处理动态连通性问题的数据结构,核心只完成两件事:Find和Union(Find:查找一个元素位于哪个集合当中,即寻找一个集合的根结点。Union:将两个元素所在的集合连接到一起)
2.并查集实现
为了加深我们对并查集的理解,我们可以尝试着去自己实现一个简单的并查集代码,在开始之前,我们要想想一个问题:要用哪种数据结构去实现呢?在这里,我们给出的答案是——数组。
我们可以用数组的下标作为编号来表示一个个元素,用其中的值来表示该元素的指向(指向的对象就是该元素所在集合的根结点),像这样,我们就可以简单地表示一堆元素的集合关系
如下图所示,我们便是成功用一个数组来表示了这些集合关系


通过这个简单的集合关系数组表示,我们可以得到以下结论:
1. 数组的下标对应集合中元素的编号
2. 数组中如果为负数,负号代表根,数字代表该集合中元素个数
3. 数组中如果为非负数,代表该元素双亲在数组中的下标
在知道了如何用数组去表示一个并查集后,我们还要知道并查集的一些基本操作,比如说查询一个元素位于哪个集合当中,查看两个元素是否处于同一个集合,将两个集合合并成一个集合,查询集合的个数等等。接下来,我们来依次实现以上操作。
2.1 查询一个元素位于哪个集合
通过上文的讲解我们知道,数组中的数据若为正数,那么我们就去查看该数据作为数组的下标指向的数组元素的值是否为正数,若是,则重复以上步骤,直到找到数组元素为负数的节点,最终找到的数组元素为负数的下标就是该集合的根结点编号
代码实现
public int findRoot(int x){
if(x<0){
throw new IndexOutOfBoundsException();
}
while(elem[x]>=0){
x=elem[x];
}
return x;
}
2.2 查看两个元素是否位于同一集合
经过了上述查询一个元素位于哪个集合操作的实现,查看两个元素是否位于同一个集合这个操作就很简单的,只需要我们先后查找这两个元素的根结点然后再判断他们的根结点是否一致即可
代码实现
public boolean isSameUnionFindSet(int x1,int x2){
int index1=findRoot(x1);
int index2=findRoot(x2);
if(index1==index2){
return true;
}
return false;
}
2.3合并两个元素所在的集合
合并两个元素所在的集合的操作也比较简单,我们依旧是要先将这两个元素所在的集合的根结点找出来。在合并操作前,我们要先判断这两个元素是否处于同一个根结点当中,若是,则无需进行合并操作,直接返回。若不是,则进行合并。合并两个集合时,我们只需将其中一个根结点继续作为新集合的根结点,然后再修改另一个根结点的指向即可(在新的集合当中该节点已经不是根结点),不过,因为根结点中的元素代表着集合中的元素个数,所以我们要先修改新的根结点的元素再进行指向的修改
代码实现
public void union(int x1,int x2){
int index1=findRoot(x1);
int index2=findRoot(x2);
if(index1==index2){
return;
}
elem[index1]=elem[index1]+elem[index2];
elem[index2]=index1;
}
2.4 查询集合的个数
因为在数组中根节点的元素都是负数,而要想知道有多少个集合只需看有几个根结点即可,所以,我们只需定义一个计数器count并去遍历整个数组,遇到负值就count++,遍历完成后count的值就是集合的个数
代码实现
public int getCount(){
int count=0;
for (int x: elem) {
if(x<0){
count++;
}
}
return count;
}
五.LRUCache
1.LRUCache的概念
LRU = Least Recently Used(最近最少使用)
LRUCache 是一种带淘汰机制的键值对缓存机制,当缓存满了,而又有新的元素添加进来时,它就会优先删除最久没被使用/访问过的元素
2.LRUCache的特点
1.有容量上限,存满了再添加新的元素时必须先删除已经存在的一个元素
2.数据被访问之后就更新其热度,将该元素标记为最近使用
3.删除机制,LRUCache每次都会删除最久没使用过的元素
4.LRUCache由双向链表+哈希构成,查找元素(get)和添加元素(put)的时间复杂度都是
O(1)
3.LRUCache的代码实现
接下来,为了更好的理解LRUCache的作用机制,我们来自己实现一下LRUCache
上文中提到,LURUCache是由一个双向链表和哈希组成,所以在实现LRUCache时,我们也使用双向链表和哈希去实现,还要定义出最大容量capacity值和双向链表的头结点和尾节点
3.1 前置操作(定义链表、链表的结点、哈希Map等等)
public class MyLRUCache {
static class DLinkNode{
public int key;
public int val;
public DLinkNode prev;
public DLinkNode next;
public DLinkNode(){
}
public DLinkNode(int key,int val){
this.key=key;
this.val=val;
}
@Override
public String toString() {
return "{ key=" + key +", val=" + val+"} ";
}
}
public DLinkNode head;
public DLinkNode tail;
public int usedSize;
public Map<Integer,DLinkNode> cache;
public int capacity;
public MyLRUCache(int capacity){
this.head=new DLinkNode();
this.tail=new DLinkNode();
//先将头节点和尾巴节点连接起来
head.next=tail;
tail.prev=head;
cache=new HashMap<>();
this.capacity=capacity;
}
完成了这些前置的定义工作之后,我们就可以开始逐步完成LRUCache中的其他操作了
3.2 添加元素(put操作)
在添加元素之前,我们要在map中先行查找看这个元素的key值是否已经被储存过,若是,则只需更新该节点的val值并将该结点移动至尾节点(即更新为最近使用过,因为之后我们实现删除操作是从链表的头节点开始删除),若该key值未被存储过,则要先创建一个链表节点并将该节点放入map和双向链表当中,最后检查容量是否超过规定的最大容量capacity,看是否需要进行删除操作(删除链表头节点)
代码实现
public void put(int key,int val){
//1.看key是否已经被存储过
DLinkNode node=cache.get(key);
if(node==null){
//2.这是没被存储过的情况
DLinkNode dLinkNode=new DLinkNode(key,val);
//将其添加进map中
cache.put(key,dLinkNode);
//将该节点添加到链表的尾部
addToTail(dLinkNode);
usedSize++;
//判断存储的元素是否大于预设的最大值
if(usedSize>capacity){
//如果大于,那么就要将头部的节点删除
DLinkNode removeNode=removeNodeHead();
cache.remove(removeNode.key);
usedSize--;
}
printNodes("put: ");
}else {
//3.这是已经被存储过的情况
//先更新一下链表中key所对应的val值
node.val=val;
//再将其移动到链表的尾部
moveToTail(node);
}
}
3.3 查找元素(get操作)
查找一个关键字对应的val值很简单,只需要在哈希map中直接调用get函数进行查找即可
代码实现
public int get(int key){
DLinkNode node=cache.get(key);
if(node==null){
return -1;
}
moveToTail(node);
return node.val;
}
像这样,我们就基本上自己实现了一个简单的LRUCache
六.B-树
在之前的学习中,我们了解到了一些基本搜索结构,比如说顺序查找、二分查找、二叉搜索树、二叉平衡树、哈希、位图、布隆过滤器等等。这些搜索结构都只适用于数据量不是很大的情况,如果数据量很大,无法一次性全部加载到内存中,那么使用上述的搜索结构就不是很方便。对于二叉搜索树来说,数据量大时树的高度就会比较高,查找数据时最差情况下要查找树的高度次,而数据量大时无法一次性全部加载,需要从磁盘中多次IO(输入/输出),时间花费会比较久。为了解决这个问题,我们便提出了多叉平衡树(B-树)的概念
1.B-树的概念
B - 树(B-Tree)是一种平衡的多路查找树,专门为磁盘、数据库、文件系统这种外存设备设计,用来减少磁盘 I/O,提升查找效率
2.B-树的性质
一棵 m 阶 B - 树 必须满足:
1. 根节点至少有两个孩子
2. 每个非根节点至少有M/2-1(向上取整)个关键字,至多有M-1个关键字,并且以升序排列
例如:当M=3的时候,至少有3/2=1.5,向上取整等于2,2-1=1个关键字,最多是2个关键字
3. 每个非根节点至少有M/2(向上取整)个孩子,至多有M个孩子
例如:当M=3的时候,至少有3/2=1.5,向上取整等于2个孩子。最多有3个孩子。
4. key[i]和key[i+1]之间的孩子节点的值介于key[i]、key[i+1]之间
5. 所有的叶子节点都在同一层。
注:空树也是B-树(空树并不满足以上性质)
3.B-树的插入
在本部分内容中,我们以三叉树来举例,即有两个关键字节点,三个孩子节点,结构如下图所示

该三叉树只要keys数组中的元素大于等于3就会进行分裂操作,为了方便分裂操作的理解,我们将该结构中的keys数组和subs数组中的元素均增加1个,变成如下所示

像这样,我们能更加简单的理解分裂元素的操作。
插入一个关键字时的初始操作是和向一棵二叉树插入新的关键字的操作是一样的,都是先逐层找到新关键字要插入的位置并将其插入,只不过在成功插入新关键字之后,B-树要检查新关键字插入的节点还是否满足B-树的性质,即关键字数量要小于 n,若大于等于,则要进行分裂操作,分裂完成后要接着检查父节点的关键字的有效数据,若也大于等于n,则父节点也要进行分裂操作,如此循环直到所有节点满足B-树的性质为止
插入过程总结:
1. 如果树为空,直接插入新节点中,该节点为树的根节点
2. 树非空,找待插入元素在树中的插入位置(注意:找到的插入节点位置一定在叶子节点中)
3. 检测是否找到插入位置(假设树中的key唯一,即该元素已经存在时则不插入)
4. 按照插入排序的思想将该元素插入到找到的节点中
5. 检测该节点是否满足B-树的性质:即该节点中的元素个数是否等于M,如果小于则满足
6. 如果插入后节点不满足B树的性质,需要对该节点进行分裂
7. 如果向上已经分裂到根节点的位置,插入结束
插入操作的代码实现
public boolean insert(int key){
//b树为空的情况
if(root==null){
root=new BTRNode();
root.keys[0]=key;
root.usedSize++;
return true;
}
//b树不为空的情况,我们要先去查找在当前b树中是否已经存在key值
Pair<BTRNode,Integer> pair=find(key);
if(pair.getVal()!=-1){
//key值已经存在,返回false
return false;
}
//走到这里说明key值不存在,需要插入
//先获取父亲结点位置(就是新key值要插入的结点)
BTRNode parent=pair.getKey();
//遍历父结点的key值进行直接插入排序
//这里不用处理孩子结点,因为插入时都是在叶子结点中插入,孩子结点都是null
int index=parent.usedSize-1;
for (; index>=0 ; index--) {
if(parent.keys[index]>=key){
//>=key的关键字后移
parent.keys[index+1]=parent.keys[index];
}else {
break;
}
}
//走到这里说明index已经走到-1值或已经找到要插入的下标
// 两种情况的要插入的下标值都是index+1
parent.keys[index+1]=key;
parent.usedSize++;
//usedSize值小于M,直接返回true
if(parent.usedSize<M){
return true;
}else {
//usedSize大于M,要进行分裂
split(parent);
return true;
}
}
4.节点的分裂
在插入新的关键字后,有些节点难免会不符合B-树的性质(关键字个数大于等于n)。此时,为了维护这课B-树,我们就要对该节点进行分裂操作,以让其满足B-树的性质
4.1 分裂普通节点(非根节点)
分裂普通节点(非根节点)的过程如下:
1.找到该节点的关键字数组的中间位置
2.创建一个新的节点,将原节点的关键字数组中间位置之后的数据全都转移到新节点中
3.将中间位置的数据放入父节点当中
4.连接好分裂完成后的节点的位置关系
以下是分裂一个普通节点(非根节点)的示意图:
此时,我们往这棵三叉树中插入关键字36,插入成功后我们发现插入的节点pCur已经不满足B-树的性质了,所以,我们要对该节点进行分裂

以下是分裂完成后的pCur节点的结构:

像这样分裂完成后,该节点就又重新满足B-树的性质了
4.2 分裂根结点
分裂根结点的过程与分裂普通节点基本相同,只不过因为根结点没有对应的父节点,所以我们要额外多创建一个新的节点来作为新的根结点,也就是说,根结点的分裂会使B-树的高度加 1 。
分裂根节点的过程如下:
1.创建一个新的节点,找到根结点的中间关键字插入该节点中,并将该节点作为新的根结点。
2.创建一个新的节点,将原节点的关键字数组中间位置之后的数据全都转移到新节点中
3.新生成的根结点仅包含该中间关键字,其左孩子为拆分后的左结点,右孩子为拆分后的右结点
4.此次分裂完成后,整棵 B 树的高度增加 1,且所有结点的关键字数量均重新符合 B 树的规则约束
以下是分裂根节点的示意图:
这是分裂前的节点,该节点的keys数组的关键字数量已经等于3,不满足三叉B-树的性质,要进行分裂操作

下图就是分裂完成之后的示意图

像这样分裂完成后,所有节点就都满足B-树的性质了
4.3 多次分裂的情况
我们在分裂完一个节点后,会将其中间位置的数据插入父节点中,有时候,父节点也会因为这个新插入的中间数据而不满足B-树的性质,此时,就会发生多次分裂的情况(先分裂插入关键字的节点,再分裂其父节点,再分裂其父节点的父节点……直到最终的那个父节点满足B-树的性质或分裂完根结点)
以下是多次分裂的情况的示意图
1.先分裂插入的节点

2.分裂其父节点

3.多次分裂后的最终图
4.4 分裂操作的代码实现
private void split(BTRNode cur){
//先创建要分裂去的新结点并标记cur的父亲结点
BTRNode newNode=new BTRNode();
BTRNode parent=cur.parent;
//取中间值
int mid= cur.usedSize >> 1;
//要分去新节点的一半数据从下标mid+1开始
int i=mid+1;
//要放入新结点的位置
int j=0;
//从i开始遍历,将要分去的原结点的keys和subs数据移动到新结点中
for (; i < cur.usedSize; i++) {
newNode.keys[j]=cur.keys[i];
newNode.subs[j]=cur.subs[i];
//修改孩子结点的指向(要先判断该孩子是否存在避免发生空指针异常)
if(newNode.subs[j]!=null){
newNode.subs[j].parent=newNode;
}
j++;
}
//在多移动一次孩子结点(孩子结点的个数比关键字结点的个数多1)
newNode.subs[j]=cur.subs[i];
//修改孩子结点的指向(要先判断该孩子是否存在避免发生空指针异常)
if(newNode.subs[j]!=null){
newNode.subs[j].parent=newNode;
}
//修改有效关键字数量
newNode.usedSize=j;
cur.usedSize=cur.usedSize-j-1;
//接下来就是将mid位置的关键字往上提
//如果要分裂的结点是根结点
if(cur==root){
//创建一个新的结点作为根结点
root=new BTRNode();
//新根结点第一个关键字就是被提上来的那个中间值
root.keys[0]=cur.keys[mid];
//新根结点的两个孩子就是分裂出来的两个结点
root.subs[0]=cur;
root.subs[1]=newNode;
//修改新根结点有效关键字个数
root.usedSize=1;
//修改分裂出来的结点的父亲结点的指向
cur.parent=root;
newNode.parent=root;
//如果是根结点分裂,说明已经调整完毕,那么直接返回
return;
}
newNode.parent=parent;
//直接插入排序寻找往上提的midVal值要插入的位置
int endT=parent.usedSize-1;
int midVal=cur.keys[mid];
for (; endT >= 0 ; endT--) {
if(parent.keys[endT]>=midVal){
//当parent.keys[endT]>=midVal时要将关键字和孩子结点一起后移
parent.keys[endT+1]=parent.keys[endT];
parent.subs[endT+2]=parent.subs[endT+1];
}else {
break;
}
}
//走到这时已经找到key值要插入位置endT+1,孩子结点要插入位置endT+2
parent.keys[endT+1]=midVal;
parent.subs[endT+2]=newNode;
//插入了一个key值,有效数据+1
parent.usedSize++;
//判断插入midVal值后该节点的有效数据是否大于等于最大值,若大于等于,则接着分裂该结点
if(parent.usedSize>=M){
split(parent);
}
}
5.B-树的性能分析
对于一棵节点为N度为M的B-树,查找和插入需要 log(M-1)N~ log(M/2)N 次比较,这个很好证明:对于度为M的B-树,每一个节点的子节点个数为M/2 ~(M-1)之间,因此树的高度应该在要log(M-1)N~ log(M/2)N 之间,在定位到该节点后,再采用二分查找的方式可以很快的定位到该元素。
B-树的效率是很高的,对于N = 62*1000000000个节点,如果度M为1024,则 <= 4,即在620亿个元素 中,如果这棵树的度为1024,则需要小于4次即可定位到该节点,然后利用二分查找可以快速定位到该元素,大大 减少了读取磁盘的次数。
七.图
图是由顶点和边组成的一种数据结构,即G=(V,E),其中:顶点集合V = {x|x属于某个数据对象集}是有穷非空集合,E = {(x,y)|x,y属于V}或者E = {<x,y>|x,y属于V && Path(x, y)}是顶点间关系的有穷集合,也叫做边的集合。
注:(x, y)表示x到y的一条双向通路,即(x, y)是无方向的;Path表示从x到y的一条单向通路,即Path 是有方向的。
1.图的存储结构
图中既有一个一个的顶点,又有一条一条的边,所以,在存储图的过程中,我们只需要存储好顶点和边即可,对此,以下有两种存储方式来存储图
1.1 邻接矩阵
两个顶点之间的关系只有两种:连通和不连通,也就是说,我们只需要用0和1来分别表示不连通和连通即可,所以,我们只需要创建一个二维数组matrix来存储两个顶点之间的关系。
但是,有时候,我们只存储两个顶点是否连通是不够的,我们还要去记录这两个顶点之间的权值,以方便我们后续选择最短路径等等,当然,我们的核心思路依旧不变,还是去用一个二维数组来存储,只不过两个没连通的顶点的值设定为Max(相当于无穷远),而相连的顶点的值就设定为他们之间的权值。
1.2 邻接表
邻接表:使用数组表示顶点的集合,使用链表表示边的关系
在邻接表中,我们需要先创建一个vector数组来存储图中的各个链表的头节点(存储类型为Node),然后在添加一条新的边的时候就相当于使用头插法去插入对应的链表来完成边的存储
注意:在存储时要注意有向图和无向图的区别,即无向图是两个顶点之间都要记录对应的边,而有向图是只记录起点到终点这条边
2.图的遍历
当给定一张图和一个顶点时,我们要能够去遍历这张图中的每一个顶点,图的遍历方式分为两种,即广度优先遍历和深度优先遍历
2.1 广度优先遍历
我们能将广度优先遍历和二叉树当中的层序遍历作类比,都相当于遍历完这一层后再去遍历下一层(当然,在图中并没有层的概念,这里只是打比方),广度优先遍历就是先遍历给定的点,再去遍历该点所连通的所有的点………以此往复,直到所有的点都被遍历完为止
我们若想要实现广度优先遍历,则需要一个队列来存储各个连通出去的点,这时,就会出现一系列的问题,比如,不同的两个顶点可能会连通一个相同的顶点,按照以上的逻辑,就会将该相同节点重复放入优先级队列当中,造成重复遍历一个顶点,对此,我们的解决方法是定义一个boolean类型的访问数组,在一个顶点入队的同时将该顶点所对应的boolean数组的值设为true,每次先检查要入队的顶点是否已经被访问过(boolean数组中对应的值为ture即已经被访问过),若已经被访问过,则不入队。像这样,我们就成功避免了一个顶点的重复遍历。
代码实现:
public void bfs(char v){
//定义一个访问数组,看一个元素是否已经被放入队列过
boolean[] visited=new boolean[arrayV.length];
//优先级队列
Queue<Integer> queue=new LinkedList<>();
int srcIndex=getIndexOf(v);
queue.offer(srcIndex);
visited[srcIndex]=true;
while (!queue.isEmpty()){
int top=queue.poll();
System.out.print(arrayV[top]+"->");
for (int i = 0; i < arrayV.length; i++) {
if(matrix[top][i]!=Constant.Max&&!visited[i]){
//每次入队时就将访问数组对应下标置为true标记,下次再遇到这个元素就不会重复放入
queue.offer(i);
visited[i]=true;
}
}
}
}
2.2 深度优先遍历
同样的,我们也可以将图的深度优先遍历和二叉树的前序遍历作类比,图的深度优先遍历就是先遍历给定的顶点,然后在遍历的过程中若找到相连通的点则先去遍历那个相连通的点,循环往复,直到所有的顶点全都被遍历完为止
同样的,我们也需要一个boolean类型的访问数组来确定一个顶点是否已经被遍历过,以防止重复遍历。再者,我们在此使用递归的方式去处理深度优先遍历的问题,来做到遍历给定的顶点的的过程中若找到相连通的点则先去遍历那个相连通的点,循环往复直到遍历完成
代码实现:
public void dfs(char v){
boolean[] visited=new boolean[arrayV.length];
int srcIndex=getIndexOf(v);
dfsChild(srcIndex,visited);
}
private void dfsChild(int srcIndex,boolean[] visited){
System.out.print(arrayV[srcIndex]+" -> ");
//将访问数组的对应下标置为true,防止重复打印
visited[srcIndex]=true;
for (int i = 0; i < arrayV.length; i++) {
if(matrix[srcIndex][i]!=Constant.Max&&!visited[i]){
//递归来打印下一个节点
dfsChild(i,visited);
}
}
}
3.最小生成树
连通图中的每一棵生成树,都是原图的一个极大无环子图,即从其中删去任何一条边,生成树就不在连通;反之,在其中引入任何一条新边,都会形成一条回路
构造最小生成树的准则:
1. 只能使用图中的边来构造最小生成树
2. 只能使用恰好n-1条边来连接图中的n个顶点
3. 选用的n-1条边不能构成回路
构造最小生成树的方法有Kruskal算法和Prim算法,这两个方法都运用了逐步求解的贪心策略
注:此处的最小生成树都是建立在无向图的基础上
3.1 Kruskal算法
Kruskal算法采用的是全局贪心策略,即在整张图的范围中依次寻找不构成环的最小权值的边,直到选够n-1条边为止
在完成Kruskal算法之前,我们要先弄明白几个关键性问题:
1.如何寻找全局范围内的最小权值边
2.如何确定选定的边不够成环
要解决这两个问题也很简单:
1.我们只需要创建一个小根堆(依靠weight权值大小来排列)去存储每一条边,像这样,以后这个堆弹出的边就是我们所需的全局范围中的最小权值边
2.想解决这个问题,我们就要用到另一个数据结构:并查集,将已经选定的边的起点和终点放入同一个集合中,在后续添加新的边时,我们只需要判断要添加的边的起点和终点是否全在该集合中即可,若全在该集合中,则不能选择这条边,因为这会形成一个环
当堆中的元素已经全部弹出或size==n-1时(即while循环退出时),我们要去检查选择的边的条数,若选定了n-1条边,则最小生成树已经确定,返回该最小生成树的总权值,反之若选定的边的条数不为n-1条,则说明该图没有相应的最小生成树
代码实现
public int kruskal(GraphByMatrix minTree){
//定义一个优先级队列,以小根堆的形式存储边
PriorityQueue<Edge> minQ=new PriorityQueue<>(new Comparator<Edge>() {
@Override
public int compare(Edge o1, Edge o2) {
return o1.weight- o2.weight;
}
});
int n=arrayV.length;
//将所有的边放入小根堆中
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if(i<j && matrix[i][j]!=Constant.Max){
minQ.offer(new Edge(i,j,matrix[i][j]));
}
}
}
//定义一个并查集,避免在选择边的过程中形成环
UnionFindSet ufs=new UnionFindSet(n);
int size=0;
int totalWeight=0;
while (size<n-1 && !minQ.isEmpty()){
Edge edge=minQ.poll();
int srcIndex=edge.srcIndex;
int destIndex=edge.destIndex;
//如果选择该最小边不会形成环
if(!ufs.isSameUnionFindSet(srcIndex,destIndex)){
//就将该边添加到最小生成树当中
minTree.addEdgeUseIndex(srcIndex,destIndex,matrix[srcIndex][destIndex]);
System.out.println("选择的边"+arrayV[srcIndex]+"->"+arrayV[destIndex]+":"+matrix[srcIndex][destIndex]);
size++;//记录已经添加的边的条数
totalWeight+=matrix[srcIndex][destIndex];//记录最小生成树的权值
ufs.union(srcIndex,destIndex);//将添加进去的边的起点和终点连成为同一个集合
}
}
if(size==n-1){
//如果边的条数等于n-1,则说明这就是所生成的最小生成树
return totalWeight;
}else {
//说明没有最小生成树
return -1;
}
}
private void addEdgeUseIndex(int srcIndex,int destIndex,int weight){
matrix[srcIndex][destIndex]=weight;
if(!isDirect){
matrix[destIndex][srcIndex]=weight;
}
}
3.2 Prime算法
Prime算法采取的是局部的贪心策略,即在已经选定的顶点中选择出所连接出去的最小权值边,直到选择到n-1条边以构成最小生成树为止(不一定能选择到n-1条边)
在完成Prime算法之前,我们也一样要想明白几个问题:
1.我们要怎么找到所选定的点连接出去的最小权值边
2.我们要如何避免所选择的边构成环
当然,这里的解决方法和上述的Kruskal算法类似:
1.我们只需要创建一个小根堆,刚开始时先把给定的顶点所连接出去的边放进去,在后续添加新的边进入最小生成树时,我们再把新的边的终点所连接出去的边添加到小根堆里即可,循环此过程直到最小生成树出现或堆为空
2.避免构成环的方法和Kruskal一样,我们只需定义两个集合(setX和setY)来分别表示已经确定的点和还未确定的点,每次在添加新的边之前先检查终点(destI)是否在已经确定的点的集合中(起点必定在已经确定的点的集合中无需检查,因为堆中的边的起点就是以已经确定的点设置的),若起点和终点在同一集合中则说明选择该边会构成环,不能选择这条边,反之则可以添加该边
代码实现
public int prim(GraphByMatrix minTree,char chV){
int srcIndex=getIndexOf(chV);
//定义集合X,存储已经确定的点
Set<Integer> setX=new HashSet<>();
setX.add(srcIndex);
//定义集合Y,存储未确定的点
Set<Integer> setY=new HashSet<>();
int n=arrayV.length;
//将还未确定的点放入集合Y中
for (int i = 0; i < n; i++) {
if(i!=srcIndex){
setY.add(i);
}
}
//定义一个优先级队列(堆)
PriorityQueue<Edge> minQ = new PriorityQueue<>(new Comparator<Edge>() {
@Override
public int compare(Edge o1, Edge o2) {
return o1.weight - o2.weight;
}
});
//将srcIndex所连接出去的边放入优先级队列(堆)中
for (int i = 0; i < n; i++) {
if(matrix[srcIndex][i]!=Constant.Max){
minQ.offer(new Edge(srcIndex,i,matrix[srcIndex][i]));
}
}
int size=0;
int totalWeight=0;
//遍历优先级队列,取出n-1条边
while (!minQ.isEmpty()){
Edge min=minQ.poll();
int srcI=min.srcIndex;
int destI=min.destIndex;
if(setX.contains(destI)){
//构成环
System.out.println("构成环的边:"+arrayV[srcI]+"->"+
arrayV[destI]+":"+matrix[srcI][destI]);
}else {
//没有环,将该边添加到最小生成树当中
minTree.addEdgeUseIndex(srcI,destI,matrix[srcI][destI]);
System.out.println("选择的边:"+arrayV[srcI]+"->"+
arrayV[destI]+":"+matrix[srcI][destI]);
totalWeight += min.weight;
size++;
//当size=n-1时,说明已经得到所需的最小生成树,直接返回其权值
if(size==n-1){
return totalWeight;
}
//更新两个集合的元素
setX.add(destI);
setY.remove(destI);
//接着将destI连接出去的所有的边放入优先级队列当中
for (int i = 0; i < n; i++) {
if(matrix[destI][i]!=Constant.Max&&!setX.contains(i)){
minQ.offer(new Edge(destI,i,matrix[destI][i]));
}
}
}
}
return -1;
}
4.最短路径
最短路径问题:从在带权图的某一顶点出发,找出一条通往另一顶点的最短路径,最短也就是沿路径各边的权值总和达到最小
而最短路径问题又分为单源最短路径和多源最短路径问题,接下来我们一一解释
4.1 单源最短路径问题
Dijkstra算法
Dijkstra算法的核心思想就是先将图中的所有顶点分为两部分:已经确定的最短距离的点的集合S和未确定的点的集合U,每次从U中选择当前距离起点最近的点加入S,再用该点进行松弛操作更新起点到该点的邻居点的距离,重复该步骤直到所有的点全都进入S集合为止
我们要想实现Dijkstra算法,需要先定义一个dist数组去记录起点到各个点的距离,定义一个pPath数组记录下起点到各个点的最短路径,定义一个boolean类型的s数组去标记一个顶点是否已经被确定。定义好这些变量并初始化后,我们便要每次从dist数组中找出距离起点最近的点(该点必须得是未确定的点),并将该点对应的s数组的值置为true(说明该点已经确定),然后再对该点进行松弛操作(即遍历该点连接出去的所有的边,发现 源点到所连接出去的边的终点的权值 大于 源点到该点的权值与该点到终点的权值之和 就进行dist数组距离和pPath数组路径的更新),直到所有的点都被确定过(遍历n次就可以完成)
代码实现
public void dijkstra(char vSrc,int[] dist,int[] pPath){
int srcIndex=getIndexOf(vSrc);
//将距离数组初始化
Arrays.fill(dist,Constant.Max);
dist[srcIndex]=0;
//将路径数组初始化
Arrays.fill(pPath,-1);
pPath[srcIndex]=srcIndex;
int n=arrayV.length;
//定义boolen数组来标记一个顶点是否已经确定
boolean[] s=new boolean[n];
for (int k = 0; k < n; k++) {
int min=Constant.Max;
int u=srcIndex;
//每次在dist数组中找到一个最小的值作为起点,同时将该点的s数组的对应值置为true
for (int i = 0; i < n; i++) {
if(s[i]==false&&dist[i]<min){
min=dist[i];
u=i;
}
}
s[u]=true;
//松弛u连接出去的所有的顶点 v
for (int v = 0; v < n; v++) {
if(s[v]==false&&matrix[u][v]!=Constant.Max
&&dist[u]+matrix[u][v]<dist[v]){
dist[v]=dist[u]+matrix[u][v];
pPath[v]=u;//更新当前的路径
}
}
}
}
Dijkstra算法存在的问题是不支持图中带负权路径,如果带有负权路径,则可能会找不到一些路径的最短路径。
Bellman-Ford算法
上述说到,Dijkstra算法不能用来处理图中带有负权路径的问题,那么,我们要怎么去求一张含有负权路径的图的最短路径呢?这时,我们就要引入另一个算法——Bellman-Ford算法
Bellman-Ford算法核心思想:先遍历所有的边,若发现 源点直接到该边的终点的权值 大于 源点先到该边的起点和该边的起点到终点的权值 之和,那么就进行源点到该边的最短路径的更新,由于存在负权路径,源点到后续的点的最短路径可能会影响到之前的顶点的最短路径,所以以上过程要重复n次(顶点的数量),以保证最后求出的最短路径的正确性(Bellman-Ford算法实际上是一种暴力求解的办法)
注意:在上述求完最短路径后,我们要再遍历一遍所有的边,如果还会进入最短路径的更新(也可以说是还会进行松弛操作),那么就说明该图中含有负权回路,即图中有一个负权值环,此时便求不了最短路径,直接返回false
代码实现:
public boolean bellmanFord(char vSrc,int[] dist,int[] pPath){
int srcIndex=getIndexOf(vSrc);
Arrays.fill(dist,Constant.Max);
dist[srcIndex]=0;
Arrays.fill(pPath,-1);
pPath[srcIndex]=0;
int n=arrayV.length;
for (int k = 0; k < n; k++) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
//循环n次来确定最短路径
if(matrix[i][j]!=Constant.Max&&dist[i]+matrix[i][j]<dist[j]){
dist[j]=dist[i]+matrix[i][j];
pPath[j]=i;
}
}
}
}
//确定完最短路径后若还能进入if语句,说明该路径图中含有负权回路
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if(matrix[i][j]!=Constant.Max&&dist[i]+matrix[i][j]<dist[j]){
return false;
}
}
}
return true;
}
4.2 多源最短路径问题
Floyd-Warshall算法
核心思想:初始时,顶点 i 到顶点 j 的权值为 i 直接到 j 的权值(不经过中间顶点的最短路径),然后我们遍历所有的顶点,检查是否能通过k让i到j的路径更短(即i→k→j 比 i→j更短),若能,则更新最短路径。在遍历完所有的顶点之后,我们就得到了顶点 i 到顶点 j 的最短路径。又因为我们要求的是多源最短路径,所以我们要让 i 、j 遍历完所有的顶点,确定 i(0、1、2、3、4……n)到 j(0、1、2、3、4……n)的最短路径。
要实现Floyd-Warshall算法,因为有多个源点,我们要将dist数组、pPath数组设为二维数组,以方便存储各个顶点到其他顶点的最短路径。
代码实现:
public void floydWarShall(int[][] dist,int[][] pPath) {
int n = arrayV.length;
//先初始化dist、pPath数组
for (int i = 0; i < n; i++) {
Arrays.fill(dist[i],Constant.Max);
Arrays.fill(pPath[i],-1);
}
//将matrix数组中不为Max的值移到dist数组中(两个节点直接相连的权值就是在不经过其他点的情况下的最短路径)
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if(matrix[i][j] != Constant.Max) {
dist[i][j] = matrix[i][j];
pPath[i][j] = i;
}else {
pPath[i][j] = -1;
}
//将i等于j的节点处的权值设为0,路径设为-1(自己到自己的权值为0,没有相应的路径)
if(i == j) {
dist[i][j] = 0;
pPath[i][j] = -1;
}
}
}
//遍历k次,即依次为经过中间节点1、2、3……n
for (int k = 0; k < n; k++) {
//这是比较 i直接到j 和 i先到k再到j 的权值大小,若是后者小于前者,则要替换
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if(dist[i][k] != Constant.Max &&
dist[k][j] != Constant.Max &&
dist[i][k] + dist[k][j] < dist[i][j]) {
dist[i][j] = dist[i][k] + dist[k][j];
//更新父节点下标
//pPath[i][j] = k;//不对
//如果经过了 i->k k->j 此时是k
//i->x->s->k k->..t->...x->j
pPath[i][j] = pPath[k][j];
}
}
}
}
1094



被折叠的 条评论
为什么被折叠?



