알고리즘(Algorithm)

[BOJ] 1509번 : 팰린드롬 분할(Manacher's Algorithm, DP)

내 이름인데 윤기를 왜 못써 2023. 12. 28. 16:30

접근

팰린드롬 문자열의 전체 분할 갯수의 최솟값을 구하는 문제이다.

문자열의 최대 길이는 2,500자이기에 인덱스 연산으로 팰린드롬 분할 전체를 구해내는 것은 힘들어보였다. 사실 이 문제는 DP 문제인데. 팰린드롬 공부를 좀 더 자세히 해보고싶어서 찾아보다, Manacher's Algorithm을 알게 되었다. 그래서 팰린드롬을 공부하는겸 Manacher's Algorithm을 이용해서 한번 문제를 해결해봤다.

 

해결

Manacher's Algorithm

Manacher씨가 개발한 Longest Palindromic Substring을 $ O(n) $ 의 시간복잡도로 찾는 엄청난 알고리즘이다.

 

이 문제를 해결하면서 아래의 링크들을 참고했다.

 

Longest palindromic substring - Wikipedia

From Wikipedia, the free encyclopedia Computer science problem In computer science, the longest palindromic substring or longest symmetric factor problem is the problem of finding a maximum-length contiguous substring of a given string that is also a palin

en.wikipedia.org

 

[알고리즘] Manacher’s Algorithm - 마나커(마나허) 알고리즘

어떤 문자열에서 팰린드롬의 개수와 위치를 찾는 알고리즘인 "Manacher's Algorithm"에 ...

blog.naver.com

 

[BAE/<JOON> 문제풀이] 1509. 팰린드롬 분할

www.acmicpc.net/problem/1509 1509번: 팰린드롬 분할 세준이는 어떤 문자열을 팰린드롬으로 분할하려고 한다. 예를 들어, ABACABA를 팰린드롬으로 분할하면, {A, B, A, C, A, B, A}, {A, BACAB, A}, {ABA, C, ABA}, {ABACABA}

steins-gate.tistory.com

위의 슈테인-게이트님 덕분에 마지막에 점화식을 작성하는 부분에 시간이 너무 많이 들어서 참고해서 해결할 수 있었다.


Manacher's Algorithm은 팰린드롬의 부분문자열을 가장 길게 갖도록 업데이트해 나가는 일종의 동적프로그래밍과 느낌이 비슷하다.

예를들어 "bananas" 라는 문자열에서 나올 수 있는 팰린드롬 부분문자열은 무엇이 있을까? 1글자인 문자를 제외하고, "ana", "nan", "ana", "anana" 등이 있을 수 있다. 근데 우리가 맨앞에서부터 순차탐색을 진행한다면, 팰린드롬을 이루는 문자열이 최대가 되지 않고, 분할이 이상하게 될 수 있다. 그렇기 때문에 전체 팰린드롬인 문자열을 체크해나가면서, 가장 긴 팰린드롬 부분문자열을 갖도록 해줘야한다.

그 과정을 어떻게 수행하는지 한번 살펴보자.

가장 맨 위의 위키백과 레퍼런스에 보면, Manacher's 보단 느린 알고리즘의 의사코드가 있다. 바로 Manacher's로 들어가면, 너무 어렵기 때문에(내가 이해하는데 시간이 너무 들었다...) 조금 더 느린 의사코드를 해석하면서 한번 시작해보자.

일단 스포를 조금 하자면, Manacher's Algorithm은 홀수길이의 문자열에서 대칭성을 이루는 것을 체크하도록 수행하기 때문에 짝수길이의 문자열은 홀수길이로 만들어줘야한다.
그 방법으로 짝수길이의 문자열은 아래와 같은 위치에 해당 문자열에 쓰이지 않는 특수문자('@', '#', '|' 등)을 추가해줘야한다.($ N = 2n $ -> $ N` = 2n(original) + (2n+1)(fake char numbers) = 4n+1(odd Numbers) $
- 문자열의 양 끝
- 문자열 사이

 

Slower Algorithm

    Longest_Palindrome_SLOW(string S, string S') {
        // S' : S에 가짜 문자(여기선 '|')가 삽입된 문자열

        // S'의 각각의 문자를 중심으로 하는 가장 긴 팰린드롬 반지름을 저장해두기 위한 array
        // note: length(S') = length(PalindromeRadii) = 2 × length(S) + 1
        array PalindromeRadii = [0,...,0]
        
        Center = 0

        while Center < length(S') {
            // (Center-Radius) ~ (Center+Radius)부터 가장 긴 팰린드롬 반지름(Radius) 확인
            Radius = 0

            while Center-(Radius + 1) >= 0 and 
                  Center+(Radius + 1) < length(S') and 
                  S'[Center-(Radius + 1)] = S'[Center+(Radius + 1)] {
                Radius = Radius + 1 // 문자가 대칭이라면, 반지름 계속 확장
            }
            
            // 가장 긴 반지름을 위에 선언한 array에 저장

            PalindromeRadii[Center] = Radius
            
            Center = Center + 1 // 그 다음 중심점 확인
        }   
                 
        // 가장 긴 팰린드롬의 길이는 PalindromeRadii의 Max값을 구하면 된다.
        // 만약 S'[i] == '|', PalindromeRadii[i]이 짝수이면, PalindromeRadii[i]에 1씩 더해줄 수 있는데, 이는 각 테두리에 '|' 를 추가로 삽입한 것과 같다.
        // 문자열S'에서 '|'에 대해 나온 팰린드롬은 문자열 S에서 짝수 팰린드롬에 대응된다는걸 기억해야한다.
        
        // 만약 S'[i] != '|', PalindromeRaii[i]이 홀수면, 홀수 팰린드롬에 대응한다.
        // 이 경우엔, 중심에 대한 팰린드롬의 길이는 x=PalindromeRaii[i]이 된다.
        // 우리가 중심에 대해서 양쪽에 (x-1)/2 개의 문자를 갖고 있고,
        // 대칭의 중심이 되는 문자 1개를 더해서 (x-1)/2*2+1=x 이 길이가 된다.
        longest_palindrome_in_S = max(PalindromeRadii)

        return longest_palindrome_in_S 
    }

위키백과에 있는 의사 코드의 주석을 해석해서 작성해봤다.

의사코드가 복잡하지 않으니 쉽게 이해할 수 있을 것이라 생각한다.

 

문자열을 전체 탐색하면서, 해당 문자를 중심으로 하는 팰린드롬 반지름을 모두 구한다. 저장해놓은 모든 반지름의 길이에서 가장 긴 길이를 찾는다.

 

바깥쪽 반복문이 $ O(N) $, 안쪽 반복문이 $ O(N/2) $의 시간복잡도를 가지면서, 전체 알고리즘의 시간복잡도는 $ O(N^2) $ 이 된다. 이 방법이 Manacher's Algorithm의 기본적인 내용과 동일하다. 이제 Manacher's Algorithm을 통해 어떻게 효율적으로 개선했는지 보자.

 

Manacher's Algorithm

Longest_Palindrome(string S, string S') {
        // S' : S에 가짜 문자(여기선 '|')가 삽입된 문자열

        // S'의 각각의 문자를 중심으로 하는 가장 긴 팰린드롬 반지름을 저장해두기 위한 array
        // note: length(S') = length(PalindromeRadii) = 2 × length(S) + 1
        array PalindromeRadii = [0,...,0]
        
        Center = 0
        Radius = 0
        
        while Center < length(S') {
        // 반복문의 시작에, Radius는 이미 '가장 긴 radius'의 하한으로 설정해둔다.
        // 처음 반복에서는 Radius가 0이지만, 반복이 진행될수록 더 높아질 수 있다.
            
	    // (Center-Radius) ~ (Center+Radius) 까지의 가장 긴 팰린드롬 길이를 구한다.
            while Center < length(S') and 
	          Center+(Radius+1) < length(S') and 
		  S'[Center-(Radius+1)] = S'[Center+(Radius+1)] { // 위의 의사코드랑 구하는 과정은 동일
                Radius = Radius+1
            }             
         
            // 가장 긴 팰린드롬 반지름을 위의 array에 저장
            PalindromeRadii[Center] = Radius
            
            // 아래 코드는, Center를 증가시킨다.
            // 미리 계산된 값이 있다면, 재사용할 수 있다.
            // 또한 Radius는 0보다 클 수 있다.
            
            OldCenter = Center
            OldRadius = Radius
            Center = Center+1

	    // 아래 반복문이 끝나게 되면, Radius의 기본값은 0이 된다.
            Radius = 0 

            while Center <= OldCenter + OldRadius {

        // Center는 오래된 팰린드롬안에 있고, 팰린드롬 안에 있는 모든 문자들은 Center에 대해 "반사된" 문자를 가지고 있다.
        // 그렇기 때문에 우리는 Center의 반사된지점에 대해서 미리 계산된 데이터를 사용할 수 있다.
                MirroredCenter = OldCenter - (Center - OldCenter)  // 현재 Center의 OldCenter에 대해 반사된 지점
                MaxMirroredRadius = OldCenter + OldRadius - Center // 반사된 지점에 대한 최대 Radius

                if PalindromeRadii[MirroredCenter] < MaxMirroredRadius { // 만약 MirroredCenter의 팰린드롬 반지름이 MaxMirroredRadius보다 작다면,

            // MirroredCenter의 팰린드롬은 OldCenter의 팰린드롬에 전체가 포함된다.
            // 그래서, MirroredCenter와 Center는 동일한 사이즈의 팰린드롬을 갖는다.

                    PalindromeRadii[Center] = PalindromeRadii[MirroredCenter]
                    Center = Center+1
                } else if PalindromeRadii[MirroredCenter] > MaxMirroredRadius { // 만약 MirroredCenter의 저장된 Radius가 MaxMirroredRadius보다 크면
                
            // MirroredCenter의 팰린드롬이 OldCenter의 팰린드롬을 넘어 확장될 수 있다는 것이다.
            // Center의 팰린드롬은 OldCenter의 팰린드롬의 가장자리에서 끝나야만 하지만, 그렇지 않으면 OldCenter의 팰린드롬이 더 커질 수 있다.

                    PalindromeRadii[Center] = MaxMirroredRadius
                    Center = Center+1
                } else { // PalindromeRadii[MirroredCenter] = MaxMirroredRadius

            // MirroredCenter의 팰린드롬이 완벽히 OldCenter의 가장자리에서 끝난다면,
            // Center의 팰린드롬은 더 커질 수 있다. 그렇기에 Radius의 최소 크기를 MirroredCenter의
            // 최댓값으로 설정하여, 불필요한 체크를 하지않도록 만든다.

                    Radius = MaxMirroredRadius
                    break
                }   
            }      
        }


    // 팰린드롬의 사이즈는 2 * 반지름이 되어야한다. 그러나 우리는 위에서 도입한 가짜문자를 기준으로 양쪽으로
    // 반지름을 고려했기때문에, 실제로는 2 * (Radius / 2)와 상응한다. 
    // 그렇기에 우리가 저장해둔 PalindromeRadii Array의 Radius 값과 상응한다.

        longest_palindrome_in_S = max(PalindromeRadii)
        return longest_palindrome_in_S
    }

 

위의 코드에 대한 설명을 그림을 통해 예시로 수행해보자.

예시 단어는 "bananas"다.

짝수 길이라고 한다면, 가짜 문자(@, #, | 등)을 넣어서 수행해야하지만, 일단 그냥 수행해보겠다.

Center=0

i = 0 => 'b'

'b'문자 양쪽으로는 대칭이 되는 문자가 없기 때문에, Radius가 0이 된다.

OldCenter = 0

OldRadius = 0

Center = 1

Center <= OldCenter + OldRadius 조건을 만족하지 않기 때문에, 그에 따라 나머지 수정이 없다.

 

 

Center=1

i = 1 => 'a'

'a'문자 양쪽으로도 대칭이 되는 문자가 없기 때문에, Radius가 0이 된다.

OldCenter = 1

OldRadius = 0

Center = 2

Center <= OldCenter + OldRadius 조건을 만족하지 않기 때문에, 그에 따라 나머지 수정이 없다.

 

Center=2

i = 2 => 'n'

'n'문자 양쪽으로는 대칭이 되는 문자가 하나 있기 때문에, Radius가 1이 된다.

그에 따라 OldRadius=1이 된다.

이제 Center=3, OldCenter=2, OldRadius=1로 Center <= OldCenter + OldRadius가 조건인 While문이 수행될 수 있다.

MirroredCenter = 2 - (3 - 2) = 1로 Center 3의 OldCenter 2 에 대한 반사 지점이다.

MaxMirroredRadius = 2 + 1 - 3 = 0 반사 지점에 대한 최대 팰린드롬 반지름은 현재는 0이다.

 

PalindromeRadii[MirroredCenter=1] = 0, MaxMirroredRadius = 0 으로 두 값은 같기 때문에, MirroredCenter의 팰린드롬이 OldCenter의 팰린드롬에서 완벽히 끝난다는 것을 알 수 있다. 그렇기에 Radius = MaxMirroredRadius로 설정하고, 반복문을 탈출한다. 어차피 0이라 여기서는 무슨말인지 잘 이해가 안될 수 있다.

 

Center=3

i = 3 => 'a'

이번 'a'문자는 양쪽으로 대칭이 되는 문자가 2개나 있기 때문에, Radius가 2가 된다.

그에 따라 OldRadius = 2가 된다.

Center = 4

OldCenter = 3

OldRadius = 2

Center <= OldCenter + OldRadius 가 만족하기 때문에, 반복문이 시작된다.

 

MirroredCenter = 3 - (4 - 3) = 2, Center 4의 OldCenter 3에 대한 반사 지점은 2

MaxMirroredRadius = 3 + 2 - 4 = 1, 반사 지점의 최대 Radius는 1

 

PalindromeRadii[MirroredCenter=2] = 1, MaxMirroredRadius = 1로, 완벽히 가장자리에서 끝나기에,

Radius = MaxMirroredRaidus=1 로 수정해주고 반복문을 마친다.

 

Center=4, 5

i = 4, 5 => 'n', 'a'

현재 Radius = 1로 초기화가 되어 있다.

앞서 수행한 반복문에서, 그 다음 탐색을 위한 초기화를 진행해줬기에 Radius=0부터 다 탐색할 필요가 없고, 1까지는 팰린드롬이 보장된다는 의미이기에, 그 이후부터 탐색하는 것이라고 생각하면 된다.

 

Center = 5

OldCenter = 4

OldRadius = 1

Center <= OldCenter + OldRadius가 만족하기 때문에, 반복문이 시작된다.

 

MirroredCenter = 4 - (5 - 4) = 3, Center 5의 OldCenter 4에 대한 반사 지점은 3

MaxMirroredRadius = 4 + 1 - 5 = 0, 반사 지점의 최대 Radius는 0

 

PalindromeRadii[MirroredCenter=3] = 2 > MaxMirroredRadius = 0으로,  반사 지점의 팰린드롬 반지름이 OldCenter를 넘어서 더 커질 수 있다는 것이다.

그럼으로 현재 탐색 중인 PalindromeRadii[Center=5] = MaxMirroredRadius=0 으로 설정한다.

그 다음 Center는 범위를 넘어가기에 반복문을 중지한다.

 

Center=6

i = 6 => 's'

Center = 7

OldCenter = 6

OldRadius = 0

Center <= OldCenter + OldRadius 가 만족하지 않기 때문에, 이대로 반복문을 수행하지 않고 알고리즘을 종료한다.


장황하게 설명을 진행한 것 같은데, 결론적으로는 팰린드롬의 성질을 이용해서 해당 문자를 중심으로 가장 긴 반지름이 될 수 있는 값을 미리 저장해두고, 그 다음 탐색 때 사용함으로써 더욱 효율적으로 팰린드롬 길이를 구할 수 있는 것이다. 

위키백과의 코드가 너무 복잡해보일 수 있다. 그렇다면 Ref 2번의 런타임 에러님의 블로그에 들어가서 더욱 간결히 표현된 코드를 참고해보자. 나도 그 형태로 구현했다.

 

또한 위에선 짝수 문자열만 가짜 문자를 넣어서 팰린드롬 길이를 구하고 했지만, 이 문제를 해결할 땐 그냥 다 넣어서 구해야 한다. 왜냐면, 중간에 등장하는 짝수 팰린드롬(A[BB]CD)이 존재할 수 있기에, 넣어서 구해줘야한다.

DP를 이용한 해결법도 구성해봐야 할 것 같다.

코드

sentence = input()

len_S = len(sentence)
mod_sentence = "@"
for i, s in enumerate(sentence):
    mod_sentence += s
    mod_sentence += "@"
sentence = mod_sentence
len_S = len(sentence)

P = [0] * len_S # i번째 문자 중심 가장 긴 팰린드롬 반지름 크기
r = 0 # i-1단계 까지의 모든 팰린드롬의 끝나는 인덱스 중 가장 큰 값
c = 0 # i-1단계 까지 r의 값이 최대가 되게 하는 중심 인덱스

for i in range(len_S):
    if r < i: # i-1단계 까지 팰린드롬이 끝나는 인덱스 중 가장 큰 값이 현재 탐색하려는 인덱스보다 작다면, 현재(i번쨰)문자 중심 가장 긴 팰린드롬 반지름 크기 = 0 -> 현재 문자는 이전에 탐색한  팰린드롬에 포함되지 않는다는 거니까, 현재 문자 중심 팰린드롬 반지름 크기는 0이 된다.
        P[i] = 0
    else: # i-1단계 까지 팰린드롬 끝나는 인덱스 중 가장 큰 값이 현재 탐색 인덱스를 넘어간다면, 이전 팰린드롬에 현재 문자가 속한다는거임
        P[i] = min(P[2*c - i], r-i) # i의 이전 팰린드롬의 중심 인덱스에 대해서 대칭인 곳에서의 가장 긴 팰린드롬 반지름 크기와, 이전의 팰린드롬이 끝나는 인덱스 중 가장 큰 값에서 현재 인덱스를 뺀 위치까지 중 더 작은 값을 현재 인덱스에서의 초기 반지름 크기로 지정한다. => 진짜 어려움 이렇게 하는 이유에 대한 이해는 Ref: https://m.blog.naver.com/jqkt15/222108415284 확인
        
    while (i - P[i] - 1 >= 0 and i + P[i] + 1 < len_S) and (sentence[i - P[i] -1] == sentence[i + P[i] + 1]): # 현재 문자에 대해서 최대 팰린드롬 반지름 탐색
        P[i] += 1
        
    if r < i + P[i]: # i-1단계 까지 팰린드롬이 끝나는 인덱스 중 가장 큰 값이, 현재보다 더 작다면, 업데이트 해줘야함
        r = i + P[i] # 팰린드롬이 끝나는 인덱스 업데이트
        c = i # 더 큰 반지름이 나오는 중심 인덱스 업데이트
        
dp = [float('inf')] * len_S
dp[0] = 0
for t in range(1, len_S):
    if sentence[t] == "@" and P[t] == 0:
        dp[t] = dp[t-1]
        continue
    dp[t] = dp[t] if dp[t] < dp[t-1] + 1 else dp[t-1] + 1
    
    for j in range(1, P[t]+1):
        st = t - j -1
        ed = t + j
        if st < 0:
            dp[ed] = 1
            continue
        dp[ed] = dp[ed] if dp[ed] < dp[st] + 1 else dp[st] + 1

print(dp[len_S-1])
반응형