분할정복

분할 정복은 문제를 부분문제로 나누어 문제를 해결할 수 있는단위까지 나눈 후, 해결하고 다시 조합하는 방법이다.

분할정복은 세단계로 구성되어있다.

 

분할: 문제를 분할할 수 있을 만큼 같은 유형의 문제로 분할한다.

정복: 분할된 작은 문제를 해결한다.

조합: 해결된 작은 부분문제들을 조합하여 정답을 출력한다.

 

 

※ 분할정복과 동적 계획법 차이:

분할정복은 큰 문제를 여러개의 작은 문제로 나누어 해결하고, 이를 모두 조합한다.

동적계획법은 작은 문제부터 해결하여 기록하고, 기록된 정보를 통해 큰문제를 해결해나간다.

 

분할정복은 대표적으로 합병정렬과 퀵 정렬이 있다.

 

 

합병정렬

분할정복의 대표적인 정렬 알고리즘이다.

 

합병정렬의 단계

1. 주어진 배열의 크기가 0또는 1이라면 이미 정렬이 되있다고 간주하고, 그렇지 않다면 2개의 부분배열로 나눈다.

2. 부분배열의 크기가 정렬할 수 있을만큼 작지않다면 1번을 재호출한다.

3. 나눠진 부분배열을 합친다. 이때 순서에 맞춰서 정렬한다.

4. 부분배열을 조합하여 결과를 출력한다.

 

합병정렬의 특징:

- 합병정렬의 조합단계에서 따로 저장해둘 배열로부터 값을 가져오므로 입력과 같은 공간의 임시배열이 필요하다.

- 재귀호출로 인해 오버헤드가 발생할 수 있다.

- 연결리스트를 정렬할때는 다른 정렬방법보다 더 효율적이다.

 

Swift 코드로 구현

 

// 합병 정렬
func mergeSort(arr: inout [Int], p: Int, q: Int) -> [Int] {
	// 따로 저장해둘 공간(입력된배열의 크기만큼)
    var result = Array(repeating: 0, count: arr.count)
    // 배열의 크기가 1 또는 0이 될때까지 최대로 나누어준다.
    if p < q {
        // 배열의 중간인덱스
        var k = (p + q) / 2
        // 배열을 둘로 나누었을때, 왼쪽배열과 오른쪽배열
        var leftArr = mergeSort(arr: &arr, p: p, q: k)
        var rightArr = mergeSort(arr: &arr, p: k+1, q: q)
        
        // 배열이 최대로 나누어졌을때 아래 행동이 시작된다.
        // 분할된 배열들을 정렬하며 합친다.
        var i = p
        var j = k+1
        var d = p
        
        // 왼쪽배열과 오른쪽배열중 한 배열이 모두 정렬이 완료될때까지
        while i <= k && j <= q {
            // 왼쪽배열 원소가 더크면 오른쪽배열 원소를 넣고, 오른쪽배열 원소가 더 크면 왼쪽배열 원소를 넣는다.
            if leftArr[i] < rightArr[j] {
                result[d] = leftArr[i]
                i += 1
            } else {
                result[d] = rightArr[j]
                j += 1
            }
            d += 1
        }
        // 한쪽의 배열이 모두 정렬되었을때, 다른 한쪽은 원소가 남아있기때문에 그대로 붙여준다.
        // 남아있는 원소는 이미 이전에 정렬되어있는 원소기때문에 그대로 붙여도 좋다.
        if i > k {
            for index in j...q {
                result[d] = rightArr[index]
                d += 1
            }
        } else if j > q {
            for index in i...k {
                result[d] = leftArr[index]
                d += 1
            }
        }
        
        // 정렬된 원소를 배열에 넣고 리턴
        for i in p...q {
            arr[i] = result[i]
        }
    }
    return arr
}

 

입력 & 결과:

var A = [37, 10, 22, 30, 35, 13, 25, 24]
print(mergeSort(arr: &A, p: 0, q: A.count-1))
// [10, 13, 22, 24, 25, 30, 35, 37]

 

위 코드 재귀호출의 흐름은 다음과 같다.

1. 왼쪽배열을 분할

2. [37, 10] 정렬 후 결합

3. [22,30] 정렬 후 결합

4. 두 배열을 정렬 후 결합

5. 왼쪽배열: [10,22,30,37]

6. 오른쪽배열을 분할

7. [35,13] 정렬 후 결합

8. [25,24] 정렬 후 결합

9 두 배열을 정렬 후 결합

10. 오른쪽 배열: [13, 24, 25, 35] 

11. 왼쪽배열과 오른쪽배열을 정렬 후 결합하여 리턴

 

이름 최선 평균 최악 메모리
합병정렬 O(nlogn) O(nlogn) O(nlogn) O(n)

 

 

