【问题标题】:Avoid list concatenation during in-order binary tree traversal在有序二叉树遍历期间避免列表连接
【发布时间】:2020-12-22 18:43:43
【问题描述】:

Haskell 初学者在这里:二叉树的中序遍历很简单,例如:

data IntegerTree = Leaf Integer
                 | Node IntegerTree Integer IntegerTree

inorder :: IntegerTree -> [Integer]
inorder (Leaf n)     = [n]
inorder (Node l n r) = inorder l ++ [n] ++ inorder r

但是,在我看来,必须有一个更有效的实现。由于列表是单链接的,连接inorder l[n] 似乎很浪费,特别是因为对于一棵大树执行了多次此操作。我可以通过不同的方式编写相同的函数来避免这个问题吗?

我最初在尝试解决以类似方式构建移动列表的河内塔谜题时想到了这一点,我希望可以使用类似的递归算法来解决许多问题。

【问题讨论】:

  • 除了严重左偏的树之外,我认为inorder l ++ (n : inorder r) 将大大有助于避免(++) 的最坏情况行为。
  • 据我所知,ghc 总是将l ++ [x] ++ r 优化为l ++ x:r,即使是-O0,所以我已经养成了写前者的习惯,因为它通常更容易阅读。

标签: haskell binary-tree


【解决方案1】:

嵌套的++,作为您在有序访问中的那些,可能效率低下。这是因为list1 ++ list2 会复制list1(的书脊),如下:

(1:2:3:[]) ++ list2
=
1: ((2:3:[]) ++ list2)
=
1:2: ((3:[]) ++ list2)
=
1:2:3: ([] ++ list2)
=
1:2:3:list2

如果复制第一个列表可能不会那么糟糕,但是当我们嵌套 ++

((list1 ++ list2) ++ list3) ++ list4

我们复制副本的副本,这会减慢一切,通常会使 O(N^2) 成为 O(N)。

在计算list1 ++ list2 时,一个关键的想法是:如果我们只能在list1 中保留一个指向末尾[] 的“指针”,我们就可以避免复制,只需用指向list2,我们将获得恒定时间(破坏性)追加。

现在,我们在 Haskell 中有命令式的可变性吗?不适用于常规列表。但是,我们可以将列表转换为“结束的函数”,即,而不是编写

1:2:3:[]

对于列表,我们可以写

\end -> 1:2:3:end

表示相同的数据。列表的后一种表示称为“差异列表”。从常规列表转换为差异列表很简单:

type DList a = [a] -> [a]

toDList :: [a] -> DList a
toDlist = (++)

fromDlist :: DList a -> [a]
fromDlist dl = dl []

到目前为止一切都很好,但是如何连接两个DList a?我们需要采取类似的东西

list1 = \end -> 1:2:3:end
list2 = \end -> 4:5:6:7:end

然后返回

concatenate list1 list2 = \end -> 1:2:3:4:5:6:7:end

原来concatenate 只是函数组合,(.)。 IE。 list1 . list2 正是我们需要的串联。当我们评估时

fromDList (list1 . list2)
-- i.e.
(list1 . list2) []

没有复制,因为list1 的结尾立即链接到list2

因此,考虑到这一点,我们可以用最少的更改重写您的代码:

inorder :: IntegerTree -> [Integer]
inorder tree = fromDList (inorderDL tree)

inorderDL :: IntegerTree -> DList Integer
inorderDL (Leaf n)     = (n :)               -- that is, \end -> n: end
inorderDL (Node l n r) = inorderDL l . (n :) . inorderDL r

这不会复制列表,因为在每个递归步骤生成每个子列表时,它的尾部不会是[],而是指向要连接的下一个列表的“指针”。

最后,请注意,这种方法在不同的角度下,正是威廉在他的回答中使用的。

【讨论】:

  • 如果列表被临时使用,则不会复制。在左倾斜的退化树上它仍然是二次的。这个answer to "Why are difference lists more efficient than regular concatenation" 给出了解释。 Richard Bird 的把戏(见另一个答案),以及这个答案中的 DList 方法(甚至用explicitly manipulated data structure 替换++s),实际上都重新发现了John McCarthy 的旧gopher 技术。跨度>
  • @WillNess Uhm,你引用的答案说总成本是O(number of lists + sum (map length lists)),在这个例子中是 O(N),不管树是什么。我在这里错过了什么吗?答案确实表明,如果您将 任意函数 [a]->[a] 封装在 DLists 中,那么您可以获得二次性能,例如如果我们封装(++ list) 而不是(list ++),但这不适用于这里,AFAICS (?)
  • 是的,当然 O(N) 是正确的(对于 DL,即 ++ 树是二次的),它只是解释了 为什么,并且非常注重细节提到“复制”(在惰性评估下非常模糊)。关键的见解是在将组合的 DL 首次应用到 [] 哨兵之后,将 DL 中的 (.) 树重组为 ($) 列表(我在回答中提到了这一点;这就是我的理解) .关于潜在的二次行为,我认为这不适用于这里,是的。
  • 即答案表明,左嵌套 ++ 树的问题不在于 ++ 复制列表,它不复制任何内容,因为 Haskell 很懒;问题是它的结构是固定的,所以tail 之后的head 又是一个O(N) 操作,但是对于DL,tail 将结构向右旋转,以便headtail 之后是 O(1)(与显式数据和显式旋转相同,如我的链接答案中所建议的那样)。
【解决方案2】:

您可以传递一个额外的参数,即尾部:在该项目之后仍将生成的项目列表。这看起来像:

inorder :: IntegerTree -> [Integer]
inorder = go []
    where go tl (Leaf n) = n : tl
          go tl (Node l n r) = go (n : go tl r) l

因此tl 是节点结束后的元素列表。在顶层,该列表是空的(因此是 go [])。当我们看到一片叶子时,我们发出包裹在Leaf 中的项目n,然后是尾巴。

对于Node,我们因此将在左侧元素上执行递归,作为尾部n : go tl r,因此这是该节点的项目,然后是我们使用给定尾部tl的右子树上的递归再次。

【讨论】:

  • 好的,我想我明白了。非常清楚的解释,谢谢。
  • 注:这种模式被称为“差异列表”,并且在dlist 包@Peter 中可用(作为一种“感觉”像列表的新类型)
猜你喜欢
  • 1970-01-01
  • 2021-03-08
  • 2020-01-15
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-01-01
  • 2022-11-11
相关资源
最近更新 更多