백준 1976 - 여행 가자 (유니온 파인드 알고리즘을 함께 알아보자)

 

문제

동혁이는 친구들과 함께 여행을 가려고 한다. 한국에는 도시가 N개 있고 임의의 두 도시 사이에 길이 있을 수도, 없을 수도 있다. 동혁이의 여행 일정이 주어졌을 때, 이 여행 경로가 가능한 것인지 알아보자. 물론 중간에 다른 도시를 경유해서 여행을 할 수도 있다. 예를 들어 도시가 5개 있고, A-B, B-C, A-D, B-D, E-A의 길이 있고, 동혁이의 여행 계획이 E C B C D 라면 E-A-B-C-B-C-B-D라는 여행경로를 통해 목적을 달성할 수 있다.

도시들의 개수와 도시들 간의 연결 여부가 주어져 있고, 동혁이의 여행 계획에 속한 도시들이 순서대로 주어졌을 때 가능한지 여부를 판별하는 프로그램을 작성하시오. 같은 도시를 여러 번 방문하는 것도 가능하다.


입력

첫 줄에 도시의 수 N이 주어진다. N은 200이하이다. 둘째 줄에 여행 계획에 속한 도시들의 수 M이 주어진다. M은 1000이하이다. 다음 N개의 줄에는 N개의 정수가 주어진다. i번째 줄의 j번째 수는 i번 도시와 j번 도시의 연결 정보를 의미한다. 1이면 연결된 것이고 0이면 연결이 되지 않은 것이다. A와 B가 연결되었으면 B와 A도 연결되어 있다. 마지막 줄에는 여행 계획이 주어진다. 도시의 번호는 1부터 N까지 차례대로 매겨져 있다.


출력

첫 줄에 가능하면 YES 불가능하면 NO를 출력한다.


예제 입력

3
3
0 1 0
1 0 1
0 1 0
1 2 3

예제 출력

YES

 


제가 구현한 코드 - ❎ 실패

저는 이 문제에 처음에 접근했을 때 그래프 탐색 알고리즘(BFS, DFS)을 기반으로 순회하는 문제라고 생각했습니다. 

때문에 특정 노드에서 갈 수 있는 모든 경우의 수를 담은 그래프를 DFS를 통해서 구현을 했었습니다만 시간 초과 문제가 발생했습니다. 

실제 제가 구현한 코드는 다음과 같습니다.

 

const fs = require("fs");

const input = fs.readFileSync("/dev/stdin").toString().trim().split("\n");

const N = Number(input[0]); // 200 이하
const M = Number(input[1]); // 1000 이하

const matrix = input.slice(2, N+2).map(r => r.split(" ").map(Number));
const travelRoute = input.at(-1).split(" ").map(Number);

const canGo = Array.from(Array(N), () => ({}));
const visited= Array.from(Array(N), () => Array(N).fill(false));

// 윗 삼각형만으로 판단 가능
for(let i=0; i<N; i++) { // i가 갈 수 있는 곳
  for (let j=i+1 ; j<N; j++){
    if(matrix[i][j] === 1 && visited[i][j] === false) {
      dfs(i, j, i)
    }
  }
}

// 갈 수 있는 리스트를 만듦
function dfs(i, j, l){
  if(matrix[i][j] === 1 && visited[l][j] === false) {
    visited[l][j] = true;
    visited[j][l] = true;

    canGo[l][j] = true;
    canGo[j][l] = true;
    for(let k = j+1; j<N; j++) {
      dfs(j, k, l);
    }
  }
}
let hasRoute = true;

for (let i = 0; i<M-1; i++) {
  // -1은 인덱스 처리 때문에
  const start = travelRoute[i] - 1;
  const dest = travelRoute[i+1] - 1;


  if(!canGo[start][dest]) {
    hasRoute = false;
    break;
  }
}

console.log(hasRoute ? "YES" : "NO")

 

