가중 그래프의 기본 개념

모든 가중 그래프는 다음 요소를 포함해야 한다:

  1. 정점/노드 (이 글에서는 "정점"이라는 용어를 사용한다).

  1. 정점을 연결하는 간선. 간단하게 방향 그래프를 만들어 보자. 방향 그래프는 간선이 방향을 가지며, 시작 정점과 끝 정점이 명확히 정의된다. 하지만 매우 중요한 사실을 기억해야 한다:
    • 모든 무방향 그래프는 방향 그래프로 볼 수 있다.
    • 방향 그래프는 모든 간선이 반대 방향의 간선과 쌍을 이루는 경우에만 무방향 그래프가 된다.

  1. 모든 간선에 대한 가중치.

최종 결과.
방향 가중 그래프:

무방향 가중 그래프:

다시 한번 강조하자면, 무방향 그래프는 모든 간선이 반대 방향의 간선과 쌍을 이루는 방향 그래프이다. 이 설명은 위 이미지에서 명확히 확인할 수 있다.

좋다! 이제 그래프의 기본 개념에 대해 익숙해졌다.

다익스트라 알고리즘

알고리즘은 1956년 에츠허르 다익스트라가 고안했다.

하나의 시작 정점에서 그래프 내 모든 다른 정점들까지의 최단 경로를 찾을 때 사용할 수 있다.

가장 좋은 예는 도로 네트워크다. 집에서 직장까지의 최단 경로를 찾거나 집에서 가장 가까운 상점을 찾으려면 바로 다익스트라 알고리즘을 사용할 때다.

이 알고리즘은 모든 정점이 방문 처리될 때까지 다음 사이클을 반복한다.
사이클:

  1. 아직 방문하지 않은 정점 중 시작점에서 가장 짧은 경로 길이를 가진 정점을 선택한다. (동일한 최단 경로 값을 가진 정점이 여러 개라면 그 중 아무거나 선택한다)
  2. 선택한 정점을 방문 처리한다.
  3. 선택한 정점의 모든 이웃을 확인한다. 현재 정점의 시작점부터의 경로 길이에 이웃까지의 간선 가중치를 더한 값이 이웃의 현재 시작점부터의 경로 길이보다 작다면, 이웃의 시작점부터의 경로 길이를 새로운 값으로 갱신한다.

모든 정점이 방문 처리되면 알고리즘의 작업이 끝난다. 이제 관심 있는 정점을 선택해 시작점부터의 최단 경로를 확인할 수 있다.

알고리즘의 흐름을 더 잘 이해할 수 있도록 VisualizedDijkstra.playground 게임/튜토리얼을 만들었다. 아래에는 단계별 알고리즘 설명도 있다.

짧은 참고 사항. Swift Algorithm Club에는 A* 알고리즘도 포함되어 있다. 이 알고리즘은 기본적으로 다익스트라 알고리즘의 더 빠른 버전으로, 목적지의 위치를 미리 알고 있어야 한다는 추가 조건이 있다.

예제

여러분이 상점에 가고 싶다고 가정해 보자. 여러분의 집을 A 정점으로 하고, 집 주변에 4개의 상점이 있다. 가장 가까운 상점을 어떻게 찾을 수 있을까? 다행히도 여러분은 집과 이 모든 상점을 연결하는 그래프를 가지고 있다. 이제 무엇을 해야 할지 알 것이다. :)

초기화

알고리즘이 동작을 시작할 때 초기 그래프는 다음과 같다:

아래 표는 그래프의 상태를 나타낸다:

A B C D E
방문 여부 F F F F F
시작점부터의 경로 길이 inf inf inf inf inf
시작점부터의 경로 정점 [ ] [ ] [ ] [ ] [ ]

inf는 무한대를 의미하며, 알고리즘이 해당 정점이 시작점으로부터 얼마나 떨어져 있는지 알 수 없음을 나타낸다.

F는 False를 나타낸다.

T는 True를 나타낸다.

그래프를 초기화하려면 시작 정점의 경로 길이를 0으로 설정하고, 시작점부터의 경로 정점에 자기 자신을 추가해야 한다.

A B C D E
방문 여부 F F F F F
시작점부터의 경로 길이 0 inf inf inf inf
시작점부터의 경로 정점 [A] [ ] [ ] [ ] [ ]

이제 그래프가 초기화되었으므로 다익스트라 알고리즘을 적용할 수 있다. 시작해 보자!

알고리즘의 사이클을 따라가며, 이웃 정점을 확인할 첫 번째 정점을 선택한다. 모든 정점이 방문되지 않은 상태이지만, 시작점으로부터의 경로 길이가 가장 짧은 정점은 A이다. 이 정점이 이웃 정점을 확인할 첫 번째 정점이 된다. 먼저 이 정점을 방문한 것으로 표시한다.

A.visited = true

이 단계 이후 그래프의 상태는 다음과 같다:

