数据结构中的”树“(一)

189 阅读42分钟

数据结构中的”树“(一)

数据结构其实顾名思义就是计算机中数据的组织结构,当然最底层的无非就是顺序排列(数组)或者地址引用(指针)这两种存储结构。但是利用这两种可以实现许多抽象程度更高的数据存储结构(链表、栈、队列、树等)。而这些抽象程度高的数据结构由于对数据的组织方式不一样,所以在对里面的数据进行操作时(增删改查),各种操作的复杂度也不一样(比如查找操作,对链表是O(n)的时间复杂度,但是对二叉搜索树BTS就是O(logn) 的时间复杂度),特别是针对不同业务中n的不同规模和对不同数据操作有着不一样频率的需要,可以根据各种数据结构的特点去选择效率匹配度最高的数据结构来进行业务的实现

自然界的树

很多的数据结构都是基于人们对自然规律的总结和观察,然后抽象出来并且在计算机中实现。

自然界中的树都是有一个根,然后主干,然后主干上有许多分支,分支上有许多叶子....

而这种结构抽象出来就是我们数据结构中的各种树了:二叉树,满二叉树,完全二叉树,BTS树,AVL树, 红黑树,B树,B+树,堆(大顶堆,小顶堆),Fibonacci 堆,Binomial 堆等(是不是一大堆)。

但是记住,不管是哪一种由树这个概念衍生出来的数据结构都具有树的特点,都是从增删改查角度去考虑数据的操作

二叉树

二叉树算是树结构的入门应用了。其实普通的树结构并不限制子节点的个数,而二叉树其实就是限制了子节点个数的树,规定了每一个节点只能拥有不超过2个的子节点。基于二叉树的性质,我们又加入了其他性质便有了BST

image.png

BTS

顾名思义,BTS的存在就是为了满足搜索数据的需要。想象下,我们要从n个数据中找到其中一个数据,那么我们平均需要花费多少时间?加入以数组(无序)的形式,那么就是O(n),但是以二叉树的形式呢?你肯定会说也是O(n)呀,别慌,此时我们将二叉树加一条性质:根节点大于左子节点小于右子节点。此时的平均花费时间就是O(log_2n)了,而且我们会发现对满足新加性质的二叉树的中序遍历会直接得到一个升序序列(买一送一是不是?)

其实二叉搜索树就是在二叉树的基础上加了一条性质:根节点大于左子节点小于右子节点。

我们现在考虑如何实现它。(实现一种数据结构永远从数据的增删改查角度去思考实现)

我们BTS的入口肯定是一个根节点

先看我们节点node类的声明:

public class TreeNode {
    
    private TreeNode leftChild;
    private TreeNode rightChild;
    private int nodeVal;
    
    public TreeNode(int nodeVal) {
        super();
        this.nodeVal = nodeVal;
    }
    public TreeNode(TreeNode leftChild, TreeNode rightChild, int nodeVal) {
        super();
        this.leftChild = leftChild;
        this.rightChild = rightChild;
        this.nodeVal = nodeVal;
    }
    public TreeNode getLeftChild() {
        return leftChild;
    }
    public void setLeftChild(TreeNode leftChild) {
        this.leftChild = leftChild;
    }
    public TreeNode getRightChild() {
        return rightChild;
    }
    public void setRightChild(TreeNode rightChild) {
        this.rightChild = rightChild;
    }
    public int getNodeVal() {
        return nodeVal;
    }
    public void setNodeVal(int nodeVal) {
        this.nodeVal = nodeVal;
    }
}
搜索节点
  1. 从根节点(入口节点)开始
  2. 如果当前节点为null,返回不存在该元素
  3. 比较当前节点的key和搜索节点的key
  4. 如果相等,返回当前节点
  5. 如果大于当前节点,将当前节点的右子节点变成当前节点,回到#2
  6. 如果小于当前节点,将当前节点的左子节点变成当前节点,回到#2

代买实现:分别用递归和循环实现

public boolean searchNoRecursion(TreeNode node) {
    if (node == null || root == null) {
        return false;
    }
    TreeNode tempRoot = root;
    while(tempRoot != null) {
        if(node.getNodeVal() > tempRoot.getNodeVal()) {
            tempRoot = tempRoot.getRightChild();
            continue;
        }
        else if (node.getNodeVal() < tempRoot.getNodeVal()) {
            tempRoot = tempRoot.getLeftChild();
            continue;
        }
        return true;
    }
    return false;
}
public TreeNode searchWithRecursion(TreeNode root, TreeNode node) {
    if (node == null || root == null) {
        return null;
    }
    if(node.getNodeVal() > root.getNodeVal()) {
       return searchWithRecursion(root.getRightChild(), node);
    }
    else if(node.getNodeVal() < root.getNodeVal()) {
        return searchWithRecursion(root.getLeftChild(), node);
    }
    return root;
}
增加节点
  1. 从入口节点开始
  2. 开始比较当前节点的key和插入节点的key
  3. 如果相等,插入失败(不存储重复key的元素)
  4. 如果大于当前节点,将当前节点的右子节点变成当前节点,回到#2
  5. 如果小于当前节点,将当前节点的左子节点变成当前节点,回到#2
  6. 当子节点为null时,就将节点插入在这里

代码实现:

public boolean addNode(TreeNode node) {
    if(root == null) {
        this.root = node;
        return true;
    }
​
    if(searchNoRecursion(node)) {
        return false;
    }
​
    TreeNode tempRoot = root;
    while(tempRoot != null) {
        if(tempRoot.getNodeVal() > node.getNodeVal()) {
            if(tempRoot.getLeftChild() == null) {
                tempRoot.setLeftChild(node);
                return true;
            }
            tempRoot = tempRoot.getLeftChild();
        }
        else {
            if(tempRoot.getRightChild() == null) {
                tempRoot.setRightChild(node);
                return true;
            }
            tempRoot = tempRoot.getRightChild();
        }
    }
    return false;
}

上述代码是用循环实现的,其实也可以用递归实现

删除节点
  1. 先搜索到该节点
  2. 找到该节点右子树最小(也可以是左子树最大)的节点,跳到#5
  3. 如果没有右子树,则直接将左子树赋给该节点的父节点
  4. 如果也没有左子树,直接删除该节点(因为该节点是叶子节点)
  5. 交换他们两个
  6. 删除该节点右子树最小(也可以是左子树最大)的节点
public boolean deleteNode(TreeNode node) {
		//如果不存在该节点
		if(!searchNoRecursion(node)) {
			return false;
		}
		//两个游标指针,用于指向当前节点和当前节点的父节点
		TreeNode tempParent = null;
		TreeNode childcur = this.root;
		//寻找删除的目标节点
		while(childcur != null) {
			//获取当前节点的节点值用于后面简化代码
			int val = childcur.getNodeVal();
			//找到删除的目标节点
			if(val == node.getNodeVal()) {
				//节点左右子节点都为null
				if (childcur.getLeftChild() == null &&
                    childcur.getRightChild() == null) {
					 if(tempParent.getRightChild() != null && 
						tempParent.getRightChild().getNodeVal() 
                        == childcur.getNodeVal() ) {
						 tempParent.setRightChild(null);
						 return true;
					 } 
					 else {
						 tempParent.setLeftChild(null);
						 return true;
					 }
				}
				//如果只有一个子节点
				if (childcur.getLeftChild() == null || 
                    childcur.getRightChild() == null) {
					//如果当前删除节点是父节点的右子节点
					 if(tempParent.getRightChild() != null && 
							 tempParent.getRightChild().getNodeVal() == 		    									childcur.getNodeVal()) {
						 //将当前节点的左子节点或者右子节点设为父节点的左子节点
						 tempParent.setRightChild(childcur.getLeftChild() == null ? 
								 childcur.getRightChild(): childcur.getLeftChild());
						 return true;
					 } 
					 else {
						 tempParent.setLeftChild(childcur.getLeftChild() == null ? 
								 childcur.getRightChild(): childcur.getLeftChild());
						 return true;
					 }
				}
				//有两个子节点:找到右子树的最小节点,与当前节点交换,然后将右子树最左节点置为null
				//找到右子树的最小节点
				TreeNode temp = childcur;
				childcur = childcur.getRightChild();
		    	while(childcur.getLeftChild() != null) {
		    		temp = childcur;
		    		childcur = childcur.getLeftChild();
		    	}
		    	//如果当前删除节点是父节点的左子节点
			    if(tempParent.getRightChild() == null) {
			    	tempParent.getLeftChild().setNodeVal(childcur.getNodeVal());
				}
			    else {
			    	tempParent.getRightChild().setNodeVal(childcur.getNodeVal());
			    }
			    //将右子节点的最小节点置为null
			    if(temp.getRightChild() != null && 
			    temp.getRightChild().getNodeVal() == childcur.getNodeVal()) {
			    	temp.setRightChild(null);
			    }
			    else {
			    	temp.setLeftChild(null);
			    }
			}
			//如果目标节点大于当前节点的值,游标重置为当前节点的右子节点,同时父节点游标也要移动
			if(val < node.getNodeVal()) {
				tempParent = childcur;
				childcur = childcur.getRightChild();
			}
			//如果目标节点小于当前节点的值,游标重置为当前节点的左子节点,同时父节点游标也要移动
			if(val > node.getNodeVal()) {
				tempParent = childcur;
				childcur = childcur.getLeftChild();
			}
		}
		//一般不可能运行
		return false;
	}

