是否可以通过O(1)内存使用延迟遍历递归数据结构,尾部调用优化?

时间:2013-08-20 22:06:02

标签: scala haskell data-structures functional-programming binary-tree

假设我们有一个递归数据结构,就像二叉树一样。有许多方法可以遍历它,它们具有不同的内存使用配置文件。例如,如果我们只是打印每个节点的值,使用伪代码,如下面的有序遍历......

visitNode(node) {
    if (node == null) return;
    visitNode(node.leftChild);
    print(node.value);
    visitNode(node.rightChild);
}

...我们的内存使用量是常量,但由于递归调用,我们会增加调用堆栈的大小。在非常大的树上,这可能会溢出它。

假设我们决定针对调用堆栈大小进行优化;假设这种语言能够正确地进行尾调用,我们可以将其重写为以下预先遍历...

visitNode(node, nodes = []) {
    if (node != null) {
        print(node.value);
        visitNode(nodes.head, nodes.tail + [node.left, node.right]);
    } else if (node == null && nodes.length != 0 ) {
        visitNode(nodes.head, nodes.tail);
    } else return;
}

虽然我们永远不会破坏堆栈,但现在我们会看到堆使用量相对于树的大小呈线性增长。

让我们说我们当时试图懒洋洋地穿越树 - 这是我的推理变得模糊的地方。我认为即使使用基本的懒惰评估策略,我们也会以与尾部优化版本相同的速度增长内存。下面是使用Scala的Stream类的具体示例,它提供了惰性求值:

sealed abstract class Node[A] {
  def toStream: Stream[Node[A]]
  def value: A
}

case class Fork[A](value: A, left: Node[A], right: Node[A]) extends Node[A] {
  def toStream: Stream[Node[A]] = this #:: left.toStream.append(right.toStream)
}

case class Leaf[A](value: A) extends Node[A] {
  def toStream: Stream[Node[A]] = this #:: Stream.empty
}

虽然只对流的头部进行严格评估,但只要评估left.toStream.append(right.toStream)我认为这实际上会评估左右流的头部。即使它没有(由于追加聪明),我认为递归地构建这个thunk(从Haskell借用一个术语)本质上会以相同的速率增长内存。我不是说“将这个节点放在要遍历的节点列表中”,而是基本上说,“这是评估的另一个值,它会告诉你下一步要穿什么”,但结果是一样的;线性记忆增长。

我能想到的唯一策略是避免这种情况,即在每个节点中都有可变状态,声明已经遍历了哪些路径。这将允许我们有一个引用透明的函数,它说:“给定一个节点,我将告诉你下一个应该遍历的单个节点”,我们可以用它来构建一个O(1)迭代器。

是否有另一种方法可以实现O(1),对二叉树进行尾调优化遍历,可能没有可变状态?

4 个答案:

答案 0 :(得分:12)

  

是否有另一种方法可以实现O(1),对二叉树进行尾调优化遍历,可能没有可变状态?

正如我在评论中所述,如果树不需要在遍历中存活,您就可以这样做。这是一个Haskell示例:

data T = Leaf | Node T Int T

inOrder :: T -> [Int]
inOrder Leaf                     =  []
inOrder (Node Leaf x r)          =  x : inOrder r
inOrder (Node (Node l x m) y r)  =  inOrder $ Node l x (Node m y r)

如果我们假设垃圾收集器将清理我们刚处理的任何Node,则需要O(1)辅助空间,因此我们有效地用右旋转版本替换它。但是,如果我们处理的节点不能立即被垃圾收集,那么final子句可能会在它到达叶子之前建立一个O( n )个节点。

如果你有父指针,那么它也是可行的。但是,父指针需要可变状态,并且防止共享子树,因此它们实际上不起作用。如果您通过最初(cur, prev)的{​​{1}}对表示迭代器,则可以按照here概述执行迭代。但是,你需要一种带指针比较的语言来实现这一目的。

