1. 思路分析
对于deleteMax()和deleteMin(),双向链接的逻辑和代码和原版是非常相似的,只需要在原版的基础上修复双向指向关系,和删除双向链表的头或尾节点即可,非常的简单。
所以这一节我们把重点放在delete()上面,和之前分析put()的方法一样,我们也来看看原版delete()有哪些主要的步骤:
- 自顶向下查找待删除的节点,并且消除2-节点,只保留3-或4-节点;
- 移除待删除节点(可能触发替换操作),之后自底向上进行自平衡操作;
同样,如果你对上述步骤不是很熟悉,请参考:红黑树(七):删除 和 2-3查找树(一):基本概念。相似地,大家可以思考一下,针对直接删除某个节点,哪些步骤是必需的,哪些又是多余的呢?和之前put()类似,自顶向下查找待删除节点的过程是多余的,其他步骤我们必须保留。
因为我们需要在删除前,自顶向下进行2-节点消除工作,所以我们可以利用原版delete()的代码逻辑,即使用递归来实现直接删除操作。现在没有查找待删除节点的过程,那么自顶向下进行2-节点消除的路径也就不存在了,有什么方法可以解决这个问题呢?没错,还是利用双向指向关系,我们可以从待删除结点出发,向上查找经过的路径,比如上一节中的例子:
如果我们需要删除P节点,那么经过的路径即为:M -> R -> P。现在我们得到了想要的路径,那是不是像之前直接插入时,就万事大吉了呢?在回答这个问题之前,大家先来思考这么一个问题:之前直接插入操作中,自底向上自平衡的路径会受到自平衡(或者说旋转,染色对树结构无影响)的影响嘛?
自底向上的自平衡对整体路径是没有影响的,因为自平衡只会对当前节点,和它子树的结构有影响,对其父节点是没有影响的,但是对于自顶向下的路径中,消除2-节点(旋转)的操作会对路径产生影响。比如上面的路径,我们在R节点发生了左旋,那么整个路径会变成:M -> X -> R -> P,所以针对这个问题,需要观察一下左右旋对路径的影响。我们先来看看下面这个例子:
通过这个例子,我们是否能总结出:只要发生左旋,只需把被左旋节点的左孩子加入路径中,就可以了呢?好像有点不太对劲,因为如果路径不是经过左孩子(P),而是右孩子(X),那么经过左旋之后,路径反而减少了(X被旋转到父节点的位置)。这里的分析非常tricky,很容易掉入分情况讨论的陷阱。其实真实的情况没有这么复杂,大家需要回忆起原来代码逻辑:
我们先决定是往当前节点的左孩子,还是右孩子前进,之后再针对左右孩子的情况进行2-节点消除操作。
这里我们以左孩子为例,先前的代码为:
// the key may be in the left subtree.
if ( compareKeys( root, key ) > 0 ) {
// this part of code is very similar to
// deleteMin( RedBlackTreeNode root ).
// base 3, not found the key in this R-B tree
// and this corner case,
// where you delete a minimum key that is not in the R-B tree,
// is missed by the textbook
if ( root.left == null ) return root;
if ( !isRed( root.left ) &&
!isRed( root.left.left ) )
root = moveRedLeft( root );
root.left = delete( ( RBTreeNode<K, V> ) root.left, key );
}
大家可以注意到,旋转只会在moveRedLeft()里面才会出现,而且里面只有左旋,不太熟悉的童鞋可以参考之前章节的分析。并且路径已经充当了我们的“指路人”,我们可以不通过比较key来判断前进的方向,而是通过路径中存储的节点来判断。所以我们可以得到下面的结论:
左旋只会出现在往左孩子前进的情况,所以只要发生左旋,我们只需把被旋转节点的左孩子加入到路径中即可(注意如果左孩子为null,则不添加)。
对称的,对于往右孩子前进的情况,我们也只需把被旋转节点的右孩子加入到路径中即可(注意如果右孩子为null,则不添加),原因也是类似的,右旋只会出现在往右孩子前进的情况,下面是原来前往右孩子的代码:
// the key may be in the right subtree,
// or found the key to delete.
else {
// this part of code is very similar to
// deleteMax( RedBlackTreeNode root ).
// starts at here ---->
if ( isRed( root.left ) )
root = rotateRight( root );
// base case 2 and also case 1, found the key and the node
// associated with the key is either a 3-node or 4-node
// just delete it
// code
// base 3, not found the key in this R-B tree
// and this corner case,
// where you delete a maximum key that is not in the R-B tree,
// is missed by the textbook
// code
if ( !isRed( root.right ) &&
!isRed( root.right.left ) )
root = moveRedRight( root );
// ----> ends here
// case 2, found the key but
// the node associated with the key is a 2-node
// replace it with its successor and
// delete the successor with deleteMin( root ).
// code
}
同样,右旋只会出现在这两个地方,并且moveRedRight()中只会有右旋:
root = rotateRight( root );
root = moveRedRight( root );
或许有童鞋会提出疑问,moveRedLeft()里面明确有一个右旋,这个不会影响路径嘛?
root.right = rotateRight( ( RBTreeNode<K, V> ) root.right );
答案是不会的,这里右旋的是root的右子树,但是当我们来到这部分代码时,路径一定是往root的左子树前进的,所以这里的右旋不会改变路径的结构,因此我们可以得到另一个结论:
只有旋转发生在路径节点时,才会出现路径变化,否则路径不会发生改变。
比如之前的例子,路径是往R的左孩子前进,但是在R节点,我们需要右旋它的右孩子(X),然后在对其进行左旋,结果只有R节点的左旋对路径产生了影响。(注意root虽然一开始不在路径当中,当也算能产生影响的节点,因为我们从root开始前进,所以不再需要root作为“指路人”。)
到此我们就基本结束了直接删除的主要思路分析,下面我们来看看如何用代码来实现它!
2. 代码分析
首先我们需要在发生旋转的地方,修改路径的结构:
// left rotate, meaning we need visit root's left child once ( if it's not null )
// before reaching the node we want to delete
if ( root.left != null ) path.addFirst( ( RBTreeNode<K, V> ) root.left );
// right rotate, meaning we need visit root's right child once ( if it's not null )
// before reaching the node we want to delete
if ( root.right != null ) path.addFirst( ( RBTreeNode<K, V> ) root.right );
这些只会出现三个地方:
- moveRedLeft();
- moveRedRight();
- delete()中前往右孩子的情况中;
之后,我们需要先得到自顶向下的路径:
@SuppressWarnings( "unchecked" )
protected void deleteCommon( RBTreeNode<K, V> node ) {
deletedMinNode = deletedLinkedNode = deletedNode = null;
// the root is null, i.e. the tree is empty,
if ( node == null || isEmpty() ) return;
// https://docs.oracle.com/en/java/javase/16/docs/api/java.base/java/util/LinkedList.html
LinkedList<RBTreeNode<K, V>> path = new LinkedList<>();
// get the path from the node all the way to the root node
do {
path.addFirst( node );
// only RedBlackTreeNode used here, so ignore warning
node = ( RBTreeNode<K, V> ) node.parent;
} while ( node != null );
// re-get the node to be deleted
node = path.getLast();
// remove the root node
path.poll();
// if both children of root are black, set root to red
if ( !isRed( root.left ) &&
!isRed( root.right ) )
( ( RBTreeNode<K, V> ) root ).color = RED;
// delete the node and update the root
updateRootForDelete( delete( ( RBTreeNode<K, V> ) root, path, node ) );
linkedList.remove( deletedLinkedNode.node );
}
这里我们使用Java内置链表进行操作,使用双向指向关系进行路径查找,这里需要注意把root从路径中移除:
// https://docs.oracle.com/en/java/javase/16/docs/api/java.base/java/util/LinkedList.html
LinkedList<RBTreeNode<K, V>> path = new LinkedList<>();
// get the path from the node all the way to the root node
do {
path.addFirst( node );
// only RedBlackTreeNode used here, so ignore warning
node = ( RBTreeNode<K, V> ) node.parent;
} while ( node != null );
// re-get the node to be deleted
node = path.getLast();
// remove the root node
path.poll();
之后我们就可以利用原版delete()的代码逻辑进行魔改:
/**
* @param root current node
* @param path path to the node to be deleted.( excluding the root node )
* For example, we inserted S -> E -> A -> R one by one,
* and want to delete R and the path will be S -> R in this R-B tree
* @param node node to be deleted
* */
// note that with this method,
// there must be a node to be deleted in this R-B tree
private RBTreeNode<K, V> delete( RBTreeNode<K, V> root,
LinkedList<RBTreeNode<K, V> > path,
RBTreeNode<K, V> node ) {
// the node to be deleted may be in the left subtree.
// and the path must have nodes as we step into the left subtree
if ( !path.isEmpty() &&
root.left == path.poll() ) {
// this part of code is very similar to
// deleteMin( RedBlackTreeNode root )
if ( !isRed( root.left ) &&
!isRed( root.left.left ) )
root = moveRedLeft( root, path );
root.left = delete( ( RBTreeNode<K, V> ) root.left, path, node );
// restore doubly-connected node, root <-> child.
if ( root.left != null ) root.left.parent = root;
}
// the node to be deleted may be in the right subtree,
// or found the node to delete where the path is supposed to be empty.
// but we don't take rotation into consideration.
// if so happens, we should guarantee current node is the one we want to delete,
// otherwise, we need step into right subtree.
else {
// this part of code is very similar to
// deleteMax( RedBlackTreeNode root ).
// starts at here ---->
if ( isRed( root.left ) ) {
// left rotate, meaning we need visit root's left child once ( if it's not null )
// before reaching the node we want to delete
if ( root.right != null ) path.addFirst( ( RBTreeNode<K, V> ) root.right );
root = rotateRight( root );
}
// base case 2 and also case 1, found the key and the node
// associated with the key is either a 3-node or 4-node
// just delete it
if ( path.isEmpty() && root == node &&
root.right == null ) {
assert deletedNode == null;
deletedLinkedNode = deletedNode = root;
return null;
}
assert root.right != null : root;
if ( !isRed( root.right ) &&
!isRed( root.right.left ) )
root = moveRedRight( root, path );
// ----> ends here
// case 2, found the key but
// the node associated with the key is a 2-node
// replace it with its successor and
// delete the successor with deleteMin( root ).
if ( path.isEmpty() && root == node ) {
assert deletedNode == null;
deletedNode = new MapTreeNode<>( root.ID, root.key, root.val );
// replace the node with its successor
root.replace( min( root.right ) );
// delete the successor
root.right = deleteMin( ( RBTreeNode<K, V> ) root.right );
deletedLinkedNode = deletedMinNode;
}
// the key may be in the right subtree,
else root.right = delete( ( RBTreeNode<K, V> ) root.right, path, node );
// restore doubly-connected node, root <-> child.
if ( root.right != null ) root.right.parent = root;
}
// update size and restore this R-B tree
return balance( root );
}
这里进入左孩子情况的条件为:1)路径不为空(当前节点不是待删除节点);2)root左孩子为路径中指向的下一个节点(前往左孩子):
// the node to be deleted may be in the left subtree.
// and the path must have nodes as we step into the left subtree
if ( !path.isEmpty() &&
root.left == path.poll() )
因为原版代码里面,前往右孩子或者当前节点为待删除结点,都视为前往右孩子的情况,这里也是相似的。在进入到左孩子之后,代码逻辑和之前保持一致。
如果前往右孩子,或者当前节点为待删除节点,我们都需要先消除2-节点。但是这里我们会遇到一个稍微麻烦的情况:现在我们是删除当前节点,还是继续向右孩子前进呢?或者说,如果当前路径为空,是否保证当前节点就是待删除结点呢?非也!如果当前节点确实是待删除结点,那么路径一定为空,但是如果这时又发生了旋转操作,那么我们需要再往右子树再前进一次才能抵达真正需要删除的节点。
还是上面的例子,如果需要删除X,我们来到X节点,路径为空,但是这里需要右旋一次,那么现在位置不是X节点,而是S,所以需要往右在前进一步,这点非常容易忽略,所以判断当前节点是否需要删除的条件为:
// 待删除节点右孩子为空,直接删除
// base case 2 and also case 1, found the key and the node
// associated with the key is either a 3-node or 4-node
// just delete it
if ( path.isEmpty() && root == node &&
root.right == null ) {}
// 待删除节点右孩子非空,需要用其直接后继进行替换删除
// case 2, found the key but
// the node associated with the key is a 2-node
// replace it with its successor and
// delete the successor with deleteMin( root ).
if ( path.isEmpty() && root == node ) {}
最后我们还剩一个问题:当发生用直接后继进行替换删除时,我们删除的双向链表节点究竟是哪个?还是之前的例子,这次我们删除R节点:
大家可以看到,需要删除的链表节点是原来的后继节点S,而不是待删除节点R,这点需要和之前双向链接BST的删除操作进行区分。总的来说:
- BST的删除需要移除待删除节点的链表节点,因为我们是用直接后继节点进行替换;
- 红黑树的删除需要移除待删除节点的直接后继的链表节点,因为我们是用直接后继节点的值进行替换;
相关联代码为:
// case 2, found the key but
// the node associated with the key is a 2-node
// replace it with its successor and
// delete the successor with deleteMin( root ).
if ( path.isEmpty() && root == node ) {
assert deletedNode == null;
deletedNode = new MapTreeNode<>( root.ID, root.key, root.val );
// replace the node with its successor
root.replace( min( root.right ) );
// delete the successor
root.right = deleteMin( ( RBTreeNode<K, V> ) root.right );
deletedLinkedNode = deletedMinNode;
}
因为原直接后继节点是在deleteMin()里面被删除的,所以我们需要保存一下这个过程中被删除的节点,之后再删除它的链表节点即可:
// 保存deleteMin()中被删除的后继节点
deletedLinkedNode = deletedMinNode;
// 在双向链表中删除它的链表节点
linkedList.remove( deletedLinkedNode.node );
到此,我们就结束了双向链接红黑树的讲解,其余操作和代码大家可以在项目代码中看到。另外,我在TestLinkedBBST类中进行了相关测试,测试用例和我在 红黑树(三):插入·续 中的构造轨迹图例是一样的,两种类型的红黑树最后生成的树结构应该是一模一样的,因为虽然插入实现不一样,但是本质上是没有任何区别的。大家有条件可以用IDE的debugger看看两种方法生成的树结构,加深理解。
接下来,我们将进入到下一个章节的讲解:点定位算法(Point Location),但是下一章为拓展章节,因为点定位对Voronoi图的实现是非必须的,但是在后面为了让Voronoi图具备高效的空间查找能力,即让Voronoi图具有更好的工程实践能力,我们需要借助点定位算法的助力。
如果你只想了解Voronoi图的实现,不关心其工程实践能力,那可以跳过点定位的相关章节,直接阅读Voronoi图相关的讲解文章。
上一节:双向链接的红黑树(一):基础概念和插入
下一节:点定位:如何拆分更新梯形图和二分搜索结构 · (一):总思路分析
系列汇总:塞尔达和计算几何 | Voronoi图详解文章汇总(含代码)
3. 免责声明
※ 本文之中如有错误和不准确的地方,欢迎大家指正哒~
※ 此项目仅用于学习交流,请不要用于任何形式的商用用途,谢谢呢;