上述代码可能比较复杂(可能是笔者代码功底太差),但是还是需要沉下心来去理解

BTS Tree的遍历

其实树的遍历有先序、中序、后序、BFS(层序遍历)、DFS(先序遍历),一共是五种模式

private void preOrderRecursion(ArrayList<TreeNode> res, TreeNode node) {
        if (node != null) {
            res.add(node);
            preOrderRecursion(res, node.getLeftChild());
            preOrderRecursion(res, node.getRightChild());
        }
    }
    private void middleOrderRecursion(ArrayList<TreeNode> res, TreeNode node) {
        if (node != null) {
            middleOrderRecursion(res, node.getLeftChild());
            res.add(node);
            middleOrderRecursion(res, node.getRightChild());
        }
    }
    private void postOrderRecursion(ArrayList<TreeNode> res, TreeNode node) {
        if (node != null) {
            postOrderRecursion(res, node.getLeftChild());
            postOrderRecursion(res, node.getRightChild());
            res.add(node);
        }
    }
    
    public ArrayList<TreeNode> traverseBFS(){
        if(root == null) {
            return null;
        }
        //Queue for storing Tree node
        LinkedList<TreeNode> queue = new LinkedList<>();
        //List for storing traverse history
        ArrayList<TreeNode> res = new ArrayList<>();
        //root gets into the queue
        queue.offer(root);
        //traverse
        while(queue.size() > 0) {
            TreeNode temp = queue.poll();
            res.add(temp);
            if(temp.getLeftChild() != null) {
                queue.offer(temp.getLeftChild());
            }
            if(temp.getRightChild() != null) {
                queue.offer(temp.getRightChild());
            }
        }
        return res;
    }
    

上述代码中,除开BFS搜索,其他都是递归实现的。当然也可以用栈+循环来实现。一切的递归其实都可以用栈+循环来实现。

在BFS遍历中,我们采用了的是循环+队列来实现。因为我们在遍历下一层的时候需要上层的父亲节点中的索引,所以我们需要采用queue的先进先出的性质来保存遍历过的上层节点

AVL Tree

AVL树(Adelson-Velsky and Landis Tree)是计算机科学中最早被发明的自平衡二叉查找树。在AVL树中,任一节点对应的两棵子树的最大高度差为1,因此它也被称为高度平衡树。查找、插入和删除在平均和最坏情况下的时间复杂度都是O(\log{n})。增加和删除元素的操作则可能需要借由一次或多次树旋转,以实现树的重新平衡。AVL树得名于它的发明者G. M. Adelson-VelskyEvgenii Landis,他们在1962年的论文《An algorithm for the organization of information》中公开了这一数据结构 zh.wikipedia.org/wiki/AVL%E6…(引用自) 其实这里的AVL 树就是二叉搜索树的升级版。那我们为何要升级二叉搜索树呢?

想象一下,我们使用某种数据结构的原因是想让我们的某种数据操作在这种数据结构上能够更加的有效。比如我们在使用BTS的时候需要的是数据搜索操作能够更加有效,确实在一般的情况下,我们的平均搜索时间复杂度能达到O(log_2n) ,但是在极端情况下(比如n个数据有序插入BTS树中),就有可能退化成链表如下:

image.png

此时搜索数据的时间复杂度就变成了O(n) , 所以这就是我们引进AVL Tree(高度平衡二叉查找树或者叫自平衡二叉查找树)的原因,因为它就是在BTS的基础上加了一条性质:每个节点的子树高度差不能超过1。这样就保证了整棵树不会退化成链表,而且还能保证我们每次查找的平均时间都是O(log_2n) 的(一般来说比二叉查找树更有效)。

img AVL Tree例子

img

非AVL Tree例子

实现思路

既然只是多了一条性质,那么我们就来看要怎样改变BTS使其满足这个性质。

在BTS的实现过程中我们可以看到,节点的左右高度其实都是在删除和插入的时候被影响的。如果想保证节点的左右子树高度之差不超过1,那么我们就需要在每次插入或者删除后去检查树的平衡情况。然后根据平衡情况来决定需要怎样的后续操作。

怎样处理不平衡的树,使其达到平衡?

平衡操作

