树形DP浅析 | 豆包MarsCode AI刷题

132 阅读5分钟

树形DP是动态规划在树结构上的应用,是解决许多涉及树形结构问题的重要方法之一。在本文中,我们以一道具体的题目为例,讲解该题目的解法,并以该解法为例,分析树形DP的特点与通用思路。


问题描述

天气越来越冷了,村子里有留守老人缺少照顾,会在这个冬天里挨冻,小华想运用自己的知识帮帮他们。已知村庄里每户人家作为一个节点,这些节点可以组成一个二叉树。我们可以在二叉树的节点上供应暖炉,每个暖炉可以为该节点的父节点、自身及其子节点带来温暖。给定一棵二叉树,求使个村庄都暖和起来至少需要供应多少个暖炉?

输入格式

输入为一个数组,按层遍历顺序描述二叉树的节点情况。值为 1,代表存在一个节点,值为 0,代表不存在该节点。

输出格式

输出最少暖炉供应数量。

输入样例

1, 1, 0, 1, 1

输出样例

1

数据范围

树的节点数的范围是 [1, 1000]。


解法

思路分析

首先,题目要求我们把取暖范围覆盖到所有节点,并以最小暖炉数量为优化目标。由于暖炉的作用范围包括了暖炉所在节点、节点的父节点和该节点的所有子节点,因此这不是一个简单的覆盖问题,而是一个带约束的覆盖问题。直接铺满暖炉显然不是最优解,因为节点的覆盖范围会产生重叠;还有一种很直观的思路是每隔一层安装暖炉,但这样也会产生覆盖范围重叠。于是,我们需要思考每个节点可能会出现的不同情况:

  1. 如果当前节点未被覆盖,它的父节点或它自己必须安装暖炉。
  2. 如果当前节点被覆盖,它可能由父节点、子节点或自身的暖炉覆盖。
  3. 如果当前节点安装了暖炉,子节点和父节点的覆盖状态都受到影响。

因此,我们可以总结出每个节点所有可能的状态:

  • 未覆盖(状态0)
  • 被覆盖(状态1)
  • 安装暖炉(状态2)

状态设计与转移

由上面的所有可能状态,我们设计出DP中的以下三种状态:

  1. 被覆盖:该节点及周围相关节点都已被暖炉温暖。
  2. 安装了暖炉:当前节点安装了一个暖炉。
  3. 未被覆盖 :当前节点没有被暖炉覆盖。

通过对节点的递归分析,我们可以动态地求解最优解。具体状态转移公式如下:

  • 若当前节点未被覆盖,则必须在其父节点或自身安装暖炉。
  • 若当前节点被覆盖,则可以选择不安装暖炉。
  • 若当前节点安装了暖炉,则其父节点和子节点均被覆盖。

状态定义

设:

  • dp[u][0]:以节点 u 为根的子树中,节点 u 未覆盖时的最小暖炉数量。
  • dp[u][1]:以节点 u 为根的子树中,节点 u 被覆盖但未安装暖炉时的最小暖炉数量。
  • dp[u][2]:以节点 u 为根的子树中,节点 u 安装暖炉时的最小暖炉数量。

状态转移

  • u 未被覆盖时:dp[u][0] = dp[left][1] + dp[right][1]
  • u 被覆盖但未安装暖炉时:dp[u][1] = min(dp[left][2] + dp[right][2], ...)
  • u 安装暖炉时:dp[u][2] = 1 + min(dp[left]) + min(dp[right])

代码实现

public class Main {
    // 定义二叉树结构
    static class TreeNode {
        int val;
        TreeNode left, right;

        TreeNode(int val) {
            this.val = val;
            this.left = null;
            this.right = null;
        }
    }

    public static int solution(int[] nodes) {
        // 构建二叉树
        TreeNode root = buildTree(nodes);
        // 初始化结果数组:返回数组[0]是放置暖炉数量
        int[] res = dfs(root);
        // 最小的暖炉数量为:放置暖炉状态 或 已覆盖状态
        return Math.min(res[1], res[2]);
    }

    private static int[] dfs(TreeNode node) {
        // 如果是空节点,默认状态
        if (node == null) {
            return new int[] { 0, 0, Integer.MAX_VALUE / 2 };
        }

        // 后序遍历左右子树
        int[] left = dfs(node.left);
        int[] right = dfs(node.right);

        // 三种状态
        int notCovered = left[1] + right[1]; // 子节点需要暖炉
        int covered = Math.min(left[2] + Math.min(right[1], right[2]),
                right[2] + Math.min(left[1], left[2]));
        int withHeater = 1 + Math.min(left[0], Math.min(left[1], left[2]))
                + Math.min(right[0], Math.min(right[1], right[2]));

        return new int[] { notCovered, covered, withHeater };
    }

    private static TreeNode buildTree(int[] nodes) {
        if (nodes == null || nodes.length == 0)
            return null;
        TreeNode root = new TreeNode(nodes[0]);
        Queue<TreeNode> queue = new LinkedList<>();
        queue.add(root);
        int i = 1;
        while (i < nodes.length) {
            TreeNode curr = queue.poll();
            if (nodes[i] == 1) {
                curr.left = new TreeNode(1);
                queue.add(curr.left);
            }
            i++;
            if (i < nodes.length && nodes[i] == 1) {
                curr.right = new TreeNode(1);
                queue.add(curr.right);
            }
            i++;
        }
        return root;
    }
}

总结

该题目是一道非常经典的树形DP例题。通过我们上面的思考与分析,可以总结出树形DP问题所共同具有的以下2个特点:

  1. 状态定义与树形结构紧密相关:树形DP通常通过递归定义节点的状态,由叶节点向根节点递推解决问题。
  2. 自下而上的状态转移:根节点的状态往往在子节点全部求解之后才能求解。

当我们在平时的刷题中遇到具有以上特点的题目时,往往需要思考它是否能够通过树形DP解决。