提到二叉树的按层遍历,大多数人第一反应是用队列做广度优先搜索。不过在一些面试场景或者算法题里,经常会出现附加限制条件,比如不允许使用队列、要求用递归实现,或者更直接一点:给定一个层级 k,把这个层级上的所有节点打印出来。这时候就需要换一种思路,用深度优先的方式去模拟按层访问的效果。

递归实现按层遍历的核心思路
队列解法的本质是:节点出队的顺序天然就是按层排列的。而递归解法的本质则是换一个角度——我们仍然按照深度优先的顺序访问节点(先一路走到底,再回溯),但在递归函数里多带一个参数depth,用来记录当前节点位于第几层。
这样一来,虽然访问顺序不是按层的,但每个节点都知道自己在哪一层。如果我们想收集第 k 层的所有节点,只需要在访问每个节点时判断一下:当depth == k时,把节点值加入结果即可。配合二叉树先左后右的遍历顺序,同一层内的节点依然会按照从左到右的顺序被收集,这正好符合按层打印的要求。
举个具体例子,对于一棵简单的树:根节点为 1,第二层是 2 和 3,第三层是 4、5、6。递归从根节点开始,depth 为 1;走到节点 2 时 depth 变成 2,走到节点 4 时 depth 变成 3,依此类推。每进入一层,depth 加一;每回溯一层,层级状态由调用栈自然负责恢复,这正是递归写法简洁的地方。
完整代码实现:打印指定层级的节点
下面用 Go 语言实现一个函数,输入二叉树的根节点和目标层级 k,返回第 k 层的所有节点值(k 从 1 开始计数)。如果目标层级超过树的高度,返回空切片。
package main
import "fmt"
// TreeNode 定义二叉树节点结构
type TreeNode struct {
Val int
Left *TreeNode
Right *TreeNode
}
// collectByLevel 递归收集第 target 层的节点值
// node 为当前节点,depth 为当前层级(从 1 开始),target 为目标层级
func collectByLevel(node *TreeNode, depth, target int, result *[]int) {
// 空节点直接返回,什么都不做
if node == nil {
return
}
// 当前层级等于目标层级,收集该节点
if depth == target {
*result = append(*result, node.Val)
// 目标层以下不会再有同层节点,提前返回做剪枝
return
}
// 否则继续向左右子树递归,层级加一
collectByLevel(node.Left, depth+1, target, result)
collectByLevel(node.Right, depth+1, target, result)
}
// PrintLevel 对外暴露的入口函数
func PrintLevel(root *TreeNode, k int) []int {
result := make([]int, 0)
collectByLevel(root, 1, k, &result)
return result
}
func main() {
// 构造测试用例树: 1
// / \
// 2 3
// / \ \
// 4 5 6
root := &TreeNode{Val: 1,
Left: &TreeNode{Val: 2,
Left: &TreeNode{Val: 4},
Right: &TreeNode{Val: 5},
},
Right: &TreeNode{Val: 3,
Right: &TreeNode{Val: 6},
},
}
fmt.Println(PrintLevel(root, 2)) // 输出: [2 3]
fmt.Println(PrintLevel(root, 3)) // 输出: [4 5 6]
}这段代码有几个值得注意的细节。第一,结果切片用指针*[]int传入,避免每次递归调用都产生切片头拷贝的歧义,也可以改用包级变量或者闭包捕获,效果相同。第二,当depth == target时直接return,这是一个小的剪枝优化——既然目标层已经到达,再往下走不可能出现同层节点,提前返回可以减少无用的递归调用。
第三点是边界处理:PrintLevel(root, 0)或者传入超过树高的层级时,函数会返回空切片而不是报错,因为递归永远满足不了depth == target的条件。这种安静地返回空结果的行为在大多数场景下是合理的,但如果你希望显式区分非法输入,可以在入口处先校验 k 是否大于等于 1。
从打印单层扩展到完整的按层遍历
只打印一层还不够,很多时候题目要求把整棵树按层输出成一个二维数组,也就是类似经典算法题的形式。用递归同样可以做到:最直接的办法是先遍历一次求出树的高度 h,然后对 1 到 h 的每一层分别调用上面的PrintLevel函数。这种写法最容易理解,但每调用一次都要完整遍历一遍树,总时间复杂度是 O(n·h),在树很高时效率不理想。
更优雅的做法是只递归一次,把所有层的结果同时收集齐。思路是维护一个二维切片levels,递归时根据 depth 判断该层的结果切片是否已经初始化,没有则追加一个新的空切片,然后把当前节点值追加到对应层的切片末尾:
// LevelOrder 递归实现完整的层序遍历
func LevelOrder(root *TreeNode) [][]int {
levels := make([][]int, 0)
var dfs func(node *TreeNode, depth int)
dfs = func(node *TreeNode, depth int) {
if node == nil {
return
}
// 第一次到达某一层时,为该层创建结果切片
if depth == len(levels) {
levels = append(levels, make([]int, 0))
}
// 将当前节点值放入对应层
levels[depth] = append(levels[depth], node.Val)
// 递归处理左右子树
dfs(node.Left, depth+1)
dfs(node.Right, depth+1)
}
dfs(root, 0)
return levels
}这个版本的时间复杂度是 O(n),每个节点只被访问一次。depth == len(levels)这个判断很关键:由于总是先访问左子树,某一层第一次被触达时对应的必然是该层最左边的节点,此时len(levels)正好等于 depth,我们就为这一层开辟存储空间;后续同层的节点会因为 depth 小于 len(levels) 而直接走追加逻辑,不会重复建层。
递归解法与队列解法的对比及常见坑
从复杂度上看,两种解法的时间复杂度都是 O(n),差别主要在空间上。队列解法的空间取决于树的最大宽度,最坏情况(满二叉树的最底层)大约有 n/2 个节点同时排队;递归解法的空间取决于树的高度,最坏情况(链状退化树)需要 n 层调用栈。所以对于很宽但很矮的树,递归反而更省内存;对于极不平衡的树,队列解法更稳妥。另外递归深度过大时要注意栈溢出风险,Go 语言中 goroutine 栈可以动态扩容问题不大,但在栈大小固定的语言里需要特别留意。
实现过程中有几个容易踩的坑需要提醒。一是层级计数从 0 还是 1 开始必须全局统一,入口函数传 1 内部判断就要用 1,前后不一致会导致结果整体偏移一层,这是新手最常犯的错误。二是空节点判断必须放在递归函数的最开头,否则对 nil 节点取node.Val会直接 panic。三是如果题目要求之字形打印(奇数层正序、偶数层倒序),只需在收集完某一层后根据层的奇偶性做一次反转,递归框架完全不用改动。
总的来说,递归实现按层遍历的关键就一句话:给递归函数加上深度参数,让每个节点携带自己的层级信息。理解了这一点,无论是打印指定层、收集所有层,还是变体的之字形遍历,都只是在这个骨架上做少量修改而已。建议自己动手把上面的代码敲一遍,再和队列版本对比着实现一次,对这个知识点的掌握会扎实很多。