假设我们有一个递归的数据结构,比如二叉树。有很多遍历它的方法,它们具有不同的内存使用特点。例如,如果我们仅仅打印每个节点的值,使用伪代码像以下中序遍历...
visitNode(node) {
if (node == null) return;
visitNode(node.leftChild);
print(node.value);
visitNode(node.rightChild);
}
...我们的内存使用量将保持不变,但由于递归调用,我们会增加调用栈的大小。在非常大的树上,这可能会导致溢出。
假设我们决定优化调用栈大小;假设该语言能够进行 proper tailcalls,则我们可以将其重写为以下的先序遍历...
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)
被求值时,我认为这实际上会评估左右两个流的头部。即使它没有(由于追加 cleverness),我认为 递归构建这个 thunk(借用 Haskell 的术语)本质上会以相同的速率增加内存。我们不是说,“把这个节点放在要遍历的节点列表中”,而是基本上说,“这里有另一个要评估的值,它将告诉您下一个要遍历的内容”,但结果是相同的;线性内存增长。
我唯一能想到的避免这种情况的策略是在每个节点中具有可变状态,声明已经遍历过的路径。这将允许我们拥有一个参考透明的函数,该函数表示:“给定一个节点,我将告诉您下一个应该遍历的单个节点”,并且我们可以使用该函数构建 O(1) 迭代器。
是否有另一种方法可以实现二叉树的 O(1)、尾调用优化遍历,可能不需要可变状态?