跳转至

字符串匹配算法

概述

字符串匹配(String Matching)是在文本串T中查找模式串P所有出现位置的问题。这是计算机科学中最基本、最重要的问题之一,广泛应用于文本编辑、搜索引擎、生物信息学等领域。

字符串匹配的重要性

字符串匹配是许多应用的核心操作:文本编辑器的查找替换、搜索引擎的关键词匹配、DNA序列分析、入侵检测系统的模式匹配等。高效的字符串匹配算法对性能至关重要。

问题定义

Text Only
输入:
- 文本串 T[0..n-1],长度为n
- 模式串 P[0..m-1],长度为m (m ≤ n)

输出:
- 模式串在文本串中所有出现的起始位置
- 或判断模式串是否存在于文本串中

示例:
T = "ABABCABCAB"
P = "ABC"

匹配位置: 2, 5
       T: ABABCABCAB
          ^^^   ^^^
          2     5

暴力匹配算法

算法思想

暴力匹配(Brute Force)是最直观的方法:尝试文本串的每个可能起始位置,逐个字符比较。

graph TB
    A["枚举所有起始位置 i = 0 to n-m"] --> B["从位置i开始逐个字符比较"]
    B --> C{"全部匹配?"}
    C -->|是| D["记录匹配位置 i"]
    C -->|否| E["尝试下一个位置 i+1"]
    D --> E
    E --> F{"还有位置?"}
    F -->|是| B
    F -->|否| G["结束"]
    
    style D fill:#E8F5E9

可视化演示

Text Only
文本串 T: ABABCABCAB (n=10)
模式串 P: ABC (m=3)

┌─────────────────────────────────────────────────────┐
│ 暴力匹配过程                                        │
└─────────────────────────────────────────────────────┘

i=0: T[0..2] = "ABA" vs P = "ABC"
     A B A B C A B C A B
     ↑ ↑ ↑
     A B C
     第3个字符不匹配 (A ≠ C),i++

i=1: T[1..3] = "BAB" vs P = "ABC"
     A B A B C A B C A B
       ↑ ↑ ↑
       A B C
     第1个字符不匹配 (B ≠ A),i++

i=2: T[2..4] = "ABC" vs P = "ABC"
     A B A B C A B C A B
         ↑ ↑ ↑
         A B C
     全部匹配!记录位置 2

i=3: T[3..5] = "BCA" vs P = "ABC"
     A B A B C A B C A B
           ↑ ↑ ↑
           A B C
     第1个字符不匹配 (B ≠ A),i++

i=4: T[4..6] = "CAB" vs P = "ABC"
     A B A B C A B C A B
             ↑ ↑ ↑
             A B C
     第1个字符不匹配 (C ≠ A),i++

i=5: T[5..7] = "ABC" vs P = "ABC"
     A B A B C A B C A B
               ↑ ↑ ↑
               A B C
     全部匹配!记录位置 5

i=6: T[6..8] = "BCA" vs P = "ABC"
     第1个字符不匹配

i=7: T[7..9] = "CAB" vs P = "ABC"
     第1个字符不匹配

结果: [2, 5]

实现

C
int bruteForce(const char *text, const char *pattern) {
    int n = strlen(text);
    int m = strlen(pattern);
    
    for (int i = 0; i <= n - m; i++) {
        int j;
        for (j = 0; j < m; j++) {
            if (text[i + j] != pattern[j]) {
                break;
            }
        }
        if (j == m) {
            return i;  // 找到匹配
        }
    }
    
    return -1;  // 未找到
}

// 查找所有匹配位置
int* bruteForceFindAll(const char *text, const char *pattern, int *count) {
    int n = strlen(text);
    int m = strlen(pattern);
    
    int *result = (int*)malloc(sizeof(int) * n);
    *count = 0;
    
    for (int i = 0; i <= n - m; i++) {
        int j;
        for (j = 0; j < m; j++) {
            if (text[i + j] != pattern[j]) {
                break;
            }
        }
        if (j == m) {
            result[(*count)++] = i;
        }
    }
    
    return result;
}

