Suffix Array, Lcp Array
Suffix Array
Wiki 에 기본적인 사항들이 잘 정리되어 있다. 용도가 많아서 개발도 활발하고 개선에 따라 여러 버전이 있어 내용이 많지만, PS 용으로는 오리지널 버전이 기본적으로 사용된다.
공간복잡도는 배열이 길이만 생각하면 O(\(\mathrm{N}\)) 으로 선형이다. 하지만 비트단위로 들어가면 O(\(\mathrm{N}\log{\mathrm{N}}\)) 으로 텍스트 크기인 O(\(\mathrm{N}\log{\alpha}\)) 보다 같거나 크다. (\(\alpha\) 는 char
의 크기이고 \(\mathrm{N}\) 을 텍스트 길이). Suffix Array 가 저장하는 정보는 텍스트의 시작 인덱스이기 때문에 텍스트가 매우 길면 로그단위로 공간을 더 잡아먹기 때문이다.
Suffix Array 구축에 대한 시간복잡도는 구현에 따라 다르다. 많이 알려진 SA-IS Algorithms 은 O(\(\mathrm{N}\)) 으로 끝낼 수 있고, 글쓰는 현 시점에서 상용되는 Yuta Mori 의 알고리즘도 마찬가지이다. PS 에서 주로쓰는 Manber-Myers Algorithms 는 시간복잡도가 O(\(\mathrm{N}\log{\mathrm{N}}\)) 이다.
Manber-Myers Algorithms
직관적인 버전과 대략적 설명
Code
설명
총 \(\log{\mathrm{N}}\) 번 정렬을 하고 퀵소트를 사용해서 O(\(\mathrm{N}\log{\mathrm{N}}^2\)) 의 시간복잡도가 된다. 그래서 계수정렬 버전보단 느리지만 간단해서 먼저 소개한다.
배열
핵심적인 배열이 두개가 존재한다.
sa[]
는 Suffix Array 의 약자로, Suffix 자체가 들어가진 않고 Suffix 가 시작하는 인덱스가 들어간다. “banana” 에서sa[2] = 4
라면2
번째 Suffix 가 “na” 라는 뜻이 된다.rk[]
는 Rank 의 약자로,sa[]
의 역배열(역함수와 비슷한 개념)이다.rk[4] = 2
라면 Suffix"na"
가sa[]
에서 2번째에 있다는 뜻이 된다.
분할정복
sa[]
에 Suffix 를 넣었으므로 이제 정렬을 해야한다. 먼저 Suffix 의 첫번째 문자를 이용해서 rk[]
를 채운다. 그리고 분할정복의 방식을 이용해 \(\log{\mathrm{N}}\) 번 정렬을 반복한다. 원리는 기본적으로 자신의 rk[]
순서대로 sa[]
를 정렬하되, 만약 rk[]
가 같으면 2의 지수승 뒤의 rk[]
를 사용해 비교하는 것이다. 즉 루프마다 1, 2, 4, 8 등의 뒤의 rk[]
를 보는 것이다.
왜 이것이 가능한지는 수학적 귀납법으로 간단히 보일 수 있다. Suffix 의 첫번째 문자와 2의 제곱수인 o
번째 뒤의 문자를 사용해서 rk[]
를 구성해두었다고 하자. 그리고 다음 루프에서 rk[i]
와 rk[j]
를 비교한다고 하자.
rk[i] != rk[j]
인 경우 이미 비교가 끝난 대상이다.rk[i] == rk[j]
의 경우 전제에 의해 Suffix 의o/2
번째 문자까지는 서로 같은 상황이다. 그리고 지금o
번째 문자까지 고려해서 비교를 해야한다. 그런데rk[i+o]
는 그것의o/2
번째 문자까지 비교가 마친 상황이고, 이 범위는rk[i]
에 해당되는 Suffix 의o/2
부터o
번째의 문자에 해당된다. 이는rk[j+o]
도 마찬가지이다. 그러므로rk[i+o] < rk[j+o]
를 수행해도 지장이 없다.
Suffix Array 출력
계수정렬 버전
Code
설명
총 \(\log{\mathrm{N}}\) 번 계수정렬을 하므로 O(\(\mathrm{N}\log{\mathrm{N}}\)) 의 시간복잡도가 된다.
계수정렬은 총 2부분으로 나뉜다.
- Suffix 에서
o
만큼 떨어진 Suffix 에 대한 Counting Sort.rk[i+o]
가 일어나는 빈도를 구하고 누적합을 수행한다. 그러면cnt[i]
에는 Rank 가i+o
인 Suffix 가 최대 몇번째의sa[]
인지가 저장된다.- Count Sort 의 나머지 과정을 수행한다.
tmp[]
에는 시작 위치가i+o
인 Suffix 의 Rank 순 으로i
(Suffix 의 시작 인덱스)가 저장된다. - 이때 주의할 부분이
i+o>=len
의 경우로, 이땐 다른 경우보다 앞에 와야하므로0
이 되도록 하고, 나머지는 한칸을 미뤄쓰도록 했다.
- 시작위치가
i+o
인 Suffix 의 Rank 순서를 고려한 Counting Sort- 기존
rk[]
에 대해 위와 똑같이 수행한다. 단 이번에는 같은 Rank 끼리 순서가 중요하다. 이 순서는 위에서 구한tmp[]
에 의해 결정이 된다. tmp[]
에서 뒤에오는게 나중에 와야하므로 꼭 뒤에서부터 세줘야한다.
- 기존
중간에 Rank 의 서로다른 갯수가 src 문자열의 길이와 같아지면 더 정렬을 할 필요가 없으므로 for
문 탈출과정에 이를 반영시켰다. 정렬 하나하나가 오래걸려서 이 최적화가 성능을 상당히 많이 올려주므로 꼭 넣자.
SA-IS Algorithms
LCP Array
위에서 구한 Suffix Array 의 Longest Common Prefix 를 계산해두면 Suffix Array 로 Suffix Tree 와 같은 문제를 풀 수 있다. 이를 구하는 방법은 많이 있지만 PS 에서 자주 사용되는 알고리즘이 Kasai Algorithms 이다.
Kasai Algorithms
Code
설명
lcp[]
는 sa[i]
와 sa[i-1]
간의 LCP 를 저장한다.
작동하는 방식은 다음과 같다. 먼저 가장 긴 Suffix 부터 계산해서 그것의 LCP 를 구한다. 이 값을 A
라고 하자. 만약 A
가 0 보다 크다면 그것보다 바로 다음으로 긴 Suffix 는 A-1
보다 같거나 크다. 이를 고려해 다음 문자열 비교는 max(A-1, 0)
번째 문자부터 수행한다.
이해를 돕기위해 예를들어 "abcabbc"
에서 Suffix Array 를 구하면 다음과 같다.
abbc
abcabbc
bbc
bc
bcabbc
c
cabbc
여기서 처음 구한 A
은 "abcabbc"
와 "abbc"
의 공통부분인 len("ab")
가 된다. 그런데 전 Suffix 의 앞글자만 뺀 "bcabbc"
와 "bbc"
도 Suffix 이다. 그래서 다음 순서인 "bcabbc"
의 LCP 는 len("ab")-1
보다 무조건 같거나 크다. 이 예제에는 그 사이에 다른 Suffix 가 있어서 LCP 가 이보다 더 긴 len("bc")
가 된다.
이러한 LCP 값을 추적하는 num
은 최고 \(2\mathrm{N}\) 을 넘어서 증가 감소할 수 없으므로 시간복잡도는 O(\(\mathrm{N}\)) 이 된다.
댓글남기기