树:基本树形

树:基本树形
树拓展了那些一维顺序结构(链表,栈,队列等),链表的插入删除速度快,但是查找速度慢,数组查找速度快,但是插入删除速度慢,为了能够插入、删除、遍历、查找速度相对较快,就使用了树结构。树多用与查找和索引,比如内存管理中的堆heap就是一个不存储边的完全二叉树。本文不对树的定义进行阐述,这些在教科书中都有,主要是对树的使用发展进行一个简单的梳理。全部代码放在我的github

二叉树

树可以分叉,最常见的就是二叉,当然也有N叉树。这是因为二叉简单,更容易被理解。一般的搜索问题,二叉树就能将之搞定,其中满二叉树和完全二叉树,就不细说,满二叉树就是树的每一层都有最多的节点,节点都是满的,如图1,完全二叉树就是节点从左往右的顺序排列的,并且仅仅只有最后一层节点可以不满,如图2。二叉树也有很多的操作,比如中序、前序、后序遍历等等。


图1. 满二叉树

图2. 完全二叉树

但是无特征的树在工业上没啥用,所以就出现了一些具有特殊特征的二叉树。首先是节点按顺序排序的二叉排序。

二叉排序树(BST)

二叉排序树(BST),左子树上所有节点小于根节点的数,右子树上所有节点大于根节点的数,并且左右子树也是如此。如图3所示。


图3. BST树

可以从图中看出,它的查找过程和二分法类似,同样都是分治法,不过二叉树的查找过程更像是递归操作,小于它就进入左子树,大于它就进入右子树。递归查找代码如下

// 查找BST树中的结点,递归写法
private TreeNode searchValue(TreeNode node, int value) {	
    if(node == null || node.node_value == value) {
	return node;
    }	
    if(node.node_value < value) {
	return searchValue(node.right_node,value);
    }else{
	return searchValue(node.left_node,value);
    }
}

非递归写法,使用循环来不断地进入左右子树来查找。

// 查找BST树中的结点,非递归写法
private TreeNode searchValue(TreeNode node, int value) {
    while(node != null) {
	if(node.node_value == value) {
	    return node;
	}		
	if(node.node_value < value) {
	    node = node.right_node;
	}else if(node.node_value > value) {
	    node = node.left_node;
	}
    }
    return node;
}

插入的过程也只是需要查找位置所在就行,然后直接插入。这其中也是需要递归的思想,不断地进入左右子树。

// 往BST树中插入节点 
private TreeNode insertNode(TreeNode node, int data) {
    if(node == null) {
	// 这边不是直接赋值的,而是初始化
	node = new TreeNode(data);
    }else if(node.node_value == data){
	System.out.println("已经有相同的节点存在");
    }else if(node.node_value > data) {
	node.left_node = insertNode(node.left_node,data);
    }else if(node.node_value < data) {
	node.right_node = insertNode(node.right_node,data);
    }
    return node;
}

构造过程也比较简单,更多的都是使用上面插入,只要将元素依次插入就行。

// 构造BST树 
private TreeNode constructTree(int[] data) {
    TreeNode rootNode = new TreeNode(data[0]);
    int start_index = 1;
		
    while(start_index < data.length) {
	System.out.println(start_index);
        insertNode(rootNode,data[start_index]);
        start_index++;
    }	
    return rootNode;
}

删除的过程分为三种情况:

  1. 如果被删除的节点是叶子节点,那么直接删除。
  2. 如果这个节点上只有一个左子树或者右子树,那么用它的子树来代替这个根节点。
  3. 如果这个节点上既有左子树也有右子树,那么令节点的直接后继节点或者直接前驱节点来代替这个节点的位置,然后删除这个直接后继或者直接前驱,这样就能转换成其他的情况来处理了。不过做题的时候,我经常直接找和删除节点差不多大的节点就行。

图4. 删除情况1

图5. 删除情况2

图6. 删除情况3

删除的代码如下,这其中只要考虑三种情况就行了。

// 删除BST树中的节点
// 1. 如果是叶节点,那么直接变成null
// 2. 如果只有左子树或者右子树,那么直接用左子树或者右子树代替
// 3. 如果左右都有,那么先从右子树中找出直接后继,也就是右子树当中最小的值,
//              或者先从右子树中找出直接前驱,也就是左子树中最大的值。
private void deleteNode(TreeNode rootNode, int delete_value) {
    TreeNode deleteNode = searchValue(rootNode,delete_value);
    if(deleteNode == null) {
	System.out.println("树中没有该节点的存在");
    }else {
	if (deleteNode.left_node == null && deleteNode.right_node == null) {
	    deleteNode = null;
	} else if (deleteNode.left_node != null && deleteNode.right_node != null) {
	    int min_value = findMin(deleteNode.right_node);
	    deleteNode.node_value = min_value;
	    deleteNode(deleteNode.right_node, min_value);
	} else {
	    deleteNode = deleteNode.left_node == null ? deleteNode.right_node : deleteNode.left_node;
	}
    }
}

从图中可以看出它的查找长度与树的高度有关,如果树很高,查找效率就很低。并且BST还有一个问题,就是相同关键字,如果插入的顺序不同可能生成不同的二叉排序,比如一边倒


图7. 一边倒

这种极端的一边倒情况,使得树结构重新变成了链表,查找效率变得更低了,这明显不是我们想要的,要有没有办法使得插入顺序无关呢?那就是需要对树进行平衡操作。

平衡二叉树(AVL)

平衡二叉树(AVL),为了上面一边倒的情况出现,因此规定插入和删除二叉树节点的时候,左右子树的高度差不超过1,这个高度差就是平衡因子。AVL的查找过程与BST的查找相同,只不过AVL因为有了平衡因子,这使得含有n的AVL的最大深度就是\(O(log_2n)\),而查找长度是不会超过最大深度的,因此AVL的平均查找长度是\(O(log_2n)\)

