从自顶向下的2-3-4左倾红黑树中删除需要进行哪些额外旋转?

48

我一直在实现一个LLRB包,它应该能够在两种模式下运行,Bottom-Up 2-3或Top-Down 2-3-4由Sedgewick描述(代码-改进的代码,尽管仅处理2-3树这里,感谢RS提供指针)。

Sedgewick为2-3模式的树操作提供了非常清晰的描述,尽管他花了很多时间讨论2-3-4模式。他还展示了如何通过插入时颜色翻转顺序的简单修改来改变树的行为(对于2-3-4,要么在下行时分裂,要么在上行时分裂):

private Node insert(Node h, Key key, Value value)
{
    if (h == null)
        return new Node(key, value);

    // Include this for 2-3-4 trees
    if (isRed(h.left) && isRed(h.right)) colorFlip(h);

    int cmp = key.compareTo(h.key);

    if (cmp == 0)     h.val = value;
    else if (cmp < 0) h.left = insert(h.left, key, value);
    else              h.right = insert(h.right, key, value);

    if (isRed(h.right) && !isRed(h.left))    h = rotateLeft(h);
    if (isRed(h.left) && isRed(h.left.left)) h = rotateRight(h);

    // Include this for 2-3 trees
    if (isRed(h.left) && isRed(h.right)) colorFlip(h);

    return h;
}

然而,他在2-3-4 LLRBs中删除的部分却轻描淡写地说道:
引用: 下一页的代码是LLRB 2-3树delete()的完整实现。它基于自上而下2-3-4树插入方法的相反方式:我们在搜索路径下降时执行旋转和颜色翻转,以确保搜索不会在2节点上结束,这样我们就可以直接删除底部的节点。我们使用fixUp()方法在insert()代码的递归调用后共享颜色翻转和旋转的代码。使用fixUp(),我们可以保留右倾红链接和不平衡的4节点沿搜索路径,确信这些条件将在树向上生长的过程中得到修复。(该方法对于2-3-4树同样有效,但需要在搜索路径外的右节点为4节点时进行额外的旋转。)
他的delete()函数:
private Node delete(Node h, Key key)
{
    if (key.compareTo(h.key) < 0)
        {
            if (!isRed(h.left) && !isRed(h.left.left))
            h = moveRedLeft(h);
            h.left = delete(h.left, key);
        }
    else
        {
            if (isRed(h.left))
                h = rotateRight(h);
            if (key.compareTo(h.key) == 0 && (h.right == null))
                return null;
            if (!isRed(h.right) && !isRed(h.right.left))
                h = moveRedRight(h);
            if (key.compareTo(h.key) == 0)
                {
                    h.val = get(h.right, min(h.right).key);
                    h.key = min(h.right).key;
                    h.right = deleteMin(h.right);
                }
            else h.right = delete(h.right, key);
        }
    return fixUp(h);
}

