본문 바로가기
알고리즘/Leetcode

[Leetcode]125. Valid Palindrome

by 응애~ 개발자 2026. 10. 1.
728x90
반응형

📌 한 줄 요약 영문자·숫자만 남긴 소문자 문자열을 만들고, 양 끝에서 안쪽으로 짝지어 비교한다(투 포인터). 정리 단계를 없애고 포인터로 건너뛰며 비교하는 방법도 함께 본다.

이 글에서 다루는 것

  • "정리"와 "비교"를 나눠서 생각하는 사고 과정
  • 왜 절반만 비교해도 되는가 (홀수 길이의 가운데 글자 포함)
  • 이 문제가 왜 투 포인터에 잘 맞는지 (투 포인터 이론은 이 글 참고)
  • Python · JavaScript · Java 코드 (알고리즘, 변수명, 주석 위치를 통일)
  • 코드에 나온 문법: re.sub, replaceAll (JS의 g 플래그 포함), Java의 String.replaceAll
  • 정리한 문자열을 만들지 않는 투 포인터 개선안, 그리고 언어별로 실제로 빨라지는지 직접 잰 결과

1. 문제

  • 문제: 125. Valid Palindrome — https://leetcode.com/problems/valid-palindrome/
  • 요약: 문자열에서 영문자와 숫자만 남기고 대소문자를 구분하지 않을 때, 앞에서 읽으나 뒤에서 읽으나 같은지 true / false로 답한다. 남는 글자가 하나도 없으면 회문으로 본다.
  • 난이도: Easy
  • 유형: 문자열 투 포인터
  • 입력 규모: 문자열 길이는 최대 20만 자 수준이고, 화면에 찍을 수 있는 ASCII 문자로만 이뤄진다.

직접 만든 예시:

s = "Was it a car or a cat I saw?"  →  true   (정리하면 wasitacaroracatisaw)
s = "0P"                              →  false  (숫자 0과 문자 p는 서로 다른 글자)
s = ".,"                              →  true   (정리하면 빈 문자열)

2. 접근

2-1. 회문 판정은 "짝지어 비교"다

회문이면 앞에서 i번째 글자와 뒤에서 i번째 글자가 항상 같다. 그러니 양 끝에서 시작해 안쪽으로 한 쌍씩 비교하다가, 하나라도 다르면 바로 false를 돌려주면 된다.

2-2. 어려운 건 비교가 아니라 "지저분한 입력"이다

이 문제에서 짝지어 비교하는 부분은 쉽다. 손이 가는 건 입력에 섞인 공백·쉼표·물음표 같은 기호와 대소문자다. 비교하는 도중에 매번 "이 글자는 건너뛰어야 하나? 대문자는 어떻게 맞추지?"를 따지면 코드가 금방 복잡해진다.

그래서 일을 둘로 나눴다.

  1. 정리: 소문자로 통일하고, 영문자·숫자가 아닌 글자를 지운다.
  2. 비교: 정리된 문자열에서는 "글자 == 글자"만 본다.

예시로 보면 이렇게 된다.

"Was it a car or a cat I saw?"
  → 소문자로:   "was it a car or a cat i saw?"
  → 영숫자만:   "wasitacaroracatisaw"   (19자)

정리 단계의 순서에 함정이 하나 있다. 반드시 소문자 통일이 먼저이고 필터링이 나중이다. 이유는 5장에서 실제로 돌려 본 결과와 함께 다룬다.

2-3. 절반만 비교하면 충분한 이유

정리된 문자열의 길이를 size라고 하면, 앞에서 i번째 글자의 짝은 뒤에서 i번째 글자이고 인덱스로는 size - i - 1이다. 인덱스가 0부터 시작해서 -1이 붙는다.

s = "Xy-7, yx"를 따라가 보자. 정리하면 xy7yx이고 size = 5다.

i 앞 글자 (i) 뒤 글자 (size - i - 1) 같은가
0 x x ✅
1 y y ✅

size // 2 = 2이므로 2번 비교하고 끝난다. 가운데 7은 자기 자신과 짝이라 비교할 필요가 없다. 길이가 짝수(예: 4)면 정확히 2쌍, 홀수(예: 5)면 가운데 하나를 뺀 2쌍이라서, 둘 다 size // 2(내림)번이면 된다. 나머지 절반을 또 돌면 같은 쌍을 거꾸로 한 번 더 보는 셈이다.

