☰
字符串算法刷题指南:底层逻辑、双指针与多语言避坑
2026/10/1 13:38:25 网站建设 项目流程

每次刷题刷到字符串,都有不少朋友问我:“这Part不是很简单嘛,不就是字符数组的操作?”但真到了代码随想录Day4,把字符串相关题目系统性过一遍,你会发现字符串专题的水远比想象中深。我当年刷到这一章时,最大的感触是:字符串在算法题里,从来不是一个“简单类型”,而是综合考察指针、边界、内存、库函数熟悉度的试金石。这篇我就结合自己刷题和实际开发里踩过的坑,把字符串这块的底层逻辑、经典操作和多语言避坑点一次讲透。

1. 字符串题目为什么值得单独开一天

字符串出现在算法题里,跟日常业务里处理字符串完全是两码事。业务里你拿Java或Python,调split()、replace()、indexOf(),写完就跑,很少关心底层怎么刨的。但在算法题里,面试官要看的恰恰是底层:你知不知道字符串是连续内存?你知不知道某些语言字符串不可变?你知不知道双指针可以原地解决一大类反转问题?这些才是“代码随想录Day4字符串”真正想让你掌握的。

1.1 算法题里的字符串和业务字符串是两种东西

业务开发中,字符串是“拿来即用”的。比如解析一行CSV,按逗号分割就行了;比如给用户发短信,模板里替换几个变量。但算法题里的字符串,本质是一个字符数组加上若干操作约束。你不仅要处理数据,还得处理数据在内存里的排布方式。一个很典型的例子:C语言里,字符串就是char[],以'\0'结尾,你不知道这个细节,写strcpy、strlen的时候就会出各种越界和错位问题。

再举一个例子:业务里你要把一个字符串逆序,Python一句line[::-1]搞定。但算法题里,面试官会追问:“如果只能原地操作O(1)额外空间呢?”这时候你就要用双指针从两头往中间交换字符。这个思路在Day4里反复出现,贯穿了反转字符串、反转单词、替换空格等好多题目。

1.2 字符串题的核心考点其实就三类

我把代码随想录Day4涉及的字符串题归纳了一下,核心考点翻来覆去就是三类:

  • 指针操作类:反转字符串、反转单词、替换空格、旋转字符串。这类题考验的是双指针、快慢指针,以及“先整体处理、再局部处理”的思路。
  • 字符判定与转换类:判断字母数字、大小写转换、字符串转数字。这类题考验的是ASCII码的敏感度,以及边界条件的处理(比如空串、正负号、溢出)。
  • 匹配与比较类:字符串相等判断、是否包含子串、KMP。这类题表面是字符串问题,本质是“模式匹配算法”的选择。

想明白这三类,再去刷题就有的放矢了。Day4的内容其实不是让你把每个字符串API背下来,而是让你遇到任何字符串题时,能迅速把它归到某个类别,并想到对应的底层解法。

2. 字符串逆序的三个层级:从手写swap到库函数

字符串逆序是Day4里最基础也最常考的操作之一。但我发现不同水平的人写出来的东西完全不同,我总结成三个层级,你可以对照一下自己在哪一层。

2.1 第一层:直接用库函数

这是最快、最稳妥的写法,适合日常开发。

Python:

s = "hello world" reversed_s = s[::-1] print(reversed_s) # dlrow olleh

Java(借助StringBuilder):

String s = "hello world"; String reversed = new StringBuilder(s).reverse().toString();

C++(借助std::reverse):

#include <algorithm> #include <string> std::string s = "hello world"; std::reverse(s.begin(), s.end());

这一层的好处是代码量少、不容易错。但坏处也很明显:你完全没在“处理算法”,你只是在调包。如果面试题里明确要求“不能使用库函数”“空间复杂度O(1)”,这一层就直接废了。

2.2 第二层:手写双指针原地反转

这是算法题最常考的层级。核心思路非常朴素:一头一尾两个指针,互相交换字符,然后向中间靠拢,直到两个指针相遇。

C++实现:

void reverseString(std::string& s) { int left = 0; int right = s.size() - 1; while (left < right) { std::swap(s[left], s[right]); left++; right--; } }

