Blog

C# 二叉树遍历:四种顺序

三种递归遍历顺序只差一行代码;迭代写法为什么一紧张就写不出来;层序遍历靠的不是技巧,而是一个局部变量。

树是 C# 没有替你准备的另一种结构。BCL 里没有 BinaryTreeNode<T>,所以这里的每个程序都自己声明一个:

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

这一篇只讲遍历:怎样按有用的顺序走到每个节点。走到之后要算什么,是第 13 篇的内容。

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

模式 56 — 三种递归顺序

前序、中序和后序是同一个函数写了三遍。唯一的区别是访问节点的那一行放在哪里。

1 2 3 4 5 6 前序 1 2 4 5 3 6 先访问,再左,再右 中序 4 2 5 1 6 3 先左,再访问,再右 后序 4 5 2 6 3 1 先左,再右,再访问

三个函数一模一样,只有访问节点的那一行放在哪不同。区别全在这一行:它决定一个节点是在子节点的答案出来之前处理,还是之后处理。

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

// The three orders differ by ONE line: where the visit sits relative to the
// two recursive calls.
static void PreOrder(TreeNode? n, List<int> outp)
{
    if (n is null) return;
    outp.Add(n.Value);                 // visit, then children
    PreOrder(n.Left, outp);
    PreOrder(n.Right, outp);
}

static void InOrder(TreeNode? n, List<int> outp)
{
    if (n is null) return;
    InOrder(n.Left, outp);
    outp.Add(n.Value);                 // left, visit, right
    InOrder(n.Right, outp);
}

static void PostOrder(TreeNode? n, List<int> outp)
{
    if (n is null) return;
    PostOrder(n.Left, outp);
    PostOrder(n.Right, outp);
    outp.Add(n.Value);                 // children, then visit
}

foreach ((string name, Action<TreeNode?, List<int>> walk) in new (string, Action<TreeNode?, List<int>>)[]
         { ("pre-order ", PreOrder), ("in-order  ", InOrder), ("post-order", PostOrder) })
{
    List<int> got = [];
    walk(tree, got);
    Console.WriteLine($"{name}  {string.Join(" ", got)}");
}

Console.WriteLine();
Console.WriteLine("pre-order  visits a node BEFORE its subtrees  -> copying a tree, serialising");
Console.WriteLine("in-order   visits left, node, right           -> a BST comes out sorted");
Console.WriteLine("post-order visits a node AFTER its subtrees   -> freeing, or any answer that");
Console.WriteLine("                                                 depends on both children");

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

输出:

pre-order   1 2 4 5 3 6
in-order    4 2 5 1 6 3
post-order  4 5 2 6 3 1

pre-order  visits a node BEFORE its subtrees  -> copying a tree, serialising
in-order   visits left, node, right           -> a BST comes out sorted
post-order visits a node AFTER its subtrees   -> freeing, or any answer that
                                                 depends on both children

按位置给它们起名,是通常的教法,但这不是它们有用的原因。真正有用的区别是:处理一个节点时,它的子节点的答案出来了没有

  • 前序处理节点时,对它的子树一无所知。适合复制一棵树、序列化,或者任何只靠节点自身数据就够的场景。
  • 中序遍历二叉搜索树,输出是有序的。这不是巧合:这就是二叉搜索树(BST)的定义,只不过换成了遍历的写法。
  • 后序等两个子节点都处理完才处理当前节点。凡是要把下面的结果合起来的答案,都是后序,不管你叫不叫它后序。第 13 篇全部是后序。

代价: O(n) 时间,调用栈占 O(h) 空间,h 是树高。

适用场景: 永远。后面所有关于树的内容,都用这套词汇来写。

模式 57 — 不用递归的中序遍历

第 7 篇讲过:C# 遇到栈溢出会直接终止进程,而且捕获不了;而退化的树很深。所以在 C# 里,迭代写法比在其他一些语言里更重要。

很多人一紧张就写不出这个版本,原因是他们想去背代码。其实只要说清楚栈里放的是什么,代码自然就出来了。

4 2 6 1 3 5 1 2 4 栈底 这里的每个节点, 左子树都已处理完, 自己却还没被访问。 这正是递归原本 放在调用栈上的东西。 一路向左并入栈。弹出、访问,再从这个节点的右孩子重新开始。

手写栈之所以难,是因为说不清栈里放的是什么。一旦把“左边已完成、自己未访问”当作不变式,循环就自己写出来了。而且在退化的树上,它不会像递归版那样栈溢出。

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

