자료구조 - Graph(BFS/DFS)
Graph
Graph의 데이터들은 nodes / vertices 라고 부름.
node들이 연결된 관계 선을 edges라고 부름.
Directed / Undirected graphs로 구분됨.
Node들의 관계에 방향이 있느냐, 없느냐의 차이.
표현 방식은 Adjacency List, Adjacency Matrix, Incidence Matrix로 나뉨.
Adjacency Matrix는 각 행과 열이 모두 nodes를 표현함.
반면에, Incidence Matrix에서는 행이 nodes를 표현하고, 열이 edges를 표현함.
두 nodes에 연결된 edge는 그 두 node를 의미하는 행의 값이 1이 된다.
Edge의 갯수가 적은 Sparse matrix에서는 Adjacent List가 더 빠르고,
반대의 경우에는 Adjacent Matrix가 더 빠르다.
참고 : 꿈꾸는 KingPoDo 블로그
Directed graph의 incidence matrix에서는
특정 edge가 들어가는 node에는 -1, 나오는 node에는 1을 표시한다.
또, 각 edge가 weight를 가지는 graphs도 있다.
그러면 matrix에는 1과 0이 아니라, 더 큰 숫자들이 weight를 의미하며 들어가게 된다.
Graph의 구현
class Graph {
constructor() {
this.vertices = [];
this.edges = [];
this.numberOfEdges = 0;
}
addVertex(vertex) {
this.vertices.push(vertex);
this.edges[vertex] = [];
// 만약 edges[0]에 1, 3이 들어가있으면 0과 1, 0과 3을 연결하는 edge가 있는 것.
}
removeVertex(vertex) {
const index = this.vertices.indexOf(vertex);
if(index >= 0) {
// 일단 vertex 목록에서 지워준다.
this.vertices.splice(index, 1);
}
while(this.edges[vertex].length) {
// 지우려는 vertex와 연결된 edge들도 전부 지워줘야 함.
const adjacentVertex = this.edges[vertex].pop();
this.removeEdge(adjacentVertex, vertex);
}
}
addEdge(vertex1, vertex2) {
this.edges[vertex1].push(vertex2);
this.edges[vertex2].push(vertex1);
this.numberOfEdges++;
}
removeEdge(vertex1, vertex2) {
// 첫 번째 점의 edges 목록에서 두 번째 점을 지우기 위해 index를 찾아준다.
// 없으면 -1을 리턴한다.
const index1 = this.edges[vertex1] ? this.edges[vertex1].indexOf(vertex2) : -1;
const index2 = this.edges[vertex2] ? this.edges[vertex2].indexOf(vertex1) : -1;
if(index1 >= 0) {
this.edges[vertex1].splice(index1, 1);
this.numberOfEdges--;
// numberOfEdges는 한 번만 감소.
// 실제로 edge는 하나만 지워지는거니깐.
}
if(index2 >= 0) {
this.edges[vertex2].splice(index2, 1);
}
}
size() {
return this.vertices.length;
}
relations() {
return this.numberOfEdges;
}
print() {
console.log(this.vertices.map(vertex => {
return `${vertex} => ${this.edges[vertex].join(', ').trim()}`;
}, this).join('\n'));
}
}
Graph Traversal Algorithms
하나의 정점에서 시작해, 모든 정점들을 한 번씩 방문하는 알고리즘.
ex) 특정 도시에서 다른 도시로 갈 수 있는지, 없는지
ex) 전자 회로에서 특정 단자와 다른 단자가 서로 연결되어 있는지
Graph가 사용되는 가장 대표적인 예이기도 하다. 가장 자주 사용됨.
코딩 테스트에서도 탐색 알고리즘이 종종 출제됨.
탐색 알고리즘은 크게 깊이 우선(DFS), 너비 우선(BFS)으로 나뉨.
Depth First Search
루트 노드로부터 한 노드씩 넘어가면서 모든 노드를 탐색함.
한 노드에서 갈 수 있는 모든 방향을 고려한다.
- 미로를 탐색할 때 한 방향으로 갈 수 있을 때까지 계속 가다가,
더 이상 갈 수 없게 되면 다시 가장 가까운 갈림길로 돌아와서
거기서부터 다른 방향으로 다시 탐색을 진행하는 것과 유사함. - 즉, 이곳저곳 넓게 탐색하기 전에, 일단 들어갈 수 있는 만큼 최대한 깊게 들어간다.
그래서 이름이 깊이 우선 탐색임. - ‘모든 노드를 방문하고자 하는 경우’에 이 방법을 사용한다.
- BFS보다 간단한 알고리즘이지만, BFS보다 검색 속도는 느리다.
깊이 우선 탐색의 특징
- 재귀 호출을 사용하거나, 스택을 사용해서 구현할 수 있다.
- 어떤 노드를 방문했는지 여부를 반드시 검사해야한다. 안 그러면 무한 루프에 빠진다.
- stack 자료구조에다가 방문한 노드들을 저장해뒀다가, 다시 꺼내서 작업한다.
인접 행렬을 이용하고, 방문한 노드를 저장하기 위해 stack 자료구조를 사용한다.
참고 : 권희정님 블로그
블로그의 ‘깊이 우선 탐색(DFS)의 과정’같은 경우는 그림과 함께
한 눈에 이해가 되도록 잘 설명해 놓으셨으니, 까먹을 때마다 들어가서 보면 좋을 듯.
/* Depth First Search */
/* BFS와 흡사하지만, 큐 대신 스택을 사용한다. */
function dfs(matrix, root) {
let node;
const visited = new Array(matrix.length).fill(0);
// stack에다가 시작 노드를 추가해준다.
const stack = [root];
// 시작 노드의 visited value를 'visited'로 표시.
visited[root] = 1;
// stack이 비게 되면 멈춤.
while(stack.length > 0) {
// stack에서 맨 위에 있는 노드를 하나 pop
node = stack.pop();
console.log(node);
// pop한 노드에 인접한 모든 노드들을 검사한다.
for(let i = 0; i < visited.length; i++) {
// 인접한 모든 노드들 중, 연결되어 있는데 아직 방문하지 않은 노드가 있다면
// 방문 표시를 하고 stack에다가 집어넣는다.
// 이렇게 stack에다 쌓으면, 일단 한 쪽으로 끝까지 진행하게 되기때문에
// 가장 깊은 곳까지 먼저 들어갔다가, 다른 곳을 본다는 의미에서 깊이 우선 탐색.
if(matrix[node][i] == 1 && visited[i] == 0) {
// Visit node
visited[i] = 1;
// Push the element in the stack
stack.push(i);
}
}
}
}
const matrix = [
[0, 1, 1, 0],
[0, 0, 1, 0],
[1, 0, 0, 1],
[0, 0, 0, 1]
];
dfs(matrix, 0);
Breadth First Search
알고리즘은 하나의 노드로부터 시작한다.
그 노드로부터 하나의 edge만으로 연결된 이웃 노드들(neighbors)을 우선 모두 방문한다.
그러고 난 다음에는, 그 각각의 이웃 노드들의 이웃 노드를 또 방문한다.
- 간단하게 말하자면 시작 정점으로부터 가까운 정점을 먼저 방문하고, 멀리 떨어져 있는 정점을 나중에 방문하는 방법.
- 즉, DFS와 반대로 특정 경로로 깊게 파고 들어가는 것보다는 넓게 퍼져서 탐색을 하는 것에 초점.
- 포인트는, 루트 노드(시작 노드)로부터의 거리를 결정하여 최단 경로를 찾아내는 것.
ex) 지구 상에 존재하는 모든 친구 관계를 그래프로 표현한 후,
Ash와 Vanessa 사이에 존재하는 경로를 찾는 경우
- 깊이 우선 탐색의 경우, 모든 친구관계를 다 살펴봐야 할 수도 있다.
- 너비 우선 탐색의 경우, Ash와 가까운 관계부터 탐색한다.
너비 우선 탐색의 특징
- 재귀 호출을 사용하지 않는다.
- DFS와 마찬가지로, 어떤 노드를 방문하였는지를 모두 표시해놓아야 한다.
- BFS는 방문한 노드들을 차례로 저장한 후 꺼낼 수 있는 자료구조인 Queue를 사용한다.
다음의 Adjacency Matrix와 루트 노드의 index를 input으로 넣어,
각 노드들이 루트 노드로부터 얼마나 떨어져 있는지를 output으로 받을 것이다.
output은 각 노드의 index와 그들의 루트 노드로부터의 거리를 담고 있는 key-value pair이다.
function bfs(graph, root) {
const nodesDistances = {};
for(let i = 0; i < graph.length; i++) {
nodesDistances[i] = Infinity;
// 모든 거리를 infinity로 설정하고 시작.
// Infinity는 시작 노드에서 도달할 수 없음을 나타낸다.
}
nodesDistances[root] = 0;
// 시작 노드의 시작 노드로부터 거리는 0이다.
const queue = [root];
// 방문해야 할 노드들을 담을 큐.
let current;
// 현재 방문중인 노드를 담는 변수.
while(queue.length != 0) {
// 방문해야 할 노드들이 더 이상 없을 때까지 반복
current = queue.shift();
// 큐에서 방문할 노드를 꺼내면서 loop를 시작.
// 맨 처음에는 root 노드를 꺼냄.
const curConnected = graph[current];
// 현재 노드와 다른 모든 노드들의 관계를 가져온다.
// 그래프의 각 index에는 그 노드와 어떤 노드들이 연결되어있는지를 나타내는 배열이 들어가있다는 것을 기억하자.
const neighborIdx = [];
// 현재 노드와 연결되어있는 노드들을 담을 배열.
let idx = curConnected.indexOf(1);
// 현재 노드와 다른 노드들의 관계를 나타내는 배열에서 처음으로 나타나는 1을 찾는다.
// 해당 배열에서 1은 연결되어있다는 것을 의미함.
// 만약 1을 발견하지 못하면, indexOf 메소드는 -1을 반환한다.
while(idx != -1) {
neighborIdx.push(idx);
// idx가 -1이 아니라는 건 1을 발견했다는 뜻이므로, 그 index를 neighborIdx 배열에다 저장.
idx = curConnected.indexOf(1, idx + 1);
// 처음 발견된 1의 다음 index부터 또 1을 탐색.
// 연결된 또 다른 노드를 찾아간다는 뜻.
}
// 여기까지 하면, 현재 노드와 연결된 모든 노드들이 neighborIdx에 저장됨.
// 이제 그 노드들을 돌며 distance를 구할 차례.
for(let j = 0; j < neighborIdx.length; j++) {
if(nodesDistances[neighborIdx[j]] == Infinity) {
// Infinity면 아직 distance를 설정해주지 않았다는 뜻.
nodesDistances[neighborIdx[j]] = nodesDistances[current] + 1;
// 바로 이웃한 노드들이기 때문에 현재 노드의 distance에서 1을 더해주면 된다.
queue.push(neighborIdx[j]);
// 이제 그 이웃 노드들의 이웃들을 또 방문하기 위해, 이웃 노드들을 queue에 넣어준다.
}
}
}
return nodesDistances;
}
const graph = [
[0, 1, 1, 1, 0],
[0, 0, 1, 0, 0],
[1, 1, 0, 0, 0],
[0, 0, 0, 1, 0],
[0, 1, 0, 0, 0]
];
console.log(bfs(graph, 1));
BFS와 DFS를 활용한 문제 유형/응용
- 그래프의 모든 정점을 방문하는 것이 주요한 문제
이 경우, DFS와 BFS중 편한 것을 사용하면 됨. - 경로의 특징을 저장해둬야 하는 문제
예를 들어, 각 정점에 숫자가 적혀있고 a부터 b까지 가는 경로를 구해야 하는데, 경로 상에 같은 숫자가 존재하면 안 된다는 등의 문제. 각 경로의 특징을 저장할 필요가 있을 경우 DFS를 사용한다. (BFS는 경로의 특징을 저장할 수 없다.) - 최단거리를 구해야 하는 문제
미로 찾기 등 최단거리를 구해야 하는 경우, BFS가 유히하다. 왜냐면, 깊이 우선 탐색의 경우 처음으로 발견되는 해답이 최단거리가 아닐 수 있지만, 너비 우선 탐색으로 현재 노드에서 가까운 곳부터 찾으면 먼저 찾아지는 해답이 곧 최단거리. - 그 밖에도, 검색 대상 그래프가 너무 크면 DFS를 고려하고
규모가 크지 않거나 검색 시작 지점에서 타겟이 멀지 않다면 BFS를 고려.
출처 1 : Hays Standford 유튜브
출처 2 : Quinston Pimenta 유튜브
출처 3 : freeCodeCamp 유튜브
출처 4 : devuna 블로그
남의 코드를 보고 이해하는 건 충분히 가능하지만
막상 문제를 풀 때 이런 코드를 생각해낸다는 게 상상이 안 간다…
당분간은 코드를 보면서, 코드가 굴러가는 과정을 좀 눈에 익힌 후에
비슷한 유형의 풀이가 필요한 문제들이 나오면
그 때 한 번 짜보는걸로.