字符串问题——算法系列教程(c++版)

梦想不会自己发光,真正闪耀的是那个为梦狂奔的你。献给知行的孩子们!(Eric.He著)


  动态规划的本质:它是一种 “以空间换时间”的优化算法,核心是把复杂问题拆解为多个重叠的子问题,通过记录子问题的最优解(状态),避免重复计算,最终由子问题的最优解推导出原问题的最优解。

算法回顾

字符串问题

  字符串问题是解决字符串的匹配、编辑、分割等问题,比如求最长回文子串、最小编辑距离、判断子序列等。这类问题的状态通常定义为两个字符串的下标 / 单个字符串的区间,状态转移依赖于字符的匹配情况,属于二维 DP 的高频应用。

教程目录导航

一、最小编辑距离(LeetCode 72)

问题描述:

算法解析:

  1. 定义状态:dp[i][j] 表示将 word1 的前 i 个字符转换成 word2 的前 j 个字符所需的最少操作数。
  2. 初始状态:
    • dp[0][j] = j:将空字符串转换成 word2 的前 j 个字符,需要插入 j 次;
    • dp[i][0] = i:将 word1 的前 i 个字符转换成空字符串,需要删除 i 次。
  3. 状态转移:对每个物品,选或不选:
    • 如果 word1[i-1] == word2[j-1](注意字符串下标从 0 开始):无需操作,dp[i][j] = dp[i-1][j-1];
    • 如果 word1[i-1] != word2[j-1]:取「替换、删除、插入」三种操作的最小值 + 1,即: dp[i][j] = min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1]) + 1
      • dp[i-1][j-1] + 1:替换(把 word1[i-1] 换成 word2[j-1]);
      • dp[i-1][j] + 1:删除(删掉 word1[i-1],再处理前 i-1 个字符);
      • dp[i][j-1] + 1:插入(在 word1 后插入 word2[j-1],再处理前 j-1 个字符)。
  4. 遍历顺序:从word1的左到右(i 从 1 到 m 对应第i个字符),从word2的左到右(j 从 1 到 n 对应第j个字符))。
  5. dp[len(word1)][len(word2)] 即为最终答案。

#include <iostream>
#include <vector>
using namespace std;

int minDistance(string word1, string word2) {
    int m = word1.size();
    int n = word2.size();

    // 定义dp数组:dp[i][j]表示word1前i个字符转word2前j个字符的最少操作数
    vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));

    // 初始化:空字符串转word2前j个字符,需要插入j次
    for (int j = 0; j <= n; ++j) {
        dp[0][j] = j;
    }
    // 初始化:word1前i个字符转空字符串,需要删除i次
    for (int i = 0; i <= m; ++i) {
        dp[i][0] = i;
    }

    // 状态转移
    for (int i = 1; i <= m; ++i) {
        for (int j = 1; j <= n; ++j) {
            if (word1[i - 1] == word2[j - 1]) {
                // 字符相等,无需操作,继承子问题结果
                dp[i][j] = dp[i - 1][j - 1];
            } else {
                // 字符不等,取替换、删除、插入的最小值 + 1
                dp[i][j] = min({dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]}) + 1;
            }
        }
    }

    // 返回最终结果
    return dp[m][n];
}

int main() {
    // 测试用例1:word1="horse", word2="ros",预期输出3
    cout << minDistance("horse", "ros") << endl;
    // 测试用例2:word1="intention", word2="execution",预期输出5
    cout << minDistance("intention", "execution") << endl;
    // 测试用例3:空字符串转换,预期输出2
    cout << minDistance("", "ab") << endl;

    return 0;
} 
        

输出结果


3
5
2
        

二、最长回文子串(LeetCode 5)

问题描述:

