字符串算法概述

什么是字符串算法

【字符串算法】是处理文本数据的一系列算法,主要关注如何高效地存储、搜索、比较和操作字符串。在算法竞赛和实际编程中,字符串处理是一类常见且重要的问题。

字符串算法的基本操作

  1. 字符串匹配:在文本中查找特定的模式串。

    • 朴素算法:O(n*m) 时间复杂度
    • KMP算法:O(n+m) 时间复杂度
    • Rabin-Karp算法:平均O(n+m),最坏O(n*m)
    • Boyer-Moore算法:平均情况下比KMP更快
  2. 字符串处理

    • 分割与合并
    • 替换与删除
    • 大小写转换
    • 子串提取
  3. 字符串存储

    • 字典树(Trie)
    • 后缀树与后缀数组
    • 哈希表

常用字符串算法

1. KMP算法

Knuth-Morris-Pratt算法是一种高效的字符串匹配算法,通过预处理模式串,避免了不必要的比较。

核心思想:利用已经匹配过的信息,避免回溯文本串的指针。

时间复杂度:O(n+m),其中n和m分别是文本串和模式串的长度。

主要步骤

  1. 预处理模式串,计算next数组
  2. 使用next数组指导匹配过程
// KMP算法实现
void computeLPS(string pat, int* lps) {
    int m = pat.length();
    int len = 0;  // 前缀长度

    lps[0] = 0;  // lps[0]总是0

    int i = 1;
    while (i < m) {
        if (pat[i] == pat[len]) {
            len++;
            lps[i] = len;
            i++;
        } else {
            if (len != 0) {
                len = lps[len - 1];
            } else {
                lps[i] = 0;
                i++;
            }
        }
    }
}

vector<int> KMP(string txt, string pat) {
    int n = txt.length();
    int m = pat.length();
    vector<int> result;  // 存储所有匹配位置

    // 创建lps数组存储最长匹配前缀后缀值
    int lps[m];
    computeLPS(pat, lps);

    int i = 0;  // txt的索引
    int j = 0;  // pat的索引

    while (i < n) {
        // 当前字符匹配
        if (pat[j] == txt[i]) {
            i++;
            j++;
        }

        // 找到完整匹配
        if (j == m) {
            result.push_back(i - j);  // 记录匹配位置
            j = lps[j - 1];  // 寻找下一个匹配
        }
        // 不匹配的情况
        else if (i < n && pat[j] != txt[i]) {
            if (j != 0) {
                j = lps[j - 1];
            } else {
                i++;
            }
        }
    }

    return result;
}

2. 字典树(Trie)

字典树是一种树形数据结构,用于高效地存储和检索字符串集合。

特点

  • 根节点不包含字符
  • 每个节点包含多个子节点,对应不同的字符
  • 从根节点到某一节点的路径上的字符连接起来形成一个字符串

应用场景

  • 前缀匹配
  • 自动补全
  • 字符串集合的快速查找
// 字典树实现
struct TrieNode {
    bool isEndOfWord;  // 标记该节点是否为某个单词的结尾
    TrieNode* children[26];  // 假设只包含小写英文字母

    TrieNode() {
        isEndOfWord = false;
        for (int i = 0; i < 26; i++) {
            children[i] = nullptr;
        }
    }
};

class Trie {
private:
    TrieNode* root;

public:
    Trie() {
        root = new TrieNode();
    }

    // 插入单词
    void insert(string word) {
        TrieNode* node = root;
        for (char c : word) {
            int index = c - 'a';
            if (!node->children[index]) {
                node->children[index] = new TrieNode();
            }
            node = node->children[index];
        }
        node->isEndOfWord = true;  // 标记单词结尾
    }

    // 搜索单词
    bool search(string word) {
        TrieNode* node = root;
        for (char c : word) {
            int index = c - 'a';
            if (!node->children[index]) {
                return false;  // 找不到对应字符
            }
            node = node->children[index];
        }
        return node->isEndOfWord;  // 只有到达单词结尾才算找到
    }

    // 前缀搜索
    bool startsWith(string prefix) {
        TrieNode* node = root;
        for (char c : prefix) {
            int index = c - 'a';
            if (!node->children[index]) {
                return false;
            }
            node = node->children[index];
        }
        return true;  // 只要能走完前缀就算找到
    }

    // 析构函数
    ~Trie() {
        deleteTrie(root);
    }

private:
    // 递归删除树节点
    void deleteTrie(TrieNode* node) {
        for (int i = 0; i < 26; i++) {
            if (node->children[i]) {
                deleteTrie(node->children[i]);
            }
        }
        delete node;
    }
};

3. Rabin-Karp算法

Rabin-Karp算法使用哈希函数计算字符串的哈希值,通过比较哈希值来进行字符串匹配。

核心思想

  • 计算模式串的哈希值
  • 计算文本串中每个长度为m的子串的哈希值
  • 比较哈希值,如果相同则进一步验证

特点

  • 平均时间复杂度:O(n+m)
  • 最坏时间复杂度:O(n*m)
  • 适合多模式串匹配
