在定义二叉搜索树时,是否允许重复的键?

212

我正在尝试寻找二叉搜索树的定义,但无论在哪里都找到了不同的定义。

有人说对于任何给定的子树,左子节点的键小于或等于根。

有些人说对于任何给定的子树,右子节点的键大于或等于根。

而我的老大学数据结构书上说“每个元素都有一个键,没有两个元素拥有相同的键。”

是否有一个通用的BST定义?特别是关于如何处理具有多个相同键的树。

编辑:也许我表达不够清楚,我看到的定义是:

1)left <= root < right

2)left < root <= right

3)left < root < right,其中不存在重复的键。

13个回答

104
许多算法都会指定排除重复项。例如,在MIT算法书中的示例算法通常会呈现没有重复项的示例。实现重复项相当简单(可以在节点上作为列表或在一个特定方向上)。
大多数(我见过的)都将左子节点指定为“<=”,右子节点指定为“>”。实际上,允许任何一个右侧或左侧的子节点等于根节点的二叉搜索树,需要额外的计算步骤来完成允许重复节点的搜索。
最好利用节点上的列表存储重复项,因为将“=”值插入节点的一侧需要重写该侧的树以将该节点放置为子节点,否则该节点将作为子孙级别的节点,这消耗了一些搜索效率。
您必须记住,大多数教室示例都是简化的,目的是传达概念。在许多实际情况下并不值得一提。但是,“每个元素都有一个键,没有两个元素具有相同的键”的声明,并不违反在元素节点上使用列表的原则。
所以按照你的数据结构书中说的去做吧!
编辑:
二叉搜索树的通用定义涉及基于在两个方向之一遍历数据结构来存储和搜索关键字。从实际意义上讲,这意味着如果值为“<>”,则在两个“方向”之一遍历数据结构。因此,在这种意义上,重复值根本没有任何意义。
这与BSP(二元搜索分区)不同,但并不完全不同。搜索算法有两个方向进行“行进”,或者已经结束(成功或不成功)。因此,我很抱歉我的最初回答没有涉及“通用定义”的概念,因为重复项实际上是一个不同的主题(成功搜索后处理,而不是作为二叉搜索的一部分)。

2
使用节点列表的缺点是什么? - Pacerier
4
我认为,我们可以在每个节点上维护一个引用计数,并在出现重复时更新计数,而不是维护一个列表。这种算法在搜索和存储方面更容易和高效。此外,它对于现有的不支持重复的算法需要进行最小的更改。 - SimpleGuy
2
@SimpleGuy 如果你指的是一个引用列表,我可以同意。如果你真的指的是一个引用计数(即有多个节点但只有一个存储计数),我认为这不会起作用。哪个节点将维护计数?如果树需要将该节点重新平衡到较低的级别怎么办? - Andrew
1
@Andrew,我认为@SimpleGuy的意思是在每个节点上存储计数器以表示其值{num = 1, count 5}。我认为这对于基本数据类型和字符串来说很有效,但对于其他非原始数据类型则不适用。 - k4anubhav

69
如果你的二叉搜索树是红黑树,或者你打算进行任何类型的"树旋转"操作,重复的节点会引发问题。 想象一下你的树规则是这样的:
左 < 根 <= 右
现在想象一个简单的树,其根为5,左孩子为nil,右孩子为5。如果你在根上进行左旋转,你就会得到一个左孩子为5的5和一个根为5的根,右孩子为nil。现在左树中的某些内容等于根,但上面的规则假定左 < 根。
我花了数小时来解决我的红/黑树为什么会偶尔遍历顺序不正确的问题,问题就是我上面描述的问题。希望有人看到这篇文章并节省自己将来数小时的调试时间!