复杂度

情况 时间复杂度 说明
最好 O(n) 第一次就匹配或模式串不匹配
平均 O(n+m) 随机文本
最坏 O(nm) T="AAA...A", P="AAA...B"

KMP算法

核心思想

KMP(Knuth-Morris-Pratt)算法通过预处理模式串,构建**部分匹配表(LPS)**,在匹配失败时利用已匹配信息,避免回溯。

graph TB
    A["匹配失败在位置 j"] --> B["查询LPS表"]
    B --> C["模式串右移 j-lps j-1 位"]
    C --> D["继续匹配,无需回溯文本指针"]
    
    style D fill:#E8F5E9

部分匹配表(LPS)

LPS[i] = 模式串P[0..i]的最长相同真前缀和真后缀的长度

Text Only
模式串 P = "ABABAC"

计算LPS过程:
┌─────────────────────────────────────────────────────┐
│ LPS数组计算                                         │
└─────────────────────────────────────────────────────┘

i=0: P[0..0] = "A"
     真前缀: 无
     真后缀: 无
     LPS[0] = 0

i=1: P[0..1] = "AB"
     真前缀: "A"
     真后缀: "B"
     无匹配, LPS[1] = 0

i=2: P[0..2] = "ABA"
     真前缀: "A", "AB"
     真后缀: "BA", "A"
     最长匹配: "A", 长度=1
     LPS[2] = 1

i=3: P[0..3] = "ABAB"
     真前缀: "A", "AB", "ABA"
     真后缀: "BAB", "AB", "B"
     最长匹配: "AB", 长度=2
     LPS[3] = 2

i=4: P[0..4] = "ABABA"
     真前缀: "A", "AB", "ABA", "ABAB"
     真后缀: "BABA", "ABA", "BA", "A"
     最长匹配: "ABA", 长度=3
     LPS[4] = 3

i=5: P[0..5] = "ABABAC"
     真前缀: "A", "AB", "ABA", "ABAB", "ABABA"
     真后缀: "BABAC", "ABAC", "BAC", "AC", "C"
     无匹配, LPS[5] = 0

LPS数组: [0, 0, 1, 2, 3, 0]

LPS的意义

Text Only
匹配失败时如何利用LPS:

假设匹配到 P[j] 时失败:
P: A B A B A C
T: A B A B A B ...
       失败在 j=5

已匹配部分: "ABABA"
LPS[4] = 3, 说明 "ABABA" 的最长相同前后缀是 "ABA"

因此:
- "ABABA" 的前3个字符 "ABA" = 后3个字符 "ABA"
- 可以直接将模式串右移,让前缀对齐到后缀位置
- 不需要重新比较已经匹配的部分

新的比较位置: j = LPS[4] = 3

KMP匹配过程

Text Only
文本串 T: ABABABAC
模式串 P: ABABAC (LPS=[0,0,1,2,3,0])

┌─────────────────────────────────────────────────────┐
│ KMP匹配过程                                         │
└─────────────────────────────────────────────────────┘

初始: i=0, j=0

Step 1-4: 匹配成功
     T: A B A B A B A C
        ↑ ↑ ↑ ↑ ↑
        A B A B A C
        j=0,1,2,3,4

Step 5: T[4]=A vs P[4]=A, 匹配
        i=5, j=5
        
Step 6: T[5]=B vs P[5]=C, 不匹配!
        j = LPS[5-1] = LPS[4] = 3
        模式串右移 5-3=2 位
        
     T: A B A B A B A C
              ↑ ↑ ↑
              A B A B A C
              j=3

Step 7-9: 继续匹配
     T: A B A B A B A C
              ↑ ↑ ↑ ↑ ↑ ↑
              A B A B A C
              j=3,4,5,6

