左关联二叉树折叠

Posted

技术标签:

【中文标题】左关联二叉树折叠【英文标题】:Left associative binary tree fold 【发布时间】:2022-01-04 02:26:49 【问题描述】:

使用正常的右关联树折叠,我可以通过重新排列提供的函数 f 中的连接来定义前序/中序/后序:

const treeFoldr = f => acc => function go(t) 
  if (t[TAG] === "Leaf") return acc;
  else return f(t.v) (go(t.l)) (go(t.r));
;

const TAG = Symbol.toStringTag;

const N = (l, v, r) => ([TAG]: "Node", l, v, r);
const L = [TAG]: "Leaf";

const foo = N(
  N(
    N(L, 4, L),
    1,
    N(L, 5, L)
  ),
  0,
  N(
    L,
    2,
    N(L, 3, L)
  )
);

const r1 = treeFoldr(
  x => acc1 => acc2 => return [x].concat(acc1).concat(acc2)) ([]) (foo); // pre-order

const r2 = treeFoldr(
  x => acc1 => acc2 => return acc1.concat([x]).concat(acc2)) ([]) (foo); // in-order
  
const r3 = treeFoldr(
  x => acc1 => acc2 => return acc1.concat(acc2).concat([x])) ([]) (foo); // post-order

console.log(r2); // in-order [4,1,5,0,2,3]

现在我猜想左关联折叠也应该是这样,f 的结果被传递给下一个递归go 调用。但我能想到的只是这个硬编码的预购折叠:

treeFoldl = f => acc => function go(t) 
  if (t[TAG] === "Leaf") return acc;
  
  else 
    acc = f(acc) (t.v);
    acc = go(t.l);
    return go(t.r);
  
;

为了获得所需的设计,我必须以某种方式合并两个累加器(因为f 的签名)和左/右节点的递归调用,但我不知道如何。

这可能很容易,但我就是只见树木不见森林..

[编辑]

根据 cmets 的要求,这里是 treeFoldl 的纯版本:

const treeFoldl = f => init => t => function go(acc, u) 
  if (u[TAG] === "Leaf") return acc;
  
  else 
    const acc_ = go(f(acc) (u.v), u.l);
    return go(acc_,   u.r);
  
 (init, t);

【问题讨论】:

你能把你的treeFoldl改写成纯粹的吗?对acc 的分配让人非常困惑 “期望的设计”我想你的意思是一个折叠函数,它允许与你的treeFoldr 中演示的相同的界面,而是发展了一个扁平的尾递归过程?我认为这基本上是不可能的,因为f 调用的顺序必须有一个由折叠实现确定的顺序。在您的尝试中,您注意到了一个硬编码的预购,但重新安排了您可以实现中序和后序的操作。 这支持了为什么我建议不要将其视为 treeFoldRtreeFoldL 而是根据它们的名称来考虑各种折叠,inorderpreorder、@ 987654339@。您提议的treeFoldL 有两次对go 的调用,因此它仍然会演变为递归(非线性)过程。 inorderpreorderpostorder 都可以通过使用 cps 的尾递归来实现。 this related Q&A你应该很熟悉,the other answer包含了每个遍历的实现 我只想感谢所有参与者的非常有趣和启发性的 Q + A。 【参考方案1】:

使用正常的右关联树折叠,我可以通过重新排列提供的函数 f 中的连接来定义前序/中序/后序

您在那里定义的不是foldR 函数。它实际上是您的树结构的catamorphism(另请参阅this answer),并且具有您可以用它实现任何东西的优势。你可以实现fmap,构造一个相同形状的新树。或者你可以实现linear左右折叠:

// cata :: ((b, a, b) -> b), () -> b) -> Tree a -> b
const cata = (onNode, onLeaf) => tree => 
  if (t[TAG] === "Leaf")
    return onLeaf();
  else
    return onNode(
      cata(onNode, onLeaf)(t.l),
      t.v,
      cata(onNode, onLeaf)(t.r)
    );
;

