Suffix Array · LCP

문자열을 정렬된 인덱스로 바꾸기.
접미사 순위에서 공통 접두사 질의까지.

동기 → 직관 → 정의 → 증명 → 활용0-index · 빈 접미사 제외 · 반열린 문자열 구간
MOTIVATION → INTUITION

0. 동기: 반복되는 문자열 질의를 위한 인덱스

한 본문에서 여러 패턴을 찾거나 서로 다른 위치의 문자열을 반복해서 비교할 때, 질의마다 원문을 처음부터 훑는 비용을 피하고 싶다. 본문이 고정되어 있다면 한 번 정렬해 두고 이후 질의를 빠르게 처리하는 전처리가 가능하다.

문자열 S의 길이를 N이라 두자. 접미사 배열은 문자열을 새로 만드는 자료구조가 아니라, 어디서부터 읽으면 사전순으로 몇 번째인가를 기록한 인덱스이다. LCP 배열은 그 정렬에서 이웃한 두 접미사가 앞에서부터 몇 글자나 같은지 기록한다.

이 둘을 구성하면 패턴이 시작하는 위치들을 이분 탐색하고, 두 접미사의 공통 접두사 길이를 질의하고, 서로 다른 부분 문자열의 수를 구할 수 있다. 여기서는 banana 하나를 끝까지 계산하면서 각 결과가 어떻게 연결되는지 확인한다.

선수 지식은 배열, 정렬, 기본적인 반복문이다. 사전순·접미사·이분 탐색·RMQ의 의미는 아래에서 정의한다. 문자열 구간은 [l,r)[l,r), 즉 l 포함·r 제외이며 길이는 rlr-l이다. 빈 접미사 S[NN)S[N\ldots N)는 배열에 넣지 않는다.

사전순 비교의 두 가지 규칙

  1. 왼쪽부터 비교하여 처음 다른 문자가 나오면 그 문자의 순서로 결정한다. banana<nana\mathtt{banana} < \mathtt{nana}b<nb < n이기 때문이다.
  2. 한쪽이 먼저 끝날 때까지 모두 같으면 짧은 쪽이 먼저이다. a<ana<ananaa < \mathtt{ana} < \mathtt{anana}. “없음”은 뒤에 문자가 더 있는 것보다 앞선다.

이 페이지의 예제는 a<b<<za < b < \ldots < z 순서이다. 접미사들은 시작 위치가 다르면 길이도 다르므로, 전체 문자열로서 서로 같을 수 없다. 다만 앞 몇 글자만 비교하는 중간 단계에서는 동률이 가능하다.

그림 1. 원문 위치 i에서 시작한 접미사 → 사전순 순위 rank[i]\operatorname{rank}[i]
시작 i012345rank[i]\operatorname{rank}[i]
0banana3
1·anana2
2··nana5
3···ana1
4····na4
5·····a0

위 표의 i는 원문의 좌표이다. 아래 표의 r은 정렬된 배열의 좌표이다. 둘을 구별해야 한다. SA[2]=1\mathrm{SA}[2]=1은 순위 2에 원문 위치 1의 접미사 anana가 있다는 뜻이고, rank[1]=2\operatorname{rank}[1]=2는 같은 사실을 반대 방향으로 읽은 것이다.

그림 2. 정렬된 접미사와 이전 이웃의 LCP · 파랑 부분이 일치
순위 rSA[r]\mathrm{SA}[r]접미사LCP[r]\mathrm{LCP}[r]
05a0
13ana1
21anana3
30banana0
44na0
52nana2
SA=[5,3,1,0,4,2]\mathrm{SA}=[5,3,1,0,4,2]
rank=[3,2,5,1,4,0]\operatorname{rank}=[3,2,5,1,4,0]
LCP=[0,1,3,0,0,2]\mathrm{LCP}=[0,1,3,0,0,2]

LCP[r]\mathrm{LCP}[r]는 위치 r에서 시작한 접미사의 값이 아니다. 예를 들어 LCP[2]=3\mathrm{LCP}[2]=3은 정렬된 위치 1과 2의 접미사 ana·anana가 ana를 공유한다는 뜻이다. LCP[0]=0\mathrm{LCP}[0]=0은 비교할 이전 이웃이 없어서 정한 약속이다.

그냥 접미사 문자열을 전부 정렬하면 안 되는가

정의대로 구현할 수는 있다. 하지만 N개 접미사를 모두 복사하면 문자 수만 N+(N1)++1=N(N+1)/2N+(N-1)+\ldots +1=N(N+1)/2이다. 시작 위치만 저장해도 접미사를 직접 비교할 때 한 번에 O(N)O(N)글자를 볼 수 있다. Doubling은 긴 문자열 비교를 두 정수 비교로 바꾸어 이 비용을 줄인다.

01 / DEFINITION → PROOF

1. 두 좌표계: 원문 위치와 사전순 순위

직관. 정렬된 책장의 칸 번호와 책에 붙은 원래 번호는 다르다. SA는 칸에서 원래 번호로, rank는 원래 번호에서 칸으로 이동한다.

정의 1 · 접미사 배열과 역배열

전순서가 주어진 유한 알파벳 Σ\Sigma 위의 문자열 SΣ,N=SS\in \Sigma^*, N=|S|를 둔다. Si=S[iN),0i<NS_{i}=S[i\ldots N), 0\le i<N이다. 빈 접미사는 제외한다. SA는 0,,N10,\ldots ,N-1의 순열 중 S[SA[0]]<<S[SA[N1]]S[\mathrm{SA}[0]\ldots ] < \ldots < S[\mathrm{SA}[N-1]\ldots ]를 만족하는 유일한 순열이다. 서로 다른 접미사는 길이가 달라 같을 수 없으므로 순서가 유일하다. rank[SA[r]]=r\operatorname{rank}[\mathrm{SA}[r]]=r로 정의한다.

