我们来看一个简单的处理树的例子。清单 10.15 声明了一个表示整数树的类型,并用递归函数,统计树中所有值的和。
清单 10.15 树型数据结构并计算元素的和 (F# Interactive)
> type IntTree = [1]
| Leaf of int
| Node of IntTree * IntTree;;
type IntTree = (...)
> let rec sumTree(tree) = [2]
match tree with
| Leaf(n) -> n
| Node(l, r) -> sumTree(l) + sumTree(r);; ? 递归统计子树中值的和
val sumTree : IntTree -> int
表示树的 IntTree 类型,是有两个选项的差别联合。注意,它其实与列表类型非常相似的!树值既可以表示为包含整数的叶子,也可以节点;节点不包含数值,但它有两个 IntTree 类型的子树。求和的递归函数[2]使用模式匹配,区分这两种情况:对于叶子,返回数值;对于节点,需要递归地对左、右子树的元素求和,并把这两个值加到一起。
如果我们看一下 sumTree 函数,可以发现,它不是尾递归。它执行递归调用 sumTree,计算左子树中元素的和,然后需要执行一些另外的操作;更确切地说,还要计算右子树中元素的和,最后把这两个数字相加。我们不知道如何用尾递归的方式写这个函数,因为它要执行两个递归调用。最后,通过一些努力(通过使用某种类型的累加器参数),这两个调用可以改成尾递归,但是,我们还必须实现普通的递归调用!这很烦人,因为,对于一些大型的树,这种方法将导致栈溢出。
我们需要考虑不同的方法,首先,需要知道树到底是什么样子,图 10.6 显示了两种树。
图 10.6 平衡树和不平衡树的例子。暗圈对应节点,亮圈包含值,对应叶子
在图 10.6 中的平衡树(balancedtree),是一种相当典型的情况,树中的元素合理分布在左、右子树。这不是太坏的情况,因为我们从来不会有特别深的递归。(用我们当前的算法,最大递归深度,就是树的根和叶之间存在的较长路径。)不平衡(Imbalanced tree)的例子要危险得多,在右侧有许多节点元素,所以,当我们递归处理时,必须进行大量的递归调用。处理这两种树之间的差异如清单 10.16 所示。
清单 10.16 使用自然的递归函数统计树的和 (F# Interactive)
> let tree = Node(Node(Node(Leaf(5),Leaf(8)), Leaf(2)),
Node(Leaf(2), Leaf(9)))
sumTree(tree);;
val it : int = 26
> let numbers = List.init 100000 (fun _-> rnd.Next(– 50, 51);;
val numbers : int list = [29; -44; -1; 25;-33; 36; ...]
> let imbalancedTree =
numbers |> List.fold (fun currentTree num –>
Node(Leaf(num), currentTree)) (Leaf(0));; [1] ? 在当前树的右边创建节点
val imbalancedTree : IntTree
> sumTree(imbalancedTree);;
Process is terminated due toStackOverflowException.
第一个命令创建了简单的树,并计算了叶子值的和。第二个[好像应该是第三个]命令使用 fold 函数创建树,类似于在图 10.6 中的不平衡树,只是要更大一些;它首先有一个包含零的叶子,然后,每一步在当前树的右侧,追加一个有左侧叶子的新节点[1],从我们在清单 10.2 中创建的列表中取数,包含10 万个 –50 到 50 之间的随机数。结果,我们就会得到有 10 万个节点高度的树。当我们试图计算树中叶子的和时,会遭遇栈溢出。这不是特别典型的情况,但在处理树的代码中,我们仍可能会遇到;幸运的是,连续给我们提供了一种方法,即使像这样的树,函数也能正常运行。