我想先解释一下为什么问题中的代码会分配级别编号。这将直接引导我们找到两种不同的解决方案,一种是通过缓存,一种是基于同时进行两次遍历。最后,我展示了第二个解决方案与其他答案提供的解决方案之间的关系。
问题中的代码需要改变什么?
问题中的代码将级别编号分配给每个节点。我们可以通过查看number' 函数的递归情况来理解为什么代码会这样:
number' a (Node x xl xr) = Node (a,x) (number' (a+1) xl) (number' (a+1) xr)
请注意,我们对两个递归调用使用相同的数字a + 1。因此,两个子树中的根节点将被分配相同的编号。如果我们希望每个节点有不同的编号,我们最好将不同的编号传递给递归调用。
我们应该将什么数字传递给递归调用?
如果我们想根据从左到右的前序遍历来分配数字,那么a + 1对于左子树的递归调用是正确的,但对于右子树的递归调用则不正确。相反,我们希望留下足够的数字来注释整个左子树,然后开始用下一个数字来注释右子树。
我们需要为左子树保留多少个数字?这取决于子树的大小,由该函数计算得出:
size :: Tree a -> Int
size Empty = 0
size (Node _ xl xr) = 1 + size xl + size xr
回到number' 函数的递归情况。在左子树某处注释的最小数字是a + 1。在左子树某处注释的最大数字是a + size xl。所以右子树可用的最小数字是a + size xl + 1。这种推理导致number' 的递归案例的以下实现正常工作:
number' :: Int -> Tree a -> Tree (Int, a)
number' a Empty = Empty
number' a (Node x xl xr) = Node (a,x) (number' (a+1) xl) (number' (a + size xl + 1) xr)
不幸的是,这个解决方案有一个问题:它太慢了。
为什么size 的解决方案很慢?
函数size 遍历整个树。函数number' 也遍历整个树,并在所有左子树上调用size。这些调用中的每一个都将遍历整个子树。所以总的来说,函数size 在同一个节点上执行了不止一次,当然它总是返回相同的值。
调用size时如何避免遍历树?
我知道两种解决方案:要么我们通过缓存所有树的大小来避免在 size 的实现中遍历树,要么我们首先通过对节点编号并计算大小来避免调用 size遍历。
我们如何在不遍历树的情况下计算大小?
我们在每个树节点中缓存大小:
data Tree a = Empty | Node Int a (Tree a) (Tree a) deriving (Show)
size :: Tree a -> Int
size Empty = 0
size (Node n _ _ _) = n
请注意,在size 的Node 情况下,我们只返回缓存的大小。所以这种情况不是递归的,size 不会遍历树,我们上面实现number' 的问题就消失了。
但是关于size 的信息必须来自某个地方!每次我们创建Node 时,我们都必须提供正确的大小来填充缓存。我们可以将此任务交给智能构造函数:
empty :: Tree a
empty = Empty
node :: a -> Tree a -> Tree a -> Tree a
node x xl xr = Node (size xl + size xr + 1) x xl xr
leaf :: a -> Tree a
leaf x = Node 1 x Empty Empty
只有node 是真正需要的,但为了完整起见,我添加了另外两个。如果我们总是使用这三个函数之一来创建树,那么缓存的大小信息将始终正确。
以下是适用于这些定义的number' 版本:
number' :: Int -> Tree a -> Tree (Int, a)
number' a Empty = Empty
number' a (Node _ x xl xr) = node (a,x) (number' (a+1) xl) (number' (a + size xl + 1) xr)
我们必须调整两件事:在Node 上进行模式匹配时,我们忽略了大小信息。而在创建Node时,我们使用智能构造函数node。
这很好用,但它的缺点是必须更改树的定义。一方面,无论如何缓存大小可能是个好主意,但另一方面,它使用一些内存并且它迫使树是有限的。如果我们想在不改变树定义的情况下实现一个快速的number' 怎么办?这就引出了我承诺的第二个解决方案。
我们如何在不计算大小的情况下给树编号?
我们不能。但是我们可以在一次遍历中给树编号并计算大小,避免多次size 调用。
number' :: Int -> Tree a -> (Int, Tree (Int, a))
已经在类型签名中,我们看到这个版本的number' 计算了两条信息:结果元组的第一个组件是树的大小,第二个组件是带注释的树。
number' a Empty = (0, Empty)
number' a (Node x xl xr) = (sl + sr + 1, Node (a, x) yl yr) where
(sl, yl) = number' (a + 1) xl
(sr, yr) = number' (a + sl + 1) xr
实现从递归调用分解元组并组合结果的组件。请注意,sl 类似于上一个解决方案中的 size xl,sr 类似于 size xr。我们还必须给带注释的子树命名:yl 是带有节点号的左子树,所以就像前面解决方案中的 number' ... xl,yr 是带有节点号的右子树,所以就像 @987654366 @在上一个解决方案中。
我们还必须将number 更改为仅返回number' 结果的第二部分:
number :: Tree a -> Tree (Int, a)
number = snd . number' 1
我认为在某种程度上,这是最清晰的解决方案。
我们还能改进什么?
之前的解决方案通过返回子树的大小来工作。然后使用该信息来计算下一个可用节点号。相反,我们也可以直接返回下一个可用的节点号。
number' a Empty = (a, Empty)
number' a (Node x xl xr) = (ar, Node (a, x) yl yr) where
(al, yl) = number' (a + 1) xl
(ar, yr) = number' al xr
请注意,al 类似于前面解决方案中的 a + sl + 1,ar 类似于 a + sl + sr + 1。显然,此更改避免了一些添加。
这本质上是 Sergey 回答的解决方案,我希望这是大多数 Haskelers 会写的版本。您还可以在状态 monad 中隐藏 a、al 和 ar 的操作,但我认为这对于这样一个小例子没有帮助。 Ankur 的回答显示了它的样子。