퀵정렬

퀵정렬은 임의의 수 하나를 골라, 그 수보다 작으면 왼쪽에, 크다면 오른쪽에 정렬하는 방법이다.

 

퀵정렬 과정

정복: 임의의수 n을 기준으로 n보다 작은수는 왼쪽배열, n보다 큰수는 오른쪽배열에 배치

분할: 배치된 배열에서 왼쪽배열과 오른쪽배열 재귀호출

 

퀵정렬은 정복을 먼저하고 분할하여 재귀호출을 한다.

 

세부과정

1. 기준이되는 원소를 선택

2. 기준원소의 인덱스를 저장하고, 나머지 원소를 검사하며 기준원소보다 작다면 인덱스+1 후 원소와 저장된인덱스와 자리바꿈

3. 모든 원소 검사 후 기준원소와 인덱스와 자리바꿈

(여기까지 완료된다면 배열은 기준원소를 기준으로 왼쪽에는 작은값, 오른쪽엔 큰값 배치됨) (정복)

4. 기준원소를 제외한 왼쪽배열과 오른쪽배열을 재귀호출한다.(분할)

5. 1-4를 반복하여 결과 도출

 

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

// 퀵 정렬
func quickSort(arr: inout [Int], left: Int, right: Int) -> [Int] {
    // 배열의 크기가 2 이상이면 정복, 아니면 그냥 출력
    if left < right {
        // 기준점 선택(가장왼쪽)
        var pivot = arr[left]
        // 기준점이 위치할 곳을 저장
        var j = left
        // 기준점을 제외한 배열의 원소들을 검사
        for i in left+1...right {
            // 원소가 기준점보다 작다면 j위치에 있는 원소와 바꿈
            // 원소가 기준점보다 크다면 패스
            // 이렇게 반복하면 j위치보다 이전에 위치한 원소들은 기준점보다 작은 원소들이다.
            if arr[i] < pivot {
                j += 1
                arr.swapAt(i, j)
            }
        }
        // 기준점과 j위치와 원소 바꾸기
        // 기준점은 중간에 위치하게되고, 기준점 왼쪽은 기준점보다 작은원소, 오른쪽은 기준점보다 큰원소가 배치됨
        arr.swapAt(j, left)
        // 분할
        // 왼쪽배열로 재귀호출
        // 오른쪽배열로 재귀호출
        quickSort(arr: &arr, left: left, right: j-1)
        quickSort(arr: &arr, left: j+1, right: right)
    }
    
    return arr
}

입력 및 결과:

var A = [25, 10, 22, 30, 35, 13, 37, 24]
print(quickSort(arr: &A, left: 0, right: A.count-1))
// [10, 13, 22, 24, 25, 30, 35, 37]

 

위 코드 재귀호출의 흐름은 다음과 같다.

1. 기준점:25 를 기준으로 [작은값, 25, 큰값] 배치

2. [작은값], [큰값] 재귀호출

3. 1-2 반복(기준점을 제외한 원소가 없을때까지)

 

이름 최선 평균 최악 메모리
퀵정렬 O(nlogn) O(nlogn) O(n^2) O(nlogn)

 

퀵정렬의 최악의경우는 이미 정렬된 데이터를 정렬할 경우이다.

 

플로이드-워셜 알고리즘

그래프에서 최단거리를 구할때, 여러가지 알고리즘이 있고, 우리는 선택할 수 있다.

플로이드-워셜 알고리즘은 주어진 모든 노드쌍의 최단거리를 구하는 알고리즘이다.

이 알고리즘은 다익스트라와는 다르게 음의 가중치가 있는 그래프에서도 사용이 가능하며, 시간복잡도는 O(N^3)이다.

 

플로이드-워셜 알고리즘 과정

이 알고리즘은 노드 a에서 b까지 가는 최단거리를 구하기 위해, 두 노드 사이의 중간노드인 m을 이용하여 구한다.

m을통해 a에서 m까지의 최단거리와 m에서 b까지의 최단거리를 구하고 더하면 a에서 b까지의 최단거리가 된다.

그래프의 모든 노드를 중간노드로 설정해주며, 최단거리를 구해내간다.

 

아래 그래프로 플로이드 워셜 알고리즘을 이용해 최단거리를 구해보자.

 

이 그래프를 인접행렬로 나타내면 다음과 같다.

arr[i][j] : i에서 j로 가는 최단거리, INF: 연결되지않음

  A B C D E
A 0 5 1 INF INF
B 5 0 2 1 4
C 1 2 0 4 INF
D INF 1 4 0 2
E INF 4 INF 2 0

 

주어진 모든 노드들을 중간노드로 설정하며 최단거리를 구해줄 것이다.

노드 i와 j사이의 중간노드 m을 설정한다면

