题目描述

✅ 1473. 粉刷房子 III

image-20260929084830133

image-20260929084830235

image-20260929084830323

题意分析

有一排 m 间房子,可以使用编号一到 n 的颜色。houses[i] == 0 表示尚未粉刷,需要选择颜色并支付对应费用;非零表示颜色已经确定,不能重新粉刷,也不再产生费用。

一个街区是连续且颜色相同的最大一段房子。同一种颜色如果被其他颜色隔开,会属于不同街区,所以街区数不是使用过的颜色种类数。

要求给所有未刷房子定色,使整排恰好形成 target 个街区,并让新增粉刷费用最低。不能只要求街区数不超过目标;如果固定颜色与可选颜色无法满足要求,返回 -1。

解法:三维动态规划记录位置、颜色和街区数

核心思路

[!blue]

从左到右粉刷时,下一间是否增加街区,只取决于它的颜色与前一间颜色是否相同。因此状态必须同时记住已处理位置、末尾颜色和已有街区数。定义 dp[i][color][blocks] 为前 i 间房子全部确定后,最后颜色为 color、恰好形成 blocks 个街区的最小费用。

对于当前颜色 color,枚举上一间颜色 preColor。如果两色相同,当前房子延续已有街区,前一状态也需要 blocks 个街区;如果不同,就新开一个街区,前一状态应只有 blocks - 1 个。对每个可达来源,加上当前需要支付的费用,取最小值。

相同位置、末色和街区数的不同粉刷历史,后续允许的选择完全相同,只有累计费用有差别,因此只保留最便宜的历史不会损失全局最优解。这也说明不能省略末色:只知道街区数,无法判断下一间会延续还是新开街区。

已涂色房子只能枚举其指定颜色,新增费用为零;未涂色房子才允许全部实际颜色,并支付 cost[i - 1][color - 1]。已有颜色仍然参与相邻比较,不能直接跳过,否则街区边界会出错。

用虚拟颜色零表示尚未处理任何房子的空前缀,仅初始化 dp[0][0][0] = 0,其余状态全为不可达。第一间房子的实际颜色必定不同于零,会自然新增第一个街区。虚拟颜色不会赋给真实房子,也不会在后续层产生可达状态。

全部房子处理完后,只考虑恰好 target 个街区,在所有可能的末尾颜色中取最低费用。所有候选仍不可达时返回 -1;不可达状态不能拿来正常累加转移。

解题步骤

  1. 创建三维状态表并初始化为正无穷哨兵,只有空前缀、虚拟颜色零、零街区的费用为零。
  2. 依次处理每间房子,枚举当前实际颜色,跳过与已有颜色冲突的选择。
  3. 确定当前费用:已刷房子为零,未刷房子查对应的费用表。
  4. 枚举上一颜色与当前街区数;同色使用同样的前一街区数,异色使用少一个的前一街区数。
  5. 从可达前一状态转移,加上当前费用并更新最小值。
  6. 在最后一层所有末色的 target 状态中取最小值,无可达方案则返回 -1。

代码实现

