Blog

C# 树的 DFS:路径和、直径与祖先

几乎所有树的题目都归结为一个问题:这个量是沿着树向下传,还是向上返回?路径和、不经过根的直径、最近公共祖先,以及人人第一次都会写错的二叉搜索树校验。

第 12 篇讲了怎样走到每个节点。这一篇讲走到之后要算什么,而这些内容几乎都归结为一个区别。

下面每个程序都是完整的,都在 .NET 10 上跑过,输出直接从运行结果里复制。

模式 61 — 从根到叶子的路径

收集从根到叶子的每一条路径,并找出和等于目标值的那些。

当前路径没法向上返回,因为它是来自上方的信息。所以它要向下传,放在一个列表里:进入节点时加进去,离开时再删掉。

//           5
//         /   \
//        4     8
//       /     / \
//      11   13   4
//     /  \        \
//    7    2        1
TreeNode tree = new(5,
    new TreeNode(4, new TreeNode(11, new TreeNode(7), new TreeNode(2))),
    new TreeNode(8, new TreeNode(13), new TreeNode(4, null, new TreeNode(1))));

// The path so far is CARRIED DOWN in a list that is mutated and then undone.
// That undo is the same move backtracking is built on — see part 15.
static void Paths(TreeNode? n, List<int> path, List<string> found, int target)
{
    if (n is null) return;

    path.Add(n.Value);

    bool isLeaf = n.Left is null && n.Right is null;
    if (isLeaf)
    {
        int sum = path.Sum();
        string mark = sum == target ? "  <- sums to " + target : "";
        found.Add($"{string.Join(" -> ", path),-22} = {sum,3}{mark}");
    }
    else
    {
        Paths(n.Left, path, found, target);
        Paths(n.Right, path, found, target);
    }

    path.RemoveAt(path.Count - 1);          // undo, so a sibling starts clean
}

List<string> found = [];
Paths(tree, [], found, 22);
foreach (string s in found) Console.WriteLine(s);

Console.WriteLine();
Console.WriteLine("A leaf is a node with NO children. A node with one child is not a leaf,");
Console.WriteLine("which is why 8 -> 4 -> 1 is a path and 8 -> 4 is not.");

class TreeNode(int value, TreeNode? left = null, TreeNode? right = null)
{
    public int Value = value;
    public TreeNode? Left = left, Right = right;
}

输出:

5 -> 4 -> 11 -> 7      =  27
5 -> 4 -> 11 -> 2      =  22  <- sums to 22
5 -> 8 -> 13           =  26
5 -> 8 -> 4 -> 1       =  18

A leaf is a node with NO children. A node with one child is not a leaf,
which is why 8 -> 4 -> 1 is a path and 8 -> 4 is not.

递归调用之后的 path.RemoveAt(path.Count - 1) 就是这个模式的全部。没有这一行,列表会留下访问过的每一个节点,第二条路径就错了。第 15 篇的回溯全都建立在这种“先加、再撤销”的结构上,值得先在这个小例子里认出来。

另一个坑是叶子的定义。叶子是一个孩子都没有的节点。 只有一个孩子的节点不是叶子,所以输出里没有 8 -> 4,却有 8 -> 4 -> 1。如果写成 if (n.Left is null) return path.Sum();,就把只有一个孩子的节点当成了叶子,得到的短路径看起来几乎是对的。

代价: 访问 O(n) 个节点;如果把每条路径都实际生成出来,总共 O(n·h)。

适用场景: 答案是关于从根出发的完整路线:路径总和、所有路径、从叶子开始的最小字符串。

模式 62 — 递归返回什么,向下传什么

这一篇剩下的内容都建立在这个想法上。

5 4 8 7 2 向下 传递 参数 向上 返回 返回值 深度、当前路径、最小/最大范围 — 查看任何子节点之前就已知。 高度、子树和、节点数 — 两个子节点都答复之后才知道。

几乎每道树的题目都属于这两类之一,判断是哪一类,就决定了那一行代码写在函数的哪里。有些题两者同时需要:范围向下传,结论向上返回。

TreeNode tree = new(5,
    new TreeNode(4, new TreeNode(11, new TreeNode(7), new TreeNode(2))),
    new TreeNode(8, new TreeNode(13), new TreeNode(4, null, new TreeNode(1))));

// CARRIED DOWN: the parameter. Information flowing from the root towards leaves.
static void CarryDown(TreeNode? n, int depth, List<string> log)
{
    if (n is null) return;
    log.Add($"  node {n.Value,2} is at depth {depth}");
    CarryDown(n.Left, depth + 1, log);
    CarryDown(n.Right, depth + 1, log);
}