AVL所特有的就是平衡旋转操作,平衡旋转操作一共分为四种,分别是LL平衡旋转(右单旋转)、RR平衡旋转(左单旋转)、LR平衡旋转(先左后右旋转)、RL平衡旋转(先右后左旋转)。上面图3中的BST是可以通过LR平衡旋转得到AVL,如图8


图8. AVL树

并且在树节点的写法也是和BST不同,多了节点高度,用来查看是否平衡的。

LL平衡旋转

当插入位置是左子树的左节点而导致的不平衡,就需要进行LL平衡旋转。就是将根节点的左节点作为新的根节点,原先的根节点作为新的右节点。如下图所示


图9. LL旋转

代码如下所示

// LL旋转
private AVLTreeNode LLrotate(AVLTreeNode rootNode) {
    AVLTreeNode leftNode = rootNode.left_node;
    rootNode.left_node = leftNode.right_node;
    leftNode.right_node = rootNode;
		
    // 更新节点的高度
    rootNode.height = Math.max(getHeight(rootNode.left_node), getHeight(rootNode.right_node)) + 1;
    leftNode.height = Math.max(getHeight(leftNode.left_node), getHeight(leftNode.right_node)) + 1;
		
    return leftNode;
}

RR平衡旋转

当插入位置是右子树的右节点而导致的不平衡,就需要进行RR平衡旋转。就是将根节点的右节点作为新的根节点,原先的根节点作为新的左节点。如下图所示


图10. RR旋转

代码如下所示

// RR旋转 
private AVLTreeNode RRrotate(AVLTreeNode rootNode) {
    AVLTreeNode rightNode = rootNode.right_node;
    rootNode.right_node = rightNode.left_node;
    rightNode.left_node = rootNode;
		
    // 更新节点的高度
    rootNode.height = Math.max(getHeight(rootNode.left_node), getHeight(rootNode.right_node)) + 1;
    rightNode.height = Math.max(getHeight(rightNode.left_node), getHeight(rightNode.right_node)) + 1;
		
    return rightNode;
}

LR平衡旋转

当插入位置是左子树的右节点而导致了不平衡,就需要进行LR平衡旋转。这个就是我们将图3的树变成AVL的平衡旋转操作,就是将左子树的右节点4先左旋转到2,再将4旋转到根节点,成为新的根节点,原来的根节点就变成了右子树。


图11. LR旋转

这里的代码就比较简单了,只要运用上面的操作就行了。

// LR旋转
private AVLTreeNode LRrotate(AVLTreeNode rootNode) {
    // 这里确实就是先将左子树进行RR,之后再LL,不知道怎么起名的
    rootNode.left_node = RRrotate(rootNode.left_node);
    return LLrotate(rootNode);
}

RL平衡旋转

当插入位置是右子树的左节点而导致的不平衡,就需要进行RL平衡旋转,这与LR类似,只不过方向不同。就是将右子树的左节点先右旋转到节点6,之后再左旋转到根节点,成为新的根节点,原先的根节点2变成了左子树。


图12. RL旋转

代码如下

// RL旋转
private AVLTreeNode RLrotate(AVLTreeNode rootNode) {
    rootNode.right_node = LLrotate(rootNode.right_node);
    return RRrotate(rootNode);
}

对于AVL树的操作,我只想谈及插入和删除,因为其他的都与BST差不多写法,并且插入和删除也是差不多,只不过完成操作之后再检查是否满足平衡条件。

插入,和BST不同的就是插入完成之后检查是否平衡,还有一个要更新节点的高度。

private AVLTreeNode insertNode(AVLTreeNode rootNode, int value) {
		
    if(rootNode != null) {
	if(rootNode.node_value > value) {
	    rootNode.left_node = insertNode(rootNode.left_node, value);
	    // 插入的是左子树,那么检查左子树就好,如果不平衡,需要调整
	    if(getHeight(rootNode.left_node) - getHeight(rootNode.right_node) > 1) {
	        // 插入的位置是左子树的左节点,使用LL旋转
		if(value < rootNode.left_node.node_value) {
		    rootNode = LLrotate(rootNode);
		}else {
		    // 如果插入的位置是左子树的右节点,那么LR旋转
		    rootNode = LRrotate(rootNode);
		}
	    }
	}else if(rootNode.node_value < value) {
	    rootNode.right_node = insertNode(rootNode.right_node, value);
	    // 不平衡的话,需要进行调整
	    if(getHeight(rootNode.right_node) - getHeight(rootNode.left_node) > 1) {
		// 插入的位置是右子树的右节点的话,就需要进行RR
		if(value > rootNode.right_node.node_value) {
		    rootNode = RRrotate(rootNode);
		}else {
		    // 如果是右子树的左节点,就需要进行RL
		    rootNode = RLrotate(rootNode);
		}
	    }
	}else {
	    System.out.println("不能插入,已经有了相同节点");
	}
    }else {
	rootNode = new AVLTreeNode(value);			
    }
		
    // 更新节点高度
    rootNode.height = Math.max(getHeight(rootNode.left_node), getHeight(rootNode.right_node)) + 1;
		
    return rootNode;
}

删除,不同和BST一样,先进行查找,因为时刻要调整,不过不需要更新高度,因为旋转操作会更新节点的高度。