A B C D E
방문 여부 T F F F F
시작점부터의 경로 길이 0 inf inf inf inf
시작점부터의 경로 정점 [A] [ ] [ ] [ ] [ ]

1단계

그 다음, 모든 이웃 정점을 확인한다. 만약 시작점부터 현재 정점까지의 경로 길이에 간선 가중치를 더한 값이 이웃 정점의 시작점부터의 경로 길이보다 작다면, 이웃 정점의 시작점부터의 경로 길이를 새로운 값으로 설정하고, 이웃 정점의 pathVerticesFromStart 배열에 현재 정점을 추가한다. 이 작업을 모든 정점에 대해 반복한다.

더 명확하게 설명하면 다음과 같다:

if (A.pathLengthFromStart + AB.weight) < B.pathLengthFromStart {
    B.pathLengthFromStart = A.pathLengthFromStart + AB.weight
    B.pathVerticesFromStart = A.pathVerticesFromStart
    B.pathVerticesFromStart.append(B)
}

이제 그래프는 다음과 같이 보인다:

그리고 현재 상태는 아래와 같다:

A B C D E
방문 여부 T F F F F
시작점부터의 경로 길이 0 3 inf 1 inf
시작점부터의 경로 정점 [A] [A, B] [ ] [A, D] [ ]

2단계

이제 모든 작업을 다시 반복하고 테이블에 새로운 정보를 채워 넣는다!

A B C D E
방문 여부 T F F T F
시작점부터의 경로 길이 0 3 inf 1 2
시작점부터의 경로 정점 [A] [A, B] [ ] [A, D] [A, D, E]

A B C D E
방문 여부 T F F T T
시작점부터의 경로 길이 0 3 11 1 2
시작점부터의 경로 정점 [A] [A, B] [A, D, E, C] [A, D] [A, D, E ]

A B C D E
방문 여부 T T F T T
시작점부터의 경로 길이 0 3 8 1 2
시작점부터의 경로 정점 [A] [A, B] [A, B, C] [A, D] [A, D, E ]

A B C D E
방문 여부 T T T T T
시작점부터의 경로 길이 0 3 8 1 2
시작점부터의 경로 정점들 [A] [A, B] [A, B, C] [A, D] [A, D, E ]

코드 구현

먼저 그래프의 모든 정점을 표현할 클래스를 만든다. 이 클래스는 매우 간단하다.

open class Vertex {

    // 모든 정점은 고유해야 하므로 식별자를 설정한다.
    open var identifier: String

    // 다익스트라 알고리즘에서 모든 정점은 최소한 하나의 다른 정점과 연결되어야 한다. 하지만 초기에는 이웃이 없는 상태로 정점을 생성하고, 이후에 이웃을 설정하는 경우도 있다. 따라서 초기값으로 이웃 배열은 비어 있다.
    // 배열은 (Vertex, Double) 튜플을 포함한다. Vertex는 이웃 정점이고, Double은 그 이웃까지의 간선 가중치이다.
    open var neighbours: [(Vertex, Double)] = []

    // 알고리즘 설명에서 언급했듯이, 시작점에서 모든 정점까지의 기본 경로 길이는 가능한 한 크게 설정한다.
    // 이 값은 알고리즘 실행 중에 업데이트되므로 var로 선언한다.
    open var pathLengthFromStart = Double.infinity

    // 이 배열은 시작 정점에서 이 정점까지 도달하기 위해 거쳐야 하는 정점들을 포함한다.
    // 시작점에서의 경로 길이와 마찬가지로, 이 배열도 알고리즘 실행 중에 변경된다.
    open var pathVerticesFromStart: [Vertex] = []

    public init(identifier: String) {
        self.identifier = identifier
    }

    // 이 함수는 동일한 정점 배열을 다시 사용하여 다른 시작 정점으로 경로를 계산할 수 있게 한다.
    // 새로운 시작 정점을 설정하고 경로를 다시 계산할 때, 그래프 정점의 캐시를 지운다.
    open func clearCache() {
        pathLengthFromStart = Double.infinity
        pathVerticesFromStart = []
    }
}

모든 정점은 고유해야 하므로, Hashable 및 Equatable 프로토콜을 준수하게 만든다. 이를 위해 식별자를 사용한다.

extension Vertex: Hashable {
    open var hashValue: Int {
        return identifier.hashValue
    }
}

extension Vertex: Equatable {
    public static func ==(lhs: Vertex, rhs: Vertex) -> Bool {
        return lhs.hashValue == rhs.hashValue
    }
}

이제 알고리즘의 기반을 만들었다. 이제 다익스트라 알고리즘을 구현해보자. 다익스트라 알고리즘은 매우 직관적이다.

public class Dijkstra {
    // 그래프의 모든 정점을 저장하는 변수이다.
    // 정점이 고유하다고 가정하므로, 배열 대신 Set을 사용한다. 이 접근 방식은 나중에 몇 가지 이점을 제공한다.
    private var totalVertices: Set<Vertex>