SA:ri,rank:ir\mathrm{SA}:r\mapsto i,\quad\operatorname{rank}:i\mapsto r
rank[SA[r]]=r,SA[rank[i]]=i\operatorname{rank}[\mathrm{SA}[r]]=r,\quad\mathrm{SA}[\operatorname{rank}[i]]=i

비용 분석은 문자 비교, 배열 접근, 인덱스 산술을 O(1)O(1)로 세는 RAM 모델을 따른다. 정수에는 위치와 길이를 담을 충분한 비트가 있다고 가정한다.

정리 1. SA와 최종 rank의 역함수 관계

SA는 서로 다른 시작 인덱스 0,,N10,\ldots ,N-1의 순열이다. rank[SA[r]]=r\operatorname{rank}[\mathrm{SA}[r]]=r로 정의하면 각 시작 위치에 정확히 한 순위가 대응한다. i=SA[r]i=\mathrm{SA}[r]를 대입하면 SA[rank[i]]=i\mathrm{SA}[\operatorname{rank}[i]]=i가 바로 따른다. 두 접미사의 사전순 비교는 두 rank의 비교와 동치이다. 단, Doubling 중간 단계의 동치류 번호에는 이 역함수 관계를 적용하지 않는다.

활용. 전체 접미사의 대소는 rank 비교 한 번으로 결정된다. 길이가 제한된 부분 문자열끼리는 이 결론을 그대로 사용할 수 없다.

02 / INTUITION → DEFINITION → PROOF

2. Doubling: 긴 비교를 두 정수 비교로 바꾸기

직관. 앞 k글자의 순서를 이미 알면 앞 2k글자는 길이 k인 블록 두 개로 비교할 수 있다. 첫 블록이 같을 때만 둘째 블록을 비교한다.

정의 2 · 중간 동치류 RkR_{k}

Wk(i)=S[imin(i+k,N))W_{k}(i)=S[i\ldots \min (i+k,N)). RkR_{k}WkW_{k}의 동치와 순서를 보존하는 정수 번호이다. Rk(i)=Rk(j)    Wk(i)=Wk(j),Rk(i)<Rk(j)    Wk(i)<Wk(j)R_{k}(i)=R_{k}(j) \iff W_{k}(i)=W_{k}(j), R_{k}(i)<R_{k}(j) \iff W_{k}(i)<W_{k}(j). 아직 동률이 가능한 RkR_{k}와 최종 역배열 rank는 다르다.

