Suffix Array · LCP
문자열을 정렬된 인덱스로 바꾸기.
접미사 순위에서 공통 접두사 질의까지.
0. 동기: 반복되는 문자열 질의를 위한 인덱스
한 본문에서 여러 패턴을 찾거나 서로 다른 위치의 문자열을 반복해서 비교할 때, 질의마다 원문을 처음부터 훑는 비용을 피하고 싶다. 본문이 고정되어 있다면 한 번 정렬해 두고 이후 질의를 빠르게 처리하는 전처리가 가능하다.
문자열 S의 길이를 N이라 두자. 접미사 배열은 문자열을 새로 만드는 자료구조가 아니라, 어디서부터 읽으면 사전순으로 몇 번째인가를 기록한 인덱스이다. LCP 배열은 그 정렬에서 이웃한 두 접미사가 앞에서부터 몇 글자나 같은지 기록한다.
이 둘을 구성하면 패턴이 시작하는 위치들을 이분 탐색하고, 두 접미사의 공통 접두사 길이를 질의하고, 서로 다른 부분 문자열의 수를 구할 수 있다. 여기서는 banana 하나를 끝까지 계산하면서 각 결과가 어떻게 연결되는지 확인한다.
선수 지식은 배열, 정렬, 기본적인 반복문이다. 사전순·접미사·이분 탐색·RMQ의 의미는 아래에서 정의한다. 문자열 구간은 , 즉 l 포함·r 제외이며 길이는 이다. 빈 접미사 는 배열에 넣지 않는다.
사전순 비교의 두 가지 규칙
- 왼쪽부터 비교하여 처음 다른 문자가 나오면 그 문자의 순서로 결정한다. 는 이기 때문이다.
- 한쪽이 먼저 끝날 때까지 모두 같으면 짧은 쪽이 먼저이다. . “없음”은 뒤에 문자가 더 있는 것보다 앞선다.
이 페이지의 예제는 순서이다. 접미사들은 시작 위치가 다르면 길이도 다르므로, 전체 문자열로서 서로 같을 수 없다. 다만 앞 몇 글자만 비교하는 중간 단계에서는 동률이 가능하다.
| 시작 i | 0 | 1 | 2 | 3 | 4 | 5 | |
|---|---|---|---|---|---|---|---|
| 0 | b | a | n | a | n | a | 3 |
| 1 | · | a | n | a | n | a | 2 |
| 2 | · | · | n | a | n | a | 5 |
| 3 | · | · | · | a | n | a | 1 |
| 4 | · | · | · | · | n | a | 4 |
| 5 | · | · | · | · | · | a | 0 |
위 표의 i는 원문의 좌표이다. 아래 표의 r은 정렬된 배열의 좌표이다. 둘을 구별해야 한다. 은 순위 2에 원문 위치 1의 접미사 anana가 있다는 뜻이고, 는 같은 사실을 반대 방향으로 읽은 것이다.
| 순위 r | 접미사 | ||
|---|---|---|---|
| 0 | 5 | a | 0 |
| 1 | 3 | ana | 1 |
| 2 | 1 | anana | 3 |
| 3 | 0 | banana | 0 |
| 4 | 4 | na | 0 |
| 5 | 2 | nana | 2 |
는 위치 r에서 시작한 접미사의 값이 아니다. 예를 들어 은 정렬된 위치 1과 2의 접미사 ana·anana가 ana를 공유한다는 뜻이다. 은 비교할 이전 이웃이 없어서 정한 약속이다.
그냥 접미사 문자열을 전부 정렬하면 안 되는가
정의대로 구현할 수는 있다. 하지만 N개 접미사를 모두 복사하면 문자 수만 이다. 시작 위치만 저장해도 접미사를 직접 비교할 때 한 번에 글자를 볼 수 있다. Doubling은 긴 문자열 비교를 두 정수 비교로 바꾸어 이 비용을 줄인다.
1. 두 좌표계: 원문 위치와 사전순 순위
직관. 정렬된 책장의 칸 번호와 책에 붙은 원래 번호는 다르다. SA는 칸에서 원래 번호로, rank는 원래 번호에서 칸으로 이동한다.
정의 1 · 접미사 배열과 역배열
전순서가 주어진 유한 알파벳 위의 문자열 를 둔다. 이다. 빈 접미사는 제외한다. SA는 의 순열 중 를 만족하는 유일한 순열이다. 서로 다른 접미사는 길이가 달라 같을 수 없으므로 순서가 유일하다. 로 정의한다.
비용 분석은 문자 비교, 배열 접근, 인덱스 산술을 로 세는 RAM 모델을 따른다. 정수에는 위치와 길이를 담을 충분한 비트가 있다고 가정한다.
정리 1. SA와 최종 rank의 역함수 관계
SA는 서로 다른 시작 인덱스 의 순열이다. 로 정의하면 각 시작 위치에 정확히 한 순위가 대응한다. 를 대입하면 가 바로 따른다. 두 접미사의 사전순 비교는 두 rank의 비교와 동치이다. 단, Doubling 중간 단계의 동치류 번호에는 이 역함수 관계를 적용하지 않는다.
∎
활용. 전체 접미사의 대소는 rank 비교 한 번으로 결정된다. 길이가 제한된 부분 문자열끼리는 이 결론을 그대로 사용할 수 없다.
2. Doubling: 긴 비교를 두 정수 비교로 바꾸기
직관. 앞 k글자의 순서를 이미 알면 앞 2k글자는 길이 k인 블록 두 개로 비교할 수 있다. 첫 블록이 같을 때만 둘째 블록을 비교한다.
정의 2 · 중간 동치류
. 는 의 동치와 순서를 보존하는 정수 번호이다. . 아직 동률이 가능한 와 최종 역배열 rank는 다르다.
정리 2. Doubling의 순위 불변식과 종료 조건
불변식. 길이 k 단계의 rank는 각 접미사의 앞 글자에 대해 동치와 사전순을 보존한다. 즉 두 rank가 같을 필요충분조건은 비교 대상 문자열이 같음이고, rank의 대소는 그 문자열의 사전순과 일치한다.
기초. 에서 문자의 순위로 정의하면 불변식이 성립한다. C++ 구현은 문자 코드 자체를 초기 rank로 사용한다. 연속된 번호가 아니어도 순서와 동치를 보존하므로 충분하다.
귀납 단계. 앞 2k글자를 비교할 때 먼저 앞 k글자를 비교하고, 같으면 다음 k글자를 비교한다. 귀납 가정에 의해 이는 의 사전순 비교와 같다. 뒷부분이 없을 때 −1을 사용하면 짧은 문자열이 먼저 온다는 규칙도 보존된다. 정렬 후 같은 키에 같은 번호, 다른 키에 증가한 번호를 부여하면 2k 단계의 불변식이 성립한다.
길이가 k보다 짧을 때도 성립하는가. 첫 블록이 길이 k 미만이면 그 접미사는 이미 끝났으므로 둘째 블록은 없다. 따라서 짧은 첫 블록이 다른 첫 블록의 proper prefix인 경우에도 전체 비교에서 반드시 먼저 온다. 첫 블록이 같고 길이 k이면 둘째 블록 비교로 넘어가며, 비어 있는 둘째 블록은 −1로 모든 비어 있지 않은 블록보다 앞선다. 그러므로 모든 경계 경우에서 쌍 비교가 의 동치와 순서를 모두 보존한다.
모든 rank가 다르면 접미사들의 순서가 이미 비교된 부분에서 결정됐으므로 나머지 문자와 무관하게 전체 순서도 확정된다. 그렇지 않아도 이면 모든 접미사를 끝까지 비교하므로 정렬이 완료된다. 단계 수는 이다. 은 자명한 경우이다.
∎
두 블록으로 분해하면 왜 정수 두 개로 충분한가
(1, 1)(1, 0)(0, −1)첫 블록의 rank가 같으면 둘째 블록을 본다. 따라서 이 보다 먼저이다. 는 첫 블록 a 자체가 an보다 짧아 더 먼저이다.
rank 번호의 크기는 사전순만 표현한다. 문자 길이나 원문 인덱스가 아니다. 첫 문자 단계에서는 로 압축했지만, C++ 코드는 문자 코드 97, 98, 110을 그대로 쓴다. 순서와 동률이 같으므로 첫 쌍 정렬 결과는 동일하다.
banana의 모든 Doubling 단계
| 시작 i | 비교 부분 | 이전 rank로 만든 키 | 새 |
|---|---|---|---|
| 1 | a | (0) | 0 |
| 3 | a | (0) | 0 |
| 5 | a | (0) | 0 |
| 0 | b | (1) | 1 |
| 2 | n | (2) | 2 |
| 4 | n | (2) | 2 |
| 시작 i | 비교 부분 | 이전 rank로 만든 키 | 새 |
|---|---|---|---|
| 5 | a | (0, -1) | 0 |
| 1 | an | (0, 2) | 1 |
| 3 | an | (0, 2) | 1 |
| 0 | ba | (1, 0) | 2 |
| 2 | na | (2, 0) | 3 |
| 4 | na | (2, 0) | 3 |
| 시작 i | 비교 부분 | 이전 rank로 만든 키 | 새 |
|---|---|---|---|
| 5 | a | (0, -1) | 0 |
| 3 | ana | (1, 0) | 1 |
| 1 | anan | (1, 1) | 2 |
| 0 | bana | (2, 3) | 3 |
| 4 | na | (3, -1) | 4 |
| 2 | nana | (3, 3) | 5 |
앞 2글자 단계에서 과 은 모두 an이므로 이다. 와 도 na가 같아 이다. 동률에 임의로 다른 번호를 부여하면 다음 단계에서 올바른 뒷블록 비교를 할 수 없다.
앞 4글자 단계에서는 모든 rank가 달라진다. 전체 길이가 6이어도 더 볼 필요가 없다. 어떤 두 접미사도 이미 비교한 부분에서 순서가 결정됐기 때문이다. 아직 동률이 있더라도 비교 길이가 N 이상이면 모든 접미사를 끝까지 본 것이므로 반드시 종료한다.
정렬하는 동안 rank를 바꾸면 안 된다
현재 단계의 모든 키는 같은 이전 rank 배열에서 만들어야 한다. 일부 위치의 rank를 먼저 덮어쓰면 뒤의 키는 새 값과 옛 값이 섞인다. 따라서 새 번호를 next_rank에 전부 기록하고, 단계 마지막에 rank와 교환한다. 비교 함수도 정렬 중에는 변하지 않는 rank만 읽는다.
Counting Sort로 바꿀 때: −1을 0으로, 실제 rank를 로 옮긴 뒤 둘째 키로 안정 정렬하고 첫째 키로 안정 정렬한다. 마지막 정렬이 같은 첫째 키의 원소들 사이에서 둘째 키 순서를 보존해야 쌍의 사전순이 된다. 키 범위를 으로 압축하면 두 번의 정렬도 이다.
#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;
} 3. LCP: 재사용 가능한 일치 정보
직관. 긴 문자열 두 개가 h글자 일치하면 첫 글자를 지워도 글자는 남는다. 어려운 점은 다음 접미사의 비교 상대가 바뀔 수 있다는 것이다. 그 사이의 문자열도 같은 접두사를 공유한다는 보조정리가 이 간극을 메운다.
정의 3 · 공통 접두사와 LCP 배열
. 최댓값은 유한하고 0이 후보이므로 존재한다. 에서 이다. 이 페이지는 이전 이웃 기준이며 다음 이웃 기준 정의와 인덱스를 섞지 않는다.
보조정리 3. 동일 접두사를 갖는 접미사들의 사전순 연속성
A와 C가 길이 h의 접두사 P를 공유하고 라고 하자. B가 P를 공유하지 않는다고 가정한다. 첫 불일치에서 B의 문자가 작으면 , 크면 가 된다. B가 h글자 이전에 끝나는 경우에도 B는 A의 proper prefix이므로 이다. 모두 가정에 모순이다. 따라서 B도 P를 공유한다. 즉 특정 접두사를 갖는 접미사들은 SA의 연속 구간을 이룬다.
∎
Kasai의 반복 규칙
원문 순서 로 순회한다. 이면 과 비교한다. 이전 반복에서 가져온 하한 h에서 시작하여 일치하는 동안 증가시키고 에 저장한다. 다음 반복을 위해 으로 둔다. 이면 이다.
정리 4. Kasai의 재사용이 올바른 이유
i의 사전순 바로 이전 접미사를 라 하고 라 하자. 이면 다음 위치에서 재사용하는 길이는 0이므로 자명하다. 이면 와 의 첫 문자가 같고, 이를 제거한 과 은 글자를 공유한다.
같은 첫 문자를 제거해도 사전순은 보존되므로 이다. 의 바로 이전 접미사를 Sₚ라 하면 이다. 보조정리 3에 의해 Sₚ도 동일한 글자를 공유한다. 따라서 다음 비교는 최소 까지 이미 일치함이 보장된다.
이면 과 모두 N보다 작으므로 두 접미사는 배열에 실제로 존재한다. 또한 이 보다 작으므로 이 경우 의 순위가 0일 수 없다. 이면 하한이 0이므로 이전 이웃의 존재 여부와 무관하게 안전하다. “비교 상대가 반드시 이다”라는 더 강한 주장은 필요하지 않다.
이 하한부터 문자가 같은 동안 증가시키고, 불일치 또는 문자열 끝에서 멈추면 정확한 LCP를 얻는다. 이면 이전 접미사가 없으므로 으로 정의하고 h를 0으로 초기화한다. 모든 i를 순회하고 rank가 순열이므로 모든 LCP 원소를 정확히 한 번 계산한다.
반복문 불변식. i번째 반복 진입 시 h는 현재 비교 상대와의 실제 LCP 이하이다. 에서는 이므로 성립하고, 위 보조 성질이 다음 반복에 대한 귀납 단계를 보장한다. while에서는 앞 h글자의 동일성을 유지하며, 멈춘 이유가 불일치 또는 한쪽 문자열의 종료이므로 얻은 h는 하한에 그치지 않고 정확한 최댓값이다.
∎
구현상의 결론. j는 매번 SA에서 다시 찾는다. 재사용하는 것은 비교 상대의 번호가 아니라 일치 길이의 하한이다.
정리 5. Kasai 알고리즘의 선형 시간
h가 증가할 때마다 성공한 문자 비교가 한 번 발생한다. 일반 반복 끝에서의 감소는 최대 1이므로 감소량 합은 N 이하이다. 인 위치는 한 번뿐이며 이때의 초기화로 발생할 수 있는 추가 감소량도 N 이하이다. h는 0에서 시작하고 항상 이므로 총 증가량은 이다.
각 i에서 마지막 불일치·경계 검사와 나머지 연산은 이므로 전체 시간은 이다. rank와 LCP가 길이 N인 배열이므로 추가 공간도 이다. 매 위치마다 h를 0으로 바꾼 뒤 비교하면 이 분석이 성립하지 않으며, 반복 문자열에서 이 될 수 있다.
증가 총량을 I, 감소 총량을 D, 마지막 값을 H라 쓰면 이므로 이다. 에서 을 얻는다. 이 상계는 느슨하지만 선형임을 보이기에 충분하다. 안쪽 while이 있다는 문법만으로 라고 판단할 수 없는 이유이다.
∎
“다음”은 정렬의 다음 행이 아니라 원문의
ananaanananana재사용 위 그림에서는 다음 위치의 이전 이웃도 마침 이다. 하지만 일반적으로 비교 상대가 이라는 보장은 없다. 실제 상대는 언제나 로 다시 찾는다. 은 재사용 길이를 증명하기 위한 비교 대상일 뿐이다.
실제로 aabaa의 SA는 [4,3,0,1,2]이다. 의 이전 이웃은 이며 aabaa와 aa의 LCP는 2이다. 다음 의 이전 이웃은 가 아니라 위치 0의 aabaa이다. 그래도 가 모두 a를 공유하므로 을 안전하게 재사용한다.
Kasai의 실제 계산 기록
| i | 이전 이웃 j | 재사용 seed | 완성 h | 다음 seed | |
|---|---|---|---|---|---|
| 0 | 3 | 1 | 0 | 0 | 0 |
| 1 | 2 | 3 | 0 | 3 | 2 |
| 2 | 5 | 4 | 2 | 2 | 1 |
| 3 | 1 | 5 | 1 | 1 | 0 |
| 4 | 4 | 0 | 0 | 0 | 0 |
| 5 | 0 | 없음 | 0 | 0 | 0 |
에서 anana와 ana를 비교해 . 다음 에서는 na 두 글자를 다시 비교하지 않고 건너뛴다. 에서는 a 한 글자를 재사용한다. 는 이므로 이전 이웃이 없으며 h를 0으로 둔다.
은 정답이 아니라 하한
이전 반복에서 가져온 seed까지 같다는 사실만 보장된다. 그 뒤도 같을 수 있으므로 while로 추가 비교해야 한다. 반대로 매번 으로 초기화하면 이미 확인한 긴 반복 구간을 계속 비교하게 되어 aaaa…a에서 이차 시간이 걸릴 수 있다.
// 앞 코드의 헤더 선언 이후에 작성.
// 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;
} 4. 두 접미사의 LCP를 구간 최솟값으로
직관. 정렬된 접미사를 길로, 이웃 LCP를 연결 간선의 길이로 생각한다. 양 끝이 공통으로 유지할 수 있는 길이는 그 사이에서 가장 짧은 이웃 LCP이다.
정의 4 · RMQ 환원
에 대해 . 배열 A의 는 반열린 구간 A[l…r)의 최솟값이며 일 때만 정의한다.
정리 6. 두 접미사의 LCP와 RMQ의 동치
에 대해 , 라 두자.
. 구간 안의 모든 인접 접미사 쌍이 앞 q글자를 공유한다. 동일성의 추이성으로 구간 양 끝도 앞 q글자를 공유한다.
. 양 끝 접미사가 앞 h글자를 공유하므로 보조정리 3에 의해 사이의 모든 접미사가 그 접두사를 공유한다. 따라서 모든 인접 LCP가 h 이상이고 그 최솟값 q도 h 이상이다.
두 부등식을 합치면 이다. 동일한 시작 위치 이면 비교 대상이 같으므로 이며, 비어 있는 RMQ 구간을 질의하지 않는다.
∎
접미사는 꼭짓점, LCP는 이웃을 연결하는 간선
a →ana →anana 양 끝 a와 anana의 공통 길이는 . 가장 작은 이웃 LCP가 전체 구간에서 유지되는 공통 접두사의 길이를 결정한다.
순위 a와 b 사이를 연결하는 간선 번호는 a+1,…,b이다. 는 구간 밖의 이전 접미사와 연결되므로 포함하면 안 된다. C++에서는 이를 반열린 구간 로 전달한다.
Sparse Table이 최솟값을 두 번의 조회로 구하는 방법
은 의 최솟값이다. 기초는 . 길이 구간을 두 절반으로 쪼개면 다음 점화식을 얻는다.
. 가운데 3을 두 번 보아도 이므로 문제가 없다. 합계에 같은 방법을 쓰면 중복 원소가 두 번 더해진다.
정리 7. RMQ 자료구조의 정당성과 복잡도
Sparse Table. 에 i에서 시작하는 길이 구간의 최솟값을 저장한다. 길이 절반인 두 구간의 최솟값으로 계산할 수 있으므로 시간·공간이다. 길이 d인 질의는 로 두고 양 끝의 길이 구간 두 개로 덮는다. 두 구간이 겹쳐도 이므로 답이 변하지 않는다. 로그를 전처리하면 질의는 이다.
두 구간이 빠짐없이 덮는 이유. 라 두면 이다. 와 는 모두 질의 안에 있고이므로 사이에 빈틈이 없다. 두 구간의 합집합이 정확히 질의 구간이며 중복은 min의 멱등성으로 제거된다. 전처리 점화식도 길이 1을 기초로 길이별 귀납하면 정확하다.
Segment Tree. 각 부모를 두 자식의 최솟값으로 구성하면 개의 노드를 한 번씩 처리한다. 구간 질의는 각 깊이에서 양 경계에 걸친 노드만 더 내려가므로 개의 대표 구간으로 분해된다. 구성·공간은 , 질의는 이다.
SA-IS의 선형 시간은 이 페이지에서 구현하거나 증명하는 범위가 아니다. 정수 알파벳에 대한 별도 알고리즘이며, 위 Doubling 구현의 복잡도와 구분한다.
∎
활용. Sparse Table 전처리 후 임의의 접미사 LCP는 . 화면은 짧은 예제의 값을 직접 순회해서 강조하며, 실제 질의 코드는 마지막 절에 제시한다.
5. 서로 다른 부분 문자열과 최장 반복
직관. 모든 부분 문자열은 어떤 접미사의 접두사이다. 정렬된 접미사를 순서대로 보면서 이전에 등장한 접두사만 빼면 중복을 제거할 수 있다.
D는 비어 있지 않은 서로 다른 부분 문자열 수, 는 최장 반복 부분 문자열 길이이다. 비어 있지 않은 서로 다른 문자열 값을 센다. 반복은 서로 다른 시작 위치 두 곳 이상의 등장이며, 구간 겹침을 허용한다.
정리 8. 서로 다른 부분 문자열 수와 최장 반복 부분 문자열
서로 다른 부분 문자열 수. 모든 비어 있지 않은 부분 문자열은 어떤 접미사의 접두사이다. SA 순서에서 현재 접미사의 길이를 , 이전 이웃과의 LCP를 h라 하자. 길이 1,…,h인 접두사는 이미 등장했다. 길이 h보다 긴 접두사가 더 앞선 접미사에 있었다면, 보조정리 3에 의해 바로 이전 이웃도 이를 공유해야 하므로 h의 정의에 모순이다. 따라서 새로 추가되는 부분 문자열은 정확히 개이다. 합산하면 이다.
최장 반복 부분 문자열. 인접 LCP가 h이면 서로 다른 두 시작 위치에서 길이 h의 문자열이 등장한다. 역으로 길이 L의 문자열이 두 번 이상 등장하면, 그 문자열을 접두사로 갖는 접미사들은 SA의 연속 구간을 이룬다. 해당 구간에는 인접 쌍이 있고 LCP가 L 이상이다. 따라서 겹침을 허용한 최장 반복 부분 문자열 길이는 이다. 빈 입력은 답 0으로 처리한다.
∎
부분 문자열은 “접미사의 접두사”로 센다
| 접미사 | 길이 | 이미 나온 길이 | 새로 추가되는 문자열 | 개수 |
|---|---|---|---|---|
| a | 1 | 1…0 (없음) | a | 1 |
| ana | 3 | 1…1 | an, ana | 2 |
| anana | 5 | 1…3 | anan, anana | 2 |
| banana | 6 | 1…0 (없음) | b, ba, ban, bana, banan, banana | 6 |
| na | 2 | 1…0 (없음) | n, na | 2 |
| nana | 4 | 1…2 | nan, nana | 2 |
접미사마다 원문에서의 등장은 다르더라도 문자열 값이 같은 접두사는 한 번만 센다. 표의 새 개수는 이다. 이전에 나온 접미사 전체가 아니라 바로 이전 이웃의 LCP만 빼도 되는 이유는 뒤의 사전순 연속성 보조정리로 증명한다.
6. 구간 비교와 패턴 검색
직관. 공통 접두사만 건너뛰면 구간 비교는 첫 불일치 문자에서 끝난다. 패턴 검색은 같은 접두사를 가진 접미사들이 모인 연속 구간을 찾는 문제이다.
두 유효 구간 의 길이를 라 한다. 공통 길이는 . 빈 구간은 접미사 질의 전에 길이로 처리한다. 비어 있지 않은 패턴 p의 검색 결과는 p를 접두사로 갖는 접미사들의 SA 순위 구간 이다.
정리 9. 접미사·부분 문자열 비교와 패턴 검색
구간 비교. 두 부분 문자열의 길이가 이고 시작 위치의 suffix LCP가 h라면 공통 부분 길이는 이다. 이 값이 더 짧은 구간 길이이면 prefix 관계이므로 길이를 비교하고, 아니면 바로 다음 문자에서 순서가 결정된다.
패턴 검색. 패턴 P로 시작하는 접미사들은 보조정리 3에 의해 연속 구간을 이룬다. 접미사의 앞 글자와 P의 비교 결과는 SA에서 음수·0·양수 순으로 나타나므로 lower/upper bound로 구간의 양 끝을 찾을 수 있다. 비교 한 번이 , 비교 횟수가 이므로 단순 구현은 이다. 결과 위치 K개를 출력하면 가 추가된다.
이분 탐색의 정당성. lower bound의 조건은 비교 결과가 0 이상, upper bound의 조건은 양수이다. 각각 거짓 뒤에 참이 오는 단조 조건이다. 답을 첫 참 위치(없으면 N)로 두면, 반복 진입 시 답은 닫힌 인덱스 구간 안에 있다. mid가 거짓이면 mid 이하를 버려 , 참이면 답이 mid보다 뒤일 수 없어 로 둔다. 구간 길이가 엄격히 감소해 로 종료하고, 불변식상 그 값이 답이다. 따라서 두 반환값 사이에 정확히 일치 접미사들만 남는다.
∎
패턴 ana를 검색하면
ana로 시작하는 접미사는 ana와 anana이며 순위 구간은 이다. 따라서 등장 위치는 . 결과는 원문 위치순이 아니라 SA순이다. 원문 순서가 필요하면 결과 위치들을 별도로 정렬한다.
비교 함수는 접미사의 앞 글자와 패턴만 비교한다. 패턴이 먼저 끝나면 나머지 접미사와 무관하게 0을 반환해야 한다. 음수에서 0으로 바뀌는 첫 위치와, 0에서 양수로 바뀌는 첫 위치를 이분 탐색하면 일치 구간을 얻는다.
7. 여러 문자열로 확장
직관. 각 접미사에 원본 소속을 붙인다. 서로 다른 소속의 접미사들이 공유하는 접두사는 두 원문에 모두 등장한다.
원문마다 고유하며 원래 알파벳에 없는 구분자를 뒤에 붙여 연결한다. 원본 문자에서 시작한 접미사만 후보로 삼는다. 목표는 모든 대상 원문에 적어도 한 번 등장하는 가장 긴 부분 문자열이며, 어떤 원문이 비어 있으면 답은 0이다.
정리 10. 여러 문자열의 공통 부분 문자열
서로 다른 문자열 사이에 입력에 없는 서로 다른 구분자를 넣고 각 접미사의 원본 문자열을 기록한다. 서로 다른 원본의 두 접미사가 일치하는 구간은 서로 다른 구분자를 동시에 통과할 수 없으므로 원본 경계를 넘어 확장되지 않는다.
두 문자열. 공통 부분 문자열 P가 있으면 P를 접두사로 갖는 SA 구간에 두 원본의 접미사가 모두 있다. 이 구간 안에는 원본 소속이 바뀌는 인접 쌍이 있으며 그 LCP는 이상이다. 역으로 서로 다른 원본의 접미사 쌍의 LCP는 실제 공통 부분 문자열이다. 따라서 소속이 다른 인접 쌍의 최대 LCP로 최장 공통 부분 문자열을 구할 수 있다.
세 문자열 이상. 모든 원본을 포함하는 SA 구간을 고려해야 한다. 정리 6에 의해 구간의 인접 LCP 최솟값은 구간 전체가 공유하는 접두사 길이이다. 모든 원본이 등장하는 구간 중 이 값의 최댓값을 구하면 답이 된다. 단순히 소속이 다른 인접 쌍만 확인하면 모든 원본에 공통이라는 조건을 보장하지 못한다.
∎
세 원문 이상에서는 모든 소속이 들어 있는 SA 구간이 필요하다. 서로 다른 두 소속만 확인하면 “모든 원문”이라는 조건이 누락된다. SA-IS와 동적 문자열 인덱싱은 이 강의의 구현·증명 범위가 아니다.
8. SA · LCP 구성 및 질의 시각화
banana의 SA는 [5, 3, 1, 0, 4, 2], LCP는 [0, 1, 3, 0, 0, 2]이다. aaaaaa를 넣으면 반복이 많은 경우도 살펴볼 수 있다.
Suffix laboratory
파랑: 첫 블록 · 초록: 둘째 블록 / 재사용같은 키는 같은 rank를 받는다. 화면에서 동률은 시작 인덱스순으로 표시하며, 아직 전체 접미사 순서가 확정된 것은 아니다.
SA · rank · LCP
| 순위 r | 접미사 |
|---|
9. 질의 구현과 실행 예제
SA와 LCP를 실제 질의 함수로 연결하기
아래 코드는 앞의 suffix_array와 lcp_array를 사용한다. SparseMin은 반열린 구간의 최솟값을 구하고, SuffixIndex는 문자열과 SA·rank·LCP·RMQ를 함께 보관한다. RMQ의 빈 구간과 범위를 벗어난 위치는 예외로 거절한다.
// 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) | 두 비어 있지 않은 접미사의 LCP | |
| compare_substrings(l1,r1,l2,r2) | 반열린 두 구간 비교 −1 / 0 / 1, 빈 구간 허용 | |
| pattern_range(p) | 일치하는 SA 순위 구간 , 빈 패턴 제외 |
이 구현 전체의 전처리는 비교 정렬 Doubling 때문에 , 저장 공간은 Sparse Table 때문에 이다. SA·LCP만 만들 때의 공간과 구별한다. 패턴의 등장 위치 K개를 나열하면 추가 , 그 위치들을 원문순으로 정렬하면 추가 이다.
네 파일을 연결한 실행 예제
앞의 suffix-array.cpp, kasai.cpp, suffix-query.cpp와 아래 파일을 같은 폴더에 둔다. c++ -std=c++17 -O2 suffix-example.cpp -o suffix로 컴파일한다. 다른 세 파일은 예제 파일에 포함되므로 별도 번역 단위로 함께 컴파일하지 않는다.
#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';
} 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