24
当节点相等时不要旋转!遍历到下一层并旋转那一层。 - Rich
2
其他解决方案是将树规则更改为 left <= node <= right,或仅在值的第一次出现之前插入。 - paxdiablo
这在实践中会带来什么问题?对我而言,如果你认为 left <= node <= right 是可以接受的,那么所有红黑树操作都将得到解决。 - Björn Lindqvist
@BjörnLindqvist 如另一个答案中所提到的,允许 <= <= 的问题在于你基本上破坏了首先拥有 BST 的主要目的之一:对已排序集合进行快速操作。除非你按照 Rich 所说的做法或者将所有重复项放入同一个节点中,否则你将不得不遍历整个树,可能要一直遍历到底部才能检查是否存在任何重复项。 - Andrew
@BjörnLindqvist 不,你不会这么做的。要么你需要打破那种模式来重新平衡,要么如我所说,如果有许多相同键的副本,你将为自己打开一个可能可怕的BST。如果你为了重新平衡而打破那个范例,那么在重新平衡之后,你可能会有左子节点的右后代或右子节点的左后代是等价的。 - Andrew
显示剩余2条评论

60

所有三个定义都是可接受和正确的。它们定义了BST的不同变体。

你们学院的数据结构书没有澄清它的定义不是唯一可能的。

当然,允许重复项会增加复杂性。如果你使用定义“左<=根<右”,并且你有这样一棵树:

      3
    /   \
  2       4

然后将一个“3”重复键添加到这棵树中,结果为:

      3
    /   \
  2       4
    \
     3

请注意,重复项不在连续的层级中。

当允许在上述BST表示中有重复项时,这是一个较大的问题:重复项可能由任意数量的级别分隔,因此检查重复项的存在并不像仅检查节点的直接子节点那样简单。

避免此问题的一种选项是不将重复项结构化表示(作为单独的节点),而是使用计数器来计算密钥的出现次数。则先前的示例将具有以下类似的树:

      3(1)
    /     \
  2(1)     4(1)

在插入重复的"3"键之后,它将变为:

      3(2)
    /     \
  2(1)     4(1)

这简化了查找、删除和插入操作,但会牺牲一些额外的字节和计数操作。


1
我非常惊讶,我的教科书甚至没有提到这一点。教授也没有提及,重复键甚至是一个问题也没有提到,唉... - Oloff Biermann
1
@OloffBiermann 可能是因为大多数人只是认为,“我们已经涵盖了二叉搜索树,./”,而没有考虑允许重复项的重大影响。也许他们决定如果你理解了BST,那么你可以根据需要进行自己的修改。为了辩护他们,仅仅可能的树结构数量就是巨大的;它们有非常多不同的实现细节。作为一个入门者,可以在这里看一下:https://en.wikipedia.org/wiki/List_of_data_structures#Trees - Andrew
真的很喜欢这里的计数器方法,看起来是一个不错的例子。 - Nuclearman

35

在二叉搜索树(BST)中,节点左侧下降的所有值都小于(或等于,稍后查看)该节点本身。同样,节点右侧下降的所有值都大于(或等于)该节点值(a)

一些BST可能选择允许重复的值,因此上面的“或等于”限定符。以下示例可能会更清楚:

     14
    /  \
  13    22
 /     /  \
1    16    29
          /  \
        28    29

这显示了一个允许重复的二叉搜索树(b) - 您可以看到,要查找一个值,您需要从根节点开始并向左或向右子树移动,具体取决于您的搜索值是小于还是大于节点值。

这可以通过递归实现,例如:

def hasVal (node, srchval):
    if node == NULL:
         return false
    if node.val == srchval:
        return true
    if node.val > srchval:
        return hasVal (node.left, srchval)
    return hasVal (node.right, srchval)

并使用以下方式调用:

foundIt = hasVal (rootNode, valToLookFor)

由于可能需要查找相同值的其他节点,因此重复项会增加一些复杂性。对于hasVal来说,显然这并不重要,因为有多少个副本并不重要,只要存在至少一个就可以了。但是对于像countVal这样的东西就很重要了,因为它需要知道有多少个。


