공부/코딩

다익스트라 알고리즘: 최단 경로를 모두 찾아야 할 때

finding wangdo 2025. 5. 6. 19:17

다익스트라 알고리즘(Dijstkra Algorithm)이란, 그래프가 주어졌을 때 특정 노드 두 개 사이의 최단 거리를 찾을 수 있는 알고리즘이다.

 

1부: 다익스트라 알고리즘을 이용해 최단 경로 복원하기

간단히 구현해보자면 다음과 같다:

코드 A

#include <iostream>
#include <queue>
#include <vector>
#include <climits>

#define NMAX 1500
using namespace std;
typedef pair<int, int> pii;


int main()
{
    int v,e;
    cin>>v>>e;
    int k;
    cin>>k;
    vector<pii> rd[NMAX]; // road
    for(int i=0;i<e;i++)
    {
        int u,v,w;
        cin>>u>>v>>w;
        rd[u].push_back(pii(v,w));
    }
    
    int dis[NMAX];
    for(int i=1;i<=v;i++)
    {
        dis[i]=INT_MAX;
    }
    
    dis[k]=0;
    
    priority_queue<pii> pq;
    pq.push(pii(-0,k));
    
    while(!pq.empty())
    {
        pii x=pq.top();
        pq.pop();
        int cn=x.second; // current node
        int cd=-x.first; // current distance
        
        if(cd>dis[cn])
        {
            continue;
        }
        
        for(auto j:rd[cn])
        {
            int nn=j.first; // next node
            int nd=cd+j.second; // next distance
            if(nd<dis[nn])
            {
                dis[nn]=nd;
                pq.push(pii(-nd,nn));
            }
        }
    }
    
    for(int i=1;i<=v;i++)
    {
        if(dis[i]==INT_MAX)
        {
            cout<<"INF\n";
        }
        else
        {
            cout<<dis[i]<<"\n";
        }
    }
    
    return 0;
}

 

참고로 이 코드는 백준 1753번: 최단경로 문제의 해답 코드이다. https://www.acmicpc.net/problem/1753

 

 

이 코드는 시작 노드(k)에서 나머지 노드들로 가는 최단 경로의 길이를 찾아준다.

다익스트라 알고리즘은 즉, 그 길이를 찾아주는 알고리즘이다.

 

따라서 그 경로 자체는 알 수 없다.

 

하지만 알 수 있는 방법이 있다.

코드를 조금 수정해보았다.

코드 B

#include <iostream>
#include <queue>
#include <vector>
#include <climits>

#define NMAX 1500
using namespace std;
typedef pair<int, int> pii;


int main()
{
    int v,e;
    cin>>v>>e;
    int k;
    cin>>k;
    vector<pii> rd[NMAX]; // road
    int bef[NMAX]; // bef 배열: 특정 노드를 최단 경로를 따라 이동했을 때 그 노드 직전에 방문한 노드
    for(int i=0;i<e;i++)
    {
        int u,v,w;
        cin>>u>>v>>w;
        rd[u].push_back(pii(v,w));
    }
    
    int dis[NMAX];
    for(int i=1;i<=v;i++)
    {
        dis[i]=INT_MAX;
    }
    
    dis[k]=0;
    
    priority_queue<pii> pq;
    pq.push(pii(-0,k));
    
    while(!pq.empty())
    {
        pii x=pq.top();
        pq.pop();
        int cn=x.second; // current node
        int cd=-x.first; // current distance
        
        if(cd>dis[cn])
        {
            continue;
        }
        
        for(auto j:rd[cn])
        {
            int nn=j.first; // next node
            int nd=cd+j.second; // next distance
            if(nd<dis[nn])
            {
                dis[nn]=nd;
                pq.push(pii(-nd,nn));
                bef[nn]=cd; // bef 노드 업데이트: nn을 방문하기 직전에 cn을 방문한다
            }
        }
    }
    
    //이후는 생략
    
    return 0;
}

 

두 줄 추가했다. 바로 bef[]라는 배열을 추가한 것이다.

nn번 노드를 방문하는 최단 경로가 cn과 nn을 잇는 간선을 포함한다면, nn번 노드 방문 직전에 cn번 노드를 방문했을 것이다.

그러면 bef[nn]=cn 이렇게 저장해준다.

 

그리고 도착지 노드로부터 bef[]배열을 이용해서 그 직전에 방문한 노드를 하나씩 역추적해가면 경로 자체를 복원할 수도 있다.

 

