奇怪的打印机 II(最小打印次数问题,颜色数量有限的最少打印次数)
题目描述
我们有一台奇怪的打印机。它有一个长度为 n 的目标字符串 target,每个字符 target[i] 是 26 个小写字母之一,表示一种颜色。打印机初始时有一张白纸(可以视为由空格组成的字符串,或者任意空白字符)。每次操作,打印机可以选择一个连续区间 [i, j],并用一种颜色(一个小写字母)覆盖这个区间内的所有字符。注意,每次打印必须使用单一颜色,且会完全覆盖区间内原有的字符(无论原来是什么颜色)。打印机可以执行任意多次操作。
问:打印出恰好等于目标字符串 target 所需的最少操作次数是多少?
此外,题目有一个进阶条件:目标字符串中最多只包含 k 种不同颜色(字母)。本题中 k 通常较小(例如 k ≤ 5 或 k ≤ 6)。
示例
target = "ababa"
输出:3
解释:最少三次操作:第一次打印整个区间为 'a' 得到 "aaaaa";第二次打印区间 [1,3] 为 'b' 得到 "abbba";第三次打印区间 [2,2] 为 'a' 得到 "ababa"。
限制
- 1 ≤ n ≤ 100
- target 仅包含小写字母
- 颜色种类 k ≤ 6(或类似小常数)
解题思路
这是一道区间动态规划(interval DP)题目,与之前讲过的“奇怪的打印机”问题相似,但多了一个条件:颜色数量有限。这允许我们用状态压缩来优化,将颜色集合作为状态的一部分。
核心思路:
- 如果区间两端字符相同,可以在第一次打印整个区间时直接使用这个颜色,从而减少一次操作。
- 但颜色有限,我们可以在状态中记录“已使用的颜色集合”,以便复用已有颜色。
- 最终目标是打印整个字符串,并且使用的颜色集合是 target 中实际出现的颜色集合的子集。
第一步:状态定义
设 dp[l][r][mask] 表示将区间 [l, r] 打印成 target[l…r] 所需的最少操作次数,且打印过程中使用的颜色集合恰好是 mask(mask 是一个二进制数,第 c 位为 1 表示颜色 c 被使用过)。
颜色编号:我们可以将 target 中出现的不同颜色映射到 0 到 k-1。
第二步:状态转移
考虑区间 [l, r]:
- 如果 l == r,则只需一次操作(打印这个字符),mask 中只有这个字符对应的位为 1。
- 如果 l < r,我们考虑第一次操作打印的颜色和区间。
- 情况 A:第一次操作打印整个区间 [l, r] 为颜色 c(必须与 target[l] 相同吗?不一定,但如果第一次打印的颜色与最终 target[l] 不同,则后续还要覆盖,显然不优,所以第一次打印的颜色应该与 target[l] 相同)。事实上,我们可以证明存在最优解,使得第一次操作打印的颜色与 target[l] 相同,且打印的区间左端点为 l。
- 情况 B:第一次操作只打印区间 [l, m](m ≥ l),颜色为 target[l]。之后分别处理子区间。
实际上,更高效的方法是:
第一次操作打印颜色 c = target[l],覆盖的区间是 [l, p],其中 p 是满足 target[p] == c 且 p 尽可能大的位置(不一定要连续,但为了最少操作,应尽量覆盖所有相同颜色的位置)。
然后区间被分成若干段,每一段是连续的不等于 c 的区间,对每一段递归求解。
但这样需要枚举 p,时间复杂度较高。
更通用的区间 DP 思路(适用于颜色数 k 小的情况):
我们定义 dp[l][r][mask] 为最少操作次数。
转移时,我们考虑第一次操作打印的颜色 c 在 mask 中(必须使用),然后枚举这个颜色 c 在区间 [l, r] 中覆盖的若干段。但这样很复杂。
另一种更简单的状态设计(不显式记录 mask):
设 f[l][r] 表示打印区间 [l, r] 成 target[l…r] 的最少操作次数。
转移方程:
- 如果 target[l] == target[r],那么第一次操作可以打印颜色 target[l] 覆盖整个区间,之后只需要处理中间部分。但注意:如果中间有不同颜色,可能需要额外操作。
我们可以考虑:f[l][r] = f[l][r-1] 或者 f[l+1][r](因为最后打印 target[l] 时可以把右端点一起覆盖)。
实际上,当 target[l] == target[r] 时,最优策略是第一次打印整个区间为 target[l],然后递归处理去掉两端相同颜色后的区间。但这样不一定最优,因为中间可能有同色区间可以合并打印。
更正确的方程:
f[l][r] = min( f[l][k] + f[k+1][r] ) for k in [l, r-1]
但这样没有利用“同色可一起打印”的性质,会超时。
优化:考虑如果 target[l] == target[r],我们可以让第一次打印颜色 target[l] 覆盖 l 和 r 以及中间所有同色的位置,然后中间的不同色的区间分别处理。
即:找到所有与 target[l] 相同颜色的位置,将它们视为一个整体,第一次打印这些位置,然后处理中间的段。
实现时,我们可以用如下递推:
f[l][r] = min( f[l+1][r], f[l][r-1] ) if target[l] == target[r]
因为如果两端相同,可以先打印整个区间为 target[l],然后处理内部,但这样会多一次操作,所以不是 min(f[l+1][r], f[l][r-1]),而是 f[l][r] = f[l][r-1] 或者 f[l+1][r]?
实际上,考虑打印区间 [l, r],如果 target[l] == target[r],我们可以先打印 [l, r] 为 target[l],然后递归处理 [l, r-1] 或 [l+1, r](因为右端点已被覆盖,只需要处理左边部分)。
更准确:f[l][r] = f[l][r-1] (即先打印 [l, r] 为 target[l],则 r 已正确,只需处理 [l, r-1])。
但这是假设我们在最后一次操作才打印 target[l],不一定最优。
标准解法(LeetCode 546 移除盒子的思路类似,但颜色数有限):
我们定义 dp[l][r][k] 表示在区间 [l, r] 后面还有 k 个与 target[l] 相同颜色的字符已经打印好的情况下,完成该区间的最少操作次数。
但本题是打印而非移除,所以需调整。
更简单的解法(针对颜色数 k ≤ 6):
状态:dp[l][r][mask] 表示将区间 [l, r] 打印成目标,且使用的颜色集合是 mask 的最少操作次数。
转移:
- 如果 mask 中只有一种颜色,且区间内所有字符都是这个颜色,则 dp=1。
- 否则,枚举第一次操作打印的颜色 c(在 mask 中),然后枚举这个颜色 c 在区间中覆盖的所有位置(这些位置必须是目标中就是颜色 c 的)。
但这样复杂度太高。
实际高效解法(区间 DP + 状态压缩优化,k 小):
我们预处理每个位置的颜色编号 0~k-1。
状态:dp[l][r][mask] 表示将区间 [l, r] 打印成目标,且这个区间最终使用的颜色集合是 mask 的最少操作次数。
转移:
- 如果 mask 中只有一种颜色 c,则 dp[l][r][mask] = 1 如果区间 [l, r] 中所有目标颜色都是 c,否则为无穷大。
- 否则,枚举分割点 m,将区间分成 [l, m] 和 [m+1, r],它们使用的颜色集合分别是 mask1 和 mask2,且 mask1 ∪ mask2 = mask,mask1 ∩ mask2 可以非空(因为颜色可复用)。
则 dp[l][r][mask] = min( dp[l][m][mask1] + dp[m+1][r][mask2] - count(mask1 ∩ mask2) ),其中 count 是 mask1 和 mask2 交集的大小(重复颜色被多算了一次操作,需减去)。
但这样要枚举子集,复杂度 O(n^3 * 3^k),对于 n=100, k=6 勉强可行。
最终简化版本(常用解法):
我们只定义 dp[l][r] 为打印区间 [l, r] 的最少操作次数,不考虑颜色集合。
转移:
- dp[l][r] = dp[l][r-1] + 1 (先打印 [l, r-1],再打印 r)
- 如果 target[k] == target[r] 对于某个 k 在 [l, r-1] 中,则可以将 k 和 r 在一次操作中打印(颜色为 target[r]),然后中间部分另外处理。
即:dp[l][r] = min( dp[l][k] + dp[k+1][r-1] ) for k in [l, r-1] where target[k] == target[r]
这是因为,当 target[k] == target[r] 时,我们可以先打印区间 [k, r] 为 target[r],然后分别处理 [l, k-1] 和 [k+1, r-1]。但这样会多一次操作,所以方程是 dp[l][r] = min( dp[l][k-1] + dp[k+1][r-1] )?
实际上,标准方程是:
dp[l][r] = min( dp[l][k-1] + dp[k][r-1] ) if target[k] == target[r]?
更准确的方程(来自 LeetCode 664 奇怪的打印机,但这里颜色有限不影响这个方程):
dp[l][r] = dp[l][r-1] + 1
dp[l][r] = min( dp[l][k] + dp[k+1][r-1] ) for k in [l, r-1] where target[k] == target[r]
实现步骤(最终采用 k 小的优化版):
-
将 target 中不同颜色映射到 0~k-1。
-
预处理每个区间是否全为同色。
-
动态规划:
dp[l][r] 初始化 = dp[l][r-1] + 1。
枚举 k 从 l 到 r-1,如果 target[k] == target[r],则
dp[l][r] = min(dp[l][r], dp[l][k-1] + dp[k][r-1]) (注意边界)
或者 dp[l][r] = min(dp[l][r], dp[l][k] + dp[k+1][r-1])。实际上,当 target[k] == target[r] 时,我们可以将 k 和 r 在同一次操作打印,所以区间 [k, r] 可以视为一次操作,然后分别处理 [l, k-1] 和 [k+1, r-1],但 k+1 可能大于 r-1,此时第二部分为 0。
所以方程:
dp[l][r] = min(dp[l][r], dp[l][k-1] + dp[k+1][r-1]) if target[k] == target[r] -
最终答案 = dp[0][n-1]。
复杂度:O(n^3),对于 n=100 可行。颜色种类限制 k 在本解法中未直接利用,但实际运行更快因为相同颜色判断更少。
举例说明
target = "ababa"
n=5, 颜色 a,b。
初始化 dp[i][i]=1。
计算 dp[0][1]: 'a','b' 不同,所以 dp[0][1] = dp[0][0]+1=2。
dp[1][2]: 'b','a' -> 2。
dp[0][2]: 'a','b','a'
首先 dp[0][2] = dp[0][1]+1=3。
然后 k=0: target[0]=='a' 但 target[2]=='a' 相等,所以 dp[0][2] = min(3, dp[0][-1] + dp[1][1]),注意边界。
实际上,当 k=0,区间分 [l, k-1] 和 [k+1, r-1] 即 [0,-1] 和 [1,1]。
dp[0][-1] 表示空区间,操作数 0。dp[1][1]=1,所以总 1,但还需要一次操作打印 target[0] 和 target[2] 为 'a',所以是 1+1=2?
我们定义 dp[l][r] 为打印区间 [l,r] 的最少次数,当 k=0 时,我们可以在一次操作打印 [0,2] 为 'a',然后处理 [1,1] 为 'b',所以总次数 = 1 + dp[1][1] = 2。
所以方程应该是:
dp[l][r] = min(dp[l][r], dp[l][k-1] + 1 + dp[k+1][r-1])?
但 k=0 时,dp[l][k-1] 是空区间 0,dp[k+1][r-1] 是 [1,1] 即 1,再加 1 得 2。正确。
所以最终方程为:
dp[l][r] = min(dp[l][r], dp[l][k-1] + 1 + dp[k+1][r-1]) if target[k] == target[r]
且当 k+1 > r-1 时,dp[k+1][r-1] = 0。
按此计算,最终 dp[0][4] = 3。
代码框架(Python)
def strangePrinterII(target):
n = len(target)
dp = [[0]*n for _ in range(n)]
for i in range(n):
dp[i][i] = 1
for length in range(2, n+1):
for l in range(0, n-length+1):
r = l+length-1
dp[l][r] = dp[l][r-1] + 1
for k in range(l, r):
if target[k] == target[r]:
left = dp[l][k-1] if k > l else 0
right = dp[k+1][r-1] if k+1 <= r-1 else 0
dp[l][r] = min(dp[l][r], left + 1 + right)
return dp[0][n-1]
总结
这道题是区间 DP 的经典应用,通过判断区间两端或中间相同颜色来减少操作次数。颜色种类有限的条件在标准解法中不影响状态转移,但若 k 极小,可用状态压缩进一步优化。核心是找到最优的第一次打印区间,使得相同颜色尽量一起打印,从而减少操作次数。