본문으로 바로가기

< Suffix Array O(NlgNlgN)과 O(NlgN) 구현 및 설명 >


최근 입사 동기가 물어본 일도 있고 해서 정리를 해봤다.


먼저 어떤 스트링(문자열)의 Suffix Array를 구한다는 것이 무슨 뜻인지부터 살펴보자.

결론부터 얘기하자면, mississipi란 스트링이 주어졌을 때 Suffix Array라는 것을 구하면 9741086352가 되겠다.


이제 조금씩 살펴보자.

어떤 string의 suffix라는 것은 접미사를 의미한다.

mississipi란 string에는


[0] mississipi

[1] ississipi

[2] ssissipi

[3] sissipi

[4] issipi

[5] ssipi

[6] sipi

[7] ipi

[8] pi

[9] i


이렇게 위와 같이 10가지 suffix가 있을 것이다.

해당 suffix들을 사전순으로 정렬하면 다음과 같다.


[9] i

[7] ipi

[4] issipi

[1] ississipi

[0] mississipi

[8] pi

[6] sipi

[3] sissipi

[5] ssipi

[2] ssissipi


이 순서가 바로 Suffix Array이다.

즉, 접미사 배열(Suffix Array) = 한 문자열(a string)의 접미사들을 모두 모아 정렬했을 때, 그 순서를 문자열의 index로만 표현한 배열이라 할 수 있다.


그럼 이걸 어떻게 구할까?

가장 무식한 방법은 문자열 길이 N만큼 Suffix들을 따로 저장한 다음 sorting 해버리는 것이다.

코드로 구현하면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//suffix array: bruteforce O(N^2lgN)
#include <string>
#include <cstring>
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
typedef pair<string, int> psi;
vector<int> getsfx(string &s) {
    int n=s.size();
    vector<psi> v;
    for(int i=0; i<n; i++) {
        v.emplace_back(psi(s.substr(i),i));
    }
    sort(v.begin(), v.end());
    vector<int> ans;
    for(int i=0; i<n; i++) {
        ans.emplace_back(v[i].second);
    }
    return ans;
}
int main() {
    ios::sync_with_stdio(false); cin.tie(0);
    string s; cin >> s;
    vector<int> sfx = getsfx(s);
    for(auto x: sfx) cout << x << " ";
    return 0;
}
cs


Suffix를 옮겨담는데만 O(N^2)시간이 걸리고, sorting은 문자열 sorting이므로 O(N^2lgN)시간이 걸린다.

결과적으로 O(N^2lgN)시간이 걸리고, 문자열길이 N값이 10,000만 되더라도 엄청난 성능저하가 기대된다.


그럼 알고리즘을 개선시켜 조금 빠른속도인 O(NlgNlgN)에 구해보자.

이 방법은 Suffix Array를 구하고자 하는 문자열 하나를 길이 1,2,4,8,16,...단위로 확인하면서 그룹을 지어주는 것이다.

이 말만 들으면 이 블로그를 찾아온 보람이 없다.

좀 더 디테일하게 알아보자.



mississipi를 길이 1단위로 그룹을 지어보자. 그룹은 사전순서로 작은 부분문자열에 작은 숫자를 부여하는 것이다.


 index

0

 string[index]

m

 g[index]

2

4

1

3

1


각 부분문자열을 1개 길이 단위로 봤으므로 결국 '문자'만 보고 몇 번째에 해당하는지 g[index]에 적어주었다.

i값이 사전순으로 가장 빠르므로 1이고, m은 2, p는 3, s는 4이다.


이제 2개 길이 단위로 g[index]를 구해보자.

일단 결과를 보면 아래와 같다.

 index

0

 string[index]

m

 g[index]

4

7

3

5

1


2개 길이 단위로 본다는 것은, string[0..1], string[1..2], string[2..3], .. 값들을 비교하겠다는 뜻이다.

다시 말해서,


[0]: string[0..1]: mi

[1]: string[1..2]: is

[2]: string[2..3]: ss

[3]: string[3..4]: si

[4]: string[4..5]: is

[5]: string[5..6]: ss

[6]: string[6..7]: si

[7]: string[7..8]: ip

[8]: string[8..9]: pi

[9]: string[9..10]: i* (존재하지 않는 문자를 *로 표시했다.)


위와 같은 부분문자열을 정렬해서 순서대로 그룹번호를 지정하겠다는 뜻이다.

위의 부분문자열(substring)들을 정렬해보면 다음과 같다.


[9]: string[9..10]: i* => 1번째

[7]: string[7..8]: ip => 2번째

[1]: string[1..2]: is => 3번째

[4]: string[4..5]: is => 3번째

[0]: string[0..1]: mi => 4번째

[8]: string[8..9]: pi => 5번째

[3]: string[3..4]: si => 6번째

[6]: string[6..7]: si => 6번째

[2]: string[2..3]: ss => 7번째

[5]: string[5..6]: ss => 7번째


이제 표에서 g[index]가 무엇을 구하는 것인지 확실히 캐치했을 것이다.

