작은 집합을 큰 집합에 합치기 (Smaller to Larger)
정의
Smaller to Larger (작은 집합을 큰 집합에 합치기) 는 두 집합을 합칠 때 항상 작은 쪽을 큰 쪽에 합치면 전체 O(N log N) 또는 O(N log^2 N) 으로 amortized 비용이 떨어지는 최적화 기법. 트리 서브트리 병합, DSU (Disjoint Set Union), map/set 합병에서 자주 쓰임.
개념 자체는 고전적이지만, “DSU on Tree” (Arpa’s trick) 라는 이름으로 2015년 경 Codeforces 에서 널리 퍼짐.
문제 상황과 동기
트리의 각 서브트리마다 “값의 집합” 을 관리하고, 부모로 올라가며 자식들을 병합.
- naive: 각 노드에서 자식 서브트리를 flat merge. 최악 O(N^2) (일자 트리).
- smaller to larger: 작은 집합을 큰 집합에 합침. 각 원소는 최대 log N 번 이동 → O(N log N) 또는 O(N log^2 N).
핵심 통찰: 한 원소가 합병될 때마다 새 집합 크기가 최소 2배. 따라서 원소당 최대 log N 번 합병.
자주 등장: 트리 서브트리 쿼리 (distinct colors, frequency), DSU 최적화, map/set 병합.
시각화
핵심 아이디어
invariant: 각 원소는 최대 log N 번만 다른 집합으로 복사됨.
merge(A, B):
if |A| > |B|:
swap(A, B)
for x in A:
B.insert(x)
return B
- 작은 집합 A 를 큰 집합 B 로 합침.
- A 의 각 원소는 B 로 이동. 다음 합병 시 B 의 크기는 최소 2|A|.
- 원소 하나가 log N 번 이동하면 최종 집합 크기 ≥ 2^(log N) = N.
map/set 인 경우: insert 가 O(log M) 이면 전체 O(N log N × log N) = O(N log^2 N).
알고리즘
기본형 (set 병합)
dfs(v):
set S_v = {value[v]}
for each child u:
S_u = dfs(u)
if |S_u| > |S_v|:
swap(S_v, S_u)
for x in S_u:
S_v.insert(x)
answer[v] = process(S_v)
return S_v
DSU on Tree (모든 서브트리 쿼리)
dfs(v, keep):
heavy_child = child with max subtree size
for each light child u:
dfs(u, false)
dfs(heavy_child, true)
for each light child u:
add(u)
answer[v] = query()
if not keep:
remove(v)
heavy_child 는 재귀 후 그대로 두고, light child 는 재귀 후 삭제. light child 재추가 시 smaller to larger 보장.
구현
// 서브트리의 distinct 값 개수 (smaller to larger, O(N log^2 N))
#include <bits/stdc++.h>
using namespace std;
vector<vector<int>> adj;
vector<int> val;
vector<int> ans;
set<int> dfs(int v, int p) {
set<int> S;
S.insert(val[v]);
for (int u : adj[v]) {
if (u == p) continue;
auto Su = dfs(u, v);
if (Su.size() > S.size()) swap(S, Su);
for (int x : Su) S.insert(x);
}
ans[v] = (int)S.size();
return S;
}
int main() {
int n; cin >> n;
adj.resize(n); val.resize(n); ans.resize(n);
for (int i = 0; i < n; i++) cin >> val[i];
for (int i = 0; i < n - 1; i++) {
int u, v; cin >> u >> v; u--; v--;
adj[u].push_back(v);
adj[v].push_back(u);
}
dfs(0, -1);
for (int i = 0; i < n; i++) cout << ans[i] << " ";
cout << "\n";
}5
1 2 1 3 2
1 2
1 3
2 4
2 53 2 1 1 1복잡도
| 항목 | 값 |
|---|---|
| 시간 (set/map 병합) | O(N log N × log N) = O(N log^2 N) |
| 시간 (배열/벡터 병합) | O(N log N) |
| 시간 (DSU on Tree) | O(N log N) or O(N log^2 N) |
| 공간 | O(N) - DFS 스택 + 집합 |
각 원소는 최대 log N 번 복사. set 의 insert 가 O(log M) 이면 × log N. 단순 배열 병합은 O(1) insert 라 O(N log N).
변형 / 활용
1. DSU on Tree (Arpa’s trick)
모든 서브트리에서 쿼리. heavy/light decomposition 결합. 각 노드에서 light child 만 재추가 → O(N log N).
2. map 병합 (frequency count)
map<int, int> 를 병합할 때도 동일 원리. 작은 map 을 큰 map 에 insert.
3. persistent data structure
작은 집합을 복사하므로 persistent 버전 (함수형 자료구조) 으로 확장 가능.
함정
1. swap 누락
항상 작은 쪽을 큰 쪽으로 합쳐야 함. swap 안 하면 최악 O(N^2).
2. move semantic 미사용 (C++)
S.insert(Su.begin(), Su.end()) 대신 swap + insert 가 효율적. C++11 move 로 더 빠름.
3. 재귀 깊이 (Python)
Python 은 sys.setrecursionlimit 필수. N=10^5 면 기본 1000 한도 초과.
4. DSU on Tree 의 keep flag 오용
keep=false 일 때 서브트리 정보를 삭제해야 메모리 절약. 안 하면 공간 O(N^2).
BOJ 연습 문제
| 번호 | 제목 | 정답률 | 링크 |
|---|---|---|---|
| BOJ 17429 | 국제 메시 기구 | - | kokoa-lab |
| BOJ 13513 | 트리와 쿼리 2 | - | kokoa-lab |
| BOJ 16074 | Mountaineers | - | kokoa-lab |
참고
이 글의 용어 (4개)
- 깊이 우선 탐색 (DFS)algorithm
- 정의 깊이 우선 탐색 (Depth-First Search, DFS) 는 그래프 G=(V, E) 에서 갈 수 있는 만큼 깊이 들어가다가 막히면 백트래킹하는 알고리즘. 스택 (LIF…
- 분리 집합 (Disjoint Set, Union-Find)algorithm
- 정의 분리 집합 (Disjoint Set, Union-Find) 은 서로 겹치지 않는 집합들을 관리하며, 다음 두 연산을 거의 상수 시간 (amortized O(α(N)), α는…
- 트리 (Trees)algorithm
- 정의 트리 (Tree) 는 사이클이 없는 연결 그래프 (acyclic connected graph). N 개 정점이면 정확히 N-1 개 간선. 임의의 두 정점 사이에 유일한 경로…
- Heavy-Light Decompositionalgorithm
- 정의 Heavy-Light Decomposition (HLD) 는 트리를 O(log N) 개의 경로 (chain) 로 분해해 임의 경로 쿼리 (u-v 경로의 합/최대/최소) 를 …
💬 댓글