arr[i][j] = arr[i][m] + arr[m][j]가 될것이고,

먼저 저장된 arr[i][j]와 arr[i][m] + arr[m][j]의 값을 비교하여 더 작은값을 넣는다.

 

1. 중간노드 : A

  A B C D E
A 0 5 1 INF INF
B 5 0 2 1 4
C 1 2 0 4 INF
D INF 1 4 0 2
E INF 4 INF 2 0

 

중간노드 A로 설정시 갱신되는 최단거리가 없다.

 

2. 중간노드: B

  A B C D E
A 0 5 1 6 9
B 5 0 2 1 4
C 1 2 0 3 6
D 6 1 3 0 2
E 9 4 6 2 0

 

중간노드를 B로 설정하면 위와 같이 최단거리값이 갱신된다.

이는 노드 i에서 j로갈때의 중간노드 m : arr[i][j] > arr[i][m] + arr[m][j]이 성립한다는것이다.

 

3. 중간노드: C

  A B C D E
A 0 3 1 4 7
B 3 0 2 1 4
C 1 2 0 3 6
D 4 1 3 0 2
E 7 4 6 2 0

중간노드를 C로 설정하면 위와 같이 최단거리값이 갱신된다.

 

이렇게 그래프의 모든 노드를 중간노드로 하나씩 설정해주며 최단거리를 갱신하면 모든 쌍의 최단거리 배열이 완성된다.

이를 코드로 구현해보자.

import Foundation

// 그래프의 인접행렬
var graph = [
    [0, 5, 1, 1000000, 1000000],
    [5, 0, 2, 1, 4],
    [1, 2, 0, 4, 1000000],
    [1000000, 1, 4, 0, 2],
    [1000000, 4, 1000000, 2, 0]
]

// 플로이드-워셜 알고리즘
// 모든 노드를 중간노드로 설정해보고, 노드 i에서 j 사이 중간노드 m이라면
// arr[i][j] > arr[i][m] + arr[m][j]이라면 값 갱신
for m in 0..<5 {
    for i in 0..<5 {
        for j in 0..<5 {
            graph[i][j] = min(graph[i][j], graph[i][m] + graph[m][j])
        }
    }
}

 

결과:

더보기
// 결과
0 3 1 4 6
3 0 2 1 3
1 2 0 3 5
4 1 3 0 2
6 3 5 2 0

 

'코딩테스트 > 알고리즘' 카테고리의 다른 글

분할 정복 Swift  (1) 2024.04.26
다익스트라 알고리즘 Swift  (0) 2024.04.24
투포인터 알고리즘 Swift  (0) 2024.04.14
순열과 조합 Swift  (0) 2024.04.10
깊이 우선 탐색(DFS)과 너비 우선 탐색(BFS) Swift  (1) 2024.04.08

알고리즘 문제를 풀면서, 최단경로를 구하는 문제에서 BFS 혹은 DFS를 이용해 문제를 푸려다, 늘 시간초과와 함께했다..

이번에는 특정 노드로 가는데 걸리는 최단경로를 구하기에 용이한 다익스트라 알고리즘을 배우려한다.

 

다익스트라 알고리즘

 다익스트라 알고리즘은 다이나믹 프로그래밍을 활용한 최단경로를 구하는 탐색 알고리즘이다.

이는 실생활에서 최단경로를 구하는데 용이한 알고리즘으로 어떠한 그래프든 방향과 상관이 없지만, 실생활에서 음수의 경로는 없듯이

가중치가 음수인 그래프는 사용할 수 없다.

다익스트라 알고리즘은 하나의 최단거리를 구할때, 이전의 최단거리를 이용하여 구한다는점이 다이나믹 프로그래밍의 특징이라 알 수 있다.

다익스트라는 하나의 노드로 부터 최단경로를 구하는 알고리즘 이지만, 플로이드-워셜 알고리즘은 모든 노드쌍들의 최단거리를 구한다.

다익스트라의 확장된 알고리즘으론 A* 알고리즘이 있다.

 

알고리즘 작동 과정

이 그래프에서 우리는 A지점에서 출발 한다고 하자.

A에서 출발해 인접한 노드들로 갈 것이다. (B, C)

현재 우리는 B,C의 최단거리를 알고 있다.(B = 5, C = 1)

그 다음 A에서 C로 간다면, C의 인접한 노드들의 최단거리를 갱신하게되며, A에서B로가는 최단거리는 A - C - B = 3 이 된다.

-> A에 있을땐 B와의 최단거리는 5로 알고있지만, C로 가보았다면 B의 최단거리는 3이 됨을 알 수 있다.

이렇게 인접한 노드들의 거리를 이용해, 현재 최단거리의정보를 계속 갱신해나가는 것이다.

 