如果没有父指针和可变状态,您需要维护一些数据结构,至少跟踪树根的位置以及如何到达那里,因为在有序或某些时候您需要这样的结构。后序遍历。这样的结构必然需要Ω( d )空间,其中<​​em> d 是树的深度。

答案 1 :(得分:8)

一个奇特的答案。

我们可以使用免费的monad来获得有效的内存利用率。

    {-# LANGUAGE RankNTypes
               , MultiParamTypeClasses
               , FlexibleInstances
               , UndecidableInstances #-}

    class Algebra f x where
      phi :: f x -> x

对于某些f,仿函数phi的代数是从f xx的函数x。例如,任何monad都有任何对象m x的代数:

    instance (Monad m) => Algebra m (m x) where
      phi = join

可以构造任何仿函数f的免费monad(可能只有某种类型的仿函数,比如omega-cocomplete,或者其他类似的;但是所有Haskell类型都是多项式仿函数,它们是omega-cocomplete,所以对于所有Haskell仿函数来说,这个陈述肯定是正确的:

    data Free f a = Free (forall x. Algebra f x => (a -> x) -> x)
    runFree g (Free m) = m g

    instance Functor (Free f) where
      fmap f m = Free $ \g -> runFree (g . f) m

    wrap :: (Functor f) => f (Free f a) -> Free f a
    wrap f = Free $ \g -> phi $ fmap (runFree g) f

    instance (Functor f) => Algebra f (Free f a) where
      phi = wrap

    instance (Functor f) => Monad (Free f) where
      return a = Free ($ a)
      m >>= f = fjoin $ fmap f m

    fjoin :: (Functor f) => Free f (Free f a) -> Free f a
    fjoin mma = Free $ \g -> runFree (runFree g) mma

现在我们可以使用Free为仿函数T a构建免费的monad:

    data T a b = T a b b
    instance Functor (T a) where
      fmap f (T a l r) = T a (f l) (f r)

对于这个仿函数,我们可以为对象[a]

定义代数
    instance Algebra (T a) [a] where
      phi (T a l r) = l++(a:r)

树是游戏T a上的免费monad:

    type Tree a = Free (T a) ()

它可以使用以下函数构造(如果定义为ADT,它们是构造函数名称,所以没什么特别的):

    tree :: a -> Tree a -> Tree a -> Tree a
    tree a l r = phi $ T a l r -- phi here is for Algebra f (Free f a)
    -- and translates T a (Tree a) into Tree a

    leaf :: Tree a
    leaf = return ()

演示其工作原理:

    bar = tree 'a' (tree 'b' leaf leaf) $ tree 'r' leaf leaf
    buz = tree 'b' leaf $ tree 'u' leaf $ tree 'z' leaf leaf
    foo = tree 'f' leaf $ tree 'o' (tree 'o' leaf leaf) leaf

    toString = runFree (\_ -> [] :: String)

    main = print $ map toString [bar, buz, foo]

当runFree遍历树以用leaf ()替换[]时,所有上下文中T a [a]的代数是构造表示树的有序遍历的字符串的代数。因为仿函数T a b构造了一个新的树,它必须具有与larsmans引用的解决方案相同的内存消耗特性 - 如果树没有保存在内存中,节点一被替换就被丢弃表示整个子树的字符串。

答案 2 :(得分:1)

鉴于你有节点父母的引用,有一个很好的解决方案here。用尾递归调用替换while循环(传入lastcurrent,这应该这样做。

内置的反向引用允许您跟踪遍历排序。如果没有这些,我就无法想到在辅助空间小于O(log(n))的(平衡)树上做到这一点的方法。

答案 3 :(得分:0)

我无法找到答案,但我得到了一些指示。去看看http://www.ics.uci.edu/~dan/pub.html,向下滚动到

  

[33] D.S. Hirschberg和S.S.Seiden,有界空间树遍历算法,信息处理快报47(1993)

下载postscript文件,您可能需要convert它到PDF(我的ps查看器无法正确显示)。它在第2页(表1)中提到了许多算法和其他文献。