LeetCode 265. 粉刷房子 II
题目描述
题意分析
一排
n个房子,每个房子必须刷成k种颜色之一,costs[i][j]是把第i个房子刷成第j种颜色的开销,唯一的限制是相邻两个房子不能同色,要求刷完所有房子的最小总开销。限制只作用在相邻两项之间,且决策沿着房子编号单向推进,这说明「处理到第
i个房子时需要知道的全部历史信息」只有第i-1个房子刷了什么颜色,更早的选择不影响后续可行性。这种「无后效性 + 局部约束」是典型的逐位决策信号。数据规模上
n和k都可以到 100 量级,n * k * k大约是 10^6,其实能过;但这题被标成困难、且经典追问就是「能否做到 $O(nk)$」,说明出题人真正考的是如何把转移里那层对上一行颜色的枚举去掉。边界包括:
costs为空时答案为 0;只有一个房子时答案是该行最小值;k = 1且n >= 2时无解——不过题目数据保证不会出现这种情况,实现上只要不特意去访问不存在的次小值就不会崩。
解法:DP + 最小二值
核心思路
先按定义写朴素转移。设
dp[i][j]表示「把前i+1个房子全部刷完、且第i个房子刷成颜色j时的最小总开销」。转移是dp[i][j] = costs[i][j] + min{ dp[i-1][c] : c != j },初值dp[0][j] = costs[0][j],答案是min(dp[n-1][*])。这个式子正确,但内层要对每个j枚举全部c,单行代价是 $O(k^2)$,总代价 $O(nk^2)$。瓶颈很清楚:对同一行的
k个不同的j,我们都在对几乎同一个集合dp[i-1][*]求最小值,区别仅仅是各自排除掉一个元素。这是重复劳动。关键观察是:从一个集合里「排除某一个元素后的最小值」只有两种可能——如果被排除的不是最小值本身,答案就是最小值;如果被排除的恰好是最小值,答案就是次小值。也就是说,整行只需要知道上一行的最小值和次小值这两个数字(以及最小值所在的列),就能在 $O(1)$ 时间内回答任意一个
j的转移需求。由此把状态压成一维滚动数组。不变量是:每轮外层循环开始时,
dp[j]存的是「前i个房子已刷完且第i-1个房子颜色为j」的最小总开销;min1和min2分别是本轮开始时dp中最小值和次小值所在的下标,满足dp[min1] <= dp[min2] <= dp[c]对所有其余c成立。有了这条不变量,新一行的转移就是
ndp[j] = costs[i][j] + (j == min1 ? dp[min2] : dp[min1]),单行两遍线性扫描即可。这里用下标而不是数值来记录最小/次小,是为了能直接用
j == min1判断「当前颜色是否就是上一行的最优颜色」;若只记数值,遇到多列取值相同时就分不清该不该退让。
解题步骤
- 先处理
costs为空的情况直接返回 0。之所以要判,是因为后面要用costs[0].length取颜色数,空输入会越界。- 用长度为
k的一维数组dp作为滚动状态,初始全为 0。之所以初值取 0 而不是costs[0],是因为下面的主循环从i = 0开始就把costs[0][j]加上了,全零的dp恰好表示「还没刷任何房子,任何颜色的历史开销都是 0」,这样第一轮的min1、min2都指向值为 0 的列,转移结果正是costs[0][j],与朴素定义的初值一致,省掉了单独的初始化分支。- 每轮外层循环先扫一遍
dp找出最小值下标min1和次小值下标min2。之所以要在计算新行之前先找,是因为整行转移共享同一份上一行信息,先算好一次就能被k个转移复用,这正是从 $O(k^2)$ 降到 $O(k)$ 的地方。- 找最小/次小时用
-1表示「尚未确定」。之所以不用Integer.MAX_VALUE作哨兵,是因为这里记的是下标不是数值,-1是天然的非法下标,判断min1 == -1比比较数值更直白,也避免了开销累加后逼近 int 上界时的误判。- 更新逻辑必须是「先判是否比最小还小,是则把旧最小挤成次小」,否则才去更新次小。之所以顺序不能颠倒,是因为新来的元素若小于当前最小,它同时也小于当前次小,直接改次小会丢掉真正的第二名。
- 第二遍循环生成新行:对每个
j,取dp[min1]作为基准,仅当j == min1且次小存在时改取dp[min2],再加上costs[i][j]。之所以只在j == min1时退让,是因为只有这一列会撞上「相邻同色」的禁令,其余列都可以放心接在最优列后面。- 用新数组整体替换
dp,进入下一轮。之所以要新开数组而不是原地改,是因为本行的每个ndp[j]都依赖上一行的完整信息,原地写会污染尚未读取的dp元素。- 全部房子处理完后,答案是
dp中的最小值。之所以要再扫一遍,是因为最后一个房子刷成哪种颜色没有约束,取全局最优即可。以
costs = [[1, 5, 3], [2, 9, 4]]走一遍,n = 2,k = 3。初始
dp = [0, 0, 0]。第一轮i = 0:扫描找极值,j = 0时min1 = 0;j = 1时dp[1] = 0不小于dp[0] = 0,进入 else,min2 = 1;j = 2时既不小于dp[min1]也不小于dp[min2],不更新。得min1 = 0、min2 = 1。生成新行:j = 0撞上min1,取dp[min2] = 0,得ndp[0] = 1 + 0 = 1;j = 1取dp[min1] = 0,得 5;j = 2同理得 3。dp = [1, 5, 3]。第二轮
i = 1:扫描dp = [1, 5, 3],j = 0令min1 = 0;j = 1不小于 1,min2 = 1;j = 2的 3 不小于dp[min1] = 1,但小于dp[min2] = 5,故min2 = 2。得min1 = 0(值 1)、min2 = 2(值 3)。生成新行:j = 0撞上min1,取dp[min2] = 3,得2 + 3 = 5;j = 1取dp[min1] = 1,得9 + 1 = 10;j = 2取 1,得4 + 1 = 5。dp = [5, 10, 5]。收尾取最小值 5。人工核对:第一间刷颜色 0 花 1、第二间刷颜色 2 花 4,合计 5,且颜色不同,确实是最优解。
代码实现
class Solution {
public int minCostII(int[][] costs) {
if (costs.length == 0) {
return 0;
}
int k = costs[0].length;
int[] dp = new int[k];
for (int i = 0; i < costs.length; i++) {
int min1 = -1;
int min2 = -1;
for (int j = 0; j < k; j++) {
if (min1 == -1 || dp[j] < dp[min1]) {
min2 = min1;
min1 = j;
} else if (min2 == -1 || dp[j] < dp[min2]) {
min2 = j;
}
}
int[] ndp = new int[k];
for (int j = 0; j < k; j++) {
int best = dp[min1];
if (j == min1 && min2 != -1) {
best = dp[min2];
}
ndp[j] = costs[i][j] + best;
}
dp = ndp;
}
int answer = Integer.MAX_VALUE;
for (int v : dp) {
answer = Math.min(answer, v);
}
return answer;
}
}
func minCostII(costs [][]int) int {
if len(costs) == 0 {
return 0
}
k := len(costs[0])
dp := make([]int, k)
for i := 0; i < len(costs); i++ {
min1, min2 := -1, -1
for j := 0; j < k; j++ {
if min1 == -1 || dp[j] < dp[min1] {
min2 = min1
min1 = j
} else if min2 == -1 || dp[j] < dp[min2] {
min2 = j
}
}
ndp := make([]int, k)
for j := 0; j < k; j++ {
best := dp[min1]
if j == min1 && min2 != -1 {
best = dp[min2]
}
ndp[j] = costs[i][j] + best
}
dp = ndp
}
answer := dp[0]
for _, v := range dp {
if v < answer {
answer = v
}
}
return answer
}
复杂度分析
- 时间复杂度:$O(nk)$,凭据是外层对
n个房子各执行一次,内层只有两遍互不嵌套的长度为k的扫描——一遍求最小与次小,一遍生成新行——转移中原本对上一行颜色的枚举被这两个预先算好的极值取代了。- 空间复杂度:$O(k)$,凭据是二维状态被滚动成一维,任意时刻只同时存在上一行的
dp和当前行的ndp两个长度为k的数组,与房子数量n无关。
关键点总结
- 相邻元素之间的互斥约束加上单向推进的决策序列,就是逐位动态规划的标准信号;状态里只需要携带「上一步做了什么选择」,更早的历史可以全部丢弃。
- 当转移形如「在上一层所有状态中排除一个后取最小」时,维护最小值与次小值就能把内层枚举降成常数,这是一条可以直接搬到别处的优化模式。
- 记录极值时存下标而非数值,能同时回答「最小是多少」和「最小是谁」两个问题,在需要判断「当前决策是否与上一层最优决策冲突」时必不可少。
- 让滚动数组的初值恰好落在「零成本的虚拟第 -1 行」上,可以把首行初始化并入主循环,减少一处容易写错的特判——这类「虚拟起点」技巧在序列 DP 里非常通用。
- 新行必须写进独立数组,因为同一轮内所有转移都读的是上一行的完整快照,原地覆盖会让后写的列读到已被修改的值。
- 面试视角:面试官几乎一定会先让你写 $O(nk^2)$ 的朴素版,再问「k 很大怎么办」。答题时要主动点出「每个 j 的转移只是从同一个集合里挖掉一个元素」,然后自然引出最小/次小。如果还被追问空间,可以说明
ndp也能省掉——用两个变量记住本行的新最小与新次小,边算边更新,把空间降到 $O(1)$。
易错点总结
- 内层判断写成
else if (dp[j] < dp[min2])却不先处理min2 == -1:用例k = 2且dp = [3, 5],j = 1时min2还是 -1,dp[-1]直接抛数组越界。- 更新最小值时忘记把旧最小挤给次小,写成
if (dp[j] < dp[min1]) min1 = j;:用例dp = [5, 3, 1],最终min1 = 2、min2停在 1(值 3),但真正的次小是 3 所在的列 1,此例侥幸对;换成dp = [1, 3, 0]则min2停在 1(值 3),而正确次小应是 1(列 0),转移会多花 2。- 用数值而不是下标记录最小/次小,然后用
dp[j] == minVal判断是否冲突:用例某行dp = [2, 2, 7],两列并列最小,j = 1会被误判为「必须退让」而取次小 2,结果虽相同,但当dp = [2, 2, 7]且次小被错记为 7 时,j = 1会多花 5。- 原地更新
dp[j] = costs[i][j] + best而不新建数组:用例costs = [[1, 5, 3], [2, 9, 4]],第二轮算完dp[0]后dp已被污染,后面列取到的dp[min1]是新值而非上一行的值,答案偏大。- 把
dp初始化为costs[0]之后主循环仍从i = 0开始:用例costs = [[1, 5, 3]],第一行被计入两次,返回 2 而非 1。- 收尾时 Java 里用
int answer = 0再取Math.min:用例任意正开销输入如[[1, 5, 3]],min(0, 1)恒为 0,直接返回 0。- 忘记
costs.length == 0的判断:用例costs = [],costs[0].length抛数组越界异常。- 转移时对所有
j一律取dp[min1],漏掉j == min1的退让:用例costs = [[1, 5, 3], [2, 9, 4]],第二轮j = 0会取dp[0] = 1得 3,最终返回 3,对应两间房都刷颜色 0,违反相邻不同色,正确答案是 5。- 反过来对
j != min1也用dp[min2]:用例同上,第二轮j = 2取dp[min2] = 3得 7,答案变成 5 与 7 取小仍为 5 看不出问题;但在costs = [[1, 100], [1, 1]]上会让j = 1取到不必要的大值,返回 3 而非 2。- 每轮在生成新行的循环内部才去找最小/次小:用例任意输入,读到的
dp已经掺入部分新值(若原地写)或每次重复扫描(若不原地写),前者答案错误,后者复杂度退回 $O(nk^2)$,失去本解法的全部意义。k = 1且房子数大于 1 时仍去访问dp[min2]:用例costs = [[1], [2]],min2恒为 -1,若不写min2 != -1的保护会越界;加上保护后返回 3,虽不符合「相邻不同色」的现实语义,但题目数据不包含该情形,实现上只需保证不崩。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 256. 粉刷房子 | 中等 | 颜色数固定为 3,转移可以直接手写三项取小,无需极值优化 |
| 1473. 粉刷房子 III | 困难 | 多出「恰好形成 target 个街区」的维度,状态要加一维段数 |
| 198. 打家劫舍 | 中等 | 相邻约束变成「不能同时选」而非「不能同值」,状态只有两种 |
| 931. 下降路径最小和 | 中等 | 转移只允许来自相邻三列,是本题约束的收紧版 |
| 746. 使用最小花费爬楼梯 | 简单 | 一维序列上的最小代价推进,考察起点与终点的边界处理 |