1. 출발점을 지정한다.

2. 출발노드와 인접한 노드들의 최단거리를 갱신한다.

3. 인접한 노드들 중 거리가 짧은 노드를 선택한다.

4. 선택한노드와 인접한 노드의 거리로 최단거리 정보를 갱신한다.

5. 3-4 반복

 

 

A지점에서는 B와 C로 갈 수 있고, D, E는 갈 수 없다.

이를 표로 나타내면 다음 과 같다. 이 표는 현재 최단거리의 정보를 저장한 표다.

A B C D E
0 5 1 INF INF

 

 

그 다음노드는 인접한 노드들중 가장 가까운 거리를 먼저 간다.

 

C에 도착했다면 현재 저장된 최단거리와 C와 인접한 노드들의 거리를 계산하여 갱신한다.

B: C까지의 최단거리 + (C-B) = 3

D: C까지의 최단거리 + (C-D) = 5 

A B C D E
0 3 1 5 INF

 

 

그 다음 방문하지 않은 노드중 가장 비용이 적은 노드를 고른다.

B를 방문했을때, 인접한노드들과 최단거리정보와 비교한다.

D: B까지의 최단거리 + (B-D): 4

E: B까지의 최단거리 + (B-E): 7

A B C D E
0 3 1 4 7

 

그다음 방문하지 않은 노드중 비용이 적은 노드를 방문한다.

D를 방문했을때, 인접한노드 E의 최단거리를 갱신한다.

E: D까지의 최단거리 + E = 4 + 2 = 6

A B C D E
0 3 1 4 6

 

마지막 남은 노드를 방문한다.

해당 노드를 방문해도 최단거리 배열은 같다.

A B C D E
0 3 1 4 6

 

 

다익스트라 알고리즘은 가중치가 가장 낮은 인접 노드를 우선으로 탐색하지만,

선형탐색으로 가중치와 관계없이 인접한 노드를 탐색하는 방법 도 있다.

하지만 선형탐색으로 탐색한다면 O(N^2)가 걸리고, Heap구조를 이용해 구현한다면 O(NlogN)만큼 걸리게된다.

선형탐색은 노드 수가 많을수록 치명적이다.

 

선형탐색 O(N^2)

import Foundation

// 그래프 2차원배열 graph[1][2] -> 노드1과 노드2의 가중치, 10000000은 임의의 큰 값 (연결되지않음)
var graph = [
    [0, 5, 1, 10000000, 10000000],
    [5, 0, 2, 1, 4],
    [1, 2, 0, 4, 10000000],
    [10000000, 1, 4, 0, 2],
    [10000000, 4, 10000000, 2, 0]
]
// 출발점 지정
var needVisit = [(cur: 0, cost: 0)]

// 현재 최단거리를 저장할 배열
// ex) minDistances[1]: 출발점에서 노드1까지의 최단거리
var minDistances = Array(repeating: 10000000, count: graph.count)
minDistances[0] = 0

// 탐색
while !needVisit.isEmpty {
    let node = needVisit.removeFirst()
    // 노드와 인접한 노드들을 탐색한다.
    for i in 0..<graph[node.cur].count {
        let dist = graph[node.cur][i]
        // 노드와 연결되어 있고, 현재 저장된 최단거리 + 해당노드와의 거리가 더 작은값이라면 갱신
        // 갱신된 노드와 최단거리와 함께 큐에 재삽입
        if dist != 10000000 && node.cost + dist < minDistances[i] {
            minDistances[i] = node.cost + dist
            needVisit.append((cur: i, cost: minDistances[i]))
        }
    }
}

 

결과:

더보기
// 결과
// A와의 인접노드: B,C
selectedNode: (cur: 0, cost: 0)
before: [0, 10000000, 10000000, 10000000, 10000000]
after: [0, 5, 1, 10000000, 10000000]
needVisit: [(cur: 1, cost: 5), (cur: 2, cost: 1)]

// B와의 인접노드: A,C,D,E
selectedNode: (cur: 1, cost: 5)
before: [0, 5, 1, 10000000, 10000000]
after: [0, 5, 1, 6, 9]
needVisit: [(cur: 2, cost: 1), (cur: 3, cost: 6), (cur: 4, cost: 9)]

// C와의 인접노드: A,B,D
// 여기서 B의 최단거리가 C를 통해 가는게 더 작은값임을 알고, 갱신하고 B를 재탐색하기위해 큐에 삽입)
// D도 갱신되어 큐에 삽입
selectedNode: (cur: 2, cost: 1)
before: [0, 5, 1, 6, 9]
after: [0, 3, 1, 5, 9]
needVisit: [(cur: 3, cost: 6), (cur: 4, cost: 9), (cur: 1, cost: 3), (cur: 3, cost: 5)]

