数据结构
5、二叉排序树
二叉排序树(Binary Sort Tree),又称二叉查找树(Binary Search Tree)或二叉搜索树。二叉排序树为满足以下条件的树:
- 若左子树不空,则左子树上所有节点的值均小于它的根节点的值;
- 若右子树不空,则右子树上所有节点的值均大于或等于它的根节点的值;
- 左、右子树也分别为二叉排序树。如图4-10所示便是一个二叉排序树。

5.1、插入操作
在二叉排序树中进行插入操作时只需找到待插入的父节点,将数据插入即可,具体流程如下。
- (1)将待插入的新节点与当前节点进行比较,如果两个节点的值相同,则表示新节点已经存在于二叉排序树中,直接返回false。
- (2)将待插入的新节点与当前节点进行比较,如果待插入的新节点的值小于当前节点的值,则在当前节点的左子树中寻找,直到左子树为空,则当前节点为要找的父节点,将新节点插入当前节点的左子树即可。
- (3)将待插入的新节点与当前节点进行比较,如果待插入的新节点的值大于当前节点的值,则在当前节点的右子树中寻找,直到右子树为空,则当前节点为要找的父节点,将新节点插入当前节点的右子树即可。具体的插入流程如图所示。

5.2、删除操作
二叉排序树的删除操作主要分为三种情况:待删除的节点没有子节点;待删除的节点只有一个子节点;待删除的节点有两个子节点。具体情况如下。
- (1)在待删除的节点没有子节点时,直接删除该节点,即在其父节点中将其对应的子节点置空即可。如图所示,要删除的节点14没有子节点,则直接将其删除即可。

- (2)在待删除的节点只有一个子节点时,使用子节点替换当前节点,然后删除该节点即可。如图所示,要删除的节点5有一个子节点8,则使用子节点8替换需要删除的节点5,然后删除节点5的数据即可。

- (3)在待删除的节点有两个子节点时,首先查找该节点的替换节点(替换节点为左子树中的最大节点或者右子树中的最小节点),然后替换待删除的节点为替换节点,最后删除替换节点。如图所示,要删除的节点4有两个子节点,其左子树最小的节点为2,其右子树最小的节点为5,因此有两种结果。

