개요
Manacher's Algorithm은 주어진 배열에서 배열의 각 entry를 중심으로 하는 가장 큰 회문(palindrome)의 크기를 구하는 알고리즘이다.
Brute Force Approach
이를 단순히 Brute Force로 접근해보자.
총 substring의 개수가 O(n2)이며, 각 substring이 회문인지 아닌지 판별하는데 O(n)이 걸린다.
따라서 총 시간복잡도 = O(n2)×O(n)=O(n3) 이다.
중복 제거
s=bananax가 회문인지 아닌지 판단한다고 하자.
우리는 bananax가 회문이 아니라는 걸 알기 위해 다음과 같은 과정을 거친다.
s[2]=s[4] -> s[1]=s[5] -> s[0]≠s[6]
우리가 각각의 substring을 모두 회문이 아닌지 체크만 한다면, bananax가 회문이 아니라는 정보만 이용하고, 계산과정에 있었던 s[2]=s[4],s[1]=s[5],s[0]≠s[6]과 같은 정보들을 버리게 되는 것이다. 따라서 이러한 정보를 이용하여 중복 계산을 줄인다면 시간복잡도의 개선을 노릴 수 있을 것이다.
조금 생각을 해본다면 각 entry를 중심으로 하는 가장 큰 회문의 크기를 O(n)만에 구할 수 있다는 것을 알 수 있다.
abanana를 예시로 들면
a | b | a | n | a | n | a |
이 부분이 회문이기 때문에 | ||||||
이 부분이 회문인지 확인하려면 b와 n이 같은지만 보면 된다. | ||||||
또한 위쪽이 회문이 아니기 때문에, 이 부분은 절대 회문이 될수 없다. |
따라서 각각의 중심을 기준으로 길이를 0부터 하나씩 늘려가면, O(n)만에 특정 index를 중심으로 하는 가장 큰 회문을 구할 수 있다.
총 O(n)의 index가 있고, 각 index에 대해서 가장 큰 회문을 찾는데 O(n)의 시간이 걸린다.
따라서 이 방법의 시간복잡도는 O(n)×O(n)=O(n2)이다.
아직도 덜 쓴 정보가 있다?
이 정도면 충분히 버려지는 정보없이 잘 만들었다고 보일 수 있다.
O(n2)이면 많은 PS 문제에서 Acceptable한 시간복잡도이기 때문에 더욱 그렇다.
그럼에도 아직도 버려지는 정보가 있다.
우리는 다음과 같은 의문을 가져볼 수 있다.
각 중심을 기준으로 하는 회문을 길이 1짜리 회문부터 찾아야하는 것인가?
여기까지 왔다면 위의 질문에 대한 답이 'No'라는 것을 대략 추측하셨으리라 믿는다.
각 index를 중심으로 하는 최대 회문의 반지름을 A라고 하고 A배열을 채운다고 해보자.
s=banana의 경우
s | b | a | n | a | n | a |
A | 0 | 0 | 1 | 2 | 1 | 0 |
위와 같은 배열이 채워지게 되는 것이다.
만약 s=abacababa에 대해서 A배열을 구하는 과정에서 다음과 같은 상황을 가정해보자.
s | a | b | a | c | a | b | a | b | a |
A | 0 | 1 | 0 | 3 | 0 |
다음의 올 문자 b는 s[4]를 중심으로 하는 길이 A[4]=3인 회문 'abacaba'에 포함되어 있다.
우리는 또한 abacaba가 회문임을 알고, s[6]과 s[2]가 s[4]를 기준으로 대칭임을 안다.
따라서 s[0:2]="aba"가 회문이라는 정보를 이용한다면, 추가적인 계산없이 s[5:7]="aba"가 회문이라는 사실을 알 수 있는 것이다.
이 사실을 이용하면, A[4]를 0부터 찾을 필요없이 1부터 찾을 수 있게 된다.
좀더 엄밀하게 얘기하자면 A[i]를 계산하고자 할 때, 현재까지 가장 멀리 뻗어있는 회문을 관리하는 것이다.
회문의 index인 ind=argmax(j+A[j]) 와 그 회문이 얼마나 뻗어있는지의 정보인 x=max(j+A[j])를 매번 계산한다.
이 회문 안에 들어가는 범위 내에서는 이미 계산한 i의 대칭점인 i′=2∗ind−i의 정보를 사용하게 된다.
따라서 0부터 보는 것이 아니라 min(A[i′],x−i)부터 하나씩 늘려가면서 A[i]를 찾는 것이다.
만약 i가 범위에 들어가지 않는다면 O(n2) 알고리즘과 동일하게 0부터 계산한다.
시간복잡도는?
시간복잡도를 계산하기 위해서 A[i]의 초기값이 어디서 부터 시작하는 지를 체크해야한다.
case 1) 초기값이 A[i′]이고, A[i′]<x−i일 때,
이미 A[i']를 계산할때, 더 긴 회문이 없다는 계산이 끝났기 때문에, 이 경우에는 바로 A[i]=A[i′]을 하고 종료한다.
case 2) 초기값이 x−i이거나 0일 때
이 경우에는 원래 O(n2) 알고리즘과 똑같이 하나씩 늘리면서 계산하게 된다.
이 때 x의 값이 1 증가하게 되는데, x는 초기값 0부터 감소하지 않고 1씩 증가만 해서 최종적으로 n이 된다.
따라서 case 2는 총 n번 시행되게 된다.
따라서 시간복잡도는 O(n)이 된다.
일반적인 회문은 어떻게 구하는가?
이 알고리즘은 홀수 길이의 회문만 구할 수 있다는 단점이 있다.
짝수 길이의 회문을 구하기 위해서는 string의 중간에 사용하지 않는 문자를 추가해주는 것이다.
예를 들어서 s=banana 라면 s′=b@a@n@a@n@a로 변경한다.
이렇게 하면 @를 기준으로 하는 회문은 짝수길이의 회문을 모두 표현하게 된다.