目录

题目描述

345. 反转字符串中的元音字母

image-20230312173321571

题意分析

给定字符串 s,只把其中的元音字母按相反顺序重排,其余字符必须留在原来的位置上,返回改造后的字符串。

题面明确写了元音包括 aeiou,且大小写都算。这一句是最容易读漏的约束,样例 "Hello" 里的大写 O 就是专门用来卡这一点的。

输入长度最大 3 × 10^5,字符可以是任意可打印 ASCII,不只是字母。所以判断函数不能写成「不是辅音就是元音」,必须显式列出那十个字符。

把要求换个说法会更清楚:取出所有元音构成的子序列,把它整体倒过来,再按原来的下标依次放回去。非元音位置从头到尾不动,元音位置的集合也不变,变的只是里面装的字符。

边界要覆盖:没有元音(结果与输入相同)、只有一个元音(同样不变)、以及全是元音(等价于整串反转)。

解法:左右指针交换元音

核心思路

按上面那个说法直译,最容易想到的是两趟:第一趟把所有元音和它们的下标收集到数组里,第二趟把元音数组反转后按下标写回。这个做法完全正确,但要额外开一个最长为 n 的数组,也要写两个循环。

瓶颈在于「先全部收集再统一回填」这个中间环节其实是多余的。反转一个序列等价于把首尾元素两两对调,而这件事不需要先把序列物化出来,只要能按顺序找到「从左数第 i 个元音」和「从右数第 i 个元音」,直接就地交换即可。

于是用两个指针从两端相向推进,各自跳过非元音。整趟扫描维持的不变量是:区间 [0, left) 和 (right, n - 1] 里的元音都已经落到最终位置上,且这两段里已完成配对的元音数量相同;剩下待处理的部分永远是闭区间 [left, right]。

每轮把 left 停在从左侧数的第一个未处理元音、right 停在从右侧数的第一个未处理元音,交换二者后两个指针各向内移动一格,不变量随之推进一层。当 left 和 right 相遇或交错时,剩余区间里不足两个待配对元音,中间那个(若存在)本来就应该待在原地,直接结束。

判断元音的部分单独抽成一个小函数,把十个字符显式列全,避免在主循环里堆一长串比较。

解题步骤

  • 先把字符串转成字符数组,并令 left = 0、right = n - 1。Java 和 Go 的字符串都不可变,只有转成数组才能就地交换。
  • 外层循环条件是 left < right。用严格小于是因为两个指针指向同一位置时,那个字符没有配对对象,不需要任何操作。
  • 进入循环后先让 left 向右跑,条件写成 left < right && !isVowel(chars[left])。这里必须带上 left < right,否则当右半段全是非元音时 left 会一路冲出数组末尾。
  • 再让 right 向左跑,条件同理写成 left < right && !isVowel(chars[right])。两个内层循环都以「指针停在元音上,或者两指针已经碰头」结束。
  • 两个内层循环跑完后再判一次 left < right,成立才交换。这一次判断不能省:若两个指针在寻找过程中已经碰头,说明剩余区间里没有可配对的两个元音,此时交换会把同一个字符和自己换,虽然值不变但逻辑上已经越过了不变量。
  • 无论是否发生交换,都执行 left++right--。已经配对过的两个位置不再需要考虑,而没配对的情形下两指针已经碰头,加减一只会让外层条件更快失效,不会出错。
  • 循环结束后把字符数组转回字符串返回。

s = "leetcode" 走一遍:数组为 [l, e, e, t, c, o, d, e],left = 0、right = 7。第一轮:left 上的 l 不是元音,前进到 1 停在 e;right 上的 e 是元音,原地不动。left = 1 < right = 7,交换两个 e,内容没变化但配对完成,随后 left = 2、right = 6。第二轮:left = 2 是 e,直接停下;right = 6 是 d,后退到 5 停在 o。left = 2 < right = 5,交换 eo,数组变成 [l, e, o, t, c, e, d, e],随后 left = 3、right = 4。第三轮:left = 3 是 t 不是元音,前进到 4;此时 left 已等于 right,内层条件失效停下;right 的内层循环因为同样的原因一步都不走。中间那次 left < right 判断为假,不交换,接着 left = 5、right = 3,外层条件失效退出。返回 "leotcede",与样例一致。

代码实现

class Solution {
    // 左右指针分别向中间寻找下一个元音,两个指针都停在元音上时再交换。
    public String reverseVowels(String s) {
        char[] chars = s.toCharArray();
        int left = 0;
        int right = chars.length - 1;

        while (left < right) {
            while (left < right && !isVowel(chars[left])) {
                left++;
            }
            while (left < right && !isVowel(chars[right])) {
                right--;
            }

            if (left < right) {
                char swapValue = chars[left];
                chars[left] = chars[right];
                chars[right] = swapValue;
            }

            left++;
            right--;
        }

        return new String(chars);
    }

