라이브 코딩에서 그래프 문제는 거의 빠지지 않는다. BFS/DFS만큼 자주 등장하지는 않지만, "두 원소가 같은 그룹인지", "지금까지 연결된 컴포넌트가 몇 개인지", "이 간선을 추가해도 사이클이 생기지 않는지"를 묻는 문제가 나오면 다른 자료구조로는 깔끔하게 풀리지 않는다. 이때 Union-Find(Disjoint Set Union, DSU)가 정답이다...
라이브 코딩에서 그래프 문제는 거의 빠지지 않는다. BFS/DFS만큼 자주 등장하지는 않지만, "두 원소가 같은 그룹인지", "지금까지 연결된 컴포넌트가 몇 개인지", "이 간선을 추가해도 사이클이 생기지 않는지"를 묻는 문제가 나오면 다른 자료구조로는 깔끔하게 풀리지 않는다. 이때 Union-Find(Disjoint Set Union, DSU)가 정답이다.
라이브 코딩 환경에서 Union-Find가 매력적인 이유는 세 가지다.
첫째, 코드량이 짧다. 면접 시간이 45분이라 가정할 때, 사고 시간을 충분히 확보하면서도 실수 없이 칠 수 있는 분량이다. 둘째, 거의 외워도 되는 골격이 있다. parent[], find(), union() 세 가지가 전부고, path compression과 union by rank/size를 합치면 거의 상수에 가까운 시간 복잡도가 나온다. 셋째, 응용 폭이 넓다. Kruskal MST, 사이클 탐지, 동적 연결성, 오프라인 쿼리 처리, 그리드 컴포넌트 카운팅 같은 문제를 같은 골격으로 푼다.
시니어 백엔드 관점에서도 의미가 있다. 분산 환경에서 "어떤 두 리소스가 같은 샤드에 속하는가", "장애 도메인 단위로 묶일 때 그룹이 어떻게 형성되는가" 같은 질문은 결국 disjoint set 문제다. 알고리즘 자체보다 "이 문제는 disjoint set으로 모델링되는구나"라고 빠르게 인지하는 능력이 평가된다.
Union-Find의 본질은 각 원소가 자신이 속한 집합의 대표자(root)를 향해 부모 포인터를 따라 올라가는 트리 구조다. 모든 원소가 별도 트리로 시작했다가, union 연산을 거치면서 트리들이 합쳐진다.
가장 단순한 표현은 다음 두 가지다.
parent[i] = i 자신 → i는 자기 자신이 root
parent[i] = j (j ≠ i) → i의 부모는 jfind(x)는 parent[x]를 따라 올라가다가 parent[r] == r인 r을 만나면 그 r이 x가 속한 집합의 대표자다.
union(x, y)는 find(x)와 find(y)를 구한 뒤, 한쪽 root의 부모를 다른 쪽 root로 설정해 두 트리를 합친다.
여기까지가 골격이다. 그런데 그냥 이대로 두면 한쪽으로 길게 늘어진 트리가 만들어져서 find가 O(n)까지 갈 수 있다. 그래서 두 가지 최적화를 거의 항상 같이 적용한다.
find(x)를 수행하면서 거쳐 간 모든 노드의 parent를 곧장 root로 갱신한다. 두 번째 호출부터는 거의 한 번에 root에 도달한다.
int find(int x) {
if (parent[x] == x) return x;
parent[x] = find(parent[x]); // 재귀하면서 root로 평탄화
return parent[x];
}스택이 깊어지는 게 걱정이면 반복문 + 두 번째 패스로 압축할 수 있지만, 라이브 코딩에서는 위 재귀형이 가독성이 가장 좋다. 입력 규모가 N=10^5 정도면 재귀 깊이도 충분히 견딘다.
두 트리를 합칠 때, 키(또는 크기)가 작은 트리를 큰 트리에 붙여야 트리가 더 깊어지지 않는다.
라이브 코딩에서는 size가 더 자주 유용하다. 컴포넌트 크기를 직접 묻는 문제(예: "현재 가장 큰 그룹의 크기는?")가 자주 나오는데, size를 들고 있으면 별도 계산이 필요 없다.
path compression + union by rank/size를 함께 쓰면 m개의 연산에 대해 O(m · α(n))이 된다. α는 inverse Ackermann이라 사실상 4 이하의 상수로 봐도 된다.
Union-Find로 풀어야 한다는 신호는 보통 이렇다.
반대로 "두 노드 사이의 최단 경로", "특정 경로의 가중치 합" 같은 질문이 들어오면 Union-Find만으로는 부족하고 BFS/DFS/Dijkstra가 필요하다. Union-Find는 정확히 연결성(connectivity) 만을 다루는 자료구조라는 점을 잊으면 안 된다.
또 하나, 간선 삭제(disconnect)는 Union-Find가 직접 다루기 어렵다. 간선이 빠지는 시나리오를 보면 "역으로 시간을 되감아 union으로 재해석"하는 트릭(오프라인 처리)을 떠올려야 한다. 라이브 코딩에서 이 패턴이 나오면 면접관이 시니어급 사고를 보고 싶어 한다는 신호다.
라이브 코딩 첫 5분 안에 결정해야 한다.
r * cols + c로 평탄화)세 개 이상이 들어맞으면 Union-Find로 시작하고, 풀리지 않을 때 BFS/DFS로 후퇴하는 전략을 쓴다.
라이브 코딩에서 외워둬야 할 핵심 골격이다. 약 25줄.
class DSU {
int[] parent;
int[] size;
int components;
DSU(int n) {
parent = new int[n];
size = new int[n];
components = n;
for (int i = 0; i < n; i++) {
parent[i] = i;
size[i] = 1;
}
}
int find(int x) {
if (parent[x] == x) return x;
parent[x] = find(parent[x]);
return parent[x];
}
boolean union(int a, int b) {
int ra = find(a);
int rb = find(b);
if (ra == rb) return false; // 이미 같은 그룹
if (size[ra] < size[rb]) { int t = ra; ra = rb; rb = t; }
parent[rb] = ra;
size[ra] += size[rb];
components--;
return true;
}
int componentSize(int x) { return size[find(x)]; }
boolean connected(int a, int b) { return find(a) == find(b); }
}이 템플릿은 거의 모든 라이브 코딩에서 그대로 쓸 수 있다. union이 boolean을 반환하게 한 게 핵심 트릭이다. "사이클을 만드는 간선" 문제, "MST에 들어간 간선 수" 문제 모두 반환값만 보면 된다.
라이브 코딩에서 면접관이 가장 자주 잡아내는 실수들이다. 적어도 한 번씩 직접 겪어봐야 한다.
parent[i] = i 초기화 누락. 0으로 가만히 두면 모든 원소의 root가 0이 되어 처음부터 사이클이 보인다.find 안에서 path compression을 빼먹음. 작동은 하지만 Worst-case에서 TLE.union에서 root끼리 비교하지 않고 원본 인덱스끼리 비교. parent[a] = b처럼 짜면 트리가 끊긴다. 항상 find(a), find(b)를 먼저 구한다.size를 root가 아닌 원소에 갱신. 합칠 때 반드시 size[ra] += size[rb]처럼 root 인덱스에만 누적해야 한다.components--를 union 성공 시에만 해야 하며, root가 같을 때 빼면 음수로 간다.idx = r * cols + c인데 r * rows + c로 잘못 적는 패턴이 흔하다. 화이트보드에 한 번 작성해두는 습관이 도움이 된다.HackerRank 스타일 라이브 코딩에서는 코드를 치기 전에 어떻게 사고했는지를 말로 풀어내는 게 거의 절반의 점수다. 다음 5단계로 말하면 안전하다.
면접관이 "더 최적화할 수 있나요?"라고 물으면, "이미 Union-Find는 거의 상수 시간이고, I/O 비용이 더 큰 구간일 가능성이 큽니다. BufferedReader로 입력을 받겠습니다." 정도로 정리하면 된다. 시니어급 답변이다.
JDK 17 이상이면 충분하다. 한 파일에 클래스 하나 두고 바로 컴파일하면 된다.
mkdir -p ~/practice/dsu
cd ~/practice/dsu
# Solution.java 작성 후
javac Solution.java
echo "테스트 입력" | java Solution라이브 코딩 환경(HackerRank, CoderPad 등)은 stdin/stdout 기반이 많다. 평소 연습할 때부터 BufferedReader/StringTokenizer 패턴을 손에 익혀두면 좋다.
import java.io.*;
import java.util.*;
public class Solution {
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StringTokenizer st = new StringTokenizer(br.readLine());
// ...
StringBuilder sb = new StringBuilder();
// 출력은 sb에 모아 마지막에 한 번에
System.out.print(sb);
}
}Scanner는 편하지만 큰 입력에서 느려서 면접 중 TLE의 흔한 원인이다.
N명의 사람이 있고(0 ~ N-1), 친구 관계 M개가 주어진다. 친구 관계는 양방향이며, 친구의 친구도 같은 그룹으로 본다. 전체 그룹이 몇 개인지 출력하라.
입력 1행:
N M다음 M행:a b(a와 b가 친구) 출력: 컴포넌트 개수제약: 1 ≤ N ≤ 10^5, 0 ≤ M ≤ 2·10^5
먼저 직접 풀어 보고, 막히면 아래 풀이를 본다.
핵심 아이디어는 단순하다. DSU를 만들고 모든 친구 관계에 대해 union한 뒤, components만 출력하면 된다. 별도의 set 카운팅도 필요 없다.
주의할 점은 자기 자신과의 친구 관계나 이미 같은 그룹인 관계가 들어와도 union이 false를 반환하면서 components를 줄이지 않는다는 점이다. 우리 템플릿이 이미 안전하게 처리한다.
복잡도는 O((N + M) · α(N))이고, N=10^5, M=2·10^5에서 즉시 통과한다.
import java.io.*;
import java.util.*;
public class Solution {
static int[] parent, size;
static int components;
static int find(int x) {
if (parent[x] == x) return x;
parent[x] = find(parent[x]);
return parent[x];
}
static boolean union(int a, int b) {
int ra = find(a), rb = find(b);
if (ra == rb) return false;
if (size[ra] < size[rb]) { int t = ra; ra = rb; rb = t; }
parent[rb] = ra;
size[ra] += size[rb];
components--;
return true;
}
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StringTokenizer st = new StringTokenizer(br.readLine());
int n = Integer.parseInt(st.nextToken());
int m = Integer.parseInt(st.nextToken());
parent = new int[n];
size = new int[n];
components = n;
for (int i = 0; i < n; i++) {
parent[i] = i;
size[i] = 1;
}
for (int i = 0; i < m; i++) {
st = new StringTokenizer(br.readLine());
int a = Integer.parseInt(st.nextToken());
int b = Integer.parseInt(st.nextToken());
union(a, b);
}
System.out.println(components);
}
}면접 중에 이 코드를 칠 때는 find/union을 먼저 작성한 뒤 main을 채우는 순서가 자연스럽다. main을 먼저 쓰면 자료구조가 흔들릴 때 코드가 꼬인다.
N개의 노드가 있다(1 ~ N). Q개의 쿼리가 주어진다.
1 a b: 노드 a와 b를 연결한다.2 a: 현재 a가 속한 그룹의 크기를 출력한다.3: 현재 가장 큰 그룹의 크기를 출력한다.제약: 1 ≤ N ≤ 2·10^5, 1 ≤ Q ≤ 5·10^5
1-indexed 입력에 주의.
라이브 코딩에서 자주 나오는 변형이다. "전역 최대값"을 union마다 갱신해야 한다는 작은 트릭이 추가된다.
DSU에 maxSize 변수를 추가한다. union이 성공할 때만 maxSize = max(maxSize, size[ra])를 갱신하면 된다. 쿼리 3은 O(1).
쿼리 2는 componentSize(a)를 그대로 쓰면 된다.
1-indexed 입력은 두 가지로 처리할 수 있다. (a) 0-indexed로 내부 변환, (b) 배열을 N+1 크기로 잡기. 라이브에서 실수가 적은 쪽은 (b)다. 단, parent[0]은 사용하지 않는다는 점만 명확히 한다.
또 한 가지, 출력이 많은 문제이므로 StringBuilder에 모아 마지막에 한 번 출력하는 게 안전하다. System.out.println을 쿼리마다 호출하면 I/O 때문에 시간 초과가 날 수 있다.
import java.io.*;
import java.util.*;
public class Solution {
static int[] parent, size;
static int maxSize;
static int find(int x) {
if (parent[x] == x) return x;
parent[x] = find(parent[x]);
return parent[x];
}
static void union(int a, int b) {
int ra = find(a), rb = find(b);
if (ra == rb) return;
if (size[ra] < size[rb]) { int t = ra; ra = rb; rb = t; }
parent[rb] = ra;
size[ra] += size[rb];
if (size[ra] > maxSize) maxSize = size[ra];
}
static int componentSize(int x) { return size[find(x)]; }
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StringTokenizer st = new StringTokenizer(br.readLine());
int n = Integer.parseInt(st.nextToken());
int q = Integer.parseInt(st.nextToken());
parent = new int[n + 1];
size = new int[n + 1];
for (int i = 1; i <= n; i++) {
parent[i] = i;
size[i] = 1;
}
maxSize = 1;
StringBuilder sb = new StringBuilder();
for (int i = 0; i < q; i++) {
st = new StringTokenizer(br.readLine());
int op = Integer.parseInt(st.nextToken());
if (op == 1) {
int a = Integer.parseInt(st.nextToken());
int b = Integer.parseInt(st.nextToken());
union(a, b);
} else if (op == 2) {
int a = Integer.parseInt(st.nextToken());
sb.append(componentSize(a)).append('\n');
} else {
sb.append(maxSize).append('\n');
}
}
System.out.print(sb);
}
}이 문제에서 면접관이 던질 만한 추가 질문은 두 가지다.
첫째, "간선이 빠지는 4번 쿼리도 추가되면?" — 이 순간 Union-Find로는 직접 못 푼다고 솔직히 말하고, 오프라인 처리(쿼리를 모두 받아 역순으로 union) 또는 Link-Cut Tree 같은 고급 자료구조를 언급한다. 시니어급 답변이다.
둘째, "쿼리 3을 O(1) 말고 진짜로 매번 정확히 계산하라면?" — heap을 같이 들고 있어야 하지만, lazy deletion 패턴이 필요하다고 설명한다. 시간이 남으면 구현, 부족하면 설명만으로도 충분하다.
알고리즘 면접이라도 결국 "이 사람이 시스템을 만들 수 있는가"를 본다. Union-Find 답변에서 시니어 색을 입히는 포인트는 다음과 같다.
connected, componentSize, union이 boolean을 반환하는 부분을 단위 테스트하겠습니다."면접에서 가장 듣고 싶지 않은 답변은 "외운 코드 그대로 쳤습니다"다. 반대로 가장 좋은 답변은 "이 문제를 모델링하면 disjoint set이고, 다음 트레이드오프를 검토했습니다"다.
면접 시작 직전이나 문제를 받자마자 머릿속으로 돌리면 좋은 항목이다.
StringBuilder + BufferedReader를 쓸 것인가?union의 반환값(이미 같은 그룹이면 false)을 활용해야 하는 문제인가?이 체크리스트를 한 번 돌리는 데 1~2분이면 충분하고, 라이브 코딩 결과물의 안정성이 눈에 띄게 올라간다.