이렇게 실패를 하자 어떻게 문제를 풀어야하지...? 라는 생각이 들게 되었고, 찾아보니 유니온 파인드 알고리즘을 쓰는게 좋다라는 것을 알게 되었습니다. 그런데 유니온 파인드 알고리즘이 뭐지..?

 


유니온 파인드란?

유니온 파인드(Union-Find) 알고리즘은 주로 그래프에서 서로소 집합(Disjoint Set)을 관리하기 위해 사용됩니다. 이 알고리즘은 집합의 합집합(union)과 원소가 속한 집합을 찾는(find) 연산을 효율적으로 수행할 수 있도록 설계되었습니다. 

집합을 관리하는 자료구조인 유니온-파인드를 활용해 합집합을 찾는 알고리즘이며, 유니온-파인드란 루트 노드 밑에 자식 노드들이 엮인 트리구조를 따른다고 합니다. 여러 개의 노드 중 선택된 두 노드가 같은 그래프에 속해있는지 판별하는 알고리즘입니다.

즉, 복잡한 그래프에서 두 노드가 연결되어 있는지, 단절되어 있는지 판별하기 위한 알고리즘이라고 보면 됩니다.

 

유니온 파인드의 두 주요 연산

  1. Initialization(초기화): 각 노드가 각각의 집합에 포함되도록 초기화하는 과정입니다.
  2. Find(찾기 연산): 어떤 원소가 속한 집합을 찾는 연산입니다. 이 연산은 주어진 원소가 어떤 루트에 속해 있는지를 반환합니다.
  3. Union(합치기 연산): 두 집합을 하나의 집합으로 합치는 연산입니다. 이 연산은 두 집합을 하나로 합쳐줍니다.

알고리즘의 기본 구조

유니온 파인드 알고리즘은 주로 두 가지 기술을 사용하여 성능을 최적화합니다.

  1. 경로 압축(Path Compression): Find 연산을 수행할 때, 경로 상에 있는 모든 노드를 직접 루트에 연결하여 트리의 깊이를 줄이는 기술입니다.
  2. 랭크 또는 사이즈 기반 합치기(Union by Rank or Size): Union 연산을 수행할 때, 항상 깊이가 작은 트리를 깊이가 큰 트리 아래에 붙여 트리의 깊이가 너무 깊어지지 않도록 하는 기술입니다.

유니온 파인드 구현

1. 초기화 과정: N개의 노드가 자기 자신에게 속해있는 과정

2. Find 과정: 특정 노드의 부모 (해당 노드가 속한 집합의 루트)를 찾는(반환하는) 과정

3. Union 과정: 두 노드가 포함된 집합을 합치는 과정 (두 집합을 합칠 때, 특정 기준에 따라 합치면 됩니다)

 

초기화 과정

자기 자신만의 집합을 원소로 가질 수 있도록 모든 값이 자기 자신을 가리키도록 배열을 만들면 됩니다.

const init = (N) => {
  return Array.from({length:4}, (_, idx) => idx);
}

 

초기화를 완료했으면 다음과 같은 배열이 생성됩니다.

 

 

Find 과정

 

위 노드 중 1, 2번 노드와 4, 5, 6번 노드를 합쳐보자.

1, 2번 노드를 합치는 방법은 2번 노드의 값을 1번 노드의 값으로 바꿔주면 됩니다.

4, 5, 6번 노드를 합치는 방법은 6번 노드의 값을 5번 노드의 값으로 변경해주고, 5번 노드의 값을 4번 노드의 값으로 변경해주면 됩니다.

 

그럼 본인의 루트노드를 어떻게 찾아갈까?
1. N 번째 배열의 값을 확인한다.
2. 해당 값을 M이라고 했을 때, M 번째 배열의 값을 확인한다.
3. M 번째 배열의 값과 M이 일치한다면 해당 노드가 N의 루트노드가 되는 것이다.

 

이를 코드로 구현하면 다음과 같다.

