
“字符串反转”这四个字我几乎每次面试筛人都要拿出来问。它不是那种需要背源码的偏门算法也不是写在简历上就牛得不行的绝活它就是一个特别基础、特别常见同时又特别容易被低估的操作。字符串反转的意思很简单把一串字符的排列顺序倒过来比如把“hello”变成“olleh”。但真正动手写过一遍你会发现这背后牵扯到字符编码、不可变对象、内存操作、边界条件等一系列问题很多人写出的反转代码在普通英文场景下跑得好好的一遇到中文、emoji、组合字符就翻车。这篇博文我想把这件“小事”彻底讲透。从不同语言里的实现方式到时间空间复杂度再到 Unicode 边界陷阱以及面试里常见的变形题最后附上我踩坑踩出来的速查表。无论你是刚学编程的新手还是准备面试的求职者或者纯粹想在项目里写出更稳妥反转逻辑的开发者这篇内容都能直接拿来参考。1. 字符串反转一个看似简单的高频考点的真实面目1.1 字符串反转到底在反转什么从最朴素的定义看字符串反转就是把序列首尾调换给定字符串 s反转后得到新字符串 rev使得 rev[i] s[len - 1 - i]。以“abc”为例反转后就是“cba”这个连没写过代码的人也能理解。但问题藏在一句很不起眼的话里这里的“字符”到底指什么你可以按字节反转也可以按 UTF-16 的代码单元code unit反转还可以按 Unicode 码点code point反转甚至按用户感知的字符字形grapheme cluster反转。对纯 ASCII 字符串来说这四种反转的结果一模一样可一旦混入中文、韩文、emoji、重音符号或者由多个码点组合而成的文字四种反转就可能得到四种不同的结果。我第一次意识到这个问题是在处理一个包含旗帜 emoji 的评论内容时直接调用 Java 的 StringBuilder.reverse()结果把一面完整的旗帜变成了一堆乱码符号。所以面试时如果面试官问“请反转字符串”你最好先反问三个问题字符串是什么编码是否包含非 ASCII 字符允许额外使用 O(n) 空间吗这不是抬杠而是专业性的体现。大部分线上业务走的是 UTF-8 或 UTF-16任何想当然的反转方案都可能破坏用户输入。用搭积木来类比普通英文串是一排相同颜色的积木倒过来毫无风险中文串是每块积木印着不同笔画倒过来积木还是积木问题不大但 emoji 很多是两块积木拼在一起才构成一个有意义的图案你硬把这两块拆开再倒序图案就碎了。1.2 字符串反转的真实应用场景理解了“反转什么”再来看“反转能干嘛”。很多新手觉得这个操作太简单实际项目里好像用不上。其实恰恰相反字符串反转藏在大量业务逻辑和算法题背后。回文判断是最典型的场景。判断一个字符串是不是回文最直白的写法是反转后与原串比较虽然空间效率一般但代码可读性极好。在 Lua、Python 这类脚本语言里s s[::-1]一行就能搞定回文判断很多开源项目真的就这么写的。反转单词顺序也依赖反转。经典的两步法先把整个字符串反转再把每个单词逐个反转回来就能把“I love coding”变成“coding love I”不需要额外拆分单词再拼装的麻烦。同理数组的左旋右旋问题也常用“三步反转法”解决反转在整个算法思想里就像一块万能积木。业务场景里也能见到它的身影。比如简单日志混淆把用户敏感字段反转后再接一段随机串存储虽然没有加密强度但能阻止人眼直接扫到明文再比如 URL 后缀匹配检查把域名或路径反转以后可以用前缀匹配的方式更方便地做规则判断还有大整数相关的题比如 LeetCode 415 字符串相加把两个数字串反转后从低位开始逐位加进位处理会自然很多逻辑也更贴近手算过程。还有一个容易被忽略的价值它是检验编程基本功的试金石。变量声明、循环边界、对象不可变性、字符串缓冲区怎么用这些基础能力都能在一行反转代码里体现出来。一个人写的反转代码是什么水平我基本能判断他的功底到什么程度。2. 不同语言里字符串反转的典型实现2.1 Python切片、reverse、reduce 的取舍Python 社区流传着一句近乎教条的话反转字符串请用切片s[::-1]。这句话在绝大多数情况下是对的。切片操作由解释器在 C 层面完成没有 Python 层循环的开销写法也最简洁。我在处理几万条文本时对比过几种方式的耗时s[::-1]明显快于其它写法而且代码一眼就能看懂。s hello 博客 rev s[::-1] print(rev) # 客博 olleh注意一点Python 3 的 str 是按 Unicode 码点存储的对中文这种 BMP 内的字符切片反转没什么问题但遇到 emoji 这种由两个码元组成的代理对时切片反转会把代理对拆开导致输出里出现乱码。后面第 4 部分会专门讲这个问题。如果不想用切片可以用reversed()配合join()rev .join(reversed(s))reversed()返回一个迭代器不复制中间列表内存上更省一点但多了一次函数调用和 join 的开销可读性和性能都略逊切片。还有一种写法是把字符串转成列表再调用list.reverse()最后 join 回来。这样会多分配一个列表好处是如果你需要原地做其它操作列表更灵活。最不推荐的是用reduce实现反转reduce(lambda x, y: y x, s)。这写法看起来很函数式、很酷但每拿一个字符出来都要做一次字符串拼接字符串是不可变对象拼接会产生新的字符串对象时间消耗在最坏情况下接近 O(n²)。除非你想在面试里展示知识面否则别在业务代码里这么写。2.2 JavaScript数组反转与 Unicode 的坑JavaScript 新手最容易写出的反转代码是这样的const rev s.split().reverse().join();这个写法对 hello 完全没问题但你有机会就打开控制台试一下.split()。你会看到它被拆成了两个单独字符一个显示为问号一个显示为奇怪的符号。原因在于 JavaScript 字符串内部按 UTF-16 存储emoji 这类超出 BMP 的字符占用两个 16 位代码单元合称代理对surrogate pair。split()按代码单元切分等于把一对代理对拦腰砍断反转后 join 回来自然就乱了。稍微好一点的写法是用展开语法或者Array.from它们能识别 Unicode 码点const rev Array.from(s).reverse().join();这样a能正确反转成a因为Array.from按码点迭代。但如果你处理的是“”这种由多个 emoji 通过零宽连接符组合起来的家庭符号按码点反转仍然会把一个完整家庭拆成父亲、母亲、孩子加连接符的奇怪顺序。要真正按用户感知的字符反转需要借助Intl.Segmenterfunction reverseGraphemes(s) { const segments new Intl.Segmenter(undefined, { granularity: grapheme }).segment(s); return Array.from(segments, x x.segment).reverse().join(); }在 Node 16 和现代浏览器里Intl.Segmenter是官方提供的方式不过它也有兼容性要求生产环境用之前要确认运行环境支持情况。2.3 Java/C/C原地反转与字符数组Java 的String是不可变对象你无法直接修改它内部的字符数组所以最常用的反转方式是StringBuilderString rev new StringBuilder(s).reverse().toString();很多教程绝不深入讲这行代码背后的东西但它的实现值得看StringBuilder.reverse()内部操作的是一个可变 char 数组用两个指针从两端向中间遍历并交换字符。这里有个细节JDK 的实现会额外判断高位和低位代理避免在反转时拆开代理对。也就是说在 Java 生态里直接用它反转带 emoji 的字符串效果通常比 JavaScript 的split().reverse()要好。char[] arr s.toCharArray(); for (int i 0, j arr.length - 1; i j; i, j--) { char tmp arr[i]; arr[i] arr[j]; arr[j] tmp; } String rev new String(arr);这个手动双指针版本不依赖 StringBuilder也是 LeetCode 344 的标准解法。C 里可以直接用std::reversestd::string s hello; std::reverse(s.begin(), s.end());std::reverse也是双指针交换的思路对标准库容器和字符串都适用。C 语言没有现成的字符串库函数要自己写void reverse(char* s, int len) { for (int i 0, j len - 1; i j; i, j--) { char tmp s[i]; s[i] s[j]; s[j] tmp; } }但 C 的 char 只占一个字节中文在 UTF-8 编码下占多个字节这个按字节交换的版本会把中文彻底打碎所以你至少得按 UTF-8 的字符边界来交换复杂度立刻上一个台阶。这也是为什么 C 里反转字符串从来不是“一行代码”的原因。3. 反转背后的算法与性能问题3.1 时间复杂度和空间复杂度对比字符串反转的时间复杂度一定是 O(n)因为每个字符至少要移动一次才能到达反转后的位置。这一点上没有争议。有争议的是空间复杂度它取决于你创建了多少额外的东西。实现方式时间复杂度额外空间说明Python 切片[::-1]O(n)O(n)生成新字符串C 层快速Python.join(reversed(s))O(n)O(n)/O(1)迭代器省少量内存JSArray.from(s).reverse()O(n)O(n)生成码点数组再 joinJavaStringBuilder.reverse()O(n)O(n)底层 char[] 原地交换但 String 不可变必然新开C 双指针原地交换O(n)O(1)直接改原字符数组Cstd::reverseO(n)O(1)容器原地交换表格里能看到只有 C/C 这类允许直接修改字符数组的语言才能做到真正意义上的 O(1) 辅助空间反转字符串。Java、Python、JavaScript 里字符串都是不可变对象反转必然产生新字符串所以空间复杂度至少 O(n)。面试时能说出“Java 的 String 不可变StringBuilder 也逃不开新对象分配”比背一句“StringBuilder 效率高”要强得多。还有一点值得提不要在循环里用字符串拼接做反转。比如在 C# 里写for (int i s.Length - 1; i 0; i--) result s[i];C# 的字符串是不可变的每个都会在内存里创建一个新的字符串对象n 次循环就是 n 次分配时间开销几乎逼近 O(n²)。正确做法是使用 StringBuilder 或者先转成 char 数组。3.2 原地反转的经典双指针写法双指针是反转算法里最核心的思想也是面试官最想看到的解法。逻辑很简单一个指针指向头部一个指针指向尾部交换两个位置的元素头部指针向后移尾部指针向前移直到两个指针相遇。public void reverseString(char[] s) { int left 0, right s.length - 1; while (left right) { char tmp s[left]; s[left] s[right]; s[right] tmp; left; right--; } }这个解法的时间复杂度 O(n)空间复杂度 O(1)是最标准的面试答案。如果把它扩展到按 Unicode 码点反转双指针的步进就不能永远是一格了。比如 C 语言按 UTF-8 反转一个字符串你需要确定 left 和 right 各自指向的字符边界还要先交换字符的最小编码长度再移动相应字节数边际情况非常多这也侧面说明为什么“反转字符串”看起来简单实际能考的东西一点都不少。我对双指针这个模式有个感受它几乎是所有“数组类问题”的万金油。反转字符串用它判断回文用它找到容器盛水最多的问题也用它的变体。把双指针在这里练熟后面很多题目都会顺很多。4. 实战中的边界条件与 Unicode 陷阱4.1 中文、emoji、组合字符为什么容易翻车很多入门教程对反转字符串的示例都是 hello、abc 这种纯 ASCII。一到真实业务里用户输入的文本五花八门这时候反转的坑一个接一个。先看中文。在 Python 3 里中文按码点存储切片反转没毛病但在 Java 里中文基本都在 BMP 平面一个 char 就是一个汉字所以 StringBuilder.reverse() 也没毛病可是在 C 语言里一个汉字在 UTF-8 下占用三到四个字节按字节双指针交换就会把所有汉字的字节顺序打乱输出一堆乱码。再看 emoji。这是问题最集中的区域。像 这种字符Unicode 码点为 U1F602超过了 BMP 范围。在 UTF-16 编码里它被拆成两个代理码元0xD83D 和 0xDE02。很多语言的字符串就是按 UTF-16 组织存储的直接按代码单元反转就会变成 DE02 在前、D83D 在后渲染出来是一个荒腔走板的符号。我在项目里遇到过用户昵称反转后出现异常符号定位半天才发现是代理对被拆。还有组合字符情况更隐蔽。比如字母 é 可以写作一个单独的码点 U00E9也可以写成 e (U0065) 加上一个组合重音符号 (U0301)。这两种写法在视觉上看起来一模一样但字符串内容完全不同。如果按码点反转严格来说你保留了两个码点的顺序关系但把它俩的位置调换后重音符号跑到前面了显示效果就破坏掉了。更夸张的是家庭 emoji 这种由父亲、母亲、女孩和零宽连接符组合成的字形即使按码点反转出来的视觉顺序也是反的。还有一个方向性问题阿拉伯语、希伯来语这类从右向左书写的语言本身在渲染时就会自动调整顺序。如果你把它们的逻辑字符串反转后再交给渲染引擎显示效果可能是双重反转反而读起来更别扭。处理国际化文本时这件事要格外留意。4.2 几种稳妥的按用户感知字符反转方案碰到包含 emoji 或组合字符的文本真正的需求往往是“按用户看到的字符顺序反转”。也就是把一个字形grapheme cluster看作一个整体。Python 领域grapheme库是处理这类问题的常用选择import grapheme s a\u0301 # 视觉上是́a rev .join(reversed(list(grapheme.break_units(s)))) print(rev)如果你不想引入第三方依赖可以先按码点反转再对一些已知的组合场景单独修复。但在真实项目里为了一个反转引入一个库的行为要慎重大部分业务场景其实用 Python 的切片就够只有明确遇到 emoji 乱码问题再考虑升级方案。JavaScript 开发者可以用之前提到的Intl.Segmenter这是现代运行环境里最正统的方案。老环境里也有一个变通技巧先反转码点数组中的代理对顺序保留代理对组合虽然不能做到完美的字形分割但对 emoji 是好用的function reverseUnicode(str) { return Array.from(str).reverse().join(); }Java 里可以用BreakIterator.getCharacterInstance()拿到字形边界然后从后向前按边界取子串拼接。这个类的设计初衷是文本编辑场景用来反转虽然有点大材小用但很稳。Go 语言最简单字符串先转成[]rune再反转r : []rune(s) for i, j : 0, len(r)-1; i j; i, j i1, j-1 { r[i], r[j] r[j], r[i] } fmt.Println(string(r))这个方案按码点反转能处理 emoji 的代理对问题因为 Go 的 rune 就是 Unicode 码点。它依然不能处理组合字符的视觉反转但绝大多数需求到码点级已经够用了。我个人的判断标准是这样如果产品只要求把字符串内容倒序展示码点级反转足矣如果涉及多语言用户并且有严格的文本排版要求才需要考虑字形级方案。过度工程化同样是坑毕竟不会有人真的把家里姓名 emoji 拿来反转存库。5. 高频面试变形题与避坑清单5.1 单词反转、反转括号与三步反转法面试里的“字符串反转”从来不只是一道题它有一整个家族。最直接的是 LeetCode 344 反转字符串输入本身就是字符数组允许原地修改解法就是双指针交换。接着是 541 反转字符串 II每计数到 2k 个字符就反转前 k 个考察的是循环控制和对字符串操作的熟练度没什么新算法但对边界条件的掌握要求变高了。557 反转字符串中的单词 III 也很有意思它要求保留单词之间的空格和相对顺序只反转每个单词内部。我用 Python 解法是 .join(word[::-1] for word in s.split( ))注意这里要用split( )而不是默认的split()否则多个连续空格会被合并反转后空格数量就变了。还有一种题很代表性——反转字符串中的单词顺序。给定 the sky is blue期望输出 blue is sky the。标准做法是先反转整个字符串再从前往后把每个单词单独反转回来。也有人用栈来做从后往前扫描碰到单词就压栈最后依次弹出。两种思路一种体现数组翻转功底一种体现栈的应用面试时都可以展开说。三步反转法也值得单独提。它不只用于单词顺序反转还广泛用于数组旋转。比如要求把数组中前 k 个元素移到尾部最优雅的方法就是把整个数组反转、再分别反转两部分。这里的“反转”已经不是目的而是一种手段理解这一点才算真正吃透了反转操作。5.2 我踩过的坑实战速查表踩坑记录比标准答案更值钱因为很多坑是代码正确性无法覆盖的。我把自己踩过、以及帮别人排查过的真实问题整理成了一张速查表直接照着用就行。需求场景推荐写法关键注意点Python 普通文本反转s[::-1]性能最好代码最简洁Python 处理 emojigrapheme库按字形反转不要直接切片JavaScript 普通文本Array.from(s).reverse().join()不要用split()JavaScript 复杂 emojiIntl.Segmenter按字形注意浏览器兼容Java 普通文本new StringBuilder(s).reverse()JDK 会处理代理对比自己写强Java 严格字形级BreakIterator.getCharacterInstance()按边界从后往前取子串C 语言中文文本按 UTF-8 边界手动处理千万别按字节交换Go 语言先转[]rune再双指针能处理码点组合字仍有局限几个真实的教训第一个是写 Java 反转时用s.toCharArray()处理含 emoji 的字符串然后自以为没问题实际上char[]里存的是 UTF-16 代码单元双指针交换时依然会把代理对拆散。第二个是在 Python 里为了性能把字符串 encode 成 UTF-8 字节再反转中文全部乱码这个方案只在纯 ASCII 场景下才成立。第三个是反转前没做空值检查线上处理用户输入直接 NPE后来养成了“先判断 null 或空串再反转”的肌肉记忆。写给要面试的朋友一句实在话面试官不是真想让你在五分钟内徒手实现一个完美支持所有 Unicode 复杂的反转器而是想看你有没有边界意识。你能主动说出“JavaScript 的 split 会拆开代理对”“Java 的 StringBuilder 内部已经处理了代理对”“按字形反转需要额外工具”就已经赢了九成候选人。这也是我在前面不厌其烦讲编码上下文的原因。如果你只是背住一段反转代码遇到变体问题一样手足无措把编码存储、不可变对象、双指针交换这三点串起来之后遇到反转字符串的任何变体你都能接得住。按我个人的习惯写反转前永远先问自己一句“我要反转的是字节、是代码单元、是码点还是用户感知的字形”想清楚这一句比记一百个 API 都有用。这个问题的答案往往也就决定了你的代码在这台机器、这个语言、这批用户面前到底是稳的还是脆的。