Union-Find: 같은 대표를 따라가면 같은 묶음입니다
다섯 장치를 연결하며 대표 번호와 묶음을 이해합니다. parent 배열을 직접 따라간 뒤 경로 압축과 크기 합치기의 이유를 배웁니다.
먼저 작은 예제로 원리를 익히고, 마지막에 전체 구현을 펼쳐 보세요. 앞쪽 예제는 각각 독립적으로 브라우저 개발자 도구 Console에서 실행합니다. 같은 이름을 다시 선언했다는 오류가 나오면 새로고침 후 해당 예제를 실행하세요. 출력 뒤에 콘솔이 별도로 보여주는 undefined는 마지막 명령의 반환값일 수 있습니다.

1. 케이블이 늘어날 때 같은 묶음인지 알고 싶습니다
장치 0, 1, 2, 3, 4가 처음에는 따로 있습니다. 0과 1을 연결하고, 2와 3을 연결한 다음, 1과 2를 연결하면 0·1·2·3은 하나의 묶음이 됩니다. 이제 0과 3 사이에 직접 케이블이 없어도 서로 연결되어 있습니다.
| 새로 연결한 장치 | 연결 후 묶음 | 묶음 수 |
|---|---|---|
| 아직 없음 | {0}, {1}, {2}, {3}, {4} | 5 |
| 0과 1 | {0,1}, {2}, {3}, {4} | 4 |
| 2와 3 | {0,1}, {2,3}, {4} | 3 |
| 1과 2 | {0,1,2,3}, {4} | 2 |
| 0과 3 | 이미 같은 묶음이므로 그대로 | 2 |
Union-Find는 이 묶음을 관리합니다. union은 두 묶음 합치기, find는 어떤 묶음에 속하는지 알아내기입니다. 실제 케이블 경로나 최단 거리는 저장하지 않습니다. “같은 묶음인가?”라는 질문을 연결 추가 때마다 반복해서 답할 때 쓰는 구조입니다.
2. 각 장치에 따라갈 번호를 적습니다
묶음마다 대표 장치 하나를 둡니다. 처음에는 혼자이므로 모두 자신이 대표입니다. parent라는 배열에 다음으로 따라갈 번호를 적고, 자기 자신을 가리키는 번호에서 멈춥니다. 여기서 parent는 실제 케이블 목록이 아니라 묶음을 관리하기 위한 표시입니다.
const parent = [0, 1, 2, 3, 4];
parent[1] = 0;
parent[3] = 2;
console.log(parent.join(', '));
console.log(parent[1]);
0, 0, 2, 2, 4
0
parent[1] = 0은 1이 대표 0을 따라가게 합니다. parent[3] = 2는 3이 대표 2를 따라가게 합니다. 0과 2는 여전히 자신을 가리키므로 각각 묶음의 대표입니다. 이 예제는 원리를 보이려고 배열을 직접 바꿉니다. 아래 전체 클래스는 find와 union을 통해서만 바꾸도록 사용합니다.
3. 바로 앞 번호가 아니라 끝의 대표까지 찾습니다
두 묶음을 합쳐 대표 2가 대표 0을 따라가게 하면 parent는 [0,0,0,2,4]가 됩니다. 이때 3은 한 번에 0으로 가지 않고 3 → 2 → 0으로 갑니다. 1은 1 → 0으로 갑니다. 바로 앞 번호는 달라도 끝의 대표는 같습니다.
| 찾는 장치 | 따라간 경로 | 멈추는 이유 | 대표 |
|---|---|---|---|
| 1 | 1 → 0 | parent[0]이 0 | 0 |
| 3 | 3 → 2 → 0 | parent[0]이 0 | 0 |
| 4 | 4 | parent[4]가 4 | 4 |
const parent = [0, 0, 0, 2, 4];
function find(x) {
while (parent[x] !== x) {
x = parent[x];
}
return x;
}
console.log(find(3));
console.log(find(1) === find(3));
console.log(find(0) === find(4));
0
true
false
while은 자신을 가리키지 않는 동안 다음 번호로 이동합니다. x는 현재 확인 중인 번호라서 이동할 때 바뀝니다. 반복이 끝난 x가 대표입니다. 같은 묶음인지는 parent[a]와 parent[b]를 한 번 비교하는 것이 아니라 find(a)와 find(b)를 비교해야 합니다.
확인 문제: 위 배열에서 parent[1]은 0이고 parent[3]은 2입니다. 그래서 1과 3은 다른 묶음일까요?
정답과 이유 보기
아닙니다. 3의 다음 번호 2도 결국 0을 따라갑니다. 두 장치의 대표는 0으로 같습니다. 직접 가리키는 번호와 최종 대표를 구분하는 것이 find의 목적입니다.
4. 길을 한 번 알았다면 다음에는 짧게 갑니다
3 → 2 → 0을 따라가 대표가 0임을 알았습니다. 다음에도 같은 두 단계를 밟을 필요는 없습니다. 지나온 3과 2가 바로 0을 가리키도록 바꾸면 됩니다. 묶음은 그대로 두고 따라갈 길만 짧게 만드는 경로 압축입니다.
| 시점 | parent[3] | parent[2] | 3의 경로 |
|---|---|---|---|
| 압축 전 | 2 | 0 | 3 → 2 → 0 |
| 3이 바로 0을 가리키게 바꿈 | 0 | 0 | 3 → 0 |
| 2도 대표를 확인 | 0 | 0 | 같은 묶음 유지 |
const parent = [0, 0, 0, 2, 4];
let x = 3;
let root = x;
while (parent[root] !== root) root = parent[root];
while (parent[x] !== x) {
const next = parent[x];
parent[x] = root;
x = next;
}
console.log(root);
console.log(parent.join(', '));
0
0, 0, 0, 0, 4
첫 while은 끝까지 찾아 대표를 알아냅니다. 두 번째 while은 출발점 x로 돌아와 지나온 길을 고칩니다. const next = parent[x]로 원래 다음 번호를 먼저 기억해야 합니다. parent[x]를 root로 바꾼 뒤 원래 값을 읽으려 하면 이미 덮어써졌기 때문입니다.
3을 고친 뒤 x를 원래 다음 번호였던 2로 옮겨 같은 일을 합니다. root가 0이라고 장치 전체를 다 바꾸는 것이 아니라, 방금 지나온 경로만 바꿉니다. 이 과정은 묶음 수를 줄이지 않습니다.
5. 묶음을 합칠 때는 대표끼리 붙입니다
{0,1}과 {2,3}을 합칠 때 1과 2의 표시만 임의로 바꾸면 안 됩니다. 먼저 각자의 대표를 찾고, 한쪽 대표가 다른 대표를 따라가게 해야 묶음 전체가 함께 이동합니다. 또한 작은 묶음을 큰 묶음 아래에 붙이면 불필요하게 깊은 길이 생기는 것을 줄일 수 있습니다.
const parent = [0, 0, 2, 2, 4];
const sizes = [2, 1, 2, 1, 1];
let groups = 3;
const rootA = 0;
const rootB = 2;
if (rootA !== rootB) {
parent[rootB] = rootA;
sizes[rootA] += sizes[rootB];
groups -= 1;
}
console.log(parent.join(', '));
console.log(sizes[rootA]);
console.log(groups);
0, 0, 0, 2, 4
4
2
이 예제는 두 대표를 이미 찾았고 양쪽 크기가 2로 같다는 상황만 떼어 냈습니다. 아래 완성 함수는 find로 대표를 구하고 크기도 비교합니다. 여기서는 대표 2를 0 밑에 붙이고, 0의 묶음 크기를 2+2로 바꾸고, 묶음 수를 하나 줄입니다. 이 세 변경은 서로 다른 묶음을 실제로 합칠 때만 해야 합니다.
sizes는 대표 위치의 값만 사용합니다. 합병 뒤 sizes[2]에 옛 숫자가 남아 있어도 이제 2는 대표가 아니므로 그 숫자로 묶음 크기를 판단하지 않습니다. sizeOf(3)는 먼저 대표 0을 찾은 뒤 sizes[0]을 읽어야 합니다.
6. 전체 코드를 읽을 순서
아래 전체 구현을 펼치면 find의 두 반복문부터 찾으세요. 방금 본 대표 찾기와 압축입니다. 그다음 union의 같은 대표 검사 → 크기 비교 → 대표 연결 → 크기·묶음 수 갱신을 읽으면 됩니다. 낯선 수식보다 같은 예제로 parent가 바뀌는지 먼저 확인하세요.
이 구조는 연결 추가에는 잘 맞지만, 연결을 삭제하거나 실제 이동 경로를 반환하지는 않습니다. 대표 번호가 항상 가장 작은 장치 번호라는 보장도 없습니다. 중요한 것은 같은 묶음의 장치들이 같은 대표를 찾는다는 점입니다.
관련 코딩테스트 문제로 이어서 연습합니다
이 자료구조의 블로그 연습 문제와 해설에서 배운 동작을 적용해 보세요. 먼저 작은 예시를 직접 처리한 뒤 입력 전체를 다루는 코드로 확장하면 됩니다. 아래 전체 구현은 연결된 문제의 기존 메서드와 반환 형식을 유지합니다.
전체 구현 · 상세 설명 · 예외와 성능 분석 펼치기
여기부터는 필요한 기능을 골라 읽는 참고 영역입니다. 새로운 파일로 전체 코드를 실행할 때는 아래 Node.js 실행 안내를 따르세요. 앞쪽의 작은 브라우저 실습과 전체 파일을 한 콘솔에 이어 붙이지 마세요. 기능별 입력 조건과 반환 형식은 아래 설명을 기준으로 합니다.
문제 상황과 API 계약
서로 떨어진 장치 5개를 케이블로 연결한다고 생각해 보겠습니다. 케이블 하나가 추가될 때마다 “0번과 3번이 같은 네트워크인가?”를 묻는다면, 매번 BFS로 모든 간선을 읽는 비용이 쌓입니다. Union-Find는 실제 이동 경로 대신 연결 성분만 기억합니다. 답에 필요한 정보가 “같은 묶음인가”까지일 때 원래 그래프를 저장하지 않아도 됩니다.
원소는 0부터 n−1까지의 정수입니다. find(x)는 대표 번호, same(a,b)는 연결 여부, sizeOf(x)는 성분 크기를 반환합니다. union(a,b)는 서로 다른 성분을 합쳤을 때만 true를 반환하고 count를 1 줄입니다. 대표 번호는 최소 번호라는 의미가 없습니다. 같은 크기이면 첫 인수의 대표를 남기는 것은 이 구현의 규칙일 뿐, 호출 순서를 바꾸면 대표는 달라질 수 있습니다.
n은 0~1,000,000으로 제한합니다. 이 제한은 이론적 한계가 아니라 교육용 배열의 과도한 할당을 막는 계약입니다. n=0인 구조는 만들 수 있지만 원소가 없으므로 find(0)는 오류입니다. 공개 메서드 외에 parent와 sizes를 직접 고치면 뒤에서 증명할 불변식이 깨집니다.
정확성을 지키는 불변식
parent가 만드는 구조는 여러 개의 뿌리 있는 나무입니다. 루트만 parent[root]===root이고, 나머지는 같은 성분 안의 조상을 가리킵니다. 루트에서만 sizes[root]가 정확한 성분 크기입니다. 합쳐진 옛 루트의 sizes는 남아 있어도 더 이상 의미가 없으므로 sizeOf는 반드시 find를 먼저 호출합니다.
크기 합치기는 작은 나무의 루트를 큰 나무의 루트 아래에 붙입니다. 어떤 원소의 깊이가 1 증가했다면 그 원소가 속한 성분의 크기는 적어도 2배가 됩니다. 크기는 n을 넘지 않으므로 깊이는 O(log n)입니다. 합치기를 원소 a,b에 직접 적용하지 않고 대표 ra,rb에 적용해야 성분 일부만 떼어 붙이는 실수를 피할 수 있습니다.
경로 압축은 첫 반복에서 찾은 루트로 방문한 모든 부모를 바꿉니다. 이 변경은 대표 자체를 바꾸지 않아 연결 관계를 보존합니다. next를 먼저 저장하지 않으면 원래 경로를 잃고 첫 원소만 압축하게 됩니다. 같은 성분이면 union이 즉시 끝나므로 중복 케이블과 자기 자신으로의 연결은 count를 줄이지 않습니다.
전체 구현과 실행
Node.js 24의 CommonJS 환경을 기준으로 합니다. 아래 전체 코드를 union-find.cjs로 저장하고 파일이 있는 폴더에서 node union-find.cjs를 실행하세요. 외부 패키지는 필요하지 않습니다. module.exports는 다른 파일에서 가져올 때 사용하며 require.main 조건 안의 호출은 직접 실행할 때만 작동합니다.
class UnionFind {
constructor(n) {
if (!Number.isInteger(n) || n < 0 || n > 1_000_000) {
throw new RangeError('n은 0~1,000,000 정수여야 합니다.');
}
this.parent = Array.from({ length: n }, (_, i) => i);
this.sizes = Array(n).fill(1);
this.count = n;
}
check(x) {
if (!Number.isInteger(x) || x < 0 || x >= this.parent.length) {
throw new RangeError('원소 번호가 범위를 벗어났습니다.');
}
}
find(x) {
this.check(x);
let root = x;
while (this.parent[root] !== root) root = this.parent[root];
while (this.parent[x] !== x) {
const next = this.parent[x];
this.parent[x] = root;
x = next;
}
return root;
}
union(a, b) {
let ra = this.find(a), rb = this.find(b);
if (ra === rb) return false;
if (this.sizes[ra] < this.sizes[rb]) [ra, rb] = [rb, ra];
this.parent[rb] = ra;
this.sizes[ra] += this.sizes[rb];
this.count--;
return true;
}
same(a, b) { return this.find(a) === this.find(b); }
sizeOf(x) { return this.sizes[this.find(x)]; }
}
module.exports = { UnionFind };
if (require.main === module) {
const d = new UnionFind(5);
for (const [a, b] of [[0, 1], [2, 3], [1, 2], [0, 3]]) d.union(a, b);
console.log(JSON.stringify({ groups: d.count, size: d.sizeOf(3), same: d.same(0, 4) }));
}
직접 실행 출력:
{"groups":2,"size":4,"same":false}
constructor는 초기화 O(n)을 한 번 지불합니다. find의 두 while은 “대표 찾기”와 “경로 다시 쓰기”의 책임을 분리합니다. 재귀를 쓰지 않으므로 JavaScript 호출 스택에 의존하지 않습니다.
union에서 크기 비교 뒤 ra,rb를 교환하는 이유는 뒤의 세 줄을 항상 같은 방향으로 실행하기 위해서입니다. parent[rb]=ra, sizes[ra]+=sizes[rb], count–는 하나의 합병에 해당합니다. 이 세 변경을 같은 성분 처리에도 실행하면 크기가 중복 집계됩니다.
실행 예제는 네 개의 연결 요청을 처리합니다. 마지막 요청은 이미 연결된 두 원소이므로 그룹 수는 2에 머뭅니다. 출력의 size는 원소 3이 속한 네 원소 성분을 뜻하고, 4는 여전히 혼자이므로 same은 false입니다.
상태 변화 따라가기
| 작업 | 루트별 성분 | count | 판단 이유 |
|---|---|---|---|
| 초기 | {0}, {1}, {2}, {3}, {4} | 5 | 원소마다 독립 루트 |
| union(0,1) | {0,1}, {2}, {3}, {4} | 4 | 크기 1+1, 루트 0 유지 |
| union(2,3) | {0,1}, {2,3}, {4} | 3 | 루트 2의 크기 2 |
| union(1,2) | {0,1,2,3}, {4} | 2 | find(1)=0, find(2)=2 |
| find(3) | 성분은 그대로 | 2 | 3→2→0 경로를 3→0으로 단축 |
| union(0,3) | 성분은 그대로 | 2 | 이미 같은 대표라 false |
복잡도와 적용하지 말아야 할 경우
| 작업 | 시간 | 추가 공간과 조건 |
|---|---|---|
| 초기화 | 최악 O(n) | parent와 sizes O(n) |
| find / same / union / sizeOf 1회 | 최악 O(log n) | 반복 구현의 작업 공간 O(1) |
| m회 조회·합치기 | O(n + m α(n)) 상환 분석 | 크기 합치기와 경로 압축을 함께 적용 |
| count 읽기 | 최악 O(1) | 루트 수를 별도 유지 |
α(n)은 역 아커만 함수입니다. 실제 크기에서 매우 작더라도 “무조건 최악 O(1)”로 적으면 틀립니다. 상환 시간은 긴 연산열의 총비용을 나눠 본 값이며, 입력을 무작위로 가정하는 평균 시간과 다릅니다.
간선을 삭제하거나 특정 원소를 집합에서 분리하는 연산에는 이 구조를 그대로 쓰면 안 됩니다. 이미 압축한 부모에는 어떤 간선 때문에 연결됐는지 정보가 없습니다. 삭제가 섞인 문제는 오프라인 역처리, rollback DSU 또는 동적 연결성 구조가 필요한지 따로 설계해야 합니다.
실제 경로, 최단 거리, 간선 목록이 필요하면 그래프 인접 리스트와 BFS/DFS가 적합합니다. 연결이 한 번만 주어지고 질의도 한 번이라면 단순 순회가 더 읽기 쉽습니다. Union-Find의 장점은 연결 추가와 반복 질의가 함께 있을 때 드러납니다.
연습으로 확인하기
새 연결마다 전체 묶음 수와 한쪽 묶음 크기를 함께 기록해 보세요. 단순 true/false보다 “언제 상태를 바꿔야 하는가”를 더 엄격하게 확인할 수 있습니다.
AtCoder A – Disjoint Set Union — 간선 추가와 연결 여부 질의를 연습하는 공식 문제입니다. 원문은 링크에서 확인하며, 아래 창작 문제와 출력 계약은 다릅니다.
공식 자료
공식 자료 확인일: 2026년 9월 10일. 구현은 이 글의 JavaScript 계약에 맞춰 독립적으로 작성했습니다. 다른 언어 라이브러리의 API와 숫자 범위가 그대로 적용되는 것은 아닙니다.
직접 실습: 연산 비용으로 구조를 설명합니다
실습 주제: JavaScript Union-Find: 경로 압축과 크기 합치기로 연결 상태 관리하기
- 본문 구현에서 저장되는 값과 연결 관계를 그림으로 적습니다.
- 조회·삽입·삭제 중 이 구조가 가장 자주 수행할 연산을 고릅니다.
- 연산 전후에도 유지되어야 하는 규칙을 한 문장으로 적습니다.
- 배열이나 Map 같은 다른 구조로 바꿨을 때 시간·공간 비용을 비교합니다.
풀이 기준과 확인 결과
메서드 이름만 외우지 말고 한 번의 연산에서 어떤 값과 연결이 바뀌는지 추적하세요. 빈 구조, 원소 한 개, 중복값, 연속 삽입·삭제를 실행했을 때 본문이 설명한 불변식이 유지되면 성공입니다.
테스트 체크리스트
- 빈 구조에 대한 조회·삭제 처리
- 첫 원소와 마지막 원소 변경
- 중복값 또는 동일 우선순위 처리
- 입력 크기가 커졌을 때 예상 복잡도 유지
이 글이 도움이 되었나요?
자료구조 학습 순서
필수 18개 · 전체 18개
읽음 기록 관리
전체 과정 목차 (18개)
- 필수 학습 · 자료구조 선택 가이드: 연산 비용으로 배열·스택·큐·Set 고르기
- 필수 학습 · JavaScript 배열: 인덱스 조회와 삽입·삭제 비용
- 필수 학습 · JavaScript Map·Set: 값 조회와 중복 제거 실습
- 필수 학습 · 자료구조 스택 쉽게 이해하기: push pop으로 문제 풀이 감 잡기
- 필수 학습 · 큐와 FIFO: head 인덱스로 JavaScript 대기열 만들기
- 필수 학습 · 단방향 연결 리스트: head·tail 삽입과 삭제
- 필수 학습 · JavaScript 원형 덱 구현: 양끝 삽입·삭제와 고정 용량 버퍼
- 필수 학습 · JavaScript 문자열 해시 테이블 구현: 충돌 처리와 리사이즈, NFC 정규화
- 필수 학습 · 트리 자료구조 차이: 이진 트리 BST MST 구분하기
- 필수 학습 · JavaScript 이진 탐색 트리 구현: 중복 키와 세 가지 삭제 처리
- 필수 학습 · JavaScript 최소 힙 구현: 우선순위 큐의 push·pop과 비교 함수
- 필수 학습 · JavaScript 그래프 구현: 인접 리스트·인접 행렬 비교와 BFS
- 필수 학습 · JavaScript Union-Find: 경로 압축과 크기 합치기로 연결 상태 관리하기 현재 글
- 필수 학습 · JavaScript Trie: Unicode 접두사 검색과 안전한 삭제 구현
- 필수 학습 · JavaScript Fenwick Tree: lowbit로 구간 합과 단일 증가 갱신 구현
- 필수 학습 · JavaScript 반복형 세그먼트 트리: 구간 합·단일 대입·결합 순서
- 필수 학습 · JavaScript LRU 캐시: Map과 이중 연결 리스트의 불변식
- 필수 학습 · JavaScript AVL 트리: 높이 불변식과 LL·RR·LR·RL 삽입 회전
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.