算法解析:

  1. 定义状态:dp[i][j] 表示字符串 s 中,从下标 i 到 j 的子串 s[i...j] 是否为回文子串(dp[i][j] = true 表示是,false 表示否)。
  2. 初始状态:
    • 所有长度为 1 的子串都是回文:dp[i][i] = true(i 从 0 到 n-1);
    • 长度为 2 的子串,若 s[i] == s[i+1],则 dp[i][i+1] = true。
  3. 状态转移:对于长度大于 2 的子串 s[i...j],若 s[i] == s[j] 且 dp[i+1][j-1] = true(中间子串是回文),则 dp[i][j] = true;
  4. 遍历顺序:按子串长度从小到大遍历(先算短子串,再算长串),保证计算 dp[i][j] 时,dp[i+1][j-1] 已确定。
  5. 遍历过程中,记录最长回文子串的起始下标和长度,最终截取该子串。

#include <iostream>
#include <string>
#include <vector>

using namespace std;

string longestPalindrome(string s) {
    int n = s.size();
    if (n <= 1) return s;

    // dp[i][j]:s[i...j] 是否为回文子串
    vector<vector<boo>> dp(n, vector<bool>(n, false));
    int start = 0;    // 最长回文子串的起始下标
    int maxLen = 1;   // 最长回文子串的长度(初始为1,因为单个字符都是回文)

    // 初始化:长度为1的子串都是回文
    for (int i = 0; i < n; ++i) {
        dp[i][i] = true;
    }

    // 初始化:长度为2的子串
    for (int i = 0; i < n - 1; ++i) {
        if (s[i] == s[i+1]) {
            dp[i][i+1] = true;
            start = i;
            maxLen = 2;
        }
    }

    // 遍历长度 >=3 的子串,l 表示子串长度
    for (int l = 3; l <= n; ++l) {
        // i 是子串起始下标,j 是子串结束下标(j = i + l - 1)
        for (int i = 0; i + l - 1 < n; ++i) {
            int j = i + l - 1;
            // 状态转移:两端字符相等,且中间子串是回文
            if (s[i] == s[j] && dp[i+1][j-1]) {
                dp[i][j] = true;
                // 更新最长回文子串
                if (l > maxLen) {
                    start = i;
                    maxLen = l;
                }
            }
        }
    }

    // 截取最长回文子串
    return s.substr(start, maxLen);
}

int main() {
    cout << longestPalindrome("babad") << endl; // 输出 bab 或 aba(均正确)
    cout << longestPalindrome("cbbd") << endl;  // 输出 bb
    cout << longestPalindrome("a") << endl;     // 输出 a

    return 0;
}
        

输出结果


bab
bb
a
            

三、回文子串(LeetCode 647)

问题描述:

算法解析:

  1. 定义状态:dp[i][j] 表示字符串 s 中,下标从 i 到 j 的子串 s[i...j] 是否为回文子串(true 是,false 否)。
  2. 初始状态:
    • 所有长度为 1 的子串都是回文:dp[i][i] = true,且每个这样的子串都计为 1 个回文子串;
    • 长度为 2 的子串,若 s[i] == s[i+1],则 dp[i][i+1] = true,计为 1 个回文子串。
  3. 状态转移:对于长度 > 2 的子串 s[i...j],若 s[i] == s[j] 且 dp[i+1][j-1] = true,则 dp[i][j] = true,计为 1 个回文子串;
  4. 遍历顺序:按子串长度从小到大,保证计算 dp[i][j] 时 dp[i+1][j-1] 已确定。
  5. 遍历所有 dp[i][j],累加为 true 的数量。

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

int countSubstrings(string s) {
    int n = s.size();
    if (n == 0) return 0;

    // dp[i][j]:s[i...j] 是否为回文子串
    vector<vector<bool>> dp(n, vector<bool>(n, false));
    int count = 0; // 统计回文子串总数

    // 初始化:长度为1的子串(所有单个字符都是回文)
    for (int i = 0; i < n; ++i) {
        dp[i][i] = true;
        count++; // 每个长度为1的子串计1个
    }

    // 初始化:长度为2的子串
    for (int i = 0; i < n - 1; ++i) {
        if (s[i] == s[i+1]) {
            dp[i][i+1] = true;
            count++; // 符合条件的长度为2的子串计1个
        }
    }

    // 遍历长度 >=3 的子串,l 表示子串长度
    for (int l = 3; l <= n; ++l) {
        // i 是子串起始下标,j = i + l - 1 是结束下标
        for (int i = 0; i + l - 1 < n; ++i) {
            int j = i + l - 1;
            // 状态转移:两端字符相等 + 中间子串是回文
            if (s[i] == s[j] && dp[i+1][j-1]) {
                dp[i][j] = true;
                count++; // 符合条件的子串计1个
            }
        }
    }

    return count;
}