(a)如果您愿意,实际上可以按相反的方向对它们进行排序,只要调整搜索特定键的方式即可。BST只需要维护一些排序顺序,无论是升序还是降序(甚至是一些奇怪的多层排序方法,例如所有奇数升序,然后所有偶数降序)都没有关系。


(b)有趣的是,如果您的排序键使用存储在节点中的完整值(以便包含相同键的节点没有其他额外信息来区分它们),则添加每个节点的计数可能会带来性能提升,而不是允许重复节点。

主要好处是添加或删除副本将仅修改计数,而不是插入或删除新节点(这可能需要重新平衡树)。

因此,要添加项,您首先需要检查它是否已经存在。如果是,则只需增加计数并退出。如果没有,则需要插入一个计数为一的新节点,然后重新平衡。

删除项,您找到它然后减少计数 - 仅当结果计数为零时,才从树中删除实际节点并重新平衡。

给定较少的节点,搜索也会更快,但这可能不会有很大的影响。

例如,以下两棵树(左侧为非计数树,右侧为计数树)将是等价的(在计数树中,i.c表示项目ic个副本):

     __14__                    ___22.2___
    /      \                  /          \
  14        22             7.1            29.1
 /  \      /  \           /   \          /    \
1    14  22    29      1.1     14.3  28.1      30.1
 \            /  \
  7         28    30

从左树中删除叶节点22需要重新平衡(因为它现在的高度差为2),这将涉及到重新平衡结果为22-29-28-30子树,如下所示(这是一个选项,还有其他选项也符合“高度差必须为零或一”的规则):

\                      \
 22                     29
   \                   /  \
    29      -->      28    30
   /  \             /
 28    30         22

对右子树执行相同的操作只需要将根节点从22.2修改为22.1(无需重新平衡)。


1
对于重复的情况,您可以检查右子节点是否与当前节点相同,在node.val == srchval:子句中,如果是,则向右移动。 - bneil
@bneil 已经晚了,但是:不行,因为自平衡二叉搜索树重新平衡到中位数以维护子树的合理高度/权重(你不想要一个双向链表),所以很容易出现左子节点、当前节点和右子节点都是相同值的情况,除非你明确确保例如使用 >= 比较,只有一组重复数据的最左节点可以成为父节点(从而确保所有节点都是右子节点);如果有许多相同的重复数据,则可能导致灾难性的树。 - Andrew
@bneil的回答解释得很好:“即使search()找到了具有相同键的节点,它也必须遍历到叶节点。” 例如,考虑在paxdiablo的答案中使用另一个29替换28的树。 您可以想象它们的子节点中可能还有更多的29。duilio的回答也有另一个很好的例子。 - Andrew

12
在《算法导论》第三版中,Cormen、Leiserson、Rivest和Stein明确将二叉搜索树(BST)定义为“允许重复项”。这可以在图12.1和随后的内容(第287页)中看到:

“二叉搜索树中的键始终以满足二叉搜索树属性的方式存储:设x是二叉搜索树中的一个节点。如果yx左子树中的一个节点,则y:key <= x:key。如果yx右子树中的一个节点,则y:key >= x:key。”

此外,在第308页中定义了红黑树为“带有每个节点一位额外存储空间的二叉搜索树:它的颜色”,因此,在此书中定义的红黑树支持重复项。

一个二叉搜索树不一定要允许重复,那只是一个选项。它也可以禁止奇数、质数、元音字母过多的字符串或任何其他类型的数据。唯一真正的要求是以某种方式排序,并且最好是自平衡的。 - paxdiablo

6

任何定义都是有效的。只要您在实现中保持一致(始终将相等节点放置到右侧,始终将它们放置到左侧或永远不允许它们),那么就可以了。我认为最常见的做法是不允许它们存在,但如果它们被允许并且被放置在左侧或右侧,则仍然是BST。