// D와의 인접노드: B,C,E
// E의최단거리 갱신되어, E를 큐에 삽입
selectedNode: (cur: 3, cost: 6)
before: [0, 3, 1, 5, 9]
after: [0, 3, 1, 5, 8]
needVisit: [(cur: 4, cost: 9), (cur: 1, cost: 3), (cur: 3, cost: 5), (cur: 4, cost: 8)]

// E와의 인접노드: B,D
// 갱신되지않음.
selectedNode: (cur: 4, cost: 9)
before: [0, 3, 1, 5, 8]
after: [0, 3, 1, 5, 8]
needVisit: [(cur: 1, cost: 3), (cur: 3, cost: 5), (cur: 4, cost: 8)]

// 아래부턴 갱신된 최단거리를 통해 다시 노드를 선택하여 인접한 노드와의 최단거리를 갱신한다.
selectedNode: (cur: 1, cost: 3)
before: [0, 3, 1, 5, 8]
after: [0, 3, 1, 4, 7]
needVisit: [(cur: 3, cost: 5), (cur: 4, cost: 8), (cur: 3, cost: 4), (cur: 4, cost: 7)]

selectedNode: (cur: 3, cost: 5)
before: [0, 3, 1, 4, 7]
after: [0, 3, 1, 4, 7]
needVisit: [(cur: 4, cost: 8), (cur: 3, cost: 4), (cur: 4, cost: 7)]

selectedNode: (cur: 4, cost: 8)
before: [0, 3, 1, 4, 7]
after: [0, 3, 1, 4, 7]
needVisit: [(cur: 3, cost: 4), (cur: 4, cost: 7)]
// E 갱신, 갱신된 최단거리와 함께 큐에 재삽입
selectedNode: (cur: 3, cost: 4)
before: [0, 3, 1, 4, 7]
after: [0, 3, 1, 4, 6]
needVisit: [(cur: 4, cost: 7), (cur: 4, cost: 6)]

selectedNode: (cur: 4, cost: 7)
before: [0, 3, 1, 4, 6]
after: [0, 3, 1, 4, 6]
needVisit: [(cur: 4, cost: 6)]

selectedNode: (cur: 4, cost: 6)
before: [0, 3, 1, 4, 6]
after: [0, 3, 1, 4, 6]
needVisit: []

Reference:

https://m.blog.naver.com/ndb796/221234424646

투포인터 알고리즘

- 리스트에 순차적으로 접근할 때 두 원소의 위치를 기록하는 알고리즘

- 일반적으로 정렬된 리스트나 배열에서 사용한다.

- 보통 정해진 두 위치(첫번째: start, 마지막:end)는 start <= end를 만족한다.

 

 

투포인터 단계

- 배열또는 리스트에 두개의 위치를 지정한다(보통 맨 앞 위치 start, 맨 뒤 위치 end)

- 두 포인터 사이의 구간을 확인하고 조건과 비교한다.

- 해당 조건을 만족한다면, 추가로 위치를 움직이거나, 결과를 리턴한다.

- 해당 조건을 만족하지 않는다면, 위치를 움직여 재탐색후 조건과 비교한다.

 

시간복잡도: 포인터를 배열이나 리스트의크기(N)만큼 증가시키므로 O(N)

 

예시: BOJ-2470 두 용액

import Foundation

// 입력받기
let N = Int(readLine()!)!
var arr = readLine()!.split(separator: " ").map{Int(String($0))!}
var result = Int.max
var resultArr = [Int]()
var start = 0
var end = arr.count - 1
arr.sort(by: <)

// 왼쪽인덱스 start, 오른쪽인덱스 end가 서로 만날때 까지
while start < end {
    let leftNum = arr[start]
    let rightNum = arr[end]
    let sum = leftNum + rightNum
    
    // 더한값이 양수라면 오른쪽인덱스를 왼쪽으로 움직여 오른쪽 원소값을 더 작게만든다
    // 더한값이 음수라면 왼쪽인덱스를 오른쪽으로 움직여 왼쪽 원소값을 더 크게만든다.
    if sum > 0 {
        end -= 1
    } else if sum < 0 {
        start += 1
    } else {
        resultArr = [leftNum, rightNum]
        break
    }
    // 더한값의 절댓값이 이전에 구한 값보다 작아지면 갱신
    if abs(sum) < result {
        result = abs(sum)
        resultArr = [leftNum, rightNum]
    }
    
}
print(resultArr.map{String($0)}.joined(separator: " "))

 

순열 Permutation

서로다른 n개의 원소에서 r개를 중복 없이 순서와 상관있게 선택 혹은 나열하는것이다.

 

ex) [1, 2, 3]의 배열에서 두개의 원소를 뽑는다고 한다면