int main() {
    cout << countSubstrings("abc") << endl;  // 输出 3(a、b、c)
    cout << countSubstrings("aaa") << endl;  // 输出 6(a1、a2、a3、a1a2、a2a3、a1a2a3)
    cout << countSubstrings("abba") << endl; // 输出 6(a、b、b、a、bb、abba)

    return 0;
}
        

输出结果


3
6
6
            

四、正则表达式匹配(LeetCode 10)

问题描述:

算法解析:

  1. 定义状态:dp[i][j] 表示 s 的前 i 个字符(s[0..i-1])和 p 的前 j 个字符(p[0..j-1])是否匹配。
  2. 初始状态:
    • dp[0][0] = true:空字符串和空模式匹配;
    • 处理空字符串匹配带 * 的模式(如 a*、a*b*):若 p[j-1] = '*',则 dp[0][j] = dp[0][j-2](* 匹配前面字符零次)。
  3. 状态转移:分两种核心场景:
    • 场景 1:p[j-1] 是普通字符或 .:
      • 若 s[i-1] == p[j-1] 或 p[j-1] == '.',则 dp[i][j] = dp[i-1][j-1](前面的子串匹配,当前字符也匹配);
    • 场景 2:p[j-1] 是 *(需结合前一个字符 p[j-2] 分析):
      • * 匹配前面字符零次:dp[i][j] = dp[i][j-2](跳过 p[j-2] 和 *);
      • * 匹配前面字符至少一次:若 s[i-1] == p[j-2] 或 p[j-2] == '.',则 dp[i][j] |= dp[i-1][j](当前字符匹配,沿用 * 的匹配能力)。
  4. 遍历顺序:从s的左到右(i 从 1 到 m 对应第 i 个字符),从p的左到右(j 从 1 到 n 对应第 j 个字符))。
  5. 最终 dp[m][n] 为匹配结束

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

bool isMatch(string s, string p) {
    int m = s.size(), n = p.size();
    // dp[i][j]:s前i个字符和p前j个字符是否匹配
    vector<vector<bool>> dp(m + 1, vector<bool>(n + 1, false));
    dp[0][0] = true; // 空字符串匹配空模式

    // 初始化:空字符串匹配带*的模式(处理p以a*、a*b*开头的情况)
    for (int j = 2; j <= n; ++j) {
        if (p[j-1] == '*') {
            dp[0][j] = dp[0][j-2];
        }
    }

    // 状态转移
    for (int i = 1; i <= m; ++i) {
        for (int j = 1; j <= n; ++j) {
            if (p[j-1] == '.' || s[i-1] == p[j-1]) {
                // 场景1:普通字符/'.' 匹配
                dp[i][j] = dp[i-1][j-1];
            } else if (p[j-1] == '*') {
                // 场景2:'*' 匹配,先考虑匹配零次的情况
                dp[i][j] = dp[i][j-2];
                // 再考虑匹配至少一次的情况(需前面字符匹配)
                if (p[j-2] == '.' || s[i-1] == p[j-2]) {
                    dp[i][j] = dp[i][j] || dp[i - 1][j];
                }
            }
            // 其他情况:默认false
        }
    }

    return dp[m][n];
}

int main() {
    // 测试用例1:s="aa", p="a" → false(需完全匹配)
    cout << boolalpha << isMatch("aa", "a") << endl;
    // 测试用例2:s="aa", p="a*" → true(*匹配多个a)
    cout << boolalpha << isMatch("aa", "a*") << endl;
    // 测试用例3:s="ab", p=".*" → true(.*匹配任意字符任意次数)
    cout << boolalpha << isMatch("ab", ".*") << endl;
    // 测试用例4:s="aab", p="c*a*b" → true(c*匹配0次,a*匹配2次)
    cout << boolalpha << isMatch("aab", "c*a*b") << endl;

    return 0;
}
        

输出结果


false
true
true
true
            

返回顶部