// 这个和之前不能一样了,直接使用search方法找,因为它需要时刻调整
private AVLTreeNode deleteNode(AVLTreeNode avl_tree, int value) {
    if(avl_tree == null) {
	System.out.println("树中不包含这个值");
    }else {
	if(avl_tree.node_value > value) {
	    avl_tree.left_node = deleteNode(avl_tree.left_node,value);
	    // 如果不平衡的话,需要调整
	    if(getHeight(avl_tree.right_node) - getHeight(avl_tree.left_node) > 1) {
		if(getHeight(avl_tree.right_node.right_node) > getHeight(avl_tree.right_node.left_node)) {
		    avl_tree = RRrotate(avl_tree);
		}else {
		    avl_tree = RLrotate(avl_tree);
		}
	    }
	}else if(avl_tree.node_value < value) {
	    avl_tree.right_node = deleteNode(avl_tree.right_node,value);
				
	    if(getHeight(avl_tree.left_node) - getHeight(avl_tree.right_node) > 1) {
		if(getHeight(avl_tree.left_node.left_node) > getHeight(avl_tree.left_node.right_node)) {
		    avl_tree = LLrotate(avl_tree);
		}else {
		    avl_tree = LRrotate(avl_tree);
		}
	    }
	}else {
	    // 等于这个值,这个和BST树的操作是一样的,就是分三种情况,有没有左右子树的
	    if(avl_tree.left_node != null && avl_tree.right_node != null) {
		int min_value = findMin(avl_tree.right_node);
		avl_tree.node_value = min_value;
		avl_tree.right_node = deleteNode(avl_tree.right_node, min_value);
	    }else {
		// 只要这么一句话就行
		avl_tree = (avl_tree.left_node != null) ? avl_tree.left_node:avl_tree.right_node; 
	    }
	}
    }
    return avl_tree;
}

AVL虽然使得树不会出现一边倒的情况,但是每个节点的左子树和右子树高度差至多等于1,这个平衡条件过于严格,这会导致每次插入和删除的时候,都会破坏这个规则,之后就需要旋转来平衡此树,这会导致开销过大。为了能够对平衡和效率之中进行折中,需要一种不严格的平衡树,也就是红黑树。

红黑树(RBT)

红黑树(RBT),一种不严格的平衡二叉查找树。AVL树比RBT更加平衡,但是开销大。大部分自平衡BST都是红黑树实现的,java中的treeSet和treeMap也是红黑树。RBT与AVL树不同,对高度差有着严格的要求,它的平衡条件最主要的是来自于RBT的一条要求:从任意一个节点到每个叶节点的所有路径都包含相同数量的黑色节点,简称为黑高,也就是黑色节点代表着高度,所以在插入的时候,插入节点都是红色节点。将之前那个BST树变成红黑树。


图13. 红黑树

可以从图中看出根的左子树和右子树的高并不能满足AVL的要求,但是RBT只要求黑高即可。可以从种看出RBT其他几个要求:

  1. 根节点是黑色。
  2. 节点是黑色或者红色。
  3. 不能有连续的红色节点。
  4. 任意节点到叶子节点的路径都包含相同数量的黑色节点。

问题是这几个条件就能让RBT保持平衡吗?那么依据是什么?那要看RBT是从何而来的了,RBT是根据2-3-4树提出来的,2-3-4树很像一个小型的B树,只不过2-3-4树的每个节点最多只能有三个数据,B树可以放一磁盘页数据,并且它的删除和插入也和B树一样,具体操作会在B树中分析。上面的BST树可以变成2-3-4树。


图14. 2-3-4树

那么如何将2-3-4树变成所需要的红黑树中,就是将其中有俩个数据和三个数据的节点对应转换为红黑树节点,其中的对应关系也很简单,下图


图15. 对应关系

本文只讨论左倾红黑树,也就是红色节点在左边,可能有人问上面的2-3-4树如果按照下面转换规则来看的话,只有节点3是红色的啊,但是之前红黑树中节点2也是红色的,这样不对啊!这个问题还得红黑树的插入操作,红黑树的插入可以类比2-3-4树,插入2-节点,3-节点,4-节点(这里的节点是指子节点,2-节点就是有俩个子节点,也就是一个数据),注意的是旋转中节点就是原来节点的颜色。

2-节点直接插进去,不过我们是左倾红黑树,要有一个旋转过程,将红色节点放在左边。


图16. RR旋转
// 左旋转,RR旋转
private RBTNode RRrotate(RBTNode node) {
    RBTNode rightNode = node.right_node;
    node.right_node = rightNode.left_node;
    rightNode.left_node = node;
		
    // 变色
    rightNode.color_node = !rightNode.color_node;
    node.color_node = !node.color_node;
		
    return rightNode;
}

3-节点在2-3-4树也是直接插进去,但是在红黑树中就多了一个步骤,就是判断是否破坏了性质,也就是俩个连续红色节点,那么就需要旋转变色操作。


图17. LL旋转
// 右旋转, LL旋转
private RBTNode LLrotate(RBTNode node) {
    RBTNode leftNode = node.left_node;
    node.left_node = leftNode.right_node;
    leftNode.right_node = node;
		
    // 变色
    leftNode.color_node = !leftNode.color_node;
    node.color_node = !node.color_node;
		
    return leftNode;
}

4-节点是需要分裂操作,在红黑树中也是如此,所以代码实现中,一般都是先将4-节点进行变色将之变成2-节点或者3-节点,那么再插入之后就不会碰到4-节点分裂操作了。


图18. 分裂变色
// 分裂操作,也就是变色
private RBTNode splitFour(RBTNode node) {
    node.color_node = !node.color_node;
    node.left_node.color_node = !node.left_node.color_node;
    node.right_node.color_node = !node.right_node.color_node;
    return node;
}

代码实现中,可以先保证节点不会是4-节点,那么增加的时候就不会有分裂这种操作,都是直接插入。在本文的红黑树插入操作中,如果有4-节点,那么先对4-节点进行变色操作,之后再依次查找插入的位置,插入之后,再进行平衡调整,而插入的平衡调整也很简单,就是上面的俩种情况,也就是俩种操作,一种是LL旋转,一种是RR旋转。

