MO's algorithm
- 알고리즘 / etc.
- 2016. 11. 21. 12:49
MO's algorithm은 온라인으로 풀기 힘든 쿼리 문제를 오프라인으로 쉽게 풀 수 있게 해주는 알고리즘이다. MO's algorithm은 쿼리를 정렬한 뒤 정렬된 순서대로 처리를 한다. 이론은 이게 끝이다.
이론만 들어서는 아마 문제에 어떻게 적용해야 할 지 감이 안올것이다. 그럼 연습문제를 풀면서 MO's algorithm을 익혀보도록 하자. 연습문제는 https://www.acmicpc.net/problem/13547에서 풀 수 있다.
구간에 존재하는 서로 다른 수의 개수
연습 문제는 아래와 같다.
자연수로 이루어진
1.
문제 자체는 상당히 단순하다. 문제를 꼬아놓은 것도 없으며 요구하는것도 명확하다. 이 문제에 어떻게 MO's algorithm을 적용할 수 있을까?
완전 탐색
우선 가장 쉽게 짤 수 있는 완전 탐색코드를 살펴 보자. 쿼리로 들어온 구간을 모두 살펴보면서 flag배열을 하나 둬 중복되는 것을 제외하고 수를 카운팅한다. 이 코드의 시간복잡도는 어떻게 될까? 쿼리의 개수가
// brute force function
int query(int lo, int hi) {
int ret = 0;
// A[i]가 체크가 안됐다면 체크를 해주고 count를 1증가 시킨다
for (int i = lo ; i <= hi ; ++i) {
if (!isExist[A[i]]) {
++ret;
isExist[A[i]] = true;
}
}
// 다음 쿼리를 위해 true로 체크되어 있는 곳을 false로 초기화 시켜준다
for (int i = lo ; i <= hi ; ++i) {
isExist[A[i]] = false;
}
return ret;
}
이전 쿼리를 재활용
우리는 완전 탐색보다 조금 더 똑똑하게 코드를 짤 수 있다. 잘 생각해 본다면 이전 쿼리의 내용을 일부 재활용 할 수 있다는 것을 알 수 있다. 만약