注意这里的几个细节:

  • 循环条件是left < right,不是left <= right。当左右指针相等时,说明已经到中间了,剩下那个字符没必要跟自己交换。
  • 每次交换之后必须更新两个指针,否则就会死循环。
  • 如果不用std::swap,自己手写交换逻辑,千万别忘了用临时变量保存其中一个值。

自己手写交换也可以,就是考你有没有意识到“覆盖”这个问题:

char tmp = s[left]; s[left] = s[right]; s[right] = tmp;

这个操作看着简单,但很多初学者会漏掉第一步,直接s[left] = s[right],结果原字符弄丢了。

2.3 第三层:理解“逆序”还能玩出什么花样

Day4里真正有区分度的题目,不是单纯反转整个字符串,而是“先反转局部、再反转整体”这种组合拳。

最经典的题目就是:反转字符串里的单词顺序。比如输入"the sky is blue",要求输出"blue is sky the"。

这道题的经典解法思路如下:

  1. 先把整个字符串反转,得到"eulb si yks eht"。
  2. 再逐个反转每个单词,就得到了"blue is sky the"。

C++实现:

std::string reverseWords(std::string s) { // 先整体反转 std::reverse(s.begin(), s.end()); int n = s.size(); int idx = 0; for (int i = 0; i < n; i++) { if (s[i] != ' ') { // 找到一个单词的开头 if (idx != 0) { s[idx++] = ' '; } int j = i; while (j < n && s[j] != ' ') { s[idx++] = s[j++]; } // 反转这个单词 std::reverse(s.begin() + idx - (j - i), s.begin() + idx); i = j; } } s.resize(idx); return s; }

这里有个很容易忽略的坑:std::reverse(s.begin() + idx - (j - i), s.begin() + idx),这段代码是通过计算单词长度来确定单词的起始位置。我在第一次写的时候漏掉了idx - (j - i)这个偏移量,导致单词反转的位置完全错了。

这道题的思路,其实就是Day4里反复强调的“整体与局部”的辩证关系。你光会整体反转没用,还得会精准地控制局部边界。

3. 判断字符类型:字母、数字与符号判断的三种姿势

字符串题目里有一类非常基础但高频的操作:判断某个字符是不是字母、数字、空格或其他符号。我见过很多人在这个地方翻车,基本都是因为用了“不够稳”的方法。

3.1 姿势一:ASCII码范围判断(最底层,最通用)

C语言的char在底层就是一个整数,所以要判断一个字符是不是数字,直接看它的ASCII码是否落在数字区间内:

char c = '5'; if (c >= '0' && c <= '9') { printf("是数字\n"); }

字母判断同理:

if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) { printf("是字母\n"); }

这个写法在任何语言里都通用,因为它不依赖任何库函数。缺点是代码啰嗦,而且你要记得字母不仅有大小写两套区间,还要同时处理。

3.2 姿势二:标准库函数(日常开发首选)

C语言提供了<ctype.h>,C++里也可以用<cctype>,Python和Java也有类似的内置方法:

C/C++:

#include <ctype.h> if (isalpha(c)) { ... } // 判断字母 if (isdigit(c)) { ... } // 判断数字 if (isalnum(c)) { ... } // 判断字母或数字 if (isspace(c)) { ... } // 判断空白符

Python:

c = '5' if c.isdigit(): print("是数字") if c.isalpha(): print("是字母") if c.isalnum(): print("是字母或数字")

Java:

char c = '5'; if (Character.isLetter(c)) { ... } if (Character.isDigit(c)) { ... } if (Character.isLetterOrDigit(c)) { ... }

这里有个坑:Python的isalnum()和Java的isLetterOrDigit(),对Unicode字符的判断范围比ASCII大得多。比如Python里'中'.isalnum()返回的是True。这跟C语言的isalnum行为不一样,C的isalnum('中')在默认locale下是不成立的。所以如果你是写跨语言逻辑,一定要搞清楚目标语言的判定范围。

3.3 姿势三:正则表达式(灵活但有性能隐患)

Java、Python、JavaScript这些语言里,判断一个字符串是否由纯字母数字组成,很多人第一反应是写正则:

boolean isAlphanumeric = str.matches("[a-zA-Z0-9]+");
import re pattern = re.compile(r'^[a-zA-Z0-9]+$') if re.match(pattern, "abc123"): print("全是字母数字")

正则的优势是灵活,比如你要判断“字符串是否包含至少一个数字”或“是否不包含特殊字符”,改一下模式就行。但正则的缺点是性能不如直接遍历,尤其是题目要求在一个超长的字符串里做多次判断时,反复编译正则的开销不可忽略。我实际开发中建议:高频小字符串判断用库函数,低频复杂模式匹配才用正则。

4. 字符串转数字:手写atoi的边界处理实录

字符串转数字是Day4里绕不开的一道题,也是面试官特别喜欢的题目。虽然很多语言都有现成的atoi、parseInt、int()函数,但算法题里出现“手写字符串转数字”,考察的点非常集中:空串、正负号、前导空格、溢出。

4.1 基本逻辑与代码实现

手写字符串转数字,核心逻辑其实就三步:

  1. 跳过前导空格(如果有的话)。
  2. 判断正负号。
  3. 逐位累加,同时检查溢出。

C++实现(LeetCode 8题简化版):

int myAtoi(std::string s) { int i = 0, n = s.size(); // 跳过前导空格 while (i < n && s[i] == ' ') { i++; } // 处理正负号 int sign = 1; if (i < n && (s[i] == '+' || s[i] == '-')) { if (s[i] == '-') { sign = -1; } i++; } long long result = 0; while (i < n && isdigit(s[i])) { result = result * 10 + (s[i] - '0'); // 提前溢出判断 if (result > INT_MAX) { return (sign == 1) ? INT_MAX : INT_MIN; } i++; } return (int)(sign * result); }

4.2 边界条件的三种处理方式对比

这里最考验人的地方是溢出处理。我总结了三类选手常见的写法:

处理方式示例优点缺点
直接用更大类型存long long result,最后再判断简单直观,不容易错题目如果要用更高精度,long long也不够
累加时逐位预判判断result > (INT_MAX - digit) / 10不依赖更大类型,鲁棒性最好代码稍复杂,需要理解边界不等式
用C++17之后的std::from_chars直接解析数字,自动判断溢出效率高、代码少算法题里用这个就没意义了,它也是库函数

我个人的建议是:如果在面试/刷题场景,老老实实手写累加 + 提前判断溢出。特别是负数场景,INT_MIN的绝对值比INT_MAX大1(-2147483648 vs 2147483647),不少人在这个边界上栽过跟头。

为了验证这段逻辑,我跑过几组测试用例:

std::cout << myAtoi(" -42") << std::endl; // -42 std::cout << myAtoi("4193 with words") << std::endl; // 4193 std::cout << myAtoi("words and 987") << std::endl; // 0 std::cout << myAtoi("-91283472332") << std::endl; // -2147483648

最后一组-91283472332超出了int范围,正确处理是返回INT_MIN而不是原值。如果你用的是直接赋值给int的写法,这一步就会出问题——因为91283472332已经超过了int的表示范围,会发生未定义行为。

5. 字符串分割与多语言实现的底层差别

字符串分割也是热搜词里出现频率很高的话题。不同语言对split的实现差异非常大,搞不清楚的话,换语言写代码时会非常难受。

5.1 分隔符是单字符还是多字符

Python的split()默认按空白分割,且可以传一个字符串作为分隔符:

line = "a,b,c" print(line.split(',')) # ['a', 'b', 'c'] line2 = "a||b||c" print(line2.split('||')) # ['a', 'b', 'c']

Java的split接收的是一个正则表达式,不是普通字符串。这点特别坑:

String line = "a.b.c"; String[] parts = line.split("."); // 错误!返回空数组

因为.在正则里表示“任意字符”,你要转义才能按字面量分割:

String[] parts = line.split("\\.");

我在帮同事排查过类似的问题,他拿split("|")去分割字符串,结果每个字符都成了独立元素。原因也一样:|是正则里的“或”运算符。

C++的标准库并没有提供现成的split函数,所以通常要自己写。一个稳定版本是:

std::vector<std::string> split(const std::string& s, char delimiter) { std::vector<std::string> tokens; std::string token; std::istringstream tokenStream(s); while (std::getline(tokenStream, token, delimiter)) { tokens.push_back(token); } return tokens; }

这里要留意:std::getline遇到分隔符时,会把分隔符之前的内容读出来,如果两个分隔符紧挨着,会读出空字符串。这个行为跟Python的split不一样(Python默认会丢弃空串,Java的split也会丢弃尾部空串)。算法题里要求精确控制结果时,一定要确认语言行为。

5.2 “每10个字符一组”的分割需求

热搜词里有个很实际的需求:“每10个字符一组,汉字算一个字符,英文字母和数字两个算一个字符”。这个需求在短信服务里特别常见(一条短信一般限制70个字符,纯英文可以到160个字符,中英文混排时就要按权重计数)。

用Python实现大概是这样的:

def split_by_weight(s, limit=10, chinese_weight=1, ascii_weight=2): result = [] current = "" current_weight = 0 for ch in s: weight = chinese_weight if '\u4e00' <= ch <= '\u9fff' else ascii_weight if current_weight + weight > limit: result.append(current) current = "" current_weight = 0 current += ch current_weight += weight if current: result.append(current) return result

这种按“权重计长”的分割,核心逻辑是:先判断新字符的权重,如果加上会超限就换下一组。这里要特别注意:如果一个字本身就是2分,当前组已经9分了,再放一个2分的字会到11分,超限,应该换到新组。但如果你提前判断成current_weight + weight >= limit,可能就会在刚好10分时强制换行,跟需求不符。所以边界条件到底是>还是>=,取决于产品需求,一定要先确认。

6. 字符串相等比较的暗坑:内容与引用的区别

“字符串比较是否相等”这块,不同语言的表达方式完全不同,而且坑很深。如果你是写Java或C#出身,换到Python或JavaScript时特别容易用错。

6.1 Java和C#的“引用 vs 内容”陷阱

Java里判断字符串相等,绝对不能写成==,因为==比较的是引用地址,不是内容:

String a = "abc"; String b = new String("abc"); System.out.println(a == b); // false,引用的对象不同 System.out.println(a.equals(b)); // true,内容相同

更坑的是,如果两个字符串都是字面量,Java编译器可能把它们放到字符串常量池里,==结果反而是true:

String a = "abc"; String b = "abc"; System.out.println(a == b); // true,因为指向常量池里的同一个对象

这是初学者最容易懵的地方。同样是==,结果一会儿true一会儿false,完全取决于对象的创建方式。所以我在写Java代码时,字符串比较一律用equals(),只有需要判断“是否同一个对象”时才用==。

C#的字符串比较也类似,但C#的==运算符被重载成了内容比较,所以直接写==反而更自然。C++的std::string重载了==,比较的也是内容,而C风格字符串char*用==则是地址比较。每换一种语言,都要重新确认一次语言语义。

6.2 Python和JavaScript的比较方式

Python里字符串比较用==就是内容比较,非常符合直觉:

a = "abc" b = "".join(["a", "b", "c"]) print(a == b) # True

但Python里有个“小字符串驻留”的机制,某些短字符串会复用对象,导致is的结果偶尔是True。比如:

a = "abc" b = "abc" print(a is b) # Python实现中可能为True,因为短字符串被驻留了

但这只是实现细节,Python官方并不保证所有字符串都会被驻留。你如果依赖is去判断字符串相等,就是踩进了实现细节的坑。字符串内容比较请一律用==。

JavaScript的字符串比较也有自己的怪癖,就是==和===的区别。==会做类型转换,"1" == 1是true;===要求类型相同,"1" === 1是false。字符串跟字符串比较时两者差别不大,但涉及数字字符串时一定要意识到底层会做隐式转换:

const a = "123"; console.log(a === 123); // false console.log(a == 123); // true,隐式转换

6.3 反向:忽略大小写的比较

算法题里还经常遇到“忽略大小写比较字符串”的需求。最可靠的方式是统一转成大写或小写后再比较:

a = "Hello" b = "hello" print(a.lower() == b.lower()) # True

但要小心大小写转换在某些语言里会影响字符个数。德语里ß转大写后会变成SS,两个字符;中文没有大小写问题,但土耳其语里有特殊的i处理规则。算法题里如果明确走ASCII范围,可以直接用库函数或在ASCII码上加偏移,避免掉进locale的坑。

7. 多语言字符串实操避坑记录

这部分整理一下我实际刷题和开发中反复踩过的坑,有的坑查了半天资料才搞明白,写出来给大家省点时间。

7.1 C/C++:字符数组与指针的灵魂拷问

C语言里的字符串有两种表示方式,一个是字符数组,一个是字符指针:

char arr[] = "hello"; // 可修改,栈上分配 char* ptr = "hello"; // 字符串字面量,常量区,不可修改

ptr指向的是只读内存,如果你尝试ptr[0] = 'H',程序会直接崩溃。很多初学者在这个地方吃了大亏:明明编译没问题,运行时就是段错误。原因是修改了只读常量区。

另外,用char[]时要注意说好的“数组名是常量指针”,不能写arr = ptr这种赋值。有些同学把字符数组传给函数以后,以为函数里能直接str = newStr,这是不允许的;正确做法要么用strcpy逐字符复制,要么改用std::string。

C++里还有个经典错误是sizeof和strlen的混用:

char str[] = "hello"; sizeof(str); // 6,包括结尾的'\0' strlen(str); // 5,不包括'\0'

你在处理二进制数据或加密字符串时,如果不小心把'\0'也算进去,长度就会差1,导致最后一位被截断或多余一个空字符。

7.2 Python:字符串不可变导致的惯性错误

Python的字符串是不可变对象,这是非常核心的约束。很多从C++转过来的人会下意识地写:

s = "hello" s[0] = 'H' # TypeError: 'str' object does not support item assignment

这行代码直接报错。正确做法是生成新字符串:

s = "hello" s = 'H' + s[1:]

或者用replace、join等操作。你可能会觉得这种设计很笨拙,但不可变带来的好处是线程安全和哈希性能稳定,这也是Python字符串可以被用作字典key的原因。

另一个常见问题是“字符串直接赋值更改”的误解。热搜词里有个“python字符串直接赋值更改”,大概是有人想给字符串重新赋值:

s = "hello" s = "world" # 这是合法的,s现在指向新的字符串对象

这没问题,但要明白的是:这改变了s的引用,而不是在原有内存上修改内容。旧字符串对象如果没有其他引用,会被垃圾回收。

7.3 Java:getBytes()、拼接与编码的连环坑

Java里有个高频操作是将字符串转成字节数组:

byte[] bytes = "hello".getBytes();

但这行代码隐藏了一个坑:getBytes()使用的默认字符集取决于运行环境。如果你的代码部署到不同操作系统的服务器上,默认字符集可能是UTF-8,也可能是GBK甚至ISO-8859-1,导致同样的字符串转出来字节不同。我在跨平台对接接口时踩过这个坑,排查到最后发现是自己用了无参getBytes()。正确做法是指定字符集:

byte[] bytes = "hello".getBytes(StandardCharsets.UTF_8);

Java的字符串拼接也是常被问到的考点。用+拼字符串,在循环里会产生大量中间对象,性能很差。至少要用StringBuilder:

StringBuilder sb = new StringBuilder(); for (int i = 0; i < 10000; i++) { sb.append(i); } String result = sb.toString();

7.4 模板字符串的便利与陷阱

热搜词里提到“模板字符串”,这个概念在JavaScript和Python里都很流行。JavaScript是这样用的:

const name = "张三"; const greeting = `你好,${name}!`;

Python的f-string类似:

name = "张三" greeting = f"你好,{name}!"

模板字符串虽然方便,但有一个值得警惕的点:不要在里面嵌入过于复杂的表达式。我在代码评审里见过别人的代码,${a.b.c.d.map(...).join(',')},一长串逻辑全塞进模板字符串里,阅读体验极差。模板字符串适合简单变量插值,复杂逻辑请在外部先算好再传进去。

8. 实际开发中的字符串处理扩展建议

刷完Day4的题目之后,我建议你在实际项目里多做一步,把算法题里的思路应用过来,而不只是停留在刷题层面。

8.1 二进制场景下的“字符串”处理

很多协议解析场景里,数据不是文本而是二进制字节。这时候用字符串函数要特别小心。比如一个HTML页面里可能有一个字符占用多个字节的UTF-8编码,strlen计算的是字节数,不是字符数;如果直接按字节截取,可能会把一个多字节字符从中间切断,生成乱码。

实际项目里做“按字符截取”时,优先使用语言提供的“按字符(code point)迭代”接口:

  • Go:[]rune(s)转成Unicode码点切片
  • Java:s.codePoints().toArray()
  • Python:天然支持Unicode,len(s)统计的就是字符数
  • C++:至少用std::wstring或配合UTF-8库

C++里有个很常见的错误是拿std::string当Unicode字符串用,然后发现中文输出变乱码。std::string本质是字节序列,不关心编码;你需要自己保证操作的是完整编码序列。

8.2 字符串处理性能优化的三个方向

算法题里经常要求时间和空间的最优解,实际开发里同样会遇到长文本处理的性能瓶颈。我总结优化方向有三个:

  • 减少不必要的复制:不要用+在循环里拼字符串,选对容器(如C++的std::string::reserve预留容量、Java的StringBuilder、Python的join)。
  • 避免重复扫描:能用一次遍历完成多个判断就别写多个循环。比如同时判断“是否有数字”和“是否只有字母数字”,一个循环就够了。
  • 对不可变字符串做“批量修改”时,先转成可变结构:Python里可以先list(s)再改,改完再''.join(list)。

8.3 刷题之后怎么沉淀

Day4的字符串题目刷完之后,我建议你做两件事:

第一,把每次做错的题整理成一张“边界条件表”。比如字符串反转的边界是空串和单字符;字符串转数字的边界是正负号、前导空格和溢出;字符串相等的边界是空串和大小写。把这些边界列出来,下次写字符串代码时对照检查,比盲目刷题高效得多。

第二,用至少两种语言实现同一道题。我自己的体验是:用C++写一遍能让你体会到内存操作的本质,再用Python写一遍能让你体会到高级抽象的便利。两种语言对照下来,你对字符串底层模型的理解会非常扎实。这也是为什么我能一眼看出很多代码问题的原因——不是记忆力好,是不同语言的对比把“共性边界”给暴露出来了。

9. 常见问题速查表

问题典型原因解决办法
Java里==比较字符串结果是false比较的是引用,不是内容用equals()
C语言里修改字符串字面量崩溃字面量存放在只读区用字符数组char[]
Python里修改字符串报TypeError字符串不可变创建新字符串或转list
Java的split(".")结果为空参数是正则,.匹配任意字符转义split("\\.")
strlen和sizeof结果不一致前者不含'\0',后者含明确取的是字节数还是长度
C++中文乱码std::string不感知编码使用std::wstring或UTF-8库
手动转数字溢出累加结果超出int范围提前判断或用更大类型
JS里"1" == 1为true隐式类型转换用===严格比较

10. 我的个人体会

代码随想录Day4字符串这一块,表面上是讲API和算法套路,实际上是在逼你建立“底层心智模型”。我能给你最直接的建议是:做字符串题时,永远先问自己三个问题——这个字符串在内存里长什么样?语言是否允许我原地修改?如果遇到空串或超长串,我的代码会不会崩?想清楚这三个问题,字符串题你已经赢了一半。

我自己早期刷题时,最常犯的错是在反转字符串时忘记更新指针,在转数字时忘记处理溢出。后来每次写完代码,我都会用自己的测试用例集快速验证:空串、单字符、全部空格、正负号混合、超大数字。这套“边界用例清单”一直用到现在,写任何算法题都比别人快一步。

如果你刷到这个Day4时感觉有难度,别灰心。字符串是所有算法专题里最适合“练手感”的:它没有太复杂的数学建模,考察的就是细致和基本功。把上面这些坑都趟一遍之后,你会发现后面刷链表、二叉树时,很多边界处理的思路都是相通的。字符串这个地基打牢了,后面会轻松很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询