private RBTNode insertNode(RBTNode node, int value) {
		
    if(node != null) {
    // 判断需不需要变色,有4-节点就变色
	if(isRed(node.left_node) && isRed(node.right_node)) {
	    node = splitFour(node);
	}
			
	if(value < node.node_value) {
	    node.left_node = insertNode(node.left_node, value);
	}else if(value > node.node_value) {
	    node.right_node = insertNode(node.right_node, value);
	}else {
	    System.out.println("树中已经有相同的节点");
	}
    }else {
	node = new RBTNode(value);
    }
		
    // 只需要判断俩种情况就行,看是否要左旋转还是左右旋转
    if(!isRed(node.left_node) && isRed(node.right_node)) node = RRrotate(node);
    if(isRed(node.left_node) && isRed(node.left_node.left_node)) node = LLrotate(node);
		
    return node;
}

删除操作比插入操作难很多,因为考虑的情况很多,删除操作也可以先从2-3-4树中进行寻找,删除节点,也会有几种情况,就是在2-节点、3-节点、4-节点中删除,

  1. 3-节点、4-节点中的删除很简单,就是直接删掉,红黑树中对应的就是删除红色节点,也是直接删除。
  2. 删除2-节点,在红黑树中,就是删除黑色节点,需要进行讨论情况,2-3-4树中就是要看兄弟是否可借,兄弟可借,那么删除节点之后旋转就行,如果兄弟不可借,那么需要进行合并操作。

在代码实现中,可以类似于插入的时候,提前分解4-节点,删除的时候提前改造2-节点,将之变成3-节点或者4-节点,这个根据兄弟是否可借和查找的值在左子树还是右子树分为四种情况:

  1. 兄弟不可借,对应的就是,如果查找的值在红黑树的左子树中,左节点是2-节点,右节点的子节点不是红色,或者没有节点。直接对节点变色,成为一个4-节点。

图19. 查找左子树,兄弟不可借
  1. 兄弟可借,对应的就是,该节点是红节点,如果查找的值在红黑树的左子树中,左节点是2-节点,右节点的子节点是红色。需要先进行变色,之后RL旋转,之后再进行变色。代码实现中可以将1,2俩个操作放在一起。

图20. 查找左子树,兄弟可借
// 变色
private RBTNode colorFilp(RBTNode rootNode) {
    rootNode.color_node = !rootNode.color_node;
    rootNode.left_node.color_node = !rootNode.left_node.color_node;
    rootNode.right_node.color_node = !rootNode.right_node.color_node;
    return rootNode;
}

private RBTNode moveRedLeft(RBTNode rootNode) {
    rootNode = colorFilp(rootNode);
    // 判断是否兄弟可借
    if(isRed(rootNode.right_node.left_node)) {
	rootNode = RLrotate(rootNode);
	rootNode = colorFilp(rootNode);
    }
		
    return rootNode;
}
  1. 兄弟不可借,对应的就是,如果查找的值在红黑树的右子树中,右节点是2-节点,左节点的子节点不是红色,或者没有节点。直接对节点变色,成为一个4-节点。

图21. 查找右子树,兄弟不可借
  1. 兄弟可借,对应的就是,该节点是红节点,如果查找的值在红黑树的右子树中,右节点是2-节点,左节点的子节点是红色。需要先进行变色,之后LL旋转,之后再进行变色。

图22. 查找右子树,兄弟可借
// 变色
private RBTNode colorFilp(RBTNode rootNode) {
    rootNode.color_node = !rootNode.color_node;
    rootNode.left_node.color_node = !rootNode.left_node.color_node;
    rootNode.right_node.color_node = !rootNode.right_node.color_node;
    return rootNode;
}
	
private RBTNode moveRedRight(RBTNode rootNode) {
    rootNode = colorFilp(rootNode);
    if(isRed(rootNode.left_node.left_node)) {
	rootNode = LLrotate(rootNode);
	rootNode = colorFilp(rootNode);
    }
    return rootNode;
}

有了这些操作之后,我们可以对删除操作进行实现,要特别注意删除中出现的各种情况,特别是去右子树的时候,也要将红色节点转移到右边,一定要转移,不然上面的操作不成立(操作都是基于根节点就是红节点)。

// 删除节点
private RBTNode deleteNode(RBTNode rootNode, int value) {
    if(searchValue(rootNode, value) == null) {
	System.out.println("树中不包含此节点");
	return rootNode;
    }
		
    // 因为处理的条件就是顶点是红节点,因而如果是2-节点,并且先有红节点,才能变色。
    if(!isRed(rootNode.left_node) && !isRed(rootNode.right_node)) {
	rootNode.color_node = true;
    }
    rootNode = deleteProcess(rootNode,value);
    if(rootNode.color_node) {
	rootNode.color_node = false;
    }
    return rootNode;
}
    
private RBTNode deleteProcess(RBTNode rootNode, int value) {
		
    if(rootNode.node_value > value) {
	// 检查查找位置在左边的2-节点
	if(!isRed(rootNode.left_node) && !isRed(rootNode.left_node.left_node)) {
	    rootNode = moveRedLeft(rootNode);
	}
	rootNode.left_node = deleteProcess(rootNode.left_node, value);
    }else if(rootNode.node_value < value) {
	// 首先先把3-节点的红色节点移到右边去,这是因为如果不移动的话,就会出现都是黑色的情况
	// 4-节点不需要移动
	if(isRed(rootNode.left_node) && !isRed(rootNode.right_node)) {
	    rootNode = LLrotate(rootNode);
	}
			
	if(!isRed(rootNode.right_node) && !isRed(rootNode.right_node.left_node)) {
	    rootNode = moveRedRight(rootNode);
	}
	rootNode.right_node = deleteProcess(rootNode.right_node, value);
    }else {
	// 因为它的节点有颜色,并不能简单的代替,还要考虑颜色的变化
	// 1. 节点是叶节点,直接删除
	// 2. 节点有左节点,直接用左节点代替删除,代码中就是LL旋转
	// 3. 节点有右节点,选出最小的代替删除节点
	// 4. 节点有左节点和右节点,从有节点中选出最小的代替删除节点。
			
	// 先将红色节点进行右移,然后统一处理右边即可
	if(isRed(rootNode.left_node) && !isRed(rootNode.right_node)) {
	    rootNode = LLrotate(rootNode);
	}
			
	// 情况1和情况2的结合
	if(rootNode.node_value == value && rootNode.right_node == null) {
	    return null;
	}
			
	// 依旧需要判断是不是2-节点,因为不能从2-节点中删除
	if(!isRed(rootNode.left_node) && !isRed(rootNode.right_node)) {
	    rootNode = moveRedRight(rootNode);
	}
			
	// 处理第三和第四种情况
	if(rootNode.node_value == value) {
	    int min_value = findMin(rootNode.right_node);
	    rootNode.node_value = min_value;
	    // 删除右子树的最小值
            //rootNode.right_node = deleteNode(rootNode.right_node, min_value);
	    // 这里还是单独写一个最好,因为这个时候的根节点不需要变成黑色。
	    rootNode.right_node = deleteMin(rootNode.right_node);
	}else {
	    // 处理因为上面LL旋转而导致节点变化的问题
	    rootNode.right_node = deleteProcess(rootNode.right_node, value);
	}
    }
		
    // 别忘了这是一个左倾,需要调整
    return fixup(rootNode);
}

