접두사 함수

접두사 함수

저자 Сергей Слотин
게시 2018

정의. 문자열 $s$의 접두사 함수란 배열 $p$를 말하며, 여기서 $p_i$는 문자열 $s_0 s_1 s_2 \ldots s_i$의 접두사 중 $i$번째 접두사의 접미사이기도 한 가장 긴 접두사의 길이입니다($i$번째 접두사 전체는 제외합니다).

예를 들어, 문자열 «aataataa»에서 접미사와 같은 가장 큰 접두사는 «aataa»이며, 이 문자열의 접두사 함수는 $[0, 1, 0, 1, 2, 3, 4, 5]$입니다.

vector<int> slow_prefix_function(string s) {
    int n = (int) s.size();
    vector<int> p(n, 0);
    for (int i = 1; i < n; i++)
        for (int len = 1; len <= i; len++)
            // если префикс длины len равен суффиксу длины len
            if (s.substr(0, len) == s.substr(i - len + 1, len))
                p[i] = len;
    return p;
}

이 알고리즘은 현재 $O(n^3)$에 동작하지만, 나중에 더 빠르게 개선하겠습니다.

#이것이 원래 문제를 푸는 데 어떻게 도움이 됩니까?

우선 접두사 함수를 문자열 길이에 대해 선형 시간에 계산할 수 있다고 믿고, 이를 이용해 문자열에서 부분 문자열을 찾는 방법을 배워 봅시다.

부분 문자열 $s$와 $t$를 어느 쪽에도 등장하지 않는 어떤 문자로 이어 붙입시다. 이 문자를 #로 나타내겠습니다. 그렇게 얻은 문자열 s#t의 접두사 함수를 살펴봅시다.

string s = "choose";
string t =
    "choose life. choose a job. choose a career. choose a family. choose a fu...";

cout << s + "#" + t << endl;
cout << slow_prefix_function(s + "#" + t) << endl;
choose#choose life. choose a job. choose a career. choose a family. choose a fu...
0000000123456000000012345600000000123456000100000001234560000000000012345600000000

값이 6($s$의 길이)인 모든 위치가 텍스트 $t$에서 $s$가 등장하는 위치의 끝이라는 것을 알 수 있습니다.

이러한 알고리즘(s#t에 대한 접두사 함수를 계산하고, 그 값이 $|s|$와 같은 위치를 찾는 것)을 크누스-모리스-프랫 알고리즘이라고 합니다.

#빠르게 계산하는 방법

접두사 함수의 예를 몇 가지 더 살펴보고 규칙성을 찾아봅시다:

aaaaa
01234

abcdef
000000

abacabadava
00101230101

다음과 같은 특징을 발견할 수 있습니다: $p_{i+1}$은 $p_i$보다 최대 1만큼 큽니다.

증명. 문자열 $s_{:i+1}$의 접미사와 같은 길이 $p_{i+1}$의 접두사가 있다면, 마지막 문자를 버림으로써 문자열 $s_{:i}$에 대한 올바른 접미사를 얻을 수 있으며, 그 길이는 정확히 1만큼 작습니다.

DP를 이용해 문제를 풀어 봅시다. 즉, $p_i$를 이전 값들로 나타내는 공식을 찾아봅시다.

$p_{i+1} = p_i + 1$인 것은 $s_{p_i} =s_{i+1}$인 경우이며, 오직 그 경우뿐임에 주목하십시오. 이 경우에는 $p_{i+1}$을 갱신하고 바로 다음으로 넘어가면 됩니다.

예를 들어, 문자열 $\underbrace{aabaa}t\overbrace{aabaa}$에서 접미사와 같은 최대 접두사가 표시되어 있습니다: $p_{10} = 5$. 다음 문자가 $t$와 같다면 $p_{11} = p_{10} + 1 = 6$입니다.

그런데 $s_{p_i}\neq s_{i+1}$인 경우에는 어떻게 됩니까? 같은 예시에서 다음 문자가 $t$가 아니라 $b$라고 합시다.

  • $\implies$ 새 문자열의 접미사와 같은 접두사의 길이는 확실히 5보다 작습니다.
  • $\implies$ 찾고 있는 새로운 접두접미사는 «aabaab»의 접미사일 뿐만 아니라, 부분 문자열 «aabaa»의 접두사이기도 합니다.
  • $\implies$ 따라서 다음으로 확인할 후보는 «aabaa»의 접두사 함수 값, 즉 이미 계산해 둔 $p_4 = 2$입니다.
  • $\implies$ 만약 $s_2 = s_{11}$이라면(즉, 새 문자가 후보 접두사 바로 다음에 오는 문자와 같다면) $p_{11} = p_2 + 1 = 2 + 1 = 3$입니다.

이 경우에는 실제로 그렇습니다(필요한 접두사는 «aab»입니다). 그런데 일반적인 경우에 $p_{i+1} \neq p_{p_i+1}$이라면 어떻게 해야 합니까? 그러면 같은 추론을 반복하여 길이가 더 짧은 새로운 후보 $p_{p_{p_i}}$를 얻습니다. 이 후보도 맞지 않으면 같은 방식으로 더 짧은 후보를 확인하며, 이 인덱스가 0이 될 때까지 반복합니다.

vector<int> prefix_function(string s) {
    int n = (int) s.size();
    vector<int> p(n, 0);
    for (int i = 1; i < n; i++) {
        // префикс функция точно не больше этого значения + 1
        int cur = p[i - 1];
        // уменьшаем cur значение, пока новый символ не сматчится
        while (s[i] != s[cur] && cur > 0)
            cur = p[cur - 1];
        // здесь либо s[i] == s[cur], либо cur == 0
        if (s[i] == s[cur])
            p[i] = cur + 1;
    }
    return p;
}

시간 복잡도. 최악의 경우 이 while은 한 번의 반복에서 $O(n)$번 실행될 수 있지만, 평균적으로 각 while은 $O(1)$에 동작합니다.

접두사 함수는 매 단계마다 최대 1만큼 증가하고, while의 각 반복이 끝날 때마다 최소 1만큼 감소합니다. 따라서 전체 연산 횟수는 $O(n)$을 넘지 않습니다.