// RETURNED UP: the return value. Information flowing from leaves to the root.
static int ReturnUp(TreeNode? n)
{
    if (n is null) return 0;
    int left = ReturnUp(n.Left);
    int right = ReturnUp(n.Right);
    return 1 + Math.Max(left, right);       // needs BOTH children's answers first
}

List<string> log = [];
CarryDown(tree, 0, log);
Console.WriteLine("carried down (a parameter) — depth of each node:");
foreach (string s in log) Console.WriteLine(s);

Console.WriteLine($"\nreturned up (a return value) — height of the tree: {ReturnUp(tree)}");

Console.WriteLine();
Console.WriteLine("Depth is known on the way DOWN, before any child is examined.");
Console.WriteLine("Height is only known on the way UP, after both children have answered.");
Console.WriteLine("Deciding which one a quantity is tells you where the line goes.");

class TreeNode(int value, TreeNode? left = null, TreeNode? right = null)
{
    public int Value = value;
    public TreeNode? Left = left, Right = right;
}

输出:

carried down (a parameter) — depth of each node:
  node  5 is at depth 0
  node  4 is at depth 1
  node 11 is at depth 2
  node  7 is at depth 3
  node  2 is at depth 3
  node  8 is at depth 1
  node 13 is at depth 2
  node  4 is at depth 2
  node  1 is at depth 3

returned up (a return value) — height of the tree: 4

Depth is known on the way DOWN, before any child is examined.
Height is only known on the way UP, after both children have answered.
Deciding which one a quantity is tells you where the line goes.

深度向下的路上就知道了。到达一个节点时,你已经知道走了多远,而子节点一个都还没看。它是参数。

高度只有在向上的路上才知道。两个子节点都报上来之前,你说不出这棵子树有多高。它是返回值。

判断你要算的量属于哪一种,就知道那一行该写在函数里的什么位置:递归调用之前,还是之后。这一篇的每道题,都是在回答这同一个问题:

方向
深度、当前路径、合法范围 向下传,作为参数
高度、子树和、节点数 向上返回,作为值
直径、“树中任意位置的最优”类答案 向上返回,并且记录在函数外面

第三行就是下一个模式。

模式 63 — 直径:答案是副作用

任意两个节点之间最长的路径。难点在于它不一定经过根,而且经常不经过。

1 2 3 4 5 6 7 在节点 2: 左高度 2 右高度 2 → 经过它有 4 条边 根不在这条路径上

函数返回的是高度直径记在函数外面的一个变量里。答案是副作用,递归从来不返回它。想把它当返回值,这道题就是在这里出错的。

//         1
//       /   \
//      2     3
//     / \
//    4   5
//   /     \
//  6       7
TreeNode tree = new(1,
    new TreeNode(2, new TreeNode(4, new TreeNode(6)), new TreeNode(5, null, new TreeNode(7))),
    new TreeNode(3));

// The longest path in a tree need not pass through the root. At every node the
// best path THROUGH it is left height + right height. The answer is the largest
// of those — which is a SIDE EFFECT of a function that returns something else.
int best = 0;

int Height(TreeNode? n, int depth)
{
    if (n is null) return 0;
    int left = Height(n.Left, depth + 1);
    int right = Height(n.Right, depth + 1);

    int through = left + right;                 // edges, counting through this node
    if (through > best)
    {
        best = through;
        Console.WriteLine($"{new string(' ', depth * 2)}node {n.Value}: left={left} right={right} -> path of {through} edges through it (new best)");
    }
    else
    {
        Console.WriteLine($"{new string(' ', depth * 2)}node {n.Value}: left={left} right={right} -> {through} edges, best stays {best}");
    }

    return 1 + Math.Max(left, right);           // the RETURN value is the height
}

Height(tree, 0);
Console.WriteLine($"\ndiameter: {best} edges");
Console.WriteLine("the path is 6 -> 4 -> 2 -> 5 -> 7, which never touches the root");

class TreeNode(int value, TreeNode? left = null, TreeNode? right = null)
{
    public int Value = value;
    public TreeNode? Left = left, Right = right;
}

输出:

      node 6: left=0 right=0 -> 0 edges, best stays 0
    node 4: left=1 right=0 -> path of 1 edges through it (new best)
      node 7: left=0 right=0 -> 0 edges, best stays 1
    node 5: left=0 right=1 -> 1 edges, best stays 1
  node 2: left=2 right=2 -> path of 4 edges through it (new best)
  node 3: left=0 right=0 -> 0 edges, best stays 4
node 1: left=3 right=1 -> 4 edges, best stays 4

diameter: 4 edges
the path is 6 -> 4 -> 2 -> 5 -> 7, which never touches the root

在每个节点,经过这个节点的最长路径是 leftHeight + rightHeight。所以一边遍历树、一边计算高度,并在每个节点检查经过它的路径是否超过目前的最优值。