class Solution {
    public int minCost(int[] houses, int[][] cost, int m, int n, int target) {
        int inf = 1_000_000_000;
        // dp[i][c][b]:前 i 栋刷完、末栋颜色 c、共 b 个街区的最小花费。
        int[][][] dp = new int[m + 1][n + 1][target + 1];

        for (int i = 0; i <= m; i++) {
            for (int color = 0; color <= n; color++) {
                Arrays.fill(dp[i][color], inf);
            }
        }

        // 颜色 0 是不存在的哨兵,让第一栋房子自动走「变色加街区」分支。
        dp[0][0][0] = 0;

        for (int i = 1; i <= m; i++) {
            for (int color = 1; color <= n; color++) {
                // 已刷过的房子颜色被锁死,不能枚举成别的颜色。
                if (houses[i - 1] != 0 && houses[i - 1] != color) {
                    continue;
                }

                // 已刷过的房子不产生任何花费。
                int paintCost = 0;

                if (houses[i - 1] == 0) {
                    paintCost = cost[i - 1][color - 1];
                }

                // preColor 从 0 开始,必须包含哨兵颜色。
                for (int preColor = 0; preColor <= n; preColor++) {
                    for (int blocks = 1; blocks <= target; blocks++) {
                        int preBlocks = blocks;

                        if (color != preColor) {
                            preBlocks = blocks - 1;
                        }

                        if (preBlocks < 0 || dp[i - 1][preColor][preBlocks] == inf) {
                            continue;
                        }

                        dp[i][color][blocks] =
                                Math.min(
                                        dp[i][color][blocks],
                                        dp[i - 1][preColor][preBlocks] + paintCost);
                    }
                }
            }
        }

        int answer = inf;

        for (int color = 1; color <= n; color++) {
            answer = Math.min(answer, dp[m][color][target]);
        }

        if (answer == inf) {
            return -1;
        }

        return answer;
    }
}
func minCost(houses []int, cost [][]int, m int, n int, target int) int {
    const inf = 1000000000
    // dp[i][c][b]:前 i 栋刷完、末栋颜色 c、共 b 个街区的最小花费。
    dp := make([][][]int, m+1)
    for i := 0; i <= m; i++ {
        dp[i] = make([][]int, n+1)
        for color := 0; color <= n; color++ {
            dp[i][color] = make([]int, target+1)
            for blocks := 0; blocks <= target; blocks++ {
                dp[i][color][blocks] = inf
            }
        }
    }
    // 颜色 0 是不存在的哨兵,让第一栋房子自动走「变色加街区」分支。
    dp[0][0][0] = 0

    for i := 1; i <= m; i++ {
        for color := 1; color <= n; color++ {
            // 已刷过的房子颜色被锁死,不能枚举成别的颜色。
            if houses[i-1] != 0 && houses[i-1] != color {
                continue
            }
            // 已刷过的房子不产生任何花费。
            paintCost := 0
            if houses[i-1] == 0 {
                paintCost = cost[i-1][color-1]
            }
            // preColor 从 0 开始,必须包含哨兵颜色。
            for preColor := 0; preColor <= n; preColor++ {
                for blocks := 1; blocks <= target; blocks++ {
                    preBlocks := blocks
                    if color != preColor {
                        preBlocks = blocks - 1
                    }
                    if preBlocks < 0 || dp[i-1][preColor][preBlocks] == inf {
                        continue
                    }
                    cand := dp[i-1][preColor][preBlocks] + paintCost
                    if cand < dp[i][color][blocks] {
                        dp[i][color][blocks] = cand
                    }
                }
            }
        }
    }

    answer := inf
    for color := 1; color <= n; color++ {
        if dp[m][color][target] < answer {
            answer = dp[m][color][target]
        }
    }
    if answer == inf {
        return -1
    }
    return answer
}

复杂度分析

  • 时间复杂度:O(m × target × n²)。每个位置、末色和街区数构成一个状态,转移枚举至多 n + 1 个前一颜色。
  • 空间复杂度:O(m × target × n),当前实现保存完整三维状态表。

关键点总结

[!green]

  • 街区由连续同色段决定,相同颜色可以在不同位置形成多个街区。
  • 末尾颜色决定是否新开街区,位置、末色和街区数一起构成足够的状态。
  • 固定颜色限制选择但仍参与街区划分,只有未刷房子产生费用。
  • 唯一的空前缀哨兵让第一间房子的首街区自然产生。
  • 最终固定街区数量,再跨全部末尾颜色取最小值。

易错点总结

[!yellow]

  • 把街区数当颜色种数:被其他颜色隔开的相同颜色不属于同一个连续街区。
  • 给已涂色房子换色或再次收费:它们只能保留原颜色,新增费用为零。
  • 跳过已刷房子不更新状态:固定颜色也会与前后房子形成或打断街区。
  • 不记录末尾颜色:无法判断下一间是否增加街区,状态信息不足。
  • 全部空前缀状态初始化为零:会凭空产生已有颜色和街区,只有 dp[0][0][0] 可达。
  • 最终对不超过目标的街区数取最小值:题目要求恰好目标数量,不能混入更少街区。
  • 只查看某一种末尾颜色:最优方案可能以任意合法颜色结束。

相似题目

题目 难度 关联与区别
256. 粉刷房子 中等 本题允许相邻同色并精确限制街区数,还包含已涂色房屋,不能只沿用相邻必须不同的状态。
265. 粉刷房子 II 困难 颜色数推广后仍可借鉴前层颜色转移,本题还要增加已形成街区数维度。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2023/95663463
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!