[1,2], [1,3], [2,1], [2,3], [3,1], [3,2]이 된다.

여기서 [1,2] 와 [2,1]은 들어있는 원소는 같지만, 순서가 다르기때문에 다른 경우의수로 판단한다.

 

import Foundation

// 순열
func permutation(_ targetArr: [Int],_ targetNum: Int,_ arr: [Int]) {
    // 뽑으려는 갯수와 동일한경우
    if arr.count == targetNum {
        print(arr)
        return
    }
    // 순열은 순서가 상관있는 뽑기방식으로, [2,1] != [1,2]이다.
    // 그러므로 반복문을 처음부터 시작한다.
    for i in 0..<targetArr.count {
        // 중복되는값이 아니라면 추가한다.
        if !arr.contains(targetArr[i]) {
            permutation(targetArr, targetNum, arr + [targetArr[i]])
        }
    }
}

permutation([1,2,3,4], 2, [])

// 출력
//[1, 2]
//[1, 3]
//[1, 4]
//[2, 1]
//[2, 3]
//[2, 4]
//[3, 1]
//[3, 2]
//[3, 4]
//[4, 1]
//[4, 2]
//[4, 3]

 

조합 Combination

서로다른 n개의 원소에서 r개를 중복없이 순서와 상관없게 선택 혹은 나열하는것이다.

 

ex) [1, 2, 3]의 배열에서 두개의 원소를 뽑는다고 한다면

[1,2], [1,3], [2,3]이 된다.

순열과 다르게 순서와 상관이 없으므로 [1,2], [2,1]은 같은 경우의수로 판단한다

 

import Foundation

// 조합
func combi(_ targetArr: [Int],_ targetNum: Int,_ index: Int,_ arr: [Int]) {
    // 뽑으려는 수만큼 뽑았다면 출력
    if arr.count == targetNum {
        print(arr)
        return
    }
    // 순열과 다르게 순서가 달라도 들어있는 원소가 같다면 같은 경우의 수로 판단한다.
    // 그래서 뽑은 원소 바로 다음 원소를 추가하고 재귀호출
    for i in index..<targetArr.count {
        combi(targetArr, targetNum, i+1, arr + [targetArr[i]])
    }
}

combi([1,2,3,4], 2, 0, [])

// 출력
//[1, 2]
//[1, 3]
//[1, 4]
//[2, 3]
//[2, 4]
//[3, 4]

 

 

※ 틀린 부분이나 오류나는 부분 알려주시면 감사하겠습니다 (_ _)

출처:https://namu.wiki/w/%EB%84%88%EB%B9%84%20%EC%9A%B0%EC%84%A0%20%ED%83%90%EC%83%89

깊이 우선 탐색(DFS)

- 루트노드에서 시작하여 다음 분기로 넘어가기전 해당 분기를 완벽하게 탐색하는 방법.

- 해당 분기를 모두 탐색한 후, 이 분기는 이미 탐색함을 기록하고 다음분기로 넘어감

- 백트래킹으로 이용한다.

- 스택 또는 재귀함수로 구현한다.

-> 모든 노드를 방문하고자 할때 주로 사용함

-> BFS에 비해 상대적으로 검색속도가 느림

-> 구현은 DFS가 더 간단하다.

 

너비 우선 탐색(BFS)

- 루트노드 혹은 임의노드부터 시작하여 인접한 가까운 노드부터 탐색하고 마지막으로 가장 먼 노드를 탐색하는 방법

- 주로 노드사이의 최단거리를 계산할때 사용한다.

- 큐로 구현한다.

 

ex) 지구상의 모든 관계를 그래프로 표현했을때, 철수와 영희와의 관계를 찾는다면

깊이 우선탐색: 모든 관계를 탐색하며 찾는다.

너비 우선탐색: 철수의 가까운 관계부터 찾는다.

 

DFS와 BFS의 시간복잡도

모든 노드를 탐색한다는점에서 DFS와 BFS의 시간복잡도는 동일하다.

구현할때의 이용한 자료구조에 따라서 전체 시간복잡도는 다르게 될 수 있다.

N은 노드, E는 걸리는 시간 이라 한다면

인접리스트: O(N+E)

인접행렬: O(N^2)

 

DFS와 BFS의 활용유형

1. 그래프의 모든 접점을 방문해야 할 때

- 이는 둘다 비슷한 성능을 내므로 알맞은 자료구조로 시간을 줄여서 구현한다.

 

2. 경로의 특징을 저장해야할 때

- 백트래킹과 같은 노드를 탐색했을때, 조건에 맞지않아서 이를 기록하고 더이상 노드탐색을 하지않을때나 탐색하는데 있어서 여러가지 특징이나 조건을 저장하고 탐색할때 DFS를 이용한다.

 

3. 최단거리를 구할 때