匹配成功!位置 = i - j = 7 - 6 = 1... 实际上
更正:位置 = i - m = 7 - 6 = 1

实现

C
// 计算LPS数组
void computeLPS(const char *pattern, int *lps) {
    int m = strlen(pattern);
    int len = 0;    // 当前最长相同前后缀长度
    lps[0] = 0;     // LPS[0]总是0
    int i = 1;
    
    while (i < m) {
        if (pattern[i] == pattern[len]) {
            // 可以扩展相同前后缀
            len++;
            lps[i] = len;
            i++;
        } else {
            if (len != 0) {
                // 回退到更短的相同前后缀
                len = lps[len - 1];
            } else {
                // 没有相同前后缀
                lps[i] = 0;
                i++;
            }
        }
    }
}

// KMP搜索
int kmpSearch(const char *text, const char *pattern) {
    int n = strlen(text);
    int m = strlen(pattern);
    
    if (m == 0) return 0;
    if (n < m) return -1;
    
    int *lps = (int*)malloc(sizeof(int) * m);
    computeLPS(pattern, lps);
    
    int i = 0;  // 文本串指针
    int j = 0;  // 模式串指针
    
    while (i < n) {
        if (pattern[j] == text[i]) {
            i++;
            j++;
        }
        
        if (j == m) {
            free(lps);
            return i - j;  // 找到匹配
        } else if (i < n && pattern[j] != text[i]) {
            if (j != 0) {
                j = lps[j - 1];  // 利用LPS跳转
            } else {
                i++;  // 完全不匹配,前进一步
            }
        }
    }
    
    free(lps);
    return -1;
}

复杂度

阶段 时间复杂度 说明
预处理 O(m) 计算LPS数组
匹配 O(n) 每个字符最多访问常数次
总计 O(n+m) 线性时间

BM算法

核心思想

Boyer-Moore算法从右向左匹配,利用**坏字符规则**和**好后缀规则**进行跳转,实践中通常是最快的字符串匹配算法。

graph TB
    A["从右向左匹配"] --> B{"匹配失败"}
    B -->|是| C["坏字符规则"]
    B -->|是| D["好后缀规则"]
    C --> E["选择更大的跳转距离"]
    D --> E
    E --> F["模式串右移"]
    
    style F fill:#E8F5E9

坏字符规则

Text Only
坏字符规则:
匹配失败时,文本串中导致失败的字符称为"坏字符"

如果坏字符在模式串中存在:
  右移 = 坏字符在模式串中的位置 - 坏字符在模式串中最右出现位置

如果坏字符在模式串中不存在:
  右移 = 整个模式串长度

示例:
T: A B C A B D
P: A B D

从右向左匹配:
P[2]=D vs T[2]=C, 不匹配
坏字符 = 'C'

'C' 不在模式串中,右移3位

T: A B C A B D
P:       A B D

继续匹配...

实现

C
// 坏字符启发式预处理
void badCharHeuristic(const char *pattern, int m, int badChar[256]) {
    // 初始化为-1
    for (int i = 0; i < 256; i++) {
        badChar[i] = -1;
    }
    
    // 记录每个字符在模式串中最右出现的位置
    for (int i = 0; i < m; i++) {
        badChar[(unsigned char)pattern[i]] = i;
    }
}

// BM搜索
int bmSearch(const char *text, const char *pattern) {
    int n = strlen(text);
    int m = strlen(pattern);
    
    int badChar[256];
    badCharHeuristic(pattern, m, badChar);
    
    int s = 0;  // 模式串相对于文本串的偏移
    while (s <= n - m) {
        int j = m - 1;  // 从右向左匹配
        
        // 匹配时j递减
        while (j >= 0 && pattern[j] == text[s + j]) {
            j--;
        }
        
        if (j < 0) {
            return s;  // 找到匹配
        } else {
            // 坏字符跳转
            int bcShift = j - badChar[(unsigned char)text[s + j]];
            s += (bcShift > 1) ? bcShift : 1;
        }
    }
    
    return -1;
}