通过上面维基百科的描述可以直到,当树出现不平衡后(肯定是某个节点的某个子树比另一个子树高出大于1了),那么其实很自然的我们就想到,旋转!对就是旋转!比如节点的右子树高度比左子树高2个度,那么直接将右子树高出的节点移到左子树肯定是不行的(违背了BTS的性质),所以我们就左旋(此处可以一根锁链,我们用手提溜着,但是一边长度太大,导致不平衡,我们怎么办,当然是手往长的那个方向走一点,也就是整个锁链向短的那边旋转一点),具体操作如下(引用自:www.geeksforgeeks.org/avl-tree-se…

左旋和右旋
T1, T2 and T3 are subtrees of the tree rooted with y (on the left side) or x (on the right side) 
T1,T2 和 T3是以y(左边的)或者x(右边的)为根节点的子树(注意这里的子树可以是一颗avl树,也可以是一个节点,甚至可以是null)
  y                               x
 / \     Right Rotation          /  \
x   T3   - - - - - - - >        T1   y 
/ \       < - - - - - - -            / \
T1  T2     Left Rotation            T2  T3
Keys in both of the above trees follow the following orderkeys(T1) < key(x) < keys(T2) < key(y) < keys(T3)
So BST property is not violated anywhere.

然后在实际的插入中,我们主要就采取左右单旋和他们的组合旋转来处理不平衡的情况。

造成不平衡情况出现的原因有两种,他们的处理也有一些细微的不同之处

插入节点后造成了不平衡

我们在插入节点时还是根据BTS插入节点的方式去插入,只不过在插入完成后从插入节点开始往上检查树的稳定因子,如果不稳定,那么一定是下列4中情况之一

  1. Left Left case(LL型不平衡)

此时节点的左右子树高度差大于1的节点(图中是z节点),此时是左子树高于右子树,而且左子树的左子树高于左子树的右子树(说明插入节点位于左子树的左子树中),此时我们将不平衡的节点(z)向右旋来重新达到平衡。

T1, T2, T3 and T4 are subtrees.
         z                                      y 
        / \                                   /   \
       y   T4      Right Rotate (z)          x      z
      / \          - - - - - - - - ->      /  \    /  \ 
     x   T3                               T1  T2  T3  T4
    / \
  T1   T2
  1. Right Right case(RR型不平衡)

此时节点的左右子树高度差大于1的节点(图中是z节点),此时是右子树高于左子树,而且右子树的右子树高于右子树的左子树(说明插入节点位于右子树的右子树中),此时我们将不平衡的节点(z)向左旋来重新达到平衡。

  z                                y
 /  \                            /   \ 
T1   y     Left Rotate(z)       z      x
    /  \   - - - - - - - ->    / \    / \
   T2   x                     T1  T2 T3  T4
       / \
     T3  T4
  1. Left Right case(LR型不平衡)

此时节点的左右子树高度差大于1的节点(图中是z节点),此时是左子树高于右子树,而且左子树的右子树高于左子树的左子树(说明插入节点位于左子树的右子树中),此时通过简单的一步旋转是无法使得树重新平衡的。此时应该先将这种情况转变成上面的LL型不平衡case,怎么做呢?就是先将左子树(y节点)左旋,此时相当于将左子树的右子树高度-1,而左子树的左子树+1,就变成了左子树的左子树高于左子树的右子树(变成了LL型),所以此时再将z节点右旋就可以了

     z                               z                           x
    / \                            /   \                        /  \ 
   y   T4  Left Rotate (y)        x    T4  Right Rotate(z)    y      z
  / \      - - - - - - - - ->    /  \      - - - - - - - ->  / \    / \
T1   x                          y    T3                    T1  T2 T3  T4
    / \                        / \
  T2   T3                    T1   T2
  1. Right Left(RL型不平衡) 此时节点的左右子树高度差大于1的节点(图中是z节点),此时是右子树高于左子树,而且右子树的左子树(以x为根的树)高于右子树的左子树(T4),说明插入节点位于左子树的右子树中。此时通过简单的一步旋转是无法使得树重新平衡的。此时应该先将这种情况转变成上面的RR型不平衡case,怎么做呢?就是先将右子树(y节点)右旋,此时相当于将右子树的左子树高度-1,而右子树的右子树+1,就变成了右子树的右子树高于右子树的左子树(变成了RR型),所以此时再将z节点左旋就可以了。
   z                            z                            x
  / \                          / \                          /  \ 
T1   y   Right Rotate (y)    T1   x      Left Rotate(z)   z      y
    / \  - - - - - - - - ->     /  \   - - - - - - - ->  / \    / \
   x   T4                      T2   y                  T1  T2  T3  T4
  / \                              /  \
T2   T3                           T3   T4
插入节点例子示意图

(引用自:www.geeksforgeeks.org/avl-tree-se…

avlinsert1

avlinsert2-jpg

avlinsert3

avlinsert4

avlinsert5 代码实现:

		//如果当前树为空
		if(this.root == null) {
			 this.root = node;
			 return root;
		}
		//树中已经存在该节点了
		if(search(node) != null) {
			return null;
		}
	
		AVLTreeNode tempRoot = this.root;
		while(tempRoot != null) {
			if(tempRoot.getNodeValue() > node.getNodeValue()) {
				if(tempRoot.getLeftTreeNode() == null) {
					tempRoot.setLefTreeNode(node);
					node.setParentNode(tempRoot);
					break;
				}
				tempRoot = tempRoot.getLeftTreeNode();
			}
			else {
				if(tempRoot.getRightTreeNode() == null) {
					tempRoot.setRighTreeNode(node);
					node.setParentNode(tempRoot);
					break;
				}
				tempRoot = tempRoot.getRightTreeNode();
			}
		}
		balance(node);
		return node;
    }
	/**
	 * 从给定的叶子节点向上回溯找出不平衡的节点,然后通过旋转平衡树
	 * @param node
	 */
	public void balance(AVLTreeNode node) {
		AVLTreeNode parent = node.getParentNode();
		int leftHeight, rightHeight;
		while(parent != null) {
			leftHeight = getHeight(parent.getLeftTreeNode());
			rightHeight = getHeight(parent.getRightTreeNode());
			//如果子树的高度差大于一则说明不平衡需要调整
			if(Math.abs(leftHeight-rightHeight) > 1) {
				//节点是最近不平衡子树根节点的左子树,而且是左子树中的左子树:需要右旋转,LL型
				if(parent.getNodeValue() > node.getNodeValue() && 
                   parent.getLeftTreeNode().getNodeValue() > node.getNodeValue()) {
					rightRotation(parent);
					break;
				}
				//节点是最近不平衡子树根节点的左子树,而且是左子树中的右子树,
                //需要先左旋转,再右旋转,LR型
				if(parent.getNodeValue() > node.getNodeValue() && 
                   parent.getLeftTreeNode().getNodeValue() < node.getNodeValue()) {
					leftAndRightRotation(parent);
					break;
				}
				//节点是最近不平衡子树根节点的右子树的左叶子节点,需要先右旋,再左旋转,RL型
				if(parent.getNodeValue() < node.getNodeValue() &&   	                    				parent.getRightTreeNode().getNodeValue() > node.getNodeValue()) {
					rightAndLeftRotation(parent);
					break;
				}
				//节点是最近不平衡子树根节点的右子树的右叶子节点,需要左旋转,RR型
				if(parent.getNodeValue() < node.getNodeValue() &&   	           							parent.getRightTreeNode().getNodeValue() < node.getNodeValue()) {
					leftRotation(parent);
					break;
				}
			}
			parent = parent.getParentNode();
		}
	}
	/**
	 * 右旋转
	 * 参数是最近的不平衡子树的根节点
	 * @param node
	 */
	public void rightRotation(AVLTreeNode node) {
		AVLTreeNode child = node.getLeftTreeNode();
		AVLTreeNode parent = node.getParentNode();
        //如果插入的是左叶子节点
		AVLTreeNode childRight = node.getLeftTreeNode().getRightTreeNode();
        //如果插入的是左叶子节点
		AVLTreeNode childLeft = node.getLeftTreeNode().getLeftTreeNode(); 
		//判断最小不平衡子树的根节点是父节点的左子节点或者右子节点
		if(parent == null) {
			this.root = child;
		}
		else {
			if(parent.getLeftTreeNode().getNodeValue() == node.getNodeValue()) {
				parent.setLefTreeNode(child);
			}
			else {
				parent.setRighTreeNode(child);
			}
		}
		//将根节点设置为左子节点的右子节点
		node.getLeftTreeNode().setRighTreeNode(node);
		node.setParentNode(child);
		child.setParentNode(parent);
		//将 根节点的左子节点 设为 根节点的左子节点的右子节点
		if(childRight != null) {
			node.setLefTreeNode(childRight);
			childRight.setParentNode(node);
		}
		else {
			node.setLefTreeNode(null);
		}
	}
	
	public void leftRotation(AVLTreeNode node) {
		AVLTreeNode child = node.getRightTreeNode();
		AVLTreeNode parent = node.getParentNode();
		AVLTreeNode childleft = node.getRightTreeNode().getLeftTreeNode();
		AVLTreeNode childright = node.getRightTreeNode().getRightTreeNode();
		//判断最小不平衡子树的根节点是父节点的左子节点或者右子节点
		if(parent == null) {
			this.root = child;
		}
		else {
			if(parent.getLeftTreeNode().getNodeValue() == node.getNodeValue()) {
				parent.setLefTreeNode(child);
			}
			else {
				parent.setRighTreeNode(child);
			}
		}
		//将根节点设置为右子节点的左子节点
		child.setLefTreeNode(node);
		child.setParentNode(parent);
		node.setParentNode(child);
		//如果插入的是左叶子节点,将 根节点的右子节点 设为 根节点的右子节点的左子节点
		//否则啥也不会做
		if(childleft != null) {
			node.setRighTreeNode(childleft);
			childleft.setParentNode(node);
		}
		else {
			node.setRighTreeNode(null);
		}
	}
	
	public void leftAndRightRotation(AVLTreeNode node) {
		leftRotation(node.getLeftTreeNode());//左子节点先左旋
		rightRotation(node);//根节点右旋
	}
	public void rightAndLeftRotation(AVLTreeNode node) {
		rightRotation(node.getRightTreeNode());//右子节点先右旋
		leftRotation(node);//根节点左旋
	}
	
删除节点后造成了不平衡

删除节点后如果造成了不平衡的处理,稍微比上面的复杂些(what?可是上面的已经足够复杂了诶!),但其实基本操作是一样的,只不过有几种特殊情况需要讨论。删除时,我们应用的也是BTS的典型删除操作,只不过删除之后我们需要检查树是否平衡。

  1. 删除的节点是叶子节点

    此时我们可以直接删除该节点,但是该节点的删除可能会引起父亲节点或者祖先节点的不平衡。所以需要从父节点开始往上检查节点的平衡因子,如果不平衡就根据上面描述的不平衡case进行相对应的旋转。但是在对父亲节点平衡后,我们依然需要对祖先节点进行检查,直到根节点。这里就是跟insertion的区别,证明看这里:www.youtube.com/watch?v=Tbv…

  2. 删除的节点只有一颗子树

    此时我们可以直接删除该节点,然后让该节点的子树连接父亲节点。同上

  3. 删除的节点有两颗子树

    此时我们可以判断哪一棵子树的高度比较高,选择较高的那个子树中的最小(右子树高)或最大(左子树高)节点与当前节点交换,然后删除当前节点。同上

删除节点例子示意图

avl-delete1

avl-delete1 代码实现:

	public void deletebalance(AVLTreeNode node) {
		AVLTreeNode temp = node;
		int leftHeight, rightHeight;
		while(temp != null) {
			leftHeight = getHeight(temp.getLeftTreeNode());
			rightHeight = getHeight(temp.getRightTreeNode());
			
			if(Math.abs(leftHeight - rightHeight) > 1) {//找到最近的不平衡子树
				if(leftHeight > rightHeight) {//L型
					AVLTreeNode leftChild = temp.getLeftTreeNode();
					if(getHeight(leftChild.getLeftTreeNode()) >= 
                       getHeight(leftChild.getRightTreeNode())) {//LL型
						rightRotation(temp);
					}
					else {//LR型
						leftRotation(leftChild);
						rightRotation(temp);
					}
				}
				else {//R型
					AVLTreeNode rightChild = temp.getRightTreeNode();
					if(getHeight(rightChild.getLeftTreeNode()) >= 
                       getHeight(rightChild.getRightTreeNode())) {//RL型
						rightRotation(rightChild);
						leftRotation(temp);
					}
					else {//RR型
						leftRotation(temp);
					}
					
				}
			}
            //在对最近的不平衡子树平衡操作后,需要继续往上查看上面的子树是否平衡直到根节点
			temp = temp.getParentNode();
            
		}
	}
	public AVLTreeNode delete(AVLTreeNode node) {
		AVLTreeNode temp = search(node);
		if(temp == null) {
			return null;
		}
		//删除结点是叶子节点
		if(temp.getLeftTreeNode() == null && temp.getRightTreeNode() == null) {
			AVLTreeNode parent  = temp.getParentNode();
			//叶子结点是父结点的左子节点
			if(parent.getLeftTreeNode() != null && 
               parent.getLeftTreeNode().getNodeValue() == temp.getNodeValue()) {
				parent.setLefTreeNode(null);
			}
			else {
				parent.setRighTreeNode(null);
			}
			deletebalance(temp.getParentNode());
			return temp;
		}
		//删除结点只有一颗子树
		if((temp.getLeftTreeNode() != null && temp.getRightTreeNode() == null) || 
           (temp.getLeftTreeNode() == null && temp.getRightTreeNode() != null)) {
			AVLTreeNode parent  = temp.getParentNode();
			//删除结点是父节点的左子节点,直接将父节点的左子节点设置成删除结点的子树
			if(parent.getLeftTreeNode() != null && 
               parent.getLeftTreeNode().getNodeValue() == temp.getNodeValue()) {
                   parent.setLefTreeNode(temp.getLeftTreeNode() == null ? 
                   temp.getRightTreeNode() : temp.getLeftTreeNode());
			}
			else {//删除结点是父节点的右子节点,直接将父节点的右子节点设置成删除结点的子树
                    parent.setRighTreeNode(temp.getLeftTreeNode() == null ? 
                    temp.getRightTreeNode() : temp.getLeftTreeNode());
			}
			deletebalance(parent);
			return temp;
		}
		//删除结点有两颗子树
		if(temp.getLeftTreeNode() != null && temp.getRightTreeNode() != null) {
			AVLTreeNode child = temp.getRightTreeNode();
			//找到右子树的最小结点
			while(child.getLeftTreeNode() != null) {
				child = child.getLeftTreeNode();
			}
			//将删除结点的左右子节点赋值给child
			child.setLefTreeNode(temp.getLeftTreeNode());
			child.setRighTreeNode(temp.getRightTreeNode());
			//将删除结点的父节点的子节点变成child
			if(temp.getParentNode().getRightTreeNode() != null && 
               temp.getParentNode().getRightTreeNode().getNodeValue() 
               == temp.getNodeValue()) {
				temp.getParentNode().setRighTreeNode(child);
			}
			else {
				temp.getParentNode().setLefTreeNode(child);
			}
			//将删除结点的双子节点的父节点变成child
			temp.getLeftTreeNode().setParentNode(child);
			temp.getRightTreeNode().setParentNode(child);
			//将child结点删除
			if(child.getParentNode().getRightTreeNode() != null && 
               child.getParentNode().getRightTreeNode().getNodeValue() 
               == child.getNodeValue()) {
				child.getParentNode().setRighTreeNode(null);
			}
			else {
				child.getParentNode().setLefTreeNode(null);
			}
			deletebalance(child.getParentNode());
			//将child的父节点设置成删除结点的父节点
			child.setParentNode(temp.getParentNode());
			return temp;
		}
		return null;
	}
AVL Tree总结

AVL树其实是对BTS树的改进,使得他的检索时间复杂度能够稳定在O(logn) ,但是这并不是没有额外花销的。在每次的删除和插入节点后,如果出现了不平衡,需要去进行重新平衡,特别是在删除时,需要重新平衡的可能不止一个节点。这些在插入和删除过程中维持稳定的额外步骤无疑就是AVL树的劣势。而且AVL 树的插入,检索,删除的时间复杂度都是O(logn) 。我们可以分析,在插入时,我们需要找到该节点的插入位置O(logn) ,找到后插入该节点,检查是否稳定O(logn) ,所以总体的插入时间复杂度是O(logn) 。

可以说正是因为AVL 树非常强调节点子树的高度差<=1,造成了不会出现BTS的极端情况,提升了检索效率;但是也是因为这个性质造成了,维护平衡的巨大额外开销。对于有频繁删除插入的业务场景可能就不是很适用。

Red Black Tree

我们在用二叉查找树的时候,总是希望在最坏的情况下数据操作也能保持O(log_2n)的时间复杂度,BTS显然不能做到(极端情况下会退化成链表),而AVL当然可以(因为高度严格平衡),但是额外的保持平衡的操作花销也很可观,显然不适合增删操作频繁的场景。

Comparison with AVL Tree:

The AVL trees are more balanced compared to Red-Black Trees, but they may cause more rotations during insertion and deletion. So if your application involves frequent insertions and deletions, then Red-Black trees should be preferred. And if the insertions and deletions are less frequent and search is a more frequent operation, then AVL tree should be preferred over Red-Black Tree.

所以这时RB Tree就诞生了,它的出现保证了了树的相对平衡(并不是高度平衡),同时减少了重新平衡树的频率(在插入和删除的时候),正是在高度平衡性质的妥协和在rebalance操作的提升,使得红黑树在一众自我平衡树中脱颖而出有了很多应用

  1. Most of the self-balancing BST library functions like map and set in C++ (OR TreeSet and TreeMap in Java) use Red-Black Tree.
  2. It is used to implement CPU Scheduling Linux. Completely Fair Scheduler uses it.
  3. Besides they are used in the K-mean clustering algorithm for reducing time complexity.
  4. Moreover, MySQL also uses the Red-Black tree for indexes on tables.

那么RB Tree有哪些性质呢?

  1. 所有节点非黑即红
  2. 所有叶子节点都为null,且都是黑色
  3. 根节点为黑色
  4. 两个红色节点不能是父子关系(红色节点上下不相邻)
  5. 任意节点到它子孙叶子节点的路径中必须包含同样个数的黑色节点(黑色节点平衡)

我们看一个典型的红黑树例子:

image.png 到这里,我们可能会想,好像红黑树没有像AVL树中的平衡因子(balance factor),那它是怎样保持自我平衡的呢?答案就是红黑,正是因为我们在每个节点中增加了颜色这一性质,所以在平衡过程中,我们是根据节点和节点之间的颜色关系来判断平衡的。我们来看一个例子:

        30             30               30       
       / \            /  \             /  \
     20  NIL         20   NIL         20   NIL
    / \             / \              /  \   
  10  NIL          10  NIL          10  NIL  
Violates          Violates        Violates
Property 5.      Property 5       Property 4 

如果你尝试着给上述的不平衡二叉树涂色使其变成红黑树,你会发现怎么都不能做到。也就意味着不平衡树无论怎样都无法变成一颗红黑树,所以能满足红黑树性质的一定是一颗平衡树。

其他性质

  1. Black height of the red-black tree is the number of black nodes on a path from the root node to a leaf node. Leaf nodes are also counted as black nodes. So, a red-black tree of height h has black height >= h/2.
  2. Height of a red-black tree with n nodes is h<= 2 log2(n + 1).
性质2证明
  1. For a general Binary Tree, let k be the minimum number of nodes on all root to NULL paths, then n >= 2^k – 1 (Ex. If k is 3, then n is at least 7). This expression can also be written as k <= log_2(n+1)

    对一颗普通二叉树,设k是根节点到null节点(叶子节点)的最短路径包含的节点个数,那么n >= 2^k -1

    n是总的节点个数,这个式子也可以写成k <= log_2(n+1)

  2. From property 5 of Red-Black trees and above claim, we can say in a Red-Black Tree with n nodes, there is a root to leaf path with at-most log_2(n+1) black nodes.

    由性质RB Tree的性质5,可得设RB Tree的节点数为n,那么从根节点到叶子节点的路径中最多有log_2(n+1)个黑色节点(也就是树中的节点全是黑色节点)

  3. From property 2 and 4 of Red-Black trees, we can claim that the number of black nodes in a Red-Black tree is at least ⌊ n/2 ⌋ where n is the total number of nodes.

    由性质2和4可得,在红黑树中总节点数为n,那么黑色节点数至少为n/2

From the above points, we can conclude the fact that Red Black Tree with n nodes has height <= 2Log2(n+1)

从以上推论,我们可以总结出:一颗节点数为n的红黑树,height <= 2log_2(n+1)

其实这一条性质我们就能看出,红黑树的高度在[log_2n, 2log_2(n+1)],并不像AVL树一样严格保持在log_2n,也可以说是不严格平衡。而且也可以看出,在红黑树中只看黑色节点,其实是高度平衡的一颗完全二叉树

平衡操作(rebalance)

旋转

因为红黑树是一颗特殊的平衡二叉搜索树,所以当遇到不平衡的时候,当然就会像AVL树一样通过旋转节点来重新平衡。但是这里的旋转基本与AVL树的旋转相同,但是多了一个颜色的考虑。

重新上色

除开是平衡二叉搜索树,还需要满足颜色性质(比如双红不能为父子,黑色节点必须平衡),所以我们也需要在颜色不满足的时候进行recolor

那下面我们来具体看在插入和删除中的rebalance和recolor的过程

插入节点

新增一个节点的时,我们首先按照BST的规则将新增的节点插入符合BTS性质的地方(肯定都是叶子节点或者根节点),然后设置新插入的节点颜色为红色。然后进行rebalance,具体步骤如下

Let x be the newly inserted node. 设x是新插入的节点

  1. Perform standard BST insertion and make the colour of newly inserted nodes as RED.

    执行BST的标准插入,将插入的节点颜色设置为红色

  2. If x is the root, change the colour of x as BLACK (Black height of complete tree increases by 1).

    如果X是根节点,将x的颜色改为黑色(黑色完全树的高度加一)

  3. Do the following if the color of x’s parent is not BLACK and x is not the root.

    如果x的父母颜色不为黑色,而且x不是root的时候,做以下操作

    a) If x’s uncle is RED (Grandparent must have been black from property 4)

    如果x的伯父节点为红色(根据性质4,祖父节点必须为黑色)

    (i) Change the colour of parent and uncle as BLACK.

    将父节点和伯父节点改为黑色

    (ii) Colour of a grandparent as RED.

    将祖父节点改为红色 (iii) Change x = x’s grandparent, repeat steps 2 and 3 for new x.

    此时将X重新赋值为X的祖父节点,重复#2和#3

    b) If x’s uncle is BLACK, then there can be four configurations for x, x’s parent (p) and x’s grandparent (g) (This is similar to AVL Tree)

    当x的伯父节点是黑色时,对x、x的父节点(p)、x的祖父节点(g)就有四种情况

    (i) Left Left Case (p is left child of g and x is left child of p)

    LL型(p是g的左孩子(第一个L),x是p的左孩子(第二个L))。

    此时要进行对节点g进行右旋转,并且将g和p的颜色互换

    img

    (ii) Left Right Case (p is left child of g and x is the right child of p)

    LR型(p是g的左孩子(第一个L),x是p的右孩子(第二个R))

    此时需要先将p左旋变成LL型,然后再将g右旋,并且需要交换x和g的颜色

    img

    (iii) Right Right Case (Mirror of case i)

    是LL型的镜像case,作相对应的镜像操作就行了

    img

    (iv) Right Left Case (Mirror of case ii)

    是LR的镜像类型,做相对因的镜像操作就行了

    img