5.3、查找操作
二叉排序树的查找方式和效率接近二分查找法,因此可以很容易获取最大(最右最深子节点)值和最小(最左最深子节点)值,具体的查找流程为:将要查找的数据与根节点的值进行比较,如果相等就返回,如果小于就到左子树中递归查找,如果大于就到右子树中递归查找。
5.4、用Java实现二叉排序树
(1)定义二叉排序树的数据结构:
java
public class Node {
private int value;
private Node left;
private Node right;
public Node(){
}
public Node(Node left, Node right, int value){
this.left = left;
this.right = right;
this.value = value;
}
public Node(int value){
this(null, null, value);
}
public Node getLeft(){
return this.left;
}
public void setLeft(Node left){
this.left = left;
}
public Node getRight(){
return this.right;
}
public void setRight(Node right){
this.right = right;
}
public int getValue(){
return this.value;
}
public void setValue(int value){
this.value = value;
}
}
如上代码定义了二叉排序树的数据结构Node,在Node中包含的value、left、right分别表示二叉排序树的值、左子节点、右子节点。
(2)定义二叉排序树的插入方法:
java
/**向二叉排序树中插入节点*/
public void insertBST(int key){
Node p = root;
/**记录查找节点的前一个节点*/
Node prev = null;
/**一直查找下去,直到到达满足条件的节点位置*/
while(p ! = null){
prev = p;
if(key < p.getValue())
p = p.getLeft();
else if(key > p.getValue())
p = p.getRight();
else
return;
}
/**prev是待插入节点的父节点,根据节点值的大小,被插入相应的位置*/
if(root == null)
root = new Node(key);
else if(key < prev.getValue())
prev.setLeft(new Node(key));
else prev.setRight(new Node(key));
}
如上代码定义了insertBST()来向二叉排序树中插入节点,具体操作分4步:①循环查找需要插入的节点prev; ②如果二叉树的根节点为null,则说明二叉树是空树,直接将该节点设置为根节点;③如果待插入的数据小于该节点的值,则将其插入该节点的左节点;④如果待插入的数据大于该节点的值,则将其插入该节点的右节点。
(3)定义二叉排序树的删除方法:
java
/**
* 删除二叉排序树中的节点
* 分为三种情况:(删除节点为*p ,其父节点为*f)
* (1)要删除的*p节点是叶子节点,只需要修改它的双亲节点的指针为空
* (2)若*p只有左子树或者只有右子树,则直接让左子树或右子树代替*p
* (3)若*p既有左子树,又有右子树
* 则用p左子树中最大的值(即最右端S)代替P,删除s,重接其左子树
* */
public void deleteBST(int key){
deleteBST(root, key);
}
private boolean deleteBST(Node node, int key) {
if(node == null) return false;
else{
if(key == node.getValue()){
return delete(node);
}
else if(key < node.getValue()){
return deleteBST(node.getLeft(), key);
}
else{
return deleteBST(node.getRight(), key);
}
}
}
private boolean delete(Node node) {
Node temp = null;
/**右子树空,只需要重接它的左子树
* 如果是叶子节点,则在这里也把叶子节点删除了
* */
if(node.getRight() == null){
temp = node;
node = node.getLeft();
}
/**左子树空, 重接它的右子树*/
else if(node.getLeft() == null){
temp = node;
node = node.getRight();
}
/**左右子树均不为空*/
else{
temp = node;
Node s = node;
/**转向左子树,然后向右走到"尽头"*/
s = s.getLeft();
while(s.getRight() ! = null){
temp = s;
s = s.getRight();
}
node.setValue(s.getValue());
if(temp ! = node){
temp.setRight(s.getLeft());
}
else{
temp.setLeft(s.getLeft());
}
}
return true;
}
以上代码通过三种方法实现了二叉树的删除,deleteBST(int key)是提供给用户的删除方法,会调用deleteBST(Node node, int key),其中Node参数为根节点,表示从根节点开始递归查找和删除;deleteBST(Node node, int key)通过递归查找找到要删除的节点。查找要删除的节点的具体做法如下。
- 如果key和当前节点的值相等,则说明找到了需要删除的节点。
- 如果key小于当前节点的值,则在左子树中查找。
- 如果key大于当前节点的值,则在右子树中查找。
在找到要删除的节点后,调用delete(Node node)删除该节点,这里的删除分3种情况。
- 如果右子树为空,则只需将它的左子树接到该节点。
- 如果右子树为空,则只需将它的右子树接到该节点。
- 如果左右子树均不为空,则需要在左子树中寻找最小的节点,并将左子树中最小的节点接到当前节点。
(4)定义二叉排序树的查询方法:
java
/**查找在二叉排序树中是否有key值*/
public boolean searchBST(int key){
Node current = root;
while(current ! = null){
//等于当前值则查找成功,返回
if(key == current.getValue())
return true;
//比当前值小,进入左子树中查找
else if(key < current.getValue())
current = current.getLeft();
else //比当前值大,进入右子树中查找
current = current.getRight();
}
return false;
}
以上代码定义了searchBST()用于查询二叉排序树,具体做法如下。
- 如果key和当前节点的值相等,则说明找到了该节点。
- 如果key小于当前节点的值,则在左子树中查找。
- 如果key大于当前节点的值,则在右子树中查找。
6、红黑树
红黑树(Red-Black Tree, R-B Tree)是一种自平衡的二叉查找树。在红黑树的每个节点上都多出一个存储位表示节点的颜色,颜色只能是红(Red)或者黑(Black)。
6.1、红黑树的特性
红黑树的特性如下。
- 每个节点或者是黑色的,或者是红色的。
- 根节点是黑色的。
- 每个叶子节点(NIL)都是黑色的。
- 如果一个节点是红色的,则它的子节点必须是黑色的。
- 从一个节点到该节点的子孙节点的所有路径上都包含相同数量的黑色节点。具体的数据结构如图所示。

6.2、红黑树的左旋
对a节点进行左旋,指将a节点的右子节点设为a节点的父节点,即将a节点变成一个左节点。因此左旋意味着被旋转的节点将变成一个左节点,具体流程如图所示。

6.3、红黑树的右旋
对b节点进行右旋,指将b节点的左子节点设为b节点的父节点,即将b节点设为一个右节点。因此右旋意味着被旋转的节点将变成一个右节点,具体流程如图所示。

