一、引言
在二叉树与算法结合的面试题库中,LeetCode 968 监控二叉树是一道极具代表性的经典题目。它以 "最小数量摄像头覆盖全树" 为目标,既不依赖复杂数学推导,也不涉及冷门数据结构,却精准考察了后序遍历思想、节点状态设计、贪心策略取舍以及树形动态规划四大核心能力。看似简单的监控规则,背后藏着自底向上的最优决策逻辑,也是面试官常用来检验算法思维是否严谨的高频考题。
很多题解往往只给出单一解法,或是只停留在递归实现,不利于应对面试中 "避免递归栈溢出""手写迭代版" 等进阶要求。因此本文围绕两种最主流、最实用的解法展开:贪心算法 与树形动态规划 ,并且每种思路都完整提供递归版 与非递归迭代版。递归版代码简洁、逻辑直观,适合快速理解与现场手写;非递归版基于后序遍历实现,更稳健、更贴近工程实践,也能充分展现对二叉树遍历细节的把控。
全文从题目本质出发,先明确状态定义与核心策略,再逐步推导状态转移逻辑,最后给出可直接提交的完整代码,力求让你不仅能 AC 题目,更能清晰讲清思路、从容应对同类二叉树最优覆盖问题。

二、贪心算法
2.1 介绍
贪心算法(Greedy Algorithm)的核心思想就一句话:在每一步做出当前看起来最好的选择,不去考虑未来会不会更优,只追求局部最优,最终希望得到全局最优。
特点:
- 不从全局考虑,只看眼前一步
- 选择简单、直接、好理解
- 效率极高,通常是线性复杂度
- 不是所有问题都能用贪心,但能用的时候,就是最优解
2.2 思路
这道题的贪心策略非常典型:自底向上,尽量晚装摄像头,尽量让父节点来装,而不是叶子节点。
具体体现在这 3 个选择上:
2.2.1 优先让父节点装,而不是叶子
- 叶子装摄像头:只能覆盖 自己 + 父节点
- 父节点装摄像头:覆盖 自己 + 两个孩子 + 祖父
局部最优选择:父节点装,收益更大。 这就是典型的贪心:每一步都选覆盖范围最大、性价比最高的方案。
2.2.2 能不装就不装,能晚装就晚装
我们用后序遍历,先处理孩子,再处理自己 。只有一种情况才装摄像头:孩子没被监控,必须我来装。
否则能不装就不装,把装摄像头的机会留给更上面的节点。这也是贪心:尽可能推迟决策,让一个摄像头覆盖更多节点。
2.2.3 绝不回头修改决策
一旦确定某个节点装 / 不装,就不再回溯、不再反悔。这就是贪心的典型行为:只做一次决策,不回头。
三、递归贪心
3.1 节点状态定义
0:当前节点被父节点监控1:当前节点被子节点监控2:当前节点安装了摄像头
接下来我们来讨论所有情况
| left\right | 0 | 1 | 2 |
| 0 | 左右节点都被父节点监控,当前节点作为父节点一定安装了摄像头,为2 | 左节点被父节点监控,当前节点作为父节点一定安装了摄像头,为2 | 左节点被父节点监控,当前节点作为父节点一定安装了摄像头,为2 |
| 1 | 右节点被父节点监控,当前节点作为父节点一定安装了摄像头,为2 | 左右节点都被子节点监控,自己不需要安装监控去监控子节点浪费,所以当前节点应该被父节点监控,为0(当然也会有没有父节点的情况,所以如果父节点,当前节点就需要自己安监控) | 右节点上安装了监控,当前节点作为父节点不需要再安装监控了,在另外一个节点不是0不需要父节点监控的情况下,为1 |
| 2 | 右节点被父节点监控,当前节点作为父节点一定安装了摄像头,为2 | 左节点上安装了监控,当前节点作为父节点不需要再安装监控了,在另外一个节点不是0不需要父节点监控的情况下,为1 | 左右节点上安装了监控,当前节点作为父节点不需要再安装监控了,为1 |
|---|
由此,我们可以看出一个结论,
java
if (left == 0 || right == 0) {
return 2;
}
if (left == 2 || right == 2) {
return 1;
}
return 0;
接下来我们再来看一下特例,左右节点如果有null怎么办
0:当前节点被父节点监控1:当前节点被子节点监控2:当前节点安装了摄像头- **null :**为空
| left\right | 0 | 1 | 2 | null |
| 0 | 左右节点都被父节点监控,当前节点作为父节点一定安装了摄像头,为2 | 左节点被父节点监控,当前节点作为父节点一定安装了摄像头,为2 | 左节点被父节点监控,当前节点作为父节点一定安装了摄像头,为2 | 左节点被父节点监控,当前节点作为父节点一定安装了摄像头,为2 |
| 1 | 右节点被父节点监控,当前节点作为父节点一定安装了摄像头,为2 | 左右节点都被子节点监控,自己不需要安装监控去监控子节点浪费,所以当前节点应该被父节点监控,为0(当然也会有没有父节点的情况,所以如果父节点,当前节点就需要自己安监控) | 右节点上安装了监控,当前节点作为父节点不需要再安装监控了,在另外一个节点不是0不需要父节点监控的情况下,为1 | 左节点被子节点监控,自己不需要安装监控去监控子节点浪费,所以当前节点应该被父节点监控,为0(当然也会有没有父节点的情况,所以如果父节点,当前节点就需要自己安监控) |
| 2 | 右节点被父节点监控,当前节点作为父节点一定安装了摄像头,为2 | 左节点上安装了监控,当前节点作为父节点不需要再安装监控了,在另外一个节点不是0不需要父节点监控的情况下,为1 | 左右节点上安装了监控,当前节点作为父节点不需要再安装监控了,为1 | 左节点上安装了监控,当前节点作为父节点不需要再安装监控了,在另外一个节点不是0不需要父节点监控的情况下,为1 |
| null | 右节点被父节点监控,当前节点作为父节点一定安装了摄像头,为2 | 右节点被子节点监控,自己不需要安装监控去监控子节点浪费,所以当前节点应该被父节点监控,为0(当然也会有没有父节点的情况,所以如果父节点,当前节点就需要自己安监控) | 右节点上安装了监控,当前节点作为父节点不需要再安装监控了,在另外一个节点不是0不需要父节点监控的情况下,为1 | 左右节点为null,当前节点是叶子节点,自己不需要安装监控去监控子节点浪费,所以当前节点应该被父节点监控,为0(当然也会有没有父节点的情况,所以如果父节点,当前节点就需要自己安监控) |
|---|
我们可以发现当节点为null时,情况和1完全一样,那么我们遇到节点为null时,可以直接返回1。
java
private int dfs(TreeNode node) {
if (node == null) return 1;
int left = dfs(node.left);
int right = dfs(node.right);
if (left == 0 || right == 0) {
res++;
return 2;
}
if (left == 2 || right == 2) {
return 1;
}
return 0;
}
}
3.2 代码实现
值得注意的是,当我们返回0的时候
|-----------------------------------------------------------------------------------------|
| 左/右节点被子节点监控,自己不需要安装监控去监控子节点浪费,所以当前节点应该被父节点监控,为0(当然也会有没有父节点的情况,所以如果父节点,当前节点就需要自己安监控) |
我们要判断root返回之后是不是0,因为root是没有父节点的,应该直接在root上装一个。
java
class Solution {
int res = 0;
public int minCameraCover(TreeNode root) {
if (dfs(root) == 0) {
res++;
}
return res;
}
private int dfs(TreeNode node) {
if (node == null) return 1;
int left = dfs(node.left);
int right = dfs(node.right);
// 有孩子未被监控 → 必须在此装摄像头
if (left == 0 || right == 0) {
res++;
return 2;
}
// 有孩子装了摄像头 → 自己被覆盖
if (left == 2 || right == 2) {
return 1;
}
// 孩子都被覆盖但无摄像头 → 自己未被覆盖
return 0;
}
}
四、非递归贪心
4.1 后续遍历
我们在递归中,都是先去处理叶子节点,从下往上地一点点推到根节点,那这种思想,就让我们想到了后序遍历,左-右-根。
那么这里我们先来写一下后续遍历的代码。
java
public class PostOrder {
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
if (root == null) return res;
// 栈:先进后出
Deque<TreeNode> stack = new ArrayDeque<>();
TreeNode prev = null; // 记录上一个访问过的节点
stack.push(root);
while (!stack.isEmpty()) {
// 注意: 当前仅仅是读取,并没有从栈中取出
TreeNode cur = stack.peek();
// 如果当前节点是叶子节点或者前驱节点是自己的子节点
if ((cur.left == null && cur.right == null) // 如果是叶子节点,肯定不是根,遍历
|| (prev != null && (prev == cur.left || prev == cur.right))) {
// 当前驱节点是自己的子节点时,前驱节点是一定被遍历过了的
// 并且我们在入栈的时候保证了根 - 右 - 左,所以此时是遍历到了根节点
res.add(cur.val);
stack.pop();
prev = cur;
} else {
// 如果是叶子节点,不用走这个分支,毕竟也无法把左右节点压入栈
// 如果prev为null,也说明当前遍历未开始,我们遍历的第一个节点应该是最左边的叶子节点
// 如果前驱节点不是当前节点的子节点,说明此时已经换了子树
// 我们也需要要把它的节点按顺序压入栈
// 先压右,再压左 → 根 - 右 - 左顺序
if (cur.right != null) {
stack.push(cur.right);
}
if (cur.left != null) {
stack.push(cur.left);
}
}
}
return res;
}
}
4.2 代码实现
java
class Solution {
public int minCameraCover(TreeNode root) {
if (root == null) return 0;
int res = 0;
Deque<TreeNode> stack = new ArrayDeque<>();
Map<TreeNode, Integer> stateMap = new HashMap<>();
TreeNode prev = null;
stack.push(root);
while (!stack.isEmpty()) {
TreeNode cur = stack.peek();
// 后序:只有孩子处理完了才处理自己
if ((cur.left == null && cur.right == null)
|| (prev != null && (prev == cur.left || prev == cur.right))) {
// 取孩子状态
int l = stateMap.getOrDefault(cur.left, 1);
int r = stateMap.getOrDefault(cur.right, 1);
int state;
// 贪心核心逻辑
if (l == 0 || r == 0) {
state = 2;
res++;
} else if (l == 2 || r == 2) {
state = 1;
} else {
state = 0;
}
stateMap.put(cur, state);
stack.pop();
prev = cur;
} else {
// 先压右再压左,保证左先遍历
if (cur.right != null) stack.push(cur.right);
if (cur.left != null) stack.push(cur.left);
}
}
// 根节点未被监控,必须补一个
return stateMap.get(root) == 0 ? res + 1 : res;
}
}
4.3 区别
- 递归版 :依靠 JVM 函数调用栈 自动实现后序遍历,代码极简,逻辑自然。
- 非递归版 :手动用 Deque 栈 + 标记 /prev 节点 模拟后序遍历,自己控制遍历流程。
两者思路完全一样 ,都是:后序遍历 + 自底向上贪心 + 3 种状态判断 区别只在于遍历怎么实现。
|----|----------------------------------------------|--------------------------------------------|
| | 递归版 | 非递归版 |
| 优点 | * 代码极短,逻辑直观 * 好写、好讲、不易写错 * 面试首选,秒懂 | * 无栈溢出风险,更稳定 * 适合工程、大数据量 * 能体现你对二叉树遍历的深度掌握 |
| 缺点 | * 树极深时可能 栈溢出(StackOverflow) * 无法精细控制遍历过程 | * 代码稍长 * 要手动维护栈、prev、状态 Map |
五、动态规划
5.1 思路
动态规划的核心,是把复杂问题拆成若干子问题,先求出所有子问题的最优解,再通过状态转移,推导出原问题的最优解。
放到二叉树上,就叫树形 DP:
- 一棵树的最优解,由它左子树 + 右子树的最优解组合而来
- 每个节点维护几种状态,表示 "在不同决策下的最小代价"
- 不做局部贪心猜测,而是把所有合法情况都算一遍,取最小,保证全局最优
六、DP递归
6.1 思路
摄像头的安装会影响父、自己、子节点,所以不能只记录 "装没装",还要记录被谁覆盖。因此我们给每个节点定义三个状态:
-
dp[0] 当前节点安装了摄像头
-
dp[1] 当前节点被父节点监控
-
dp[2] 当前节点被子节点监控
6.1.1 自底向上,用子树推父亲
树形 DP 天然是后序遍历:先递归算出左、右子树的三个 dp 值,再计算当前节点的 dp。这一点和贪心一样,但思路完全不同:
- 贪心:猜一个最优选择
- DP:把所有可能的合法选择都算一遍,取最小
6.1.2 状态转移的体现
-
**当前装摄像头(dp [0])**孩子随便什么状态都行(0或1或2),所以取每个孩子的最小值相加:
javadp0 = 1 + min(左0,左1,左2) + min(右0,右1,右2) -
**被父节点监控(dp [1])**孩子必须自己有监控或者自己被孩子监控(必须是0或2)
javadp1 = min(左0,左2) + min(左0,左2) -
**被子节点监控(dp [2])**必须至少有一个孩子装摄像头才能覆盖我:
javadp2 = 左0 + 右0
6.1.3 null节点
我们在贪心算法中总结出null节点其实和被子节点监控一样
6.1.4 全局最优的体现
根节点没有父节点,所以它被父节点监控,只能二选一:
- 自己装摄像头:dp [0]
- 被子节点监控:dp [2]
最终答案:
java
min(dp[0], dp[2])
这就是 DP 最典型的体现:枚举所有合法最终状态,取全局最优。
6.2 代码实现
java
class Solution {
public int minCameraCover(TreeNode root) {
int[] dp = dfs(root);
return Math.min(dp[0], dp[2]);
}
//dp[0]当前节点安装了摄像头
//dp[1]当前节点被父节点监控
//dp[2]当前节点被子节点监控
private int[] dfs(TreeNode node) {
// 空节点:相当于被子节点监控
if (node == null) {
// 子节点上安装了一个监控,父节点上没有安装(不需要被父节点监控)
// 被子节点监控数这么大是为了防止被min取到
return new int[]{1, 0, 10000};
}
int[] left = dfs(node.left);
int[] right = dfs(node.right);
// 当前装摄像头
int dp0 = 1 // 当前一个监控
+ Math.min(Math.min(left[0], left[1]), left[2])// 左节点上最少的监控
+ Math.min(Math.min(right[0], right[1]), right[2]);// 右节点上最少监控
// 被父节点监控
int dp1 = // 自己不装监控
Math.min(left[0], left[2]) // 左孩子上最少的自己装和被孩子监控
+ Math.min(right[0], right[2]); // 右孩子上最少的自己装和被孩子监控
// 被子节点监控
int dp2 = // 自己不装
left[0] + right[0];//左右节点一定要装
return new int[]{dp0, dp1, dp2};
}
}
七、非递归DP
这里依旧采用后续遍历
java
class Solution {
public int minCameraCover(TreeNode root) {
if (root == null) return 0;
Deque<TreeNode> stack = new ArrayDeque<>();
Map<TreeNode, int[]> dpMap = new HashMap<>();
TreeNode prev = null;
stack.push(root);
while (!stack.isEmpty()) {
TreeNode cur = stack.peek();
if ((cur.left == null && cur.right == null)
|| (prev != null && (prev == cur.left || prev == cur.right))) {
int[] l = dpMap.getOrDefault(cur.left, new int[]{1, 0, 10000});
int[] r = dpMap.getOrDefault(cur.right, new int[]{1, 0, 10000});
int dp0 = 1 + Math.min(Math.min(l[0], l[1]), l[2])
+ Math.min(Math.min(r[0], r[1]), r[2]);
int dp1 = Math.min(l[0], l[2]) + Math.min(r[0], r[2]);
int dp2 = l[0] + r[0];
dpMap.put(cur, new int[]{dp0, dp1, dp2});
stack.pop();
prev = cur;
} else {
if (cur.right != null) stack.push(cur.right);
if (cur.left != null) stack.push(cur.left);
}
}
int[] rootDp = dpMap.get(root);
return Math.min(rootDp[0], rootDp[2]);
}
}