그럼 이걸 어떻게 구할까? 보통 suffix array를 처음 공부하는 사람들은 여기서 막힌 사람들이 많다.

" 여기서 3길이 단위를 구하지 않고 어떻게 4길이 단위를 바로 구할 수 있다는 것일까? "

일단 어떻게 구하는지는 차차 설명하고 4길이 단위로 그룹번호를 매겨보겠다.


 index

0

 string[index]

m

 g[index]

4

9

3

5

1



결과적으로 살펴보면 2번째 index에서 시작하는 issi와 4번째 index에서 시작하는 issi만 빼고 모두 그룹번호가 다르게 매겨졌다.


이제 다시 2개 길이 단위로 그룹번호를 지정했던 테이블을 살펴보자.

알아보기 쉽게 아래와 같이 테이블을 좀 더 상세히 그려보았다.



 

 

 

 

 

 

 

 

 

 

 

 g[index]

0

m

i

s

s

i

s

s

i

p

i

*

4

1

m

i

s

s

i

s

s

i

i

s

s

i

7

m

i

s

s

6

m

i

s

3

m

i

s

s

7

m

i

s

s

6

m

i

s

s

2

m

i

s

s

5

m

i

s

s

 










 

 

 


2개 길이 단위에서 구분짓지 못했던 g[2]와 g[5]를 살펴보자. 

위에서 g[2]와 g[5]값은 7이다.

g[2]==g[5]이므로 4개 길이 단위에서 그룹번호를 결정짓기 위해서는 아래 표의 빨간 부분을 비교해보면 된다.



0

10 

11 

12 

g[index] 

0

m

i

s

s

i

s

s

i

p

i

*

*

4

1

m

i

s

s

i

s

s

i

*

3

i

s

s

i

?

m

i

s

s

m

i

s

3

m

i

s

s

?

m

i

s

s

m

i

s

s

2

m

i

s

s

5

m

i

s

s

 










 

 

 

 

 


그런데 빨간 부분을 비교한다고 string[4]와 string[7]을 비교한 다음 string[5]와 string[8]을 비교하는 것일까?

아니다.

우리는 이미 2개 길이 단위의 g(그룹)값을 구하면서 string[4..5]와 string[7..8]에 해당하는 그룹번호를 알고 있다. (위의 테이블에서 보라색 부분이 바로 그 부분이다.)

즉, t개 길이 단위에서 g값이 결정되어 있을때 g[x]==g[y]이면 => g[x+t]와 g[y+t]를 비교하면 되는 것이다.

그러므로 2개 길이 단위에서 4개길이 단위를 만드는데, 최대 비교회수는 2번이다.

더 간단히 얘기하자면, (g[x],g[x+t])와 (g[y],g[y+t])를 pair<int,int>로 만들어 놓고 정렬하면 된다는 것이다.


그럼 같은 방식으로 [4개 단위에 대한 그룹번호]를 가지고 있다면 [8개 단위의 새로운 그룹번호]를 만들때에도 각각 최대 2번까지만 비교하게 된다.

O(NlgNlgN)방식은 이게 전부다.


이것을 코드로 옮겨보면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <iostream>
#include <string>
#include <cstring>
#include <algorithm>
#include <vector>
using namespace std;
//sfx: suffix array
//g: group number
//ng: new group number
vector<int> sfx,g,ng;
vector<int> getsfx(string &s) {
    int n = s.size();
    sfx.resize(n); g.resize(n+1); ng.resize(n+1);
    for(int i=0; i<n; i++) sfx[i]=i, g[i]=s[i];
 
    g[n]=-1;
    for(int t=1; t<n; t<<=1) {
        auto cmp = [&](int i, int j) {
            if(g[i]==g[j]) return g[i+t]<g[j+t];
            else return g[i]<g[j];
        };
        sort(sfx.begin(), sfx.end(), cmp);
        ng[sfx[0]]=0;
        ng[n]=-1;
        for(int i=1; i<n; i++) {
            if(cmp(sfx[i-1],sfx[i])) ng[sfx[i]]=ng[sfx[i-1]]+1;
            else ng[sfx[i]]=ng[sfx[i-1]];
        }
        g=ng;
    }
    return sfx;
}
 
int main() {
    ios::sync_with_stdio(false); cin.tie(0);
    string s; cin >> s;
    vector<int> sfx = getsfx(s);
    for(int i=0; i<sfx.size(); i++) {
        printf("%d ", sfx[i]);
    }
    return 0;
}
cs


이전까지 한 설명을 모두 이해했다면, 코드도 이해하는데 어려움이 없을 것이다.

약간 헷갈리는 부분이 있을 수도 있는데,

그룹번호에 접근하는 순서가 0번부터 n-1번까지 순서대로 접근하는 것이 아니라

sfx[0]번부터 sfx[n-1]번 순서로 접근한다는 것이다.

이는 당연한데, 그룹번호가 1번인것들끼리 먼저 비교를 해야하지 않겠는가?!