红黑树的代码实现挺难的,今后得好好复习,很多源代码中的自平衡树都是使用的红黑树实现的,因为开销小,它其实就是小B树的二叉实现,这种二叉实现无法满足数据库索引的大容量需求,后面产生了B树和B+树。

B树和B+树

B树和B+树的特性就是加了多分支,本身来说。树在内存中使用多分支没有意义,只会读取更多的数据而已。但是如果是在索引中的话,上述的树(AVL、RBT)不适合索引,因为索引文件很大,因此不能一次性全部读到内存中,因此每次只能从磁盘中读取一个磁盘页的数据到内存中。因此每次读取操作都是IO操作,虽然磁盘为了提高效率,有一种局部性原理的设计(当一个数据被用到的时候,其附近的数据也马上会被用到,因此读取的都是一页数据,一页可能是4K、8K),但是AVL和RBT树都无法充分的使用这个局部性原理,这时因为逻辑上相近的节点,物理存储上可能很远,它并不是顺序存储的。并且他俩的树深度大,RBT比AVL更深,需要的IO操作更多。下面俩种树的节点大小都是磁盘页的大小,它们相较于上面的树,更加的矮胖。

B树

首先声明没有B减树,只有B树。造成这种读音的原因是当年的翻译。错把B-树翻译成B减树了,其实中间的-不是减就是个横杠。

B树,又称为多路平衡查找树,从这个名字就可以看出,B树是从二叉平衡树演变过来的,把二叉变成了N叉,B树的一个节点大小进行了变化。对于一棵m阶B树来看,必须要满足一下特性:

  1. 每个节点最多有m个子树,就是一个节点中有m-1个关键字。
  2. 如果根节点不是终端节点,那么根节点至少有俩个子树
  3. 除了叶子节点外,所有的非叶子节点至少有\(\lceil m/2 \rceil\)个子树,也就是\(\lceil m/2 \rceil-1\)个关键字。
  4. 节点中的关键字是按照顺序的。

因而B树的定义需要存储多个关键字,外加多个子节点,为了后面方便判断是否是叶子节点,需要有一个布尔值记录是否为叶子节点。

public class BTreeNode {
    // 树的阶,也就是树有几个子节点
    int m = 4;
    List<Integer> keys;
    List<BTreeNode> b_node;
    boolean isLeaf;
    
    public BTreeNode() {
    	keys = new ArrayList<Integer>(m-1);
    	b_node = new ArrayList<BTreeNode>(m);
    	isLeaf = true;
    }
}

而B树的操作,其实和上面的红黑树有异曲同工之妙,因为红黑树就是可以看成2-3-4树,等同于4阶树,因而可以从上面的红黑树来看B树代码的实现。先看查找,B树的查找是先找节点,再从节点中找关键字,这也是根据局部性原理设计出的。查看的时候需要判断是否为叶子节点来判断结束与否。

// 查找
private BTreeNode searchValue(BTreeNode rootNode, int value) {
    Iterator iter = rootNode.keys.iterator();
    int index = 0;
    boolean notResult = true;
    while(iter.hasNext() && notResult) {
	int current_key = (int) iter.next();
	if(value == current_key) {
	    notResult= false;
	    return rootNode;
	}else if(value < current_key && !rootNode.isLeaf) {
	    notResult= false;
	    return searchValue(rootNode.b_node.get(index),value);
	}
	index++;
    }
    if(notResult && !rootNode.isLeaf) {
	return searchValue(rootNode.b_node.get(index),value);
    }
    return null;
}

插入是个小难点,插入会导致节点满了,超过了m-1个,使得节点进行分裂操作,分裂就是将中间节点提出来插入到父结点中,原来的节点删除值之后作为左节点,新建一个节点作为右节点,再往右节点中添加关键字,如果这个节点不是叶子节点,还需要添加左右子树。


图23. 分裂提取
// 分裂操作
private BTreeNode splitNode(BTreeNode rootNode, BTreeNode childNode, int insert_index) {
    int middle = childNode.m / 2 - 1;
    // 之前的节点做左节点,新定义的节点做右节点
    BTreeNode right_leaf = new BTreeNode();
    right_leaf.isLeaf = childNode.isLeaf;
    rootNode.b_node.add(insert_index + 1, right_leaf);
    // 定义好右节点
    if(!childNode.isLeaf) right_leaf.b_node.add(0, childNode.b_node.remove(childNode.m-1));
    int key_index = childNode.m - 2;
    while(key_index > middle) {
	right_leaf.keys.add(0, childNode.keys.remove(key_index));
	if(!childNode.isLeaf) right_leaf.b_node.add(0, childNode.b_node.remove(key_index));
	key_index--;
    }
    // 父节点和分裂的节点合并
    rootNode.keys.add(insert_index, childNode.keys.remove(middle));
		
    return rootNode;
}