예시) 1 -> 8번 노드를 잇는 최단 경로를 찾자.
bef[8]=7이다 => 1 -> 7번 노드를 잇는 최단 경로를 찾자.
bef[7]=3이다 => 1 -> 3번 노드를 잇는 최단 경로를 찾자.
bef[3]=1이다

=> 1 -> 8을 잇는 최단 경로는 1 -> 3 -> 7 -> 8 이 된다.

 

최단 경로의 경유지를 파악해서 그를 이용해 경로 전체를 알아낸다는 점에서 플로이드 워셜 알고리즘에서 경로 복원하는 방식이랑 비슷하다.

 

그런데, 이 방식으로는 최단 경로 중 하나만 알아낼 수 있다.

비용이 한은 여러 가지 경로들이 있다면 전부 다 알 수는 없는 것이다.

 

            if(nd<dis[nn])
            {
                dis[nn]=nd;
                pq.push(pii(-nd,nn));
                bef[nn]=cd; // bef 노드 업데이트: nn을 방문하기 직전에 cn을 방문한다
            }

 

바로 이 부분 때문이다.

if문 안에 부등호가 <=가 아니고 < 이다.

새로 찾은 특정 노드를 도착지로 하는 최단 경로가 기존에 찾았던 경로보다 작은 때만 업데이트를 해준다.

그럼 ==인 경우도 추가한다면 답을 찾을 수 있지 않을까?

 

그렇다.

 

다만 코드 수정이 조금 필요하다. int 배열인 bef[] 배열은 숫자 하나만 저장할 수 있기 때문이다.

그래서 int가 아닌 vector 배열으로 수정해보자.

코드 C

#include <iostream>
#include <queue>
#include <vector>
#include <climits>

#define NMAX 1500
using namespace std;
typedef pair<int, int> pii;


int main()
{
    int v,e;
    cin>>v>>e;
    int k;
    cin>>k;
    vector<pii> rd[NMAX]; // road
    vector<int> bef[NMAX]; // bef 배열: 특정 노드를 최단 경로를 따라 이동했을 때 그 노드 직전에 방문한 노드
    for(int i=0;i<e;i++)
    {
        int u,v,w;
        cin>>u>>v>>w;
        rd[u].push_back(pii(v,w));
    }
    
    int dis[NMAX];
    for(int i=1;i<=v;i++)
    {
        dis[i]=INT_MAX;
    }
    
    dis[k]=0;
    
    priority_queue<pii> pq;
    pq.push(pii(-0,k));
    
    while(!pq.empty())
    {
        pii x=pq.top();
        pq.pop();
        int cn=x.second; // current node
        int cd=-x.first; // current distance
        
        if(cd>dis[cn])
        {
            continue;
        }
        
        for(auto j:rd[cn])
        {
            int nn=j.first; // next node
            int nd=cd+j.second; // next distance
            if(nd<dis[nn])
            {
                dis[nn]=nd;
                pq.push(pii(-nd,nn));
                bef[nn].clear();
                bef[nn].push_back(cd); // bef 노드 업데이트: nn을 방문하기 직전에 cn을 방문한다
            }
            else if(nd==dis[nn])
            {
                bef[nn].push_back(cd);
            }
        }
    }
    
    //이후는 생략
    
    return 0;
}

 

bef[] 배열은 이제 특정 노드를 방문하기 직전에 방문해도 되는 노드들의 목록이다.

예시)
1 -> 8 로의 최단 경로가
1 -> 7 -> 8
1 -> 4 -> 8
이렇게 두 개가 존재한다면,
bef[8]은 {4, 7} 이렇게 4와 7 두 숫자가 저장된 vector이다.

 

 

그래프 A

이와 같은 그래프를 상정해보자.

1번 노드에서 5번 노드로 가는 최단 경로를 찾고자 한다.

최단 경로는 다음과 같다.

그래프 B

최단 경로는 2가지가 있다. 4번 노드를 경유하는 빨간 경로, 그리고 2번 노드와 3번 노드를 경유하는 분홍 경로.

 

자, 그래프 A에 대한 간선들의 정보는 rd[] 배열이 가지고 있다.

rd[x]는 x에서 출발하는 모든 간선들의 도착노드와 그 비용에 대한 정보가 담겨져 있다.

 

그렇다면 bef[] 배열은 과연 어떤 그래프의 간선들에 대한 정보를 가지고 있을까?

그래프 C

바로 그래프 C가 된다.

1과 5를 잇는 최단 경로들에 포함된 모든 간선들을 저장해둔 것이 바로 bef[] 배열인 것이다! (다만 방향이 거꾸로이므로 역추적을 통해 경로를 복원해야 한다)

 