// Rabin-Karp算法实现
vector<int> rabinKarp(string txt, string pat) {
    int n = txt.length();
    int m = pat.length();
    vector<int> result;

    // 基数,通常选择一个比字符集大小大的素数
    int d = 256;
    // 一个大素数
    int q = 101;

    int h = 1;
    // 计算 h = d^(m-1) % q
    for (int i = 0; i < m - 1; i++) {
        h = (h * d) % q;
    }

    // 计算模式串和文本串第一个窗口的哈希值
    int p = 0;  // 模式串哈希值
    int t = 0;  // 文本串当前窗口的哈希值

    for (int i = 0; i < m; i++) {
        p = (d * p + pat[i]) % q;
        t = (d * t + txt[i]) % q;
    }

    // 滑动窗口并比较
    for (int i = 0; i <= n - m; i++) {
        // 如果哈希值相等,进一步比较字符
        if (p == t) {
            bool match = true;
            for (int j = 0; j < m; j++) {
                if (txt[i + j] != pat[j]) {
                    match = false;
                    break;
                }
            }
            if (match) {
                result.push_back(i);
            }
        }

        // 计算下一个窗口的哈希值
        if (i < n - m) {
            t = (d * (t - txt[i] * h) + txt[i + m]) % q;
            if (t < 0) {
                t += q;
            }
        }
    }

    return result;
}

字符串算法优化技巧

  1. 字符串哈希:将字符串转换为整数,便于快速比较。

    • 常用哈希函数:h = (h * base + s[i]) % mod
    • 常见的base值:31, 131, 1331等
    • 常见的mod值:10^9+7, 10^9+9等
  2. 位运算优化:利用位运算优化字符串处理。

    • 例如,用一个整数表示字符集,利用位操作进行字符集合运算
  3. 滑动窗口:在需要连续处理子串的问题中使用滑动窗口技术。

    • 维护一个窗口,通过添加和删除元素更新窗口状态
  4. 双指针技术:使用两个指针处理字符串。

    • 如对撞指针、快慢指针等

常见字符串问题类型

  1. 模式匹配:在文本中查找特定的模式串。

    • 精确匹配:KMP, Rabin-Karp等
    • 近似匹配:编辑距离,最长公共子序列等
  2. 字符串统计:统计字符串中的特定字符或模式。

    • 字符频率统计
    • 最长重复子串
    • 最长回文子串
  3. 字符串转换:将一个字符串转换为另一个字符串。

    • 编辑距离问题
    • 字符串压缩与解压
  4. 字符串排序:特定条件下的字符串排序。

    • 字典序排序
    • 按特定条件排序

典型例题分析

例题1:最长回文子串

// Manacher算法求最长回文子串
string longestPalindrome(string s) {
    // 预处理,在字符间插入特殊字符
    string t = "$#";
    for (char c : s) {
        t += c;
        t += '#';
    }
    t += '@';

    int n = t.length();
    vector<int> p(n, 0);  // p[i]表示以i为中心的回文半径

    int center = 0, maxRight = 0;
    int maxLen = 0, start = 0;

    for (int i = 1; i < n - 1; i++) {
        // 初始化p[i]
        if (i < maxRight) {
            p[i] = min(p[2 * center - i], maxRight - i);
        } else {
            p[i] = 1;
        }

        // 中心扩展
        while (t[i + p[i]] == t[i - p[i]]) {
            p[i]++;
        }

        // 更新maxRight
        if (i + p[i] > maxRight) {
            center = i;
            maxRight = i + p[i];
        }

        // 更新最长回文子串
        if (p[i] - 1 > maxLen) {
            maxLen = p[i] - 1;
            start = (i - p[i] + 1) / 2;
        }
    }

    return s.substr(start, maxLen);
}

例题2:字符串哈希应用

// 使用字符串哈希检查重复子串
bool hasRepeatingSubstring(string s, int len) {
    int n = s.length();
    if (len * 2 > n) return false;  // 无法形成重复子串

    unordered_map<long long, vector<int>> hash_pos;
    const int base = 31;
    const int mod = 1e9 + 7;

    long long hash = 0;
    long long power = 1;

    // 计算第一个窗口的哈希值
    for (int i = 0; i < len; i++) {
        hash = (hash * base + (s[i] - 'a' + 1)) % mod;
        if (i < len - 1) {
            power = (power * base) % mod;
        }
    }

    hash_pos[hash].push_back(0);

    // 滑动窗口计算后续哈希值
    for (int i = 1; i <= n - len; i++) {
        hash = (hash - (s[i - 1] - 'a' + 1) * power % mod + mod) % mod;
        hash = (hash * base + (s[i + len - 1] - 'a' + 1)) % mod;

        // 检查哈希冲突
        for (int pos : hash_pos[hash]) {
            bool equal = true;
            for (int j = 0; j < len; j++) {
                if (s[pos + j] != s[i + j]) {
                    equal = false;
                    break;
                }
            }
            if (equal) return true;  // 找到重复子串
        }

        hash_pos[hash].push_back(i);
    }

    return false;
}

练习题推荐

  1. 字符串匹配

  2. 字典树

  3. 回文串

  4. 字符串哈希

总结

字符串算法在竞赛编程和实际应用中都有广泛的用途。掌握KMP、字典树等核心算法,以及各种优化技巧,能够帮助我们高效处理各种字符串问题。在实践中,应根据问题特点选择合适的算法和数据结构,灵活运用各种技巧提高解题效率。

在接下来的章节中,我们会更详细地介绍各种字符串算法及其应用场景。