分裂操作设计到了到父节点与子节点的合并,如果递归的话,并不会很好用代码实现,因而在写代码的时候在进去相关子树之前,需要先对子节点进行判断,是否子节点已满,如果满了,先进行分裂操作,找出中间值放入到父节点中,父节点已经检查过个数了,因为插入中间值不会满。如果找到了叶子节点,那么直接插入到叶子节点中,这里不用担心叶子节点会满,并且B树的值就是插入叶子节点中的。

// 插入操作 
private BTreeNode insertNode(BTreeNode rootNode, int value) {
		
    if(rootNode.isLeaf) {
	// 找到的话,直接插入即可,因为叶节点没有子节点,所以不用考虑子节点的去向		
	rootNode.insertkey(value);
    }else {
	// 如果所走的子节点满了就需要进行分裂操作
	Iterator it = rootNode.keys.iterator();
	boolean notFind = true;
	int insert_index = 0;
	while(it.hasNext() && notFind) {
	    int current_key = (int) it.next();
	    if(value < current_key) {
		// 找到插入位置
		notFind = false;
		// 检查子节点是否满了
		if(rootNode.b_node.get(insert_index).keys.size() == rootNode.m - 1) {
		    rootNode = splitNode(rootNode,rootNode.b_node.get(insert_index),insert_index);
		}
					
		// 重新判断这个值的大小
		if(value > rootNode.keys.get(insert_index)) {
		    BTreeNode current_node = rootNode.b_node.get(insert_index + 1);
		    current_node = insertNode(current_node,value);
		}else {
		    BTreeNode current_node = rootNode.b_node.get(insert_index);
		    current_node = insertNode(current_node,value);
		}
					
	    }else if(value == current_key) {
		// 树中有相同的值
		System.out.println("树中有相同的值");
	    }else {
		insert_index++;
	    }
	}
			
	// 插入到最右边
	if(notFind) {
	    // 检查子节点是否满了
	    if(rootNode.b_node.get(insert_index).keys.size() == rootNode.m - 1) {
		rootNode = splitNode(rootNode,rootNode.b_node.get(insert_index),insert_index);
	    }
				
	    // 重新判断这个值的大小
	    if(value > rootNode.keys.get(insert_index)) {
		BTreeNode current_node = rootNode.b_node.get(insert_index + 1);
		current_node = insertNode(current_node,value);
	    }
	    BTreeNode current_node = rootNode.b_node.get(insert_index);
	    current_node = insertNode(current_node,value);
	}
				
    }
		
    return rootNode;
}

删除的操作就比较复杂了,插入在删除面前可能就是个弟弟。不过还是和以前一样,不能让节点中的关键字变成\(\lceil m/2 \rceil-1\),那么在删除的过程中,需要判断子节点的关键字是否大于\(\lceil m/2 \rceil-1\),分为种情况:

  1. 子节点的关键字个数大于\(\lceil m/2 \rceil-1\),那么直接进入子树。
  2. 子节点的关键字个数等于\(\lceil m/2 \rceil-1\),那么看左右兄弟是否可借,并且还需要判断是否有左右兄弟,因为第一个分支子树,没有左兄弟,最后一个没有右兄弟。如果右兄弟可借,那么需要右兄弟的最小值代替当前节点的关键字,然后当前节点的关键字到相关子节点中。还需要看是否是叶子节点,如果不是叶子节点,还需要设置子树。

图24. 右兄弟可借
  1. 如果左兄弟可借,那么就是将上图倒回去,用左兄弟的最大值代替关键字,并将关键字到相关子树中,之后看是否为叶子节点,如果不是,也需要设置子树。

图25. 左兄弟可借
  1. 如果左右兄弟都不可借,那么就是将关键字、相关子树的关键字和左(右)节点合并,并且一定要记得加入关键字,不然兄弟的子树很难合并。因为多路分支的原因,还是需要判断是否有左兄弟还是有右兄弟。

图26. 左右兄弟不可借
  1. 上面都是要删除的值不在当前节点中的情况,如果不在当前节点并且当前节点是叶子节点的话,那么就没有这个值。

  2. 下面就是删除的值就在当前节点中,已经找到了要删除的值了,如果是叶子节点,那么直接删除,因为上面的操作使得它的关键字个数肯定大于\(\lceil m/2 \rceil-1\),并且是叶子节点也不需要考虑子树的问题,因而直接删除。

  3. 如果不是叶子节点,那么我们不需要判断左右子树是否存在,因为一个节点一定存在左右子树。如果左子树的关键字个数大于\(\lceil m/2 \rceil-1\),找出左子树的最大值,也就是删除值的直接前驱来代替这个删除值,然后删除左子树中的这个最大值。记住是直接前驱,不是左子节点的最大值,而是左子树的最大值。


图27. 非叶节点,找直接前驱
  1. 如果右子树的关键字个数大于\(\lceil m/2 \rceil-1\),找出右子树的最小值,也就是删除值的直接后继来代替这个删除值,然后删除右子树中的这个最小值。

图28. 非叶节点,找直接后继
  1. 如果左右子树的关键字个数都是\(\lceil m/2 \rceil-1\),那么需要先将左右子树合并,图中可以看到先将左右子节点和关键字一起合并,这是因为如果左右子节点不是叶子节点,那么节点3的右子树和节点9的左子树进行合并,这边比较复杂了,不如直接先插入关键字8,那么就不用考虑的那么多,直接插入子树了。之后再进行删除操作即可。

图29. 非叶节点,合并
  1. 还需要考虑根节点因为合并操作而没有值,那么删除之后还需要检查根节点,如果出现了这种10情况,还需要将根节点的子树更新为根节点