한가지 좀 더 특이한 부분은 g배열을 n까지만 잡았다는 것이다. g[n+1]에 접근하는 일이 없는 것일까?

mississipi를 종이에 적고 조금만 생각해보면 그런 일은 발생하지 않는 것을 알 수 있다.



시간복잡도는 위의 코드에서 t에 관한 반복문이 1,2,4,8,16,... 형태로 진행되기 때문에 lgN이고,

각 루프마다 NlgN의 sorting을 수행하므로 O(NlgNlgN)이 된다.



이제 O(NlgN) 방법을 알아보자.

이미 눈치챘겠지만, sorting을 O(NlgN)이 아닌 O(N)에 수행하게 되면 시간복잡도는 O(NlgN)이 된다.

그럼 정렬하는 방법을 Counting Sort로 바꿔보자.

코드는 다음과 같다.



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <iostream>
#include <algorithm>
#include <cstring>
#include <string>
#include <vector>
using namespace std;
vector<int> g,ng,sfx,cnt,orderToIdx;
vector<int> getsfx(string &s) {
    int n=s.size();
    int lim = max(257,n+1);
    g.resize(n+1); ng.resize(n+1); sfx.resize(n); orderToIdx.resize(n+1);
    for(int i=0; i<n; i++) sfx[i]=i, g[i]=s[i];
    for(int t=1; t<n; t<<=1) {
        cnt.clear(); cnt.resize(lim,0);
        for(int i=0; i<n; i++) cnt[g[min(i+t,n)]]++;
        for(int i=1; i<lim; i++) cnt[i]+=cnt[i-1];
        for(int idx=n-1; idx>=0; idx--) orderToIdx[--cnt[g[min(idx+t,n)]]]=idx;
        //orderToIdx[x]=idx; => pair에서 second기준으로 정렬했을 때, second기준으로 x번째에 해당하는 index는 idx라는 뜻이다.
 
        cnt.clear(); cnt.resize(lim,0);
        for(int i=0; i<n; i++) cnt[g[i]]++;
        for(int i=1; i<lim; i++) cnt[i]+=cnt[i-1];
        for(int i=n-1; i>=0; i--) sfx[--cnt[g[orderToIdx[i]]]]=orderToIdx[i];
        //sfx[x]=idx; => pair에서 second기준으로 정렬후 first기준으로 다시 stable sort를 했을 때, 순서로 x번째에 해당하는 인덱스는 idx라는 뜻이다.
 
        auto cmp = [&](int i, int j) {
            if(g[i]==g[j]) return g[i+t]<g[j+t];
            else return g[i]<g[j];
        };
        ng[sfx[0]]=1;
        for(int i=1; i<n; i++) {
            if(cmp(sfx[i-1],sfx[i])) ng[sfx[i]]=ng[sfx[i-1]]+1;
            else ng[sfx[i]]=ng[sfx[i-1]];
        }
        g=ng;
    }
    return sfx;
}
int main() {
    ios::sync_with_stdio(false); cin.tie(0);
    string s; cin >> s;
    vector<int> sfx = getsfx(s);
    for(int i=0; i<sfx.size(); i++) {
        printf("%d ", sfx[i]);
    }
    return 0;
}
cs



보면 알겠지만, 알고리즘 공부가 부족하면 "쭤게 뭐얍!" 과 같은 상황이 발생한다.

15번째 줄부터 23번째 줄 까지가 STL sort를 사용했던 부분을 counting sort로 변경한 부분이다.

필자도 처음 suffix array를 코딩하기 전까지는 counting sort는 당연히 할줄 안다고 생각했는데, 정말 무지했다는 것을 깨달았다.


아마도 이 글을 읽는 분들은 쉽게 카운팅소트 정도야 가볍게 지나가셨을거라 생각하지만,

혹시 모를 나와 같은 극소수의 분들을 위해 조금 더 적어보겠다.


LINK : Counting Sort하는 방법 (http://plzrun.tistory.com/entry/Counting-Sort-Radix-Sort)


pair값 sorting을 counting sort로 구현하는 것이니까

second에 해당하는 g[i+t]부분을 먼저 정렬한다.

정렬하고 났을때 orderToIdx[]배열에 다음과 같이 저장하면 된다.


orderToIdx[x] = idx; => g[idx+t]를 기준으로 정렬했을 때 x번째 순서에 해당하는 인덱스 값이 idx이다.


그렇다면 second기준(g[idx+t])으로 먼저오는 인덱스 값부터 first(g[idx])를 비교해주면~! 똭~!!!





Suffix Array를 어디에 활용하는지는 다음에 설명해 보겠다. (ㅈㅅㅈㅅ)

이 글 무려 7시간동안 작성함 ㅠㅠ,, 다음에 시간날 때 마저 써야겠다.


LCP관련글은 아래에서 확인할 수 있다.

LCP: → 약간 설명이 있는 내가 올린 포스팅: http://plzrun.tistory.com/entry/문자열-처리-알고리즘-LCP