    private boolean isVowel(char ch) {
        return "aeiouAEIOU".indexOf(ch) >= 0;
    }
}
func reverseVowels(s string) string {
    // 左右指针分别向中间寻找下一个元音,两个指针都停在元音上时再交换。
    chars := []byte(s)
    left, right := 0, len(chars)-1

    for left < right {
        for left < right && !isVowel(chars[left]) {
            left++
        }
        for left < right && !isVowel(chars[right]) {
            right--
        }

        if left < right {
            chars[left], chars[right] = chars[right], chars[left]
        }
        left++
        right--
    }

    return string(chars)
}

func isVowel(ch byte) bool {
    switch ch {
    case 'a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U':
        return true
    default:
        return false
    }
}

复杂度分析

  • 时间复杂度:$O(n)$,left 只会向右走、right 只会向左走,两者移动步数之和不超过 n,每一步的元音判断是常数次比较。
  • 空间复杂度:$O(n)$,来自不可变字符串复制出的字符数组;除此之外只有两个下标变量,若语言支持原地改字符串则为 $O(1)$。

关键点总结

  • 「保持某些位置不动、只把另一些位置上的元素反转」这类题,都可以用相向双指针在原地完成,不必先把目标元素抽出来再回填。
  • 内层的跳过循环一定要带上与外层相同的边界条件,否则一侧全是无关字符时指针会越界,这是相向双指针最常见的崩溃点。
  • 交换前的那次 left < right 复查,本质是在确认「剩余区间里确实还有两个待配对元素」,把它当成不变量的守卫来记比死背模板更牢。
  • 把「是否属于目标集合」抽成独立的判定函数,主循环就只剩指针推进逻辑;集合一变(比如改成数字、改成大写字母)只需要改一处。
  • 面试视角:这题真正的采分点是边界处理和大小写覆盖,写完后主动说明「left、right 各自只走 n 步所以是线性」以及「用集合还是 switch 判元音各有什么权衡」比把代码写快更重要。

易错点总结

  • 错误写法:内层跳过循环写成 while (!isVowel(chars[left])) left++ 而不带 left < right → s = "bcd" 全是辅音时 left 一路走出数组,直接下标越界。
  • 错误写法:判定函数只写小写元音 → s = "Hello" 里的大写 O 不被识别,得到 "Hello" 原样返回,而正确答案是 "Holle"。
  • 错误写法:用「不是辅音字母就当元音」的反向判定 → 题目允许出现 !.、数字等字符,它们会被误判成元音参与交换,位置全乱。
  • 错误写法:两个内层跳过循环跑完后不再复查 left < right 就直接交换 → 指针在寻找途中碰头时会执行一次自己和自己的交换,虽然值没变,但如果交换写法带临时变量或位运算异或,自异或会把该字符清零。
  • 错误写法:把 left++right-- 只写在交换成功的分支里 → 未发生交换时两个指针都不动,外层条件永远成立,直接死循环。
  • 错误写法:外层条件写成 left <= right → 长度为奇数且中点是元音时,中点会和自己配对,再叠加上一条的异或写法就会出错;即便不出错也做了无谓的一轮。
  • 错误写法:对着原字符串用 s.charAt 判断、却把结果写进一个新数组的对应位置 → 交换后再读原串会拿到旧字符,配对关系错乱,必须始终读写同一份数组。
  • 错误写法:先用 s.replaceAll 之类的库函数把元音抽出来反转,再用正则逐个替换回去 → 替换是从左到右按次序生效的,中途的替换结果会被后续匹配再次命中,s = "aA" 这类输入会得到错误结果,而且这条路把双指针这个考点整个绕过了。

相似题目

题目 难度 考察点
151. 反转字符串中的单词 中等 先整体反转再逐词反转,还要压缩多余空格
186. 反转字符串中的单词 II 中等 与 151 同思路但要求原地、额外空间 $O(1)$
344. 反转字符串 简单 最基础的相向双指针交换,可当本题的子过程
541. 反转字符串 II 简单 按 2k 分组,右边界靠 min 合并尾部规则
557. 反转字符串中的单词 III 简单 只反转每个单词内部,单词顺序保持不变
剑指 Offer 58 - I. 翻转单词顺序 简单 与 151 同题,注意首尾和中间的连续空格
补充题 11. 翻转URL字符串里的单词 中等 分隔符换成点号,反转逻辑不变但切分要改