한 바퀴 돌아간 배열에서도 반쪽은 정렬돼 있다
최근 로그 1,000줄을 링 버퍼에 담아 둡니다. 꽉 차면 가장 오래된 줄부터 덮어쓰니, 배열 어딘가에서 시간이 한 번 끊깁니다.
인덱스 0 1 2 3 4 5 6 7
시각 [50, 60, 70, 80, 10, 20, 30, 40]
↑ 여기서 끊긴다
전체로 보면 정렬이 아닙니다. 그래도 원래 정렬된 배열을 어느 지점에서 잘라 앞뒤를 바꾼 모양이라는 성질이 남아 있습니다. 이런 배열을 회전된 정렬 배열(rotated sorted array) 이라고 부릅니다. 그 성질만으로 이분 탐색이 그대로 됩니다.
가운데를 보면 한쪽은 반드시 정렬돼 있다
끊긴 자리는 하나뿐입니다. 그러니 가운데에서 배열을 둘로 나누면 끊긴 자리는 둘 중 한쪽에만 들어갑니다. 남은 한쪽은 통째로 정렬돼 있습니다.
정렬된 쪽인지는 양 끝을 비교하면 알 수 있습니다.
| 조건 | 뜻 |
|---|---|
a[lo] <= a[mid] |
왼쪽 반쪽이 정렬돼 있다 |
| 아니면 | 오른쪽 반쪽이 정렬돼 있다 |
정렬된 쪽은 첫 값과 끝 값만 보면 찾는 값이 그 안에 있는지 알 수 있습니다. 있으면 그쪽으로 좁히고, 없으면 반대쪽으로 갑니다. 반대쪽은 끊긴 자리를 품고 있지만 상관없습니다. 다음 회차에서 다시 반으로 나누면 또 한쪽은 정렬돼 있습니다.
int Search(int[] a, int target) {
int lo = 0, hi = a.Length - 1;
while (lo <= hi) {
int mid = lo + (hi - lo) / 2;
if (a[mid] == target) return mid;
if (a[lo] <= a[mid]) { // 왼쪽이 정렬돼 있다
if (a[lo] <= target && target < a[mid]) hi = mid - 1;
else lo = mid + 1;
} else { // 오른쪽이 정렬돼 있다
if (a[mid] < target && target <= a[hi]) lo = mid + 1;
else hi = mid - 1;
}
}
return -1;
}
조건이 네 줄이라 복잡해 보이지만 하는 일은 하나입니다. 정렬된 반쪽에 답이 있는지 먼저 묻고, 없으면 나머지로 간다.
위 배열에서 30을 찾으면 이렇게 갑니다.
| 단계 | lo | hi | mid | a[mid] | 판단 |
|---|---|---|---|---|---|
| 1 | 0 | 7 | 3 | 80 | a[0]=50 <= 80 이라 왼쪽이 정렬. 30은 50~80 밖이니 오른쪽으로 |
| 2 | 4 | 7 | 5 | 20 | a[4]=10 <= 20 이라 왼쪽이 정렬. 30은 10~20 밖이니 오른쪽으로 |
| 3 | 6 | 7 | 6 | 30 | 찾았다 |
끊긴 자리 자체를 찾을 때
“가장 오래된 로그가 어디부터인가” 는 배열의 최솟값을 찾는 일이고, 이건 더 간단합니다.
int lo = 0, hi = a.Length - 1;
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (a[mid] > a[hi]) lo = mid + 1; // 끊긴 자리가 오른쪽에 있다
else hi = mid; // 끊긴 자리가 mid 이거나 왼쪽에 있다
}
return lo; // 최솟값의 자리 = 원래 배열의 시작점
끝 값과 비교한다는 것이 요점입니다. a[mid] 가 마지막 값보다 크다면 그 사이 어딘가에서 끊겼다는 뜻입니다.
a[lo] 와 비교하면 회전이 없는 배열에서 답이 어긋나므로 a[hi] 쪽을 씁니다.
이 자리를 알면 원래 순서로 읽는 것도 쉬워집니다. 인덱스에 시작점을 더하고 길이로 나눈 나머지를 쓰면 됩니다.
int real = (start + i) % a.Length;
같은 값이 있으면 무너진다
이 방식이 성립하는 이유는 a[lo] <= a[mid] 로 정렬된 쪽을 가려낼 수 있다는 것 하나입니다.
값이 중복되면 그 판단이 안 됩니다.
인덱스 0 1 2 3 4
배열 [1, 0, 1, 1, 1] 찾는 값 0
lo=0, hi=4, mid=2 → a[lo]=1, a[mid]=1
a[lo] <= a[mid] 가 성립하니 왼쪽이 정렬됐다고 판단합니다. 그런데 왼쪽은 [1, 0, 1] 이라 정렬이 아닙니다.
판단이 틀렸으니 그다음이 전부 틀립니다. 0은 1 이상 1 미만이 아니라서 오른쪽으로 가고,
오른쪽에는 0이 없으니 못 찾았다고 답합니다. 배열에 있는데도 그렇습니다.
양 끝과 가운데가 같은 값이면 어느 반쪽에 끊긴 자리가 있는지 구별할 방법이 없습니다.
이럴 때는 한 칸씩 좁히는 수밖에 없고, 최악에는 전부 훑게 되어 O(n) 이 됩니다.
if (a[lo] == a[mid] && a[mid] == a[hi]) { lo++; hi--; continue; }
중복이 흔한 데이터라면 이 방식 자체를 다시 생각하는 편이 낫습니다. 로그 버퍼라면 시각이 아니라 순번을 같이 저장해서 중복을 없애는 쪽이 간단합니다.
정리
회전된 정렬 배열은 전체로 보면 정렬이 아니지만, 가운데에서 자르면 반쪽 중 하나는 반드시 정렬돼 있습니다. 그 반쪽의 양 끝만 보면 찾는 값이 거기 있는지 알 수 있고, 없으면 반대쪽으로 갑니다. 절반을 버리는 것은 그대로입니다.
- 정렬된 쪽인지는
a[lo] <= a[mid]로 가립니다 - 끊긴 자리를 찾을 때는
a[hi]와 비교합니다.a[lo]와 비교하면 회전이 없는 배열에서 틀립니다 - 값이 중복되면 어느 쪽이 정렬인지 못 가리고 최악
O(n)이 됩니다. 그런 데이터라면 중복을 없앨 방법부터 찾습니다