【问题标题】:Trees with values on the leaves only仅在叶子上有值的树
【发布时间】:2015-01-26 21:32:20
【问题描述】:

几年前,在 C# 课程中,我学会了编写二叉树,看起来或多或少像这样:

data Tree a = Branch a (Tree a) (Tree a) | Leaf

我看到了它的好处,它在分支上有它的值,这允许快速轻松地查找和插入值,因为它会在每个分支的根上遇到一个值,直到它遇到一个叶,没有任何价值。

然而,自从我开始学习 Haskell 以来;我见过很多这样定义的树的例子:

data Tree a = Branch (Tree a) (Tree a) | Leaf a

这个定义让我很困惑。我看不出在 分支的元素上有数据的用处,因为它最终会导致一棵看起来像这样的树:

在我看来,这似乎是一个设计不佳的列表替代品。这也让我质疑它的查找时间,因为它无法评估要向下查找哪个分支以找到它正在寻找的值;而是需要遍历每个节点才能找到它要查找的内容。

那么,谁能解释一下为什么第二个版本(叶子的价值)在 Haskell 中比第一个版本更普遍?

【问题讨论】:

  • 确定你可以在 C# 中做到这一点,而且也相当容易;如果你知道怎么做
  • 它们只是两种不同的数据结构,可能有不同的用途、优点、缺点(也可能没有)。例如,Data.IntMap 是后一种形式(仅叶子上的数据),Data.Map 是前一种形式。两者兼得有什么意义?文档中有这样的说法:“[IntMap] 在联合和交集等二元运算上表现得特别好。但是,我的基准测试表明,与 [Data.Map] 相比,它在插入和删除方面也(快得多)”。最后,我不会说第二个版本“更流行”。

标签: haskell tree binary-tree


【解决方案1】:

我认为这取决于您要建模的内容以及您尝试建模的方式。

内部节点存储值而叶子只是叶子的树本质上是一棵标准的二叉树(每个叶子的树都为NULL,您基本上有一个命令式二叉树)。如果这些值是按排序顺序存储的,那么您现在就有了一个二叉搜索树。以这种方式存储数据有许多特定的优势,其中大部分直接从命令式设置转移过来。

叶子存储数据而内部节点仅用于结构的树确实有其优势。例如,红/黑树支持两个强大的操作,称为splitjoin,它们在某些情况下具有优势。 split 将一个键作为输入,然后破坏性地修改树以生成两棵树,其中一棵包含小于指定输入键的所有键,另一棵包含剩余的键。 join 在某种意义上是相反的:它接收两棵树,其中一棵树的值都小于另一棵树的值,然后将它们融合在一起成为一棵树。这些操作在大多数红/黑树上特别难以实现,但如果所有数据仅存储在叶子中而不是内部节点中,则要简单得多。 This paper detailing an imperative implementation of red/black trees 提到一些旧的红/黑树实现正是出于这个原因使用这种方法。

作为将键存储在叶子中的另一个潜在优势,假设您要实现连接操作,它将两个列表连接在一起。如果叶子中没有数据,这很简单

concat first second = Branch first second

这是可行的,因为这些节点中没有存储任何数据。如果数据存储在叶子中,您需要以某种方式将密钥从其中一个叶子移动到新的串联节点,这需要更多时间并且更难处理。

最后,在某些情况下,您可能希望将数据存储在叶子中,因为叶子与内部节点根本不同。例如,考虑一个解析树,其中叶子存储解析中的特定终端,内部节点存储生产中的所有非终端。在这种情况下,确实有两种不同类型的节点,因此在内部节点中存储任意数据是没有意义的。

希望这会有所帮助!

【讨论】:

  • 我可以在解析树的情况下看到它,其中,假设运算符是分支,操作数是叶子;但是,我仍然看不到拥有叶子中数据的数据树的好处,您最终会得到这棵根本不包含任何数据的巨型树,然后是一堆数据。不会像疯了一样做那个伤害查找时间吗?那么使用普通列表不是更好吗?
  • @ElectricCoffee 我认为这取决于你想做什么。如果您没有按排序顺序存储元素,您可以为树设置一个严格规定的结构(例如,完美的二叉树),然后通过对这些树的大小使用数学来按索引查找各个元素。这实际上有时在实践中完成;查看倾斜二项式随机访问列表作为示例。但是,如果您正在尝试制作 BST,那么将元素纯粹存储在叶子中而不在节点中留下一些辅助数据绝对不是一个好主意。
  • @ElectricCoffee 一棵霍夫曼树就是关于穿过树的路径。除了指向子节点的指针之外,节点不需要任何东西。这只是节点不需要数据的众多用例示例之一。
  • @ElectricCoffee,“根本不包含数据的巨型树,最后是一堆数据”:从问题中的图表可以看出,叶子集更加“巨型” " 比树的其余部分。