불일치 예시 s = "0P"는 정리하면 0p이고, i = 0에서 0과 p가 달라 바로 false다.

이 접근의 대가도 미리 적어 둔다. 정리된 문자열을 통째로 새로 만든다는 점이다. 이 부분은 6장 리팩토링에서 다시 다룬다.

2-4. 왜 투 포인터인가

투 포인터는 위치를 가리키는 변수 두 개를 두고, 두 위치를 함께 움직이며 푸는 방식이다. 투 포인터 자체의 이론적인 설명은 이 글에서 확인하면 되고, 여기서는 이 문제와 연결되는 부분만 다룬다.

이 문제에 투 포인터가 잘 맞는 이유는 회문의 성질에 있다.

  1. 비교할 짝의 위치가 처음부터 정해져 있다. 앞에서 i번째와 뒤에서 i번째가 짝이다. 한쪽은 맨 앞에서, 다른 한쪽은 맨 뒤에서 출발하면 된다.
  2. 한 쌍을 볼 때마다 두 포인터가 한 칸씩 안쪽으로 온다. 그러다 둘이 만나거나 엇갈리면 모든 짝을 다 본 것이다. 2-3장에서 "절반만 보면 충분하다"고 한 것이 바로 이 종료 조건이다.
  3. 문자열은 양 끝에 O(1)로 접근할 수 있다. 인덱스로 바로 읽을 수 있어서, 뒤쪽 글자를 찾으려고 앞에서부터 세지 않아도 된다.

두 풀이 모두 투 포인터로 볼 수 있다. 다만 포인터를 드러내는 방식이 다르다.

풀이 왼쪽 포인터 오른쪽 포인터 오른쪽을 정하는 방법
1차 (정리 후 비교) i size - i - 1 왼쪽 i에서 계산한다
개선안 (6장) left right 왼쪽과 따로 움직인다

1차 풀이는 정리된 문자열에서 오른쪽 포인터의 위치가 왼쪽 i만 알면 정해지니, 변수를 하나만 두고 size - i - 1로 계산했다.

개선안은 정리하지 않은 원문 위에서 곧바로 비교한다. 왼쪽 포인터는 왼쪽에 있는 기호를 건너뛰고, 오른쪽 포인터는 오른쪽에 있는 기호를 건너뛰는데, 양쪽에 기호가 몇 개씩 있는지는 서로 다르다. 예를 들어 s = "a!!b,a"에서는 왼쪽 포인터가 ! 두 개를, 오른쪽 포인터가 , 한 개를 건너뛴 뒤 가운데 b에서 만난다(직접 돌려서 확인했다). 그래서 두 포인터가 각자 따로 움직여야 하고, 이때부터 변수 두 개가 필요해진다. 이 문제에서 투 포인터가 "필요"해지는 지점이 여기다.

💡 투 포인터가 꼭 있어야 풀리는 문제는 아니다. 정리한 문자열을 t라고 하면, t를 뒤집어서 원본과 같은지 비교해도 풀린다. (Python t == t[::-1], JavaScript [...t].reverse().join(""), Java new StringBuilder(t).reverse(). 세 언어 모두 같은 테스트를 통과시켜 확인했다.) 다만 뒤집은 복사본이 하나 더 생기고(추가 메모리 O(n)), 전체를 뒤집고 나서야 비교가 시작된다. 6장의 개선안은 새 문자열 없이 첫 불일치에서 바로 멈춘다. 즉 이 문제에서 투 포인터는 필수 조건이 아니라, 회문의 대칭 구조에 잘 맞는 도구다.


3. 코드

세 언어 모두 알고리즘, 변수명(replace_string / replaceString, size, i), 주석 위치를 통일했고, 문법은 각 언어에서 쓰는 방식 그대로 썼다. 주석은 "무엇을"이 아니라 "왜"를 적었다.

🐍 Python

import re

