1. 字符串处理实战:从基础操作到KMP算法
今天我们要深入探讨字符串处理的四个经典问题:翻转字符串中的单词、右旋转字符串、实现strStr()函数以及检测重复子字符串。这些问题看似基础,却涵盖了字符串处理的核心技巧,从简单的指针操作到复杂的KMP算法,每个问题都能让我们对字符串有更深层次的理解。
作为算法训练营的第九天内容,这些题目经过精心设计,难度循序渐进。翻转字符串里的单词考察我们对字符串基本操作和边界条件的处理能力;右旋转字符串则考验我们对字符串旋转这类问题的解决思路;而strStr()和重复子字符串问题将带我们进入字符串匹配的高级领域,特别是KMP算法的精妙设计。
在实际编程面试中,字符串处理问题出现的频率极高。根据我的经验,大约60%的算法面试题都会涉及字符串操作。掌握这些基础问题的解法,不仅能帮助我们应对面试,更能提升日常开发中处理文本数据的能力。接下来,我将逐一拆解每个问题的解决思路,分享我在实际编码和教学过程中积累的经验和技巧。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 151.翻转字符串里的单词:从暴力到优雅
2.1 问题描述与初步思路
翻转字符串里的单词要求我们将一个字符串中的单词顺序翻转,同时去除多余的空格。例如,输入"the sky is blue"应该输出"blue is sky the"。看似简单的问题,却隐藏着几个容易忽略的细节:
- 字符串前后可能有空格
- 单词之间可能有多个空格
- 需要保留单词内部的原有顺序
最直观的暴力解法是使用语言内置的字符串分割和翻转函数。以C++为例:
cpp复制string reverseWords(string s) {
// 去除前后空格
int start = 0, end = s.size() - 1;
while (start <= end && s[start] == ' ') start++;
while (end >= start && s[end] == ' ') end--;
s = s.substr(start, end - start + 1);
// 分割单词
vector<string> words;
string word;
for (char c : s) {
if (c != ' ') {
word += c;
} else if (!word.empty()) {
words.push_back(word);
word.clear();
}
}
if (!word.empty()) words.push_back(word);
// 翻转并拼接
reverse(words.begin(), words.end());
string result;
for (int i = 0; i < words.size(); i++) {
if (i != 0) result += " ";
result += words[i];
}
return result;
}
这种方法虽然直观,但使用了额外的O(n)空间存储单词列表。在实际面试中,面试官往往会要求我们实现空间复杂度为O(1)的原地解法。
2.2 原地处理的高级技巧
要实现原地翻转,我们可以采用三步翻转法:
- 去除多余空格(快慢指针法)
- 翻转整个字符串
- 翻转每个单词
这种方法的优势在于完全不需要额外空间,所有操作都在原字符串上进行。下面是具体实现:
cpp复制void reverse(string& s, int start, int end) {
while (start < end) {
swap(s[start++], s[end--]);
}
}
string reverseWords(string s) {
// 去除多余空格
int slow = 0;
for (int fast = 0; fast < s.size(); fast++) {
if (s[fast] != ' ') {
if (slow != 0) s[slow++] = ' ';
while (fast < s.size() && s[fast] != ' ') {
s[slow++] = s[fast++];
}
}
}
s.resize(slow);
// 翻转整个字符串
reverse(s, 0, s.size() - 1);
// 翻转每个单词
int start = 0;
for (int end = 0; end <= s.size(); end++) {
if (end == s.size() || s[end] == ' ') {
reverse(s, start, end - 1);
start = end + 1;
}
}
return s;
}
提示:快慢指针法是字符串处理中的常用技巧,slow指针指向下一个有效字符的位置,fast指针遍历原字符串。这种方法可以高效地原地修改字符串。
在实际编码中,边界条件的处理尤为重要。我建议在编写这类代码时,先在纸上画出指针移动的示意图,特别是处理字符串的start和end索引时,很容易出现off-by-one错误。我在教学过程中发现,大约70%的学生第一次尝试时都会在边界条件上犯错。
3. 卡码网:55.右旋转字符串:旋转的艺术
3.1 问题分析与常规解法
右旋转字符串要求我们将字符串的后k个字符移动到字符串前面。例如,输入"abcdefg"和k=2,输出应为"fgabcde"。
最直接的方法是使用额外的空间存储后k个字符,然后将前面的字符后移,最后拼接。这种方法简单直观,但需要O(n)的额外空间:
cpp复制string rightRotate(string s, int k) {
k %= s.size();
string temp = s.substr(s.size() - k);
return temp + s.substr(0, s.size() - k);
}
然而,面试中更常见的要求是实现原地旋转。这时我们可以借鉴翻转字符串里单词的思路,通过三次翻转实现右旋转:
- 翻转整个字符串
- 翻转前k个字符
- 翻转剩下的字符
这种方法的巧妙之处在于完全不需要额外空间:
cpp复制string rightRotate(string s, int k) {
k %= s.size();
reverse(s.begin(), s.end());
reverse(s.begin(), s.begin() + k);
reverse(s.begin() + k, s.end());
return s;
}
3.2 旋转问题的通用解法
旋转问题在字符串处理中非常常见,掌握这种三次翻转的技巧可以解决一系列相关问题。例如,左旋转字符串可以通过以下方式实现:
- 翻转前n个字符
- 翻转剩下的字符
- 翻转整个字符串
这种方法的本质是利用翻转操作改变了字符的相对顺序,而三次精心设计的翻转可以精确控制哪些字符移动到前面或后面。
在实际应用中,我们还需要考虑k大于字符串长度的情况。这时应该对k取字符串长度的模,如代码中的k %= s.size()。这是一个常见的边界条件,容易被忽略但非常重要。
4. 28. 实现 strStr():从暴力匹配到KMP算法
4.1 暴力匹配法的局限
strStr()函数要求在haystack字符串中找出needle字符串第一次出现的位置,如果不存在则返回-1。最直观的方法是暴力匹配:
cpp复制int strStr(string haystack, string needle) {
int m = haystack.size(), n = needle.size();
if (n == 0) return 0;
for (int i = 0; i <= m - n; i++) {
int j = 0;
for (; j < n; j++) {
if (haystack[i + j] != needle[j]) break;
}
if (j == n) return i;
}
return -1;
}
暴力匹配的时间复杂度为O(m*n),在最坏情况下(如haystack为"aaaaa...aaaab",needle为"aaaab")性能很差。这就引出了更高效的KMP算法。
4.2 KMP算法的核心思想
KMP算法通过预处理模式串(needle),构建一个部分匹配表(也称为next数组),利用已匹配的信息避免不必要的回溯,将时间复杂度优化到O(m+n)。
部分匹配表的核心思想是:对于模式串的每个位置,计算其前缀和后缀的最长公共长度。这个值告诉我们当匹配失败时,可以跳过多少字符而不丢失可能的匹配。
构建next数组的过程:
cpp复制vector<int> buildNext(const string& pattern) {
vector<int> next(pattern.size(), 0);
int j = 0;
for (int i = 1; i < pattern.size(); i++) {
while (j > 0 && pattern[i] != pattern[j]) {
j = next[j - 1];
}
if (pattern[i] == pattern[j]) {
j++;
}
next[i] = j;
}
return next;
}
使用next数组进行匹配:
cpp复制int strStr(string haystack, string needle) {
if (needle.empty()) return 0;
vector<int> next = buildNext(needle);
int j = 0;
for (int i = 0; i < haystack.size(); i++) {
while (j > 0 && haystack[i] != needle[j]) {
j = next[j - 1];
}
if (haystack[i] == needle[j]) {
j++;
}
if (j == needle.size()) {
return i - needle.size() + 1;
}
}
return -1;
}
4.3 KMP算法的理解难点
KMP算法最难理解的部分是next数组的构建和使用。我在学习时发现,通过具体例子一步步推导非常有帮助。例如,对于模式串"aabaaf":
- next[0] = 0(单个字符没有真前缀和真后缀)
- next[1] = 1("aa"的前缀"a"和后缀"a"匹配,长度1)
- next[2] = 0("aab"没有相同的前缀和后缀)
- next[3] = 1("aaba"的前缀"a"和后缀"a"匹配)
- next[4] = 2("aabaa"的前缀"aa"和后缀"aa"匹配)
- next[5] = 0("aabaaf"没有匹配的前缀和后缀)
当我们在匹配过程中发现不匹配时,next数组告诉我们模式串可以向右移动多少位而不遗漏可能的匹配。这是KMP算法高效的关键。
注意:KMP算法虽然高效,但在实际面试中,除非明确要求,否则可以先给出暴力解法,再优化到KMP。因为KMP的实现细节较多,在紧张的面试环境下容易出错。
5. 459.重复的子字符串:KMP的巧妙应用
5.1 问题分析与直观解法
这个问题要求判断一个字符串是否可以由它的一个子串重复多次构成。例如,"abab"可以由"ab"重复两次构成,返回true;而"aba"则不能,返回false。
最直观的方法是枚举所有可能的子串长度(从1到n/2),然后检查字符串是否由该子串重复构成:
cpp复制bool repeatedSubstringPattern(string s) {
int n = s.size();
for (int len = 1; len <= n / 2; len++) {
if (n % len != 0) continue;
string pattern = s.substr(0, len);
bool match = true;
for (int i = len; i < n; i += len) {
if (s.substr(i, len) != pattern) {
match = false;
break;
}
}
if (match) return true;
}
return false;
}
这种方法的时间复杂度为O(n^2),在最坏情况下(如质数长度的字符串)性能不佳。
5.2 基于KMP的优化解法
仔细观察可以发现,如果一个字符串由重复子串构成,那么它的next数组会有特殊的性质。具体来说,对于字符串s,如果len % (len - next[len-1]) == 0,则s可以由长度为(len - next[len-1])的子串重复构成。
例如,对于"ababab":
- next数组为[0,0,1,2,3,4]
- len = 6, next[len-1] = 4
- 6 % (6 - 4) = 0 → 可以由"ab"重复构成
实现代码如下:
cpp复制bool repeatedSubstringPattern(string s) {
int n = s.size();
if (n == 0) return false;
vector<int> next(n, 0);
int j = 0;
for (int i = 1; i < n; i++) {
while (j > 0 && s[i] != s[j]) {
j = next[j - 1];
}
if (s[i] == s[j]) {
j++;
}
next[i] = j;
}
return next[n - 1] != 0 && n % (n - next[n - 1]) == 0;
}
这种方法的时间复杂度为O(n),空间复杂度也是O(n),是更优的解法。
5.3 字符串拼接法
还有一种巧妙的解法是将原字符串s拼接成s+s,然后去掉首尾字符,检查原字符串s是否是新字符串的子串:
cpp复制bool repeatedSubstringPattern(string s) {
string doubled = s + s;
return doubled.substr(1, doubled.size() - 2).find(s) != string::npos;
}
这种方法基于一个数学观察:如果s由重复子串构成,那么s一定是s+s的子串(去掉首尾字符后)。虽然看起来时间复杂度较高,但实际运行效率不错,代码也非常简洁。
6. 字符串处理的经验总结
经过这四个问题的练习,我总结了一些字符串处理的通用技巧和注意事项:
-
双指针技巧:快慢指针用于原地修改字符串,左右指针用于翻转或比较操作。这是字符串处理中最常用的技巧之一。
-
边界条件:字符串问题特别容易在边界条件上出错,如空字符串、单个字符、前后空格等。编写测试用例时要特别注意这些情况。
-
KMP算法的应用:虽然KMP算法理解起来有难度,但一旦掌握,可以解决许多字符串匹配问题。建议通过具体例子一步步推导next数组的构建过程。
-
空间复杂度优化:在面试中,往往更看重空间复杂度优化的解法。即使给出了使用额外空间的解法,也要思考能否实现原地操作。
-
字符串旋转的通用解法:三次翻转法是解决旋转类问题的通用方法,值得牢记。
在实际开发中,字符串处理无处不在,从简单的用户输入验证到复杂的文本分析都会用到这些技巧。掌握这些基础算法不仅能帮助我们通过技术面试,更能提升日常开发中的问题解决能力。