6.4、红黑树的添加
红黑树的添加分为3步:①将红黑树看作一颗二叉查找树,并以二叉树的插入规则插入新节点;②将插入的节点涂为"红色"或"黑色"; ③通过左旋、右旋或着色操作,使之重新成为一颗红黑树。
根据被插入的节点的父节点的情况,可以将具体的插入分为3种情况来处理。
- 如果被插入的节点是根节点,则直接把此节点涂为黑色的。
- 如果被插入的节点的父节点是黑色的,则什么也不需要做,在节点插入后,仍然是红黑树。
- 如果被插入的节点的父节点是红色的,则在被插入节点的父节点是红色的时,被插入节点一定存在非空祖父节点,即被插入节点也一定存在叔叔节点,即使叔叔节点(叔叔节点指当前节点的祖父节点的另一个子节点)为空,我们也视之为存在,空节点本身就是黑色节点。然后根据叔叔节点的颜色,在被插入节点的父节点是红色的时,进一步分为3种情况来处理。
- 如果当前节点的父节点是红色的,当前节点的叔叔节点是红色的,则将父节点设为黑色的,将叔叔节点设为黑色的,将祖父节点设为红色的,将祖父节点设为当前节点。
- 如果当前节点的父节点是红色的,当前节点的叔叔节点是黑色的且当前节点是右节点,则将父节点设为当前节点,以新节点为支点左旋。
- 如果当前节点的父节点是红色的,当前节点的叔叔节点是黑色的且当前节点是左节点,则将父节点设为黑色的,将祖父节点设为红色的,以祖父节点为支点右旋。
6.5、红黑树的删除
红黑树的删除分为两步:①将红黑树看作一颗二叉查找树,根据二叉查找树的删除规则删除节点;②通过左旋、旋转、重新着色操作进行树修正,使之重新成为一棵红黑树,具体操作如下。
- 将红黑树看作一颗二叉查找树,将节点删除。
- 如果被删除的节点没有子节点,那么直接将该节点删除。
- 如果被删除的节点只有一个子节点,那么直接删除该节点,并用该节点的唯一子节点替换该节点的位置。
- 如果被删除的节点有两个子节点,那么先找出该节点的替换节点,然后把替换节点的数据复制给该节点的数据,之后删除替换节点。
- 通过左旋、旋转、重新着色操作进行树修正,使之重新成为一棵红黑树,因为红黑树在删除节点后可能会违背红黑树的特性,所以需要通过旋转和重新着色来修正该树,使之重新成为一棵红黑树:①如果当前节点的子节点是"红+黑"节点,则直接把该节点设为黑色的;②如果当前节点的子节点是"黑+黑"节点,且当前节点是根节点,则什么都不做;③如果当前节点的子节点是"黑+黑"节点,且当前节点不是根节点,则又可以分为以下几种情况进行处理。
- 如果当前节点的子节点是"黑+黑"节点,且当前节点的兄弟节点是红色的,则将当前节点的兄弟节点设置为黑色的,将父节点设置为红色的,对父节点进行左旋,重新设置当前节点的兄弟节点。
- 如果当前节点的子节点是"黑+黑"节点,且当前节点的兄弟节点是黑色的,兄弟节点的两个子节点也都是黑色的,则将当前节点的兄弟节点设置为红色的,设置当前节点的父节点为新节点。
- 如果当前节点的子节点是"黑+黑"节点,且当前节点的兄弟节点是黑色的,兄弟节点的左子节点是红色的且右子节点是黑色的,则将当前节点的左子节点设置为黑色的,将兄弟节点设置为红色的,对兄弟节点进行右旋,重新设置当前节点的兄弟节点。
- 如果当前节点的子节点是"黑+黑"节点,且当前节点的兄弟节点是黑色的,兄弟节点的右子节点是红色的且左子节点是任意颜色的,则将当前节点的父节点的颜色赋值给兄弟基点,将父节点设置为黑色的,将兄弟节点的右子节点设置为黑色的,对父节点进行左旋,设置当前节点为根节点。
7、图
图是由有穷非空集合的顶点和顶点之间的边组成的集合,通常表示为G(V, E),其中G表示一个图,V是图G中顶点的集合,E是图G中边的集合。
在线性结构中,每个元素都只有一个直接前驱和直接后继,主要用来表示一对一的数据结构;在树形结构中,数据之间有着明显的父子关系,每个数据和其子节点的多个数据相关,主要用来表示一对多的数据结构;在图形结构中,数据之间具有任意关系,图中任意两个数据元素之间都可能相关,可用来表示多对多的数据结构。图根据边的属性可分为无向图和有向图。
7.1、无向图和有向图
若从顶点Vi到Vj的边没有方向,则称这条边为无向边。顶点和无向边组成的图为无向图,用无序对(Vi, Vj)来表示无向边。如图所示,G=(V1, {E1}),其中顶点集合V1={A, B, C, D},边集合E1={ (A, B), (A, C), (A, D), (B, D), (C,D) }。