시퀀스의 원소들을 총 네가지로 분류할 수 있다.
- 회색 원소들 :
과 어디에도 속하지 않는 원소들 - 파란색 원소들 :
과 두 곳에 모두 속하는 원소들 - 주황색 원소들 :
에만 속하는 원소들 - 초록색 원소들 :
에만 속하는 원소들
// add function
void add(int num) {
// num이 새롭게 cnt배열에 들어올때만 카운팅 해준다
if (++cnt[num] == 1) {
++distinctNumbers;
}
}
// erase function
void erase(int num) {
// num이 cnt배열에서 완전히 없어질때 카운팅 해준다
if (--cnt[num] == 0) {
--distinctNumbers;
}
}
추가 함수에서는
// Change the iterator from the previous query to the current query
int lo = 0, hi = 0;
// 시작 이터레이터는 [0, 0]이기 때문에 A[0]의 값을 cnt배열에 넣고 시작한다
add(A[0]);
// 모든 쿼리에 대한 답을 계산한다
for (auto cur : query) {
// 현재 위치는 [lo, hi]이고 다음 위치는 [nlo, nhi]이다
int nlo = cur.lo, nhi = cur.hi;
// lo를 nlo로 움직이는 부분
// 1. lo가 nlo보다 왼쪽에 있는 경우 : 수를 제거할 필요가 있다
for (int i = lo ; i < nlo ; ++i) {
erase(A[i]);
}
// 2. lo가 nlo보다 오른쪽에 있는 경우 : 수를 더할 필요가 있다
for (int i = lo - 1; i >= nlo ; --i) {
add(A[i]);
}
// hi를 nhi로 움직이는 부분
// 1. hi가 nhi보다 왼쪽에 있는 경우 : 수를 더할 필요가 있다
for (int i = hi + 1 ; i <= nhi ; ++i) {
add(A[i]);
}
// 2. hi가 nhi보다 오른쪽에 있는 경우 : 수를 제거할 필요가 있다
for (int i = hi ; i > nhi ; --i) {
erase(A[i]);
}
// 이터레이터가 현재 쿼리의 범위를 나타내도록 한다
lo = nlo;
hi = nhi;
// 답을 저장할 배열에 실제 쿼리의 순서에 맞게 답을 기록해 준다
ans[cur.idx] = distinctNumbers;
}
이제 완벽하게 이전 쿼리를 재활용 할 수 있게 됐다. 그렇다면 이 코드의 시간복잡도는 어떻게 될까? 너무나 불행하게도 이코드 또한
쿼리를 정렬
놀랍게도 쿼리를 정렬함으로써 위 코드의 시간복잡도를 개선할 수 있다. 쿼리를 정렬할 때 Sqrt decomposition의 개념이 들어간다. 우선 시퀀스의 인덱스들을 일정한 크기로 나눈 뒤 나눠진 부분을 한 묶음으로 생각한다. 통상적으로 나누는 크기는
// sort queries
int sz = sqrt(n);
sort(query.begin(), query.end(), [&](const Query& l, const Query& r) {
// 쿼리가 끝나는 부분이 어떠한 묶음에 속하는지 확인하다
// 묶음의 인덱스가 더 작은곳에 속하는 쿼리를 먼저 처리한다
// 만약 묶음의 인덱스가 같다면 쿼리의 시작지점이 작은것을 먼저 처리한다
int lIdx = l.hi / sz;
int rIdx = r.hi / sz;
return lIdx == rIdx ? l.lo < r.lo : lIdx < rIdx;
});
MO's algorithm의 정당성
이렇게 쿼리를 정렬하는 것만으로
먼저 이터레이터
이제 이터레이터
이터레이터
마지막으로 이 문제의 전체 소스코드를 첨부한다.
전체 코드
#include <stdio.h>
#include <math.h>
#include <vector>
#include <algorithm>
using namespace std;
struct Query {
int lo, hi, idx;
Query(int lo, int hi, int idx) : lo(lo), hi(hi), idx(idx) {}
};
int n, m, A[100010], cnt[1000010], ans[100010], distinctNumbers;
vector<Query> query;
// add function
void add(int num) {
// num이 새롭게 cnt배열에 들어올때만 카운팅 해준다
if (++cnt[num] == 1) {
++distinctNumbers;
}
}
// erase function
void erase(int num) {
// num이 cnt배열에서 완전히 없어질때 카운팅 해준다
if (--cnt[num] == 0) {
--distinctNumbers;
}
}
int main() {
scanf("%d", &n);
for (int i = 0 ; i < n ; ++i) {
scanf("%d", &A[i]);
}
scanf("%d", &m);
for (int i = 0 ; i < m ; ++i) {
int lo, hi;
scanf("%d%d", &lo, &hi);
query.emplace_back(lo - 1, hi - 1, i);
}
// sort queries
int sz = sqrt(n);
sort(query.begin(), query.end(), [&](const Query& l, const Query& r) {
// 쿼리가 끝나는 부분이 어떠한 묶음에 속하는지 확인하다
// 묶음의 인덱스가 더 작은곳에 속하는 쿼리를 먼저 처리한다
// 만약 묶음의 인덱스가 같다면 쿼리의 시작지점이 작은것을 먼저 처리한다
int lIdx = l.hi / sz;
int rIdx = r.hi / sz;
return lIdx == rIdx ? l.lo < r.lo : lIdx < rIdx;
});
// Change the iterator from the previous query to the current query
int lo = 0, hi = 0;
// 시작 이터레이터는 [0, 0]이기 때문에 A[0]의 값을 cnt배열에 넣고 시작한다
add(A[0]);
// 모든 쿼리에 대한 답을 계산한다
for (auto cur : query) {
// 현재 위치는 [lo, hi]이고 다음 위치는 [nlo, nhi]이다
int nlo = cur.lo, nhi = cur.hi;
// lo를 nlo로 움직이는 부분
// 1. lo가 nlo보다 왼쪽에 있는 경우 : 수를 제거할 필요가 있다
for (int i = lo ; i < nlo ; ++i) {
erase(A[i]);
}
// 2. lo가 nlo보다 오른쪽에 있는 경우 : 수를 더할 필요가 있다
for (int i = lo - 1; i >= nlo ; --i) {
add(A[i]);
}
// hi를 nhi로 움직이는 부분
// 1. hi가 nhi보다 왼쪽에 있는 경우 : 수를 더할 필요가 있다
for (int i = hi + 1 ; i <= nhi ; ++i) {
add(A[i]);
}
// 2. hi가 nhi보다 오른쪽에 있는 경우 : 수를 제거할 필요가 있다
for (int i = hi ; i > nhi ; --i) {
erase(A[i]);
}
// 이터레이터가 현재 쿼리의 범위를 나타내도록 한다
lo = nlo;
hi = nhi;
// 답을 저장할 배열에 실제 쿼리의 순서에 맞게 답을 기록해 준다
ans[cur.idx] = distinctNumbers;
}
for (int i = 0 ; i < m ; ++i) {
printf("%d\n", ans[i]);
}
}
'알고리즘 > etc.' 카테고리의 다른 글
빠르게 배열의 부분 합을 구하는 Prefix Sum 알고리즘 (0) | 2024.06.04 |
---|---|
Sqrt Decomposition (4) | 2016.11.20 |