奇怪的打印机(最小打印次数问题,进阶版:每次打印必须覆盖一个连续区间,且每次只能打印一种颜色,已打印部分可被覆盖,求打印整个目标字符串的最少打印次数)
题目描述:
给定一个字符串 s,表示最终要打印出的目标字符串。初始时,打印纸是空白的(可以视为由任意数量的相同字符组成,例如全是 'a',但最终我们需要打印出 s)。你有一台奇怪的打印机,它每次操作必须打印一个连续区间,并且这个区间内的所有字符必须被打印成同一种颜色(即同一个字符)。已打印的部分可以被后续打印覆盖。问至少需要多少次打印操作,才能得到目标字符串 s。
示例:
输入:s = "aaabbb"
输出:2
解释:先打印整个区间为 'a'(得到 "aaaaaa"),再打印后三个字符为 'b'(得到 "aaabbb")。
解题过程:
这个问题是区间动态规划的典型应用。我们需要逐步推导出最优的打印策略。核心思路是:将字符串分割成子区间,考虑如何在子区间内以最少的打印次数得到目标字符串。
第一步:明确状态定义
定义 dp[i][j] 为打印出子串 s[i...j](下标从 i 到 j,包含两端)所需的最少打印次数。
目标是求 dp[0][n-1],其中 n 是字符串长度。
第二步:分析基础情况
- 当区间长度为 1 时(即 i == j),只需要打印一次即可得到该字符。
所以dp[i][i] = 1。
第三步:状态转移分析(关键)
对于区间 [i, j],我们考虑第一次打印操作如何覆盖这个区间。由于每次打印必须是一个连续区间且全部打印为同一种颜色,我们可以考虑第一次打印覆盖了区间 [i, k],且这次打印的颜色设为 s[i](因为最终 s[i] 的颜色是固定的,第一次打印可以选择打印成 s[i],这样后续再覆盖其他部分)。注意,第一次打印不一定只打印一个字符,它可以打印一段连续的区间。
但更通用的思路是:
- 首先,如果
s[i] == s[j],那么我们可以利用一个性质:可以在打印区间[i, j]时,先将整个区间打印成s[i](或 s[j]),然后在内部调整。但更精确的优化是:因为 s[i] 和 s[j] 相同,我们可以将打印 s[i] 和 s[j] 的操作合并考虑,即先打印整个区间为 s[i],然后再在内部覆盖。但动态规划中更常用的是:如果 s[i] == s[k](i < k <= j),那么可以先打印区间 [i, k] 为 s[i],然后问题分解为 [i+1, k-1] 和 [k, j]。但这样处理比较复杂。
更简洁且正确的状态转移方程是:
我们考虑将区间分成两段:[i, k] 和 [k+1, j]。
如果两段完全独立打印,则总打印次数为 dp[i][k] + dp[k+1][j]。
但这里有一个优化:如果 s[i] 和 s[k+1] 相同,我们是否可以在某次打印中同时覆盖它们,从而减少一次打印?
实际上,更精确的优化是:如果 s[i] == s[k+1],那么我们可以让第一次打印的颜色为 s[i],覆盖区间 [i, m],然后后续处理其他部分。但这样在状态转移中不易直接表示。
经典且正确的状态转移方法是:
对于区间 [i, j],我们考虑第一次打印操作打印了区间 [i, k],且颜色为 s[i]。那么这次打印之后,位置 i 已经正确了。接下来我们需要处理剩余部分:区间 [i+1, j]。但注意,由于第一次打印覆盖了 [i, k],所以区间 [i+1, k] 现在也是颜色 s[i]。如果 s[i] 和 s[k+1] 不同,那么 [i+1, k] 可能需要被覆盖。但更简单的思路是:将问题分解为:先打印出 s[i] 正确的部分,再处理剩下的。
但最常用的状态转移方程是(经过验证的):
dp[i][j] = dp[i][j-1] 如果 s[i] == s[j]。
为什么?因为如果 s[i] == s[j],我们可以在打印 s[i] 的时候,一次性将整个区间 [i, j] 打印成 s[i],然后再在区间内部覆盖修改,而不需要额外增加一次打印来专门处理 s[j]。但更准确的理解是:我们可以将 s[j] 视为和 s[i] 一起打印,所以次数不会比打印 [i, j-1] 多。但这是否正确?考虑例子 "aba",如果 i=0, j=2, s[0]=a, s[2]=a,那么 dp[0][2] 应该等于 2(先打印整个区间为 'a',再打印中间为 'b'),而 dp[0][1] 也是 2(先打印 "aa",再打印第二个为 'b'),所以相等。但注意,这个等式并不总是成立,所以我们需要更精确的推导。
实际上,正确的状态转移方程是:
- 首先,可以单独打印位置 i,然后处理 [i+1, j],即
dp[i][j] = 1 + dp[i+1][j]。 - 其次,我们尝试节省操作:如果在 [i+1, j] 中有一个位置 k,使得
s[i] == s[k],那么我们可以将区间 [i, k] 在一次打印中完成(打印成 s[i]),然后问题分解为:先打印出 [i+1, k-1] 的部分(因为 [i, k] 已经打印成 s[i],所以 [i+1, k-1] 需要被覆盖成正确的字符),再加上打印 [k, j] 的部分。但注意,由于 s[i] == s[k],打印 [i, k] 为 s[i] 后,位置 k 已经是正确的颜色了,所以问题变成:打印 [i+1, k-1] 和打印 [k, j] 两部分。但这两部分可能有重叠,所以更好的分解是:打印 [i+1, k-1] 和打印 [k+1, j],但这样不连续。
经过整理,正确的状态转移方程为:
dp[i][j] = min(dp[i][j], dp[i][k-1] + dp[k+1][j]) 当 s[i] == s[k] 且 i < k <= j。
但这是不完整的,因为当 s[i] == s[k] 时,我们可以将区间 [i, k] 用同一次打印完成,所以总的打印次数可能是:打印 [i, k] 为 s[i] 这一次,再加上打印 [i+1, k-1] 的次数(因为 [i, k] 已经打印成 s[i],所以只需要处理内部区间 [i+1, k-1] 即可),再加上打印 [k+1, j] 的次数。但注意,打印 [i+1, k-1] 实际上就是 dp[i+1][k-1],而打印 [k+1, j] 是 dp[k+1][j]。但这样会把区间割裂。
实际上,更标准且正确的状态转移方程是(来自 LeetCode 664 题的标准解法):
dp[i][j] = min(dp[i][j], dp[i][k-1] + dp[k][j]) 当 s[i] == s[k] 且 i < k <= j。
但这样需要处理边界。常用的正确形式是:
dp[i][j] = min(dp[i][j], dp[i][k-1] + dp[k][j] - 1) 如果 s[i] == s[k]。
但这里为了清晰,我们采用另一种更直观的推导。
第四步:标准状态转移方程
定义 dp[i][j] 为打印 s[i...j] 的最少打印次数。
基础情况:dp[i][i] = 1。
对于 i < j:
- 先考虑最差情况:每次只打印一个字符,则
dp[i][j] = j - i + 1。 - 优化:如果
s[i] == s[j],那么我们可以将打印 s[i] 和 s[j] 合并为同一次操作(在打印区间 [i, j] 时先整体打印成 s[i],再处理内部),所以dp[i][j] = min(dp[i][j], dp[i][j-1])。注意,这里 dp[i][j-1] 表示打印 s[i...j-1] 的次数,因为 s[j] 可以和 s[i] 一起打印,所以不会增加次数。 - 进一步优化:在区间 [i, j] 中,如果有一个位置 k (i < k <= j) 使得
s[i] == s[k],那么我们可以将区间 [i, k] 在一次操作中打印成 s[i],然后问题分解为:打印 [i+1, k-1] 和打印 [k, j]。但注意,打印 [k, j] 时,s[k] 已经正确了,所以实际上只需要打印 [k, j] 这部分。但更准确的分解是:打印 [i, k] 为 s[i] 这一次操作,加上打印 [i+1, k-1] 的次数,再加上打印 [k+1, j] 的次数。但这样比较复杂。
经过验证,标准且正确的状态转移方程为:
dp[i][j] = dp[i][j-1] # 如果 s[i] == s[j]
否则:
dp[i][j] = min(dp[i][k] + dp[k+1][j]) 对于所有 i <= k < j
但这样在 s[i] == s[j] 时,我们可以进一步优化为 dp[i][j] = dp[i][j-1]。
为什么?因为如果 s[i] == s[j],我们可以在打印 s[i] 的时候,一次性覆盖到 j,这样打印 s[j] 就不需要额外次数,所以次数等于打印 s[i...j-1] 的次数。
第五步:示例推导
以 s = "ababa" 为例,n=5。
初始化 dp 矩阵,对角线 dp[i][i] = 1。
计算区间长度从 2 到 4(0-indexed)。
- 区间 [0,1]: s[0]='a', s[1]='b',不相等,所以 dp[0][1] = min(dp[0][0]+dp[1][1]) = 1+1=2。
- 区间 [1,2]: 'b' 和 'a' 不同,dp[1][2]=2。
- 区间 [2,3]: 'a' 和 'b' 不同,dp[2][3]=2。
- 区间 [3,4]: 'b' 和 'a' 不同,dp[3][4]=2。
- 区间 [0,2]: s[0]='a', s[2]='a' 相等,所以 dp[0][2] = dp[0][1] = 2。
验证:先打印整个区间为 'a'(一次),再打印中间为 'b'(第二次),共2次,正确。 - 区间 [1,3]: s[1]='b', s[3]='b' 相等,dp[1][3] = dp[1][2] = 2。
- 区间 [2,4]: s[2]='a', s[4]='a' 相等,dp[2][4] = dp[2][3] = 2。
- 区间 [0,3]: s[0]='a', s[3]='b' 不同,所以尝试分割点 k=1,2:
k=1: dp[0][1]+dp[2][3] = 2+2=4
k=2: dp[0][2]+dp[3][3] = 2+1=3
取 min=3,所以 dp[0][3]=3。 - 区间 [1,4]: s[1]='b', s[4]='a' 不同,尝试分割:
k=2: dp[1][2]+dp[3][4]=2+2=4
k=3: dp[1][3]+dp[4][4]=2+1=3
取 min=3,dp[1][4]=3。 - 区间 [0,4]: s[0]='a', s[4]='a' 相等,所以 dp[0][4] = dp[0][3] = 3。
验证:先打印整个区间为 'a'(一次),再打印区间 [1,3] 为 'b'(第二次),再打印位置 2 为 'a'(第三次),但这样是3次吗?实际上,最优策略是:先打印整个区间为 'a'(一次),再打印区间 [1,3] 为 'b'(第二次),但此时位置2变成了 'b',需要再打印位置2为 'a'(第三次),共3次。正确。
第六步:算法实现
我们可以用区间 DP 自底向上计算。
伪代码:
n = len(s)
dp = [[0]*n for _ in range(n)]
for i in range(n):
dp[i][i] = 1
for length in range(2, n+1):
for i in range(n-length+1):
j = i+length-1
dp[i][j] = length # 最差情况
if s[i] == s[j]:
dp[i][j] = dp[i][j-1] # 注意这里是 j-1,因为 j 可以和 i 一起打印
else:
for k in range(i, j):
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j])
但注意,当 s[i]==s[j] 时,我们也可以不单独处理,而在分割中自动优化。但上述处理是常见且正确的。
第七步:复杂度分析
时间复杂度 O(n³),空间复杂度 O(n²)。可以通过进一步优化减少常数,但思路不变。
第八步:总结
这个问题的核心在于:如果区间两端的字符相同,那么可以在一次打印中覆盖两端,从而减少打印次数。通过区间 DP 逐步合并子区间的最优解,最终得到整个字符串的最小打印次数。