Problem: 2977. 转换字符串的最小成本 II
文章目录
- 整体思路
-
-
- [1. 核心问题](#1. 核心问题)
- [2. 算法架构](#2. 算法架构)
-
- 完整代码注释
- 时空复杂度
-
-
- [1. 时间复杂度](#1. 时间复杂度)
- [2. 空间复杂度](#2. 空间复杂度)
-
整体思路
1. 核心问题
我们需要将 source 字符串转换为 target 字符串。转换规则是基于子串 的(例如把 "abc" 转换为 "def" 花费 x 元)。
这与第一版(基于单字符)的区别在于:
- 操作对象是变长的字符串,而非单个字符。
- 我们需要决定在哪里切割字符串,以及对切割出来的部分应用哪种转换规则。
2. 算法架构
这个问题可以分解为三个步骤:
-
步骤一:字符串映射与去重 (Trie)
- 输入给出的
original和changed包含很多字符串。为了方便处理(比如作为图的节点),我们需要给每一个出现过的独特字符串分配一个唯一的整数 ID。 - 使用 字典树 (Trie) 来存储这些字符串。Trie 的每个节点可以标记一个
sid(String ID)。 put方法负责将字符串插入 Trie 并返回其 ID。
- 输入给出的
-
步骤二:预计算最小转换代价 (Floyd-Warshall)
- 转换具有传递性(A -> B, B -> C => A -> C)。
- 我们将每个独特的字符串视为图中的一个节点,给定的转换规则视为有向边。
- 由于节点数量较少(
original数组最大长度 100,这就意味着最多 200 个独特字符串),我们可以使用 Floyd-Warshall 算法在 O ( V 3 ) O(V^3) O(V3) 时间内计算出任意两个字符串 ID 之间的最小转换代价。结果存入dis矩阵。
-
步骤三:动态规划求解最小总代价 (DFS + Memoization)
- 定义
dfs(i)为:将source从索引i开始的后缀转换为target从索引i开始的后缀所需的最小成本。 - 状态转移 :
- 逐字符匹配 :如果
source[i] == target[i],我们可以选择不进行转换,直接处理i+1,代价为dfs(i+1)。 - 子串转换 :尝试从
i开始匹配长度为len的子串。- 我们需要在 Trie 中同时查找
source[i...j]和target[i...j]。 - 如果这两个子串在 Trie 中都有对应的 ID(说明它们在规则集中出现过),并且它们之间存在转换路径(
dis[id1][id2]可达),则可以尝试这种转换。 - 代价 =
dis[id1][id2] + dfs(j+1)。
- 我们需要在 Trie 中同时查找
- 取上述所有情况的最小值。
- 逐字符匹配 :如果
- 使用
memo数组记录已计算的状态,避免重复计算。
- 定义
完整代码注释
java
import java.util.Arrays;
// 字典树节点类
class Node {
Node[] son = new Node[26]; // 子节点数组,对应 26 个小写字母
int sid = -1; // 字符串的唯一编号 (String ID)。-1 表示该节点不是一个完整规则字符串的结尾
}
class Solution {
private Node root = new Node(); // 字典树根节点
private int sid = 0; // 全局计数器,用于分配字符串 ID
private char[] s, t; // source 和 target 的字符数组引用
private int[][] dis; // 距离矩阵,存储字符串 ID 之间的最小转换成本
private long[] memo; // 记忆化数组,memo[i] 存储 dfs(i) 的结果
public long minimumCost(String source, String target, String[] original, String[] changed, int[] cost) {
// --- 步骤 1 & 2: 构建图并预计算最短路径 ---
int m = cost.length;
// 最多有 2*m 个不同的字符串(original 和 changed 各不相同的情况)
dis = new int[m * 2][m * 2];
// 初始化距离矩阵
for (int i = 0; i < dis.length; i++) {
Arrays.fill(dis[i], Integer.MAX_VALUE / 2); // 使用较大值表示不可达,除2防止加法溢出
dis[i][i] = 0; // 自己转自己成本为 0
}
// 将所有规则插入 Trie 并构建初始图
for (int i = 0; i < cost.length; i++) {
int x = put(original[i]); // 获取 original[i] 的 ID
int y = put(changed[i]); // 获取 changed[i] 的 ID
dis[x][y] = Math.min(dis[x][y], cost[i]); // 处理重边,取最小值
}
// 使用 Floyd-Warshall 算法求任意两点间最短路
// sid 是目前分配出去的 ID 总数,即图中实际节点的数量
for (int k = 0; k < sid; k++) {
for (int i = 0; i < sid; i++) {
if (dis[i][k] == Integer.MAX_VALUE / 2) {
continue; // 剪枝:如果 i 无法到达 k,则无法经过 k 更新
}
for (int j = 0; j < sid; j++) {
dis[i][j] = Math.min(dis[i][j], dis[i][k] + dis[k][j]);
}
}
}
// --- 步骤 3: 动态规划求解 ---
s = source.toCharArray();
t = target.toCharArray();
memo = new long[s.length];
Arrays.fill(memo, -1); // -1 表示该状态尚未计算
long ans = dfs(0);
// 如果结果过大,说明无法完成转换,返回 -1
return ans < Long.MAX_VALUE / 2 ? ans : -1;
}
// 将字符串插入 Trie 并返回其 ID
// 如果字符串已存在,返回已有 ID;否则分配新 ID
private int put(String s) {
Node o = root;
for (char b : s.toCharArray()) {
int i = b - 'a';
if (o.son[i] == null) {
o.son[i] = new Node();
}
o = o.son[i];
}
// 如果当前节点还没有分配 ID,则分配一个新的
if (o.sid < 0) {
o.sid = sid++;
}
return o.sid;
}
// 深度优先搜索 + 记忆化
// 计算从索引 i 开始匹配 source 和 target 的最小成本
private long dfs(int i) {
// Base case: 如果处理完了所有字符,成本为 0
if (i >= s.length) {
return 0;
}
// 查表:如果计算过,直接返回
if (memo[i] != -1) {
return memo[i];
}
long res = Long.MAX_VALUE / 2;
// 策略 1: 如果当前字符相同,可以尝试不进行转换,直接匹配下一位
// 注意:这只是一个候选项,并不一定最优(可能当前字符包含在某个更长的转换规则中更划算)
if (s[i] == t[i]) {
res = dfs(i + 1);
}
// 策略 2: 尝试匹配以 i 开头的各种长度的子串转换
// 使用两个指针 p 和 q 在 Trie 上同步移动,分别对应 source[i...] 和 target[i...]
Node p = root, q = root;
for (int j = i; j < s.length; j++) {
// 在 Trie 上移动
p = p.son[s[j] - 'a'];
q = q.son[t[j] - 'a'];
// 如果任意一个在 Trie 中断了(说明规则集中没有以此为前缀的字符串),则后续更长的也不可能有了,直接 break
if (p == null || q == null) {
break;
}
// 如果 source[i...j] 和 target[i...j] 都是有效的规则字符串(都有 ID)
if (p.sid >= 0 && q.sid >= 0) {
// 获取这两个子串之间的最小转换代价
int d = dis[p.sid][q.sid];
// 如果可达,则尝试更新最小成本
if (d < Integer.MAX_VALUE / 2) {
res = Math.min(res, d + dfs(j + 1));
}
}
}
return memo[i] = res; // 记录并返回
}
}
时空复杂度
假设:
- N N N 为
source字符串的长度。 - M M M 为规则数组
cost的长度。 - L L L 为规则中字符串的最大长度。
- V V V 为所有规则中出现的独特字符串数量( V ≤ 2 M V \le 2M V≤2M)。
1. 时间复杂度
- 构建 Trie : O ( M ⋅ L ) O(M \cdot L) O(M⋅L)。需要遍历所有规则字符串。
- Floyd-Warshall : O ( V 3 ) O(V^3) O(V3)。由于 V ≤ 2 M V \le 2M V≤2M,近似为 O ( M 3 ) O(M^3) O(M3)。在题目限制下 M ≤ 100 M \le 100 M≤100,这是一个较小的常数部分(约 8 × 10 6 8 \times 10^6 8×106 操作)。
- DFS (动态规划) :
- 状态数量:共有 N N N 个状态(
i从 0 到 N N N)。 - 单次状态转移:内部循环
j最多执行 L L L 次(受限于 Trie 的深度,即规则字符串的最大长度)。在循环内部,操作是 O ( 1 ) O(1) O(1) 的。 - 因此 DP 部分总时间为 O ( N ⋅ L ) O(N \cdot L) O(N⋅L)。
- 状态数量:共有 N N N 个状态(
- 总时间复杂度 : O ( M ⋅ L + M 3 + N ⋅ L ) O(M \cdot L + M^3 + N \cdot L) O(M⋅L+M3+N⋅L)。
- 考虑到题目约束 N ≤ 1000 , M ≤ 100 N \le 1000, M \le 100 N≤1000,M≤100,这个复杂度是可以接受的。
2. 空间复杂度
- Trie : O ( M ⋅ L ⋅ Σ ) O(M \cdot L \cdot \Sigma) O(M⋅L⋅Σ),其中 Σ = 26 \Sigma = 26 Σ=26。存储所有规则字符串。
- 距离矩阵
dis: O ( V 2 ) O(V^2) O(V2) 或 O ( M 2 ) O(M^2) O(M2)。 - 记忆化数组
memo: O ( N ) O(N) O(N)。 - 递归栈 : O ( N ) O(N) O(N)。
- 总空间复杂度 : O ( M ⋅ L + M 2 + N ) O(M \cdot L + M^2 + N) O(M⋅L+M2+N)。
参考灵神