// Recursion keeps its place implicitly, on the call stack. Doing it by hand
// means the stack holds the nodes whose LEFT side is done but which have not
// themselves been visited yet.
static List<int> InOrderIterative(TreeNode? root, bool trace)
{
    List<int> outp = [];
    Stack<TreeNode> st = [];
    TreeNode? cur = root;

    while (cur is not null || st.Count > 0)
    {
        while (cur is not null)                 // go as far left as possible
        {
            st.Push(cur);
            if (trace) Console.WriteLine($"  push {cur.Value}   stack=[{string.Join(",", st.Select(x => x.Value).Reverse())}]");
            cur = cur.Left;
        }
        cur = st.Pop();                         // nothing further left: visit
        outp.Add(cur.Value);
        if (trace) Console.WriteLine($"  pop  {cur.Value}   visit -> [{string.Join(" ", outp)}]");
        cur = cur.Right;                        // then handle the right subtree
    }
    return outp;
}

var got = InOrderIterative(tree, true);
Console.WriteLine($"\nin-order: {string.Join(" ", got)}");
Console.WriteLine($"sorted:   {got.SequenceEqual(got.Order())}   <- it is a BST, so in-order is sorted");

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

输出:

  push 4   stack=[4]
  push 2   stack=[4,2]
  push 1   stack=[4,2,1]
  pop  1   visit -> [1]
  pop  2   visit -> [1 2]
  push 3   stack=[4,3]
  pop  3   visit -> [1 2 3]
  pop  4   visit -> [1 2 3 4]
  push 6   stack=[6]
  push 5   stack=[6,5]
  pop  5   visit -> [1 2 3 4 5]
  pop  6   visit -> [1 2 3 4 5 6]

in-order: 1 2 3 4 5 6
sorted:   True   <- it is a BST, so in-order is sorted

栈里放的是:左子树已经处理完、自己还没被访问的节点。 调用栈原本隐式保存的,正是这些。这句话记住了,循环就只有一种写法:

  1. 一路向左,把经过的节点全部入栈。
  2. 左边走到头了?弹出一个节点并访问它。按照构造方式,它的左边一定已经处理完。
  3. 然后转到它的右孩子,回到第 1 步。

外层条件是 cur is not null || st.Count > 0,两半都不能少。栈可能是空的,但还有右子树要往下走:一开始就是这样,推演里到节点 4 时又是这样。

代价: O(n) 时间,O(h) 空间。空间一样,但放在堆上,不会搞垮进程。

适用场景: 树可能很深,或者题目要求遍历能暂停、再继续。BST 迭代器就是把这个循环从中间拆开。

模式 58 — 层序遍历

到目前为止,每个模式都是深度优先。层序遍历是广度优先,用的就是第 7 篇里的队列,只多了一样东西。

问题在于:队列只是一条扁平的序列,根本不知道什么是层。第 1 层还没出队完,第 2 层的节点就已经入队,全都混在一起。

队列 2 3 width = 2,在循环之前取值 之后 2 3 4 5 6 下一层已经混了进来 在循环里读 q.Count,它会随子节点入队而变大,这一层永远结束不了。 先读一次存进局部变量,就正好处理完一层。

队列不知道层的存在,它只是一条扁平的序列。层的边界要靠快照找回来:记下这一层开始时队列里有多少个节点在等。

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

// The queue naturally mixes levels together. Capturing Count BEFORE the inner
// loop is what puts the boundaries back.
static List<List<int>> LevelOrder(TreeNode? root)
{
    List<List<int>> levels = [];
    if (root is null) return levels;

    Queue<TreeNode> q = [];
    q.Enqueue(root);

    while (q.Count > 0)
    {
        int width = q.Count;              // exactly this many nodes are on this level
        List<int> level = [];
        for (int i = 0; i < width; i++)
        {
            TreeNode n = q.Dequeue();
            level.Add(n.Value);
            if (n.Left is not null) q.Enqueue(n.Left);
            if (n.Right is not null) q.Enqueue(n.Right);
        }
        levels.Add(level);
        Console.WriteLine($"level {levels.Count - 1}: width was {width}, values [{string.Join(", ", level)}], queue now holds {q.Count}");
    }
    return levels;
}

var levels = LevelOrder(tree);
Console.WriteLine($"\nlevels: [{string.Join("], [", levels.Select(l => string.Join(", ", l)))}]");
Console.WriteLine($"depth: {levels.Count}");

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

输出:

