双向链接的红黑树(二):直接删除

本文详细解析了在双向链接红黑树中,如何通过利用节点的双向指向关系,避免自顶向下查找,直接进行删除操作,包括思路分析、代码实现和注意事项。删除过程涉及路径维护、旋转影响及特殊情况处理。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

双向链接的红黑树(二):直接删除

1. 思路分析

对于deleteMax()和deleteMin(),双向链接的逻辑和代码和原版是非常相似的,只需要在原版的基础上修复双向指向关系,和删除双向链表的头或尾节点即可,非常的简单。

所以这一节我们把重点放在delete()上面,和之前分析put()的方法一样,我们也来看看原版delete()有哪些主要的步骤:

  1. 自顶向下查找待删除的节点,并且消除2-节点,只保留3-或4-节点;
  2. 移除待删除节点(可能触发替换操作),之后自底向上进行自平衡操作;

同样,如果你对上述步骤不是很熟悉,请参考:红黑树(七):删除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 );

这些只会出现三个地方:

  1. moveRedLeft();
  2. moveRedRight();
  3. 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的删除操作进行区分。总的来说:

  1. BST的删除需要移除待删除节点的链表节点,因为我们是用直接后继节点进行替换;
  2. 红黑树的删除需要移除待删除节点的直接后继的链表节点,因为我们是用直接后继节点的值进行替换;

相关联代码为:

// 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. 免责声明

※ 本文之中如有错误和不准确的地方,欢迎大家指正哒~
※ 此项目仅用于学习交流,请不要用于任何形式的商用用途,谢谢呢;


在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值