반복 찾기¶
길이가 $n$인 문자열 $s$가 주어집니다.
반복이란 어떤 문자열이 두 번 연달아 나타나는 것을 말합니다. 다시 말해, 반복은 부분 문자열 $s[i \dots j]$가 동일한 두 문자열을 이어 쓴 형태가 되도록 하는 인덱스 쌍 $i < j$로 나타낼 수 있습니다.
과제는 주어진 문자열 $s$에서 모든 반복을 찾는 것입니다. 또는 더 단순한 과제로, 아무 반복이나 하나 찾거나 가장 긴 반복을 찾을 수도 있습니다.
여기서 설명하는 알고리즘은 1982년에 Main과 Lorentz가 발표했습니다.
예시¶
다음 예시 문자열에서 반복을 살펴봅시다.
이 문자열에는 다음 세 개의 반복이 있습니다.
- $s[2 \dots 5] = abab$
- $s[3 \dots 6] = baba$
- $s[7 \dots 8] = ee$
또 다른 예시입니다.
여기에는 반복이 두 개뿐입니다.
- $s[0 \dots 5] = abaaba$
- $s[2 \dots 3] = aa$
반복의 개수¶
일반적으로 길이가 $n$인 문자열에는 반복이 최대 $O(n^2)$개까지 있을 수 있습니다. 명백한 예로 같은 문자를 $n$번 나열한 문자열이 있으며, 이 경우 길이가 짝수인 모든 부분 문자열이 반복입니다. 일반적으로 주기가 짧은 주기적 문자열에는 반복이 많이 포함됩니다.
한편 이 사실 때문에 반복의 개수를 $O(n \log n)$ 시간에 계산하지 못하는 것은 아닙니다. 알고리즘이 반복을 압축된 형태로, 즉 여러 개를 한꺼번에 묶은 그룹으로 제공할 수 있기 때문입니다.
심지어 주기적 부분 문자열의 그룹을 크기가 4인 튜플로 나타내는 개념도 있습니다. 이러한 그룹의 개수는 문자열 길이에 대해 많아야 선형임이 증명되어 있습니다.
또한 반복의 개수와 관련된 흥미로운 결과를 몇 가지 더 소개합니다.
- 원시 반복(절반이 반복이 아닌 반복)의 개수는 최대 $O(n \log n)$입니다.
- 반복을 수의 튜플(Crochemore 삼중쌍이라고 부릅니다) $(i,~ p,~ r)$로 인코딩하면(여기서 $i$는 시작 위치, $p$는 반복되는 부분 문자열의 길이, $r$은 반복 횟수입니다), 모든 반복을 이러한 삼중쌍 $O(n \log n)$개로 나타낼 수 있습니다.
-
다음과 같이 정의되는 피보나치 문자열은
$$\begin{align} t_0 &= a, \\\\ t_1 &= b, \\\\ t_i &= t_{i-1} + t_{i-2}, \end{align}$$"강하게" 주기적입니다. 피보나치 문자열 $f_i$에서 반복의 개수는 Crochemore 삼중쌍으로 압축하더라도 $O(f_n \log f_n)$입니다. 원시 반복의 개수 역시 $O(f_n \log f_n)$입니다.
Main-Lorentz 알고리즘¶
Main-Lorentz 알고리즘의 핵심 아이디어는 분할 정복입니다.
이 알고리즘은 처음 문자열을 절반으로 나누고, 두 번의 재귀 호출로 각 절반 안에 완전히 들어 있는 반복의 개수를 계산합니다. 그다음이 어려운 부분입니다. 알고리즘은 앞쪽 절반에서 시작하여 뒤쪽 절반에서 끝나는 모든 반복(이를 교차 반복이라고 부르겠습니다)을 찾습니다. 이것이 Main-Lorentz 알고리즘의 핵심 부분이며, 여기서 자세히 설명하겠습니다.
분할 정복 알고리즘의 복잡도는 이미 잘 연구되어 있습니다. 마스터 정리에 따르면, 교차 반복을 $O(n)$ 시간에 계산할 수 있다면 최종적으로 $O(n \log n)$ 알고리즘을 얻게 됩니다.
교차 반복 찾기¶
따라서 문자열의 앞쪽 절반(이를 $u$라고 부릅시다)에서 시작하여 뒤쪽 절반(이를 $v$라고 부릅시다)에서 끝나는 모든 반복을 찾고자 합니다:
이들의 길이는 대략 $s$의 길이를 2로 나눈 값과 같습니다.
임의의 반복을 하나 잡고 가운데 문자(더 정확히는 반복의 뒤쪽 절반의 첫 번째 문자)를 살펴봅시다. 즉, 반복이 부분 문자열 $s[i \dots j]$라면 가운데 문자는 $(i + j + 1) / 2$입니다.
이 문자가 문자열 $u$와 문자열 $v$ 중 어디에 위치하는지에 따라 반복을 왼쪽 반복 또는 오른쪽 반복이라고 부릅니다. 다시 말해, 문자열의 대부분이 $u$에 놓여 있으면 왼쪽이라고 부르고, 그렇지 않으면 오른쪽이라고 부릅니다.
이제 모든 왼쪽 반복을 찾는 방법을 살펴보겠습니다. 모든 오른쪽 반복도 같은 방법으로 찾을 수 있습니다.
왼쪽 반복의 길이를 $2l$로 나타냅시다(즉, 반복의 각 절반의 길이는 $l$입니다). 반복에서 문자열 $v$에 속하는 첫 번째 문자를 생각해 봅시다(이 문자는 문자열 $s$의 위치 $|u|$에 있습니다). 이 문자는 그보다 $l$칸 앞에 있는 문자와 일치하며, 이 위치를 $cntr$라고 합시다.
이 위치 $cntr$를 고정하고, $cntr$ 위치에서 모든 반복을 찾겠습니다.
예를 들면 다음과 같습니다:
세로선은 두 절반을 나눕니다. 여기서는 위치 $cntr = 1$을 고정했으며, 이 위치에서 반복 $caca$를 찾습니다.
위치 $cntr$를 고정하면 가능한 반복의 길이도 동시에 고정된다는 것은 분명합니다: $l = |u| - cntr$. 이러한 반복을 찾는 방법을 알게 되면, $cntr$의 가능한 모든 값을 $0$부터 $|u|-1$까지 순회하여 길이가 $l = |u|,~ |u|-1,~ \dots, 1$인 모든 왼쪽 교차 반복을 찾을 것입니다.
왼쪽 교차 반복의 판정 기준¶
그러면 고정된 $cntr$에 대해 이러한 반복을 모두 어떻게 찾을 수 있겠습니까? 이러한 반복이 여전히 여러 개 있을 수 있다는 점에 유의하십시오.
다시 시각화를 살펴봅시다. 이번에는 반복 $abcabc$에 대한 것입니다:
여기서 반복의 두 조각의 길이를 $l_1$과 $l_2$로 나타냈습니다: $l_1$은 위치 $cntr-1$까지의 반복의 길이이고, $l_2$는 $cntr$부터 반복의 절반이 끝나는 지점까지의 반복의 길이입니다. 반복의 전체 길이는 $2l = l_1 + l_2 + l_1 + l_2$입니다.
위치 $cntr$에서 길이가 $2l = 2(l_1 + l_2) = 2(|u| - cntr)$인 이러한 반복에 대한 필요충분 조건을 만들어 봅시다:
- $k_1$을 위치 $cntr$ 앞의 $k_1$개 문자가 문자열 $u$의 마지막 $k_1$개 문자와 일치하는 가장 큰 수라고 합시다:
- $k_2$를 위치 $cntr$에서 시작하는 $k_2$개 문자가 문자열 $v$의 처음 $k_2$개 문자와 일치하는 가장 큰 수라고 합시다:
- 그러면 다음을 만족하는 쌍 $(l_1,~ l_2)$에 대해서, 그리고 오직 그러한 쌍에 대해서만 반복이 존재합니다
요약하면 다음과 같습니다:
- 특정 위치 $cntr$를 고정합니다.
- 이제 찾을 모든 반복의 길이는 $2l = 2(|u| - cntr)$입니다. 이러한 반복은 여러 개 있을 수 있으며, 이들은 길이 $l_1$과 $l_2 = l - l_1$에 따라 달라집니다.
- 위에서 설명한 대로 $k_1$과 $k_2$를 구합니다.
- 그러면 조각의 길이 $l_1$과 $l_2$가 다음 조건을 만족하는 반복이 모두 적합한 반복입니다:
따라서 남은 부분은 모든 위치 $cntr$에 대해 값 $k_1$과 $k_2$를 어떻게 빠르게 계산하는지뿐입니다. 다행히 Z-함수를 사용하면 이 값들을 $O(1)$에 계산할 수 있습니다:
- 문자열 $\overline{u}$(즉, 문자열 $u$를 뒤집은 문자열)에 대한 Z-함수를 계산하면 각 위치의 값 $k_1$을 구할 수 있습니다. 그러면 특정 $cntr$에 대한 값 $k_1$은 Z-함수 배열의 해당 값과 같습니다.
- 모든 $k_2$ 값을 미리 계산하기 위해 문자열 $v + \# + u$(즉, 문자열 $u$에 구분 문자 $\#$와 문자열 $v$를 이어 붙인 문자열)에 대한 Z-함수를 계산합니다. 이번에도 Z-함수에서 해당 값을 찾아보기만 하면 $k_2$ 값을 얻을 수 있습니다.
이것으로 모든 왼쪽 교차 반복을 찾기에 충분합니다.
오른쪽 교차 반복¶
오른쪽 교차 반복을 계산할 때도 비슷하게 진행합니다: 중심 $cntr$를 문자열 $u$의 마지막 문자에 대응하는 문자로 정의합니다.
그러면 길이 $k_1$은 위치 $cntr$ 이전(해당 위치 포함)의 문자 중 문자열 $u$의 마지막 문자들과 일치하는 문자의 최대 개수로 정의됩니다. 그리고 길이 $k_2$는 $cntr + 1$부터 시작하는 문자 중 문자열 $v$의 문자들과 일치하는 문자의 최대 개수로 정의됩니다.
따라서 문자열 $\overline{u} + \# + \overline{v}$와 $v$에 대한 Z-함수를 계산하면 값 $k_1$과 $k_2$를 구할 수 있습니다.
그런 다음 모든 위치 $cntr$를 살펴보며 왼쪽 교차 반복에서와 같은 기준을 사용하면 반복을 찾을 수 있습니다.
구현¶
Main-Lorentz 알고리즘의 구현은 모든 반복을 크기가 4인 특수한 튜플 $(cntr,~ l,~ k_1,~ k_2)$의 형태로 $O(n \log n)$ 시간에 찾습니다. 문자열에 있는 반복의 개수만 구하거나 문자열에서 가장 긴 반복만 찾으려는 경우에는 이 정보로 충분하며, 실행 시간은 여전히 $O(n \log n)$입니다.
이 튜플들을 펼쳐서 각 반복의 시작 위치와 끝 위치를 얻으려면 실행 시간이 $O(n^2)$이 된다는 점에 유의하십시오(반복이 $O(n^2)$개까지 있을 수 있다는 점을 기억하십시오). 이 구현에서는 그렇게 하여, 찾은 모든 반복을 시작 인덱스와 끝 인덱스 쌍의 벡터에 저장합니다.
vector<int> z_function(string const& s) {
int n = s.size();
vector<int> z(n);
for (int i = 1, l = 0, r = 0; i < n; i++) {
if (i <= r)
z[i] = min(r-i+1, z[i-l]);
while (i + z[i] < n && s[z[i]] == s[i+z[i]])
z[i]++;
if (i + z[i] - 1 > r) {
l = i;
r = i + z[i] - 1;
}
}
return z;
}
int get_z(vector<int> const& z, int i) {
if (0 <= i && i < (int)z.size())
return z[i];
else
return 0;
}
vector<pair<int, int>> repetitions;
void convert_to_repetitions(int shift, bool left, int cntr, int l, int k1, int k2) {
for (int l1 = max(1, l - k2); l1 <= min(l, k1); l1++) {
if (left && l1 == l) break;
int l2 = l - l1;
int pos = shift + (left ? cntr - l1 : cntr - l - l1 + 1);
repetitions.emplace_back(pos, pos + 2*l - 1);
}
}
void find_repetitions(string s, int shift = 0) {
int n = s.size();
if (n == 1)
return;
int nu = n / 2;
int nv = n - nu;
string u = s.substr(0, nu);
string v = s.substr(nu);
string ru(u.rbegin(), u.rend());
string rv(v.rbegin(), v.rend());
find_repetitions(u, shift);
find_repetitions(v, shift + nu);
vector<int> z1 = z_function(ru);
vector<int> z2 = z_function(v + '#' + u);
vector<int> z3 = z_function(ru + '#' + rv);
vector<int> z4 = z_function(v);
for (int cntr = 0; cntr < n; cntr++) {
int l, k1, k2;
if (cntr < nu) {
l = nu - cntr;
k1 = get_z(z1, nu - cntr);
k2 = get_z(z2, nv + 1 + cntr);
} else {
l = cntr - nu + 1;
k1 = get_z(z3, nu + 1 + nv - 1 - (cntr - nu));
k2 = get_z(z4, (cntr - nu) + 1);
}
if (k1 + k2 >= l)
convert_to_repetitions(shift, cntr < nu, cntr, l, k1, k2);
}
}