React

10971 외판원 순회 2

문제

외판원 순회 문제는 영어로 Traveling Salesman problem (TSP) 라고 불리는 문제로 computer science 분야에서 가장 중요하게 취급되는 문제 중 하나이다. 여러 가지 변종 문제가 있으나, 여기서는 가장 일반적인 형태의 문제를 살펴보자.
1번부터 N번까지 번호가 매겨져 있는 도시들이 있고, 도시들 사이에는 길이 있다. (길이 없을 수도 있다) 이제 한 외판원이 어느 한 도시에서 출발해 N개의 도시를 모두 거쳐 다시 원래의 도시로 돌아오는 순회 여행 경로를 계획하려고 한다. 단, 한 번 갔던 도시로는 다시 갈 수 없다. (맨 마지막에 여행을 출발했던 도시로 돌아오는 것은 예외) 이런 여행 경로는 여러 가지가 있을 수 있는데, 가장 적은 비용을 들이는 여행 계획을 세우고자 한다.
각 도시간에 이동하는데 드는 비용은 행렬 W[i][j]형태로 주어진다. W[i][j]는 도시 i에서 도시 j로 가기 위한 비용을 나타낸다. 비용은 대칭적이지 않다. 즉, W[i][j] 는 W[j][i]와 다를 수 있다. 모든 도시간의 비용은 양의 정수이다. W[i][i]는 항상 0이다. 경우에 따라서 도시 i에서 도시 j로 갈 수 없는 경우도 있으며 이럴 경우 W[i][j]=0이라고 하자.
N과 비용 행렬이 주어졌을 때, 가장 적은 비용을 들이는 외판원의 순회 여행 경로를 구하는 프로그램을 작성하시오.

입력

첫째 줄에 도시의 수 N이 주어진다. (2 ≤ N ≤ 10) 다음 N개의 줄에는 비용 행렬이 주어진다. 각 행렬의 성분은 1,000,000 이하의 양의 정수이며, 갈 수 없는 경우는 0이 주어진다. W[i][j]는 도시 i에서 j로 가기 위한 비용을 나타낸다.
항상 순회할 수 있는 경우만 입력으로 주어진다.

출력

첫째 줄에 외판원의 순회에 필요한 최소 비용을 출력한다.

예제 입력 1

4 0 10 15 20 5 0 9 10 6 13 0 12 8 8 9 0
Plain Text
복사

예제 출력 1

35
Plain Text
복사

My solution