자, 다익스트라 알고리즘을 이용해 두 노드를 잇는 모든 최단거리들을 복원하는 데 성공했다.


 

2부: 또다른 방법을 시험해보자.

새로운 방법을 하나 제안해보겠다.

코드는 다음과 같다.

코드 D

#include <iostream>
#include <queue>
#include <vector>
#include <climits>

#define NMAX 1500
using namespace std;
typedef pair<int, int> pii;
typedef pair<int, pii> pip;


int main()
{
    int v,e;
    cin>>v>>e;
    int k;
    cin>>k;
    vector<pii> rd[NMAX]; // road
    int nex[NMAX]; // nex배열: bef 배열의 반대라고 생각하자.
    for(int i=0;i<e;i++)
    {
        int u,v,w;
        cin>>u>>v>>w;
        rd[u].push_back(pii(v,w));
    }
    
    int dis[NMAX];
    for(int i=1;i<=v;i++)
    {
        dis[i]=INT_MAX;
    }
    
    dis[k]=0;
    
    priority_queue<pip> pq; // pii 대신 pip를 사용, 숫자 3개를 동시에 저장함
    pq.push(pip(-0,pii(k,0)));
    
    while(!pq.empty())
    {
        pip x=pq.top();
        pq.pop();
        int cn=x.second.first; // current node
        int cd=-x.first; // current distance
        
        if(cd>dis[cn])
        {
            continue;
        }
        nex[cn]=x.second.second;
        for(auto j:rd[cn])
        {
            int nn=j.first; // next node
            int nd=cd+j.second; // next distance
            if(nd<dis[nn])
            {
                dis[nn]=nd;
                pq.push(pip(-nd,pii(nn,cn)));
            }
        }
    }
    
    //이후는 생략
    
    return 0;
}

직전에 방문한 노드에 대한 정보를 bef 배열이 아니라 pq에 노드를 push 할 때 같이 집어넣는 것이다.

그래서 숫자 2개를 저장하는 pii가 아니라 pip 자료형을 이용한 우선순위 큐로 수정했다.

 

( pii와 pip는 내가 직접 정의한 자료형인데 코드 윗부분 typedef 쪽 참고)

 

그러면 pq에서 x를 꺼낼 때 x에는 노드와 최단 거리뿐만 아니라 그 이전에 방문한 노드에 대한 정보까지 들어있다.

 

이 방법을 이용한다면 장점은 bef[] 배열이 아니라 nex[] 배열을 만들 수 있다.

기존에 bef[]를 이용했을 땐 도착 노드에서 시작해 출발 노드까지 최단 경로를 역추적했는데, 최단 경로를 역추적할 필요가 없이 출발 노드에서 시작해 도착지까지 최단 거리로 가는 경로를 정 순서대로 찾을 수 있다.

 

역시나 이는 최단 경로 중 단 하나만을 찾아주는 알고리즘이다. 그러니 다음과 같이 수정해 모든 최단 경로를 찾아주도록 바꾸자.

코드 E

#include <iostream>
#include <queue>
#include <vector>
#include <climits>

#define NMAX 1500
using namespace std;
typedef pair<int, int> pii;
typedef pair<int, pii> pip;


int main()
{
    int v,e;
    cin>>v>>e;
    int k;
    cin>>k;
    vector<pii> rd[NMAX]; // road
    vector<int> nex[NMAX]; // nex배열: bef 배열의 반대라고 생각하자.
    for(int i=0;i<e;i++)
    {
        int u,v,w;
        cin>>u>>v>>w;
        rd[u].push_back(pii(v,w));
    }
    
    int dis[NMAX];
    for(int i=1;i<=v;i++)
    {
        dis[i]=INT_MAX;
    }
    
    dis[k]=0;
    
    priority_queue<pip> pq; // pii 대신 pip를 사용, 숫자 3개를 동시에 저장함
    pq.push(pip(-0,pii(k,0)));
    
    while(!pq.empty())
    {
        pip x=pq.top();
        pq.pop();
        int cn=x.second.first; // current node
        int cd=-x.first; // current distance
        
        if(cd>dis[cn])
        {
            continue;
        }
        nex[cn].push_back(x.second.second);
        for(auto j:rd[cn])
        {
            int nn=j.first; // next node
            int nd=cd+j.second; // next distance
            if(nd<=dis[nn]) // 부등호 <를 <=로 수정
            {
                dis[nn]=nd;
                pq.push(pip(-nd,pii(nn,cn)));
            }
        }
    }
    
    //이후는 생략
    
    return 0;
}

 

