모눈종이에 사각사각

최단 경로(Shortest Path) 알고리즘 본문

Algorithm

최단 경로(Shortest Path) 알고리즘

모눈종이씨 2021. 9. 16. 20:32

이번 포스팅에서는 최단 경로 알고리즘에 대해 다뤄보고자 한다.

 

최단경로 알고리즘은 이름에도 나와있듯이 가장 짧은 경로를 찾는 알고리즘이다. '길찾기' 문제라고도 불린다.

 

최단경로 알고리즘은 대표적으로 세 가지가 있다.(컴공 학부수준) 

1. 다익스트라 알고리즘

2. 플로이드 워셜 알고리즘

3. 벨만 포드 알고리즘

 

1, 2번이 코딩테스트에서 가장 많이 등장하는 유형이라고 한다.

 

먼저 다익스트라 최단 경로 알고리즘에 대해 알아보고자 한다.

🥝 다익스트라 최단 경로 알고리즘 -> 한 지점에서 다른 모든 지점까지의 최단 경로

- 특정한 노드에서 출발하여 다른 노드로 가는 최단 경로를 구해주는 알고리즘

- 기본적으로 그리디 알고리즘으로 분류 (매번 가장 비용이 적은 노드를 선택해서 임의의 과정을 반복하기 때문)

동작 과정
1. 출발노드 설정
2. 최단 거리 테이블 초기화 -> 모두 무한대로 설정
3. 방문하지 않은 노드 중에서 최단 거리가 가장 짧은 노드 선택
4. 해당 노드를 거쳐 다른 노드로 가는 비용을 계산하여 최단 거리 테이블 갱신
5. 3, 4번 반복

각 과정에서 최단거리는 파란색, 노드 방문 여부는 분홍색 빗금, 갱신 정보는 빨간색으로 나타냈다.

- '각 노드에 대한 현재까지의 최단 거리'정보를 항상 1차원 리스트에 저장하며 리스트를 계속 갱신

- 매번 현재 처리하고 있는 노드를 기준으로 주변 간선 확인

 

- 한 번 선택된 노드는 최단 거리가 감소하지 않는다. \(\rightarrow\) 마지막 노드에 대해서는 확인할 필요가 없다.

더보기

왜 한 번 선택된 노드는 최단 거리가 감소하지 않을까?

현재까지 최단 거리로 도착한 노드에서 뻗어나가는 노드이기 때문이지 않을까?

 

🥦 방법1. 간단한 다익스트라 알고리즘

# 간단한 다익스트라 알고리즘

import sys
input = sys.stdin.readline
INF = int(1e9)  # 무한대 설정

n, m = map(int, input().split())  # n 노드의 개수, m 간선의 개수
start = int(input())  # 시작노드
graph = [[] for _ in range(n+1)]  # 각 노드에 연결되어 있는 노드에 대한 정보

visited = [False]*(n+1)  # 노드 방문 기록
distance = [INF]*(n+1)  # 최단거리 테이블

for _ in range(m):
    a, b, c = map(int, input().split())
    # a번 노드에서 b번노드로, 비용은 c
    graph[a].append((b, c))


def get_smallest_node():
    min_value = INF
    index = 0
    for i in range(1, n+1):
        if distance[i] < min_value and not visited[i]:
            # 거리가 짧고 아직 방문하지 않았다면
            min_value = distance[i]
            index = i
    return index


def dijkstra(start):
    distance[start] = 0  # 시작 노드는 거리 0
    visited[start] = True  # 시작노드 방문처리

    for i in graph[start]:
        distance[i[0]] = i[1]

    for _ in range(n-1):
        now = get_smallest_node()  # 가장 거리가 짧은 인덱스
        visited[now] = True  # 방문처리하고
        for j in graph[now]:  # 그 노드에서 뻗어나갈 수 있는 부분 탐색
            cost = distance[now]+j[1]
            distance[j[0]] = min(distance[j[0]], cost)  # 원래 거리 vs 갱신된 거리


dijkstra(start)

- 시간복잡도 \(O(V^2)\) (* \(V\)는 노드의 개수)

- 총 O(V)번에 걸쳐서 최단 거리가 가장 짧은 노드를 매번 선형탐색해야함.

  따라서 전체 시간 복잡도는 \(O(V^2)\)

  => 노드의 개수가 5,000개 이하라면 괜찮지만, 10,000개를 넘어가는 문제라면 다른 방법을 생각해야함.

 

 

 

🥦 방법2. 개선된 다익스트라 알고리즘

- 최악의 경우에도 \(O(ElogV)\)를 보장 (* \(V\)는 노드의 개수, \(E\)는 간선의 개수)