如果将这十种情况都考虑进去的话,那么就可以依次写出代码,代码很长,如下

// 删除操作的入口
public BTreeNode deleteNode(BTreeNode rootNode, int value) {
    rootNode = deleteProcess(rootNode,value);
    if(rootNode.keys.size() == 0 && rootNode.b_node.size() == 1) {
	rootNode = rootNode.b_node.get(0);
    }
    return rootNode;
}

// 删除,操作比较复杂,一共有十种情况,
private BTreeNode deleteProcess(BTreeNode rootNode, int value) {
    // 首先确定值是不是在当前节点中
    int index = rootNode.containKey(value);
    int min_num = (int)rootNode.m / 2 - 1;
    if(index != -1) {
	// 如果在当前节点,并且是叶子节点,那么直接删掉
	if(rootNode.isLeaf) {
	    rootNode.keys.remove(index);
	    return rootNode;
	}else {
	    BTreeNode left_node = rootNode.b_node.get(index);
	    BTreeNode right_node = rootNode.b_node.get(index + 1);
	    // 如果在当前节点,但是不是叶子节点,那么看左右子节点谁的Key个数大于最小值,就找出一个最值来代替当前节点的key
	    // 查看左节点是否大于最小值
	    if(left_node.keys.size() > min_num) {
		// 找出该节点的直接前驱代替这个节点
		int max_value = findMax(left_node);
		rootNode.keys.set(index, max_value);
		left_node = deleteNode(left_node,max_value);
		return rootNode;
	    }else if(right_node.keys.size() > min_num) {
		// 查看右节点是否大于最小值
		// 找出该节点的直接后继代替这个节点
		int min_value = findMin(right_node);
		rootNode.keys.set(index, min_value);
		right_node = deleteNode(right_node,min_value);
		return rootNode;
	    }else {
					
		// 如果左右都是最小数量的节点数,先将要删除的key插入,为了方便后面插入子树
		left_node.keys.add(value);
					
		// 再将俩个节点的key合并
		for(int one_key:right_node.keys) {
		    left_node.keys.add(one_key);
		}
		if(!right_node.isLeaf) {
		    for(BTreeNode one_node:right_node.b_node) {
			left_node.b_node.add(one_node);
		    }
		}
					
		// 直接删除key的值
		rootNode.keys.remove(index);
		// 删除右节点
		rootNode.b_node.remove(index+1);
                left_node = deleteNode(left_node, value);     
		return rootNode;
	    }
	}
    }else {
	if(rootNode.isLeaf) {
	    System.out.println("删除失败,数不在该树中");
	    return rootNode;
	}
			
	// 如果不在当前节点中,那么需要检查子节点的值的个数是否大于最小数量
	int pos = rootNode.searchInsertPos(value);
	BTreeNode current_path = rootNode.b_node.get(pos);
	if(current_path.keys.size() == min_num) {
	    // 子节点是最小数量,那么需要先进行处理,再往下走
	    // 看兄弟是否可借,进行借数
	    // 如果有右兄弟,那么借右兄弟的
	    if(pos < rootNode.keys.size() && rootNode.b_node.get(pos + 1).keys.size() > min_num) {
	        // 借右兄弟的
	        BTreeNode right_node = rootNode.b_node.get(pos + 1);
	        current_path.keys.add(rootNode.keys.get(pos));
	        if(!right_node.isLeaf) current_path.b_node.add(right_node.b_node.remove(0));
	        rootNode.keys.set(pos, right_node.keys.remove(0));
	        current_path = deleteNode(current_path,value);
	        return rootNode;
	    }else if(pos == rootNode.keys.size() && rootNode.b_node.get(pos - 1).keys.size() > min_num){
	        // 借左兄弟的
	        BTreeNode left_node = rootNode.b_node.get(pos - 1);
	        current_path.keys.add(0, rootNode.keys.get(pos - 1));
	        if(!left_node.isLeaf) current_path.b_node.add(0, left_node.b_node.remove(left_node.keys.size()));
	        rootNode.keys.set(pos - 1, left_node.keys.remove(left_node.keys.size()-1));
	        current_path = deleteNode(current_path,value);
	        return rootNode;
	    }else {
		// 左右兄弟都不可借,合并操作
		// 有右兄弟
		if(pos < rootNode.keys.size()) {
		    BTreeNode right_node = rootNode.b_node.get(pos + 1);
		    current_path.keys.add(rootNode.keys.get(pos));
		    for(int one_key:right_node.keys) {
			current_path.keys.add(one_key);
		    }
		    if(!right_node.isLeaf) {
		        for(BTreeNode one_node:right_node.b_node) {
			    current_path.b_node.add(one_node);
		        }
		    }
		    rootNode.keys.remove(pos);
		    rootNode.b_node.remove(pos+1);
		    current_path = deleteProcess(current_path, value);
		    return rootNode;
		}else {
		    // 只有左兄弟
		    BTreeNode left_node = rootNode.b_node.get(pos - 1);
		    current_path.keys.add(0,rootNode.keys.get(pos));
		    for(int one_index = left_node.keys.size() - 1;one_index >= 0;one_index--) {
			current_path.keys.add(0,left_node.keys.get(one_index));
		    }
		    if(!left_node.isLeaf) {
			for(int one_index = left_node.b_node.size() - 1;one_index >= 0;one_index--) {
			    current_path.b_node.add(0,left_node.b_node.get(one_index));
			}
		    }
		    rootNode.keys.remove(pos);
		    rootNode.b_node.remove(pos-1);
		    current_path = deleteProcess(current_path, value);
		    return rootNode;
		}			
	    }			
	}else {
	    current_path = deleteProcess(current_path, value);
	    return rootNode;
	}
    }
}

B+树