level 0: width was 1, values [1], queue now holds 2
level 1: width was 2, values [2, 3], queue now holds 3
level 2: width was 3, values [4, 5, 6], queue now holds 1
level 3: width was 1, values [7], queue now holds 0

levels: [1], [2, 3], [4, 5, 6], [7]
depth: 4

全部诀窍就是内层循环之前int width = q.Count;。这个快照就是本层的节点数,正好出队这么多个,边界就对了。

如果写成 for (int i = 0; i < q.Count; i++),每次迭代都会重新拿条件去比一个正在变长的队列,因为子节点不断入队。这一层永远结束不了,你只会得到一个扁平的列表。上面的推演里,数量在一层中间从 2 变成了 3,出错的正是这里。

代价: O(n) 时间,O(w) 空间,w 是最宽那一层的宽度。

适用场景: 题目里提到层、深度或最近。尤其是最小深度,应该用 BFS 而不是 DFS:BFS 碰到第一个叶子就能停,DFS 得把整棵树走完。

模式 59 — 锯齿形、左右视图,以及其他

有了层序遍历,很多树的题目只要在它上面再加一行。

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

static List<List<int>> Levels(TreeNode? root)
{
    List<List<int>> levels = [];
    if (root is null) return levels;
    Queue<TreeNode> q = [];
    q.Enqueue(root);
    while (q.Count > 0)
    {
        int width = q.Count;
        List<int> level = [];
        for (int i = 0; i < width; i++)
        {
            TreeNode n = q.Dequeue();
            level.Add(n.Value);
            if (n.Left is not null) q.Enqueue(n.Left);
            if (n.Right is not null) q.Enqueue(n.Right);
        }
        levels.Add(level);
    }
    return levels;
}

var levels = Levels(tree);

// Zigzag: do NOT alternate the traversal. Reverse alternate rows afterwards.
var zigzag = levels.Select((l, i) => i % 2 == 1 ? Enumerable.Reverse(l).ToList() : l).ToList();

// Right side view: the last value of each level.
var rightView = levels.Select(l => l[^1]).ToList();

// Left side view is the first of each level, for free.
var leftView = levels.Select(l => l[0]).ToList();

Console.WriteLine($"levels     : [{string.Join("], [", levels.Select(l => string.Join(", ", l)))}]");
Console.WriteLine($"zigzag     : [{string.Join("], [", zigzag.Select(l => string.Join(", ", l)))}]");
Console.WriteLine($"right view : {string.Join(", ", rightView)}");
Console.WriteLine($"left view  : {string.Join(", ", leftView)}");
Console.WriteLine($"max depth  : {levels.Count}");
Console.WriteLine($"widest     : {levels.Max(l => l.Count)} nodes, at level {levels.FindIndex(l => l.Count == levels.Max(x => x.Count))}");

Console.WriteLine();
Console.WriteLine("Every one of these is the level list plus one line. Trying to build");
Console.WriteLine("zigzag by alternating the traversal itself is where people tie themselves");
Console.WriteLine("in knots — the queue order stays the same, only the output is reversed.");

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

输出:

levels     : [1], [2, 3], [4, 5, 6], [7]
zigzag     : [1], [3, 2], [4, 5, 6], [7]
right view : 1, 3, 6, 7
left view  : 1, 2, 4, 7
max depth  : 4
widest     : 3 nodes, at level 2

Every one of these is the level list plus one line. Trying to build
zigzag by alternating the traversal itself is where people tie themselves
in knots — the queue order stays the same, only the output is reversed.

锯齿形遍历最容易把人绕晕,因为直觉是去交替改变遍历本身:奇数层先入右再入左,或者把队列换成栈。这样能做出来,但很繁琐,也很容易写错。

队列的顺序根本不用变。照常建好每一层,再把隔行的层反转。答案一样,只有一行,没什么可调试的。

左右视图就是每层的最后一个和第一个元素。最大深度就是层数。最宽的一层就是一个 Max。这些都不需要单独的算法。

适用场景: 问题是按层来问的。先建好各层,再对这个列表回答问题。

模式 60 — 用两种遍历结果重建二叉树

给定前序和中序遍历,重建这棵树。

一种遍历永远不够:很多不同的树有相同的前序。有两种,而且其中一种是中序,树就唯一确定了。

前序 1 2 4 5 3 6 第一个就是根 中序 4 2 5 1 6 3 左子树 右子树 左边有三个值,所以前序里接下来的三个值就是整棵左子树。

在中序数组里找根的位置,要用字典,不要扫描。扫描会让每一层都是 O(n),在退化的树上整个重建就成了 O(n²),而测试用的恰恰就是这种输入。