这里推荐一个在线创建红黑树的地方,可以清晰的看见红黑树创建的过程:www.cs.usfca.edu/~galles/vis…

总结下上面的插入过程。(个人理解:在插入的时候除开是根节点和父节点为黑色这两种简单情况,剩下的四种复杂情况其实都是根据伯父节点的颜色来讨论的,也就是说难点在伯父节点

image.png 代码实现


import java.io.*;

// considering that you know what are red-black trees 
// here is the implementation in java for insertion and traversal.
// RedBlackTree class. This class contains subclass for node
// as well as all the functionalities of RedBlackTree such as - rotations, insertion and
// inoredr traversal
public class RedBlackTree{
	public Node root;//root node
	public RedBlackTree(){
		super();
		root = null;
	}
	// node creating sublass
	class Node{
		int data;
		Node left;
		Node right;
		char colour;
		Node parent;

		Node(int data){
			super();
			this.data = data; // only including data. not key
			this.left = null; // left subtree
			this.right = null; // right subtree
			this.colour = 'R'; // colour . either 'R' or 'B'
			this.parent = null; // required at time of rechecking.
		}
	}
	// this function performs left rotation
	Node rotateLeft(Node node){
		Node x = node.right;
		Node y = x.left;
		x.left = node;
		node.right = y;
		node.parent = x; // parent resetting is also important.
		if(y!=null)
			y.parent = node;
		return(x);
	}
	//this function performs right rotation
	Node rotateRight(Node node){
		Node x = node.left;
		Node y = x.right;
		x.right = node;
		node.left = y;
		node.parent = x;
		if(y!=null)
			y.parent = node;
		return(x);
	}


	// these are some flags.
	// Respective rotations are performed during traceback.
	// rotations are done if flags are true.
	boolean ll = false;
	boolean rr = false;
	boolean lr = false;
	boolean rl = false;
	// helper function for insertion. 
    // Actually this function performs all tasks in single pass only.
	Node insertHelp(Node root, int data){
		// f is true when RED RED conflict is there.
		boolean f=false;
		
		//recursive calls to insert at proper position according to BST properties.
		if(root==null)
			return(new Node(data));
		else if(data<root.data){
			root.left = insertHelp(root.left, data);
			root.left.parent = root;
			if(root!=this.root){
				if(root.colour=='R' && root.left.colour=='R')
					f = true;
			}
		}
		else{
			root.right = insertHelp(root.right,data);
			root.right.parent = root;
			if(root!=this.root){
				if(root.colour=='R' && root.right.colour=='R')
					f = true;
			}
		// at the same time of insertion, we are also assigning parent nodes
		// also we are checking for RED RED conflicts
		}

		// now lets rotate.
		if(this.ll) {// for left rotate.
			root = rotateLeft(root);
			root.colour = 'B';
			root.left.colour = 'R';
			this.ll = false;
		}
		else if(this.rr) {// for right rotate
			root = rotateRight(root);
			root.colour = 'B';
			root.right.colour = 'R';
			this.rr = false;
		}
		else if(this.rl) {// for right and then left
			root.right = rotateRight(root.right);
			root.right.parent = root;
			root = rotateLeft(root);
			root.colour = 'B';
			root.left.colour = 'R';

			this.rl = false;
		}
		else if(this.lr) // for left and then right.
		{
			root.left = rotateLeft(root.left);
			root.left.parent = root;
			root = rotateRight(root);
			root.colour = 'B';
			root.right.colour = 'R';
			this.lr = false;
		}
		// when rotation and recolouring is done flags are reset.
		// Now lets take care of RED RED conflict
		if(f){
            // to check which child is the current node of its parent
			if(root.parent.right == root) {
                // case when parent's sibling is black
                // perform certaing rotation and recolouring. 
                // This will be done while backtracking. 
                // Hence setting up respective flags.
				if(root.parent.left==null || root.parent.left.colour=='B') {
					if(root.left!=null && root.left.colour=='R')
						this.rl = true;
					else if(root.right!=null && root.right.colour=='R')
						this.ll = true;
				}
				else {// case when parent's sibling is red
					root.parent.left.colour = 'B';
					root.colour = 'B';
					if(root.parent!=this.root)
						root.parent.colour = 'R';
				}
			}
			else{
				if(root.parent.right==null || root.parent.right.colour=='B'){
					if(root.left!=null && root.left.colour=='R')
						this.rr = true;
					else if(root.right!=null && root.right.colour=='R')
						this.lr = true;
				}
				else{
					root.parent.right.colour = 'B';
					root.colour = 'B';
					if(root.parent!=this.root)
						root.parent.colour = 'R';
				}
			}
			f = false;
		}
		return(root);
	}

	// function to insert data into tree.
	public void insert(int data){
		if(this.root==null){
			this.root = new Node(data);
			this.root.colour = 'B';
		}
		else
			this.root = insertHelp(this.root,data);
	}
	// helper function to print inorder traversal
	void inorderTraversalHelper(Node node){
		if(node!=null){
			inorderTraversalHelper(node.left);
			System.out.printf("%d ", node.data);
			inorderTraversalHelper(node.right);
		}
	}
	//function to print inorder traversal
	public void inorderTraversal(){
		inorderTraversalHelper(this.root);
	}
	// helper function to print the tree.
	void printTreeHelper(Node root, int space){
		int i;
		if(root != null){
			space = space + 10;
			printTreeHelper(root.right, space);
			System.out.printf("\n");
			for ( i = 10; i < space; i++){
				System.out.printf(" ");
			}
			System.out.printf("%d", root.data);
			System.out.printf("\n");
			printTreeHelper(root.left, space);
		}
	}
	// function to print the tree.
	public void printTree(){
		printTreeHelper(this.root, 0);
	}
	public static void main(String[] args){
		// let us try to insert some data into 
		// tree and try to visualize the tree as well as traverse.
		RedBlackTree t = new RedBlackTree();
		int[] arr = {1,4,6,3,5,7,8,2,9};
		for(int i=0;i<9;i++){
			t.insert(arr[i]);
			System.out.println();
			t.inorderTraversal();
		}
		// you can check colour of any node by with its attribute node.colour
		t.printTree();
	}
}

删除节点

有了上面插入节点的过程,删除节点就会变得轻松许多。但是跟上面不同的是,删除时的复杂情况发生在兄弟节点。

Insertion Vs Deletion: Like Insertion, recoloring and rotations are used to maintain the Red-Black properties. In the insert operation, we check the color of the uncle to decide the appropriate case. In the delete operation, we check the color of the sibling to decide the appropriate case. The main property that violates after insertion is two consecutive reds. In delete, the main violated property is, change of black height in subtrees as deletion of a black node may cause reduced black height in one root to leaf path.

插入vs删除

也是利用rotation和recoloring来maintain红黑树的性质

在插入操作,我们检查的是伯父节点的颜色来决定是哪一种插入case。但是在删除操作时,我们检查sibling节点来决定是哪一种删除case。

在插入的过程中,主要侵犯的是双红节点不能做父子的性质。而在删除中,主要会侵犯性质5,因为黑色节点的删除可能会减少root到leaf路径的长度

删除的第一步也是执行标准的BST删除节点操作,这里要说明的是,在BST删除的三种类型里

  1. 删除节点是叶子节点
  2. 删除节点有双子节点
  3. 删除节点只有一个孩子

上面的#1和#2都可以合并为#1,因为#2中实际是选取左子树的最大值(或者右子树的最小值)与删除节点交换,然后删除左子树的最大值(或者右子树的最小值),但是左子树的最大值(或者右子树的最小值)肯定是叶子节点。所以#2可以归类到#1.

所以我们删除的情形就是两种:删除叶子节点和删除有一个孩子的节点,但是由于在红黑树中,我们的叶子节点都是null(黑色),当我们删除叶子节点的时候就是用黑色的null节点来替代它。而删除只有一个孩子的节点也是用孩子节点来替代它,所以就成了处理同一种情况。

详细过程
  1. 设v是删除的节点,u是替代删除节点的孩子节点(注意当v是leaf时,u就是null而且是黑色的)

  2. 如果v和u中任意一个是red的,我们直接将u设置为黑色,然后替换v(黑色节点的高度没有改变性质5)记住这里v和u不可能同时是红色的,因为这样不满足红黑树的性质

    rbdelete11

  3. 如果v和u都是黑色的,此时我们就需要进行平衡处理了。

    这里我们定义一个概念叫double black:当且仅当删除的节点v是黑色且替代节点u也是黑色时,我们就标记替代节点u是double black。double black意味着我们要进行平衡处理(rebalance or recoloring),将double black转变为single black就是我们平衡需要做的事情。

    此时出现了double black(u节点),所以此时我们的任务就转变成了将double black convert to single black。注意如果此时v是leaf,那么u此时就是null也同样是黑色,所以也是double black。

    下图是删除导致了double black的示例: 0. \

    rbdelete12_new

    3.1. 当u节点是double black,且不是root节点时。设兄弟节点为s

    a. 如果s是黑色,且s至少有一个孩子节点是红色,我们对s执行旋转操作(因为我们v的这一边子树的黑 色节点少了一个,所以需要找兄弟节点s借一个来填上)。设s的红色孩子节点为r。根据s和r的位置 我们可以有四种情况。

    i: LL 型(s是父节点的左孩子,r是s的左孩子或者说s的两个孩子都是红色的),此时是下面图中RR型 的镜像情况。

    ii: LR型(s是父节点的左孩子,r是s的右孩子),此时是RL型情况的镜像

    iii: RR型(s是父节点的右孩子,r是s的右孩子或者s的两个孩子都是红色)

    rbdelete13New

iv: RL 型(s是父节点右孩子,r是s的左孩子)

rbdelete13New

b.当s是黑色,s的两个孩子都是黑色时,我们对s进行recoloring操作(将s涂成红色)。

如果是此时父节点是红色,那么直接将父节点recoloring成黑色就行(因为double black + red = single black)

如果此时父节点p是黑色的,将父节点p作为替换节点u,向上递归的进行#b,直到消除double black(如 下图,将s recoloring后后,是可以保证以20为根的这一个subtree满足红黑树的属性,因为此时相当于 手动在20的右子树减少了一个黑色节点。但是根20并列的子树呢?或者跟20的祖先父节点并列的其他子 树呢?所以这里相当于是一个向上传递的过程。比如此时20有一个黑色节点兄弟,而且它的孩子都是黑 色,那么此时我们也需要对20的兄弟节点recoloring,这个过程直到我们的double black消失)

rbdelete15

c. 如果s是红色,执行旋转操作,将s节点旋转到p节点,对原来的s和p进行recoloring。u此时的s(旋转之 和recoloring之后)肯定是黑色的。其实这一步主要是将#c这种类型convert to #a 或者 #b。同样的根据 s是父节点的左孩子或者右孩子,我们有两种情况

i: Left case(s是父节点p的左孩子),这是Right case的镜像

ii: Right case(s 是父节点的右孩子),将父节点p执行左旋转操作

rbdelete16

3.2. 如果u是root,使他变成single black 然后 return(黑色完全树的高度减一)

思维图总结删除操作

image.png

红黑树总结

红黑树总结

红黑树在综合了树的自我平衡,和平衡操作后,达到了比AVL 树好的效果。在红黑树式中是根据性质来保持平衡的,特别是2.4.5条性质在插入和删除的平衡操作里经常涉及到。不管是插入时还是删除时的平衡操作,最重要的是抓住满足性质这一条,特别是2.5条性质。很多种情况都是镜像情况,在插入时我们关键难点在伯父节点,删除时关键难点在兄弟节点。然后分类讨论,相信基本就没问题了。

从整体来说,红黑树的搜索、删除、插入时间复杂度都能保持在Olog(_2n) ,那怕是最糟的情况下都是如此。这一切都跟红黑树的高度有关,关于高度的证明已经在文中设计了。虽然不是严格高度平衡,但是这样的不严格却减少了平衡操作的次数和频率,使得红黑树也适合增删频繁的场景。一般来说,红黑树在插入时的平衡操作不会多于两次旋转(跟AVL树一样),但是删除时的平衡操作也不会多于3次(这就比AVL树优秀了),而这一切的额外开销就是每个节点多了一位颜色位的存储空间开销。所以还是非常划算的。关键是不管何种数据操作的最糟情况下,时间复杂度都能保持住Olog(_2n)。这就很了不起了!Well done RB Tree!

B树

B树其实是二叉树的变形,或者说BST是阶数m为2的B树。1972年在波音研究实验室(Boeing Research Labs)工作时发明了B 树。而且B树也是自平衡树。自平衡二叉查找树不同,B树适用于读写相对大的数据块的存储系统,例如磁盘。B树减少定位记录时所经历的中间过程,从而加快存取速度。B树这种数据结构可以用来描述外部存储。数据结构常被应用在数据库文件系统的实现上。

B树的性质

  1. 每个节点的关键字个数d: t - 1 <= d <= 2t-1(m称为B树最小度,也就是一个节点最少拥有的子节点。通常t的取值取决于disk block size)
  2. 根节点可以只有1个关键字
  3. 每个节点的关键字都是从小到大的有序排列
  4. 一个节点中两个关键字的值区间就是两个关键之间所有孩子节点中的关键字的取值区间
  5. 所有的叶子节点都在同一层,或者说根节点到所有叶子结点的路径一样长

一般我们在介绍B树的时候,会以2-3树来举例说明。也就是最小度t=2的B树,而我们的BST就是t=1的B树。

image.png

上图就是2-3树的例子。图中的ab是父节点的两个键值(key),然后相对的value也是存储在里面的,通过相对应的key去获取value(key-value键值对称为一条record),可以看到父节点有3个孩子节点,其中p的键值小于a,q是介于a,b键值之间的,r的键值大于b。尽管在实际应用中,2-3树(结束m=3的B树)并不常见,因为实际中B树的阶m一般大于100.

img

我们来看GG(geeksforgeeks.org/introduction-of-b-tree-2/)对B tree的介绍

B-Tree is a self-balancing search tree. In most of the other self-balancing search trees (like AVL and Red-Black Trees), it is assumed that everything is in main memory. To understand the use of B-Trees, we must think of the huge amount of data that cannot fit in main memory. When the number of keys is high, the data is read from disk in the form of blocks. Disk access time is very high compared to the main memory access time. The main idea of using B-Trees is to reduce the number of disk accesses. Most of the tree operations (search, insert, delete, max, min, ..etc ) require O(h) disk accesses where h is the height of the tree. B-tree is a fat tree. The height of B-Trees is kept low by putting maximum possible keys in a B-Tree node. Generally, the B-Tree node size is kept equal to the disk block size. Since the height of the B-tree is low so total disk accesses for most of the operations are reduced significantly compared to balanced Binary Search Trees like AVL Tree, Red-Black Tree, ..etc.

B树是一颗自平衡树。大多的自平衡树都预先假设所有的数据都在主存。为了理解B tree,我们必须设想数据量非常巨大以至于主存根本装不下。当键的数量大,数据以块状的形式存储在硬盘上。此时硬盘的访问时间比起主存的访问时间是非常大的。大部分树的操作要求O(h)的时间,h是树的高度。B树是一颗肥树。B树通过向节点中放入最多可能数量的key的方式来保持较低的树高,所以此时硬盘访问的操作相对于用其他的树结构(BST,AVL)都显著的被减少了

所以总的来说,B树用在大量数据的检索场景里。这是因为,B树在每个节点中采取了放多个键值的方式,同时维持了自我平衡,相较于其他自平衡树降低了树的高度,虽然此时的各个数据操作的时间复杂度还是o(logn)但是这里的底数将不再是2,而是一个比较大的数(自己认为跟阶数m有关),所以还是比其他数据结构快不少。

对于自平衡树,最重要的数据操作和最难的数据操作就是插入和删除,当然删除要比插入更复杂些。那么我们就来看看B树的删除和插入是怎么实现的

B树的结点插入操作

内容引用自geeksforgeeks: www.geeksforgeeks.org/insert-oper…

A new key is always inserted at the leaf node. Let the key to be inserted be k. Like BST, we start from the root and traverse down till we reach a leaf node. Once we reach a leaf node, we insert the key in that leaf node. Unlike BSTs, we have a predefined range on the number of keys that a node can contain. So before inserting a key to the node, we make sure that the node has extra space.

一个新的key总是被插入在叶子结点,设插入的key为k。类似BST, 我们从root结点开始向下遍历寻找到合适的插入位置。但是不同于BST的是,我们的每个结点有一个提前定义好的key的个数,所以当我们插入之前,我们需要确定叶子节点有容纳k的空间。

How to make sure that a node has space available for a key before the key is inserted? We use an operation called splitChild() that is used to split a child of a node. See the following diagram to understand split. In the following diagram, child y of x is being split into two nodes y and z. Note that the splitChild operation moves a key up and this is the reason B-Trees grow up, unlike BSTs which grow down.

如何确保节点中有容纳k的位置呢?我们用一个叫splitChild()的操作,看下面的的示意图明白如何执行这个操作的。x的child结点y被拆分为两个结点y和z。注意splitChild() 操作将一个key向上移动,这也是B 树向上增长的原因,而BST树是向下增长

BTreeSplit

As discussed above, to insert a new key, we go down from root to leaf. Before traversing down to a node, we first check if the node is full. If the node is full, we split it to create space. Following is the complete algorithm.

正如上面讨论的,插入一个新结点,我们需要从root开始向下遍历到叶子结点。在向下遍历到任意一个结点之前,我们首先检查即将遍历的结点是不是已经full了(key值的个数满了),如果是,我们需要差分它来创造新的空间给我们的k。

算法流程
  1. 初始化根节点为x

  2. 如果x不是叶子结点,就执行以下操作

    a. 找到接下来会被便利的结点y(x的孩子结点)

    b. 如果y不是full的,将x重新指向y

    c. 如果y是full的。就对y执行splitChild()操作,然后将x重新指向被差分的y的两个部分。如果k比y的中间key小,x就重新指向y被差分后的前半部分,否则重新指向后半部分。(也就是说当我们在拆分y的时候,从y中移动了一个key到父节点x中)

  3. 当x是叶子节点时,#2停止。此时x肯定有容纳k的额外空间,因为我们在从上到下的过程中已经把所有的full的结点都拆分了。

上述的算法其实是一种主动行为,也就是在插入之前,准确些是在向下遍历一个节点之前就将已经满的节点给拆分了。如果我们是在找到删除节点后才发现插入的位置是full的需要split,那么我们很可能也需要向上递归的split其他节点直到root节点(当root到插入节点的叶子节点的路径中每个都是full的时候,所以当我们来到插入的叶子节点,我们发现它是满的,于是拆分它,将mid key 移动上层节点,就会导致上层节点也需要拆分,直到root节点)。This cascading effect never happens in this proactive insertion algorithm). There is a disadvantage of this proactive insertion though, we may do unnecessary splits.