B树将数据存储在节点中,为了能使树存储的更多,这样才能减少IO操作,出现了B+树,就是非叶子节点上不存储数据,只存储索引,这会导致所有的查询都会到叶子节点上,那么查询性能就是稳定的。并且B+树更加适合范围查找,B提高了磁盘IO的性能但是并没有解决遍历效率低下的问题,这时因为它所有的数据都存在节点中,B+的数据都在叶子节点中,并且叶子节点链接在一起,那么只要遍历叶子节点就可以实现整棵树的遍历,比如查询3到8,B+树只要找到3,再找到8,然后将3到8的叶子节点都串起来就行了,但是B+就比较麻烦了,得一个一个得查找。但是B树也有优势,就是单个成功查询比B+树要好,因为数据就在节点上,每个成功查询可能不需要查找到叶子节点中,只要到中间的节点上就行找到结果了,但是B+是必须要到叶子节点才能找到数据。B树的遍历是中序遍历,B+只是顺序遍历。
B+树,很多数据库比如mysql就是使用的B+树,一个m阶的B+树性质如下:

  1. 每个分支最多有m棵子树。
  2. 子树个数与关键字个数一样。
  3. 根节点至少俩棵子树,其他每个节点至少有\(\lceil m-2 \rceil\)
  4. 所有叶子节点包含关键字及其指向相应记录的指针,叶子节点将关键字按照大小顺序排列,相邻叶子节点还会链接。
  5. 所有非叶子节点仅仅起到了索引作用,并且每个索引项只含有对应子树的最大关键字和指向该子树的指针,不含有该关键字对应记录存储地址。

图30. B+树

而B+树的插入删除和B树的插入删除基本思想上是差不多的,主要的核心代码不变,只是一些细节上的改变。

哈夫曼树(最优二叉树)

哈夫曼树就是利用贪心的思想将带权路径长度达到了最小,带权路径长度就是根节点到任意节点的路径长度与相应节点上权值的乘积的和为该节点的带权路径长度(WPL)。它的构造方法也是一种自底向上的方法。

  1. 先选出俩个权值最小节点的作为新节点的左右子树,这个新节点的权值就是选出的俩个节点的权值和
  2. 之后从中删除上面所选的俩个节点,只保留了新创建出来的子树。
  3. 不断地重复上面地过程,一直选,然后删除即可。

图31. 哈夫曼树的构造过程

按照上面地构造过程,那么权重越小地离根节点也就越远,权重越大地离根节点越近。个人感觉可以理解为那些盲文一样,在生活中越频繁使用的,那么就编码长度就越小,比如生活中的"的"之类的,这些文字的编码一定很短,构造出来的WPL一定是最小的。构造的代码如下

// 使用队列来构造树
private HuffmanTreeNode createTree(int[] data) {
    List<HuffmanTreeNode> huffman_queue = new ArrayList<HuffmanTreeNode>();
    HuffmanTreeNode rootNode = null;
    // 先将全部值都变成节点
    for(int one_data:data) {
	HuffmanTreeNode node = new HuffmanTreeNode(one_data);
	huffman_queue.add(node);
    }
    // 对象排序
    huffman_queue.sort(Comparator.naturalOrder());
		
    while(!huffman_queue.isEmpty()) {
	// 选出最小权重的俩个点,组成一个新的节点
	HuffmanTreeNode leftNode = huffman_queue.remove(0);
	HuffmanTreeNode rightNode = huffman_queue.remove(0);
	HuffmanTreeNode parentNode = new HuffmanTreeNode(leftNode.weight + rightNode.weight,
					                                         leftNode,
					                                         rightNode);
	// 只要队列中还有值,就继续
	if(!huffman_queue.isEmpty()) {
	    huffman_queue.add(parentNode);
	    huffman_queue.sort(Comparator.naturalOrder());
	}else {
	    // 队列中没有值了,那么就更新路径长度即可
	    rootNode = updateLength(parentNode);
	}
    }
    return rootNode;
}
	
private HuffmanTreeNode updateLength(HuffmanTreeNode node) {
    // 更新左子树
    if(node.left_node != null) {
	node.left_node.length_node = node.length_node + 1;
	node.left_node = updateLength(node.left_node);
    }
    // 更新右子树
    if(node.right_node != null) {
	node.right_node.length_node = node.length_node + 1;
	node.right_node = updateLength(node.right_node);
    }
		
    return node;
}

之后的代码就是得到这个哈夫曼树的WPL,这个就很简单了,只要找出叶子节点,然后将所有叶子节点的权重和长度乘机相加即可。

// 得到WPL
private int getWPL(HuffmanTreeNode rootNode) {
    // 只要叶子节点的WPL即可
    if(rootNode.left_node == null && rootNode.right_node == null) {
	return rootNode.length_node*rootNode.weight;
    }else {
	int left_wpl = 0;
	int right_wpl = 0;
	if(rootNode.left_node != null) {
	    left_wpl = getWPL(rootNode.left_node);
	}
			
	if(rootNode.right_node != null) {
	    right_wpl = getWPL(rootNode.right_node);
	}
	return left_wpl + right_wpl;
    }		
}

哈夫曼树总体的代码和其他树比起来,还是相对比较简单的。

总结

这章主要就是为了复习一下数据结构中树的内容,所以想的就是把所有常用的树都写一遍,加深一下印象,从普通的二叉排序树,为了达到自平衡到后来的平衡二叉树,之后为了减小开销而发展出的红黑树,后来需要大量索引而引出了B和B+树,最后就是WPL最小的哈夫曼树,总体上是慢慢加深强度,到了红黑树和B树是最难的,特别是红黑树,需要用2-3-4树作为参照才能理解好红黑树的操作,虽然理解,但是代码实现上也是有很多技巧的,比如在当前节点的时候处理子节点的情况,这些在代码的实现上都需要好好理解。这章的代码内容会放在我的github,有兴趣的可以看看。

posted @ 2020-07-27 14:42  长安不乱  阅读(609)  评论(0编辑  收藏  举报