더보기

노드의 개수는 탐색할 수록 줄어들고 \(\rightarrow logV\) 

간선의 개수만큼 탐색하기 때문 \(\rightarrow E) 

- 최단 거리가 가장 짧은 노드를 찾기 위해(get_smallest_node()) 매번 최단 거리 테이블을을 선형적으로 탐색함.

-> 이 과정에서만 \(O(V)\)의 시간이 걸림 => 이를 줄일 수 있다면!!

 

- 힙(Heap) 자료구조는 우선 순위 큐를 구현하기 위하여 사용하는 자료구조 중 하나.

    - 우선순위 큐 : 우선순위가 가장 높은 데이터를 가장 먼저 삭제

- 파이썬에서는 우선순위 큐가 필요할 때 PriorityQueue 혹은 heapq를 사용, heapq가 더 빠르게 동작하기 때문에 수행 시간이 제한된 상황에서는 heapq 사용 권장

- 파이썬의 우선순위 큐 라이브러리는 최소 힙에 기반

 

- 힙(Heap) 자료구조 사용 -> 단계마다 방문하지 않은 노드 중에서 최단 거리가 가장 짧은 노드를 선택하기 위해

- 다익스트라 알고리즘이 동작하는 기본 원리는 동일하지만, 현재 가장 가까운 노드를 저장해 놓기 위해서 힙 자료를 추가적으로 이용한다는 점이 다름

- 현재 가장 가까운 노드 저장하기 위한 목적으로만 우선 순위 큐를 추가로 이용

 

각 과정에서 최단거리는 파란색, 노드 방문 여부는 분홍색 빗금, 갱신 정보는 빨간색으로 나타냈다. 또한 heapq에 대한 정보는 주황색으로 나타냈다.

# 개선된 다익스트라 알고리즘

import heapq
import sys
input = sys.stdin.readline
INF = int(1e9)

n, m = map(int, input().split())  # n 노드의 개수, m 간선의 개수
start = int(input())  # 시작노드
graph = [[] for _ in range(n+1)]  # 각 노드에 연결되어 있는 노드에 대한 정보

# visited 리스트 사용하지 않음

distance = [INF]*(n+1)  # 최단거리 테이블

for _ in range(m):
    a, b, c = map(int, input().split())
    # a번 노드에서 b번노드로, 비용은 c
    graph[a].append((b, c))


def dijkstra(start):
    q = []
    # 시작 노드로 가기 위한 최단 경로는 0으로 설정하여 큐에 삽입
    heapq.heappush(q, (0, start))
    distance[start] = 0

    while q:  # 큐가 비어있지 않다면
        # 가장 최단 거리가 짧은 노드에 대한 정보 꺼내기
        dist, now = heapq.heappop(q)

        # 현재 노드가 이미 처리 된 적이 있는 노드라면 무시
        # distance에는 node에 대한 거리 정보가 들어있음.
        # 들어 있는 정보가 dist보다 짧다면 이미 방문한 것.
        if distance[now] < dist:
            continue

        for i in graph[now]:
            cost = dist + i[1]
            if cost < distance[i[0]]:
                distance[i[0]] = cost
                heapq.heappush(q, (cost, i[0]))


dijkstra(start)

 

🥝 플로이드 워셜 알고리즘 -> 모든 지점에서 다른 모든 지점까지의 최단 경로

- 모든 노드에서 다른 모든 노드까지의 최단 경로를 모두 계산

- 매번 방문하지 않은 노드 중에서 최단 거리를 갖는 노드를 찾을 필요 없음

- 각 단계마다 \(O(N^2)\)의 연산을 통해 '현재 노드를 거쳐가는 모든 경로'고려

- 시간복잡도는 \(O(V^3)\)

- 2차원 테이블에 최단 거리 정보를 저장

- 다이나믹 프로그램 유형에 속함

 

- K번의 단계에 대한 점화식 --> \(D_{ab} = min(D_{ab}, D_{ak}+D_{kb})\)

    - A에서 B로 가는 최소 비용과, A에서 K를 거쳐 B로 가는 비용 중 더 작은 값으로 갱신

 

# 플로이드 워셜 알고리즘

INF = int(1e9)

n,m = map(int,input().split()) # n: 노드의 개수, m: 간선의 개수
graph = [[INF] * (n+1) for _ in range(n+1)] # 2차원 리스트를 만들고, 무한대로 초기화

for i in range(1,n+1):
  graph[i][i] = 0 # 자기 자신으로 가는 비용은 0