示例

我们建立一颗B树,并且向其中依次加入10, 20, 30, 40, 50, 60, 70, 80 和 90

Btree1

BTree2Ins

BTreeIns3

BTreeIns4

BTreeIns6

B树的删除

引用自:geeksforgeeks.org/delete-operation-in-b-tree/

Deletion from a B-tree is more complicated than insertion, because we can delete a key from any node-not just a leaf—and when we delete a key from an internal node, we will have to rearrange the node’s children.

B树的节点删除要比插入要复杂些,因为我们可以从任意节点删除,包括叶子节点。当我们在删除内部非叶子节点的时候,我们将会需要重新排列删除节点的孩子。

As in insertion, we must make sure the deletion doesn’t violate the B-tree properties. Just as we had to ensure that a node didn’t get too big due to insertion, we must ensure that a node doesn’t get too small during deletion (except that the root is allowed to have fewer than the minimum number t-1 of keys). Just as a simple insertion algorithm might have to back up if a node on the path to where the key was to be inserted was full, a simple approach to deletion might have to back up if a node (other than the root) along the path to where the key is to be deleted has the minimum number of keys.

正如在插入时采取的策略,删除后我们也必须保证B树的性质得到保证。就像在插入时我们保证插入节点的key的数量不变得太大(超过最大限制),删除时我们必须保证删除节点的key之后,节点中的key的数量不会变得过少(除非根节点,因为根节点可以只有一个key)。在插入时,如果到达插入节点位置的路径中有full的节点,我们需要进行备份操作(分裂),删除时,一个简单的做法是如果到删除节点的路径中有节点的key的数量是最小值(除开根节点),那么我们也要进行备份操作(合并)。

