【LeetCode 每日一题】2977. 转换字符串的最小成本 II——(解法一)记忆化搜索

Problem: 2977. 转换字符串的最小成本 II

文章目录

整体思路

1. 核心问题

我们需要将 source 字符串转换为 target 字符串。转换规则是基于子串 的(例如把 "abc" 转换为 "def" 花费 x 元)。

这与第一版(基于单字符)的区别在于:

  1. 操作对象是变长的字符串,而非单个字符。
  2. 我们需要决定在哪里切割字符串,以及对切割出来的部分应用哪种转换规则。

2. 算法架构

这个问题可以分解为三个步骤:

  • 步骤一:字符串映射与去重 (Trie)

    • 输入给出的 originalchanged 包含很多字符串。为了方便处理(比如作为图的节点),我们需要给每一个出现过的独特字符串分配一个唯一的整数 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 开始的后缀所需的最小成本。
    • 状态转移
      1. 逐字符匹配 :如果 source[i] == target[i],我们可以选择不进行转换,直接处理 i+1,代价为 dfs(i+1)
      2. 子串转换 :尝试从 i 开始匹配长度为 len 的子串。
        • 我们需要在 Trie 中同时查找 source[i...j]target[i...j]
        • 如果这两个子串在 Trie 中都有对应的 ID(说明它们在规则集中出现过),并且它们之间存在转换路径(dis[id1][id2] 可达),则可以尝试这种转换。
        • 代价 = dis[id1][id2] + dfs(j+1)
      3. 取上述所有情况的最小值。
    • 使用 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)。
  • 总时间复杂度 : 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)。

参考灵神

相关推荐
码农水水3 小时前
得物Java面试被问:消息队列的死信队列和重试机制
java·开发语言·jvm·数据结构·机器学习·面试·职场和发展
m0_736919103 小时前
模板编译期图算法
开发语言·c++·算法
dyyx1113 小时前
基于C++的操作系统开发
开发语言·c++·算法
m0_736919104 小时前
C++安全编程指南
开发语言·c++·算法
蜡笔小马4 小时前
11.空间索引的艺术:Boost.Geometry R树实战解析
算法·r-tree
-Try hard-4 小时前
数据结构:链表常见的操作方法!!
数据结构·算法·链表·vim
2301_790300964 小时前
C++符号混淆技术
开发语言·c++·算法
我是咸鱼不闲呀4 小时前
力扣Hot100系列16(Java)——[堆]总结()
java·算法·leetcode
嵌入小生0074 小时前
单向链表的常用操作方法---嵌入式入门---Linux
linux·开发语言·数据结构·算法·链表·嵌入式
LabVIEW开发4 小时前
LabVIEW金属圆盘压缩特性仿真
算法·labview·labview知识·labview功能·labview程序