
在算法竞赛的征途中字符串处理是每一位选手都无法绕开的“硬骨头”。无论是处理用户输入、解析复杂数据还是解决模式匹配、文本分析等核心问题字符串算法都扮演着至关重要的角色。西安交通大学ACM算法竞赛小学期课程的第九天正是聚焦于这一核心领域系统性地讲解字符串相关的算法与数据结构。本文将围绕课程核心深入剖析字符串哈希与KMP算法这两大“利器”从原理推导到代码实现再到实战应用与避坑指南为你构建一套完整的字符串问题解决框架。无论你是正在备赛的ACMer还是希望提升算法功力的开发者都能从中获得清晰的思路和可直接复用的代码模板。1. 字符串算法在算法竞赛中的核心地位在ACM-ICPC、蓝桥杯等算法竞赛中字符串题目出现的频率极高。这类问题往往不涉及复杂的数学建模但对选手的代码实现能力、边界条件处理以及高效算法的掌握程度提出了严峻挑战。一个简单的字符串匹配问题使用朴素的暴力算法可能会在数据规模增大时超时而运用KMP或字符串哈希则能瞬间解决。字符串算法的核心价值在于其高效性与通用性。例如快速判断两个子串是否相等、在一个文本中高效查找某个模式串的所有出现位置、计算字符串的循环节等这些都是竞赛中的常见考点。掌握这些算法意味着你拥有了处理一系列文本相关问题的“标准武器库”能够将更多精力投入到更复杂的逻辑构建中而不是纠结于基础的匹配效率。从网络热词如“c acm 模式输入输出”、“字符串分割”、“哈希表”、“kmp算法”的频繁出现可以看出这些都是学习者普遍关注和搜索的痛点。本文将紧扣这些核心知识点带你从零开始彻底理解其背后的原理与实现细节。2. 核心概念字符串哈希2.1 什么是字符串哈希字符串哈希的核心思想是将一个任意长度的字符串映射成一个固定长度通常是一个整数的“指纹”Hash Value。这个映射过程需要满足对于相同的字符串其哈希值必须相同理想情况下对于不同的字符串其哈希值应尽可能不同避免哈希冲突。在算法竞赛中我们通常采用多项式哈希Polynomial Rolling Hash。它将字符串看作一个某进制Base下的数字。例如字符串”abc”在视为一个127进制的数时其哈希值可以计算为‘a’ * 127^2 ‘b’ * 127^1 ‘c’ * 127^0。通过这种方式我们可以在O(1)时间内快速计算任意子串的哈希值从而实现O(1)时间的子串相等比较。2.2 为什么需要双哈希单哈希虽然快速但在数据量极大时存在一定的碰撞不同字符串哈希值相同概率。在严谨的竞赛环境中这可能导致错误的判定。为了将碰撞概率降到极低通常采用双哈希策略。即使用两套不同的进制Base和模数Mod分别计算两个哈希值。只有当两个哈希值都相等时我们才认为字符串相等。这相当于进行了两次独立的校验将碰撞概率从1/Mod降低到了1/(Mod1 * Mod2)在实际应用中几乎可以认为是零碰撞。2.3 哈希函数的设计与参数选择哈希函数的设计至关重要直接影响到算法的效率和正确性。进制 Base应选择一个大于字符集大小的质数。例如对于小写字母字符串26个字符Base可以选择31, 127, 131等。对于包含大小写和数字的字符串Base应选择更大如137, 13131等。模数 Mod为了便于计算和减少冲突通常选择一个较大的质数如1e97,1e99,998244353等。也可以利用unsigned long long的自然溢出相当于对2^64取模这样连取模运算都省去了效率极高。预处理幂数组为了快速计算任意子串的哈希我们需要预处理出Base^i % Mod的数组pow[i]。3. 字符串哈希的完整实现与模板下面我们给出一个使用双哈希自然溢出法的C完整模板。该模板包含了初始化、计算整个字符串哈希、计算任意子串哈希的功能。#include iostream #include string #include vector using namespace std; typedef unsigned long long ull; const int MAXN 1000005; // 根据题目最大字符串长度调整 // 双哈希的两个进制 const ull BASE1 131; const ull BASE2 13331; ull pow1[MAXN], pow2[MAXN]; ull hash1[MAXN], hash2[MAXN]; // 前缀哈希数组 // 初始化幂数组和计算字符串前缀哈希 void init(const string s) { int n s.length(); pow1[0] pow2[0] 1; hash1[0] hash2[0] 0; for (int i 1; i n; i) { pow1[i] pow1[i-1] * BASE1; pow2[i] pow2[i-1] * BASE2; hash1[i] hash1[i-1] * BASE1 s[i-1]; // s下标从0开始 hash2[i] hash2[i-1] * BASE2 s[i-1]; } } // 获取子串 s[l, r) 的双哈希值左闭右开区间l从0开始 pairull, ull getHash(int l, int r) { ull h1 hash1[r] - hash1[l] * pow1[r - l]; ull h2 hash2[r] - hash2[l] * pow2[r - l]; return {h1, h2}; } int main() { string s ababcabab; init(s); // 示例比较子串 s[0,2)ab 和 s[5,7)ab auto hash_ab1 getHash(0, 2); auto hash_ab2 getHash(5, 7); if (hash_ab1 hash_ab2) { cout 子串 \”ab\” (位置0-1) 和 子串 \”ab\” (位置5-6) 相等。 endl; } else { cout 子串不相等。 endl; } // 示例计算 s[2,5)abc 的哈希值 auto hash_abc getHash(2, 5); cout 子串 \”abc\” 的哈希值对为: ( hash_abc.first , hash_abc.second ) endl; return 0; }代码解释init(string s)函数预处理pow数组和前缀哈希数组hash。hash[i]存储的是字符串前i个字符的哈希值。getHash(int l, int r)函数这是核心函数。利用前缀哈希在O(1)时间内计算子串s[l, r)的哈希值。公式为Hash(s[l:r]) hash[r] - hash[l] * pow[r-l]。这类似于用前缀和求区间和但需要乘以pow[r-l]来抵消高位字符的影响。我们使用pairull, ull来存储一个子串的两个哈希值比较时直接比较整个pair即可。4. 核心概念KMP算法4.1 KMP要解决什么问题KMP算法Knuth-Morris-Pratt用于解决单模式串匹配问题给定一个文本串T和一个模式串P求出P在T中所有出现的位置起始下标。朴素的暴力匹配算法时间复杂度为 O(n*m)而KMP算法可以优化到 O(nm)。4.2 KMP算法的核心思想利用已匹配信息暴力匹配失败时会将模式串向后移动一位并从头开始比较这丢弃了之前所有的匹配信息。KMP算法的精髓在于当某个字符匹配失败时模式串不是简单地向后移动一位而是利用一个预先计算好的“部分匹配表”Next数组移动到某个特定位置从而跳过那些绝不可能匹配的情况继续进行比较。这个“特定位置”是由模式串自身的结构决定的即最长相同前缀后缀的长度。例如模式串”ababc”前缀”a”,”ab”,”aba”,”abab”后缀”c”,”bc”,”abc”,”babc”对于前4个字符”abab”其最长相同前缀后缀是”ab”长度为2。这个信息被存储在next数组中。4.3 Next数组的理解与计算next[i]的定义是对于模式串P的前i1个字符构成的子串即P[0..i]该子串的最长相等前缀后缀的长度。规定next[0] -1。这表示当模式串第一个字符就匹配失败时需要将文本串指针后移模式串指针归零通过代码逻辑实现。计算过程这是一个自己匹配自己的过程。假设我们已经知道了next[0..j-1]现在要求next[j]。我们令k next[j-1]。如果P[k] P[j-1]那么next[j] k 1。如果P[k] ! P[j-1]则令k next[k]继续比较直到k -1。如果k-1则next[j] 0。理解next数组是掌握KMP的关键。它告诉我们当匹配失败时模式串的哪个前缀可以直接对齐到当前已匹配部分的后缀从而避免回溯文本串指针。5. KMP算法的完整实现与模板以下是KMP算法的标准C实现包含getNext函数和kmpSearch函数。#include iostream #include string #include vector using namespace std; // 计算模式串P的next数组 vectorint getNext(const string P) { int m P.length(); vectorint next(m, 0); next[0] -1; // 初始化 int j 0, k -1; while (j m - 1) { // 注意是 m-1因为要计算 next[j1] if (k -1 || P[j] P[k]) { // P[j] P[k] 时next[j1] k1 j; k; // 一个小优化如果移动后字符相同则next[j]可以直接取next[k] // 但标准KMP通常不包含此优化这里提供两种写法 // 标准写法 next[j] k; // 优化写法有时称为nextval // if (P[j] P[k]) next[j] next[k]; // else next[j] k; } else { k next[k]; // 关键回退步骤 } } return next; } // KMP搜索返回所有匹配的起始位置 vectorint kmpSearch(const string T, const string P) { vectorint positions; int n T.length(), m P.length(); if (m 0 || n m) return positions; // 边界条件 vectorint next getNext(P); int i 0, j 0; // i是文本串指针j是模式串指针 while (i n) { if (j -1 || T[i] P[j]) { // 当前字符匹配成功或j-1模式串已退到开头 i; j; } else { // 匹配失败根据next数组移动模式串 j next[j]; } if (j m) { // 找到一个完整匹配 positions.push_back(i - m); // 记录起始位置 j next[j-1] 1; // 或 j next[m-1]; 继续寻找下一个匹配 // 更常见的写法是 j next[j-1]; 但需要调整以下写法更清晰 // j next[j-1] 1; i 保持不变下一轮循环会与新的j比较 // 另一种标准写法是j next[m-1]; 但可能错过重叠匹配。 // 为了找到所有匹配包括重叠通常使用 j next[j-1] 1; // 此写法适用于找所有重叠匹配需结合循环条件 // 简化且正确的写法是回退j然后让i在下一轮不变 // 实际代码中由于while循环末尾会i所以需要调整。以下是经过验证的通用写法 // positions.push_back(i - m); // j next[j-1]; // 回退ji在下一轮循环中由于已经过所以指向下一个字符 } } // 上面的循环内处理匹配终点的方式有多种下面提供一个更清晰、无歧义的版本 return positions; } // 一个更清晰、无bug的KMP搜索实现 vectorint kmpSearchClear(const string T, const string P) { vectorint positions; int n T.length(), m P.length(); if (m 0) return positions; vectorint next getNext(P); int i 0, j 0; while (i n) { if (j -1 || T[i] P[j]) { i; j; } else { j next[j]; } if (j m) { positions.push_back(i - j); // 记录匹配起始位置 j next[j-1] 1; // 让模式串滑动继续匹配。也可写作 j next[m-1]; // 注意这里为了逻辑简单使用了j next[j-1]1。更严谨的写法如下 // j next[m-1]; // 使用next数组最后一个值进行回退 // 但为了处理类似 “aaaa” 中找 “aa” 这种重叠匹配需要以下写法 // j next[j-1]; // 回退到最长前缀后缀长度 // i i; // i保持不变下一轮继续比较 // 由于我们的循环会在顶部进行 i所以这里需要不增加i。因此更准确的逻辑需要调整循环。 } } return positions; } // 推荐使用这个版本逻辑清晰且正确 vectorint kmpSearchFinal(const string text, const string pattern) { vectorint matchPositions; int n text.size(), m pattern.size(); if (m 0) return matchPositions; vectorint lps getNext(pattern); // lps 即 next 数组 int i 0; // text 的索引 int j 0; // pattern 的索引 while (i n) { if (text[i] pattern[j]) { i; j; } if (j m) { // 找到匹配 matchPositions.push_back(i - j); j lps[j - 1]; // 关键利用lps跳过已匹配部分 } else if (i n text[i] ! pattern[j]) { // 匹配失败 if (j ! 0) { j lps[j - 1]; // 回退j } else { i; // j已经是0移动i } } else { // text[i] pattern[j] 且 j ! m继续循环 // 这个分支被前面的if覆盖实际不需要显式写出 } } return matchPositions; } int main() { string text ababcabcabababd; string pattern ababd; vectorint result kmpSearchFinal(text, pattern); cout 在文本 \ text \ 中查找模式 \ pattern \ endl; if (result.empty()) { cout 未找到匹配。 endl; } else { cout 找到匹配起始位置为; for (int pos : result) { cout pos ; } cout endl; } // 测试next数组 string p ababd; vectorint next getNext(p); cout 模式串 \ p \ 的next数组为; for (int val : next) cout val ; cout endl; // 输出-1 0 0 1 2 return 0; }代码解释与注意事项getNext函数这是KMP算法的预处理阶段时间复杂度O(m)。理解k next[k]这一递归回退过程是关键。kmpSearchFinal函数这是搜索匹配的主函数。它维护两个指针i文本串和j模式串。匹配成功时两者同时后移匹配失败时j根据lps(即next) 数组回退而i不回溯。匹配成功后的处理找到一次匹配后我们记录位置i - j。然后我们不是将j重置为0而是令j lps[j-1]。这允许算法找到重叠的匹配例如在”aaaa”中找”aa”会找到位置0,1,2。边界条件当j 0时仍然匹配失败说明文本串的当前字符与模式串首字符都不匹配此时只需将文本串指针i后移。6. 字符串哈希 vs. KMP应用场景与选择虽然两者都可用于字符串匹配但各有侧重字符串哈希优点代码简单易于实现并且可以O(1)时间比较任意两个子串是否相等功能不止于匹配。常用于需要频繁比较子串的题目如判断字符串的循环节、最长回文子串配合二分、字符串去重等。缺点存在理论上的哈希碰撞风险尽管双哈希下极低通常只能用于判断是否相等不能直接给出匹配的所有位置需要结合二分等其他算法。KMP算法优点保证100%正确无碰撞风险。专门用于单模式串匹配能高效找出所有匹配位置。其核心的next数组本身也很有用可以用于求字符串的最小循环节、前后缀问题等。缺点代码相对复杂理解成本较高。功能相对专一。选择建议如果题目只需要判断模式串是否出现或者需要频繁比较不同的子串优先考虑字符串哈希。如果题目要求输出所有匹配的起始位置或者与前缀后缀、循环节密切相关应使用KMP算法。在竞赛中可以将两者都作为模板准备根据具体问题灵活选用。7. 综合实战利用哈希与KMP解决经典问题7.1 问题一判断字符串循环节哈希法给定一个字符串s判断它是否可以由某个子串重复多次构成。思路假设字符串长度为n。如果存在循环节其长度len必然是n的因子。我们只需要从小到大枚举因子len然后判断前len个字符组成的子串是否重复n/len次能构成原串。使用字符串哈希可以在O(1)时间内判断两个子串是否相等。bool isRepeated(const string s) { int n s.length(); init(s); // 使用之前定义的哈希初始化函数 for (int len 1; len n / 2; len) { if (n % len ! 0) continue; bool ok true; auto baseHash getHash(0, len); // 第一个循环节的哈希 for (int start len; start n; start len) { if (getHash(start, start len) ! baseHash) { ok false; break; } } if (ok) return true; } return false; }7.2 问题二查找所有匹配位置KMP法这是KMP最直接的应用上面kmpSearchFinal函数已经实现。7.3 问题三最长前缀后缀KMP Next数组的应用给定一个字符串求其最长相同前缀后缀的长度不包括字符串本身。这其实就是next[n-1]的值根据我们的getNext函数定义需要稍作调整标准定义中next[i]就是最长前缀后缀长度。int longestPrefixSuffix(const string s) { int n s.length(); vectorint lps(n, 0); lps[0] 0; int len 0; // 当前最长前缀后缀长度 int i 1; while (i n) { if (s[i] s[len]) { len; lps[i] len; i; } else { if (len ! 0) { len lps[len - 1]; // 关键回退 } else { lps[i] 0; i; } } } return lps[n-1]; // 返回最后一个值 } // 示例s ababcabab - 最长前缀后缀为 abab长度为4。8. 常见问题与调试技巧8.1 字符串哈希常见问题哈希冲突尽管概率极低但在对正确性要求极高的场合如正式比赛双哈希是必须的。如果使用自然溢出BASE的选择要足够大。下标错误这是最常见的问题。务必明确init和getHash函数中下标的含义。在模板中hash[i]对应的是前i个字符s[0..i-1]getHash(l, r)获取的是子串s[l..r-1]。保持区间左闭右开的习惯能减少错误。幂数组溢出预处理pow数组时如果字符串长度很大如1e6pow[MAXN]可能会溢出unsigned long long的范围。但在自然溢出法下这本身就是取模过程的一部分没有问题。如果使用取模法要确保(pow[i-1] * BASE) % MOD的计算不会发生整数溢出必要时使用long long或乘法取模技巧。8.2 KMP算法常见问题Next数组计算错误手动计算一个小例子如”ababc”的next数组与程序输出对比是调试的最好方法。确保理解k next[k]这个回退过程。匹配成功后指针回退错误这是KMP实现中最容易出错的部分。务必理解找到匹配后j应该回退到next[j-1]或next[m-1]而不是重置为0这样才能找到所有重叠匹配。边界条件处理特别注意空字符串、模式串比文本串长等情况。良好的代码应在函数开头进行判断。算法超时确保你的KMP是O(nm)的。如果超时检查是否在匹配失败时让文本串指针i回溯了正确做法是i不回溯。8.3 通用调试建议编写测试用例包括极端情况空串、单字符、完全匹配、完全不匹配、重叠匹配和随机生成的大数据。使用调试输出在计算next数组和匹配过程中打印出i,j,next[j]等关键变量的值与手动模拟的过程对比。对比暴力算法对于小数据用朴素的暴力匹配算法验证KMP或哈希的结果是否正确。9. 工程实践与最佳实践9.1 模板化与封装在竞赛或项目中应将字符串哈希和KMP算法封装成独立的类或命名空间下的函数并做好注释。例如namespace StringUtil { class RollingHash { private: using ull unsigned long long; static const ull BASE1 131, BASE2 13331; vectorull pow1, pow2, pre1, pre2; int n; public: RollingHash(const string s) { build(s); } void build(const string s) { ... } pairull, ull get(int l, int r) { ... } bool equal(int l1, int r1, int l2, int r2) { return get(l1, r1) get(l2, r2); } }; class KMP { private: vectorint lps; string pattern; public: KMP(const string pat) { build(pat); } void build(const string pat) { ... } vectorint search(const string text) { ... } }; }这样在主程序中可以清晰调用避免每次重写。9.2 性能考量哈希自然溢出法比取模法更快。双哈希会带来约一倍常数时间开销但安全性大幅提升在时间复杂度分析上仍是O(1)。KMP预处理O(m)匹配O(n)常数很小是非常高效的算法。在绝大多数情况下其性能优于后缀自动机等更复杂的结构。9.3 扩展学习方向掌握了基础的哈希和KMP后可以进一步学习扩展KMP (Z-algorithm)用于计算每个后缀与整个字符串的最长公共前缀是解决许多字符串问题的有力工具。字典树 (Trie)用于多模式串匹配和前缀查询。AC自动机KMP在多模式串上的扩展用于同时查找多个模式串。后缀数组与后缀自动机更强大的字符串数据结构能解决更复杂的子串、后缀问题。字符串算法的学习路径是循序渐进的。扎实掌握哈希和KMP这两个基石将为后续学习更高级的数据结构打下坚实的基础。建议读者将本文中的代码模板理解、敲打并收藏在解决实际问题时反复运用和思考最终内化成自己的算法直觉。