My implementation correctly maintains the LLRB 2-3 invariants for all tree operations on 2-3 trees, but fails for a subclass of right-sided deletions on 2-3-4 trees. These failing deletions result in right leaning red nodes, which cause tree imbalance and null pointer dereferencing. After surveying example code that discusses LLRB trees and includes options for constructing trees in either mode, it seems that none correctly implements the deletion from a 2-3-4 LLRB (i.e. none has the extra rotation alluded to, e.g. Sedgewick's java above and here).
I'm having trouble figuring out exactly what he means by "extra rotation when the right node off the search path is a 4-node". Presumably this is a rotate left, but where and when?
If I rotate left passing upwards past a 4-node equivalent (i.e. RR node) or a right leaning 3-node equivalent (BR node) either before calling fixUp() or at the end of the fixUp function, I still get the same invariant contradiction.
Here are the tree states of the smallest failing examples I have found (generated by sequential insertion of elements from 0 to the respective maximum value).
第一对树展示了在删除元素15之前的不变符合状态到明显破损状态的转换。

A 2-3-4 LLRB containing 0..15

State after deleting item 15

第二个与上面基本相同,但删除了0..16中的16个数字(删除15个数字会得到相同的拓扑结构)。请注意,不变量矛盾成功跨越了根节点。

A 2-3-4 LLRB containing 0..16

State after deleting item 16

关键在于理解如何将沿树向下行走期间生成的违规行为恢复到目标节点。以下两棵树分别显示了在没有删除且在使用fixUp()之前向左和向右行走后,上面第一棵树的外观。
尝试在节点仅具有红色右子项时在向上行走时向左旋转似乎是解决方案的一部分,但它不能正确处理两个连续的红色右子项,在两个子项都是红色时进行flipColor似乎进一步改善了情况,但仍然存在一些不变量被破坏的情况。
如果我进一步检查当兄弟节点为黑色且其右子节点为红色时,其右子节点的右子节点是否为红色,并在这种情况下向左旋转,则只会失败一次,但此时我感觉需要一个新的理论而不是一个新的外圈。
有什么想法吗?

供参考,我的实现在这里(不,它不是Java)。

跟进:

我感兴趣的部分原因是要确认许多人声称2-3 LLRB树比2-3-4 LLRB树更有效。我的基准测试已经证实了这一点,对于插入和删除,2-3比2-3-4快约9%,但我发现检索2-3-4树非常稍快。

以下时间是代表性的,并且在运行中保持一致:

BU23:
BenchmarkInsert        1000000        1546 ns/op
BenchmarkDelete        1000000        1974 ns/op
BenchmarkGet           5000000         770 ns/op

TD234:
BenchmarkInsert        1000000        1689 ns/op
BenchmarkDelete        1000000        2133 ns/op
BenchmarkGet           5000000         753 ns/op

第一列是工作台名称,第二列是操作次数,第三列是结果。在i5M 2.27上进行基准测试。
我已经查看了2-3树和2-3-4树的分支长度,但这些并不能解释检索差异(从根节点到节点的平均距离和1000个插入随机节点的10000棵树的标准偏差)。
Means:
TD234 leafs  BU23 leafs 
 12.88940     12.84681 
TD234 all    BU23 all 
 11.79274     11.79163 

StdDev:
TD234 leafs  BU23 leafs 
 1.222458     1.257344 
TD234 all    BU23 all 
 1.874335     1.885204 

16
这个问题显示了研究的努力。 - 嗯...核对一下... - Mysticial
1
我在各种可以在线找到的RBT实现中发现了多个错误(例如Thomas Niemann的“排序和搜索算法”中的C代码)。不幸的是,我不记得所有的细节,除了著名书籍“算法导论”(Cormen / Leiserson / Rivest / Stein)中提供的错误参考伪代码(也用于Niemann的代码)。有关详细信息,请参见此答案。 - Alexey Frunze
我同意,有很多糟糕/粗糙的代码描述这个问题。 - kortschak
似乎我的旧RBT实现在删除路径中有一个额外的块。我甚至在那里留下了一条注释,大致是这样说的:“修复删除节点的子节点为NIL的情况(在其他几个RB树文档和源代码中缺失了此功能,这个错误的净效果是红黑高度不同于树中所有终端节点,即对数高度特性被破坏了)。除此之外就不能再说什么了,需要刷新我的RBT知识并研究代码。 - Alexey Frunze
Sedgewick 的 deleteMin 实现有误。尝试插入 3 个节点,使得你得到一棵高度为 1 的树。在此之后,尝试调用两次 deleteMin,然后你将得到一个空的树,但是第三个元素呢? - Pavel
paulpaul1076,我无法使用我的代码重现这个问题,而我的代码是 Sedgewick 的 Java 的一个相当忠实的表示。 - kortschak
1个回答

10

更新并验证

测试的关键是,该实现不支持删除不存在或先前已删除的节点!我花费了太长时间来弄清楚为什么我的工作解决方案“损坏”了。这可以通过在进行搜索之前对关键字进行初步搜索并在树中根本找不到它时返回false来解决,并且此解决方案在下面链接的代码中得到了应用。

看起来Sedgewick没有公开提供针对2-3-4删除的删除方法。他的结果专门处理2-3树(他只是简单地提到2-3-4树,因为它们的平均路径长度(因此搜索成本)以及其他红黑树与2-3情况无法区分)。似乎也没有其他人很容易找到,因此在调试问题后,这就是我找到的内容:

首先,使用Sedgewick的代码并修复过时的部分。在幻灯片这里(第31页)中,您可以看到他的代码仍然使用旧的4个节点表示方法,其中通过将两个左红色放在一起来完成,而不是平衡。然后,编写2-3-4删除例程的第一步是修复此问题,以便我们可以进行一项健全性检查,这将有助于验证后续的修复:

private boolean is234(Node x)
{         
   if (x == null)
      return true;
   // Note the TD234 check is here because we also want this method to verify 2-3 trees
   if (isRed(x.right))
      return species == TD234 && isRed(x.left);

   if (!isRed(x.right))
      return true;

   return is234(x.left) && is234(x.right);
} 

一旦我们有了这个,我们知道了一些事情。首先,从论文中我们可以看到,在使用2-3-4树时,不应该在向上的路上破坏4个节点。其次,在搜索路径上有一个右侧4节点的特殊情况。还有第三种未提及的特殊情况,当您回溯树时,您可能会出现h.right.left为红色的情况,这将使您仅进行左旋转无法得到有效结果。这是论文第4页插入情况所描述情况的镜像。

你需要的4节点旋转修复如下:

    private Node moveRedLeft(Node h)
    {  // Assuming that h is red and both h.left and h.left.left
       // are black, make h.left or one of its children red.
       colorFlip(h);
       if (isRed(h.right.left))
       { 
          h.right = rotateRight(h.right);

          h = rotateLeft(h);
          colorFlip(h);

          if (isRed(h.right.right) )
             h.right = rotateLeft(h.right);
       }
      return h;
    }

这样做可以消除对2-3-4的分割,并添加第三种特殊情况的修复。

private Node fixUp(Node h)
{
   if (isRed(h.right))
   {      
      if (species == TD234 && isRed(h.right.left))
         h.right = rotateRight(h.right);
      h = rotateLeft(h);
   }

   if (isRed(h.left) && isRed(h.left.left))
      h = rotateRight(h);

   if (species == BU23 && isRed(h.left) && isRed(h.right))
      colorFlip(h);

   return setN(h);
}

最后,我们需要对此进行测试并确保其正常工作。它们不一定是最有效的,但正如我在调试中发现的那样,它们必须能够实际与预期的树行为(即不插入/删除重复数据)一起工作!我使用了测试辅助方法来完成这个任务。注释行用于调试时,我会打断点检查树是否存在明显的不平衡。我已经尝试使用100000个节点进行了验证,并且它表现得非常完美:

   public static boolean Test()
   {
      return Test(System.nanoTime());
   }
   public static boolean Test(long seed)
   {
      StdOut.println("Seeding test with: " + seed);
      Random r = new Random(seed);
      RedBlackBST<Integer, Integer> llrb = new RedBlackBST<Integer,Integer>(TD234);
      ArrayList<Integer> treeValues = new ArrayList<Integer>();
      for (int i = 0; i < 1000; i++)
      {
         int val = r.nextInt();
         if (!treeValues.contains(val))
         {
            treeValues.add(val);
            llrb.put(val, val);
         }
         else
            i--;
      }
      for (int i = 0; i < treeValues.size(); i++)
      {
         llrb.delete(treeValues.get(i));
         if (!llrb.check())
         {
            return false;
         }
//         StdDraw.clear(Color.GRAY);
//         llrb.draw(.95, .0025, .008);
      }
      return true;
   }

完整的源代码可以在这里找到。


已编辑,是的,我已经切换了模式。您看到什么情况不能工作,因为图像显示我通过递归向下并返回(尽管是通过deleteMax(),因为这就相当于delete(15))。 - Tawnos
现在正在设置JDK,所以我应该能够运行一个快速测试套件并报告回来。 - Tawnos
有趣的是,作者在is234检查中自相矛盾。他的代码声称234树允许沿着左侧树出现两个红链接,而不是保持平衡。我通过将检查更改为return x == null || (((isRed(x.left) && isRed(x.right)) || !isRed(x.right)) && is234(x.left) && is234(x.right));来修复了这个问题。现在正在编写测试生成套件。 - Tawnos
我还在研究这个。看起来Sedgewick可能只尝试了简单的情况,并且陷入了和我一样的陷阱。希望再多尝试一下,我就能实现一个可行的删除功能。 - Tawnos
哦,天啊,我昨天已经解决了这个问题,现在才意识到为什么我仍然会得到一个空指针异常:这个实现不能处理两次删除相同的值。正在更新答案。 - Tawnos
显示剩余8条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接