- 미로찾기와 같은 최단거리를 구할 때 BFS를 이용하면 좋다.

- DFS를 이용한다면 모든 노드를 방문하게 될 수 있기때문이다.

 

DFS 구현 Swift

// 소스코드 출처: 개발자 소들이 https://babbab2.tistory.com/107

func dfs(graph: [String: [String]], start: String) -> [String]{
    // 이미 방문한 배열
    var visitedQueue = [String]()
    // 방문해야하는 배열
    var needVisitQueue = [start]
    
    // DFS
    // 분기에 가장 깊숙한 곳까지 탐색하기 때문에 removeLast()
    while !needVisitQueue.isEmpty {
        let node = needVisitQueue.removeLast()
        if visitedQueue.contains(node) { continue }
        
        visitedQueue.append(node)
        needVisitQueue += graph[node] ?? []
    }
    return visitedQueue
}

 

BFS 구현 Swift

// 소스코드 출처: 개발자 소들이 https://babbab2.tistory.com/106

func bfs(graph: [String: [String]], start: String) -> [String]{
    // 이미 방문한 배열
    var visitedQueue = [String]()
    // 방문해야하는 배열
    var needVisitQueue = [start]
    
    // BFS
    // 분기에 가장 인접한 곳 부터 탐색하기 때문에 removeFirst()
    while !needVisitQueue.isEmpty {
        let node = needVisitQueue.removeFirst()
        if visitedQueue.contains(node) { continue }
        
        visitedQueue.append(node)
        needVisitQueue += graph[node] ?? []
    }
    return visitedQueue
}

 

 

Reference:

https://velog.io/@lucky-korma/DFS-BFS%EC%9D%98-%EC%84%A4%EB%AA%85-%EC%B0%A8%EC%9D%B4%EC%A0%90

 

DFS, BFS의 설명, 차이점

그래프란, 정점(node)과 그 정점을 연결하는 간선(edge)으로 이루어진 자료구조의 일종을 말하며,그래프를 탐색한다는 것은 하나의 정점으로부터 시작하여 차례대로 모든 정점들을 한 번씩 방문하

velog.io

https://devuna.tistory.com/32

 

[알고리즘] 깊이 우선 탐색(DFS) 과 너비 우선 탐색(BFS)

[알고리즘] 깊이 우선 탐색(DFS)과 너비 우선 탐색(BFS) 그래프를 탐색하는 방법에는 크게 깊이 우선 탐색(DFS)과 너비 우선 탐색(BFS)이 있습니다. 📌여기서 그래프란, 정점(node)과 그 정점을 연결하

devuna.tistory.com

https://babbab2.tistory.com/107

 

Swift) 깊이 우선 탐색(DFS) 구현 해보기

안녕하세요 :) 소들입니다!!! 이번 포스팅에선 깊이 우선 탐색(DFS)에 대해 알아보려고 해요!!!! 깊이 우선 탐색이란, 너비 우선 탐색(BFS)과 마찬가지로 그래프를 탐색하는 방법 중 하나인데, 그래

babbab2.tistory.com

 

'코딩테스트 > 알고리즘' 카테고리의 다른 글

투포인터 알고리즘 Swift  (0) 2024.04.14
순열과 조합 Swift  (0) 2024.04.10
이분탐색 Swift  (1) 2024.04.06
동적계획법 DP(Dynamic Programming)  (0) 2023.04.20
최대공약수와 최소공배수 Swift  (0) 2023.04.07

이분탐색

유명한 이분탐색 이미지다.

위와 같이 완전탐색과 다르게 이분탐색은 훨씬 빠르게 데이터를 찾을 수 있다.

 

-> 이분탐색은 정렬된 리스트안에서 탐색범위를 절반씩 좁혀가며 데이터를 찾는 방식이다.

-> 배열의 내부가 정렬되어있어야 사용가능하다.

-> 세가지 변수 (start, end, target) 을 사용하며, 반복적 또는 재귀호출로 데이터를 찾는다.

재귀 호출을 이용한 이분탐색

import Foundation
// 이분탐색
func binarySearch(arr: [Int], start: Int, end: Int, target: Int) -> Int {
	// startIndex 가 endIndex를 넘으면 종료
    if start > end { return -1 }
    let mid = (start + end) / 2
    
    if arr[mid] == target {
        return mid		// 타겟을 찾으면 종료
    } else if arr[mid] > target {
    	// 중간에 위치한 값이 타겟보다 크면 값을 낮추기위해, endIndex를 옮긴다.
        return binarySearch(arr: arr, start: start, end: mid-1, target: target)
    }else {
        // 중간에 위치한 값이 타겟보다 작으면 값을 높이기위해, startIndex를 옮긴다.
        return binarySearch(arr: arr, start: mid+1, end: end, target: target)
    }
}