const find = (x) => {
	// 배열의 인덱스와 배열의 값이 일치한다면 루트노드의 인덱스 값을 리턴한다.
	if (parent[x] === x) {
		return x;
	} else {
    // 일치하지 않는다면 재귀함수를 통해 1 depth 더 들어가도록 한다.
		return find(parent[x]);
	}
};

 

위의 방식대로 구현하면 편향 트리가 만들어진다. 4, 5, 6의 예시가 그것을 증명합니다.

 

이를 보완하기 위해 경로 압축을 실시합니다. 경로 압축은 경로 상에 있는 모든 노드를 직접 루트에 연결하여 트리의 깊이를 줄이는 기술입니다. 

const find = (x) => {
	// 배열의 값과 인덱스가 같다면 부모 노드의 인덱스를 반환한다.
	if (parent[x] === x) {
		return x;
	}
	// 경로 압축을 위해 부모노드의 값으로 바꿔준다.
	const currentParent = find(parent[x]);
	parent[x] = currentParent;

  return currentParent;
};

 

경로 압축이 완료되면 다음과 같이 depth가 줄어들 것입니다.

 

Union 과정

두 노드가 포함된 집합을 합치는 과정입니다.

부모 노드로 올라갈 수록 번호가 작다는 규칙을 정하여 두 집합을 합칠 때 번호가 작은 노드 아래에 번호가 큰 노드로 들어갈 수 있도록 합니다.

// 두 노드를 합치기 위한 함수
 const union = (x, y) => {
	x = find(x);
	y = find(y);
    
    // x와 y가 같다면 이미 연결되어 있는 경우
	if( x === y ) return;
    
    // x와 y 둘 중 큰 값을 가지는 부모 인덱스를 작은 값을 가지는 변수로 넣는다.
	if (x < y) parent[y] = x;
	else parent[x] = y;
};

 

 

모든 코드

// 초기화
const init = (N) => { 
    return Array(N).fill(0).map((value, idx) => idx);
};

// x의 루트노드 찾는 함수
const find = (x) => {
	// 배열의 값과 인덱스가 같다면 부모 노드의 인덱스를 반환한다.
	if (parent[x] === x) {
		return x;
	}
	// 경로 압축을 위해 부모노드의 값으로 바꿔준다.
	const currentParent = find(parent[x]);
	parent[x] = currentParent;

  return currentParent;
};

// 두 노드를 합치기 위한 함수
 const union = (x, y) => {
	x = find(x);
	y = find(y);
    
    // x와 y가 같다면 이미 연결되어 있는 경우
	if( x === y ) return;
    
    // x와 y 둘 중 큰 값을 가지는 부모 인덱스를 작은 값을 가지는 변수로 넣는다.
	if (x < y) parent[y] = x;
	else parent[x] = y;
};

// 같은 부모를 가지는지 확인하는 함수
const isUnion = (x,y) => {
	x = find(x);
    y = find(y);
    if(x === y) { return true; }
	return false;
}

 

 

유니온 파인드 알고리즘으로 문제 해결하기

const fs = require("fs");
const input = fs.readFileSync("/dev/stdin").toString().trim().split("\n");

const N = Number(input[0]); // 200 이하
const M = Number(input[1]); // 1000 이하

const matrix = input.slice(2, N + 2).map(r => r.split(" ").map(Number));
const travelRoute = input.at(-1).split(" ").map(el => Number(el) - 1);
const indexArr = new Array(N).fill(0);

for (let i=1; i<N;i++) indexArr[i] = i;
// 유니온 파인드 배열 만들기
// const indexArr = Array.from({length: N}, (_, idx) => idx + 1);

// 두 도시가 연결된 경우에만 union
for(let i = 0; i< N; i++) {
  for(let j= i+1; j<N; j++) {
    if(matrix[i][j] === 1) union(i, j);
  }
}

// 여행 경로의 도시들이 동일한 root를 가지는지 확인
let canGo = true;
for (let i=1; i<M; i++) {
  const start = travelRoute[i-1]
  const dest = travelRoute[i];
  if(!isSameGroup(start, dest)) {
    canGo = false;
    break;
  }
}