【解决方案2】:

您将一棵树的叶子数据描述为“一个设计不佳的列表替代方案”。

我同意这可以用作列表的替代品,但它不一定设计得很糟糕!考虑数据类型

data Tree t = Leaf t | Branch (Tree t) (Tree t)

您可以定义conssnoc(追加到列表末尾)操作-

cons :: t -> Tree t -> Tree t
cons t (Leaf s)     = Branch (Leaf t) (Leaf s)
cons t (Branch l r) = Branch (cons t l) r

snoc :: Tree t -> t -> Tree t
snoc (Leaf s)     t = Branch (Leaf s) (Leaf t)
snoc (Branch l r) t = Branch l (snoc r t)

这些在 O(log n) 时间内运行(对于大致平衡的列表),其中 n 是列表的长度。这与标准链表形成对比,后者具有 O(1) cons 和 O(n) snoc 操作。您还可以定义一个恒定时间append(如 templatetypedef 的回答)

append :: Tree t -> Tree t -> Tree t
append l r = Branch l r

对于两个任意大小的列表,这是 O(1),而标准列表是 O(n),其中 n 是左侧参数的长度。

在实践中,您可能希望定义这些函数的稍微更智能的版本,以保持树平衡。要做到这一点,在分支处拥有一些附加信息通常很有用,这可以通过拥有多种分支来完成(如在具有“红色”和“黑色”节点的红黑树中)或显式包含附加数据在树枝上,如

data Tree b a = Leaf a | Branch b (Tree b a) (Tree b a)

例如,您可以通过将两个子树中的元素总数存储在节点中来支持 O(1) size 操作。由于您需要正确保存有关子树大小的信息,因此您对树的所有操作都会变得稍微复杂一些——实际上,计算树大小的工作已分摊到构建树的所有操作中(并且巧妙地保存了,这样当您以后需要重建尺寸时,只需完成最少的工作)。

【讨论】:

  • 使用这种列表更容易将分支添加到树中,这非常好;如果分支本身不包含任何数据,我只是看不出会有什么好处。为什么要添加更多无数据分支?这不是不必要地增加了树的大小吗?
  • 请注意,此数据类型包括(非空)链表作为子集,因为您可以将所有左侧分支设置为 Leaf a。所以这个数据结构可以做任何常规链表可以做的事情(而且它可以快速访问最后一个元素,快速 snoc,快速追加等)。非空列表是data List a = Leaf a | Branch a (List a)——你会说这在分支上有数据吗?如果是这样,那么从某种意义上说,叶子上有数据的树在树枝上也有数据(只是数据是另一棵树的形式)。
【解决方案3】:

更多是更好 更坏。我将仅解释几个基本注意事项,以说明您的直觉为何失败。不过,总体思路是,不同的数据结构需要不同的东西。

在某些情况下,空叶节点实际上可能是空间(以及时间)问题。如果一个节点由一些信息和两个指向其子节点的指针表示,那么每个节点都将得到两个空指针,其子节点都是叶子。这是每个叶节点两个机器字,这可以加起来相当多的空间。一些结构通过确保每个叶子至少包含一条信息来证明其存在的合理性来避免这种情况。在某些情况下(例如ropes),每个叶子可能有一个相当大且密集的有效载荷。

使内部节点更大(通过在其中存储信息)使得修改树的成本更高。更改平衡树中的叶子通常会强制您为 O(log n) 内部节点分配替换。如果每个都更大,那么您只是分配了更多空间并花费了额外的时间来复制更多单词。内部节点的额外大小也意味着您可以将更少的树 结构 放入 CPU 缓存中。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2019-11-21
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-12-26
    相关资源
    最近更新 更多