int[] preorder = [1, 2, 4, 5, 3, 6];
int[] inorder  = [4, 2, 5, 1, 6, 3];

// pre-order gives you the ROOT first. in-order tells you how much of the rest
// belongs on each side of it. A dictionary makes "where is the root in inorder"
// O(1) instead of a scan, which is the difference between O(n) and O(n^2).
Dictionary<int, int> where = inorder.Select((v, i) => (v, i)).ToDictionary(t => t.v, t => t.i);
int cursor = 0;

TreeNode? Build(int lo, int hi, int depth)
{
    if (lo > hi) return null;
    int value = preorder[cursor++];
    int mid = where[value];
    Console.WriteLine($"{new string(' ', depth * 2)}root {value}: inorder[{lo}..{hi}], splits at {mid} " +
                      $"-> left [{lo}..{mid - 1}], right [{mid + 1}..{hi}]");
    var node = new TreeNode(value);
    node.Left = Build(lo, mid - 1, depth + 1);       // must come first: it consumes
    node.Right = Build(mid + 1, hi, depth + 1);      // the pre-order cursor in order
    return node;
}

TreeNode? root = Build(0, inorder.Length - 1, 0);

static void Pre(TreeNode? n, List<int> o) { if (n is null) return; o.Add(n.Value); Pre(n.Left, o); Pre(n.Right, o); }
static void In(TreeNode? n, List<int> o) { if (n is null) return; In(n.Left, o); o.Add(n.Value); In(n.Right, o); }

List<int> p = [], i = [];
Pre(root, p); In(root, i);
Console.WriteLine($"\nrebuilt pre-order: {string.Join(" ", p)}   matches: {p.SequenceEqual(preorder)}");
Console.WriteLine($"rebuilt in-order : {string.Join(" ", i)}   matches: {i.SequenceEqual(inorder)}");

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

输出:

root 1: inorder[0..5], splits at 3 -> left [0..2], right [4..5]
  root 2: inorder[0..2], splits at 1 -> left [0..0], right [2..2]
    root 4: inorder[0..0], splits at 0 -> left [0..-1], right [1..0]
    root 5: inorder[2..2], splits at 2 -> left [2..1], right [3..2]
  root 3: inorder[4..5], splits at 5 -> left [4..4], right [6..5]
    root 6: inorder[4..4], splits at 4 -> left [4..3], right [5..4]

rebuilt pre-order: 1 2 4 5 3 6   matches: True
rebuilt in-order : 4 2 5 1 6 3   matches: True

靠的是两点。

前序里第一个还没用掉的值,永远是下一个根。 一个共享的游标向前推进,所以 Build(left) 必须在 Build(right) 之前调用:左子树要先用掉它那一份前序值。把这两行对调,建出来的树就是镜像,而且不报任何错。

中序告诉你在哪里切分。 根位置左边的全部属于左子树,右边的全部属于右子树。where 字典让这次查找变成 O(1);如果改成扫描中序数组,在退化的树上整个重建就是 O(n²),而测试用例偏偏就会挑这种输入。

注意基准情况 lo > hi 处理了推演里的空区间:[0..-1][2..1] 就是缺失的子节点在“报到”。

后序加中序也是同样的做法:倒着消费后序,并且先建右子树再建左子树。前序加后序不能唯一确定一棵树。

代价: O(n) 时间和空间。

适用场景: 题目给了你两种遍历结果,或者要序列化、反序列化一棵树。

要点

  • 三种顺序只差一行。 关键在于处理节点时,子节点的答案出来了没有。
  • 中序遍历 BST 得到有序序列。 这就是定义,只是走了一遍。
  • 迭代写法的栈里放的是“左边已完成、自己未访问”。 说清楚不变式,代码自然就有了;死记代码,一紧张就忘。
  • 处理一层之前,先把 q.Count 存进局部变量。 在循环条件里反复读它,这一层就永远结束不了。
  • 锯齿形不要去交替改变遍历。 建好各层,再反转隔行。
  • 先建左子树,再建右子树。 两者共用一个前序游标,这两次调用的先后顺序,就是一棵树和它的镜像之间的全部区别。
  • 用字典给中序数组建索引。 扫描会把 O(n) 变成 O(n²),而测试恰恰会挑这种输入。

第 13 篇讲走到每个节点之后要做的事:路径和、直径、祖先,以及递归“返回什么”和“向下传什么”的区别。

这篇文章对你有帮助吗?

点一颗爱心来评分!

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

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