假设我们有一个递归数据结构,比如二叉树。有很多方法可以遍历它,并且它们具有不同的内存使用配置文件。例如,如果我们要简单地打印每个节点的值,使用如下的按顺序遍历的伪代码......
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)、尾调用优化的二叉树遍历,可能没有可变状态?