字符串算法
摘要 字符串算法是算法竞赛中的关键题型,在码蹄杯中占比约5%,通常出现在中高难度题目中。核心内容包括: 基础操作:拼接、反转、回文判断等基础处理技巧 KMP算法:高效字符串匹配(O(n+m)),通过构建next数组实现快速回退 字符串哈希:将字符串映射为数字,实现快速比较 Trie树:前缀树结构,适用于前缀匹配和字符串存储 典型应用场景包括文本搜索、字符串匹配、前缀查询等。解题时需注意边界条件和算
字符串算法 ⭐⭐⭐ 🔥
重要性:⭐⭐⭐ 重要
出现频率:🔥 中频(近3年出现8次,占比5%)
难度等级:中级到高级
前置知识:字符串基础操作、数组、哈希表
预计学习时长:3-4天
目录
算法原理
字符串算法是算法竞赛的重要组成部分,虽然在码蹄杯中占比不高(约5%),但一旦出现就是拉开差距的关键题。掌握字符串算法,你就能在关键时刻多拿一题,提升排名。
为什么字符串算法重要?
1. 拉开差距的关键
- 码蹄杯近3年统计:字符串算法占比5%
- T6-T8可能出现:KMP、字符串哈希、Trie树
- 掌握者少:很多选手不熟悉,容易拉开差距
2. 实际应用广泛
- 文本搜索:KMP算法
- 字符串匹配:字符串哈希
- 前缀匹配:Trie树
- 字符串处理:各种基础操作
3. 与其他算法结合
- 动态规划:最长公共子序列、编辑距离
- 哈希表:字符串去重、统计
- 双指针:滑动窗口、回文串
字符串算法的核心思想
字符串基础操作:拼接、截取、反转、比较
KMP算法:高效的字符串匹配,O(n+m)时间复杂度
字符串哈希:将字符串映射为数字,快速比较
Trie树:前缀树,高效的前缀匹配和字符串存储
核心模板
字符串基础操作模板
import java.util.*;
public class StringBasicTemplate {
// 1. 字符串拼接(使用StringBuilder,避免创建大量临时对象)
public String concatenate(String[] strs) {
StringBuilder sb = new StringBuilder();
for (String str : strs) {
sb.append(str);
}
return sb.toString();
}
// 2. 字符串反转
public String reverse(String s) {
// 方法1:使用StringBuilder(推荐)
return new StringBuilder(s).reverse().toString();
// 方法2:使用char数组(更灵活)
// char[] chars = s.toCharArray();
// int left = 0, right = chars.length - 1;
// while (left < right) {
// char temp = chars[left];
// chars[left] = chars[right];
// chars[right] = temp;
// left++;
// right--;
// }
// return new String(chars);
}
// 3. 判断回文串
public boolean isPalindrome(String s) {
int left = 0, right = s.length() - 1;
while (left < right) {
if (s.charAt(left) != s.charAt(right)) {
return false;
}
left++;
right--;
}
return true;
}
// 4. 字符串比较(字典序)
public int compare(String s1, String s2) {
return s1.compareTo(s2); // 返回负数表示s1<s2,0表示相等,正数表示s1>s2
}
// 5. 字符串分割
public String[] split(String s, String delimiter) {
return s.split(delimiter);
}
// 6. 字符串替换
public String replace(String s, String oldStr, String newStr) {
return s.replace(oldStr, newStr);
}
// 7. 字符串转字符数组(频繁修改时使用)
public char[] toCharArray(String s) {
return s.toCharArray();
}
// 8. 字符数组转字符串
public String fromCharArray(char[] chars) {
return new String(chars);
}
}
KMP算法模板
public class KMPTemplate {
// KMP算法:在文本串text中查找模式串pattern的所有出现位置
// 时间复杂度:O(n + m),其中n是text长度,m是pattern长度
// 空间复杂度:O(m)
// 1. 构建next数组(最长公共前后缀)
private int[] buildNext(String pattern) {
int m = pattern.length();
int[] next = new int[m];
next[0] = 0; // 第一个字符的最长公共前后缀长度为0
int j = 0; // j表示当前最长公共前后缀的长度
for (int i = 1; i < m; i++) {
// 如果不匹配,回退到next[j-1]
while (j > 0 && pattern.charAt(i) != pattern.charAt(j)) {
j = next[j - 1];
}
// 如果匹配,j++
if (pattern.charAt(i) == pattern.charAt(j)) {
j++;
}
next[i] = j;
}
return next;
}
// 2. KMP匹配(查找第一个匹配位置)
public int kmpSearch(String text, String pattern) {
int n = text.length();
int m = pattern.length();
if (m == 0) return 0;
if (n < m) return -1;
int[] next = buildNext(pattern);
int j = 0; // pattern的指针
for (int i = 0; i < n; i++) { // text的指针
// 如果不匹配,回退
while (j > 0 && text.charAt(i) != pattern.charAt(j)) {
j = next[j - 1];
}
// 如果匹配,j++
if (text.charAt(i) == pattern.charAt(j)) {
j++;
}
// 如果完全匹配,返回起始位置
if (j == m) {
return i - m + 1;
}
}
return -1; // 未找到
}
// 3. KMP匹配(查找所有匹配位置)
public List<Integer> kmpSearchAll(String text, String pattern) {
List<Integer> result = new ArrayList<>();
int n = text.length();
int m = pattern.length();
if (m == 0 || n < m) return result;
int[] next = buildNext(pattern);
int j = 0;
for (int i = 0; i < n; i++) {
while (j > 0 && text.charAt(i) != pattern.charAt(j)) {
j = next[j - 1];
}
if (text.charAt(i) == pattern.charAt(j)) {
j++;
}
if (j == m) {
result.add(i - m + 1); // 记录匹配位置
j = next[j - 1]; // 继续查找下一个匹配
}
}
return result;
}
}
字符串哈希模板
public class StringHashTemplate {
// 字符串哈希:将字符串映射为数字,快速比较两个字符串是否相等
// 时间复杂度:预处理O(n),查询O(1)
// 空间复杂度:O(n)
private static final long MOD = 1000000007; // 模数
private static final long BASE = 131; // 进制(常用131或13331)
// 1. 计算单个字符串的哈希值
public long hash(String s) {
long h = 0;
for (int i = 0; i < s.length(); i++) {
h = (h * BASE + s.charAt(i)) % MOD;
}
return h;
}
// 2. 预处理前缀哈希(支持快速查询任意子串的哈希值)
private long[] prefixHash; // prefixHash[i]表示s[0...i-1]的哈希值
private long[] power; // power[i]表示BASE^i
public void buildPrefixHash(String s) {
int n = s.length();
prefixHash = new long[n + 1];
power = new long[n + 1];
power[0] = 1;
for (int i = 1; i <= n; i++) {
prefixHash[i] = (prefixHash[i - 1] * BASE + s.charAt(i - 1)) % MOD;
power[i] = (power[i - 1] * BASE) % MOD;
}
}
// 3. 查询子串s[left...right]的哈希值
public long getSubstringHash(int left, int right) {
// 注意:left和right是0-indexed
long h = (prefixHash[right + 1] - prefixHash[left] * power[right - left + 1] % MOD + MOD) % MOD;
return h;
}
// 4. 判断两个子串是否相等
public boolean isEqual(int left1, int right1, int left2, int right2) {
return getSubstringHash(left1, right1) == getSubstringHash(left2, right2);
}
}
Trie树模板
public class TrieTemplate {
// Trie树(前缀树):高效的字符串存储和前缀匹配
// 插入、查找、前缀匹配:O(m),其中m是字符串长度
// 空间复杂度:O(总字符数 × 字符集大小)
// Trie节点定义
class TrieNode {
TrieNode[] children; // 子节点数组(26个小写字母)
boolean isEnd; // 是否是单词结尾
int count; // 经过该节点的单词数量(可选)
public TrieNode() {
children = new TrieNode[26]; // 假设只有小写字母
isEnd = false;
count = 0;
}
}
private TrieNode root;
public TrieTemplate() {
root = new TrieNode();
}
// 1. 插入单词
public void insert(String word) {
TrieNode node = root;
for (int i = 0; i < word.length(); i++) {
int index = word.charAt(i) - 'a';
if (node.children[index] == null) {
node.children[index] = new TrieNode();
}
node = node.children[index];
node.count++; // 经过该节点的单词数+1
}
node.isEnd = true; // 标记单词结尾
}
// 2. 查找单词是否存在
public boolean search(String word) {
TrieNode node = root;
for (int i = 0; i < word.length(); i++) {
int index = word.charAt(i) - 'a';
if (node.children[index] == null) {
return false;
}
node = node.children[index];
}
return node.isEnd; // 必须是单词结尾
}
// 3. 查找是否存在以prefix为前缀的单词
public boolean startsWith(String prefix) {
TrieNode node = root;
for (int i = 0; i < prefix.length(); i++) {
int index = prefix.charAt(i) - 'a';
if (node.children[index] == null) {
return false;
}
node = node.children[index];
}
return true; // 只要能走到prefix的末尾即可
}
// 4. 删除单词
public boolean delete(String word) {
return deleteHelper(root, word, 0);
}
private boolean deleteHelper(TrieNode node, String word, int index) {
if (index == word.length()) {
if (!node.isEnd) {
return false; // 单词不存在
}
node.isEnd = false; // 取消单词结尾标记
return node.children.length == 0; // 如果没有子节点,可以删除
}
int charIndex = word.charAt(index) - 'a';
TrieNode child = node.children[charIndex];
if (child == null) {
return false; // 单词不存在
}
boolean shouldDeleteChild = deleteHelper(child, word, index + 1);
if (shouldDeleteChild) {
node.children[charIndex] = null; // 删除子节点
return !node.isEnd && node.children.length == 0;
}
return false;
}
// 5. 统计以prefix为前缀的单词数量
public int countWordsWithPrefix(String prefix) {
TrieNode node = root;
for (int i = 0; i < prefix.length(); i++) {
int index = prefix.charAt(i) - 'a';
if (node.children[index] == null) {
return 0;
}
node = node.children[index];
}
return node.count;
}
}
使用场景识别指南
什么时候用字符串算法?看这5个特征
特征1:题目中出现"字符串匹配"、"查找子串"关键词
- 示例:“在文本中查找模式串” → 用KMP算法
- 示例:“判断s2是否是s1的子串” → 用KMP算法或字符串哈希
- 示例:“查找所有匹配位置” → 用KMP算法
特征2:题目中出现"前缀"、“字典”、"单词搜索"关键词
- 示例:“判断是否存在以prefix开头的单词” → 用Trie树
- 示例:“实现字典的插入和查找” → 用Trie树
- 示例:“单词搜索II” → 用Trie树 + DFS
特征3:题目中出现"子串比较"、"重复子串"关键词
- 示例:“判断两个子串是否相等” → 用字符串哈希
- 示例:“查找最长重复子串” → 用字符串哈希 + 二分查找
- 示例:“判断字符串是否由重复子串构成” → 用字符串哈希或KMP
特征4:数据范围特点
- 示例:文本长度n ≤ 10^6,模式串长度m ≤ 10^3 → 用KMP(O(n+m))
- 示例:字符串数量k ≤ 10^4,每个长度≤100 → 用Trie树存储
- 示例:需要查询10^5次子串是否相等 → 用字符串哈希预处理
特征5:暴力解法的时间复杂度
- 示例:暴力匹配O(n×m),需要优化 → 用KMP优化到O(n+m)
- 示例:暴力比较子串O(n×m),需要优化 → 用字符串哈希优化到O(1)
- 示例:暴力存储字符串O(总字符数),需要优化 → 用Trie树共享前缀
快速判断流程图
题目特征 → 算法选择
├─ 字符串匹配、查找子串 → KMP算法
├─ 前缀匹配、字典操作 → Trie树
├─ 子串比较、重复子串 → 字符串哈希
├─ 回文串、反转 → 双指针
└─ 字符串拼接、修改 → StringBuilder
题型特征总结
字符串算法对应的题目特征
| 算法类型 | 关键词 | 典型题型 | 时间复杂度 |
|---|---|---|---|
| KMP算法 | “匹配”、“查找子串”、“模式串” | 字符串匹配、重复子串判断 | O(n+m) |
| 字符串哈希 | “子串比较”、“重复子串”、“最长公共子串” | 快速子串比较、查找重复 | 预处理O(n),查询O(1) |
| Trie树 | “前缀”、“字典”、“单词搜索” | 前缀匹配、字典实现、单词搜索 | O(m) |
| 双指针 | “回文串”、“反转”、“滑动窗口” | 回文判断、最长回文子串 | O(n) |
| StringBuilder | “拼接”、“修改”、“构造” | 字符串拼接、动态构造 | O(n) |
题目描述中的信号词
KMP算法信号词:
- “在文本中查找模式串”
- “判断s2是否是s1的子串”
- “查找所有匹配位置”
- “重复子串”
- “最小循环节”
字符串哈希信号词:
- “判断两个子串是否相等”
- “查找最长重复子串”
- “最长公共子串”
- “字符串去重”
- “快速比较”
Trie树信号词:
- “前缀匹配”
- “字典实现”
- “单词搜索”
- “自动补全”
- “最长公共前缀”
双指针信号词:
- “回文串”
- “反转字符串”
- “最长回文子串”
- “滑动窗口”
- “两端向中间”
解题思维技巧
从题目到字符串算法的思维路径(3步法)
第1步:判断是否需要字符串算法
- 检查点1:题目是否涉及字符串匹配、前缀匹配、子串比较?
- 检查点2:暴力解法的时间复杂度是否过高(如O(n×m))?
- 检查点3:是否需要高效的字符串存储和查询?
第2步:选择具体的算法
- 情况A:字符串匹配、查找子串 → KMP算法
- 情况B:前缀匹配、字典操作 → Trie树
- 情况C:子串比较、重复子串 → 字符串哈希
- 情况D:回文串、反转 → 双指针
- 情况E:字符串拼接、修改 → StringBuilder
第3步:实现细节
- 注意点1:KMP的next数组构建要正确
- 注意点2:字符串哈希要选择合适的模数和进制
- 注意点3:Trie树要根据字符集大小调整children数组
- 注意点4:使用StringBuilder避免创建大量临时对象
竞赛思维技巧
1. 优先使用内置方法
- Java的
String.indexOf()底层使用了优化的匹配算法 - 简单匹配可以直接用
indexOf(),不必手写KMP - 但复杂场景(如查找所有匹配位置)需要手写KMP
2. 字符串哈希的模数选择
- 常用模数:1000000007(10^9+7)、1000000009
- 常用进制:131、13331
- 双哈希:使用两个不同的模数和进制,降低冲突概率
3. Trie树的空间优化
- 如果字符集很大(如所有ASCII字符),使用HashMap代替数组
- 如果只需要判断前缀,不需要存储count
4. 字符串比较的优化
- 先比较长度,长度不同直接返回false
- 使用字符串哈希预处理,O(1)比较子串
5. 字符串拼接的优化
- 使用StringBuilder,避免创建大量临时String对象
- 预估容量:
new StringBuilder(capacity),减少扩容次数
避坑指南
❌ 错误做法1:使用String直接拼接
String result = "";
for (int i = 0; i < n; i++) {
result += str[i]; // 每次拼接都创建新对象,O(n^2)
}
✅ 正确做法1:使用StringBuilder
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
sb.append(str[i]); // O(n)
}
String result = sb.toString();
❌ 错误做法2:KMP的next数组下标错误
// 错误:next数组下标从1开始,容易越界
int[] next = new int[m + 1];
✅ 正确做法2:next数组下标从0开始
// 正确:next数组下标从0开始,与字符串下标一致
int[] next = new int[m];
❌ 错误做法3:字符串哈希溢出
// 错误:没有取模,可能溢出
long h = h * BASE + s.charAt(i);
✅ 正确做法3:每次运算都取模
// 正确:每次运算都取模,避免溢出
long h = (h * BASE + s.charAt(i)) % MOD;
❌ 错误做法4:Trie树的children数组大小错误
// 错误:字符集包含大小写字母,但只开了26个空间
TrieNode[] children = new TrieNode[26];
✅ 正确做法4:根据字符集大小调整
// 正确:大小写字母共52个,或使用HashMap
TrieNode[] children = new TrieNode[52];
// 或者使用HashMap
Map<Character, TrieNode> children = new HashMap<>();
字符串基础操作
算法原理
字符串基础操作是所有字符串算法的基础,包括拼接、截取、反转、比较等。虽然这些操作看似简单,但在竞赛中如果使用不当,会导致时间复杂度过高,甚至超时。
核心要点:
- 使用StringBuilder拼接字符串:避免创建大量临时对象
- 使用char[]进行频繁修改:比String更高效
- 使用双指针处理回文串:O(n)时间复杂度
- 使用compareTo()比较字典序:标准方法
典型例题1:反转字符串
题目:LeetCode 344 - 反转字符串(Easy)
题目描述:
编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 s 的形式给出。
不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。
输入输出样例:
输入:s = ["h","e","l","l","o"]
输出:["o","l","l","e","h"]
输入:s = ["H","a","n","n","a","h"]
输出:["h","a","n","n","a","H"]
数据范围:
- 1 ≤ s.length ≤ 10^5
- s[i] 都是 ASCII 码表中的可打印字符
题型识别:
- 关键词:“反转”
- 特征:原地修改,O(1)空间
- 算法:双指针
解题思路:
- 使用双指针,left指向开头,right指向末尾
- 交换left和right位置的字符
- left右移,right左移,直到left >= right
- 时间复杂度O(n),空间复杂度O(1)
完整代码:
public class Solution {
public void reverseString(char[] s) {
// 双指针:left指向开头,right指向末尾
int left = 0;
int right = s.length - 1;
// 当left < right时,交换并移动指针
while (left < right) {
// 交换s[left]和s[right]
char temp = s[left];
s[left] = s[right];
s[right] = temp;
// 移动指针
left++;
right--;
}
}
}
复杂度分析:
- 时间复杂度:O(n),其中n是字符串长度。需要遍历一半的字符串。
- 空间复杂度:O(1),只使用了常数个额外变量。
举一反三:
-
变体题1:反转字符串中的单词
- 题目:给定字符串"hello world",反转为"world hello"
- 解法:先反转整个字符串,再反转每个单词
- 关键变化:需要识别单词边界
-
变体题2:反转字符串的一部分
- 题目:反转字符串s[left…right]
- 解法:双指针,但起始位置是left和right
- 关键变化:指针的初始位置不同
-
变体题3:旋转字符串
- 题目:将字符串"abcdef"旋转2位,变为"efabcd"
- 解法:反转前n-k个字符,反转后k个字符,再反转整个字符串
- 关键变化:需要分段反转
同类题型推荐:
- LeetCode 541 - 反转字符串II(Easy)
- LeetCode 557 - 反转字符串中的单词III(Easy)
- 码蹄集 MT1234 - 字符串反转(青铜)
典型例题2:验证回文串
题目:LeetCode 125 - 验证回文串(Easy)
题目描述:
如果在将所有大写字符转换为小写字符、并移除所有非字母数字字符之后,短语正着读和反着读都一样。则可以认为该短语是一个回文串。
字母和数字都属于字母数字字符。
给你一个字符串 s,如果它是回文串,返回 true;否则,返回 false。
输入输出样例:
输入:s = "A man, a plan, a canal: Panama"
输出:true
解释:"amanaplanacanalpanama" 是回文串
输入:s = "race a car"
输出:false
解释:"raceacar" 不是回文串
数据范围:
- 1 ≤ s.length ≤ 2 × 10^5
- s 仅由可打印的 ASCII 字符组成
题型识别:
- 关键词:“回文串”
- 特征:需要忽略非字母数字字符,忽略大小写
- 算法:双指针
解题思路:
- 使用双指针,left指向开头,right指向末尾
- 跳过非字母数字字符
- 比较left和right位置的字符(忽略大小写)
- 如果不相等,返回false;否则继续
- 时间复杂度O(n),空间复杂度O(1)
完整代码:
public class Solution {
public boolean isPalindrome(String s) {
// 双指针:left指向开头,right指向末尾
int left = 0;
int right = s.length() - 1;
while (left < right) {
// 跳过非字母数字字符
while (left < right && !Character.isLetterOrDigit(s.charAt(left))) {
left++;
}
while (left < right && !Character.isLetterOrDigit(s.charAt(right))) {
right--;
}
// 比较字符(忽略大小写)
if (Character.toLowerCase(s.charAt(left)) != Character.toLowerCase(s.charAt(right))) {
return false;
}
// 移动指针
left++;
right--;
}
return true;
}
}
复杂度分析:
- 时间复杂度:O(n),其中n是字符串长度。需要遍历一次字符串。
- 空间复杂度:O(1),只使用了常数个额外变量。
举一反三:
-
变体题1:验证回文串II
- 题目:给定字符串,最多删除一个字符,判断是否能成为回文串
- 解法:双指针,遇到不匹配时,尝试删除left或right,再判断
- 关键变化:允许一次删除操作
-
变体题2:最长回文子串
- 题目:找到字符串中最长的回文子串
- 解法:中心扩展法,以每个字符为中心向两边扩展
- 关键变化:需要枚举所有可能的中心
-
变体题3:回文链表
- 题目:判断链表是否是回文
- 解法:快慢指针找到中点,反转后半部分,再比较
- 关键变化:数据结构从字符串变为链表
同类题型推荐:
- LeetCode 680 - 验证回文串II(Easy)
- LeetCode 5 - 最长回文子串(Medium)
- 码蹄集 MT1456 - 回文判断(白银)
KMP算法
算法原理
KMP(Knuth-Morris-Pratt)算法是一种高效的字符串匹配算法,用于在文本串中查找模式串。相比暴力匹配的O(n×m)时间复杂度,KMP算法可以在O(n+m)时间内完成匹配。
核心思想:
- 利用已匹配的信息:当匹配失败时,不需要回退文本串的指针,只需要回退模式串的指针
- next数组:记录模式串每个位置的最长公共前后缀长度
- 避免重复比较:利用next数组跳过已经匹配过的部分
什么是最长公共前后缀?
- 前缀:不包含最后一个字符的所有子串
- 后缀:不包含第一个字符的所有子串
- 最长公共前后缀:既是前缀又是后缀的最长子串
示例:
- 字符串"ababa"的最长公共前后缀是"aba"(长度为3)
- 字符串"aaaa"的最长公共前后缀是"aaa"(长度为3)
KMP算法的两个步骤:
- 构建next数组:预处理模式串,计算每个位置的最长公共前后缀长度
- 匹配:利用next数组进行高效匹配
典型例题3:实现strStr()
题目:LeetCode 28 - 找出字符串中第一个匹配项的下标(Easy)
题目描述:
给你两个字符串 haystack 和 needle,请你在 haystack 字符串中找出 needle 字符串的第一个匹配项的下标(下标从 0 开始)。如果 needle 不是 haystack 的一部分,则返回 -1。
输入输出样例:
输入:haystack = "sadbutsad", needle = "sad"
输出:0
解释:"sad" 在下标 0 和 6 处匹配。第一个匹配项的下标是 0。
输入:haystack = "leetcode", needle = "leeto"
输出:-1
解释:"leeto" 没有在 "leetcode" 中出现,所以返回 -1。
数据范围:
- 1 ≤ haystack.length, needle.length ≤ 10^4
- haystack 和 needle 仅由小写英文字符组成
题型识别:
- 关键词:“字符串匹配”、“查找子串”
- 特征:在文本串中查找模式串
- 算法:KMP算法
解题思路:
- 构建needle的next数组
- 使用KMP算法在haystack中查找needle
- 找到第一个匹配位置就返回
- 时间复杂度O(n+m),空间复杂度O(m)
完整代码:
public class Solution {
// KMP算法:在haystack中查找needle的第一个匹配位置
public int strStr(String haystack, String needle) {
int n = haystack.length();
int m = needle.length();
// 特殊情况处理
if (m == 0) return 0;
if (n < m) return -1;
// 构建next数组
int[] next = buildNext(needle);
// KMP匹配
int j = 0; // needle的指针
for (int i = 0; i < n; i++) { // haystack的指针
// 如果不匹配,回退needle的指针
while (j > 0 && haystack.charAt(i) != needle.charAt(j)) {
j = next[j - 1];
}
// 如果匹配,needle的指针前进
if (haystack.charAt(i) == needle.charAt(j)) {
j++;
}
// 如果完全匹配,返回起始位置
if (j == m) {
return i - m + 1;
}
}
return -1; // 未找到
}
// 构建next数组(最长公共前后缀)
private int[] buildNext(String pattern) {
int m = pattern.length();
int[] next = new int[m];
next[0] = 0; // 第一个字符的最长公共前后缀长度为0
int j = 0; // j表示当前最长公共前后缀的长度
for (int i = 1; i < m; i++) {
// 如果不匹配,回退到next[j-1]
while (j > 0 && pattern.charAt(i) != pattern.charAt(j)) {
j = next[j - 1];
}
// 如果匹配,j++
if (pattern.charAt(i) == pattern.charAt(j)) {
j++;
}
next[i] = j;
}
return next;
}
}
复杂度分析:
- 时间复杂度:O(n+m),其中n是haystack长度,m是needle长度。构建next数组O(m),匹配O(n)。
- 空间复杂度:O(m),需要存储next数组。
举一反三:
-
变体题1:查找所有匹配位置
- 题目:找到needle在haystack中的所有匹配位置
- 解法:KMP匹配,找到一个匹配后继续查找
- 关键变化:找到匹配后不返回,而是记录位置并继续
-
变体题2:重复的子字符串
- 题目:判断字符串是否由重复的子串构成
- 解法:如果s由重复子串构成,则s的最长公共前后缀长度为len - 最小循环节长度
- 关键变化:利用KMP的next数组判断循环节
-
变体题3:最短回文串
- 题目:在字符串前面添加最少的字符,使其成为回文串
- 解法:将s和reverse(s)拼接,用KMP找最长公共前后缀
- 关键变化:结合字符串反转和KMP
同类题型推荐:
- LeetCode 459 - 重复的子字符串(Easy)
- LeetCode 214 - 最短回文串(Hard)
- 码蹄集 MT1678 - 字符串匹配(黄金)
典型例题4:重复的子字符串
题目:LeetCode 459 - 重复的子字符串(Easy)
题目描述:
给定一个非空的字符串 s,检查是否可以通过由它的一个子串重复多次构成。
输入输出样例:
输入:s = "abab"
输出:true
解释:可由子串 "ab" 重复两次构成。
输入:s = "aba"
输出:false
输入:s = "abcabcabcabc"
输出:true
解释:可由子串 "abc" 重复四次构成。
数据范围:
- 1 ≤ s.length ≤ 10^4
- s 由小写英文字母组成
题型识别:
- 关键词:“重复子串”
- 特征:判断字符串是否由重复的子串构成
- 算法:KMP算法(利用next数组)
解题思路:
- 如果字符串s由重复子串构成,那么s的长度一定是最小循环节长度的倍数
- 利用KMP的next数组,next[n-1]表示s的最长公共前后缀长度
- 如果n % (n - next[n-1]) == 0,则s由重复子串构成
- 最小循环节长度 = n - next[n-1]
- 时间复杂度O(n),空间复杂度O(n)
完整代码:
public class Solution {
public boolean repeatedSubstringPattern(String s) {
int n = s.length();
// 构建next数组
int[] next = buildNext(s);
// 如果next[n-1] > 0,说明存在公共前后缀
// 如果n % (n - next[n-1]) == 0,说明可以由重复子串构成
if (next[n - 1] > 0 && n % (n - next[n - 1]) == 0) {
return true;
}
return false;
}
// 构建next数组
private int[] buildNext(String pattern) {
int m = pattern.length();
int[] next = new int[m];
next[0] = 0;
int j = 0;
for (int i = 1; i < m; i++) {
while (j > 0 && pattern.charAt(i) != pattern.charAt(j)) {
j = next[j - 1];
}
if (pattern.charAt(i) == pattern.charAt(j)) {
j++;
}
next[i] = j;
}
return next;
}
}
复杂度分析:
- 时间复杂度:O(n),其中n是字符串长度。构建next数组需要O(n)时间。
- 空间复杂度:O(n),需要存储next数组。
举一反三:
-
变体题1:最小循环节
- 题目:找到字符串的最小循环节
- 解法:最小循环节长度 = n - next[n-1]
- 关键变化:不仅判断是否存在,还要找到最小循环节
-
变体题2:重复K次的子串
- 题目:判断字符串是否由某个子串重复K次构成
- 解法:检查n % k == 0,且s[0…n/k-1]重复k次等于s
- 关键变化:指定重复次数
-
变体题3:最长重复子串
- 题目:找到字符串中最长的重复子串
- 解法:后缀数组或字符串哈希 + 二分查找
- 关键变化:从判断是否重复变为找最长重复
同类题型推荐:
- LeetCode 686 - 重复叠加字符串匹配(Medium)
- 码蹄集 MT1789 - 循环节判断(黄金)
字符串哈希
算法原理
字符串哈希是一种将字符串映射为数字的技术,可以在O(1)时间内比较两个子串是否相等。它的核心思想是将字符串看作一个P进制数,然后对一个大质数M取模。
核心思想:
- 将字符串看作P进制数:每个字符对应一个数字,整个字符串是一个P进制数
- 取模避免溢出:对大质数M取模,避免数字过大
- 前缀哈希:预处理所有前缀的哈希值,快速查询任意子串的哈希值
哈希函数:
hash(s) = (s[0] * P^(n-1) + s[1] * P^(n-2) + ... + s[n-1] * P^0) % M
常用参数:
- P(进制):131、13331(经验值,冲突概率低)
- M(模数):109+7、109+9(大质数)
优点:
- 查询子串哈希值:O(1)
- 比较两个子串是否相等:O(1)
缺点:
- 存在哈希冲突(概率很小)
- 不能判断字典序大小
典型例题5:最长重复子串
题目:LeetCode 1044 - 最长重复子串(Hard)
题目描述:
给你一个字符串 s,考虑其所有重复子串:即 s 的(连续)子串,在 s 中出现 2 次或更多次。这些出现之处可能存在重叠。
返回任意一个最长的重复子串。如果 s 不含重复子串,那么答案为 “”。
输入输出样例:
输入:s = "banana"
输出:"ana"
输入:s = "abcd"
输出:""
数据范围:
- 2 ≤ s.length ≤ 3 × 10^4
- s 由小写英文字母组成
题型识别:
- 关键词:“重复子串”、“最长”
- 特征:需要快速比较大量子串
- 算法:字符串哈希 + 二分查找
解题思路:
- 二分查找最长重复子串的长度len
- 对于每个长度len,使用字符串哈希判断是否存在重复子串
- 将所有长度为len的子串的哈希值存入HashSet
- 如果某个子串的哈希值已经存在,说明找到了重复子串
- 时间复杂度O(n log n),空间复杂度O(n)
完整代码:
public class Solution {
private static final long MOD = 1000000007;
private static final long BASE = 131;
public String longestDupSubstring(String s) {
int n = s.length();
// 二分查找最长重复子串的长度
int left = 1, right = n - 1;
int maxLen = 0;
int maxStart = 0;
while (left <= right) {
int mid = left + (right - left) / 2;
int start = search(s, mid);
if (start != -1) {
// 存在长度为mid的重复子串
maxLen = mid;
maxStart = start;
left = mid + 1; // 尝试更长的长度
} else {
right = mid - 1; // 尝试更短的长度
}
}
return maxLen == 0 ? "" : s.substring(maxStart, maxStart + maxLen);
}
// 判断是否存在长度为len的重复子串,返回起始位置(不存在返回-1)
private int search(String s, int len) {
int n = s.length();
// 预处理:计算BASE^len
long pow = 1;
for (int i = 0; i < len; i++) {
pow = (pow * BASE) % MOD;
}
// 计算第一个子串的哈希值
long hash = 0;
for (int i = 0; i < len; i++) {
hash = (hash * BASE + s.charAt(i)) % MOD;
}
// 使用HashSet存储所有子串的哈希值
Set<Long> seen = new HashSet<>();
seen.add(hash);
// 滑动窗口:计算所有长度为len的子串的哈希值
for (int i = 1; i <= n - len; i++) {
// 移除最左边的字符,添加最右边的字符
hash = (hash * BASE - s.charAt(i - 1) * pow % MOD + MOD) % MOD;
hash = (hash + s.charAt(i + len - 1)) % MOD;
// 如果哈希值已经存在,说明找到了重复子串
if (seen.contains(hash)) {
return i; // 返回起始位置
}
seen.add(hash);
}
return -1; // 不存在长度为len的重复子串
}
}
复杂度分析:
- 时间复杂度:O(n log n),其中n是字符串长度。二分查找O(log n),每次查找需要O(n)时间。
- 空间复杂度:O(n),需要存储所有子串的哈希值。
举一反三:
-
变体题1:最长公共子串
- 题目:找到两个字符串的最长公共子串
- 解法:二分查找长度,用字符串哈希判断是否存在公共子串
- 关键变化:需要同时处理两个字符串
-
变体题2:查找重复的DNA序列
- 题目:找到所有长度为10的重复DNA序列
- 解法:字符串哈希,将所有长度为10的子串的哈希值存入HashSet
- 关键变化:固定长度,不需要二分查找
-
变体题3:判断子串是否相等
- 题目:给定多次查询,判断s[l1…r1]和s[l2…r2]是否相等
- 解法:预处理前缀哈希,O(1)查询子串哈希值
- 关键变化:多次查询,需要预处理
同类题型推荐:
- LeetCode 187 - 重复的DNA序列(Medium)
- LeetCode 718 - 最长重复子数组(Medium)
- 码蹄集 MT1890 - 子串比较(钻石)
典型例题6:重复的DNA序列
题目:LeetCode 187 - 重复的DNA序列(Medium)
题目描述:
DNA序列由一系列核苷酸组成,缩写为 ‘A’, ‘C’, ‘G’ 和 ‘T’。
例如,“ACGAATTCCG” 是一个 DNA序列。
在研究 DNA 时,识别 DNA 中的重复序列非常有用。
给定一个表示 DNA序列 的字符串 s,返回所有在 DNA 分子中出现不止一次的长度为 10 的序列(子字符串)。你可以按任意顺序返回答案。
输入输出样例:
输入:s = "AAAAACCCCCAAAAACCCCCCAAAAAGGGTTT"
输出:["AAAAACCCCC","CCCCCAAAAA"]
输入:s = "AAAAAAAAAAAAA"
输出:["AAAAAAAAAA"]
数据范围:
- 1 ≤ s.length ≤ 10^5
- s[i] 为 ‘A’、‘C’、‘G’ 或 ‘T’
题型识别:
- 关键词:“重复序列”、“长度为10”
- 特征:固定长度的子串,需要快速判断重复
- 算法:字符串哈希或滑动窗口
解题思路:
- 使用滑动窗口遍历所有长度为10的子串
- 使用HashMap记录每个子串出现的次数
- 将出现次数≥2的子串加入结果
- 时间复杂度O(n),空间复杂度O(n)
完整代码:
public class Solution {
public List<String> findRepeatedDnaSequences(String s) {
List<String> result = new ArrayList<>();
int n = s.length();
// 如果长度小于10,不可能有重复序列
if (n < 10) {
return result;
}
// 使用HashMap记录每个子串出现的次数
Map<String, Integer> count = new HashMap<>();
// 滑动窗口:遍历所有长度为10的子串
for (int i = 0; i <= n - 10; i++) {
String substring = s.substring(i, i + 10);
count.put(substring, count.getOrDefault(substring, 0) + 1);
// 如果出现次数恰好为2,加入结果(避免重复添加)
if (count.get(substring) == 2) {
result.add(substring);
}
}
return result;
}
}
优化版本(使用字符串哈希):
public class Solution {
public List<String> findRepeatedDnaSequences(String s) {
List<String> result = new ArrayList<>();
int n = s.length();
if (n < 10) {
return result;
}
// 将字符映射为数字:A->0, C->1, G->2, T->3
Map<Character, Integer> charToNum = new HashMap<>();
charToNum.put('A', 0);
charToNum.put('C', 1);
charToNum.put('G', 2);
charToNum.put('T', 3);
// 使用HashMap记录每个哈希值出现的次数
Map<Integer, Integer> count = new HashMap<>();
// 计算第一个子串的哈希值(使用4进制)
int hash = 0;
for (int i = 0; i < 10; i++) {
hash = (hash << 2) | charToNum.get(s.charAt(i)); // 左移2位相当于乘以4
}
count.put(hash, 1);
// 滑动窗口:计算所有长度为10的子串的哈希值
int mask = (1 << 20) - 1; // 保留低20位(10个字符,每个2位)
for (int i = 1; i <= n - 10; i++) {
// 移除最左边的字符,添加最右边的字符
hash = ((hash << 2) | charToNum.get(s.charAt(i + 9))) & mask;
count.put(hash, count.getOrDefault(hash, 0) + 1);
// 如果出现次数恰好为2,加入结果
if (count.get(hash) == 2) {
result.add(s.substring(i, i + 10));
}
}
return result;
}
}
复杂度分析:
- 时间复杂度:O(n),其中n是字符串长度。需要遍历一次字符串。
- 空间复杂度:O(n),需要存储所有子串的哈希值。
举一反三:
-
变体题1:查找长度为K的重复子串
- 题目:将长度10改为任意长度K
- 解法:滑动窗口,窗口大小为K
- 关键变化:窗口大小可变
-
变体题2:查找出现次数≥M的子串
- 题目:将出现次数≥2改为≥M
- 解法:HashMap记录次数,筛选次数≥M的子串
- 关键变化:出现次数阈值可变
-
变体题3:查找所有不同的重复子串
- 题目:不限制长度,找到所有重复的子串
- 解法:枚举所有可能的长度,使用字符串哈希判断
- 关键变化:需要枚举长度
同类题型推荐:
- LeetCode 1044 - 最长重复子串(Hard)
- 码蹄集 MT1901 - DNA序列分析(黄金)
Trie树
算法原理
Trie树(前缀树)是一种树形数据结构,用于高效地存储和检索字符串集合。它的核心思想是利用字符串的公共前缀来减少存储空间和查询时间。
核心思想:
- 共享前缀:相同前缀的字符串共享同一条路径
- 树形结构:每个节点代表一个字符,从根到叶子的路径代表一个字符串
- 快速查询:插入、查找、前缀匹配都是O(m),其中m是字符串长度
Trie树的特点:
- 根节点不包含字符,其他节点包含一个字符
- 从根节点到某一节点的路径上的字符连接起来,就是该节点对应的字符串
- 每个节点的所有子节点包含的字符都不相同
应用场景:
- 字典实现
- 自动补全
- 拼写检查
- 前缀匹配
- 单词搜索
典型例题7:实现Trie(前缀树)
题目:LeetCode 208 - 实现Trie(前缀树)(Medium)
题目描述:
Trie(发音类似 “try”)或者说前缀树是一种树形数据结构,用于高效地存储和检索字符串数据集中的键。这一数据结构有相当多的应用情景,例如自动补全和拼写检查。
请你实现 Trie 类:
Trie()初始化前缀树对象。void insert(String word)向前缀树中插入字符串 word。boolean search(String word)如果字符串 word 在前缀树中,返回 true;否则,返回 false。boolean startsWith(String prefix)如果之前已经插入的字符串 word 的前缀之一为 prefix,返回 true;否则,返回 false。
输入输出样例:
输入:
["Trie", "insert", "search", "search", "startsWith", "insert", "search"]
[[], ["apple"], ["apple"], ["app"], ["app"], ["app"], ["app"]]
输出:
[null, null, true, false, true, null, true]
解释:
Trie trie = new Trie();
trie.insert("apple");
trie.search("apple"); // 返回 True
trie.search("app"); // 返回 False
trie.startsWith("app"); // 返回 True
trie.insert("app");
trie.search("app"); // 返回 True
数据范围:
- 1 ≤ word.length, prefix.length ≤ 2000
- word 和 prefix 仅由小写英文字母组成
- insert、search 和 startsWith 调用次数总计不超过 3 × 10^4 次
题型识别:
- 关键词:“前缀树”、“字典”、“前缀匹配”
- 特征:需要高效的插入、查找、前缀匹配
- 算法:Trie树
解题思路:
- 定义TrieNode类,包含children数组和isEnd标志
- insert:从根节点开始,逐个字符插入,不存在则创建新节点
- search:从根节点开始,逐个字符查找,最后检查isEnd
- startsWith:从根节点开始,逐个字符查找,不需要检查isEnd
- 时间复杂度O(m),空间复杂度O(总字符数 × 26)
完整代码:
class Trie {
// Trie节点定义
class TrieNode {
TrieNode[] children; // 子节点数组(26个小写字母)
boolean isEnd; // 是否是单词结尾
public TrieNode() {
children = new TrieNode[26];
isEnd = false;
}
}
private TrieNode root;
// 初始化Trie树
public Trie() {
root = new TrieNode();
}
// 插入单词
public void insert(String word) {
TrieNode node = root;
// 逐个字符插入
for (int i = 0; i < word.length(); i++) {
int index = word.charAt(i) - 'a';
// 如果子节点不存在,创建新节点
if (node.children[index] == null) {
node.children[index] = new TrieNode();
}
// 移动到子节点
node = node.children[index];
}
// 标记单词结尾
node.isEnd = true;
}
// 查找单词是否存在
public boolean search(String word) {
TrieNode node = root;
// 逐个字符查找
for (int i = 0; i < word.length(); i++) {
int index = word.charAt(i) - 'a';
// 如果子节点不存在,返回false
if (node.children[index] == null) {
return false;
}
// 移动到子节点
node = node.children[index];
}
// 必须是单词结尾
return node.isEnd;
}
// 查找是否存在以prefix为前缀的单词
public boolean startsWith(String prefix) {
TrieNode node = root;
// 逐个字符查找
for (int i = 0; i < prefix.length(); i++) {
int index = prefix.charAt(i) - 'a';
// 如果子节点不存在,返回false
if (node.children[index] == null) {
return false;
}
// 移动到子节点
node = node.children[index];
}
// 只要能走到prefix的末尾即可
return true;
}
}
复杂度分析:
- 时间复杂度:
- insert:O(m),其中m是单词长度
- search:O(m)
- startsWith:O(m)
- 空间复杂度:O(总字符数 × 26),最坏情况下每个字符都需要一个节点
举一反三:
-
变体题1:添加与搜索单词(支持通配符)
- 题目:支持’.'通配符,可以匹配任意字符
- 解法:在search时,遇到’.'需要尝试所有子节点
- 关键变化:需要递归处理通配符
-
变体题2:单词搜索II
- 题目:在二维网格中查找所有存在于字典中的单词
- 解法:Trie树 + DFS,在DFS过程中同时遍历Trie树
- 关键变化:结合DFS和Trie树
-
变体题3:最长公共前缀
- 题目:找到字符串数组的最长公共前缀
- 解法:将所有字符串插入Trie树,找到第一个分叉点
- 关键变化:利用Trie树的结构特性
同类题型推荐:
- LeetCode 211 - 添加与搜索单词(Medium)
- LeetCode 212 - 单词搜索II(Hard)
- 码蹄集 MT2012 - 字典树应用(钻石)
典型例题8:单词搜索II
题目:LeetCode 212 - 单词搜索II(Hard)
题目描述:
给定一个 m x n 二维字符网格 board 和一个单词(字符串)列表 words,返回所有二维网格上的单词。
单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中"相邻"单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母在一个单词中不允许被重复使用。
输入输出样例:
输入:
board = [["o","a","a","n"],
["e","t","a","e"],
["i","h","k","r"],
["i","f","l","v"]]
words = ["oath","pea","eat","rain"]
输出:["eat","oath"]
输入:
board = [["a","b"],["c","d"]]
words = ["abcb"]
输出:[]
数据范围:
- m == board.length
- n == board[i].length
- 1 ≤ m, n ≤ 12
- board[i][j] 是一个小写英文字母
- 1 ≤ words.length ≤ 3 × 10^4
- 1 ≤ words[i].length ≤ 10
- words[i] 由小写英文字母组成
- words 中的所有字符串互不相同
题型识别:
- 关键词:“单词搜索”、“字典”、“网格”
- 特征:在网格中查找多个单词,需要高效的字典查询
- 算法:Trie树 + DFS
解题思路:
- 将所有单词插入Trie树
- 遍历网格的每个位置,从该位置开始DFS
- DFS过程中同时遍历Trie树,如果当前字符不在Trie树中,剪枝
- 如果到达Trie树的单词结尾,将单词加入结果
- 时间复杂度O(m×n×4^L),其中L是单词最大长度
- 空间复杂度O(总字符数)
完整代码:
class Solution {
// Trie节点定义
class TrieNode {
TrieNode[] children = new TrieNode[26];
String word = null; // 如果是单词结尾,存储单词
}
private TrieNode root = new TrieNode();
private List<String> result = new ArrayList<>();
public List<String> findWords(char[][] board, String[] words) {
// 1. 构建Trie树
for (String word : words) {
insert(word);
}
// 2. 遍历网格,从每个位置开始DFS
int m = board.length;
int n = board[0].length;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
dfs(board, i, j, root);
}
}
return result;
}
// 插入单词到Trie树
private void insert(String word) {
TrieNode node = root;
for (char c : word.toCharArray()) {
int index = c - 'a';
if (node.children[index] == null) {
node.children[index] = new TrieNode();
}
node = node.children[index];
}
node.word = word; // 存储单词
}
// DFS搜索
private void dfs(char[][] board, int i, int j, TrieNode node) {
// 边界检查
if (i < 0 || i >= board.length || j < 0 || j >= board[0].length) {
return;
}
char c = board[i][j];
// 如果已访问或不在Trie树中,剪枝
if (c == '#' || node.children[c - 'a'] == null) {
return;
}
// 移动到Trie树的下一个节点
node = node.children[c - 'a'];
// 如果找到单词,加入结果
if (node.word != null) {
result.add(node.word);
node.word = null; // 避免重复添加
}
// 标记当前位置已访问
board[i][j] = '#';
// 向四个方向DFS
dfs(board, i - 1, j, node); // 上
dfs(board, i + 1, j, node); // 下
dfs(board, i, j - 1, node); // 左
dfs(board, i, j + 1, node); // 右
// 恢复当前位置(回溯)
board[i][j] = c;
}
}
复杂度分析:
- 时间复杂度:O(m×n×4^L),其中m和n是网格大小,L是单词最大长度。最坏情况下需要遍历所有可能的路径。
- 空间复杂度:O(总字符数),需要存储Trie树。
举一反三:
-
变体题1:单词搜索I
- 题目:在网格中查找单个单词
- 解法:DFS,不需要Trie树
- 关键变化:只查找一个单词,不需要字典
-
变体题2:最长单词
- 题目:找到网格中能构成的最长单词
- 解法:Trie树 + DFS,记录最长单词
- 关键变化:需要记录最长单词
-
变体题3:单词接龙
- 题目:从起始单词变换到目标单词,每次只能改变一个字母
- 解法:BFS + Trie树,Trie树用于快速判断单词是否存在
- 关键变化:从网格搜索变为单词变换
同类题型推荐:
- LeetCode 79 - 单词搜索(Medium)
- LeetCode 720 - 词典中最长的单词(Medium)
- 码蹄集 MT2123 - 单词迷宫(星耀)
易错点提醒
1. 字符串拼接性能问题
错误示例:
String result = "";
for (int i = 0; i < n; i++) {
result += str[i]; // 每次拼接都创建新对象,O(n^2)
}
正确做法:
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
sb.append(str[i]); // O(n)
}
String result = sb.toString();
原因:String是不可变对象,每次拼接都会创建新对象,导致时间复杂度为O(n^2)。StringBuilder是可变对象,拼接时不创建新对象,时间复杂度为O(n)。
2. KMP的next数组下标错误
错误示例:
// 错误:next数组下标从1开始,容易越界
int[] next = new int[m + 1];
next[1] = 0;
for (int i = 2; i <= m; i++) {
// ...
}
正确做法:
// 正确:next数组下标从0开始,与字符串下标一致
int[] next = new int[m];
next[0] = 0;
for (int i = 1; i < m; i++) {
// ...
}
原因:Java字符串下标从0开始,next数组也应该从0开始,保持一致性,避免下标错误。
3. 字符串哈希溢出
错误示例:
// 错误:没有取模,可能溢出
long h = h * BASE + s.charAt(i);
正确做法:
// 正确:每次运算都取模,避免溢出
long h = (h * BASE + s.charAt(i)) % MOD;
原因:字符串哈希的值可能非常大,超过long的范围,需要每次运算都取模。
4. 字符串哈希的减法取模
错误示例:
// 错误:减法可能产生负数
long h = (hash - s.charAt(i) * pow) % MOD;
正确做法:
// 正确:减法后加MOD再取模,确保结果为正
long h = (hash - s.charAt(i) * pow % MOD + MOD) % MOD;
原因:减法可能产生负数,需要加MOD再取模,确保结果为正数。
5. Trie树的children数组大小
错误示例:
// 错误:字符集包含大小写字母,但只开了26个空间
TrieNode[] children = new TrieNode[26];
int index = c - 'a'; // 大写字母会导致下标错误
正确做法:
// 方法1:根据字符集大小调整
TrieNode[] children = new TrieNode[52]; // 大小写字母共52个
int index = c >= 'a' ? c - 'a' : c - 'A' + 26;
// 方法2:使用HashMap(推荐,更灵活)
Map<Character, TrieNode> children = new HashMap<>();
原因:需要根据字符集大小调整children数组的大小,或使用HashMap更灵活。
6. 字符串比较使用==
错误示例:
// 错误:==比较的是引用,不是内容
if (s1 == s2) {
// ...
}
正确做法:
// 正确:使用equals()比较内容
if (s1.equals(s2)) {
// ...
}
原因:==比较的是引用(内存地址),equals()比较的是内容。
7. 字符串截取的边界
错误示例:
// 错误:substring的第二个参数是结束位置(不包含)
String sub = s.substring(i, j); // 包含s[i],不包含s[j]
正确理解:
// 正确:substring(i, j)返回s[i...j-1]
String sub = s.substring(i, j); // 返回s[i...j-1]
String sub = s.substring(i, i + len); // 返回长度为len的子串
原因:substring的第二个参数是结束位置(不包含),容易混淆。
8. 字符数组和字符串的转换
错误示例:
// 错误:char[]不能直接用+拼接
char[] chars = {'a', 'b', 'c'};
String s = chars + ""; // 错误,输出的是地址
正确做法:
// 正确:使用String构造函数
char[] chars = {'a', 'b', 'c'};
String s = new String(chars);
// 或者使用String.valueOf()
String s = String.valueOf(chars);
原因:char[]不能直接用+拼接,需要使用String构造函数或valueOf()方法。
9. KMP匹配时的指针回退
错误示例:
// 错误:回退到next[j],应该回退到next[j-1]
while (j > 0 && text.charAt(i) != pattern.charAt(j)) {
j = next[j]; // 错误
}
正确做法:
// 正确:回退到next[j-1]
while (j > 0 && text.charAt(i) != pattern.charAt(j)) {
j = next[j - 1]; // 正确
}
原因:next[j]表示pattern[0…j]的最长公共前后缀长度,当pattern[j]不匹配时,应该回退到next[j-1]。
10. Trie树删除节点的判断
错误示例:
// 错误:删除节点时没有检查是否有其他单词经过
if (!node.isEnd) {
node.children[index] = null; // 可能删除了其他单词的路径
}
正确做法:
// 正确:只有当节点没有子节点且不是其他单词的结尾时才删除
if (!node.isEnd && node.children.length == 0) {
node.children[index] = null;
}
原因:删除节点时需要检查是否有其他单词经过该节点,避免误删。
优化技巧
1. 字符串哈希的双哈希
问题:单哈希可能存在冲突,导致误判。
优化方案:使用两个不同的模数和进制,同时计算两个哈希值。
public class DoubleHash {
private static final long MOD1 = 1000000007;
private static final long MOD2 = 1000000009;
private static final long BASE1 = 131;
private static final long BASE2 = 13331;
private long[] hash1, hash2;
private long[] pow1, pow2;
public void buildHash(String s) {
int n = s.length();
hash1 = new long[n + 1];
hash2 = new long[n + 1];
pow1 = new long[n + 1];
pow2 = new long[n + 1];
pow1[0] = pow2[0] = 1;
for (int i = 1; i <= n; i++) {
hash1[i] = (hash1[i - 1] * BASE1 + s.charAt(i - 1)) % MOD1;
hash2[i] = (hash2[i - 1] * BASE2 + s.charAt(i - 1)) % MOD2;
pow1[i] = (pow1[i - 1] * BASE1) % MOD1;
pow2[i] = (pow2[i - 1] * BASE2) % MOD2;
}
}
// 查询子串s[left...right]的双哈希值
public long[] getHash(int left, int right) {
long h1 = (hash1[right + 1] - hash1[left] * pow1[right - left + 1] % MOD1 + MOD1) % MOD1;
long h2 = (hash2[right + 1] - hash2[left] * pow2[right - left + 1] % MOD2 + MOD2) % MOD2;
return new long[]{h1, h2};
}
}
效果:双哈希的冲突概率极低,几乎可以忽略。
2. Trie树的空间优化
问题:Trie树的空间复杂度较高,尤其是字符集很大时。
优化方案1:使用HashMap代替数组
class TrieNode {
Map<Character, TrieNode> children = new HashMap<>();
boolean isEnd = false;
}
效果:只存储实际存在的子节点,节省空间。
优化方案2:压缩路径(Radix Tree)
class RadixNode {
Map<String, RadixNode> children = new HashMap<>();
boolean isEnd = false;
}
效果:将只有一个子节点的路径压缩为一条边,节省空间。
3. KMP的优化next数组
问题:标准KMP的next数组在某些情况下会导致多余的回退。
优化方案:优化next数组,跳过相同字符的回退。
private int[] buildNextOptimized(String pattern) {
int m = pattern.length();
int[] next = new int[m];
next[0] = 0;
int j = 0;
for (int i = 1; i < m; i++) {
while (j > 0 && pattern.charAt(i) != pattern.charAt(j)) {
j = next[j - 1];
}
if (pattern.charAt(i) == pattern.charAt(j)) {
j++;
}
// 优化:如果pattern[i+1] == pattern[next[i]],跳过
if (i + 1 < m && pattern.charAt(i + 1) == pattern.charAt(j)) {
next[i] = next[j - 1];
} else {
next[i] = j;
}
}
return next;
}
效果:减少不必要的回退,提高匹配效率。
4. 字符串哈希的滚动哈希
问题:每次计算子串哈希值需要O(m)时间。
优化方案:使用滚动哈希,O(1)时间更新哈希值。
// 滑动窗口:从s[i...i+len-1]移动到s[i+1...i+len]
long newHash = (oldHash * BASE - s.charAt(i) * pow % MOD + MOD) % MOD;
newHash = (newHash + s.charAt(i + len)) % MOD;
效果:将时间复杂度从O(m)降低到O(1)。
5. StringBuilder的容量预估
问题:StringBuilder默认容量为16,频繁扩容影响性能。
优化方案:预估容量,减少扩容次数。
// 预估容量
StringBuilder sb = new StringBuilder(n * 10); // 假设每个元素平均10个字符
效果:减少扩容次数,提高性能。
6. 字符串比较的短路优化
问题:比较两个字符串时,逐个字符比较效率低。
优化方案:先比较长度,再比较内容。
// 优化:先比较长度
if (s1.length() != s2.length()) {
return false;
}
// 再比较内容
return s1.equals(s2);
效果:长度不同时直接返回,避免逐个字符比较。
7. Trie树的懒删除
问题:删除节点时需要递归检查,效率低。
优化方案:使用懒删除,只标记isEnd为false,不实际删除节点。
public void delete(String word) {
TrieNode node = root;
for (char c : word.toCharArray()) {
int index = c - 'a';
if (node.children[index] == null) {
return; // 单词不存在
}
node = node.children[index];
}
node.isEnd = false; // 懒删除,只标记为非单词结尾
}
效果:删除操作的时间复杂度从O(m)降低到O(m),但不需要递归检查。
8. 字符串哈希的模数选择
问题:模数选择不当可能导致冲突概率增加。
优化方案:选择大质数作为模数。
// 常用模数
private static final long MOD = 1000000007; // 10^9+7
private static final long MOD = 1000000009; // 10^9+9
private static final long MOD = 998244353; // 998244353(NTT友好)
效果:大质数可以降低冲突概率。
练习题推荐
基础题(青铜-白银)
字符串基础操作:
- LeetCode 344 - 反转字符串(Easy)
- LeetCode 125 - 验证回文串(Easy)
- LeetCode 242 - 有效的字母异位词(Easy)
- LeetCode 387 - 字符串中的第一个唯一字符(Easy)
- 码蹄集 MT1234 - 字符串反转(青铜)
- 码蹄集 MT1345 - 回文判断(白银)
KMP算法入门:
7. LeetCode 28 - 找出字符串中第一个匹配项的下标(Easy)
8. LeetCode 459 - 重复的子字符串(Easy)
9. 码蹄集 MT1456 - 字符串匹配(白银)
中级题(黄金-钻石)
KMP算法进阶:
10. LeetCode 214 - 最短回文串(Hard)
11. LeetCode 686 - 重复叠加字符串匹配(Medium)
12. 码蹄集 MT1678 - 字符串匹配进阶(黄金)
字符串哈希:
13. LeetCode 187 - 重复的DNA序列(Medium)
14. LeetCode 1044 - 最长重复子串(Hard)
15. LeetCode 718 - 最长重复子数组(Medium)
16. 码蹄集 MT1789 - 子串比较(钻石)
17. 码蹄集 MT1890 - 最长重复子串(钻石)
Trie树:
18. LeetCode 208 - 实现Trie(前缀树)(Medium)
19. LeetCode 211 - 添加与搜索单词(Medium)
20. LeetCode 212 - 单词搜索II(Hard)
21. LeetCode 720 - 词典中最长的单词(Medium)
22. 码蹄集 MT1901 - 字典树实现(黄金)
23. 码蹄集 MT2012 - 字典树应用(钻石)
高级题(星耀-王者)
综合应用:
24. LeetCode 336 - 回文对(Hard)
25. LeetCode 1316 - 不同的循环子字符串(Hard)
26. LeetCode 1392 - 最长快乐前缀(Hard)
27. 码蹄集 MT2123 - 单词迷宫(星耀)
28. 码蹄集 MT2234 - 字符串综合(王者)
竞赛真题:
29. 码蹄集 MC0456 - 字符串匹配(2023省赛T6)
30. 码蹄集 MC0478 - 前缀树应用(2023国赛T8)
刷题建议
第1周:字符串基础操作
- 每天2-3题,掌握StringBuilder、双指针、回文判断
- 重点:LeetCode 344、125、242
第2周:KMP算法
- 每天1-2题,理解next数组的构建和匹配过程
- 重点:LeetCode 28、459、214
第3周:字符串哈希
- 每天1-2题,掌握前缀哈希和滚动哈希
- 重点:LeetCode 187、1044、718
第4周:Trie树
- 每天1-2题,掌握Trie树的构建和应用
- 重点:LeetCode 208、211、212
考前冲刺:
- 复习核心模板:KMP、字符串哈希、Trie树
- 做2-3道码蹄集真题:MC0456、MC0478
- 总结易错点和优化技巧
总结
字符串算法虽然在码蹄杯中占比不高(约5%),但一旦出现就是拉开差距的关键题。掌握字符串算法,你就能在关键时刻多拿一题,提升排名。
核心要点:
- 字符串基础操作:使用StringBuilder拼接,双指针处理回文
- KMP算法:O(n+m)时间复杂度的字符串匹配
- 字符串哈希:O(1)时间复杂度的子串比较
- Trie树:高效的前缀匹配和字符串存储
学习建议:
- 先掌握字符串基础操作,再学习KMP、字符串哈希、Trie树
- 理解算法原理,不要死记硬背模板
- 多做练习题,熟悉各种变体
- 总结易错点,避免在考场上犯错
竞赛策略:
- 字符串题通常出现在T6-T8,难度中等偏上
- 如果不熟悉KMP或Trie树,可以先跳过,做其他题
- 字符串哈希相对简单,优先掌握
- 考前复习核心模板,确保能快速写出
时间分配:
- 字符串基础操作:5-10分钟
- KMP算法:15-20分钟
- 字符串哈希:10-15分钟
- Trie树:15-20分钟
祝你在码蹄杯中取得好成绩!🎉
更多推荐

所有评论(0)