console.log(canGo ? "YES" : "NO");

function find(x) {
  if (indexArr[x] === x) return x;

  // 경로 압축을 위해 부모 노드의 값으로 바꿔줌
  const currentParent = find(indexArr[x]);
  indexArr[x] = currentParent;
  return currentParent;
}

function union(x, y) {
  const xParent = find(x);
  const yParent = find(y);

  if(xParent < yParent) {
    indexArr[yParent] = xParent;
  } else {
    indexArr[xParent] = yParent;
  }
}

function isSameGroup(x, y){
  const xParent = find(x);
  const yParent = find(y);

  if(xParent===yParent) return true;
  else return false;
}

 


DFS로도 결국 문제를 해결하긴 했다...!

 

const fs = require('fs');
// const input = fs.readFileSync('/dev/stdin').toString().trim().split('\n');
const input = `3
3
0 1 0
1 0 1
0 1 0
1 3 2`.toString().trim().split("\n");
const N = parseInt(input[0]); // 도시의 수
const M = parseInt(input[1]); // 여행 계획에 속한 도시들의 수
const matrix = input.slice(2, N + 2).map(line => line.split(' ').map(Number));
const path = input[N + 2].split(' ').map(Number);

const isVisited = new Array(N + 1).fill(false);
const v = Array.from({ length: N + 1 }, () => []);

for (let i = 1; i <= N; i++) {
  for (let j = 1; j <= N; j++) {
    if (matrix[i - 1][j - 1] === 1) {
      v[i].push(j);
      v[j].push(i);
    }
  }
}

const queue = [];
queue.push(path[0]);

while (queue.length > 0) {
  const now = queue.shift();

  if (isVisited[now]) continue;
  isVisited[now] = true;

  for (const neighbor of v[now]) {
    if (!isVisited[neighbor]) {
      queue.push(neighbor);
    }
  }
}

let isTrue = true;
for (let i = 1; i < M; i++) {
  if (!isVisited[path[i]]) {
    isTrue = false;
    break;
  }
}

console.log(isTrue ? "YES" : "NO");

 

출처

https://velog.io/@qltkd5959/JS-%EC%9C%A0%EB%8B%88%EC%98%A8-%ED%8C%8C%EC%9D%B8%EB%93%9CUnion-find#-%EA%B2%BD%EB%A1%9C-%EC%95%95%EC%B6%95path-compression%EC%9D%B4%EB%9E%80-

 

[JS] 유니온 파인드(Union-find)

그래프 알고리즘으로서 두 노드가 같은 그래프에 속하는지 판단하는 알고리즘이다.각 집합이 서로 공통 원소를 가지지 않는 서로소 집합 혹은 상호 베타적 집합이 구해진다.합집합 찾기라는 의

velog.io

https://velog.io/@mk0504/%EB%B0%B1%EC%A4%80-1976%EB%B2%88-%EC%97%AC%ED%96%89-%EA%B0%80%EC%9E%90-JavaScript

 

[백준] 1976번, 여행 가자 (JavaScript)

문제 분류 : 자료 구조, 그래프 이론, 그래프 탐색, 분리 집합문제 출처 : 백준 골드 4, 여행가자문제는 도시와 이 도시들 간의 연결 정보가 주어졌을 때, 마지막에 주어진 여행 경로에 있는 도시

velog.io

https://lhoiktiv.tistory.com/1104

 

Node.js) 백준 1976번: 여행 가자

https://www.acmicpc.net/problem/1976 class Edge { constructor() { this.src = 0; this.dest = 0; } } class SubSet { constructor(i) { this.parent = i; this.rank = 0; } } class UF { constructor(N) { this.subsets = Array.from(Array(N), (_, i) => { return new Su

lhoiktiv.tistory.com

 

'CS지식 > 코딩테스트' 카테고리의 다른 글

백준 10026 - 적록색약  (0) 2024.07.17