The deletion procedure deletes the key k from the subtree rooted at x. This procedure guarantees that whenever it calls itself recursively on a node x, the number of keys in x is at least the minimum degree t . Note that this condition requires one more key than the minimum required by the usual B-tree conditions, so that sometimes a key may have to be moved into a child node before recursion descends to that child. This strengthened condition allows us to delete a key from the tree in one downward pass without having to “back up” (with one exception, which we’ll explain). You should interpret the following specification for deletion from a B-tree with the understanding that if the root node x ever becomes an internal node having no keys (this situation can occur in cases 2c and 3b then we delete x, and x’s only child x.c1 becomes the new root of the tree, decreasing the height of the tree by one and preserving the property that the root of the tree contains at least one key (unless the tree is empty).

从以x为根节点的子树中删除关键字k为索引的记录的删除过程,这个过程必须保证,无论何时它递归的调用自己时,关键子key的数量至少是t(内部节点含键值数量的下限+1),注意这个条件要求的是比节点含有键值数量的最小值加1,这样做是为了在有时候一个关键字在向下对孩子节点递归调用之前可能会被移动到孩子节点中。这个思路允许我们在删除一个key时,能够只从上到下遍历1次,避免了向上递归(有一个例外情景,我们待会会谈到)。在理解下面删除的具体操作时,你应该要明白当节点x是root成为没有key的内部节点,我们删除x,然后x的唯一孩子节点x.c1成为树的新root,并且整棵树的高度减一,然后保证root节点有至少1个key(除非树是空的)

算法流程

1. 如果k是在节点x中,并且节点x是叶子节点,直接从x节点中删除k

2. 如果k是在x节点中,并且x节点是内部节点(非叶子节点),做以下操作:

a) 如果k的前驱节点y(注意这里的y也是x的子节点,只不过是临近k的子节点,y的所有键值都小于k)有至少t个keys,我们就找到k的前驱替代键值k0(y节点中最大的一个键值),然后向下递归的删除k0(最后删除的k0一定是位于叶子节点),然后用k0来替代k。(这样我们就可以相当于是用一次向下的递归找到并且删除了k0)

