
每次刷题刷到字符串都有不少朋友问我“这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 第一层直接用库函数这是最快、最稳妥的写法适合日常开发。Pythons hello world reversed_s s[::-1] print(reversed_s) # dlrow ollehJava借助StringBuilderString 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.hC里也可以用cctypePython和Java也有类似的内置方法C/C#include ctype.h if (isalpha(c)) { ... } // 判断字母 if (isdigit(c)) { ... } // 判断数字 if (isalnum(c)) { ... } // 判断字母或数字 if (isspace(c)) { ... } // 判断空白符Pythonc 5 if c.isdigit(): print(是数字) if c.isalpha(): print(是字母) if c.isalnum(): print(是字母或数字)Javachar 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不依赖更大类型鲁棒性最好代码稍复杂需要理解边界不等式用C17之后的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::vectorstd::string split(const std::string s, char delimiter) { std::vectorstd::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, limit10, chinese_weight1, ascii_weight2): 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编译器可能把它们放到字符串常量池里结果反而是trueString 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 JavagetBytes()、拼接与编码的连环坑Java里有个高频操作是将字符串转成字节数组byte[] bytes hello.getBytes();但这行代码隐藏了一个坑getBytes()使用的默认字符集取决于运行环境。如果你的代码部署到不同操作系统的服务器上默认字符集可能是UTF-8也可能是GBK甚至ISO-8859-1导致同样的字符串转出来字节不同。我在跨平台对接接口时踩过这个坑排查到最后发现是自己用了无参getBytes()。正确做法是指定字符集byte[] bytes hello.getBytes(StandardCharsets.UTF_8);Java的字符串拼接也是常被问到的考点。用拼字符串在循环里会产生大量中间对象性能很差。至少要用StringBuilderStringBuilder 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码点切片Javas.codePoints().toArray()Python天然支持Unicodelen(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字符串不可变创建新字符串或转listJava的split(.)结果为空参数是正则.匹配任意字符转义split(\\.)strlen和sizeof结果不一致前者不含\0后者含明确取的是字节数还是长度C中文乱码std::string不感知编码使用std::wstring或UTF-8库手动转数字溢出累加结果超出int范围提前判断或用更大类型JS里1 1为true隐式类型转换用严格比较10. 我的个人体会代码随想录Day4字符串这一块表面上是讲API和算法套路实际上是在逼你建立“底层心智模型”。我能给你最直接的建议是做字符串题时永远先问自己三个问题——这个字符串在内存里长什么样语言是否允许我原地修改如果遇到空串或超长串我的代码会不会崩想清楚这三个问题字符串题你已经赢了一半。我自己早期刷题时最常犯的错是在反转字符串时忘记更新指针在转数字时忘记处理溢出。后来每次写完代码我都会用自己的测试用例集快速验证空串、单字符、全部空格、正负号混合、超大数字。这套“边界用例清单”一直用到现在写任何算法题都比别人快一步。如果你刷到这个Day4时感觉有难度别灰心。字符串是所有算法专题里最适合“练手感”的它没有太复杂的数学建模考察的就是细致和基本功。把上面这些坑都趟一遍之后你会发现后面刷链表、二叉树时很多边界处理的思路都是相通的。字符串这个地基打牢了后面会轻松很多。