力扣 968. 监控二叉树 —— 贪心 & 树形 DP 双解法递归 + 非递归全解(Java 实现)

一、引言

在二叉树与算法结合的面试题库中,LeetCode 968 监控二叉树是一道极具代表性的经典题目。它以 "最小数量摄像头覆盖全树" 为目标,既不依赖复杂数学推导,也不涉及冷门数据结构,却精准考察了后序遍历思想、节点状态设计、贪心策略取舍以及树形动态规划四大核心能力。看似简单的监控规则,背后藏着自底向上的最优决策逻辑,也是面试官常用来检验算法思维是否严谨的高频考题。

很多题解往往只给出单一解法,或是只停留在递归实现,不利于应对面试中 "避免递归栈溢出""手写迭代版" 等进阶要求。因此本文围绕两种最主流、最实用的解法展开:贪心算法树形动态规划 ,并且每种思路都完整提供递归版非递归迭代版。递归版代码简洁、逻辑直观,适合快速理解与现场手写;非递归版基于后序遍历实现,更稳健、更贴近工程实践,也能充分展现对二叉树遍历细节的把控。

全文从题目本质出发,先明确状态定义与核心策略,再逐步推导状态转移逻辑,最后给出可直接提交的完整代码,力求让你不仅能 AC 题目,更能清晰讲清思路、从容应对同类二叉树最优覆盖问题。

二、贪心算法

2.1 介绍

贪心算法(Greedy Algorithm)的核心思想就一句话:在每一步做出当前看起来最好的选择,不去考虑未来会不会更优,只追求局部最优,最终希望得到全局最优。

特点:

  1. 不从全局考虑,只看眼前一步
  2. 选择简单、直接、好理解
  3. 效率极高,通常是线性复杂度
  4. 不是所有问题都能用贪心,但能用的时候,就是最优解

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 思路

摄像头的安装会影响父、自己、子节点,所以不能只记录 "装没装",还要记录被谁覆盖。因此我们给每个节点定义三个状态:

  1. dp[0] 当前节点安装了摄像头

  2. dp[1] 当前节点被父节点监控

  3. dp[2] 当前节点被子节点监控

6.1.1 自底向上,用子树推父亲

树形 DP 天然是后序遍历:先递归算出左、右子树的三个 dp 值,再计算当前节点的 dp。这一点和贪心一样,但思路完全不同:

  • 贪心:猜一个最优选择
  • DP:把所有可能的合法选择都算一遍,取最小

6.1.2 状态转移的体现

  • **当前装摄像头(dp [0])**孩子随便什么状态都行(0或1或2),所以取每个孩子的最小值相加:

    java 复制代码
    dp0 = 1 + min(左0,左1,左2) + min(右0,右1,右2)
  • **被父节点监控(dp [1])**孩子必须自己有监控或者自己被孩子监控(必须是0或2)

    java 复制代码
    dp1 = min(左0,左2) + min(左0,左2)
  • **被子节点监控(dp [2])**必须至少有一个孩子装摄像头才能覆盖我:

    java 复制代码
    dp2 = 左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]);
    }
}
相关推荐
skywalker_112 小时前
力扣hot100-7(接雨水),8(无重复字符的最长子串)
算法·leetcode·职场和发展
quxuexi2 小时前
网络通信安全与可靠传输:从加密到认证,从状态码到可靠传输
java·安全·web
hrhcode3 小时前
【java工程师快速上手go】二.Go进阶特性
java·golang·go
bIo7lyA8v3 小时前
算法稳定性分析中的输入扰动建模的技术9
算法
CoderCodingNo3 小时前
【GESP】C++三级真题 luogu-B4499, [GESP202603 三级] 二进制回文串
数据结构·c++·算法
sinat_286945193 小时前
AI Coding 时代的 TDD:从理念到工程落地
人工智能·深度学习·算法·tdd
炽烈小老头3 小时前
【 每天学习一点算法 2026/04/12】x 的平方根
学习·算法
ASKED_20193 小时前
从排序到生成:腾讯广告算法大赛 2025 baseline解读
人工智能·算法
田梓燊4 小时前
leetcode 160
算法·leetcode·职场和发展