**b)**如果y的键值数量少于t,对称地,我们检查k的后继节点(注意这里的x也是x的子节点,只不过是临近k的子节点,z的所有键值都大于k)有至少t个keys,我们就找到k的后继替代键值k0(z节点中最小的一个键值),然后向下递归的删除k0(最后删除的k0一定是位于叶子节点),然后用k0来替代k。(这样我们就可以相当于是用一次向下的递归找到并且删除了k0)

c) 否则,如果z和y都只有t-1个keys,将k和所有的z中的key都merge到y中,此时x就失去了k以及指向z的引用,y此时就有2t-1个keys。然后就可以free z,递归的从y中删除k(相当于此时y成为了x,然后从第一步开始重复)

3. 如果k不在当前的内部节点x,我们就需要决定k应该是在x的哪一棵子树中,我们设这个子树是x.c(i)。如果x.c(i)只有t-1个keys,我们就需要执行3a或者3b来保证我们在下降到子节点之前,子节点至少有t个keys。然后将x设为合适的孩子节点x.c(i),递归下去

**a)** 如果x.c(i)只有t-1个keys,但是它的一个兄弟节点至少有t个keys,此时我们就向兄弟节点借一个。从x中移动一个key到x.c(i),从x.c(i)的任意兄弟节点向上移动一个key到x中,然后将兄弟节点中移动的key的对应的子节点移动到x.c(i)中。(这一步相当于是旋转,通过旋转向x.c(i)节点中增加一个key)