    public init(vertices: Set<Vertex>) {
        totalVertices = vertices
    }

    // Vertex 클래스 구현에서 clearCache 함수를 기억하는가?
    // 이 함수는 모든 저장된 정점의 캐시를 지우는 래퍼이다.
    private func clearCache() {
        totalVertices.forEach { $0.clearCache() }
    }

    public func findShortestPaths(from startVertex: Vertex) {
	// 시작 정점에서 최단 경로를 찾기 전에, 그래프가 깨끗한 상태인지 확인하기 위해 정점 캐시를 지운다.
	// Vertex는 클래스이고, 클래스는 참조로 전달된다는 점을 기억하라. 따라서 이 클래스 외부에서 정점을 변경하면, totalVertices Set 내부의 정점에도 영향을 미친다.
        clearCache()
	// 이제 모든 정점은 Double.infinity의 pathLengthFromStart와 빈 pathVerticesFromStart 배열을 갖는다.

	// 알고리즘의 다음 단계는 시작 정점의 pathLengthFromStart와 pathVerticesFromStart를 설정하는 것이다.
        startVertex.pathLengthFromStart = 0
        startVertex.pathVerticesFromStart.append(startVertex)

	// 여기서 주요 부분이 시작된다. while 루프를 사용해 그래프의 모든 정점을 순회한다.
	// 이를 위해 currentVertex 변수를 정의하고, 각 while 주기 끝에 이 변수를 변경한다.
        var currentVertex: Vertex? = startVertex

        while let vertex = currentVertex {

    	    // 다음 코드는 정점을 방문한 것으로 표시하는 구현이다.
    	    // 이미 말했듯이, 그래프에서 방문하지 않은 정점만 확인해야 한다.
	    // 따라서 Set에서 정점을 삭제하면, *"if !vertex.visited then"* 같은 확인을 건너뛸 수 있다.
            totalVertices.remove(vertex)

	    // filteredNeighbours는 아직 방문하지 않은 현재 정점의 이웃을 포함하는 배열이다.
            let filteredNeighbours = vertex.neighbours.filter { totalVertices.contains($0.0) }

	    // 이 이웃들을 순회한다.
            for neighbour in filteredNeighbours {
		// neighbour.0 또는 neighbour.1보다 더 명확한 변수명을 사용한다.
                let neighbourVertex = neighbour.0
                let weight = neighbour.1

		// 이웃에게 제공할 수 있는 새로운 가중치를 계산한다.
                let theoreticNewWeight = vertex.pathLengthFromStart + weight

		// 이 값이 이웃의 현재 pathLengthFromStart보다 작다면, 다음 코드를 실행한다.
                if theoreticNewWeight < neighbourVertex.pathLengthFromStart {

		    // 새로운 pathLengthFromStart를 설정한다.
                    neighbourVertex.pathLengthFromStart = theoreticNewWeight

		    // 새로운 pathVerticesFromStart를 설정한다.
                    neighbourVertex.pathVerticesFromStart = vertex.pathVerticesFromStart

		    // 현재 정점을 이웃의 pathVerticesFromStart에 추가한다.
                    neighbourVertex.pathVerticesFromStart.append(neighbourVertex)
                }
            }

	    // totalVertices가 비어 있다면, 즉 모든 정점을 방문했다면 루프를 종료한다.
            if totalVertices.isEmpty {
                currentVertex = nil
                break
            }

	    // 루프가 종료되지 않았다면, 방문하지 않은 정점 중 다음으로 확인할 정점을 선택한다.
	    // 다음 정점의 pathLengthFromStart는 가장 작은 값이어야 한다.
            currentVertex = totalVertices.min { $0.pathLengthFromStart < $1.pathLengthFromStart }
        }
    }
}

이제 이 알고리즘을 플레이그라운드에서 확인할 수 있다. 메인 페이지에는 랜덤 그래프를 생성하는 코드가 있다.

또한 VisualizedDijkstra.playground도 있다. 이 플레이그라운드를 사용해 알고리즘의 흐름을 실제(느리게) 시간에 따라 확인할 수 있다.

알고리즘의 특정 부분을 어떻게 구현할지는 여러분의 선택에 달려 있다. Set 대신 Array를 사용하거나, Vertex에 visited 속성을 추가하거나, func findShortestPaths(from startVertex: Vertex) 내부에 local totalVertices Array/Set을 생성해 totalVertices Array/Set을 변경하지 않도록 할 수도 있다. 이는 가능한 구현 중 하나를 설명한 일반적인 설명이다.

이 저장소에 대해

이 저장소는 두 개의 플레이그라운드를 포함한다:

데모 영상

링크를 클릭하세요: YouTube

크레딧

WWDC 2017 장학금 프로젝트(탈락) - Taras Nikulin 제작