目录

题目描述

917. 仅仅反转字母

题意分析

给一个字符串,把其中的英文字母按顺序整体反转,而所有非字母字符(数字、标点、下划线等)留在原来的下标位置不动。返回改造后的字符串。

题干只有一句话,但藏着两个必须分清的对象:位置内容。非字母字符的位置是固定的骨架,字母则像流水一样在这些骨架的空隙里从两端往中间对调。举例说,"a-bC-dEf-ghIj" 中所有的 - 保持在下标 1、4、8 不动,剩下的字母序列 abCdEfghIj 反转成 jIhgfEdCba 后依次填回空位,得到 "j-Ih-gfE-dCba"

「大小写都算字母且反转时不改变大小写」这一点容易看漏:C 反转后仍然是大写的 C,只是换了个位置。所以判定函数只负责区分「是不是字母」,不做任何转换。

约束里串长最多 100,字符是 ASCII 可见字符且不含引号,规模极小。这说明本题考的不是复杂度,而是双指针在带跳过条件时的边界控制——什么时候推进、什么时候交换、循环怎么终止。

边界:全是非字母时原样返回;只有一个字母时它自己反转还是它自己;空串或长度为 1 直接返回。这些都应当由主循环自然覆盖。

解法:双指针原地交换

核心思路

最直接的想法是分三步:先把所有字母抽出来存进一个列表,反转这个列表,再从左到右扫描原串,遇到字母就从列表里按顺序取一个填回去。这个做法思路清晰、绝不会错,但要额外开一个 $O(n)$ 的容器,还要扫两遍。

瓶颈在于我们把「抽取」和「回填」拆成了两个阶段,中间必须有地方存放。但仔细看反转的本质:反转就是把第一个字母和最后一个字母对调、第二个和倒数第二个对调…… 既然是两两对调,完全可以原地进行,一次扫描搞定,不需要中间容器。

于是用左右双指针:l 从左端出发找下一个字母,r 从右端出发找上一个字母,两者都停在字母上时交换它们,然后各自向内跨一步。非字母字符在这个过程中从未被交换过,位置自然保持不变——它们只是被跳过,而不是被处理

不变量:每轮外层循环开始时,下标区间 [0, l)(r, n-1] 里的字母已经处于最终位置,而 [l, r] 区间内的字母还保持原始相对顺序;所有非字母字符自始至终没有移动过。每次交换把区间两端各一个字母定位好,区间随之收窄,l >= r 时全部完成。

判定「是不是字母」直接用 Character.isLetter(Go 版手写范围判断),因为题目保证输入是 ASCII,判定只需覆盖 a-zA-Z 两段。这一步没有任何隐藏语义,不必引入正则或大小写转换。

实现上用 char[] 而不是反复拼接字符串:Java 与 Go 的字符串都不可变,逐字符改写只能落在字符数组上,最后再一次性构造结果。

解题步骤

  • 转成字符数组,初始化 l = 0r = n - 1:字符串不可变,原地交换必须在数组上做。两个指针分别从两端向内收敛。
  • 外层循环条件 l < r:用严格小于而不是小于等于。两者相等时指向同一个字符,自己和自己交换毫无意义,还会白白多走一轮。
  • 内层推进 l 跳过非字母while (l < r && !isLetter(a[l])) l++。内层条件里必须带上 l < r,否则在 "---" 这种全非字母的串上,l 会一路冲过 r 直到越界。
  • 内层推进 r 跳过非字母:与上一步对称。注意两个内层循环要分开写,不能合并成一个条件——它们各自的推进方向和判定对象都不同。
  • 交换前再判一次 l < r:两个内层循环跑完后,指针可能已经在中间相遇(比如中间那段全是非字母),此时不该交换。这个 if 是防御,也是让 l++r-- 只在真正完成一次交换后才执行。
  • 交换并同时内移swap(a[l], a[r])l++r--。两个指针必须同时移动——只动一个会让下一轮重复处理同一个字母,甚至把刚换好的字符又换回去。
  • 返回 new String(a):一次性构造,避免在循环里做字符串拼接。

s = "a-bC-dEf-ghIj" 走一遍(长度 13,下标 0 到 12)。字符依次是 a - b C - d E f - g h I j

初始 l = 0r = 12

第 1 轮:a[0] = 'a' 是字母,l 不动;a[12] = 'j' 是字母,r 不动。l < r 成立,交换得 j-bC-dEf-ghIal = 1r = 11

第 2 轮:a[1] = '-' 非字母,l 推进到 2(a[2] = 'b' 是字母停下);a[11] = 'I' 是字母,r 不动。交换下标 2 与 11 得 j-IC-dEf-ghbal = 3r = 10。注意下标 1 的 - 被跳过后就再也不会被触碰,它永远留在这里。

第 3 轮:a[3] = 'C' 是字母;a[10] = 'h' 是字母。交换得 j-Ih-dEf-gCbal = 4r = 9

第 4 轮:a[4] = '-' 非字母,l 推进到 5('d');a[9] = 'g' 是字母。交换得 j-Ih-gEf-dCbal = 6r = 8

第 5 轮:a[6] = 'E' 是字母;a[8] = '-' 非字母,r 退到 7('f')。此时 l = 6 < r = 7,交换得 j-Ih-gfE-dCbal = 7r = 6

第 6 轮:l = 7r = 6l < r 不成立,循环退出。返回 "j-Ih-gfE-dCba",与预期一致。