b) 如果x.c(i)和它的兄弟节点都只有t-1个keys,我们就需要merge了。将x.c(i)和它其中一个兄弟节点合并,从x中向下移动一个key到合并后的节点作为中间节点,然后让这个新节点成为x的子节点

因为B树中大部分keys都在叶子节点中,删除操作大部分都是从叶子节点中删除。上述递归删除的过程可以只用一次向下的遍历就删除节点(因为我们在向下的过程中,进入一个节点之前我们就会确保这个节点的keys数量至少为t,所以当我们在删除位于叶子节点的key时,我们已经确保叶子节点的key数量大于等于t,所以可以直接删除)。当我们删除的key是位于内部节点时(非叶子节点),我们就可能需要先向下的遍历到叶子节点(找到k的predecessor或者successor并且先保存在一个临时变量,然后从叶子节点中删除它)然后再返回到删除的key位于的节点,将该key的predecessor或者successor跟它交换(case 2b 和2b)

示例

BTreeDelet1

BTreeDelet2

B树删除和插入操作总结

B树的删除相较于插入是要复杂许多。

在插入时我们因为总是在叶子节点插入,所以我们只需要保证在我们到达合适的叶子节点之前,从root到插入位置的这条路径上所有的节点包括插入的节点的key数量都没有full(如果full了我们就执行分裂操作),那么自然我们在到达插入位置之前,插入节点也不是full的,我们就可以直接插入了。

而删除时,我们不仅需要从叶子节点中删除key(大多数情况),也需要从非叶子节点中删除key。其实采用的思想跟插入异曲同工。在删除时,我们需要找到删除的key所在的节点,此时我们在向下遍历的过程中,在进入一个孩子节点之前,我们就检查这个孩子节点的key数量是不是满足最小数量加1,如果满足,我们就继续向下直到找到,如果不满足我们就通过类似旋转或者合并操作,来使得这个节点中的key数量达到最小数量加一或者以上。那么这样就能够确保我们在遍历节点之前就已经确定节点的key数量至少为t。当删除的key在叶子节点中,我们就直接删除(进入叶子节点之前就已经确保叶子节点的key数量至少为t),如果不是叶子节点,我们就需要通过向下继续遍历找到k的predecessor或者successor来替代k,然后删除predecessor或者successor。可以这么理解,删除非叶子节点中的key,我们是转换成找到k的predecessor或者successor,而predecessor或者successor一定是叶子节点,所以就是删除叶子节点,并且多了一步就是删除了predecessor或者successor之后,需要回溯到k所在的节点,用predecessor或者successor去替换k。

这里推荐一个网站,可以将B树的各种操作图像化:www.cs.usfca.edu/~galles/vis…

上述理论、代码、博文部分思路和内容取自互联网,仅是作者自己学习所需。如果表述有错误,还请友善探讨。