复杂度

情况 时间复杂度 说明
最好 O(n/m) 每次跳过整个模式串
平均 O(n) 实践中通常很快
最坏 O(nm) 特殊构造的输入

Sunday算法

核心思想

Sunday算法考虑匹配失败时,文本串中下一个将被匹配的字符(当前窗口外的第一个字符)。

graph TB
    A["当前窗口匹配失败"] --> B["检查窗口外的字符"]
    B --> C{"该字符在模式串中?"}
    C -->|是| D["右移到对齐该字符"]
    C -->|否| E["右移整个模式串长度+1"]
    D --> F["继续匹配"]
    E --> F
    
    style F fill:#E8F5E9
Text Only
Sunday算法示意:

T: A B C A B D A B C
P: A B D

i=0: 比较T[0..2]与P[0..2]
     T: A B C A B D A B C
        ↑ ↑ ↑
        A B D
        
     T[2]='C' ≠ P[2]='D', 不匹配
     窗口外的字符: T[3]='A'
     
     'A' 在模式串中的位置: 0
     右移: m - 0 = 3 - 0 = 3
     但实际右移: m + 1 - shift['A'] = 3 + 1 - 3 = 1
     
     更正: shift[c] = m - i (i是c在P中最后出现位置)
           如果c不在P中, shift[c] = m + 1

实现

C
// Sunday预处理
void sundayHeuristic(const char *pattern, int m, int shift[256]) {
    // 初始化为m+1(字符不在模式串中)
    for (int i = 0; i < 256; i++) {
        shift[i] = m + 1;
    }
    
    // 计算每个字符的跳转距离
    for (int i = 0; i < m; i++) {
        shift[(unsigned char)pattern[i]] = m - i;
    }
}

int sundaySearch(const char *text, const char *pattern) {
    int n = strlen(text);
    int m = strlen(pattern);
    
    int shift[256];
    sundayHeuristic(pattern, m, shift);
    
    int i = 0;
    while (i <= n - m) {
        int j;
        for (j = 0; j < m; j++) {
            if (text[i + j] != pattern[j]) {
                break;
            }
        }
        
        if (j == m) {
            return i;  // 找到匹配
        }
        
        // 检查窗口外的字符
        if (i + m >= n) break;
        
        // 根据窗口外字符跳转
        i += shift[(unsigned char)text[i + m]];
    }
    
    return -1;
}

Rabin-Karp算法

核心思想

使用**滚动哈希**将模式串和文本串的子串映射为数值,通过比较哈希值快速判断是否可能匹配。

graph TB
    A["计算模式串哈希值 hp"] --> B["计算文本串第一个窗口哈希值 ht"]
    B --> C{"hp == ht?"}
    C -->|是| D["逐字符验证"]
    C -->|否| E["滚动哈希计算下一个窗口"]
    D -->|验证成功| F["记录匹配位置"]
    D -->|验证失败| E
    E --> C
    
    style F fill:#E8F5E9

滚动哈希

Text Only
滚动哈希原理:

使用多项式哈希: H(s) = s[0]*d^(m-1) + s[1]*d^(m-2) + ... + s[m-1]*d^0 (mod q)

从位置i到位置i+1的滚动:
H(T[i+1..i+m]) = d * (H(T[i..i+m-1]) - T[i]*d^(m-1)) + T[i+m] (mod q)

示例:
T = "ABCD", P = "BCD"
d = 256, q = 101

H("BCD") = (66*256^2 + 67*256 + 68) mod 101
         = (66*65536 + 67*256 + 68) mod 101
         = (4325376 + 17152 + 68) mod 101
         = 4342596 mod 101
         = 95

滚动计算H("ABC") -> H("BCD"):
H("BCD") = 256 * (H("ABC") - 65*256^2) + 68 (mod 101)

实现

