【问题标题】:Red-black tree for editing text用于编辑文本的红黑树
【发布时间】:2018-03-22 01:11:42
【问题描述】:

我正在阅读 Joaquin Cuenca Abela 的 this great article。他谈到使用红黑树来实现一块表,而不是双向链表。

我在理解这可能与正在发生变化的缓冲区有什么关系时遇到了一些麻烦。例如,取这两个缓冲区(原始,追加):

Hello!\0    Original
y           Append

假设棋子表如下所示:

buffer    start    length
original  0        2
original  5        2
append    0        1

我们应该得到:

Hey!\0

使用双向链表,可以这样实现:

------------------   -------------------   ----------------
Buffer = ORIGINAL|   |Buffer = ORIGINAL|   |Buffer = APPEND
Start  = 0       |<--|Start  = 5       |<--|Start  = 0
Length = 2       |   |Length = 2       |   |Length = 1
Next   = 0x01    |-->|Next   = 0x02    |-->|Next   = NULL
Previous = NULL  |   |Previous = 0x01  |   |Previous = 0x01
------------------   -------------------   ----------------

如果文件最初是作为 char 数组或其他内容加载的,那么在编辑后运行起来似乎非常简单且快速。另一方面,据我了解,红黑树看起来像这样:

                       ------------------
                       size       = 2
                       size_left  = 1
                       size_right = 2
                       colour     = black
                       ------------------
                        /              \
                       /                \
                      /                  \
             ----------------      ---------------- 
             size       = 1        size       = 2
             size_left  = 0        size_left  = 0
             size_right = 0        size_right = 0
             colour     = red      colour     = red
             ----------------      ----------------
             /          \              /           \
            /            \            /             \
         NULL            NULL       NULL           NULL

我没有看到在编辑后重新绘制文档其余部分的明确方法。当然,插入/删除/查找将块添加到树中会更快。但我错过了如何构建已编辑的缓冲区以供查看。

我错过了什么?如果我有一个编辑器,并且我删除/插入了一大块文本,我将如何遍历树以重新绘制缓冲区并正确反映此编辑?而且,这会比链表提供的 O(n) 时间复杂度更快吗?

【问题讨论】:

  • @rici 我想我主要了解树是如何工作的,但我只是不明白如何在这种特定的上下文中(重新绘制缓冲区以反映更改)它具有优势。维基百科确实有很好的解释,但它没有涵盖这个主题。我找不到任何其他资源可以回答我的问题,所以我在这里问了
  • 我理解这方面;我不明白的是如何通过遍历树来反映所做的更改。使用双向链表,非常清晰;每个节点指向原始和附加缓冲区中的一个点,并且通过保持每个条目的长度的运行总和,对节点所做的任何更改都可以通过遍历列表来反映:即,通过选择打印文本片段来自原始缓冲区或附加缓冲区的某个位置和长度。对于树,我不清楚这是如何完成的。但是,我确实理解轮换/必须“修复”树。
  • 我认为我最大的问题是努力全面思考一个例子。考虑到存储在树中的信息(在上面的示例或其他示例中),看看我如何构建一个新的缓冲区会很有帮助。从概念上讲,在我看来,使用链表构造缓冲区需要 O(N)。但是等效的操作不会对树采取 O(NlogN) 吗?因为,对于 N 件,需要 O(logN) 来检索其信息?
  • 在 cmets 交流的基础上,我试图写一个答案来解决你的问题。我希望它有所帮助。

标签: algorithm binary-tree text-editor red-black-tree


【解决方案1】:

我不太了解您提供的树图,因为(与链表图不同)它们似乎与实际存储的数据无关。实际上,它们将具有基本相同的数据字段(缓冲区、开始和长度)加上一个大小,即以节点为首的子树中片段的总大小。它们将具有 Left 和 Right(子)指针,而不是 Previous 和 Next 指针。