函数返回的是高度,直径记在函数外面的变量里。这是两个不同的量,函数只返回其中一个。第一次看到时觉得别扭,原因就在这里。如果试图返回直径,就会乱成一团,因为父节点需要的是子节点的高度,不是子节点的最优路径。

看推演。最优值是在节点 2 定下来的,根报告 4 条边,但没有更新它。答案是 6 -> 4 -> 2 -> 5 -> 7,节点 1 不在上面。

代价: O(n),一遍。

适用场景: 答案是“整棵树里任意位置的最优”,而不是“从根出发的最优”:二叉树中的最大路径和、最长同值路径、最大 BST 子树。都是同一种结构。

模式 64 — 最近公共祖先

两个目标节点都在它下面的、最深的那个节点。

//         3
//       /   \
//      5     1
//     / \   / \
//    6   2 0   8
//       / \
//      7   4
TreeNode tree = new(3,
    new TreeNode(5, new TreeNode(6), new TreeNode(2, new TreeNode(7), new TreeNode(4))),
    new TreeNode(1, new TreeNode(0), new TreeNode(8)));

// Return the node itself if found, otherwise whatever the children found.
// A node that hears back from BOTH sides is the meeting point.
static TreeNode? Lca(TreeNode? n, int a, int b)
{
    if (n is null) return null;
    if (n.Value == a || n.Value == b) return n;      // found one; stop descending

    TreeNode? left = Lca(n.Left, a, b);
    TreeNode? right = Lca(n.Right, a, b);

    if (left is not null && right is not null) return n;   // one on each side
    return left ?? right;                                   // pass the survivor up
}

foreach ((int a, int b) in new[] { (5, 1), (5, 4), (7, 4), (6, 8), (5, 5) })
    Console.WriteLine($"lca({a}, {b}) = {Lca(tree, a, b)!.Value}");

Console.WriteLine();
Console.WriteLine("lca(5, 4) is 5: a node is allowed to be its own ancestor, which is why");
Console.WriteLine("the search stops the moment it finds either value rather than going deeper.");

Console.WriteLine();
// In a BST you do not need any of that — the values tell you which way to go.
static TreeNode? LcaBst(TreeNode? n, int a, int b)
{
    while (n is not null)
    {
        if (a < n.Value && b < n.Value) n = n.Left;
        else if (a > n.Value && b > n.Value) n = n.Right;
        else return n;                    // they diverge here, or one IS here
    }
    return null;
}

TreeNode bst = new(6,
    new TreeNode(2, new TreeNode(0), new TreeNode(4, new TreeNode(3), new TreeNode(5))),
    new TreeNode(8, new TreeNode(7), new TreeNode(9)));
Console.WriteLine("in a BST, no recursion is needed at all:");
foreach ((int a, int b) in new[] { (2, 8), (2, 4), (3, 5) })
    Console.WriteLine($"  lca({a}, {b}) = {LcaBst(bst, a, b)!.Value}");

class TreeNode(int value, TreeNode? left = null, TreeNode? right = null)
{
    public int Value = value;
    public TreeNode? Left = left, Right = right;
}

输出:

lca(5, 1) = 3
lca(5, 4) = 5
lca(7, 4) = 2
lca(6, 8) = 3
lca(5, 5) = 5

lca(5, 4) is 5: a node is allowed to be its own ancestor, which is why
the search stops the moment it finds either value rather than going deeper.

in a BST, no recursion is needed at all:
  lca(2, 8) = 6
  lca(2, 4) = 2
  lca(3, 5) = 4

三行代码包揽一切。

如果当前节点就是某个目标,直接返回它,不再往下找。否则去问两个子节点。如果两边都返回非空,说明两个目标分在两侧,当前节点就是交汇点。否则把找到的那一个向上传。

lca(5, 4) 返回 5 不是 bug。节点算作自己的祖先,这是通常的定义,也正因为如此,搜索一找到任何一个值就停下,不再继续往下。

BST 里,这些都不需要。一路向下比较:两个目标都比当前值小就往左,都比它大就往右,第一个让两者分开的节点,或者本身就是其中一个目标的节点,就是答案。不用递归,O(h) 时间,O(1) 空间。

代价: 一般情况 O(n),BST 上 O(h)。

适用场景: 问题关于祖先关系,或者两个节点之间的距离,即 depth(a) + depth(b) - 2 * depth(lca)

模式 65 — 校验二叉搜索树

几乎人人第一次都会写错的一道题。

5 1 6 4 (−∞, +∞) (−∞, 5) (5, +∞) 4 不在 (5, 6) 内 向左走压低上界,向右走抬高下界。