再看内层条件为什么必须带 l < r:对 s = "---"l = 0r = 2 进入循环,若内层写成 while (!isLetter(a[l])) l++l 会一路走到 3 并访问 a[3],直接数组越界。带上 l < rl 停在 2,随后 r 的内层因 l < r 已不成立而不动,最外层的 if (l < r) 也不成立,循环平安结束,原样返回 "---"

代码实现

class Solution {
    public String reverseOnlyLetters(String s) {
        char[] a = s.toCharArray();
        int l = 0;
        int r = a.length - 1;
        while (l < r) {
            while (l < r && !Character.isLetter(a[l])) {
                l++;
            }
            while (l < r && !Character.isLetter(a[r])) {
                r--;
            }
            if (l < r) {
                char tmp = a[l];
                a[l] = a[r];
                a[r] = tmp;
                l++;
                r--;
            }
        }
        return new String(a);
    }
}
func reverseOnlyLetters(s string) string {
    a := []byte(s)
    l, r := 0, len(a)-1
    for l < r {
        for l < r && !isLetter(a[l]) {
            l++
        }
        for l < r && !isLetter(a[r]) {
            r--
        }
        if l < r {
            a[l], a[r] = a[r], a[l]
            l++
            r--
        }
    }
    return string(a)
}

func isLetter(c byte) bool {
    return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}

复杂度分析

  • 时间复杂度:$O(n)$。l 只增、r 只减,两者合起来在数组上走过一遍就相遇;内层的跳过循环推进的是同一对指针,不构成嵌套开销。
  • 空间复杂度:$O(n)$(Java 与 Go 都需要一份 char[] / []byte 副本,因为字符串不可变),交换本身只用一个临时变量。若语言支持可变字符串(如 C 的 char*),可做到 $O(1)$ 额外空间。

关键点总结

  • 「保留某类元素位置不变、只重排另一类」的题,通用手法是让指针跳过不参与的元素而非搬动它们——不动就是最好的保持。
  • 反转等价于两端逐对交换,因此可以原地完成,不需要「先抽取再回填」的中间容器;识别出这一点就省掉一个 $O(n)$ 结构和一趟扫描。
  • 内层跳过循环必须带上 l < r 的越界保护,这是全题最容易崩的一行;全非字母的输入就是它的反例。
  • 交换后两个指针必须同时内移,否则会重复处理同一位置甚至把已完成的交换撤销。
  • 大小写不做转换:判定只回答「是不是字母」,C 换位后依然是 C。混入 toLowerCase 会静默改变输出内容。
  • 面试视角:这题的价值全在边界。写完后主动用 "---""a""ab-" 三个用例口述验证一遍,比多写十行代码更能体现工程素养。

易错点总结

  • 内层跳过循环不带 l < r"---" 会让 l 一路推到下标 3 并访问 a[3],数组越界异常。
  • 交换前不再判一次 l < r"a-"l 停在 0、r 从 1 退到 0,若无这层判断会执行 swap(a[0], a[0]) 并让 l 变 1、r 变 -1,虽然结果侥幸正确,但同类输入 "-a-" 会多做无意义操作,逻辑上不成立。
  • 外层条件写成 l <= r:奇数个字母时中间那个字母会与自己交换,随后 lr 交错,多跑一轮;若内层保护又不严,会连带越界。
  • 交换后只移动一个指针"ab" 中若只 l++,下一轮 l = 1r = 1,外层退出还算侥幸;但 "abc" 中会把已换好的字符再换回去,输出仍是 "abc"
  • 把非字母字符也纳入交换"a-b" 会输出 "b-a"(巧合正确),但 "ab-" 会输出 "-ba",而正确答案是 "ba-"——分隔符的位置被挪动了。
  • 判定函数漏掉大写字母:只判 c >= 'a' && c <= 'z'"a-bC-dEf" 中的 CE 会被当成非字母跳过,输出 "f-bC-dEa" 之类的错误结果。
  • Character.isLetterOrDigit 代替 isLetter"a1b" 中数字 1 会被当作可反转元素,输出 "b1a" 看似正确,但 "ab1" 会输出 "1ba",数字位置被改变,正确答案是 "ba1"
  • 在循环里用字符串拼接构造结果s = s.substring(0,l) + ... 每次都新建字符串,复杂度退化到 $O(n^2)$,且极易把下标算错。
  • 顺手做了大小写转换:加了 toLowerCase"a-bC-dEf-ghIj" 会输出 "j-ih-gfe-dcba",字符内容被篡改。
  • 误以为要反转整个字符串"a-bC" 会输出 "Cb-a",非字母的 - 从下标 1 跑到了下标 2,与题意「非字母不动」直接冲突。

相似题目

题目 难度 考察点
344. 反转字符串 简单 无跳过条件的纯双指针交换,是本题去掉筛选后的骨架
345. 反转字符串中的元音字母 简单 筛选条件从「是字母」换成「是元音」,还要兼顾大小写元音
125. 验证回文串 简单 同样跳过非字母数字,但只比较不交换,且需统一大小写
557. 反转字符串中的单词 III 简单 按空格切段后各段内部反转,考的是段边界的定位
151. 反转字符串中的单词 中等 整体反转再逐词反转,还要压缩多余空格,边界比本题复杂得多
541. 反转字符串 II 简单 每 2k 个字符反转前 k 个,重点是尾部不足 k 时的处理