合并字符串的最小成本问题(字符插入、删除、替换,带相邻合并规则)
题目描述
给定两个字符串 source 和 target,以及三种操作的成本:
- 插入一个字符,成本为
insert_cost。 - 删除一个字符,成本为
delete_cost。 - 替换一个字符,成本为
replace_cost。
此外,还有一个特殊的“合并规则”:如果经过一系列操作后,source 中相邻的两个字符变得相同,那么这两个字符可以立即合并为一个字符(合并后字符保持不变),且合并操作没有成本。合并操作可能引发新的相邻相同字符,这些字符也可以继续合并(类似消消乐),直到没有相邻相同字符为止。
目标:将字符串 source 转化为字符串 target,求最小总成本。
例如:
source = "ab", target = "a", insert_cost = 1, delete_cost = 1, replace_cost = 2。
一种方案:删除 'b'(成本1),得到 "a",与 target 相同,总成本为1。
注意:这里不需要合并操作。
更复杂的例子:
source = "aaab", target = "ab"。
我们可以删除中间的 'a'(成本 delete_cost),得到 "aab",此时前两个 'a' 相邻相同,触发合并(无成本)得到 "ab",与 target 相同。总成本 = delete_cost。
如果 delete_cost 很大,也许替换某个 'a' 为 'b' 更优,这取决于具体成本。
解题思路
这是一个区间动态规划问题,难点在于合并规则使得操作后字符串长度可能动态变化,不能直接用标准编辑距离。
关键观察
- 合并的实质:合并规则意味着在最终得到的字符串中,不可能存在相邻相同字符。因此,target 本身必须满足:target 中任意相邻字符都不相同(否则不可能通过合并得到,因为合并只会消除相邻相同字符)。题目一般会保证这一点。
- 操作与合并的顺序:我们可以想象先将 source 通过插入、删除、替换变成某个中间串 T',然后在 T' 上不断合并相邻相同字符,最终得到 target。
- 由于合并没有成本,我们可以在规划中直接考虑“合并后”的效果。也就是说,我们实际上是在规划如何将 source 变成 target,且允许在过程中任意位置合并相邻相同字符(免费)。
定义状态
设 dp[i][j] 表示将 source 的子串 source[i..j](闭区间)通过操作(插入、删除、替换)以及免费合并,最终合并成一个字符的最小成本。注意这里的目标是变成一个字符,而不是任意字符串。为什么这样定义?因为 target 的每个字符可以看作是由 source 的某个区间“合并”而来的。
状态转移
考虑子串 source[i..j] 如何变成一个字符:
-
直接操作:如果
i == j,即子串长度为1,那么不需要合并。但要变成某个字符c的成本是:- 如果
source[i] == c,成本为0。 - 否则,成本为
replace_cost(替换成 c)。
注意:我们这里不直接存储 c,而是后续在更高层次中匹配 target 的字符。
- 如果
-
分割点转移:对于
i < j,我们考虑将区间[i, j]分成两部分:[i, k]和[k+1, j],分别合并成一个字符,假设这两个字符分别为a和b:- 如果
a == b,那么它们可以免费合并成一个字符a(或b),总成本 =dp[i][k] + dp[k+1][j]。 - 如果
a != b,则我们需要用操作将它们统一成一个字符:可以选择删除其中一个,或者替换其中一个与另一个相同。
具体地,总成本 =dp[i][k] + dp[k+1][j] + min(delete_cost, delete_cost, replace_cost)? 这里需要小心。
实际上,当我们得到两个字符a和b后,要变成一个字符,可以:- 删除 a(成本 delete_cost),保留 b。
- 删除 b(成本 delete_cost),保留 a。
- 替换 a 为 b(成本 replace_cost)。
- 替换 b 为 a(成本 replace_cost)。
取这四种情况的最小值作为额外成本。
因此状态转移方程为:
dp[i][j] = min_{k = i..j-1} (dp[i][k] + dp[k+1][j] + extra_cost)其中
extra_cost为将两个不同字符统一成一个字符的最小操作成本:extra_cost = 0 (如果两个字符相同) = min(delete_cost, delete_cost, replace_cost, replace_cost) = min(delete_cost, replace_cost) (因为对称) 实际上:extra_cost = min(delete_cost, replace_cost) 当两个字符不同时。注意:插入成本在这里不出现,因为插入是在更细的子问题中考虑的(见下文)。
- 如果
-
插入操作的处理:以上 dp 只考虑了删除和替换,没有考虑插入。但插入可以在任何位置插入字符,相当于我们可以在构建过程中“凭空”产生一个字符。因此,我们可以在初始时,对于长度为1的子串
[i, i],变成字符c的成本不仅是替换,还可以是插入一个字符 c(成本 insert_cost)然后删除原来的字符(成本 delete_cost),或者直接插入 c(如果原来为空?但我们的区间非空)。为了统一,我们可以认为:对于单个字符的子串,变成字符c的成本是:cost = 0 if source[i] == c min(replace_cost, delete_cost + insert_cost) otherwise因为我们可以:
- 替换:直接替换成 c,成本 replace_cost。
- 先删除原字符,再插入 c:成本 delete_cost + insert_cost。
取两者较小值。
但在 dp 递推中,我们不知道最终要变成哪个字符,所以我们在 dp 中不指定字符,而是计算“变成某个字符”的最小成本。因此,我们可以定义
dp[i][j]为将source[i..j]变成任意一个字符的最小成本(因为最后合并成的字符可以是任意的)。这样,在合并两个区间时,如果它们最终字符相同,则 extra_cost=0;如果不同,则 extra_cost 为将其中一个字符变成另一个的最小成本(即 min(delete_cost, replace_cost))。那么如何知道两个区间最终变成的字符是否相同?我们可以在 dp 中同时记录变成每个字符的成本?那状态就太大了。更好的方法是:我们用 dp[i][j] 表示将区间合并成一个字符的最小成本,并且额外用一个数组
char_cost[i][j][ch]记录变成特定字符 ch 的最小成本。但这样复杂度高。更巧妙的做法是:注意到 extra_cost 只依赖于两个字符是否相同,以及 delete_cost 和 replace_cost 的大小。我们可以将 delete_cost 和 replace_cost 的关系事先分析:
- 如果 delete_cost <= replace_cost,那么当两个字符不同时,直接删除其中一个更划算(成本 delete_cost)。
- 如果 delete_cost > replace_cost,则替换其中一个更划算(成本 replace_cost)。
因此,extra_cost 可以统一为min_cost_diff = min(delete_cost, replace_cost)。
于是,状态转移简化为:
dp[i][j] = min_{k = i..j-1} (dp[i][k] + dp[k+1][j] + (chars_eq ? 0 : min_cost_diff))但这里我们并不知道 chars_eq(两个区间最终字符是否相等)。我们需要在 dp 中同时记录变成的字符,或者用另一个状态表示。
一个经典解法是:定义
dp[i][j][ch]为将 source[i..j] 变成单个字符 ch 的最小成本。这样状态是 O(n^2 * 26),对于小字母表可行。
最终匹配 target
有了 dp[i][j][ch],我们还需要将 source 变成 target。target 是由若干“块”组成的,每块对应 source 的一个区间,该区间最终合并成 target 的一个字符。由于 target 中相邻字符不同,这些区间是不重叠且覆盖整个 source 的(可能需删除 source 中一些字符,即某些区间为空?不,区间必须连续覆盖,但可以通过删除操作消除某些字符,相当于区间长度为0?我们可以允许区间为空,但空区间变成一个字符的成本是 insert_cost)。
因此,问题转化为:将 source 划分成 m 个连续区间(m = len(target)),第 p 个区间合并成字符 target[p],求最小总成本。这可以用另一个 DP 完成:
设 f[p][i] 表示将 target 的前 p 个字符匹配完,且使用了 source 的前 i 个字符(即 source[0..i-1])的最小成本。
转移时,枚举第 p 个字符对应的 source 区间起点 j(从 0 到 i-1),则区间为 [j, i-1],成本为 dp[j][i-1][target[p-1]]。
即:
f[p][i] = min_{j=0..i-1} ( f[p-1][j] + dp[j][i-1][target[p-1]] )
初始:f[0][0] = 0,表示 target 前0个字符匹配完,用了 source 前0个字符。
最终答案:f[m][n],其中 n = len(source)。
注意:source 中可能有字符未被匹配(即被删除),这已经包含在 dp 的成本中(删除操作)。同样,插入操作也包含在 dp 中(空区间变成字符的成本是 insert_cost,我们可以将空区间视为 dp[i][i-1][ch] = insert_cost,但区间定义需调整)。
空区间的处理
对于区间 [i, j] 当 i > j 时,表示空串,变成一个字符 ch 的成本是 insert_cost(直接插入该字符)。所以我们在初始化 dp[i][i-1][ch] = insert_cost。
算法步骤
-
预处理:设 source 长度 n,target 长度 m,字母表大小 C(假设为26个小写字母)。
-
初始化三维数组
dp[i][j][ch],大小为n x n x 26,值设为无穷大。 -
初始化长度为 0 的区间(空串):
for i in 0..n: for ch in alphabet: dp[i][i-1][ch] = insert_cost # 空串变成 ch 需要插入注意 i 从 0 到 n,i-1 可能为 -1,表示区间为空。
-
初始化长度为 1 的区间:
for i in 0..n-1: for ch in alphabet: if source[i] == ch: dp[i][i][ch] = 0 else: dp[i][i][ch] = min(replace_cost, delete_cost + insert_cost) -
区间 DP 递推(区间长度 len 从 2 到 n):
for len = 2 to n: for i = 0 to n-len: j = i + len - 1 for ch in alphabet: dp[i][j][ch] = INF for k = i to j-1: # 将区间分成 [i,k] 和 [k+1, j] # 分别尝试所有可能的字符 ch1, ch2 for ch1 in alphabet: for ch2 in alphabet: cost = dp[i][k][ch1] + dp[k+1][j][ch2] if ch1 == ch2: # 两个字符相同,合并免费,最终字符可以是 ch1 if ch1 == ch: dp[i][j][ch] = min(dp[i][j][ch], cost) else: # 要将 ch1 变成 ch,需要额外操作 extra = min_cost_diff # min(delete_cost, replace_cost) dp[i][j][ch] = min(dp[i][j][ch], cost + extra) else: # 两个字符不同,需要统一成一个字符 # 选择变成 ch1 或 ch2 或另一个字符 ch # 变成 ch1: 删除 ch2 或替换 ch2 为 ch1 extra_del = delete_cost # 删除 ch2 extra_rep = replace_cost # 替换 ch2 为 ch1 # 实际上,统一成 ch1 的额外成本 = min(delete_cost, replace_cost) extra1 = min_cost_diff # 同样,统一成 ch2 的额外成本 = min_cost_diff extra2 = min_cost_diff # 统一成另一个字符 ch (既不是 ch1 也不是 ch2) # 需要将 ch1 和 ch2 都变成 ch,成本 = min_cost_diff + min_cost_diff # 但也可以删除一个再插入等,这里简化:额外成本 = 2 * min_cost_diff extra3 = 2 * min_cost_diff # 取最小 extra = min(extra1, extra2, extra3) if ch == ch1: extra = extra1 elif ch == ch2: extra = extra2 else: extra = extra3 dp[i][j][ch] = min(dp[i][j][ch], cost + extra)以上转移较复杂,但核心思想是:合并两个区间时,如果它们最终字符相同,则免费合并;如果不同,则需要额外成本(min_cost_diff)来统一字符。
-
第二层 DP:匹配 target。
f[0][0] = 0 for p = 1 to m: f[p][0] = INF # 用了0个 source 字符无法匹配 p 个 target 字符(除非全插入,这里由 dp 空区间处理) for i = 0 to n: f[0][i] = 0 # 匹配0个 target 字符,不需要成本(多余字符可删除,但删除成本在后续区间中计入) for p = 1 to m: for i = 1 to n: f[p][i] = INF for j = 0 to i: # 第 p 个字符对应区间 [j, i-1] cost = dp[j][i-1][target[p-1]] # 若 j > i-1,则为空区间成本(已在 dp 中定义) f[p][i] = min(f[p][i], f[p-1][j] + cost)最终答案:
f[m][n]。
时间复杂度优化
上述完整 DP 复杂度为 O(n^3 * C^3) 过高。实际中可以进行优化:
- 注意到在合并两个区间时,我们只关心它们变成的最小成本字符,以及该字符是什么。因此可以先用一个二维数组
best[i][j]记录将区间变成任意字符的最小成本,同时记录对应的字符。但这样可能丢失信息,因为可能变成不同字符成本不同。 - 更常见的优化是:将 delete_cost 和 replace_cost 的关系固定,从而简化转移。如果 delete_cost <= replace_cost,那么当两个字符不同时,总是删除一个更优,因此 extra_cost = delete_cost。反之 extra_cost = replace_cost。
- 进一步,可以证明:存在最优解,使得每个区间最终变成的字符要么是 source 中某字符,要么是 target 中某字符。因此字母表大小可缩减到 O(n+m),但实际中 C=26 已经很小。
在竞赛中,通常 n <= 100,C=26,O(n^3 * C^2) 可能勉强可接受(约 100^3 * 26^2 ≈ 1.76e8),需要优化常数或采用更高效的转移方程。
示例演示
假设 source = "aaab", target = "ab", insert_cost = 1, delete_cost = 1, replace_cost = 2。
min_cost_diff = min(1,2) = 1。
- 计算 dp 区间:
- 单字符区间:
dp[0][0]['a']=0,dp[0][0]['b']=min(2,1+1)=2(替换成本2,或删除+插入成本2)。
类似地初始化其他位置。 - 区间 [0,1]("aa"):可以分成 [0,0] 和 [1,1],都是 'a',相同,合并免费,变成 'a' 成本=0+0=0。
- 区间 [0,2]("aaa"):类似,成本0。
- 区间 [0,3]("aaab"):分成 "aaa" 和 "b",'a' 和 'b' 不同,额外成本=1,变成 'a' 成本=0+2+1=3(其中 'b' 变成 'a' 成本2?需要仔细计算,但根据转移可得)。
- 单字符区间:
- 匹配 target:
- target[0]='a',可以对应 source 区间 [0,2]("aaa")成本0,或 [0,3] 成本3。
- target[1]='b',对应剩余区间。
最优划分:'a' 对应 [0,2](成本0),'b' 对应 [3,3]('b' 变成 'b' 成本0),总成本0。
但注意 source 中 'b' 已经是 'b',所以不需要操作。
最终答案 = 0?不对,因为 target 是 "ab",而 source 是 "aaab",我们需要删除两个 'a'?这里我们实际上将 "aaa" 合并成一个 'a'(免费),然后保留 'b',得到 "ab"。确实不需要插入/删除/替换成本。所以最小成本为0。
总结
本题是区间 DP 与字符串编辑距离的结合,加入了合并规则。核心是定义状态 dp[i][j][ch] 表示将区间合并成单个字符 ch 的最小成本,然后利用区间分割进行转移,最后用另一个 DP 匹配 target 的每个字符。虽然复杂度较高,但通过合理优化可以求解。理解合并规则对操作顺序的影响是解题关键。