for _ in range(m):
  a,b,c = map(int,input().split())

  graph[a][b] = c # 각 간선에 대한 정보를 입력받아 그 값으로 초기화

for k in range(1, n+1):
  for a in range(1,n+1):
    for b in range(1, n+1):
      graph[a][b] = map(graph[a][b], graph[a][k]+graph[k][b])

 

 

🥝 벨만 포드 알고리즘 -> 한 지점에서 다른 노드까지의 최단 경로

- 음의 간선이 포함된 상황에서도 구할 수 있다.
- 음수 간선의 순환을 감지할 수 있다.

- 벨만-포드 알고리즘은 매번 모든 간선을 전부 확인한다.

 

* 음수 간선의 순환

왼쪽: 음수 간선의 순환X, 오른쪽: 음수 간선의 순환O 

위의 오른쪽 그림을 보면, 1+2+(-4)<0이기 때문에 음수 간선의 순환이 있는 것이다. 

 

- 음수 간선 순환을 확인하는 이유
-> 최단 거리가 음의 무한인 노드가 발생하기 때문

음수 간선에 관하여 최단 경로 문제 분류
1) 모든 간선이 양수인 경우  -> 다익스트라
2) 음수 간선이 있는 경우 -> 벨만-포드
    2-1) 음수 간선 순환은 없는 경우
    2-2) 음수 간선 순환이 있는 경우

 

 

다익스트라 알고리즘 vs 벨만-포드 알고리즘

다익스트라 알고리즘 벨만-포드 알고리즘
- 매번 방문하지 않은 노드 중에서 최단 거리가 가장 짧은 노드를 선택
- 음수의 간선이 없다면 최적의 해를 찾을 수 있음(음수의 간선이 있다면 불가능)
0 속도가 빠름
- 매번 모든 간선을 전부 확인
- 다익스트라 알고리즘에서의 최적의 해를 항상 포함
- 음수의 간선이 있어도 최적의 해를 찾을 수 있음
- 음수 간선 순환을 탐지 가능
- 시간 복잡도 느림 \(O(VE)\)
동작과정
1. 출발 노드 설정
2. 최단 거리 테이블 초기화
3. 다음의 과정을 N-1번 반복
    3-1. 모든 간선 E개를 하나씩 확인
    3-2. 각 간선을 거쳐 다른 노드로 가는 비용을 계산하여 최단 거리 테이블 갱신

- 만약 음수 간선 순환이 발생하는 지 체크하고 싶다면 3번의 과정을 한 번 더 수행
    ->이때 최단 거리 테이블이 갱신된다면 음수 간선이 존재하는 것!

 

# 벨만 포드 알고리즘

import sys
input = sys.stdin.readline
INF = int(1e9)


n, m = map(int, input().split())  # n: 노드의 개수, m: 간선의 개수
edges = []  # 모든 간선에 대한 정보를 담는 리스트
dist = [INF]*(n+1)  # 최단 거리 테이블 무한으로 초기화

for _ in range(m):
    a, b, c = map(int, input().split())
    edges.append((a, b, c))  # a번 노드에서 b번 노드로 가는 비용이 c


def bf(start):

    dist[start] = 0  # 시작 노드 초기화

    for i in range(n):  # n번 라운드 반복
        for j in range(m):  # 매번 모든 간선 확인
            cur = edges[j][0]  # 현재 노드
            next_node = edges[j][1]  # 다음 노드
            cost = edges[j][2]  # 비용

            if dist[cur] != INF and dist[next_node] > dist[cur]+cost:
                # 현재의 간선을 거쳐서 다른 노드로 이동하는 거리가 더 짧으면
                dist[next_node] = dist[cur]+cost

                if i == n-1:  # n번 째 라운드에서도 값이 갱신된다면 -> 음수 순환 존재
                    return True
    return False


negative_cycle = bf(1)  # 시작노드 -> 1번

if negative_cycle:
    print("-1")
else:
    for i in range(2, n+1):  # 1번 노드를 제외한 다른 모든 노드로 가기 위한 최단거리 출력
        if dist[i] == INF:
            print("-1")
        else:
            print(dist[i])

 


📚 참고 : 이것이 취업을 위한 코딩테스트다 with 파이썬

https://www.youtube.com/watch?v=Ppimbaxm8d8 

'Algorithm' 카테고리의 다른 글

0-1 BFS 알고리즘  (0) 2022.04.08
다이나믹 프로그래밍(Dynamic Programming, DP, 동적계획법)  (0) 2022.02.18
이진 탐색, 이진 탐색 트리  (0) 2021.09.12
정렬 (Sorting)  (0) 2021.09.12
DFS/BFS  (0) 2021.09.11
Comments