若从顶点Vi到Vj的边有方向,则称这条边为有向边,也叫作弧,用有序偶<Vi, Vj>来表示有向边,Vi叫作弧尾,Vj叫作弧头。由顶点和有向边组成的图叫作有向图。如图所示,G=(V2, {E2}),其中顶点集合V2={A, B, C, D},弧集合E2={<A, D>, <B, A>, <C, A>, <B, C>}。连接顶点A到D的有向边就是弧,A是弧尾,D是弧头,<A, D>表示弧,注意弧是有方向的,不能写成<D, A>。

7.2、图的存储结构:邻接矩阵
图的邻接矩阵的存储方式是基于两个数组来表示图的数据结构并存储图中的数据。一个一维数组存储图中的顶点信息,一个二维数组(叫作邻接矩阵)存储图中的边或弧的信息。设图G有n个顶点,则邻接矩阵是一个n×n的方阵,如图所示。

7.2.1、无向图的邻接矩阵
在无向图的邻接矩阵中,如果<Vi, Vj>的交点为1,则表示两个顶点连通,为0则不连通。在无向图的邻接矩阵中,主对角元素都为0,也就是说顶点自身没有连通关系,如图所示。

7.2.2、有向图的邻接矩阵
在有向图的邻接矩阵中,如果<Vi, Vj>的交点为1,则表示从Vi到Vj存在弧(但从Vj到Vi是否存在弧不确定),为0则表示从Vi到Vj不存在弧;同样,在有向图的邻接矩阵中主对角元素都为0,也就是说从顶点到自身没有弧。需要注意的是,有向图的连接是有方向的,V1的出度为2(从V1出发的边有两条),表示从V1顶点出发的边有两条,V3的出度为0,表示没有从V3出发的边。有向图的邻接矩阵如图所示。

7.2.3、带权重图的邻接矩阵
有些图的每条边上都带有权重,如果要将这些权值保存下来,则可以采用权值代替矩阵中的0、1,在权值不存在的元素之间用∞表示,带权重图的邻接矩阵如图所示。

7.3、图的存储结构:邻接表
数组与链表相结合的存储方法叫作邻接表。邻接表是图的一种链式存储结构,主要用于解决邻接矩阵中顶点多边少时空间浪费的问题。具体的处理方法如下。
- (1)将图中的顶点信息存储在一个一维数组中,同时在顶点信息中存储用于指向第1个邻接点的指针,以便查找该顶点的边信息。
- (2)图中每个顶点Vi的所有邻接点构成一个线性表,由于邻接点的个数不定,所以用单向链表存储,如果是无向图,则称链表为顶点Vi的边表,如果是有向图,则称链表为以顶点Vi为弧尾的出边表。
7.3.1、无向图的邻接表结构
从图可以知道,顶点是通过一个头节点类型的一维数组保存的,其中每个头节点的第1个弧都指向第1条依附在该顶点上的边的信息,邻接域表示该边的另一个顶点在顶点数组中的下标,下一个弧指向下一条依附在该顶点上的边的信息。有向图的邻接表和无向图类似,这里不再详细讲解。

7.3.2、带权值的网图连接表结构
对于带权值的图,在节点定义中再增加一个权重值weight的数据域,存储权值信息即可,如图所示。

7.4、图的遍历
图的遍历指从图中某一顶点出发访遍图中的每个顶点,且使每一个顶点仅被访问一次。图的遍历分为广度优先遍历和深度优先遍历,且对无向图和有向图都适用。
7.4.1、广度优先遍历
广度优先遍历也叫作广度优先搜索(Breadth FirstSearch),类似于树的分层遍历算法,其定义为:假设从图中某个顶点V出发,在访问了V之后依次访问V的各个未曾访问过的邻接点,然后分别从这些邻接点出发依次访问它们的邻接点,并使先被访问的顶点的邻接点先于后被访问的顶点的邻接点被访问,直到图中所有已被访问的顶点的邻接点都被访问;若此时图中尚有顶点未被访问,则另选图中未曾被访问的一个顶点作为起始点重复上述过程,直至图中所有顶点均被访问。
如图所示的图广度优先遍历顺序为:假设从起始点V1开始遍历,首先访问V1和V1的邻接点V2和V3,然后依次访问V2的邻接点V4和V5,及V3的邻接点V6和V7,最后访问V4的邻接点V8,于是得到节点的线性遍历顺序为: V1→V2→V3→V4→V5→V6→V7→V8。

