奇怪的打印机的最小打印次数问题(每次打印必须覆盖一个连续区间,且每次只能打印一种颜色,已打印部分可被覆盖,求打印整个目标字符串的最少打印次数)
题目描述
有一台奇怪的打印机,它每次打印时,必须选择一个连续区间,并将这个区间内的所有字符都统一打印成同一种字母(例如全部打印成 'a' 或 'b' 等)。
打印机可以在已打印的字符上重新打印,覆盖之前打印的内容。
给定一个目标字符串 s(只由小写字母组成),问至少需要打印多少次,才能得到这个目标字符串。
示例
输入:s = "aaabbb"
输出:2
解释:
- 第一次打印整个区间为
[0,5],全部打印为'a',得到"aaaaaa"。 - 第二次打印区间
[3,5],全部打印为'b',得到"aaabbb"。
解题思路(区间 DP)
这个问题是区间动态规划的经典题目,核心在于如何利用覆盖操作的性质来设计状态转移。
1. 关键观察
- 如果一次打印能覆盖一个连续区间,那么这个区间的首尾字符相同时,我们可以用一次打印覆盖整个区间,然后中间部分再调整。
- 更一般地,如果
s[i] == s[j],那么打印s[i]的时候,可以顺便把s[j]也打印成同样的字母,然后再处理中间部分。 - 由于打印是覆盖操作,最后打印的字符决定了这个位置最终是什么字母。
2. 定义状态
设
dp[i][j] = 打印出子串 s[i..j] 所需的最少打印次数。
其中 i 和 j 是闭区间索引,0 <= i <= j < n。
3. 边界条件
- 当区间长度为 1 时,只需打印一次:
dp[i][i] = 1。
4. 状态转移方程推导
考虑区间 [i, j]:
情况 1:s[i] == s[j]
-
我们可以在第一次打印时,将整个区间
[i, j]打印成s[i](即s[j])。 -
之后,我们只需处理区间
[i, j-1]或者[i+1, j]的细节,因为s[j]已经在第一次打印时正确了。 -
更精确地说:
第一次打印区间[i, j]为s[i]后,s[i]和s[j]都已经正确,但中间可能还需要调整。
这时,相当于在[i, j]区间内,先保证s[i]正确,然后处理[i+1, j],但要注意s[i]和s[j]相同,所以可以节省一次对s[j]的额外打印。
实际上,可以这样理解:
dp[i][j] = dp[i][j-1]
因为最后打印s[i]时可以覆盖到s[j],所以s[j]不用单独处理。但这是不完全正确的,因为可能中间有和
s[i]不同的字符,我们还要处理它们。
更准确的标准推导:
假设第一次打印是打印整个区间为s[i],那么s[j]也正确了,接下来只需要处理[i, j-1]区间,但[i, j-1]的首字符已经是s[i]正确,所以其实等价于处理[i, j-1]区间,但要注意s[i]可能和中间某些字符相同,可以减少打印次数。
实际上,更通用的正确方程是:
如果s[i] == s[j],则dp[i][j] = dp[i][j-1]。
为什么?
因为要得到s[i..j],我们可以先得到s[i..j-1],然后在最后一步打印区间[i, j]为s[i](即s[j]),这样s[j]就会从原来的(可能是错的)变成正确的,而且这一步不增加打印次数,因为它可以合并到某次打印中吗?
实际上,我们可以在得到s[i..j-1]的过程中,最后一次打印s[i]时直接延伸到j,这样打印s[i..j]的总次数等于打印s[i..j-1]的次数。严谨地说:
构造s[i..j]时,可以让第一次打印覆盖[i, k]为s[i],其中k是某个位置。但更简单的推导是:
考虑s[i] == s[j],那么我们可以先打印整个区间为s[i],这算一次打印,然后我们只需处理[i+1, j]区间,但这样会多算吗?
实际上标准做法是:
dp[i][j] = min(dp[i][j-1], dp[i+1][j])当s[i] == s[j]时。
为什么?
如果第一次打印时覆盖了[i, j]为s[i],那么s[j]已经正确,我们只需处理[i, j-1],但s[i]也正确,所以问题变为dp[i][j-1]。
同理,也可以先打印整个区间为s[j],然后处理[i+1, j],即dp[i+1][j]。
因此:
转移 1:dp[i][j] = min(dp[i][j-1], dp[i+1][j])如果s[i] == s[j]。
情况 2:s[i] != s[j]
- 这时,区间首尾字符不同,无法在一次打印中同时让它们都正确(因为一次打印只能是一种字母)。
- 我们需要在中间某个位置
k将区间分割成两部分分别处理:
dp[i][j] = min(dp[i][k] + dp[k+1][j]),其中k从i到j-1。
5. 整合方程
综合起来:
- 初始化
dp[i][i] = 1。 - 对于长度
len从 2 到 n:- 对于每个
i,j = i + len - 1:- 如果
s[i] == s[j]:
dp[i][j] = dp[i][j-1]或者dp[i+1][j]的最小值。
(其实dp[i][j-1]和dp[i+1][j]是相等的吗?不一定,取最小值即可。) - 如果
s[i] != s[j]:
dp[i][j] = min(dp[i][k] + dp[k+1][j])对k = i..j-1。
- 如果
- 对于每个
但更常见且正确的写法(也是标准答案的写法):
- 先令
dp[i][j] = j - i + 1(最差情况,每个字符单独打印)。 - 如果
s[i] == s[j],则dp[i][j] = dp[i][j-1]。 - 然后枚举中间点
k进行分割:dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j])。
注意:当 s[i] == s[j] 时,dp[i][j] = dp[i][j-1] 是成立的,因为可以在最后一步顺带打印 s[j] 而不增加次数。但为了保险,也常直接按 dp[i][j] = min(dp[i][j-1], dp[i+1][j]) 处理。
经过检验,标准正确方程是:
dp[i][j] = dp[i][j-1] if s[i] == s[j]
dp[i][j] = min(dp[i][k] + dp[k+1][j]) for k in [i, j-1] otherwise
但枚举分割点时,即使 s[i] == s[j] 也可能通过分割得到更优解,所以实际代码中,我们总是枚举分割点,但初始化时如果 s[i] == s[j] 可以先设为 dp[i][j-1] 作为候选。
6. 举例推导
s = "aba"
dp[0][0]=1,dp[1][1]=1,dp[2][2]=1。- 区间
[0,1]:s[0]='a',s[1]='b',不同,枚举k=0:
dp[0][1] = dp[0][0]+dp[1][1] = 1+1=2。 - 区间
[1,2]:s[1]='b',s[2]='a',不同,枚举k=1:
dp[1][2] = dp[1][1]+dp[2][2] = 2。 - 区间
[0,2]:s[0]='a',s[2]='a',相同,先设dp[0][2] = dp[0][1] = 2。
再枚举分割点:k=0:dp[0][0]+dp[1][2]=1+2=3k=1:dp[0][1]+dp[2][2]=2+1=3
最小是 2,所以dp[0][2]=2。
结果:最少打印 2 次。
解释:第一次打印整个区间为 'a' 得到 "aaa",第二次打印区间 [1,1] 为 'b' 得到 "aba"。
7. 算法步骤
- 初始化
dp[n][n]为 0,dp[i][i]=1。 - 按区间长度从小到大计算:
- 对每个
i,j,先设dp[i][j] = j-i+1。 - 如果
s[i] == s[j],则dp[i][j] = dp[i][j-1](当j>i时,注意len=2时dp[i][i]已知)。 - 枚举中间分割点
k从i到j-1:
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j])。
- 对每个
- 返回
dp[0][n-1]。
8. 复杂度
- 时间复杂度:O(n³),三层循环(长度、起点、分割点)。
- 空间复杂度:O(n²)。
9. 示例代码(Python 风格)
def strangePrinter(s: str) -> int:
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]
for k in range(i, j):
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j])
return dp[0][n - 1] if n > 0 else 0