// foldR :: ((a, b) -> b) -> b -> Tree a -> b
const foldR = f => init => t => cata(
  (l, v, r) => acc => l(f(v, r(acc)),
  () => acc => acc
)(t)(init);

// foldL :: ((b, a) -> b) -> b -> Tree a -> b
const foldL = f => init => t => cata(
  (l, v, r) => acc => r(f(l(acc), v),
  () => acc => acc
)(t)(init);

如果没有 catamorphism,实现应该如下所示:

// foldR :: ((a, b) -> b) -> b -> Tree a -> b
const foldR = f => init => t => 
  if (t[TAG] === "Leaf")
    return init;
  else 
    const acc1 = foldR(f)(init)(t.r);
    const acc2 = f(t.v, acc1);
    return foldR(f)(acc2)(t.l);
  
;

// foldL :: ((b, a) -> b) -> b -> Tree a -> b
const foldL = f => init => t => 
  if (t[TAG] === "Leaf")
    return init;
  else 
    const acc1 = foldL(f)(init)(t.l);
    const acc2 = f(acc1, t.v);
    return foldL(f)(acc2)(t.r);
  
;

这些都没有前序或后序变体,有关树形状的信息会丢失给归约函数。线性折叠总是有序的,只是左右变体之间的关联性不同。

【讨论】:

变质现象的精彩演示。在我的回答中写出 meta-circular evaluator 部分时,我还想象了一个控制反转,其中 lr 是函数,允许调用者在回调 @987654330 中适当地对它们进行排序@。这伴随着问题中提议的界面的变化,但我认为这种交易非常公平。出色的帖子,Bergi:D 如果你想控制评估的顺序(本质上让它变得懒惰),我可能会将onNode回调更改为(() -> b, a, () -> b) -> b 我已经习惯了列表折叠/cata一致的事实。这是类型的结果,还是有规律要求树折叠必须有序? 你的第二个foldR(不带cata)是[].reduceRight意义上的正确折叠吗?我认为f 应该在递归步骤之前访问它的第一个参数。然而,对于命令式数组,我猜它并没有太大的区别。 () => acc => acc 只是 () => id。我不知道你为什么在那里使用 thunk,否则叶子处理程序将只是 id【参考方案2】:

一厢情愿

鉴于用户提供的各种折叠功能 -

traversal user-supplied f
preorder l => v => r => [v].concat(l).concat(r)
inorder l => v => r => l.concat([v]).concat(r)
postorder l => v => r => l.concat(r).concat([v])

如果我们希望我们的愿望成真,我们可以推断lr必须已经是数组,否则Array.prototype.concat不可用。

鉴于你的树 -

    0
   / \
  1   2
 / \   \
4   5   3

这些折叠函数f 的本质是将复合节点值展平为单个数组值。在第一个节点之后,无论选择哪种f 遍历,输出都将为[0],它现在是下一个节点的累加器-

分叉累加器

               f([], 0, [])
             /              \
            /                \
    f([0], 1, [0])     f([0], 2, [0])
    /            \    /              \
   ...           ... ...             ... 

在这里我们应该已经能够看到一个问题。累加器已经变平,“左”和“右”相对于零的位置是丢失的信息。更糟糕的是,累加器已分叉,我们将需要一些合并解决方案来协调这个拆分。合并解决方案如何在不强制执行某种排序以确保其预期结果的情况下工作?

顺序折叠

如果你说,“不,我们不会分叉累加器”,而是序列调用f,我们先折叠左分支吗?我们先折叠右分支吗?

              f(..., 0, ...)
             /              \
            /                \
    f(..., 1,          f(..., 2, ...))
    /                                \
   ?               ???                ? 

即使我们可以设法对f 进行这些有效调用,首先选择左分支或右分支排序。

元循环评估器

所以我们现在已经两次陷入死胡同。好的,让我们回到开头,假设我们可以更改用户提供的遍历 -

traversal user-supplied f
preorder l => v => r => [v, l, r]
inorder l => v => r => [l, v, r]
postorder l => v => r => [l, r, v]

在这种安排下,lr 不再局限于数组,而是可以是任何东西。它们可以是 treeFoldL 识别并用于指导其不断增长的计算过程的对象、标识符或其他类型的标记。 As you know 有足够的工程,我们可以让它为所欲为 -

    0
   / \
  1   2
 / \   \
4   5   3

例如,考虑这个inorder遍历-

[ L, 0, R ]
[ ...[L, 1, R], 0, R ]
[ L, 1, R, 0, R ]
[ ...[L, 4, R], 1, R, 0, R ]
[ L, 4, R, 1, R, 0, R ]
[ ...[], 4, R, 1, R, 0, R ]
[ 4, R, 1, R, 0, R ]
[ 4, ...[], 1, R, 0, R ]
[ 4, 1, R, 0, R ]
[ 4, 1, ...[L, 5, R], 0, R ]
[ 4, 1, L, 5, R, 0, R ]
[ 4, 1, ...[], 5, R, 0, R ]
[ 4, 1, 5, ...[], 0, R ]
[ 4, 1, 5, 0, R ]
[ 4, 1, 5, 0, ...[L, 2, R] ]
[ 4, 1, 5, 0, L, 2, R ]
[ 4, 1, 5, 0, ...[], 2, R ]
[ 4, 1, 5, 0, 2, R ]
[ 4, 1, 5, 0, 2, ...[L, 3, R] ]
[ 4, 1, 5, 0, 2, L, 3, R ]
[ 4, 1, 5, 0, 2, ...[], 3, R ]
[ 4, 1, 5, 0, 2, 3, R ]
[ 4, 1, 5, 0, 2, 3, ...[] ]
[ 4, 1, 5, 0, 2, 3 ]

为了保持这种通用性,这需要一个 ordering 函数与折叠函数 f 分开提供,它现在是一个熟悉的 2 元接口 -

const treeFoldl = ordering => f => init => t =>
  // ...

const inorder =
  treeFoldL (l => v => r => [l, v, r])

const concat = a => b =>
  a.concat(b)
const toArray =
  inorder (concat) ([])

toArray (myTree) // => [...]
const toString =
  inorder (concat) ("")

toString (myTree) // => "..."

回到地球

因此,您可以克服麻烦并以这种方式编写treeFoldl,但是它忽略了最明显的实现,imo。从我看到的地方,没有treeFoldltreeFoldr,而是只有preorderinorderpostorderlevelorderwhateverorder。出于关联性的目的,有foldlfoldr -

这是一个通用的foldl,它接受任何可迭代的it -

const foldl = f => init => it => 
  let acc = init
  for (const v of it)
    acc = f(acc, v)
  return acc

还有一些可能的二叉树遍历,这里写成生成器,但你可以使用任何你喜欢的实现,堆栈安全或其他 -

function *preorder(t) 
  if (t?.[TAG] == "Leaf") return
  yield t.v
  yield *preorder(t.l)
  yield *preorder(t.r)


function *inorder(t) 
  if (t?.[TAG] == "Leaf") return
  yield *inorder(t.l)
  yield t.v
  yield *inorder(t.r)


function *postorder(t) 
  if (t?.[TAG] == "Leaf") return
  yield *postorder(t.l)
  yield *postorder(t.r)
  yield t.v


function *levelorder(t) 
  // pop quiz: 
  // how would you write levelorder using your original interface?
  // ...

而不是在您的遍历中纠结左/右关联性,这些东西是分开的,并且仍然是调用者的选择。使用它们很简单 -

// left
foldl (concat) ([]) (preorder(t)) // => [...]
foldl (concat) ([]) (inorder(t))  // => [...]
foldl (concat) ([]) (postorder(t)) // => [...]

// or right
foldr (concat) ([]) (preorder(t)) // => [...]
foldr (concat) ([]) (inorder(t))  // => [...]
foldr (concat) ([]) (postorder(t)) // => [...]
// left
foldl (concat) ("") (preorder(t)) // => "..."
foldl (concat) ("") (inorder(t))  // => "..."
foldl (concat) ("") (postorder(t)) // => "..."

// or right
foldr (concat) ("") (preorder(t)) // => "..."
foldr (concat) ("") (inorder(t))  // => "..."
foldr (concat) ("") (postorder(t)) // => "..."

关注点分离的优点很多。 foldlfoldr 适合通用以处理任何可序列化的数据类型。并且它是该数据类型的域来指定它可以被排序的方式。

【讨论】:

我喜欢你开箱即用的想法。对于普通列表,折叠可以重建它们,折叠/变态是相同的。对于 BST 类型,我也认为这是理所当然的,但是折叠/cata 分歧,它们是折叠时的信息丢失。 这总是一个有趣的探索和讨论。感谢您多年来分享您的工作:D

以上是关于左关联二叉树折叠的主要内容,如果未能解决你的问题,请参考以下文章

[数据结构4.8]平衡二叉树

二叉树

树与二叉树之二--二叉树的性质与存储

怎么根据二叉树的前序,中序,确定它的后序

二叉树区分左右

Swift 数据结构 - 二叉树(Binary Tree)