문자열의 부분 문자열 중 회문(Palindrome)이 몇 개인지를 구하는 문제는 문자열 알고리즘에서 자주 볼 수 있는 중요한 유형이다. 특히 긴 문자열에 대해 효율적으로 풀기 위해서는 고전적인 O(N^2) 접근 대신, 복잡도를 줄일 수 있는 알고리즘을 활용해야 한다. 이번 포스팅에서는 ‘Manacher 알고리즘’을 통해 문제를 해결하는 방식을 소개하고자 한다. Manacher 알고리즘은 문자열 전처리를 통해 모든 중심에 대해 팰린드롬의 반경(길이)을 O(N) 시간에 구할 수 있기 때문에, 팰린드롬 부분 문자열의 개수를 빠르게 계산할 수 있다.
문제 : https://www.acmicpc.net/problem/16163
문제 설명
문제의 요구 사항은 간단해 보이지만, 문자열의 길이가 최대 수백만에 이를 수 있어 단순한 이중 반복문(O(N^2))으로는 시간 초과가 발생하기 쉽다. 구체적으로 문제에서 주어지는 문자열을 확인하고, 이 문자열 내의 ‘연속된’ 부분 문자열 중에서 회문이 되는 경우의 수를 전부 세어야 한다는 점이 핵심이다. 여기서 ‘부분 문자열(Substring)’은 문자열에서 연속된 위치를 차지하는 것으로, 부분 수열(Subsequence)과는 구분되는 개념임에 유의해야 한다.
실제 문제에서는 알파벳 대문자로만 구성된 긴 문자열이 주어질 수 있으므로, 우리가 고려해야 하는 후보 부분 문자열의 개수는 최대 \(\frac{N(N+1)}{2}\)개가 된다. 예를 들어 길이가 N인 문자열이 주어졌다면, 가능한 부분 문자열은 길이 1짜리 N개, 길이 2짜리 N-1개, … 길이 N짜리 1개 등 총합이 \(\frac{N(N+1)}{2}\)개이다. 이 중 어떤 부분 문자열이 회문인지 판단하기 위해 매번 양끝에서부터 비교하게 되면 O(N^3)까지도 치솟을 수 있고, 이 방식으로는 대규모 데이터를 처리하기가 어렵다.
이에 대한 효율적인 해결책으로 Manacher 알고리즘이 등장한다. Manacher 알고리즘의 주요 아이디어는 문자열 사이에 구분자(예: ‘#’)를 삽입해 홀수 길이와 짝수 길이의 팰린드롬 처리를 통일시키고, 각 인덱스를 ‘중심’으로 하는 팰린드롬의 최대 ‘반지름’을 빠르게 구하는 방식이다. 이를 통해 모든 부분 문자열이 팰린드롬인지 확인하지 않고도, 각 위치별로 가능한 팰린드롬의 개수를 합산하여 결과를 얻을 수 있다.
종합적으로 정리하자면, 문제에서 요구하는 것은 “주어진 문자열에서 연속된 부분 문자열 중 회문인 것이 몇 개인가”를 빠르게 계산하는 일이다. 이 때 Manacher 알고리즘을 활용하여 O(N) ~ O(N+1) 정도의 시간 복잡도로 각 위치를 중심으로 하는 최대 팰린드롬 반지름을 구하고, 그것을 합산해 최종적인 회문 부분 문자열 수를 구하게 된다. 이 접근 방식은 크게 다음과 같은 과정으로 요약할 수 있다.
- 문자열 전처리: 구분자 삽입.
- Manacher 알고리즘으로 각 중심에 대한 팰린드롬 반경 계산.
- 팰린드롬 개수 환산: 각 중심의 팰린드롬 반경을 합산.
위 과정을 통해 문제에서 요구하는 모든 회문 부분 문자열의 개수를 효율적으로 구할 수 있다.
접근 방식
문자열 전처리
문자열의 각 문자 사이에 구분자(주로 ‘#’)를 삽입함으로써, 모든 팰린드롬을 ‘홀수 길이’ 형태로 통일한다. 이렇게 하면 짝수 길이인 경우에도 가운데 구분자를 중심으로 생각할 수 있으므로, 별도의 추가 처리가 필요 없어지는 장점이 있다.Manacher 알고리즘
- 각 인덱스를 중심으로 하는 팰린드롬 반지름을 구하는 과정을 O(N) 안에 처리한다.
- 이미 구한 반지름 정보를 활용하여, 새로운 중심을 설정할 때 중복 계산을 최소화한다.
- 각 위치에서 팰린드롬을 확장할 때, 기존에 구해둔 정보를 적극적으로 재활용한다.
팰린드롬 개수 계산
- 변환된 문자열에서 구한 팰린드롬 반지름
p[i]
는 구분자가 포함된 문자열 기준의 길이이다. - 이를 원본 문자열에 대응하기 위해 \((p[i] + 1) / 2\)를 취해 더한다.
- 마지막으로 이 값을 모두 합산하면, 원본 문자열에서의 회문 부분 문자열 개수가 산출된다.
- 변환된 문자열에서 구한 팰린드롬 반지름
C++ 코드와 설명
아래 코드는 Manacher 알고리즘을 적용해 문제를 해결한 최적화된 예시 C++ 코드이다. 각 라인에 주석을 달아 동작을 상세히 설명하겠다.
|
|
코드 동작 단계별 설명
- 입력 및 전처리
- 입력받은 문자열
s
에 대하여 앞뒤와 문자 사이사이에 구분자(#
)를 삽입해T
를 만든다.
- 입력받은 문자열
- p 배열과 변수 초기화
p
배열은 각 위치에서 팰린드롬 확장 길이를 저장한다.center
,right
는 현재까지 발견된 팰린드롬이 미치는 범위를 결정짓는다.
- 반복문을 통한 Manacher 알고리즘 실행
mirror
값으로 기존에 계산된 값을 재활용해 중복 계산을 줄인다.- 중심을 기준으로 좌우로 확장해 팰린드롬 길이를 갱신한다.
- 확장 결과가
right
를 넘어서면center
와right
를 재설정한다.
- 결과 계산
result
에 각 위치에서 \((p[i] + 1) / 2\)를 합산해 원본 문자열의 회문 부분 문자열 총 개수를 구한다.
Python 코드와 설명
아래는 동일한 로직을 Python으로 구현한 최적화 버전이다. C++ 코드와 마찬가지로 Manacher 알고리즘을 활용해 O(N) 복잡도로 결과를 도출한다. 각 라인에 주석을 달아 동작을 상세히 설명하였다.
|
|
코드 동작 단계별 설명
- 구분자 삽입
- Python의
join
함수를 이용해 원본 문자열의 문자 사이에#
을 삽입한다.
- Python의
- 배열 및 변수 초기화
p
는 각 인덱스에서의 팰린드롬 반지름을 저장한다.center
와right
는 현재 검사 중인 팰린드롬이 커버하는 구간을 나타낸다.
- 반복문
- 인덱스 i에 대해
mirror
인덱스(2 * center - i)의p
값을 참조해 초기값을 설정한다. - 문자가 좌우 대칭이면 확장하며
p[i]
를 갱신한다.
- 인덱스 i에 대해
- 결과 도출
- 각 위치의
p[i]
값을 합산하여 최종 결과를 구한다.
- 각 위치의
결론
이 문제는 문자열의 부분 문자열 중 회문인 것을 모두 세어야 하므로, 단순 이중 반복문을 사용한 방식은 긴 문자열에서 시간 초과가 발생하기 쉽다. 따라서 Manacher 알고리즘을 통해 O(N)에 가까운 시간에 각 인덱스 중심 팰린드롬의 길이를 계산하고, 이를 합산하는 방식이 유효함이다. 이를 통해 긴 문자열에 대해서도 충분히 빠른 시간 안에 정답을 도출할 수 있다. 추가적으로, 구분자 외에 특수 문자를 사용하는 등 다양한 구현상의 변형이 가능하지만, 핵심 로직은 동일하다. 이 문제를 풀며 문자열 알고리즘에서 전처리와 효율적 계산 기법을 잘 이해하고 활용하는 것이 중요하다는 점을 다시금 깨닫게 되었다.
|
|