1
如果您有一个包含重复键的数据集,则这些项应通过不同的方法(链表等)全部存储在树上的1个节点中。树应仅包含唯一键。 - nickf
1
还要注意维基百科上指出,右子树包含的值“大于等于”根节点。因此,维基百科的定义是自相矛盾的。 - SoapBox
1
+1:不同的人使用不同的定义。如果您实现了一个新的BST,您需要确保明确说明您对重复条目所做的假设。 - Mr Fooz
1
似乎共识是(left <= root <= right),允许重复。但有些人对BST的定义不允许重复。或者一些人教授这种方式是为了避免额外的复杂性。 - Tim Merrifield
1
小于或大于关系可以指向左边或右边。你实现等于的位置取决于具体实现,但必须只在一侧。 - Mitch Wheat
显示剩余8条评论

5

我想在 @Robert Paulson 的回答中添加一些信息。

假设节点包含键和数据。因此,具有相同键的节点可能包含不同的数据。(因此搜索必须找到所有具有相同键的节点)

  1. left <= cur < right
  1. left < cur <= right
  1. left <= cur <= right
  1. left < cur < right && cur 包含具有相同键的 兄弟节点
  1. left < cur < right,且不存在重复的键。

1和2. 如果树没有任何旋转相关的函数来防止倾斜,则可以正常工作。
但这种形式不能AVL树Red-Black树 一起使用,因为旋转会破坏原则。
即使 search() 找到具有该键的节点,它也必须遍历到叶节点以获取具有重复键的节点。
搜索的时间复杂度为theta(logN)

3. 将适用于任何具有旋转相关函数的 BST。
但搜索将花费O(n),破坏使用BST的目的。
假设我们拥有以下树,具有 3) 原则。

         12
       /    \
     10     20
    /  \    /
   9   11  12 
      /      \
    10       12

如果在这棵树上执行search(12),即使我们在根节点找到了12,也必须同时搜索左右子节点以寻找重复的键。
如我所说,这需要O(n)的时间。

4. 是我个人最喜欢的方法。假设sibling表示具有相同键的节点。
我们可以将上述树更改为以下形式。
         12 - 12 - 12
       /    \
10 - 10     20
    /  \
   9   11

现在任何搜索都只需要O(logN) 的时间复杂度,因为我们不需要遍历重复键的子节点。这个原则也适用于AVL或RB树。

1
这是一个很好的答案。如果可以的话,我会将其标记为答案。 #4 绝对是“正确”的方法。(附注:这里没有涉及第六种方法,但我在下面的评论中回复了它:https://dev59.com/m3VC5IYBdhLWcg3wbQbA#339597) - Andrew

3
在实现红黑树时,我遇到了验证具有多个键的树存在问题,直到我意识到在红黑插入旋转中,您必须放宽约束条件至

left <= root <= right

由于我查看的所有文档都不允许重复键,而我又不想重新编写旋转方法以解决此问题,因此我决定修改我的节点以允许节点内部具有多个值且树中没有重复的键。


2

你说的这三件事都是真的。

  • 键是唯一的
  • 在左边的是比这个小的键
  • 在右边的是比这个大的键

我想你可以反转你的树,把更小的键放在右边,但实际上,“左”和“右”的概念只是一个视觉概念,帮助我们思考一个没有左或右的数据结构,所以它并不重要。


1
1.) left <= root < right 2.) left < root <= right 3.) 左子树小于根节点,右子树大于根节点,且没有重复的键。
我可能需要找出我的算法书,但从记忆中推断(3)是规范形式。 只有当您开始允许重复节点并将重复节点放入树本身(而不是包含列表的节点)时,才会出现(1)或(2)。

你能解释一下为什么 left <= root <= right 不是最理想的吗? - Helin Wang
1
请看一下@paxdiablo提供的被接受的答案 - 你可以看到重复的值可以使用>=存在。"理想" 取决于你的需求,但是如果你有很多重复的值,并且允许这些重复的值存在于结构中,你的二叉搜索树可能会变成线性的 - 即O(n)。 - Robert Paulson

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