고급 알고리즘 완전정복 - 세그먼트 트리부터 위상정렬까지
기본적인 정렬과 탐색 알고리즘을 넘어서, 코딩 테스트와 실무에서 자주 사용되는 고급 알고리즘들을 다룬다. 각 알고리즘의 원리, 구현 방법, 시간 복잡도, 활용 사례를 실전 예제와 함께 설명한다.
1. 세그먼트 트리 (Segment Tree)
1-1. 개념과 활용
세그먼트 트리는 구간 쿼리와 업데이트를 O(log N) 시간에 처리할 수 있는 자료구조다.
활용 사례:
- 구간 합, 최솟값, 최댓값 쿼리
- 구간 업데이트
- 구간에 특정 값 더하기
1-2. 기본 구현 (구간 합)
class SegmentTree {
constructor(arr) {
this.n = arr.length;
this.size = 1;
while (this.size < this.n) this.size *= 2;
this.tree = new Array(this.size * 2).fill(0);
// 리프 노드에 초기값 저장
for (let i = 0; i < this.n; i++) {
this.tree[this.size + i] = arr[i];
}
// 내부 노드 계산
for (let i = this.size - 1; i > 0; i--) {
this.tree[i] = this.tree[i * 2] + this.tree[i * 2 + 1];
}
}
// 구간 [l, r)의 합 구하기
query(l, r) {
l += this.size;
r += this.size;
let sum = 0;
while (l < r) {
if (l % 2 === 1) {
sum += this.tree[l];
l++;
}
if (r % 2 === 1) {
r--;
sum += this.tree[r];
}
l = Math.floor(l / 2);
r = Math.floor(r / 2);
}
return sum;
}
// 인덱스 pos의 값을 val로 업데이트
update(pos, val) {
pos += this.size;
this.tree[pos] = val;
while (pos > 1) {
pos = Math.floor(pos / 2);
this.tree[pos] = this.tree[pos * 2] + this.tree[pos * 2 + 1];
}
}
// 구간 [l, r)에 val 더하기 (Lazy Propagation)
rangeUpdate(l, r, val) {
this._lazyUpdate(l, r, val, 0, this.size, 1);
}
_lazyUpdate(l, r, val, segL, segR, node) {
// Lazy Propagation 구현
// 실제 구현은 복잡하므로 생략
}
}
// 사용 예시
const arr = [1, 3, 5, 7, 9, 11];
const segTree = new SegmentTree(arr);
console.log(segTree.query(1, 4)); // 3 + 5 + 7 = 15
segTree.update(2, 10);
console.log(segTree.query(1, 4)); // 3 + 10 + 7 = 201-3. 구간 최솟값 쿼리
class MinSegmentTree {
constructor(arr) {
this.n = arr.length;
this.size = 1;
while (this.size < this.n) this.size *= 2;
this.tree = new Array(this.size * 2).fill(Infinity);
for (let i = 0; i < this.n; i++) {
this.tree[this.size + i] = arr[i];
}
for (let i = this.size - 1; i > 0; i--) {
this.tree[i] = Math.min(this.tree[i * 2], this.tree[i * 2 + 1]);
}
}
query(l, r) {
l += this.size;
r += this.size;
let minVal = Infinity;
while (l < r) {
if (l % 2 === 1) {
minVal = Math.min(minVal, this.tree[l]);
l++;
}
if (r % 2 === 1) {
r--;
minVal = Math.min(minVal, this.tree[r]);
}
l = Math.floor(l / 2);
r = Math.floor(r / 2);
}
return minVal;
}
update(pos, val) {
pos += this.size;
this.tree[pos] = val;
while (pos > 1) {
pos = Math.floor(pos / 2);
this.tree[pos] = Math.min(this.tree[pos * 2], this.tree[pos * 2 + 1]);
}
}
}2. 펜윅 트리 (Fenwick Tree / Binary Indexed Tree)
2-1. 개념
펜윅 트리는 세그먼트 트리보다 메모리를 적게 사용하며, 구현이 간단하다. 주로 구간 합 쿼리에 사용된다.
2-2. 구현
class FenwickTree {
constructor(size) {
this.n = size;
this.tree = new Array(this.n + 1).fill(0);
}
// 인덱스 i에 val 더하기
update(i, val) {
i++;
while (i <= this.n) {
this.tree[i] += val;
i += i & -i; // 마지막 비트 더하기
}
}
// [0, i] 구간의 합
query(i) {
i++;
let sum = 0;
while (i > 0) {
sum += this.tree[i];
i -= i & -i; // 마지막 비트 제거
}
return sum;
}
// [l, r] 구간의 합
rangeQuery(l, r) {
return this.query(r) - this.query(l - 1);
}
}
// 사용 예시
const arr = [1, 3, 5, 7, 9, 11];
const fenwick = new FenwickTree(arr.length);
// 초기화
for (let i = 0; i < arr.length; i++) {
fenwick.update(i, arr[i]);
}
console.log(fenwick.rangeQuery(1, 3)); // 3 + 5 + 7 = 15
fenwick.update(2, 5); // 인덱스 2에 5 더하기
console.log(fenwick.rangeQuery(1, 3)); // 3 + 10 + 7 = 203. 유니온 파인드 (Union-Find / Disjoint Set)
3-1. 개념
유니온 파인드는 두 원소가 같은 집합에 속하는지 확인하고, 두 집합을 합치는 연산을 효율적으로 수행한다.
3-2. 기본 구현
class UnionFind {
constructor(n) {
this.parent = new Array(n);
this.rank = new Array(n).fill(0);
for (let i = 0; i < n; i++) {
this.parent[i] = i;
}
}
// 경로 압축을 사용한 Find
find(x) {
if (this.parent[x] !== x) {
this.parent[x] = this.find(this.parent[x]); // 경로 압축
}
return this.parent[x];
}
// Union by Rank
union(x, y) {
const rootX = this.find(x);
const rootY = this.find(y);
if (rootX === rootY) return false; // 이미 같은 집합
// Rank가 작은 것을 큰 것에 붙이기
if (this.rank[rootX] < this.rank[rootY]) {
this.parent[rootX] = rootY;
} else if (this.rank[rootX] > this.rank[rootY]) {
this.parent[rootY] = rootX;
} else {
this.parent[rootY] = rootX;
this.rank[rootX]++;
}
return true;
}
// 같은 집합에 속하는지 확인
connected(x, y) {
return this.find(x) === this.find(y);
}
}
// 사용 예시
const uf = new UnionFind(5);
uf.union(0, 1);
uf.union(2, 3);
console.log(uf.connected(0, 1)); // true
console.log(uf.connected(0, 2)); // false
uf.union(1, 2);
console.log(uf.connected(0, 3)); // true3-3. 실전 문제: 섬의 개수
function numIslands(grid) {
const m = grid.length;
const n = grid[0].length;
const uf = new UnionFind(m * n);
const directions = [[0, 1], [1, 0], [0, -1], [-1, 0]];
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {
if (grid[i][j] === '1') {
for (const [dx, dy] of directions) {
const ni = i + dx;
const nj = j + dy;
if (ni >= 0 && ni < m && nj >= 0 && nj < n && grid[ni][nj] === '1') {
uf.union(i * n + j, ni * n + nj);
}
}
}
}
}
const islands = new Set();
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {
if (grid[i][j] === '1') {
islands.add(uf.find(i * n + j));
}
}
}
return islands.size;
}4. 최소 공통 조상 (LCA - Lowest Common Ancestor)
4-1. 개념
트리에서 두 노드의 공통 조상 중 가장 가까운 조상을 찾는 문제다.
4-2. 이진 리프팅을 사용한 구현
class LCA {
constructor(n, root, edges) {
this.n = n;
this.log = Math.ceil(Math.log2(n)) + 1;
this.depth = new Array(n).fill(0);
this.parent = Array(n).fill(null).map(() => new Array(this.log).fill(-1));
this.adj = Array(n).fill(null).map(() => []);
// 인접 리스트 구성
for (const [u, v] of edges) {
this.adj[u].push(v);
this.adj[v].push(u);
}
// DFS로 depth와 parent[0] 계산
this.dfs(root, -1, 0);
// 이진 리프팅 테이블 구성
for (let j = 1; j < this.log; j++) {
for (let i = 0; i < n; i++) {
if (this.parent[i][j - 1] !== -1) {
this.parent[i][j] = this.parent[this.parent[i][j - 1]][j - 1];
}
}
}
}
dfs(u, p, d) {
this.depth[u] = d;
this.parent[u][0] = p;
for (const v of this.adj[u]) {
if (v !== p) {
this.dfs(v, u, d + 1);
}
}
}
lca(u, v) {
// u가 더 깊게
if (this.depth[u] < this.depth[v]) {
[u, v] = [v, u];
}
// 같은 depth로 맞추기
for (let i = this.log - 1; i >= 0; i--) {
if (this.depth[u] - (1 << i) >= this.depth[v]) {
u = this.parent[u][i];
}
}
if (u === v) return u;
// LCA 찾기
for (let i = this.log - 1; i >= 0; i--) {
if (this.parent[u][i] !== this.parent[v][i]) {
u = this.parent[u][i];
v = this.parent[v][i];
}
}
return this.parent[u][0];
}
}
// 사용 예시
const n = 7;
const edges = [
[0, 1], [0, 2],
[1, 3], [1, 4],
[2, 5], [2, 6]
];
const lca = new LCA(n, 0, edges);
console.log(lca.lca(3, 4)); // 1
console.log(lca.lca(3, 6)); // 05. 강한 연결 요소 (SCC - Strongly Connected Components)
5-1. 개념
유향 그래프에서 서로 도달 가능한 노드들의 집합을 강한 연결 요소라고 한다.
5-2. Kosaraju 알고리즘
class SCC {
constructor(n, edges) {
this.n = n;
this.adj = Array(n).fill(null).map(() => []);
this.revAdj = Array(n).fill(null).map(() => []);
for (const [u, v] of edges) {
this.adj[u].push(v);
this.revAdj[v].push(u);
}
}
kosaraju() {
const visited = new Array(this.n).fill(false);
const order = [];
// 1. 첫 번째 DFS (순서 결정)
for (let i = 0; i < this.n; i++) {
if (!visited[i]) {
this.dfs1(i, visited, order);
}
}
// 2. 역방향 그래프에서 DFS
visited.fill(false);
const sccs = [];
for (let i = order.length - 1; i >= 0; i--) {
const node = order[i];
if (!visited[node]) {
const component = [];
this.dfs2(node, visited, component);
sccs.push(component);
}
}
return sccs;
}
dfs1(u, visited, order) {
visited[u] = true;
for (const v of this.adj[u]) {
if (!visited[v]) {
this.dfs1(v, visited, order);
}
}
order.push(u);
}
dfs2(u, visited, component) {
visited[u] = true;
component.push(u);
for (const v of this.revAdj[u]) {
if (!visited[v]) {
this.dfs2(v, visited, component);
}
}
}
}
// 사용 예시
const n = 5;
const edges = [
[0, 1], [1, 2], [2, 0],
[1, 3], [3, 4]
];
const scc = new SCC(n, edges);
console.log(scc.kosaraju()); // [[0, 2, 1], [3], [4]]6. 위상 정렬 (Topological Sort)
6-1. 개념
유향 비순환 그래프(DAG)에서 노드들을 선후 관계에 따라 정렬하는 알고리즘이다.
6-2. Kahn 알고리즘 (위상 정렬)
function topologicalSort(n, edges) {
const adj = Array(n).fill(null).map(() => []);
const inDegree = new Array(n).fill(0);
// 그래프 구성 및 진입 차수 계산
for (const [u, v] of edges) {
adj[u].push(v);
inDegree[v]++;
}
// 진입 차수가 0인 노드들을 큐에 추가
const queue = [];
for (let i = 0; i < n; i++) {
if (inDegree[i] === 0) {
queue.push(i);
}
}
const result = [];
while (queue.length > 0) {
const u = queue.shift();
result.push(u);
// 인접 노드들의 진입 차수 감소
for (const v of adj[u]) {
inDegree[v]--;
if (inDegree[v] === 0) {
queue.push(v);
}
}
}
// 사이클이 있으면 빈 배열 반환
if (result.length !== n) {
return []; // 사이클 존재
}
return result;
}
// 사용 예시
const n = 6;
const edges = [
[5, 2], [5, 0],
[4, 0], [4, 1],
[2, 3], [3, 1]
];
console.log(topologicalSort(n, edges)); // [4, 5, 0, 2, 3, 1]6-3. DFS를 사용한 위상 정렬
function topologicalSortDFS(n, edges) {
const adj = Array(n).fill(null).map(() => []);
const visited = new Array(n).fill(false);
const result = [];
for (const [u, v] of edges) {
adj[u].push(v);
}
function dfs(u) {
visited[u] = true;
for (const v of adj[u]) {
if (!visited[v]) {
dfs(v);
}
}
result.push(u); // 역순으로 추가
}
for (let i = 0; i < n; i++) {
if (!visited[i]) {
dfs(i);
}
}
return result.reverse();
}7. 최소 신장 트리 (MST - Minimum Spanning Tree)
7-1. Kruskal 알고리즘
class KruskalMST {
constructor(n, edges) {
this.n = n;
this.edges = edges.sort((a, b) => a[2] - b[2]); // 가중치 오름차순
this.uf = new UnionFind(n);
}
findMST() {
const mst = [];
let totalWeight = 0;
for (const [u, v, weight] of this.edges) {
if (this.uf.union(u, v)) {
mst.push([u, v, weight]);
totalWeight += weight;
}
}
return { mst, totalWeight };
}
}
// 사용 예시
const n = 4;
const edges = [
[0, 1, 10],
[0, 2, 6],
[0, 3, 5],
[1, 3, 15],
[2, 3, 4]
];
const kruskal = new KruskalMST(n, edges);
const { mst, totalWeight } = kruskal.findMST();
console.log("MST:", mst); // [[2, 3, 4], [0, 3, 5], [0, 1, 10]]
console.log("Total Weight:", totalWeight); // 197-2. Prim 알고리즘
class PrimMST {
constructor(n, edges) {
this.n = n;
this.adj = Array(n).fill(null).map(() => []);
for (const [u, v, weight] of edges) {
this.adj[u].push([v, weight]);
this.adj[v].push([u, weight]);
}
}
findMST(start = 0) {
const visited = new Array(this.n).fill(false);
const minHeap = [[0, start, -1]]; // [weight, node, parent]
const mst = [];
let totalWeight = 0;
while (minHeap.length > 0) {
minHeap.sort((a, b) => a[0] - b[0]);
const [weight, u, parent] = minHeap.shift();
if (visited[u]) continue;
visited[u] = true;
if (parent !== -1) {
mst.push([parent, u, weight]);
totalWeight += weight;
}
for (const [v, w] of this.adj[u]) {
if (!visited[v]) {
minHeap.push([w, v, u]);
}
}
}
return { mst, totalWeight };
}
}
// 사용 예시
const prim = new PrimMST(n, edges);
const { mst: primMST, totalWeight: primWeight } = prim.findMST();
console.log("Prim MST:", primMST);
console.log("Total Weight:", primWeight);8. 최대 유량 (Max Flow)
8-1. Ford-Fulkerson 알고리즘 (DFS)
class MaxFlow {
constructor(n, source, sink) {
this.n = n;
this.source = source;
this.sink = sink;
this.capacity = Array(n).fill(null).map(() => new Array(n).fill(0));
this.adj = Array(n).fill(null).map(() => []);
}
addEdge(u, v, cap) {
this.capacity[u][v] = cap;
this.adj[u].push(v);
this.adj[v].push(u); // 역방향 간선
}
fordFulkerson() {
let maxFlow = 0;
const parent = new Array(this.n).fill(-1);
while (this.bfs(parent)) {
let pathFlow = Infinity;
// 경로의 최소 용량 찾기
for (let v = this.sink; v !== this.source; v = parent[v]) {
const u = parent[v];
pathFlow = Math.min(pathFlow, this.capacity[u][v]);
}
// 유량 업데이트
for (let v = this.sink; v !== this.source; v = parent[v]) {
const u = parent[v];
this.capacity[u][v] -= pathFlow;
this.capacity[v][u] += pathFlow; // 역방향 간선
}
maxFlow += pathFlow;
}
return maxFlow;
}
bfs(parent) {
const visited = new Array(this.n).fill(false);
const queue = [this.source];
visited[this.source] = true;
while (queue.length > 0) {
const u = queue.shift();
for (const v of this.adj[u]) {
if (!visited[v] && this.capacity[u][v] > 0) {
visited[v] = true;
parent[v] = u;
queue.push(v);
if (v === this.sink) {
return true;
}
}
}
}
return false;
}
}
// 사용 예시
const n = 6;
const maxFlow = new MaxFlow(n, 0, 5);
maxFlow.addEdge(0, 1, 16);
maxFlow.addEdge(0, 2, 13);
maxFlow.addEdge(1, 2, 10);
maxFlow.addEdge(1, 3, 12);
maxFlow.addEdge(2, 1, 4);
maxFlow.addEdge(2, 4, 14);
maxFlow.addEdge(3, 2, 9);
maxFlow.addEdge(3, 5, 20);
maxFlow.addEdge(4, 3, 7);
maxFlow.addEdge(4, 5, 4);
console.log("Max Flow:", maxFlow.fordFulkerson()); // 239. 실전 문제 풀이
9-1. 구간 최솟값 쿼리 (세그먼트 트리)
// 문제: 배열에서 구간 [l, r]의 최솟값을 여러 번 구하기
function solveRangeMinQuery(arr, queries) {
const segTree = new MinSegmentTree(arr);
const results = [];
for (const [l, r] of queries) {
results.push(segTree.query(l, r + 1));
}
return results;
}
const arr = [1, 3, 2, 7, 9, 11, 5, 8];
const queries = [[0, 3], [2, 5], [1, 4]];
console.log(solveRangeMinQuery(arr, queries)); // [1, 2, 2]9-2. 친구 네트워크 (유니온 파인드)
// 문제: 친구 관계를 입력받아 네트워크 크기 구하기
function friendNetwork(n, friendships) {
const uf = new UnionFind(n);
const size = new Array(n).fill(1);
for (const [a, b] of friendships) {
const rootA = uf.find(a);
const rootB = uf.find(b);
if (rootA !== rootB) {
uf.union(a, b);
const newRoot = uf.find(a);
size[newRoot] = size[rootA] + size[rootB];
}
console.log(`Network size: ${size[uf.find(a)]}`);
}
}9-3. 코스 스케줄 (위상 정렬)
// 문제: 선수 과목 관계가 주어질 때, 모든 과목을 수강할 수 있는지 확인
function canFinish(numCourses, prerequisites) {
const adj = Array(numCourses).fill(null).map(() => []);
const inDegree = new Array(numCourses).fill(0);
for (const [course, prereq] of prerequisites) {
adj[prereq].push(course);
inDegree[course]++;
}
const queue = [];
for (let i = 0; i < numCourses; i++) {
if (inDegree[i] === 0) {
queue.push(i);
}
}
let count = 0;
while (queue.length > 0) {
const u = queue.shift();
count++;
for (const v of adj[u]) {
inDegree[v]--;
if (inDegree[v] === 0) {
queue.push(v);
}
}
}
return count === numCourses;
}
console.log(canFinish(2, [[1, 0]])); // true
console.log(canFinish(2, [[1, 0], [0, 1]])); // false (사이클)10. 시간 복잡도 비교
| 알고리즘 | 시간 복잡도 | 공간 복잡도 |
|---|---|---|
| 세그먼트 트리 (구간 쿼리) | O(log N) | O(N) |
| 펜윅 트리 (구간 합) | O(log N) | O(N) |
| 유니온 파인드 | O(α(N)) (거의 상수) | O(N) |
| LCA (이진 리프팅) | O(log N) | O(N log N) |
| SCC (Kosaraju) | O(V + E) | O(V + E) |
| 위상 정렬 | O(V + E) | O(V + E) |
| MST (Kruskal) | O(E log E) | O(V) |
| MST (Prim) | O(E log V) | O(V + E) |
| 최대 유량 (Ford-Fulkerson) | O(E × max_flow) | O(V + E) |
11. 결론
고급 알고리즘은 복잡한 문제를 효율적으로 해결하는 핵심 도구다. 이 글에서 다룬 내용:
- 세그먼트 트리: 구간 쿼리와 업데이트를 빠르게 처리
- 펜윅 트리: 메모리 효율적인 구간 합 쿼리
- 유니온 파인드: 집합 연산을 효율적으로 처리
- LCA: 트리에서 공통 조상 찾기
- SCC: 유향 그래프의 강한 연결 요소
- 위상 정렬: DAG의 선후 관계 정렬
- MST: 최소 신장 트리 구하기
- 최대 유량: 네트워크 유량 최대화
이러한 알고리즘들을 숙지하면 코딩 테스트와 실무 문제를 더 효율적으로 해결할 수 있다.