class Solution:
    def isPalindrome(self, s: str) -> bool:
        """영문자·숫자만 남겼을 때 앞뒤로 읽어도 같으면 True.

        s: 검사할 문자열 (공백·기호·대소문자가 섞여 있어도 됨)
        """

        # 소문자 통일이 먼저, 필터링이 나중 — 순서를 뒤집으면 [^a-z0-9]가 대문자를 전부 지워 버린다
        replace_string = re.sub(r"[^a-z0-9]", "", s.lower())
        size = len(replace_string)
        # 절반만 돈다 — 양 끝에서 안쪽으로 짝지어 비교하니 나머지 절반은 중복 비교.
        # 홀수 길이의 가운데 글자는 자기 자신과 짝이라 비교하든 건너뛰든 결과가 같다
        for i in range(size // 2):
            # 앞에서 i번째의 짝은 뒤에서 i번째 — 인덱스가 0부터라 -1이 붙는다
            if replace_string[i] != replace_string[size - i -1]:
                return False

        # 불일치가 없었거나 비교할 쌍 자체가 없으면(빈 문자열·1글자) 회문으로 본다
        return True

🟨 JavaScript

/**
 * 영문자·숫자만 남겼을 때 앞뒤로 읽어도 같으면 true.
 *
 * @param {string} s 검사할 문자열 (공백·기호·대소문자가 섞여 있어도 됨)
 */
var isPalindrome = function(s) {
    // 소문자 통일이 먼저, 필터링이 나중 — 순서를 뒤집으면 [^a-z0-9]가 대문자를 전부 지워 버린다
    const replaceString = s.toLocaleLowerCase().replaceAll(/[^a-z0-9]/g, "");
    const size =replaceString.length;
    // 절반만 돈다 — 양 끝에서 안쪽으로 짝지어 비교하니 나머지 절반은 중복 비교.
    // 홀수 길이의 가운데 글자는 자기 자신과 짝이라 비교하든 건너뛰든 결과가 같다
    for (let i = 0; i < size / 2; i++) {
        // 앞에서 i번째의 짝은 뒤에서 i번째 — 인덱스가 0부터라 -1이 붙는다
        if(replaceString[i] != replaceString[size - i - 1]){
            return false;
        }
    }

    // 불일치가 없었거나 비교할 쌍 자체가 없으면(빈 문자열·1글자) 회문으로 본다
    return true
};

☕ Java

class Solution {
    /**
     * 영문자·숫자만 남겼을 때 앞뒤로 읽어도 같으면 true.
     *
     * @param s 검사할 문자열 (공백·기호·대소문자가 섞여 있어도 됨)
     */
    public boolean isPalindrome(String s) {

        // 소문자 통일이 먼저, 필터링이 나중 — 순서를 뒤집으면 [^a-z0-9]가 대문자를 전부 지워 버린다
        String replaceString = s.toLowerCase().replaceAll("[^a-z0-9]","");
        int size = replaceString.length();


        // 절반만 돈다 — 양 끝에서 안쪽으로 짝지어 비교하니 나머지 절반은 중복 비교.
        // 홀수 길이의 가운데 글자는 자기 자신과 짝이라 비교하든 건너뛰든 결과가 같다
        for (int i = 0; i < size / 2; i++) {
            // 앞에서 i번째의 짝은 뒤에서 i번째 — 인덱스가 0부터라 -1이 붙는다
            if(replaceString.charAt(i) != replaceString.charAt(size - i - 1)){
                return false;
            }
        }

        // 불일치가 없었거나 비교할 쌍 자체가 없으면(빈 문자열·1글자) 회문으로 본다
        return true;
    }
}

⚠️ 언어별로 갈리는 점 절반을 도는 반복문의 조건이 언어마다 조금 다르게 동작한다. Python size // 2와 Java size / 2(int끼리 나누기)는 내림이라 홀수 길이에서 가운데 글자를 건너뛴다. 반면 JavaScript의 size / 2는 실수 나눗셈이라 size = 5이면 2.5이고, i < 2.5가 되어 i = 2까지 돈다. 즉 가운데 글자를 자기 자신과 한 번 더 비교한다. 실제로 세어 보니 abcba에서 JS는 3번, Python은 2번 비교했다. 자기 자신과 비교하면 항상 같으니 결과는 바뀌지 않는다. 그래서 코드 주석을 "비교하든 건너뛰든 결과가 같다"로 썼다.

3-1. 코드에 나온 문법 정리

🐍 Python

re.sub(패턴, 바꿀 문자열, 대상)

패턴에 맞는 부분을 전부 다른 문자열로 바꿔서 새 문자열을 돌려준다. 여기서는 바꿀 문자열이 ""라서 "지우기"가 된다.

import re

re.sub(r"[^a-z0-9]", "", "ab, 7!")   # 'ab7'
  • r"..."는 raw 문자열이다. 백슬래시를 이스케이프로 해석하지 않아서 정규식을 쓸 때 관례로 붙인다. 이 패턴에는 백슬래시가 없어서 없어도 동작하지만, 습관으로 붙이는 편이 안전하다.
  • [^a-z0-9]에서 [...]는 문자 하나를 고르는 집합이고, 맨 앞의 ^는 부정이다. 즉 "소문자, 숫자가 아닌 글자 하나"에 맞는다.
  • s.lower()도 원본을 바꾸지 않고 새 문자열을 돌려준다. 문자열은 불변이다.

range(size // 2)

//는 나눗셈의 몫(내림)이다. 5 // 2는 2, /를 쓰면 2.5가 되어 range에 넣을 수 없다(TypeError).

🟨 JavaScript

replaceAll(정규식, "")과 g 플래그

replaceAll에 정규식을 넘길 때는 g 플래그가 필수다. 빼면 예외가 난다.

'ab, 7!'.replaceAll(/[^a-z0-9]/g, "");  // 'ab7'
'ab, 7!'.replaceAll(/[^a-z0-9]/, "");
// TypeError: String.prototype.replaceAll called with a non-global RegExp argument

replace는 g가 없으면 첫 번째 일치만 바꾸지만, replaceAll은 아예 거부한다. 같은 결과를 주는 s.replace(/[^a-z0-9]/g, "")를 쓰는 코드도 많다.

toLocaleLowerCase()와 toLowerCase()

이 풀이에서는 둘의 결과가 같다. 다만 이름 그대로 앞의 것은 로케일을 고려하는 변환이고, 뒤의 것은 로케일과 무관하다. 5장에서 이 차이가 실제로 문제가 되는 경우를 다룬다.

replaceString[i], !=

  • 문자열은 [i]로 글자 하나(길이 1짜리 문자열)를 읽을 수 있다.
  • 글자끼리 비교하는 자리라 !=와 !==의 결과가 같다. 그래도 타입까지 비교하는 !==를 기본으로 쓰는 편이 좋다.

☕ Java

replaceAll(정규식, 바꿀 문자열)

String.replaceAll은 첫 인자를 정규식으로 해석하고, 바뀐 새 String을 돌려준다. String은 불변이라 s.toLowerCase()만 호출하고 결과를 받지 않으면 원본은 그대로다.

  • 이 풀이는 toLowerCase() → replaceAll(...)을 이어 붙여서 결과 하나만 변수에 받는다.
  • replaceAll은 호출할 때마다 내부에서 정규식을 컴파일한다. 이 문제는 한 번만 부르니 영향이 없지만, 반복문 안에서 여러 번 부른다면 Pattern을 미리 만들어 두는 게 낫다.

replaceString.charAt(i), size / 2

  • charAt(i)는 char 하나를 돌려주고, char끼리는 !=로 비교해도 된다. (객체인 String을 !=로 비교하면 안 된다.)
  • int / int는 정수 나눗셈이라 내림이다. 5 / 2는 2.

3-2. 세 언어 대조표

항목 Python JavaScript Java
소문자로 s.lower() s.toLocaleLowerCase() s.toLowerCase()
기호 지우기 re.sub(r"[^a-z0-9]", "", ...) .replaceAll(/[^a-z0-9]/g, "") .replaceAll("[^a-z0-9]", "")
길이 len(x) x.length x.length()
글자 읽기 x[i] x[i] x.charAt(i)
절반 size // 2 size / 2 (실수) size / 2 (정수)
홀수 길이의 가운데 건너뜀 자기 자신과 한 번 더 비교 건너뜀

4. 복잡도

n은 입력 문자열의 길이다.

단계 시간 공간 근거
정리 (소문자화 + 필터링) O(n) O(n) 각각 문자열을 한 번씩 훑고, 결과를 새 문자열로 만든다
비교 O(n) O(1) 최대 n / 2번 비교하고 추가 메모리는 변수 몇 개뿐이다
전체 O(n) O(n) 정리 단계의 복사본이 공간을 결정한다

정리하는 동안 임시 문자열이 두 개(소문자본, 필터링한 결과) 생기지만 상수 배라서 O(n)으로 본다.


5. 흔한 실수

아래는 전부 글에 실린 코드를 일부러 고치거나 특수한 입력을 넣어서 실제로 돌려 본 결과다.

  1. 필터링을 소문자화보다 먼저 한다 — [^a-z0-9]에는 대문자가 들어 있지 않아서 대문자가 전부 지워진다. "ABC"를 먼저 필터링하면 빈 문자열이 되고, "Noon"은 N이 지워져 oon이 되어 false가 나온다(정답은 true).
  2. 정규식에서 숫자를 빠뜨린다 — [^a-z]로 쓰면 숫자가 전부 지워진다. "0P"는 p만 남아 true가 나온다(정답은 false).
  3. 짝의 인덱스에서 -1을 빠뜨린다 — size - i - 1이 아니라 size - i로 쓰면 첫 비교부터 범위를 벗어난다. 이때 실패하는 모양이 언어마다 다르다.
    • Python: "aa"에서 IndexError
    • Java: "aa"에서 StringIndexOutOfBoundsException
    • JavaScript: 예외가 없다. 범위를 벗어난 읽기는 undefined를 돌려주고, "a" != undefined가 참이라 "aa"도 false, 심지어 글자 하나인 "a"도 false가 나온다. 예외 없이 답만 틀려서 셋 중 가장 찾기 어렵다.
  4. 글자가 하나도 안 남는 입력을 false로 처리한다 — ".,"는 정리하면 빈 문자열이고, 이 문제에서는 회문이다. 이 풀이는 반복문을 한 번도 돌지 않고 true로 빠져나오는 구조라서 자연스럽게 맞는다.
  5. toLowerCase()의 로케일 (Java) — toLowerCase()는 기본 로케일을 따른다. 터키어 로케일(tr_TR)에서는 I가 점 없는 ı로 바뀌고, 이 글자는 [a-z]가 아니라서 정규식에 지워진다. 실제로 "Iaba"(정답 false)가 true로 나왔다. 기본 로케일이 en_US인 일반적인 환경에서는 정상으로 나오니 제출에는 문제가 없겠지만, 알고 있으면 좋다. toLowerCase(Locale.ROOT)를 쓰면 로케일과 무관해진다.
    • JS의 toLocaleLowerCase()는 이 환경에서는 재현되지 않았다. Node를 tr-TR 로케일로 띄워도 인자 없이 부르면 I가 i로 바뀌었다. 다만 'I'.toLocaleLowerCase('tr')처럼 로케일을 직접 넘기면 ı가 나온다. 굳이 로케일 함수를 쓸 이유가 없으니 toLowerCase()가 무난하다.
  6. replaceAll에 g 없는 정규식을 넘긴다 (JS) — 3-1장에서 본 것처럼 TypeError가 난다.

6. 리팩토링 노트

1차 풀이 → 왜 아쉬운가

1차 풀이는 정답이고 짧다. 아쉬운 점은 둘이다.

  • 정리된 문자열을 통째로 새로 만든다. 추가 메모리가 O(n)이다. 그리고 첫 글자부터 짝이 안 맞는 입력이어도 정리(O(n))를 끝까지 먼저 해야 비교를 시작할 수 있다.
  • 기호를 "지운다"는 방식이 정규식에 기대고 있다. 5장의 실수 1·2·6이 전부 정규식 주변에서 나왔다.

개선안: 정리 없이, 포인터로 건너뛰며 비교

문자열을 새로 만들지 않고 양 끝에 포인터 left, right를 세운다. 영숫자가 아닌 글자는 포인터를 움직여 건너뛰고, 소문자 맞추기는 비교하는 순간에만 한다. 포인터가 왜 두 개여야 하는지는 2-4장에서 다뤘다.

🐍 Python

class Solution:
    def isPalindrome(self, s: str) -> bool:
        """영문자·숫자만 남겼을 때 앞뒤로 읽어도 같으면 True. 정리된 문자열을 따로 만들지 않고 양 끝 포인터로 바로 비교한다.

        s: 검사할 문자열 (공백·기호·대소문자가 섞여 있어도 됨)
        """

        left, right = 0, len(s) - 1

        while left < right:
            # 영숫자가 아니면 건너뛴다 — 안쪽 while에도 left < right 가드가 필요하다
            # (기호만 있는 입력에서 포인터가 범위를 벗어난다)
            while left < right and not s[left].isalnum():
                left += 1
            while left < right and not s[right].isalnum():
                right -= 1

            # 대소문자는 비교하는 순간에만 맞춘다 — 문자열 전체를 소문자로 복사하지 않아도 된다
            if s[left].lower() != s[right].lower():
                return False

            left += 1
            right -= 1

        # 포인터가 만나거나 엇갈릴 때까지 불일치가 없었다면 회문 (빈 문자열·기호만 있는 입력 포함)
        return True

🟨 JavaScript

/**
 * 영문자·숫자만 남겼을 때 앞뒤로 읽어도 같으면 true. 정리된 문자열을 따로 만들지 않고 양 끝 포인터로 바로 비교한다.
 *
 * @param {string} s 검사할 문자열 (공백·기호·대소문자가 섞여 있어도 됨)
 */
var isPalindrome = function(s) {
    const isAlnum = (ch) => /[a-z0-9]/i.test(ch);

    let left = 0;
    let right = s.length - 1;

    while (left < right) {
        // 영숫자가 아니면 건너뛴다 — 안쪽 while에도 left < right 가드가 필요하다
        // (기호만 있는 입력에서 포인터가 범위를 벗어난다)
        while (left < right && !isAlnum(s[left])) {
            left++;
        }
        while (left < right && !isAlnum(s[right])) {
            right--;
        }

        // 대소문자는 비교하는 순간에만 맞춘다 — 문자열 전체를 소문자로 복사하지 않아도 된다
        if (s[left].toLowerCase() !== s[right].toLowerCase()) {
            return false;
        }

        left++;
        right--;
    }

    // 포인터가 만나거나 엇갈릴 때까지 불일치가 없었다면 회문 (빈 문자열·기호만 있는 입력 포함)
    return true;
};

☕ Java

class Solution {
    /**
     * 영문자·숫자만 남겼을 때 앞뒤로 읽어도 같으면 true. 정리된 문자열을 따로 만들지 않고 양 끝 포인터로 바로 비교한다.
     *
     * @param s 검사할 문자열 (공백·기호·대소문자가 섞여 있어도 됨)
     */
    public boolean isPalindrome(String s) {

        int left = 0;
        int right = s.length() - 1;

        while (left < right) {
            // 영숫자가 아니면 건너뛴다 — 안쪽 while에도 left < right 가드가 필요하다
            // (기호만 있는 입력에서 포인터가 범위를 벗어난다)
            while (left < right && !Character.isLetterOrDigit(s.charAt(left))) {
                left++;
            }
            while (left < right && !Character.isLetterOrDigit(s.charAt(right))) {
                right--;
            }

            // 대소문자는 비교하는 순간에만 맞춘다 — 문자열 전체를 소문자로 복사하지 않아도 된다
            if (Character.toLowerCase(s.charAt(left)) != Character.toLowerCase(s.charAt(right))) {
                return false;
            }

            left++;
            right--;
        }

        // 포인터가 만나거나 엇갈릴 때까지 불일치가 없었다면 회문 (빈 문자열·기호만 있는 입력 포함)
        return true;
    }
}

안쪽 while의 left < right 가드가 이 방식의 새 함정이다. 이 가드를 빼고 Python으로 돌려 보니 "..."와 ".," 같은 기호만 있는 입력에서 IndexError가 났다. 건너뛰다가 포인터가 문자열 끝을 넘어가기 때문이다.

문법 몇 가지:

  • Python str.isalnum(), Java Character.isLetterOrDigit()은 유니코드 글자(한글, 악센트 문자 등)도 참으로 판단한다. 이 문제의 입력은 ASCII뿐이라 1차 풀이와 결과가 같지만, 입력 범위가 넓어지면 달라진다. JS는 내장이 없어서 /[a-z0-9]/i.test(ch)로 ASCII만 확인했다.
  • Java Character.toLowerCase(char)는 로케일과 무관하다. 그래서 5장의 터키어 로케일 함정을 피한다. tr_TR로 띄워서 "Iaba"가 false로 나오는 것을 확인했다.
  • 변수명은 left, right로 세 언어를 통일했다.

실제로 빨라지나?

직접 재 봤다. 입력은 약 19.9만 자(제약의 최대 수준)이고, 기호와 대소문자가 섞여 있다. 각 조건에서 중앙값을 구했고, 이걸 두 번 반복해서 나온 범위다.

입력 언어 1차 (정리 후 비교) 개선안 (투 포인터)
회문 (끝까지 비교) Python 19.6 ~ 21.0ms 23.1 ~ 43.2ms
회문 (끝까지 비교) JavaScript 6.9 ~ 9.9ms 10.1 ~ 10.2ms
회문 (끝까지 비교) Java 5.0 ~ 6.2ms 1.1ms
첫 글자 쌍부터 불일치 Python 12.6 ~ 13.6ms 0.001ms
첫 글자 쌍부터 불일치 JavaScript 6.0 ~ 9.0ms 1µs 미만
첫 글자 쌍부터 불일치 Java 4.1 ~ 4.2ms 0.001ms

측정의 한계를 먼저 밝힌다. 한 대의 실행 환경에서 잰 값이고, Python 개선안은 한 번의 중앙값(43ms)이 최솟값(22ms)의 두 배에 가까울 만큼 흔들렸다. Java와 JS는 JIT 워밍업의 영향도 있다. 그래서 절대 수치보다 경향만 봐야 한다.

  • 불일치가 앞에서 나는 입력은 세 언어 모두 개선안이 압도적이다. 정리 비용을 아예 치르지 않기 때문이다.
  • 끝까지 비교해야 하는 입력은 언어마다 갈렸다. Java는 개선안이 약 5배 빨랐지만, JavaScript는 비슷하거나 약간 느렸고, Python은 같거나 더 느렸다.
  • Python과 JS가 느린 이유는 측정하지 않았다. 1차 풀이의 정규식과 문자열 함수는 엔진 내부에서 한 번에 처리되는 반면, 개선안은 글자마다 인터프리터 수준에서 함수 호출(isalnum(), lower(), 정규식 test)을 한다는 점이 원인일 것이라고 추정할 뿐이다.
  • 이 제약에서는 어느 쪽이든 수십 ms 안에 끝난다. 채점에서는 차이가 의미 없다.

얻은 것과 잃은 것

  • 얻은 것: 추가 메모리가 O(1)이 된다. 불일치가 앞에서 나면 곧바로 끝난다. Java에서는 로케일 함정이 사라지고, 정규식 관련 실수(순서, 숫자 누락, g 플래그)가 없어진다.
  • 잃은 것: 코드가 길어진다. 안쪽 while 가드라는 새 함정이 생긴다. 정리 한 줄이 "기호와 대소문자를 걷어낸다"는 의도를 바로 보여 주던 가독성도 줄어든다. 그리고 최악의 입력에서 Python과 JS는 더 빨라지지 않았다.
  • 결론: 1차 풀이가 틀린 접근은 아니다. 짧고 의도가 잘 보여서 처음 풀 때는 이쪽이 낫다. "추가 메모리를 줄일 수 있나?"라는 후속 질문이 나오면 개선안으로 넘어가면 된다.

7. 마무리

  • 회문 판정의 핵심은 양 끝에서 안쪽으로 짝지어 비교하는 것이고, 절반만 보면 충분하다.
  • 짝이 양 끝에 있어서 이 문제는 투 포인터와 잘 맞는다. 정리하지 않은 원문 위에서 곧바로 비교하려면 왼쪽과 오른쪽이 서로 다른 만큼 기호를 건너뛰어야 해서 두 포인터가 따로 움직여야 한다. 다만 필수는 아니고, 뒤집어서 비교하는 풀이도 가능하다. 투 포인터의 이론은 이 글을 참고한다.
  • 이 문제의 실질적인 난이도는 비교가 아니라 입력 정리다. 정리를 먼저 끝내는 방식(O(n) 메모리)과 비교하면서 건너뛰는 방식(O(1) 메모리)이 있다.
  • 정리를 먼저 할 때는 소문자 통일 → 필터링 순서, 정규식의 숫자 포함 여부, 짝의 인덱스 -1을 확인한다.
  • 투 포인터가 이론상 항상 빠른 건 아니다. Java에서는 빨랐지만 Python·JS에서는 최악의 입력에서 이득이 없었다.
728x90
반응형