Reverse Vowels of a String — 투 포인터 `O(n)` 스왑으로 모음만 뒤집기

스터디·4분 읽기
시리즈#알고리즘· 6/36
전체 보기
  1. 01Merge Strings Alternately — 단일 루프로 `O(n + m)` 교차 병합
  2. 02Increasing Triplet Subsequence — `O(n)` 시간, `O(1)` 공간으로 푸는 그리디 패턴
  3. 03Move Zeroes — 쓰기 포인터 하나로 `O(n)`/`O(1)` 제자리 재배치
  4. 04String Compression — 읽기/쓰기 포인터로 `O(n)`/`O(1)` 제자리 압축
  5. 05Product of Array Except Self — 나눗셈 없이 `O(n)`/`O(1)`로 나머지 곱 구하기
  6. 06Reverse Vowels of a String — 투 포인터 `O(n)` 스왑으로 모음만 뒤집기읽는 중
  7. 07Can Place Flowers — `O(n)` 그리디 스캔으로 인접 금지 배치 판정
  8. 08시간 복잡도와 공간 복잡도 완전 정복 — Big-O 표기법부터 실전 분석까지
  9. 09Greatest Common Divisor of Strings — `GCD`로 `O(n + m)` 문자열 최대 공약 패턴
  10. 10Container With Most Water — 투 포인터 `O(n)`로 최대 넓이 한 번에 찾기
  11. 11Is Subsequence — 투 포인터 `O(|s| + |t|)`로 부분 수열 판정
  12. 12Richest Customer Wealth — 2차원 배열에서 행 합의 최댓값 찾기
  13. 13Running Sum of 1d Array — 누적합의 가장 기본 형태
  14. 14Roman to Integer — 감산 규칙을 한 번의 스캔으로 처리하기
  15. 15Palindrome Number — 문자열 없이 절반만 뒤집는 숫자 팰린드롬 판정
  16. 16Max Number of K-Sum Pairs — 정렬 `O(n log n)`에서 해시 `O(n)`까지 최대 페어 수 세기
  17. 17Ransom Note — 문자 개수로 문자열 구성 가능 여부 판정하기
  18. 18Middle of the Linked List — slow/fast 포인터로 중간 노드 찾기
  19. 19Max Consecutive Ones — 한 번의 스캔으로 가장 긴 1 구간 찾기
  20. 20Find Numbers with Even Number of Digits — 자릿수 개수의 짝수 여부 세기
  21. 21Merge Sorted Array — 뒤에서부터 채우는 제자리 병합
  22. 22Duplicate Zeros — 고정 길이 배열을 오른쪽에서부터 제자리 갱신하기
  23. 23Squares of a Sorted Array — 절댓값 기준 투 포인터로 `O(n)` 정렬 유지하기
  24. 24Valid Mountain Array — 올라갔다가 내려오는 배열 판별하기
  25. 25Check If N and Its Double Exist — 어떤 값과 그 두 배가 함께 있는지 확인하기
  26. 26Remove Duplicates from Sorted Array — 정렬 배열을 고유 값 구간으로 압축하기
  27. 27Remove Element — 앞쪽 유효 구간으로 압축하는 제자리 제거
  28. 28Replace Elements with Greatest Element on Right Side — 오른쪽 최댓값으로 바꾸기
  29. 29Sort Array By Parity — 짝수를 앞쪽으로 모으기
  30. 30Height Checker — 기대 순서와 다른 위치 세기
  31. 31Third Maximum Number — 세 변수로 상위 3개를 추적해 찾기
  32. 32Reorder Data in Log Files — 문자 로그만 정렬하고 숫자 로그는 순서 유지하기
  33. 33Valid Palindrome — 투 포인터로 영숫자만 비교하는 팰린드롬 판정
  34. 34Find All Numbers Disappeared in an Array — 음수 마킹으로 `O(n)`/`O(1)` 누락 숫자 찾기
  35. 35Most Common Word — 금지어를 제외하고 가장 자주 나온 단어 찾기
  36. 36Group Anagrams — 정렬 키와 문자 개수 키로 애너그램 묶기

모음만 뒤집기, 어떤 문제인가요?

LeetCode 345번 문제는 이렇게 주어집니다.

문자열 s가 주어졌을 때, 모음(vowel)만 순서를 뒤집은 문자열을 반환하세요. 모음은 a, e, i, o, u이며 대문자도 포함합니다.