1번부터 N번까지 번호가 매겨져 있는 도시들이 있고, 도시들 사이에는 길이 있다. (길이 없을 수도 있다)
어느 한 도시에서 출발해 N개의 도시를 모두 거쳐 다시 원래의 도시로 돌아오는 순회 여행 경로를 계획
단, 한 번 갔던 도시로는 다시 갈 수 없다. (맨 마지막에 여행을 출발했던 도시로 돌아오는 것은 예외)
가장 적은 비용을 들이는 여행 계획을 세우고자 한다.
각 도시간에 이동하는데 드는 비용은 행렬 W[i][j]형태로 주어진다.
W[i][j] 는 W[j][i]와 다를 수 있다.
모든 도시간의 비용은 양의 정수이다. W[i][i]는 항상 0이다.
경우에 따라서 도시 i에서 도시 j로 갈 수 없는 경우도 있으며 이럴 경우 W[i][j]=0이라고 하자.
모든 순열을 만들어서 getSum 함수로 경로의 합을 구한 다음 min을 업데이트했다.
const getSum = arr => { let sum = 0; for (let i = 0; i < N - 1; i++) { // A[arr[i]][arr[i+1] 접근했을 때 0이 나오면 길이 없는 것이므로 경로에서 제외 if (A[arr[i]][arr[i + 1]] === 0) return Number.MAX_SAFE_INTEGER; sum += A[arr[i]][arr[i + 1]]; } sum += A[arr[N - 1]][arr[0]]; return sum; }; // pass TC solution(`4 0 10 15 20 5 0 9 10 6 13 0 12 8 8 9 0`) ).toStrictEqual(35); expect( solution(`4 0 1 0 0 0 0 1 0 0 0 0 1 1 0 0 0`) ).toStrictEqual(4); }); solution(`4 0 5 1 99 99 0 5 1 1 99 0 5 5 1 99 0`) ).toStrictEqual(20);
JavaScript
복사
제출시 40%에서 fail이 나왔다.
질문 게시판의 반례들은 모두 통과했기 때문에 어디가 문제인지 알 수 없어서 다른 사람의 풀이를 참고했다.
우선 순열로 해결하는 방식은 시간복잡도도 느리다고 한다. 그래서 백트래킹을 사용했다.
① 완전 탐색 + 백트래킹 사용법 DP를 이용하는 것과 큰 차이는 없다고 보는데, 기본적으로 모든 도시를 탐색을 하는 방법이다. 그러나 이미 방문한 경우와 갈 수 없는 경우 및 이전 방문으로 계산된 최소 비용을 넘어서는 경우를 제외하고 탐색한다. 즉, 백트래킹을 이용해 가지치기(Pruning)을 하여 필요한 경우에만 건너서 도시를 탐색하는 방법이다.
visit[0] = true; visitCnt++; backtracking(0,0,0);
JavaScript
복사
실행하기 전에 visit배열의 0번째를 방문한 것으로 두고 함수를 호출한다.
순회경로의 비용을 계산할 때 어디부터 방문해도 경로가 같다면 총 비용이 같기 때문에 고정된 하나의 도시에서 시작해서 시간복잡도를 낮추기 위해서다.
시간 복잡도를 줄일 수 있는 방법이 있는데 잘 생각해보면 1 -> 2 -> 3 -> 4 , 2 -> 3 -> 4 -> 1 , 3 -> 4 -> 1 -> 2 , 4 -> 1 -> 2 -> 3 이 네가지는 모든 같은 경우의 수다. 그래서 첫번째를 제외한 나머지는 의미가 없는 연산이 된다. 시작 위치를 고정 시켜도 상관이 없는데 , 1의 위치를 고정 시켜버리자 (순열의 갯수가 1줄어듬 , factorial 기준으로는 엄청나게 효과적인 방법)
// 0번째는 방문처리 후 고정해둠 - 방문처리 안해도 비용 계산에는 영향을 주지 않는다. visit[0] = true; backtracking(0, 0, 0, 1);
JavaScript
복사
그래서 함수 호출시 첫 정점으로 start에 0을 넣고(0을 첫 정점으로 잡은 경로만 계산할 것이다.)
current도 0에서 시작하므로 0을 준 다음 호출한다.
let min = Number.MAX_SAFE_INTEGER; const backtracking = (cost, start, current, depth) => { if (depth === N && Weight[current][start] !== 0) { // 현재 기준 최솟값과 순회 완료되었을 때의 최솟값을 비교한다. min = Math.min(min, cost + Weight[current][start]); return; } // 0번째 고정이므로 i : 1 ~ N-1 for (let i = 1; i < N; i++) { if (!visit[i] && Weight[current][i] !== 0) { // 순회 중에 최소값을 넘어가면 순회를 멈춘다. if (cost + Weight[current][i] < min) { visit[i] = true; backtracking(cost + Weight[current][i], start, i, depth + 1); visit[i] = false; } } } };
JavaScript
복사
내부에서는 첫번째를 제외했기 때문에 i : 1 ~ N-1까지 for문을 돌면서 방문처리해서 재귀호출한다.
이때 에지케이스 처리가 중요한데, 방문하지 않은 i번째 인덱스이면서 이동할 수 있는 길이 있어야 한다.(Weight[current][i] ≠ 0)
이 조건을 만족하더라도 순회 도중에 최솟값보다 값이 커진다면 순회를 더 진행할 필요가 없다.
if (depth === N && Weight[current][start] !== 0) { // 현재 기준 최솟값과 순회 완료되었을 때의 최솟값을 비교한다. min = Math.min(min, cost + Weight[current][start]); return; }
JavaScript
복사
재귀의 종료조건은 depth가 N이고(N개도시를 방문하고), 현재부터 시작정점(여기서 0)까지의 경로가 존재할 때(Weight[current][start] ≠ 0) 수행된다.
현재 최솟값과 (순회한 경로의 총 비용 + 현재 도시 → 시작 도시의 비용)을 비교해 최솟값을 업데이트한다.

Code

const solution = function (i) { const [N, ...Weight] = i .toString() .trim() .split('\n') .map((v, i) => (i === 0 ? +v : v.split(' ').map(Number))); const visit = [...Array(N)].fill(false); let min = Number.MAX_SAFE_INTEGER; const backtracking = (cost, start, current, depth) => { if (depth === N && Weight[current][start] !== 0) { // 현재 기준 최솟값과 순회 완료되었을 때의 최솟값을 비교한다. min = Math.min(min, cost + Weight[current][start]); return; } // 0번째 고정이므로 i : 1 ~ N-1 for (let i = 1; i < N; i++) { if (!visit[i] && Weight[current][i] !== 0) { // 순회 중에 최소값을 넘어가면 순회를 멈춘다. if (cost + Weight[current][i] < min) { visit[i] = true; backtracking(cost + Weight[current][i], start, i, depth + 1); visit[i] = false; } } } }; // 0번째는 방문처리 후 고정해둠 - 방문처리 안해도 비용 계산에는 영향을 주지 않는다. visit[0] = true; backtracking(0, 0, 0, 1); console.log(min); return min; }; describe('외판원순회2', () => { it('TC1', () => { expect( solution(`4 0 10 15 20 5 0 9 10 6 13 0 12 8 8 9 0`) ).toStrictEqual(35); }); it('TC2', () => { expect( solution(`4 0 1 0 0 0 0 1 0 0 0 0 1 1 0 0 0`) ).toStrictEqual(4); }); it('TC3', () => { expect( solution(`4 0 5 1 99 99 0 5 1 1 99 0 5 5 1 99 0`) ).toStrictEqual(20); }); });
JavaScript
복사