C
int rabinKarp(const char *text, const char *pattern) {
    int n = strlen(text);
    int m = strlen(pattern);
    int d = 256;   // 字符集大小
    int q = 101;   // 质数模数
    
    // 计算 h = d^(m-1) mod q
    int h = 1;
    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 + pattern[i]) % q;
        t = (d * t + text[i]) % q;
    }
    
    // 滑动窗口
    for (int i = 0; i <= n - m; i++) {
        // 哈希值匹配,验证
        if (p == t) {
            int j;
            for (j = 0; j < m; j++) {
                if (text[i + j] != pattern[j]) {
                    break;
                }
            }
            if (j == m) {
                return i;  // 找到匹配
            }
        }
        
        // 滚动哈希
        if (i < n - m) {
            t = (d * (t - text[i] * h) + text[i + m]) % q;
            if (t < 0) t += q;  // 保证正数
        }
    }
    
    return -1;
}

算法对比

算法 预处理 匹配时间 空间 特点
暴力 O(1) O(nm) O(1) 简单直观
KMP O(m) O(n) O(m) 无回溯,稳定线性
BM O(m+σ) O(n/m)~O(nm) O(σ) 实践中最快
Sunday O(m+σ) O(n) O(σ) 简单高效
Rabin-Karp O(m) O(n+m) O(1) 多模式匹配

其中σ是字符集大小(通常为256)

graph TB
    A["字符串匹配需求"] --> B{"单模式还是多模式?"}
    B -->|单模式| C{"追求最简单实现?"}
    B -->|多模式| D["Rabin-Karp<br/>或AC自动机"]
    C -->|是| E["暴力匹配"]
    C -->|否| F{"追求最快速度?"}
    F -->|是| G["BM算法"]
    F -->|否| H["KMP算法"]
    
    style G fill:#E8F5E9

C++ 实现

C++
#include <string>
#include <vector>

class KMP {
private:
    std::string pattern;
    std::vector<int> lps;
    
    void computeLPS() {
        int m = pattern.length();
        lps.resize(m);
        int len = 0;
        
        for (int i = 1; i < m; i++) {
            while (len > 0 && pattern[i] != pattern[len]) {
                len = lps[len - 1];
            }
            if (pattern[i] == pattern[len]) {
                len++;
            }
            lps[i] = len;
        }
    }
    
public:
    KMP(const std::string& p) : pattern(p) {
        computeLPS();
    }
    
    int search(const std::string& text) {
        int n = text.length();
        int m = pattern.length();
        
        for (int i = 0, j = 0; i < n; i++) {
            while (j > 0 && text[i] != pattern[j]) {
                j = lps[j - 1];
            }
            if (text[i] == pattern[j]) {
                j++;
            }
            if (j == m) {
                return i - m + 1;
            }
        }
        return -1;
    }
    
    std::vector<int> findAll(const std::string& text) {
        std::vector<int> result;
        int n = text.length();
        int m = pattern.length();
        
        for (int i = 0, j = 0; i < n; i++) {
            while (j > 0 && text[i] != pattern[j]) {
                j = lps[j - 1];
            }
            if (text[i] == pattern[j]) {
                j++;
            }
            if (j == m) {
                result.push_back(i - m + 1);
                j = lps[j - 1];  // 继续搜索下一个匹配
            }
        }
        return result;
    }
};

应用场景

  1. 文本编辑器:查找替换功能
  2. 搜索引擎:关键词匹配与高亮
  3. 生物信息学:DNA/RNA序列分析
  4. 入侵检测:特征码匹配
  5. 数据压缩:LZ系列算法的基础
  6. 拼写检查:近似字符串匹配

参考资料

  • 《算法导论》第32章 - 字符串匹配
  • Knuth, Morris, Pratt (1977). "Fast Pattern Matching in Strings"
  • Boyer, Moore (1977). "A Fast String Searching Algorithm"
  • String Matching - Wikipedia