예시는 이렇습니다.

  • s = "hello""holle" (모음 e, o → 뒤집으면 o, e)
  • s = "leetcode""leotcede" (모음 e, e, o, e → 뒤집으면 e, o, e, e)

제약은 다음과 같습니다.

  • 1 <= s.length <= 3 * 10^5
  • s는 ASCII 문자로만 구성

자음은 제자리에 둔 채 모음의 순서만 양쪽 끝에서부터 교환하는 것이 핵심입니다. 이 글에서는 모음을 따로 모아 뒤집는 방식부터, 투 포인터로 제자리 스왑하는 방식까지 비교합니다. 시간·공간 복잡도 표기가 익숙하지 않다면 시간 복잡도와 공간 복잡도 완전 정복을 먼저 읽어 보시는 것을 권합니다.

Phase 1. 모음을 따로 모아 뒤집기

가장 직관적인 접근은 모음만 뽑아서 뒤집은 뒤, 원래 자리에 다시 채우는 것입니다.

fun reverseVowels(s: String): String {
    val vowels = "aeiouAEIOU".toSet()
    val collected = s.filter { it in vowels }.reversed()

    val result = StringBuilder()
    var vi = 0
    for (ch in s) {
        if (ch in vowels) {
            result.append(collected[vi++])
        } else {
            result.append(ch)
        }
    }
    return result.toString()
}

s = "leetcode"를 따라가 봅시다.

  • 모음 수집: ['e', 'e', 'o', 'e'] → 뒤집기: ['e', 'o', 'e', 'e']
  • 원본을 훑으면서 모음 자리에 순서대로 채움 → "leotcede"

로직은 명확하지만, 모음을 담는 리스트와 뒤집은 결과에 O(n) 추가 공간이 필요합니다.

Phase 2. 투 포인터 스왑 — 추가 공간 없이

모음을 따로 모을 필요 없이, 양쪽 끝에서 모음을 찾아 바로 교환하면 됩니다.

fun reverseVowels(s: String): String {
    val chars = s.toCharArray()
    val vowels = "aeiouAEIOU".toSet()
    var left = 0
    var right = chars.size - 1

    while (left < right) {
        while (left < right && chars[left] !in vowels) left++
        while (left < right && chars[right] !in vowels) right--
        if (left < right) {
            val temp = chars[left]
            chars[left] = chars[right]
            chars[right] = temp
            left++
            right--
        }
    }
    return String(chars)
}

같은 입력 s = "leetcode"를 따라가 봅시다.

초기: ['l','e','e','t','c','o','d','e']
       L →                         ← R

1회: left=1('e'), right=7('e') → 스왑 → ['l','e','e','t','c','o','d','e']
     (같은 문자끼리 스왑이라 변화 없음)

2회: left=2('e'), right=5('o') → 스왑 → ['l','e','o','t','c','e','d','e']

3회: left=3, right=4 → 둘 다 자음, left와 right가 교차 → 종료

결과: "leotcede"

leftright는 각각 한 방향으로만 이동하므로, 전체 이동 횟수의 합은 n을 넘지 않습니다. 시간 O(n), 추가 공간 O(1)입니다.

참고: Kotlin(JVM)에서 String은 불변이므로 toCharArray()로 복사본을 만들어야 합니다. 이 CharArray는 결과를 만들기 위한 필수 공간이지, 알고리즘이 추가로 요구하는 보조 공간이 아닙니다. 투 포인터 자체가 쓰는 추가 변수는 left, right, temp뿐이므로 O(1)입니다.

두 풀이를 다시 비교

풀이 시간 공간 특징
모음 수집 + 뒤집기 O(n) O(n) 모음 리스트와 뒤집은 결과에 추가 공간 필요
투 포인터 스왑 O(n) O(1) 양쪽 끝에서 모음을 찾아 바로 교환

마무리

  1. "모음만 뒤집는다"는 조건은 양쪽 끝에서 모음을 찾아 교환하는 투 포인터로 자연스럽게 변환된다 — 자음은 건너뛰고 모음끼리만 스왑하면 됩니다
  2. 투 포인터가 O(1) 추가 공간을 달성하는 핵심은 "수집 없이 즉시 교환" — 모음을 따로 모아 뒤집는 단계를 제거합니다
  3. leftright가 각각 한 방향으로만 움직이므로, 총 이동 횟수는 n을 넘지 않는다 — 안쪽 while이 두 개 있어도 전체 시간은 O(n)입니다

다음으로 읽어볼 글