var a = [1, 3, 4, 5, 7, 8]
print(binarySearch(arr: a, start: 0, end: a.endIndex - 1, target: 3))
// 1

 

반복문을 이용한 이분탐색

import Foundation

func binarySearch(arr: [Int], start: Int, end: Int, target: Int) -> Int {
    var result = -1
    var startIndex = start
    var endIndex = end
    // 이분 탐색
    while startIndex <= endIndex {
        let midIndex = (startIndex + endIndex) / 2
        // 타겟을 찾으면 탈출
        if arr[midIndex] == target {
            result = midIndex
            break
        } else if arr[midIndex] > target {
            // 타겟보다 큰경우 endIndex를 줄여 mid를 작게한다
            endIndex = midIndex - 1
        } else {
            // 타겟보다 작은경우 startIndex를 늘려 mid를 크게갖는다
            startIndex = midIndex + 1
        }
    }
    return result
}

var a = [1, 3, 4, 5, 7, 8]
print(binarySearch(arr: a, start: 0, end: a.endIndex - 1, target: 8))
// 5

 

위 두가지 방법은 모두 "중복이 없는, 정렬된(오름,내림)" 리스트에서 원하는 데이터를 찾은것이다.

동적계획법

- 큰 문제의 답을 얻기 위해, 작은 문제로 나누어 답을 저장하고 재활용한다.

- 분할정복 알고리즘과 비슷하지만, 작은문제로 나누어 해결하고 답을 재활용하며 반복해나간다는 점에서 차이가 있다.

- DP는 나누어진 모든 작은문제들을 한번씩 풀고 그 답을 저장해놓는다. 그다음 큰 문제를 위해 저장해둔 작은 문제를 활용하여 풀어나간다.

- DP를 이용하려면, 작은 문제가 반복적으로 일어나야한다.

 

Memoization

- 동일한 계산을 반복하는경우, 한번 계산한 결과를 메모리에 저장해 두었다가 필요할때 꺼내쓰는 기법이다.

- 일반적으로 반복계산을 했을때 메모리 할당의 중복 비용을 줄여주고, 계산에 소요되는 시간 비용을 줄여준다.

- DP의 핵심기법

 

DP 의 예시) 피보나치수열

피보나치 수열 fib(n) = fib(n-1) + fib(n-2)을 일반적으로 계산했을때, 재귀함수를 호출하여 계산한다.

import Foundation

func fibonacci(_ num: Int) -> Int {
    if num < 2 {
        return num
    }
    return fibonacci(num - 1) + fibonacci(num - 2)
}

fib(4)

= fib(3) + fib(2)

= fib(2) + fib(1) + fib(1) + fib(0)

= fib(1) + fib(0) + fib(1) + fib(1) + fib(0)

---> fib(4)를 구하기 위해 fib함수 5번 수행되었다.

이와같이 일반적으로 계산하는 경우 fib(n)의 n이 커질수록 계산횟수는 기하급수적으로 증가하게된다. 시간복잡도 O(n^2)

 

DP의 Memoization기법을 이용하면 시간복잡도를 줄일 수 있다.

import Foundation

func fibonacci(_ num: Int) -> Int {
    var dp = Array(repeating: 0, count: num+1)
    if num == 1 || num == 2 {
        return 1
    }
    for i in 1...num {
        if i < 3 {
            dp[i] = 1
            continue
        }
        dp[i] = dp[i-1] + dp[i-2]
    }
    return dp[num]
}

예로 fib(5) 를 구하기위해

dp[3] = dp[2] + dp[1] = fib(3) 을 저장후 dp[4]를 구할 때 재사용

dp[4] = dp[3] + dp[2] = fib(4) 도 저장후 dp[5]를 구할 때 재사용

dp[5] = dp[4] + dp[3] = fib(5)

 

위의 방법은 for문 한번만 돌기때문에 시간복잡도는 O(N) 이다.

재귀함수 호출하는 일반적인 계산방법보다 더 빠르게 계산한다.

 

DP는

1. 겹치는 동일한 작은 문제들의 반복성

2. 작은 문제들의 최적의 결과를 이용해 전체 결과를 낼 수 있는지

 

문제에서 DP를 이용 할 수 있는지 파악하는게 젤 어렵긴하다.

1. 큰 문제를 반복되는 작은 문제로 나눌 수 있는지

2. 점화식을 구현할 수 있는지 ( 1번이랑 비슷함)

3. 결과값을 최신화 하며 해결해나가면 결국 문제의 답이 나오는지..

 

 

-------------------------------------------------------

틀린부분과 추가할 부분이 있다면 댓글 남겨주시면 감사합니다~

 

 

글을 쓸때 참고한곳:

더보기

+ Recent posts