只拿节点和自己的孩子比较,这棵树能通过:4 < 6 成立,6 > 5 也成立。局部比较永远不会告诉 4:它在根的右子树里,所以必须大于 5。范围必须向下传。

//      5              5 is the root, and 4 sits in its RIGHT subtree.
//     / \             Every parent-child pair is individually fine.
//    1   6            The tree is still not a BST.
//       / \
//      4   7
TreeNode broken = new(5,
    new TreeNode(1),
    new TreeNode(6, new TreeNode(4), new TreeNode(7)));

TreeNode fine = new(5,
    new TreeNode(3, new TreeNode(1), new TreeNode(4)),
    new TreeNode(8, new TreeNode(6), new TreeNode(9)));

// WRONG: only compares a node with its immediate children.
static bool NaiveCheck(TreeNode? n)
{
    if (n is null) return true;
    if (n.Left is not null && n.Left.Value >= n.Value) return false;
    if (n.Right is not null && n.Right.Value <= n.Value) return false;
    return NaiveCheck(n.Left) && NaiveCheck(n.Right);
}

// RIGHT: every node inherits a range from its ancestors, and must fall inside it.
static bool Check(TreeNode? n, long low, long high)
{
    if (n is null) return true;
    if (n.Value <= low || n.Value >= high) return false;
    return Check(n.Left, low, n.Value) && Check(n.Right, n.Value, high);
}

foreach ((string name, TreeNode t) in new[] { ("broken", broken), ("fine  ", fine) })
    Console.WriteLine($"{name}   naive says {NaiveCheck(t),-5}   bounds say {Check(t, long.MinValue, long.MaxValue)}");

Console.WriteLine();
Console.WriteLine("The naive check passes the broken tree because 4 < 6 and 6 > 5 are both");
Console.WriteLine("true locally. Nothing ever told 4 that it had to be greater than 5.");
Console.WriteLine();
Console.WriteLine("Bounds narrow on the way down: going left caps the maximum at the node's");
Console.WriteLine("value, going right raises the minimum to it. That is carried-down");
Console.WriteLine("information, exactly like depth.");
Console.WriteLine();
Console.WriteLine("long is used for the bounds so a node holding int.MinValue still works.");

class TreeNode(int value, TreeNode? left = null, TreeNode? right = null)
{
    public int Value = value;
    public TreeNode? Left = left, Right = right;
}

输出:

broken   naive says True    bounds say False
fine     naive says True    bounds say True

The naive check passes the broken tree because 4 < 6 and 6 > 5 are both
true locally. Nothing ever told 4 that it had to be greater than 5.

Bounds narrow on the way down: going left caps the maximum at the node's
value, going right raises the minimum to it. That is carried-down
information, exactly like depth.

long is used for the bounds so a node holding int.MinValue still works.

朴素版本只把每个节点和它的直接孩子比较,结果放过了一棵不是 BST 的树。在这棵树里,4 < 6 成立,6 > 5 也成立,所以每一次局部检查都通过。但 4 在根的右子树里,必须大于 5,而局部检查从来不会说出这一点。

解决办法是向下传的信息,和深度完全一样。每个节点都从祖先那里继承一个范围。往左走,上界压到当前值;往右走,下界抬到当前值。节点落在继承的范围之外,就不合法。

还有两个细节。边界用的是 long,这样值为 int.MinValue 的节点仍然可以比较;如果用 int,哨兵值就会和合法的值撞在一起。另外比较是严格的,因为通常定义下的 BST 没有重复值。

代价: O(n) 时间,O(h) 空间。

适用场景: 要校验的结构性质取决于所有祖先,而不只是父节点。“这是不是合法的堆”“每个节点是否都比它上方的所有节点小”,都是同一种结构。

要点

  • 先问这个量是向下走还是向上走。 向下就是参数,向上就是返回值。这一个问题能解决大多数树的题目。
  • 递归调用之后要撤销。 path.RemoveAt(path.Count - 1) 不是收拾整洁,而是兄弟节点的路径能算对的前提。
  • 叶子没有孩子。 只有一个孩子的节点不是叶子,把它当成叶子,会悄无声息地得到过短的路径。
  • 直径是副作用,不是返回值。 函数返回高度,另外单独记录最优值,因为父节点需要的是子节点的高度。
  • 最长路径经常不经过根。 如果你的解法只考虑经过根的路径,在大多数树上都是错的。
  • 节点是自己的祖先。 lca(a, a) 就是 a,提前返回实现的正是这一点。
  • 校验 BST 需要范围,而不是和父节点比较。 局部检查会放过不是 BST 的树,而且不声不响。

第 14 篇离开树,转向区间。那里全部的难点,就在于按起点排序还是按终点排序。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。