7.4.2、深度优先遍历
图的深度优先遍历也叫作深度优先搜索(Depth FirstSearch),类似于树的先根遍历(先访问树的根节点)。其定义如下:
假设从图中的某个顶点V出发,在访问V节点后依次从V未被访问的邻接点出发以深度优先的原则遍历图,直到图中所有和V节点路径连通的顶点都被访问;若此时图中尚有顶点未被访问,则另选一个未曾访问的顶点作为起始点重 复上述过程,直至图中所有节点都被访问。
如图所示的深度优先遍历顺序为:假设从起始点V1开始遍历,在访问了V1后选择其邻接点V2。因为V2未曾被访问,所以从V2出发进行深度优先遍历。依此类推,接着从V4、V8、V5出发进行遍历。在访问了V5后,由于V5的邻接点都被访问过,则遍历回退到V8。同理,继续回退到V4、V2直至V1,此时V1的另一个邻接点V3未被访问,则遍历操作又从V1到V3继续进行下去,得到节点的线性顺序为:V1→V2→V4→V8→V5→V3→V6→V7。

8、位图
位图(Bitmap)通常基于数组实现,我们可以将数组中的每个元素都看作一系列二进制数,所有元素一起组成更大的二进制集合,这样就可以大大节省空间。位图通常是用来判断某个数据存不存在的,常用于在Bloom Filter中判断数据是否存在,还可用于无重复整数的排序等,在大数据行业中使用广泛。
8.1、位图的数据结构
位图在内部维护了一个M×N维的数组charMN,在这个数组里面每个字节占8位,因此可以存储M×N×8个数据。假如要存储的数据范围为0~15,则只需使用M=1,N=2的数据进行存储,具体的数据结构如图所示。

在我们要存储的数据为{1,3,6,10,15}时,只需将有数据的位设置为1,表示该位存在数据,将其他位设置为0,具体的数据结构如图所示。

8.2、位图的Java实现
在Java中使用byte字节数组来存储bit,1Byte = 8bit。对于bit中的第i位,该bit为1则表示true,即数据存在;为0则表示false,即数据不存在。其具体实现分为数据结构的定义、查询方法的实现和修改方法的实现。
8.2.1、数据结构的定义
在如下代码中定义了一个名为Bitmap的类用于位图数据结构的存储,其中byte数组用于存储具体的数据,length用于记录数据的长度:
java
//以bit为存储单位的数据结构,对于给定的第i位,1表示true,0表示false
public class Bitmap {
private byte[] bytes;
//length为位图的长度,实际可操作的下标为[0, length)
private int length;
public Bitmap(int length){
this.length = length;
bytes = new byte[length%8==0 ? length/8 : length/8+1];
}
}
8.2.2、查询方法的实现
位图的查询操作为在拿到目标bit所在的Byte后,将其向右位移(并将高位置0),使目标bit在第1位,这样结果值就是目标bit值,方法如下。
- 通过byteindex \>\> 3(等价于byteindex/8)取到目标bit所在的Byte。
- 令i = index&7(等价于index%8),得到目标bit在该Byte中的位置。
- 为了将目标bit前面的高位置0(这样位移后的值才等于目标bit本身),需要构建到目标bit为止的低位掩码,即01111111 >>>(7 - i),再与原Byte做&运算。
- 将结果向右位移i位,使目标bit处于第1位,结果值即为所求。具体的查询位图的Java代码实现如下:
java
//获取指定位的值
public boolean get(int index){
int i = index & 7;
//构建到index结束的低位掩码并做&运算(为了将高位置0),
然后将结果一直右移,直到目标位(index位)移到第1位,然后根据其值返回结果
if((bytes[index >> 3] & (01111111>>>(7-i))) >> i == 0)
return false;
else
return true;
}
8.2.3、修改方法的实现
对位图的修改操作根据设定值true或false的不同,分为两种情况。
- 如果value为true,则表示数据存在,将目标位与1做或运算,需要构建目标位为1、其他位为0的操作数。
- 如果value为false,则表示数据不存在,将目标位与0做与运算,需要构建目标位为0、其他位为1的操作数。构建目标位为1且其他位为0的操作数的做法为:1<<(index & 7)。修改位图的Java代码实现如下:
java
//设置指定位的值
public void set(int index, boolean value){
if(value)
//通过给定位index,先定位到对应的Byte,并根据value值进行不同位的操作:
//1.如果value为true,则目标位应该做或运算,构建"目标位为1,
//其他位为0"的操作数,为了只合理操作目标位,而不影响其他位
//2.如果value为false,则目标位应该做与运算,构建"目标位为0,
//其他位为1"的操作数
bytes[index >> 3] |= 1 << (index & 7);
//bytes[index/8] = bytes[index/8] | (0b0001 << (index%8))
else
bytes[index >> 3] &= ~(1 << (index & 7));
}