当然,他们需要一些额外的数据来保持树的平衡(在红/黑树的情况下,一个红/黑位,但我认为保持平衡的机制并不重要在这里;例如,您可以使用 AVL 树而不是红/黑树。所以我将在这里忽略节点的那部分。

Size 字段对于在给定偏移处查找数据是必需的(因此,如果不需要进行此类查找,则可能会被忽略。)我认为链接的文章以片段为单位测量大小,而我倾向于以字符(甚至字节)来衡量大小,这就是我将在此处说明的内容。正如链接文章所指出的,Size 字段可以很容易地以对数时间进行维护,因为它指的是子树的大小,而不是它在数据流中的位置。

您可以使用 Size 字段按缓冲区偏移量查找节点。如果偏移量小于左孩子的 Size,则递归到左孩子;如果它至少是当前长度加上左孩子的大小,则从偏移量中减去该总和并递归到右孩子。否则,当前节点包含所需的偏移量。这不能超过最大树深度,如果树是合理平衡的,则为 O(log N)。

我也对你的链表图有点困惑,在我看来它代表的是缓冲区He|!\0|y,而我希望它是He|y|!\0

------------------   -------------------   -------------------
Buffer = ORIGINAL|   |Buffer = APPEND  |   |Buffer = ORIGINAL|
Start  = 0       |<--|Start  = 0       |<--|Start  = 5       |
Length = 2       |   |Length = 1       |   |Length = 2       |
Next   = 0x01    |-->|Next   = 0x02    |-->|Next   = NULL    |
Previous = NULL  |   |Previous = 0x01  |   |Previous = 0x01  |
------------------   -------------------   -------------------

等效平衡树为:

                       -------------------
                       | Size   = 5      |
                       | Buffer = APPEND |
                       | Start  = 0      |
                       | Length = 1      |
                       -------------------
                        /              \
                       /                \
                      /                  \
             -------------------   ------------------- 
             |Size   = 2       |   |Size   = 2       |
             |Buffer = ORIGINAL|   |Buffer = ORIGINAL|
             |Start  = 0       |   |Start  = 5       |
             |Length = 2       |   |Length = 2       |
             -------------------   -------------------
               /          \            /           \
              /            \          /             \
            NULL            NULL    NULL           NULL

从给定节点开始按顺序查找下一个节点的算法如下:

  1. 当右子指针为 NULL 时,返回父。继续移动到父节点,直到找到其右子指针既不是 NULL 也不是要返回的子节点的节点。

  2. 移动到右边的孩子。

  3. 当左子指针不为NULL时,移动到左子

该算法的给定应用显然可能需要 O(log N) 步的第 1 步和/或第 3 步迭代。但是,重复的应用程序(例如,当您通过多个节点按顺序遍历缓冲区时)将总体上是线性的,因为任何给定的链接(父 &lrarr; 子)都将被准确地遍历两次,每个方向一次。因此,如果遍历整棵树,遍历的链接总数是树中链接数的两倍。 (并且树的链接比节点少一个,因为它是一棵树。)


如果你用字符来衡量大小,那么你可以避免需要“长度”字段,因为节点直接引用的数据长度只是节点的子树大小与其子节点之和之间的差异子树大小。这可以(几乎)将节点的大小减小到链表节点的大小,假设您可以找到某种方法将红/黑位(或其他平衡信息)编码为填充位。

另一方面,使用父指针和两个子指针来实现二叉树是很常见的。 (通过查看上面的遍历算法可以清楚地知道这有什么帮助。)但是,没有必要存储父指针,因为例如,它们可以在树的任何给定遍历期间在由索引的父指针数组中维护树的深度。这个数组显然不大于最大树深度,所以可以使用一个小的(~50)固定长度数组。

这些优化也远远超出了这个答案。

【讨论】:

  • 谢谢,这很有帮助。我肯定弄乱了链表的顺序。我认为 size 属性是我未能完全理解的。现在这似乎更清楚了。最后,如果遍历整棵树需要线性时间,那么在大多数情况下(查找/插入/删除)树是否比链表快,除了基于片表构造缓冲区时?在哪种情况下它们都需要线性时间?
  • 我认为最大的优势是查找。 AIUI,你通常会从几块大块开始构建缓冲区;有一个线性算法可以从预先排序的序列中构建一个 RB 树,但我认为这并不重要。找到端点后,插入和删除的时间将非常相似。所以查找很关键。
【解决方案2】:

如果我有一个编辑器,并且我删除/插入了一大段文本,我将如何遍历树以重新绘制缓冲区并正确反映此编辑?而且,这会比链表提供的 O(n) 时间复杂度更快吗?

假设片段表很大,并且重新绘制屏幕上可见的缓冲区部分通常只需要访问几个连续的节点。 假设您在特定编辑后需要访问的节点位于文档的中间或接近末尾。

对于双向链表,您可能需要从开始表遍历许多节点才能到达编辑的开头。那是 O(n)。从那里,您可以遍历接下来的几个节点来进行绘画。

使用平衡树,您可以在 O(log_2 n) 中找到第一个节点。从那里,您执行按顺序遍历以访问绘制所需的接下来的几个节点。

在添加、删除或修改一个片段后更新树中的位置只是从新/修改节点的父节点开始的祖先位置添加/减去一个值。这也是 O(log_2 n)。

【讨论】:

    猜你喜欢
    • 2014-07-03
    • 2010-09-06
    • 2013-10-27
    • 2011-04-23
    • 2015-11-17
    • 2012-11-30
    • 1970-01-01
    • 2013-06-05
    • 2018-02-07
    相关资源
    最近更新 更多