keyk(i)=(Rk(i),R^k(i+k))\operatorname{key}_k(i)=(R_k(i),\widehat R_k(i+k))
R^k(t)={Rk(t)t<N,1tN.\widehat R_k(t)=\begin{cases}R_k(t)&t<N,\\-1&t\ge N.\end{cases}
정리 2. Doubling의 순위 불변식과 종료 조건

불변식. 길이 k 단계의 rank는 각 접미사의 앞 min(k,Si)\min(k,|S_i|)글자에 대해 동치와 사전순을 보존한다. 즉 두 rank가 같을 필요충분조건은 비교 대상 문자열이 같음이고, rank의 대소는 그 문자열의 사전순과 일치한다.

기초. k=1k=1에서 문자의 순위로 정의하면 불변식이 성립한다. C++ 구현은 문자 코드 자체를 초기 rank로 사용한다. 연속된 번호가 아니어도 순서와 동치를 보존하므로 충분하다.

귀납 단계. 앞 2k글자를 비교할 때 먼저 앞 k글자를 비교하고, 같으면 다음 k글자를 비교한다. 귀납 가정에 의해 이는 (rank[i],rank[i+k])(\operatorname{rank}[i], \operatorname{rank}[i+k])의 사전순 비교와 같다. 뒷부분이 없을 때 −1을 사용하면 짧은 문자열이 먼저 온다는 규칙도 보존된다. 정렬 후 같은 키에 같은 번호, 다른 키에 증가한 번호를 부여하면 2k 단계의 불변식이 성립한다.

길이가 k보다 짧을 때도 성립하는가. 첫 블록이 길이 k 미만이면 그 접미사는 이미 끝났으므로 둘째 블록은 없다. 따라서 짧은 첫 블록이 다른 첫 블록의 proper prefix인 경우에도 전체 비교에서 반드시 먼저 온다. 첫 블록이 같고 길이 k이면 둘째 블록 비교로 넘어가며, 비어 있는 둘째 블록은 −1로 모든 비어 있지 않은 블록보다 앞선다. 그러므로 모든 경계 경우에서 쌍 비교가 W2kW_{2k}의 동치와 순서를 모두 보존한다.

모든 rank가 다르면 접미사들의 순서가 이미 비교된 부분에서 결정됐으므로 나머지 문자와 무관하게 전체 순서도 확정된다. 그렇지 않아도 2kN2k\ge N이면 모든 접미사를 끝까지 비교하므로 정렬이 완료된다. 단계 수는 O(logN)O(\log N)이다. N1N\le 1은 자명한 경우이다.

두 블록 정렬 직접 확인 →

두 블록으로 분해하면 왜 정수 두 개로 충분한가

그림 3. k=2k=2에서 앞 4글자 비교 · ∅는 없는 뒷블록
i=1i=1anan(1, 1)
i=3i=3ana(1, 0)
i=5i=5a(0, −1)

첫 블록의 rank가 같으면 둘째 블록을 본다. 따라서 i=3i=3i=1i=1보다 먼저이다. i=5i=5는 첫 블록 a 자체가 an보다 짧아 더 먼저이다.

rank 번호의 크기는 사전순만 표현한다. 문자 길이나 원문 인덱스가 아니다. 첫 문자 단계에서는 a=0,b=1,n=2a=0, b=1, n=2로 압축했지만, C++ 코드는 문자 코드 97, 98, 110을 그대로 쓴다. 순서와 동률이 같으므로 첫 쌍 정렬 결과는 동일하다.

banana의 모든 Doubling 단계

초기 단계 · 첫 1글자
시작 i비교 부분이전 rank로 만든 키rank[i]\operatorname{rank}[i]
1a(0)0
3a(0)0
5a(0)0
0b(1)1
2n(2)2
4n(2)2
k=1 → 앞 2글자 순위 계산
시작 i비교 부분이전 rank로 만든 키rank[i]\operatorname{rank}[i]
5a(0, -1)0
1an(0, 2)1
3an(0, 2)1
0ba(1, 0)2
2na(2, 0)3
4na(2, 0)3
k=2 → 앞 4글자 순위 계산
시작 i비교 부분이전 rank로 만든 키rank[i]\operatorname{rank}[i]
5a(0, -1)0
3ana(1, 0)1
1anan(1, 1)2
0bana(2, 3)3
4na(3, -1)4
2nana(3, 3)5

앞 2글자 단계에서 i=1i=1i=3i=3은 모두 an이므로 rank=1\operatorname{rank}=1이다. i=2i=2i=4i=4도 na가 같아 rank=3\operatorname{rank}=3이다. 동률에 임의로 다른 번호를 부여하면 다음 단계에서 올바른 뒷블록 비교를 할 수 없다.

앞 4글자 단계에서는 모든 rank가 달라진다. 전체 길이가 6이어도 더 볼 필요가 없다. 어떤 두 접미사도 이미 비교한 부분에서 순서가 결정됐기 때문이다. 아직 동률이 있더라도 비교 길이가 N 이상이면 모든 접미사를 끝까지 본 것이므로 반드시 종료한다.

정렬하는 동안 rank를 바꾸면 안 된다

현재 단계의 모든 키는 같은 이전 rank 배열에서 만들어야 한다. 일부 위치의 rank를 먼저 덮어쓰면 뒤의 키는 새 값과 옛 값이 섞인다. 따라서 새 번호를 next_rank에 전부 기록하고, 단계 마지막에 rank와 교환한다. 비교 함수도 정렬 중에는 변하지 않는 rank만 읽는다.

Counting Sort로 바꿀 때: −1을 0으로, 실제 rank를 rank+1\operatorname{rank}+1로 옮긴 뒤 둘째 키로 안정 정렬하고 첫째 키로 안정 정렬한다. 마지막 정렬이 같은 첫째 키의 원소들 사이에서 둘째 키 순서를 보존해야 쌍의 사전순이 된다. 키 범위를 O(N)O(N)으로 압축하면 두 번의 정렬도 O(N)O(N)이다.

suffix-array.cppC++17
#include <algorithm>
#include <numeric>
#include <string>
#include <utility>
#include <vector>
using namespace std;

// 비어 있지 않은 접미사 N개의 시작 위치. 빈 입력은 빈 배열.
vector<int> suffix_array(const string& s) {
    int n = (int)s.size();
    vector<int> sa(n), rank(n), next_rank(n);
    iota(sa.begin(), sa.end(), 0);
    for (int i = 0; i < n; ++i) rank[i] = (unsigned char)s[i];

    for (long long k = 1; k < n; k *= 2) {
        auto key = [&](int i) {
            return pair<int, int>{rank[i],
                i + k < n ? rank[i + k] : -1};
        };
        sort(sa.begin(), sa.end(), [&](int a, int b) {
            return key(a) < key(b);
        });
        next_rank[sa[0]] = 0;
        for (int r = 1; r < n; ++r) {
            next_rank[sa[r]] = next_rank[sa[r - 1]]
                + (key(sa[r - 1]) != key(sa[r]));
        }
        rank.swap(next_rank);
        if (rank[sa.back()] == n - 1) break;
    }
    return sa;
}
03 / DEFINITION → LEMMA → ALGORITHM

3. LCP: 재사용 가능한 일치 정보

직관. 긴 문자열 두 개가 h글자 일치하면 첫 글자를 지워도 h1h-1글자는 남는다. 어려운 점은 다음 접미사의 비교 상대가 바뀔 수 있다는 것이다. 그 사이의 문자열도 같은 접두사를 공유한다는 보조정리가 이 간극을 메운다.

정의 3 · 공통 접두사와 LCP 배열

lcp(A,B)=max{0min(A,B), A[0)=B[0)}\operatorname{lcp}(A,B)=\max\{\ell\mid 0\le\ell\le\min(|A|,|B|),\ A[0\ldots\ell)=B[0\ldots\ell)\}. 최댓값은 유한하고 0이 후보이므로 존재한다. LCP[0]=0,r>0\mathrm{LCP}[0]=0, r>0에서 LCP[r]=lcp(S[SA[r1]],S[SA[r]])\mathrm{LCP}[r]=\operatorname{lcp}(S[\mathrm{SA}[r-1]\ldots ],S[\mathrm{SA}[r]\ldots ])이다. 이 페이지는 이전 이웃 기준이며 다음 이웃 기준 정의와 인덱스를 섞지 않는다.

보조정리 3. 동일 접두사를 갖는 접미사들의 사전순 연속성

A와 C가 길이 h의 접두사 P를 공유하고 ABCA\le B\le C라고 하자. B가 P를 공유하지 않는다고 가정한다. 첫 불일치에서 B의 문자가 작으면 B<AB<A, 크면 B>CB>C가 된다. B가 h글자 이전에 끝나는 경우에도 B는 A의 proper prefix이므로 B<AB<A이다. 모두 가정에 모순이다. 따라서 B도 P를 공유한다. 즉 특정 접두사를 갖는 접미사들은 SA의 연속 구간을 이룬다.

Kasai의 반복 규칙

원문 순서 i=0,,N1i=0,\ldots ,N-1로 순회한다. r=rank[i]>0r=\operatorname{rank}[i]>0이면 j=SA[r1]j=\mathrm{SA}[r-1]과 비교한다. 이전 반복에서 가져온 하한 h에서 시작하여 일치하는 동안 증가시키고 LCP[r]\mathrm{LCP}[r]에 저장한다. 다음 반복을 위해 h=max(h1,0)h=\max (h-1,0)으로 둔다. r=0r=0이면 LCP[0]=0,h=0\mathrm{LCP}[0]=0, h=0이다.

정리 4. Kasai의 h1h-1 재사용이 올바른 이유

i의 사전순 바로 이전 접미사를 SjS_{j}라 하고 h=lcp(Si,Sj)h=\operatorname{lcp}(S_{i},S_{j})라 하자. h1h\le 1이면 다음 위치에서 재사용하는 길이는 0이므로 자명하다. h2h\ge 2이면 SiS_{i}SjS_{j}의 첫 문자가 같고, 이를 제거한 Si+1S_{i+1}Sj+1S_{j+1}h1h-1글자를 공유한다.

같은 첫 문자를 제거해도 사전순은 보존되므로 Sj+1<Si+1S_{j+1} < S_{i+1}이다. Si+1S_{i+1}의 바로 이전 접미사를 Sₚ라 하면 Sj+1Sp<Si+1S_{j+1} \le S_{p} < S_{i+1}이다. 보조정리 3에 의해 Sₚ도 동일한 h1h-1글자를 공유한다. 따라서 다음 비교는 최소 h1h-1까지 이미 일치함이 보장된다.

h2h\ge 2이면 i+1i+1j+1j+1 모두 N보다 작으므로 두 접미사는 배열에 실제로 존재한다. 또한 Sj+1S_{j+1}Si+1S_{i+1}보다 작으므로 이 경우 Si+1S_{i+1}의 순위가 0일 수 없다. h1h\le 1이면 하한이 0이므로 이전 이웃의 존재 여부와 무관하게 안전하다. “비교 상대가 반드시 j+1j+1이다”라는 더 강한 주장은 필요하지 않다.

이 하한부터 문자가 같은 동안 증가시키고, 불일치 또는 문자열 끝에서 멈추면 정확한 LCP를 얻는다. rank=0\operatorname{rank}=0이면 이전 접미사가 없으므로 LCP[0]=0\mathrm{LCP}[0]=0으로 정의하고 h를 0으로 초기화한다. 모든 i를 순회하고 rank가 순열이므로 모든 LCP 원소를 정확히 한 번 계산한다.

반복문 불변식. i번째 반복 진입 시 h는 현재 비교 상대와의 실제 LCP 이하이다. i=0i=0에서는 h=0h=0이므로 성립하고, 위 보조 성질이 다음 반복에 대한 귀납 단계를 보장한다. while에서는 앞 h글자의 동일성을 유지하며, 멈춘 이유가 불일치 또는 한쪽 문자열의 종료이므로 얻은 h는 하한에 그치지 않고 정확한 최댓값이다.

재사용 구간 직접 확인 →

구현상의 결론. j는 매번 SA에서 다시 찾는다. 재사용하는 것은 비교 상대의 번호가 아니라 일치 길이의 하한이다.

정리 5. Kasai 알고리즘의 선형 시간

h가 증가할 때마다 성공한 문자 비교가 한 번 발생한다. 일반 반복 끝에서의 감소는 최대 1이므로 감소량 합은 N 이하이다. rank=0\operatorname{rank}=0인 위치는 한 번뿐이며 이때의 초기화로 발생할 수 있는 추가 감소량도 N 이하이다. h는 0에서 시작하고 항상 0hN0\le h\le N이므로 총 증가량은 O(N)O(N)이다.

각 i에서 마지막 불일치·경계 검사와 나머지 연산은 O(1)O(1)이므로 전체 시간은 O(N)O(N)이다. rank와 LCP가 길이 N인 배열이므로 추가 공간도 O(N)O(N)이다. 매 위치마다 h를 0으로 바꾼 뒤 비교하면 이 분석이 성립하지 않으며, 반복 문자열에서 O(N2)O(N^{2})이 될 수 있다.

증가 총량을 I, 감소 총량을 D, 마지막 값을 H라 쓰면 0+ID=H0+I-D=H이므로 I=D+HI=D+H이다. D2N,HND\le 2N, H\le N에서 I3NI\le 3N을 얻는다. 이 상계는 느슨하지만 선형임을 보이기에 충분하다. 안쪽 while이 있다는 문법만으로 O(N2)O(N^{2})라고 판단할 수 없는 이유이다.

“다음”은 정렬의 다음 행이 아니라 원문의 i+1i+1

그림 4. 첫 글자를 제거하면 이미 일치한 부분이 한 글자 줄어든다
i=1,j=3i=1, j=3ananaanah=3h=3
i=2,j+1=4i=2, j+1=4nanana재사용 2\ge 2

위 그림에서는 다음 위치의 이전 이웃도 마침 j+1j+1이다. 하지만 일반적으로 비교 상대가 j+1j+1이라는 보장은 없다. 실제 상대는 언제나 SA[rank[i+1]1]\mathrm{SA}[\operatorname{rank}[i+1]-1]로 다시 찾는다. j+1j+1은 재사용 길이를 증명하기 위한 비교 대상일 뿐이다.

실제로 aabaa의 SA는 [4,3,0,1,2]이다. i=0i=0의 이전 이웃은 j=3j=3이며 aabaa와 aa의 LCP는 2이다. 다음 i=1i=1의 이전 이웃은 j+1=4j+1=4가 아니라 위치 0의 aabaa이다. 그래도 S[4]=a<S[0]=aabaa<S[1]=abaaS[4\ldots ]=a < S[0\ldots ]=\mathtt{aabaa} < S[1\ldots ]=\mathtt{abaa}가 모두 a를 공유하므로 h1=1h-1=1을 안전하게 재사용한다.

Kasai의 실제 계산 기록

i 순서로 순회하고 LCP[rank[i]]\mathrm{LCP}[\operatorname{rank}[i]]에 저장
irank[i]\operatorname{rank}[i]이전 이웃 j재사용 seed완성 h다음 seed
031000
123032
254221
315110
440000
50없음000

i=1i=1에서 anana와 ana를 비교해 h=3h=3. 다음 i=2i=2에서는 na 두 글자를 다시 비교하지 않고 건너뛴다. i=3i=3에서는 a 한 글자를 재사용한다. i=5i=5rank=0\operatorname{rank}=0이므로 이전 이웃이 없으며 h를 0으로 둔다.

h1h-1은 정답이 아니라 하한

이전 반복에서 가져온 seed까지 같다는 사실만 보장된다. 그 뒤도 같을 수 있으므로 while로 추가 비교해야 한다. 반대로 매번 h=0h=0으로 초기화하면 이미 확인한 긴 반복 구간을 계속 비교하게 되어 aaaa…a에서 이차 시간이 걸릴 수 있다.

kasai.cppC++17
// 앞 코드의 헤더 선언 이후에 작성.
// sa는 s의 올바른 Suffix Array여야 함.
// lcp[r] = LCP(s[sa[r-1]..], s[sa[r]..]), lcp[0] = 0.
vector<int> lcp_array(const string& s, const vector<int>& sa) {
    int n = (int)s.size();
    vector<int> rank(n), lcp(n, 0);
    for (int r = 0; r < n; ++r) rank[sa[r]] = r;

    int h = 0;
    for (int i = 0; i < n; ++i) {
        int r = rank[i];
        if (r == 0) {
            h = 0;
            continue;
        }
        int j = sa[r - 1];
        while (i + h < n && j + h < n && s[i + h] == s[j + h])
            ++h;
        lcp[r] = h;
        h = max(h - 1, 0);
    }
    return lcp;
}
04 / INTUITION → DEFINITION → PROOF

4. 두 접미사의 LCP를 구간 최솟값으로

직관. 정렬된 접미사를 길로, 이웃 LCP를 연결 간선의 길이로 생각한다. 양 끝이 공통으로 유지할 수 있는 길이는 그 사이에서 가장 짧은 이웃 LCP이다.

정의 4 · RMQ 환원

iji\ne j에 대해 a=min(rank[i],rank[j]),b=max(rank[i],rank[j])a=\min (\operatorname{rank}[i],\operatorname{rank}[j]), b=\max (\operatorname{rank}[i],\operatorname{rank}[j]). 배열 A의 RMQ(l,r)\operatorname{RMQ}(l,r)는 반열린 구간 A[l…r)의 최솟값이며 l<rl<r일 때만 정의한다.

lcp(Si,Sj)={Nii=j,RMQLCP(a+1,b+1)ij.\operatorname{lcp}(S_i,S_j)=\begin{cases}N-i&i=j,\\\operatorname{RMQ}_{\mathrm{LCP}}(a+1,b+1)&i\ne j.\end{cases}
정리 6. 두 접미사의 LCP와 RMQ의 동치

a<ba<b에 대해 q=min(LCP[a+1],,LCP[b])q=\min (\mathrm{LCP}[a+1],\ldots ,\mathrm{LCP}[b]), h=lcp(S[SA[a]],S[SA[b]])h=\operatorname{lcp}(S[\mathrm{SA}[a]\ldots ],S[\mathrm{SA}[b]\ldots ])라 두자.

hqh\ge q. 구간 안의 모든 인접 접미사 쌍이 앞 q글자를 공유한다. 동일성의 추이성으로 구간 양 끝도 앞 q글자를 공유한다.

qhq\ge h. 양 끝 접미사가 앞 h글자를 공유하므로 보조정리 3에 의해 사이의 모든 접미사가 그 접두사를 공유한다. 따라서 모든 인접 LCP가 h 이상이고 그 최솟값 q도 h 이상이다.

두 부등식을 합치면 h=qh=q이다. 동일한 시작 위치 i=ji=j이면 비교 대상이 같으므로 LCP=Ni\mathrm{LCP}=N-i이며, 비어 있는 RMQ 구간을 질의하지 않는다.

두 접미사를 골라 RMQ 확인 →

접미사는 꼭짓점, LCP는 이웃을 연결하는 간선

그림 5. 정렬된 a → ana → anana의 연결 구간
a r=0r=0LCP[1]=1\mathrm{LCP}[1]=1ana r=1r=1LCP[2]=3\mathrm{LCP}[2]=3anana r=2r=2

양 끝 a와 anana의 공통 길이는 min(1,3)=1\min (1,3)=1. 가장 작은 이웃 LCP가 전체 구간에서 유지되는 공통 접두사의 길이를 결정한다.

순위 a와 b 사이를 연결하는 간선 번호는 a+1,…,b이다. LCP[a]\mathrm{LCP}[a]는 구간 밖의 이전 접미사와 연결되므로 포함하면 안 된다. C++에서는 이를 반열린 구간 [a+1,b+1)[a+1,b+1)로 전달한다.

Sparse Table이 최솟값을 두 번의 조회로 구하는 방법

st[k][l]\operatorname{st}[k][l]LCP[ll+2k)\mathrm{LCP}[l\ldots l+2^{k})의 최솟값이다. 기초는 st[0][l]=LCP[l]\operatorname{st}[0][l]=\mathrm{LCP}[l]. 길이 2k2^{k} 구간을 두 절반으로 쪼개면 다음 점화식을 얻는다.

st[k][l]=min(st[k1][l],st[k1][l+2k1])\operatorname{st}[k][l]=\min\bigl(\operatorname{st}[k-1][l],\operatorname{st}[k-1][l+2^{k-1}]\bigr)
RMQ(l,r)=min(st[k][l],st[k][r2k])\operatorname{RMQ}(l,r)=\min\bigl(\operatorname{st}[k][l],\operatorname{st}[k][r-2^k]\bigr)
k=log2(rl)(l<r)k=\lfloor\log_2(r-l)\rfloor\quad(l<r)
그림 6. LCP[14)\mathrm{LCP}[1\ldots 4) 질의 · 두 길이 2 구간으로 덮기
질의130
왼쪽 [1,3)[1,3)13·
오른쪽 [2,4)·30

min(min(1,3),min(3,0))=0\min (\min (1,3), \min (3,0))=0. 가운데 3을 두 번 보아도 min(3,3)=3\min (3,3)=3이므로 문제가 없다. 합계에 같은 방법을 쓰면 중복 원소가 두 번 더해진다.

정리 7. RMQ 자료구조의 정당성과 복잡도

Sparse Table. st[k][i]\operatorname{st}[k][i]에 i에서 시작하는 길이 2k2^{k} 구간의 최솟값을 저장한다. 길이 절반인 두 구간의 최솟값으로 계산할 수 있으므로 O(NlogN)O(N \log N) 시간·공간이다. 길이 d인 질의는 k=log2dk=\lfloor \log _{2}d\rfloor로 두고 양 끝의 길이 2k2^{k} 구간 두 개로 덮는다. 두 구간이 겹쳐도 min(x,x)=x\min (x,x)=x이므로 답이 변하지 않는다. 로그를 전처리하면 질의는 O(1)O(1)이다.

두 구간이 빠짐없이 덮는 이유. q=2kq=2^{k}라 두면 qd<2qq\le d<2q이다. [l,l+q)[l,l+q)[rq,r)[r-q,r)는 모두 질의 [l,r)[l,r) 안에 있고,d2q, d\le 2q이므로 사이에 빈틈이 없다. 두 구간의 합집합이 정확히 질의 구간이며 중복은 min의 멱등성으로 제거된다. 전처리 점화식도 길이 1을 기초로 길이별 귀납하면 정확하다.

Segment Tree. 각 부모를 두 자식의 최솟값으로 구성하면 O(N)O(N)개의 노드를 한 번씩 처리한다. 구간 질의는 각 깊이에서 양 경계에 걸친 노드만 더 내려가므로 O(logN)O(\log N)개의 대표 구간으로 분해된다. 구성·공간은 O(N)O(N), 질의는 O(logN)O(\log N)이다.

SA-IS의 선형 시간은 이 페이지에서 구현하거나 증명하는 범위가 아니다. 정수 알파벳에 대한 별도 알고리즘이며, 위 Doubling 구현의 복잡도와 구분한다.

활용. Sparse Table 전처리 후 임의의 접미사 LCP는 O(1)O(1). 화면은 짧은 예제의 값을 직접 순회해서 강조하며, 실제 질의 코드는 마지막 절에 제시한다.

05 / APPLICATION → PROOF → EXAMPLE

5. 서로 다른 부분 문자열과 최장 반복

직관. 모든 부분 문자열은 어떤 접미사의 접두사이다. 정렬된 접미사를 순서대로 보면서 이전에 등장한 접두사만 빼면 중복을 제거할 수 있다.

D는 비어 있지 않은 서로 다른 부분 문자열 수, LrepeatL_{\mathrm{repeat}}는 최장 반복 부분 문자열 길이이다. 비어 있지 않은 서로 다른 문자열 값을 센다. 반복은 서로 다른 시작 위치 두 곳 이상의 등장이며, 구간 겹침을 허용한다.

D=N(N+1)2r=0N1LCP[r]D=\frac{N(N+1)}2-\sum_{r=0}^{N-1}\mathrm{LCP}[r]
Lrepeat={max0r<NLCP[r]N>0,0N=0.L_{\mathrm{repeat}}=\begin{cases}\max_{0\le r<N}\mathrm{LCP}[r]&N>0,\\0&N=0.\end{cases}
정리 8. 서로 다른 부분 문자열 수와 최장 반복 부분 문자열

서로 다른 부분 문자열 수. 모든 비어 있지 않은 부분 문자열은 어떤 접미사의 접두사이다. SA 순서에서 현재 접미사의 길이를 \ell, 이전 이웃과의 LCP를 h라 하자. 길이 1,…,h인 접두사는 이미 등장했다. 길이 h보다 긴 접두사가 더 앞선 접미사에 있었다면, 보조정리 3에 의해 바로 이전 이웃도 이를 공유해야 하므로 h의 정의에 모순이다. 따라서 새로 추가되는 부분 문자열은 정확히 h\ell -h개이다. 합산하면 N(N+1)/2r=0N1LCP[r]N(N+1)/2 - \sum_{r=0}^{N-1}\mathrm{LCP}[r]이다.

최장 반복 부분 문자열. 인접 LCP가 h이면 서로 다른 두 시작 위치에서 길이 h의 문자열이 등장한다. 역으로 길이 L의 문자열이 두 번 이상 등장하면, 그 문자열을 접두사로 갖는 접미사들은 SA의 연속 구간을 이룬다. 해당 구간에는 인접 쌍이 있고 LCP가 L 이상이다. 따라서 겹침을 허용한 최장 반복 부분 문자열 길이는 max0r<NLCP[r]\max_{0\le r<N}\mathrm{LCP}[r]이다. 빈 입력은 답 0으로 처리한다.

부분 문자열은 “접미사의 접두사”로 센다

정렬 순서로 처음 등장하는 접두사만 추가
접미사길이이미 나온 길이새로 추가되는 문자열개수
a11…0 (없음)a1
ana31…1an, ana2
anana51…3anan, anana2
banana61…0 (없음)b, ba, ban, bana, banan, banana6
na21…0 (없음)n, na2
nana41…2nan, nana2

접미사마다 원문에서의 등장은 다르더라도 문자열 값이 같은 접두사는 한 번만 센다. 표의 새 개수는 1+2+2+6+2+2=151+2+2+6+2+2=15이다. 이전에 나온 접미사 전체가 아니라 바로 이전 이웃의 LCP만 빼도 되는 이유는 뒤의 사전순 연속성 보조정리로 증명한다.

07 / GENERALIZATION → PROOF

7. 여러 문자열로 확장

직관. 각 접미사에 원본 소속을 붙인다. 서로 다른 소속의 접미사들이 공유하는 접두사는 두 원문에 모두 등장한다.

원문마다 고유하며 원래 알파벳에 없는 구분자를 뒤에 붙여 연결한다. 원본 문자에서 시작한 접미사만 후보로 삼는다. 목표는 모든 대상 원문에 적어도 한 번 등장하는 가장 긴 부분 문자열이며, 어떤 원문이 비어 있으면 답은 0이다.

정리 10. 여러 문자열의 공통 부분 문자열

서로 다른 문자열 사이에 입력에 없는 서로 다른 구분자를 넣고 각 접미사의 원본 문자열을 기록한다. 서로 다른 원본의 두 접미사가 일치하는 구간은 서로 다른 구분자를 동시에 통과할 수 없으므로 원본 경계를 넘어 확장되지 않는다.

두 문자열. 공통 부분 문자열 P가 있으면 P를 접두사로 갖는 SA 구간에 두 원본의 접미사가 모두 있다. 이 구간 안에는 원본 소속이 바뀌는 인접 쌍이 있으며 그 LCP는 P|P| 이상이다. 역으로 서로 다른 원본의 접미사 쌍의 LCP는 실제 공통 부분 문자열이다. 따라서 소속이 다른 인접 쌍의 최대 LCP로 최장 공통 부분 문자열을 구할 수 있다.

세 문자열 이상. 모든 원본을 포함하는 SA 구간을 고려해야 한다. 정리 6에 의해 구간의 인접 LCP 최솟값은 구간 전체가 공유하는 접두사 길이이다. 모든 원본이 등장하는 구간 중 이 값의 최댓값을 구하면 답이 된다. 단순히 소속이 다른 인접 쌍만 확인하면 모든 원본에 공통이라는 조건을 보장하지 못한다.

세 원문 이상에서는 모든 소속이 들어 있는 SA 구간이 필요하다. 서로 다른 두 소속만 확인하면 “모든 원문”이라는 조건이 누락된다. SA-IS와 동적 문자열 인덱싱은 이 강의의 구현·증명 범위가 아니다.

VISUAL LAB / 정의와 불변식 확인

8. SA · LCP 구성 및 질의 시각화

banana의 SA는 [5, 3, 1, 0, 4, 2], LCP는 [0, 1, 3, 0, 0, 2]이다. aaaaaa를 넣으면 반복이 많은 경우도 살펴볼 수 있다.

Suffix laboratory

파랑: 첫 블록 · 초록: 둘째 블록 / 재사용
시작 i / 접미사 / 비교 키 / 새 rank

같은 키는 같은 rank를 받는다. 화면에서 동률은 시작 인덱스순으로 표시하며, 아직 전체 접미사 순서가 확정된 것은 아니다.

0 / 0
09 / IMPLEMENTATION

9. 질의 구현과 실행 예제

SA와 LCP를 실제 질의 함수로 연결하기

아래 코드는 앞의 suffix_array와 lcp_array를 사용한다. SparseMin은 반열린 구간의 최솟값을 구하고, SuffixIndex는 문자열과 SA·rank·LCP·RMQ를 함께 보관한다. RMQ의 빈 구간과 범위를 벗어난 위치는 예외로 거절한다.

suffix-query.cppC++17
// suffix-array.cpp, kasai.cpp 정의 이후에 포함.
#include <stdexcept>

struct SparseMin {
    int n;
    vector<int> lg;
    vector<vector<int>> st;

    explicit SparseMin(const vector<int>& a) : n((int)a.size()), lg(n+1) {
        for (int i = 2; i <= n; ++i) lg[i] = lg[i/2] + 1;
        if (n == 0) return;
        st.assign(lg[n]+1, vector<int>(n));
        st[0] = a;
        for (int k = 1; k <= lg[n]; ++k) {
            int len = 1 << k, half = len / 2;
            for (int i = 0; i <= n-len; ++i)
                st[k][i] = min(st[k-1][i], st[k-1][i+half]);
        }
    }

    // 비어 있지 않은 반열린 구간 [l, r).
    int query(int l, int r) const {
        if (l < 0 || l >= r || r > n) throw out_of_range("RMQ range");
        int k = lg[r-l];
        return min(st[k][l], st[k][r-(1 << k)]);
    }
};

struct SuffixIndex {
    string s;
    vector<int> sa, rank, lcp;
    SparseMin rmq;

    explicit SuffixIndex(string text)
        : s(std::move(text)), sa(suffix_array(s)), rank(s.size()),
          lcp(lcp_array(s, sa)), rmq(lcp) {
        for (int r = 0; r < (int)sa.size(); ++r) rank[sa[r]] = r;
    }

    int lcp_suffix(int i, int j) const {
        int n = (int)s.size();
        if (i < 0 || j < 0 || i >= n || j >= n)
            throw out_of_range("suffix position");
        if (i == j) return n-i;
        int a = rank[i], b = rank[j];
        if (a > b) swap(a,b);
        return rmq.query(a+1,b+1);
    }

    // S[l1,r1)와 S[l2,r2) 비교: -1 / 0 / 1. 빈 구간 허용.
    int compare_substrings(int l1, int r1, int l2, int r2) const {
        int n = (int)s.size();
        if (l1 < 0 || l1 > r1 || r1 > n || l2 < 0 || l2 > r2 || r2 > n)
            throw out_of_range("substring range");
        int a = r1-l1, b = r2-l2;
        if (a == 0 || b == 0) return (a>b)-(a<b);
        int h = min({lcp_suffix(l1,l2),a,b});
        if (h == min(a,b)) return (a>b)-(a<b);
        return (unsigned char)s[l1+h] < (unsigned char)s[l2+h] ? -1 : 1;
    }

    // 비어 있지 않은 패턴의 매칭 순위 구간 [left, right).
    pair<int,int> pattern_range(const string& p) const {
        if (p.empty()) throw invalid_argument("empty pattern");
        auto cmp = [&](int i) {
            size_t h = 0, remain = s.size()-i;
            while (h < p.size() && h < remain && s[i+h] == p[h]) ++h;
            if (h == p.size()) return 0; // 패턴 전체가 접두사로 일치
            if (h == remain) return -1; // 접미사가 먼저 끝남
            return (unsigned char)s[i+h] < (unsigned char)p[h] ? -1 : 1;
        };
        auto bound = [&](bool upper) {
            int l = 0, r = (int)sa.size();
            while (l < r) {
                int m = l + (r-l)/2, c = cmp(sa[m]);
                if (c < 0 || (upper && c == 0)) l = m+1;
                else r = m;
            }
            return l;
        };
        return {bound(false),bound(true)};
    }
};
연산반환값 / 조건비용
lcp_suffix(i,j)두 비어 있지 않은 접미사의 LCPO(1)O(1)
compare_substrings(l1,r1,l2,r2)반열린 두 구간 비교 −1 / 0 / 1, 빈 구간 허용O(1)O(1)
pattern_range(p)일치하는 SA 순위 구간 [left,right)[\mathrm{left},\mathrm{right}), 빈 패턴 제외O(plog(N+1))O(|p| \log (N+1))

이 구현 전체의 전처리는 비교 정렬 Doubling 때문에 O(Nlog2N)O(N \log ^{2}N), 저장 공간은 Sparse Table 때문에 O(NlogN)O(N \log N)이다(N2)(N\ge 2). SA·LCP만 만들 때의 O(N)O(N) 공간과 구별한다. 패턴의 등장 위치 K개를 나열하면 추가 O(K)O(K), 그 위치들을 원문순으로 정렬하면 추가 O(KlogK)O(K \log K)이다.

네 파일을 연결한 실행 예제

앞의 suffix-array.cpp, kasai.cpp, suffix-query.cpp와 아래 파일을 같은 폴더에 둔다. c++ -std=c++17 -O2 suffix-example.cpp -o suffix로 컴파일한다. 다른 세 파일은 예제 파일에 포함되므로 별도 번역 단위로 함께 컴파일하지 않는다.

suffix-example.cppC++17
#include <iostream>
#include "suffix-array.cpp"
#include "kasai.cpp"
#include "suffix-query.cpp"

int main() {
    SuffixIndex index("banana");
    auto print = [](const char* label, const vector<int>& a) {
        cout << label;
        for (int x : a) cout << ' ' << x;
        cout << '\n';
    };
    print("SA:", index.sa);
    print("rank:", index.rank);
    print("LCP:", index.lcp);
    cout << "lcp(1,3): " << index.lcp_suffix(1,3) << '\n';
    cout << "compare [1,4), [3,6): "
         << index.compare_substrings(1,4,3,6) << '\n';
    auto [l,r] = index.pattern_range("ana");
    cout << "ana positions (SA order):";
    for (int k = l; k < r; ++k) cout << ' ' << index.sa[k];
    cout << '\n';
    long long n = index.s.size(), distinct = n*(n+1)/2;
    int repeated = 0;
    for (int h : index.lcp) {
        distinct -= h;
        repeated = max(repeated,h);
    }
    cout << "distinct: " << distinct << '\n';
    cout << "longest repeated: " << repeated << '\n';
}
표준 출력 · [1,4)[1,4)[3,6)[3,6)은 둘 다 ana
SA: 5 3 1 0 4 2
rank: 3 2 5 1 4 0
LCP: 0 1 3 0 0 2
lcp(1,3): 3
compare [1,4), [3,6): 0
ana positions (SA order): 3 1
distinct: 15
longest repeated: 3