每次刷题刷到字符串,都有不少朋友问我:“这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 ollehJava(借助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"。
这道题的经典解法思路如下:
- 先把整个字符串反转,得到
"eulb si yks eht"。 - 再逐个反转每个单词,就得到了
"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 基本逻辑与代码实现
手写字符串转数字,核心逻辑其实就三步:
- 跳过前导空格(如果有的话)。
- 判断正负号。
- 逐位累加,同时检查溢出。
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时感觉有难度,别灰心。字符串是所有算法专题里最适合“练手感”的:它没有太复杂的数学建模,考察的就是细致和基本功。把上面这些坑都趟一遍之后,你会发现后面刷链表、二叉树时,很多边界处理的思路都是相通的。字符串这个地基打牢了,后面会轻松很多。