nex[] 배열이 vector 배열로 바뀐 것과 더불어, 또 하나의 차이점은 바로 이 부분이다.

            if(nd<=dis[nn]) // 부등호 <를 <=로 수정
            {
                dis[nn]=nd;
                pq.push(pip(-nd,pii(nn,cn)));
            }

 

부등호를 <=로 바꾼 것이다.

두 가지 경로가 비용이 같다면 두 경로 다 우선순위 큐에 삽입한다.

 

이로써 최단 경로를 전부 구할 수 있는가?

 

일단, 정답은 그렇다 이다.

 

하지만 틀린 방법이라고도 할 수 있겠다.

특정한 경우에서 시간이 너무 오래 걸리기 때문이다.

 

백준에서 문제를 풀다보면 최단 경로들을 전부 구해야 하는 문제들이 있다.

최단 경로들 중에서도 특정 추가 조건을 만족하는 경로를 구해야한다든가...  등 말이다.

 

이를 테면 이 문제, 9370번 미확인 도착지 문제

https://www.acmicpc.net/problem/9370

 

이 문제는 최단 경로들 중에서 g와 h를 잇는 간선을 지나는 경로가 있는가를 확인하는 문제이므로, 모든 최단 경로들을 다 확인해보아야 한다.

 

백준에서 이런 문제들을 코드 E와 같은 방식으로 푼다면 아마도 메모리 초과 또는 시간 초과가 날 것이다.

극단적인 예시로 그래프 D를 가져와보겠다.

그래프 D

(모든 간선의 비용은 1이라고 하자)

1번 노드가 출발지이다.

2번 노드로 가는 최단 경로는 2가지

3번 노드로 가는 최단 경로는 4가지

....

8번 노드로 가는 최단 경로는 2^7가지

 

이런 식으로 간선의 개수에 대해 지수적으로 최단 경로의 개수가 늘어난다.

 

그런데 이러한 모든 최단 경로들에 대해 전부 다 우선순위 큐에 넣게 된다.

간선이 E개라면 우선순위 큐 안에 O(2^E)개 만큼 값이 들어가는 것이다. 메모리가 충분하지 않다면 메모리 초과가 날 것이고, 그렇지 않더라도 우선순위 큐 안에 push와 pop을 하는 과정에서 시간 초과가 날 것이다.

 

이를 방지하려면 어떻게 해야 할까?

 

어떤 식이든,

            if(nd<=dis[nn]) // 부등호 <를 <=로 수정
            {
                dis[nn]=nd;
                pq.push(pip(-nd,pii(nn,cn)));
            }

 

이처럼 부등호를 단순히 <에서 <=로 바꿔선 안된다는 것이다. 모든 최단 경로들에 대해 전부 우선순위 큐에 삽입하게 되기 때문이다.

 

그러니 코드 C와 같은 방식을 사용하자.

            if(nd<dis[nn])
            {
                dis[nn]=nd;
                pq.push(pii(-nd,nn));
                bef[nn].clear();
                bef[nn].push_back(cd); // bef 노드 업데이트: nn을 방문하기 직전에 cn을 방문한다
            }
            else if(nd==dis[nn])
            {
                bef[nn].push_back(cd);
            }

 

<를 단순히 <=로 바꾸지 말고 < 인 경우와 ==인 경우로 나누는 것이다.

 

핵심은 ==인 경우엔 우선순위 큐에 push를 하면 안된다는 것이다. push를 한다면 이는 최단 경로를 전부( O(2^E)개 )를 우선순위 큐에 push하는 것과 같다. 메모리 초과가 날 수가 있다. 직전 노드에 대한 정보를 추가만 해주어야 한다.

구현은 자유지만 나는 bef[] 배열로 구현한 방법을 보여주었다.

 

다익스크라 변형 문제를 풀다가 메모리 초과가 났다면 이 경우를 의심해 보아라.

 

 

챌린지: 모든 최단 경로를 구해야 하는 문제 백준 5719번 거의 최단 경로를 풀어보자. https://www.acmicpc.net/problem/5719


정리:

 

코드 A는 최단 거리를 구해준다.

코드 B는 최단 경로 중 하나를 구해준다.

코드 C는 최단 경로를 전부 구해준다.

코드 D는 최단 경로 중 하나를 구해준다.

코드 E는 최단 경로를 전부 구해주지만, 비효율적인 코드이다.