팩토리얼, 지수시간에 비해 빠르지만 입력 크기가 커질수록 처리 시간이 급격히 증가하는 알고리즘 범주에 속한다.

이 복잡도는 이중 반복문이 포함된 구조에서 자주 나타나며, 작은 입력에선 문제없지만, 큰 입력에서는 비효율적인 성능을 보인다.

대표적으로 정렬 알고리즘 중 버블 정렬, 삽입 정렬, 선택 정렬이 이에 해당하며, 브루트포스 방식의 문제 해결에서도 볼 수 있다.

 

버블 정렬

오름차순 기준

 

배열의 인접한 두 원소를 검사하고 정렬한다.

bubble sort

 

길이가 n인 배열이 있다.

전체 루프 i는 첫 번째부터 마지막 요소 앞 n-1까지 진행한다.

내부 루프 j는 인접한 두 요소를 비교하고 더 큰 수인 경우 교환을 진행하여 n - 1 - i까지 진행한다.

루프마다 가장 큰 요소가 순서대로 끝에 배치된다.

 

def bubble_sort(arr):
    n = len(arr)

    # 총 n-1회 반복
    for i in range(n - 1):
        # 정렬된 요소를 제외한 범위에서 반복
        for j in range(n - 1 - i):
            # 인접한 요소 비교 후 순서가 잘못되면 교환
            if arr[j] > arr[j + 1]:
                arr[j], arr[j + 1] = arr[j + 1], arr[j]

data = [5, 2, 4, 3, 1]
print("정렬 전:", data)
bubble_sort(data)
print("정렬 후:", data)

"""
정렬 전: [5, 2, 4, 3, 1]
정렬 후: [1, 2, 3, 4, 5]
"""

 

모든 요소를 반복적으로 비교 및 교환한다. 어떤 경우든 i 반복은 무조건 n-1번을 수행하게 되며 이미 정렬된 배열인 경우에도 불필요한 반복이 진행되게 된다. 이러한 점을 개선해 스왑 발생 여부를 확인하여 정렬이 된 상태인 경우 조기 종료를 시킬 수 있다.

 

def bubble_sort_optimized(arr):
    n = len(arr)

    for i in range(n - 1):
        swapped = False

        for j in range(n - 1 - i):
            if arr[j] > arr[j + 1]:
                arr[j], arr[j + 1] = arr[j + 1], arr[j]
                swapped = True

        # 스왑이 한 번도 없으면 이미 정렬된 상태
        if not swapped:
            break

data = [1, 2, 3, 4, 5]
print("정렬 전:", data)
bubble_sort(data)
print("정렬 후:", data)

 

최악이나 평균의 경우에는 모든 요소를 끝까지 비교하고 교환해야 하므로 시간 복잡도는 O(n^2)이다. 그러나 이미 정렬된 배열인 최선의 경우에는 비교만 수행되고 스왑이 한 번도 발생하지 않기 때문에 조기에 종료되어, 최적화된 구현에서는 O(n)의 시간 복잡도를 가질 수 있다.

 

선택정렬

매 반복마다 가장 작은 값을 찾아 맨 앞과 교환하는 방식이다. 정렬된 부분과 미정렬 부분을 구분하고, 매 단계마다 최솟값을 선택한다. 교환 횟수는 최대 n-1회이지만 비교는 항상 일정하게 많이 발생하기 때문에 최선/최악 구분 없이 O(n^2)이다.

 

select sort

 

비교 방식이 데이터 정렬 여부와 무관하다.

 

def selection_sort(arr):
    """
    선택 정렬 함수
    현재 위치 이후의 요소 중 최솟값을 찾아 현재 위치와 교환
    """
    n = len(arr)

    for i in range(n - 1):
        min_index = i  # 현재 위치를 최솟값 인덱스로 가정

        # i 이후 요소들 중에서 최솟값 인덱스 탐색
        for j in range(i + 1, n):
            if arr[j] < arr[min_index]:
                min_index = j

        # 현재 위치와 최솟값 위치를 교환
        if min_index != i:
            arr[i], arr[min_index] = arr[min_index], arr[i]

data = [5, 2, 4, 3, 1]
print("정렬 전:", data)
selection_sort(data)
print("정렬 후:", data)

"""
정렬 전: [5, 2, 4, 3, 1]
정렬 후: [1, 2, 3, 4, 5]
"""

 

구조가 단순하고, 교환 횟수가 적기 때문에 메모리 이동 비용이 큰 환경에 적합하지만 비교 횟수가 많고 입력이 정렬되어 있어도 시간 복잡도가 항상 O(n^2)인 단점이 있어 대규모 데이터에는 부적합하다.

 

삽입 정렬

삽입 정렬은 배열의 두 번째 요소부터 시작하여, 현재 요소를 그 앞에 있는 요소들과 비교하면서 적절한 위치를 찾아 삽입하는 방식이다. 비교 과정에서는, 앞의 요소가 현재 요소보다 크면 한 칸씩 뒤로 이동시키고, 더 작은 값을 만나거나 배열의 처음까지 도달하면 그 자리에 현재 요소를 삽입한다. 이 과정을 반복하며 배열의 왼쪽 부분부터 정렬된 상태가 된다.

insert sort

 

def insertion_sort(arr):
    """
    삽입 정렬 함수
    배열의 각 요소를 순차적으로 '정렬된 부분'에 삽입하여 정렬하는 알고리즘.
    """
    n = len(arr)
    
    # 두 번째 요소부터 시작: 첫 번째 요소는 이미 '정렬된 부분'으로 가정
    for i in range(1, n):
        key = arr[i]        # 현재 정렬할 값
        j = i - 1           # key 앞의 마지막 인덱스

        # key가 정렬된 부분의 값보다 작으면, 한 칸씩 오른쪽으로 이동시켜 자리 비움
        while j >= 0 and arr[j] > key:
            arr[j + 1] = arr[j]
            j -= 1

        # 최종적으로 key를 알맞은 위치에 배치
        arr[j + 1] = key

data = [5, 2, 4, 3, 1]
print("정렬 전:", data)
insertion_sort(data)
print("정렬 후:", data)

"""
정렬 전: [5, 2, 4, 3, 1]
정렬 후: [1, 2, 3, 4, 5]
"""

 

 

정렬된 배열일 경우 비교만 하고 이동이 거의 없어 O(n)에 가까운 성능을 보인다.

구현이 간단하며, 안정 정렬이기 때문에 동일한 값을 가진 요소들의 상대적 순서가 유지된다. 추가적인 배열 없이 입력 배열 내부에서만 정렬 수행을 하기 때문에 메모리에 효율적이지만 전체가 정렬되지 않은 경우 비교, 이동이 많아져 시간 복잡도는 O(n^2)가 된다.

 

728x90
반응형

'Coding > Algorithm' 카테고리의 다른 글

O(2^n) - 지수 시간  (1) 2025.07.12
NP - TSP  (1) 2025.07.11
P, NP  (0) 2025.07.08
O(n!) - 팩토리얼 시간  (2) 2025.07.08
빅-오(Big-O) 표기법  (1) 2025.07.08

O(2^n) - 지수 시간

O(n!) 시간 복잡도처럼, 지수 시간 복잡도 또한 입력 크기가 조금만 커져도 실행 시간이 급격히 증가하는 대표적인 비효율적인 복잡도이다. 특히, 모든 가능한 경우의 수를 탐색해야 하는 문제에서 자주 나타난다.

 

부분 집합 생성

집합 [1, 2, 3]이 있을 때, 모든 부분 집합의 개수는 2^3 = 8개이다.

 

def subsets(arr):
    if not arr:
        return [[]]

    first = arr[0]
    rest_subsets = subsets(arr[1:])

    # 기존 부분집합들 + first를 포함한 새로운 부분집합들
    return rest_subsets + [[first] + subset for subset in rest_subsets]

print(subsets([1,2,3]))

"""
[[], [3], [2], [2, 3], [1], [1, 3], [1, 2], [1, 2, 3]]
"""

 

1. 재귀를 사용해서 주어진 리스트의 모든 가능한 부분 집합을 생성한다.

재귀 호출의 종료 조건은 입력 리스트가 비어있어 더 이상 처리할 요소가 없으면 빈 리스트를 반환하여 공집합이 모든 집합의 부분집합이 되는 규칙을 반영한다.

 

2. 현재 리스트에서 첫 번째 요소를 분리하고, 나머지 요소를 처리하는 하위 문제로 분할한다.

첫 번째 요소를 제외한 나머지 리스트의 모든 부분집합을 재귀적으로 구한다.

 

3. 재귀적으로 얻은 나머지 요소들의 부분집합을 기반으로 최종 부분집합들을 조합한다.

first를 포함하지 않는 부분집합과 포함하는 부분집합 두 그룹을 반환한다.

 

 

subsets([1, 2, 3])

├── first = 1

├── rest_subsets = subsets([2, 3])

│          ├── first = 2

│          ├── rest_subsets = subsets([3])

│           │       ├── first = 3

│           │       ├── rest_subsets = subsets([]) → [[]]

│           │       └── return [[], [3]]

│           └── return [[], [3], [2], [2, 3]]

└── return [[], [3], [2], [2, 3], [1], [1, 3], [1, 2], [1, 2, 3]]

 

- n개 원소의 부분집합 개수 2^n개

- 각 원소마다 포함 또는 비포함 2가지 선택

- 재귀 호출 2^n번 발생

 

부분집합을 구하는 방식은 비트마스킹을 사용하여 재귀 없이 반복문과 비트 연산으로 더 효율적으로 생성할 수 있다.

 

def subsets_bitmask(arr):
    n = len(arr)
    result = []
    
    for i in range(2**n):  # 0부터 2^n-1까지
        subset = []
        for j in range(n):
            if i & (1 << j):  # j번째 비트가 1이면
                subset.append(arr[j])
        result.append(subset)
    
    return result
# 시간복잡도: O(2^n * n) — 모든 부분집합을 순회하며 최대 n개의 요소를 확인
print(subsets_bitmask([1, 2, 3]))

"""
[[], [1], [2], [1, 2], [3], [1, 3], [2, 3], [1, 2, 3]]
"""

 

피보나치수열(비효율적 재귀)

 피보나치수열은 첫째 및 둘째 항은 1로 고정되고 그 뒤의 모든 항은 바로 앞 두 항의 합으로 이루어진 수열이다.

입력을 받아서 해당 순서에 들어오는 수를 출력하는 코드를 만들어 본다.

 

def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

# 호출 예시
print(fibonacci(5)) 

"""
5
"""

 

시간 복잡도는 O(2^n)이며 재귀호출로 인한 중복 호출이 많이 발생하게 된다.

n = 5일 때, 

finbonacci(3) : 2번

finbonacci(2) : 3번

finbonacci(1) : 5번

finbonacci(0) : 3번

 

이러한 중복을 대처하기 위해 효율적인 대안으로 동적 계획법이나 메모이제이션으로 O(n)으로 개선이 가능하다.

 

조합 탐색 (DFS/백트래킹 기반)

n개의 원소 중에서 순서에 상관없이 R개를 뽑는 모든 조합을 탐색한다.

- DFS : 깊이 우선 탐색

- 백트래킹 : 모든 경우의 수를 탐색하면서 가능성이 없는 분기는 중단하고 되돌아가는 기법

 

def dfs(arr, path, start, r):
    if len(path) == r:
        print(path)
        return
    for i in range(start, len(arr)):
        dfs(arr, path + [arr[i]], i + 1, r)

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

"""
[1, 2]
[1, 3]
[1, 4]
[2, 3]
[2, 4]
[3, 4]
"""

 

주어진 배열(arr)에서 r개의 원소를 선택하는 모든 조합을 찾는다.

선택한 원소보다 뒤에 있는 원소들만 탐색하여 중복 선택을 방지하고 순서를 고려하지 않는 조합의 특성을 구한다.

 

조합의 수는 C(n, r)로 최대 O(2^n)이 된다. 백트래킹의 경우 조건에 따라 일부 경로는 생략 가능해서 효율은 개선되지만 최악의 경우는 여전히 O(2^n)이다.

 

동적 계획법

Top-Down

큰 문제를 작은 하위 문제로 쪼개면서 재귀적으로 호출한다.

이미 계산한 하위 문제의 결과는 메모이제이션을 통해 저장하여 중복 호출을 방지한다.

재귀 호출 중에 처음 계산되는 하위 문제만 실제 계산되고, 이후에는 저장된 값을 재사용한다.

 

def fibonacci_memo_top_down(n, memo=None):
    # memo는 계산된 값을 저장하기 위한 딕셔너리 (기본값은 None으로 시작)
    if memo is None:
        memo = {}

    if n in memo:
        return memo[n]  # 이미 계산된 값이 있으면 바로 반환 (중복 계산 방지)

    if n <= 1:
        return n  # 기저 사례: 피보나치(0) = 0, 피보나치(1) = 1

    # 아직 계산되지 않은 경우 재귀적으로 계산
    memo[n] = fibonacci_memo(n - 1, memo) + fibonacci_memo(n - 2, memo)
    return memo[n]  # 계산된 값을 반환
    
print(fibonacci_memo_top_down(10))
# 55

 

시간 복잡도 O(n), 코드가 간결하지만 메모리 사용량이 많을 수 있으며 재귀 한도가 있어 너무 큰 입력에는 스택 오버플로우 위험이 있다.

 

Bottom-Up

가장 작은 하위 문제부터 차례대로 해결하면서 결과를 쌓아 올린다.

반복문을 이용해 누적적으로 해결하여 스택 사용 없이 처리한다.

 

def fibonacci_dp_bottom_up(n):
    # 피보나치 수열의 n번째 수를 구하기 위한 동적 계획법 (Bottom-Up 방식) 구현

    if n <= 1:
        return n  # n이 0 또는 1일 경우, 그대로 반환 (기저 사례)

    dp = [0] * (n + 1)  # 0부터 n까지의 값을 저장할 DP 테이블 생성 (n+1 크기)
    dp[1] = 1  # 초기 조건 설정: 피보나치(0) = 0, 피보나치(1) = 1

    # 점화식에 따라 2부터 n까지 순차적으로 계산
    for i in range(2, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]  # 이전 두 항을 더한 값을 현재 위치에 저장

    return dp[n]  # 최종적으로 n번째 피보나치 수를 반환
    
 print(fibonacci_dp_bottom_up(10))

 

테이블을 통해 모든 값을 저장한다. 시간 복잡도 O(n)을 가지며 재귀 호출이 없어 스택 오버플로우 걱정이 없으며 공간복잡도를 O(n)에서 O(1)로 개선 가능하여 메모리 최적화가 가능하다. 하지만 DP 테이블을 모두 만들어야 하기 때문에 필요 없는 값도 계산하게 되기도 한다.

 

공간 복잡도 개선

def fibonacci_optimized(n):
    # 피보나치(0) = 0, 피보나치(1) = 1 처리
    if n <= 1:
        return n

    # 이전 두 항만 저장 (dp 테이블 전체 필요 없음)
    prev2 = 0  # 피보나치(n-2)
    prev1 = 1  # 피보나치(n-1)

    for i in range(2, n + 1):
        curr = prev1 + prev2  # 현재 피보나치 수
        prev2 = prev1         # 피보나치(n-2) ← 피보나치(n-1)
        prev1 = curr          # 피보나치(n-1) ← 현재 피보나치 수

    return prev1  # n번째 피보나치 수

 

일반적인 bottom-up 방식은 dp = [0] * (n+1)로 전체 수열을 저장하므로 공간복잡도는 O(n)이다. 하지만 피보나치수열의 다른 특징은 항을 계산할 때 오직 이전 두 항만 사용하기 때문에 dp 배열 없이 prev1, prev2 두 변수만 사용해도 충분하다. 불필요한 dp 배열을 제거하게 되면 공간 복잡도는 O(1)로 줄어들게 된다.

728x90
반응형

'Coding > Algorithm' 카테고리의 다른 글

O(n^2) - 이차 시간  (1) 2025.07.12
NP - TSP  (1) 2025.07.11
P, NP  (0) 2025.07.08
O(n!) - 팩토리얼 시간  (2) 2025.07.08
빅-오(Big-O) 표기법  (1) 2025.07.08

외판원 문제(TSP)

NP는 정답을 빠르게 검증할 수 있는 문제 집합이다. 즉, 어떤 해답이 주어졌을 때, 그것이 맞는지 다항 시간 내에 확인 가능한 문제들을 말한다. 

 

NP-complete는 NP 문제 중 가장 어려운 문제들로, NP에 속하면서 모든 NP 문제로부터 다항 시간 내에 환원이 가능한 문제이다. 

 

NP-hard는 모든 NP-complete 이상의 난이도를 포함하는 상위 개념이다. 이 집합은 NP를 포함하지 않을 수도 있으며, 검증조차 다항 시간 내에 불가능할 수 있는 문제까지 포함한다.

 

P ⊆ NP  
NP-complete ⊆ NP  
NP-complete ⊆ NP-hard  

 

TSP는 하나의 경로가 주어졌을 때, 그 경로가 총얼마의 비용을 갖는지 계산하는 데는 O(n)으로 정답을 빠르게 검증하는 게 가능하기 때문에 NP 문제에 속한다.

 

최적 경로를 찾기 위한 모든 경우를 탐색하면 (n-1)!개의 경로가 생기고, 이는 O(n!) 시간 복잡도로, 도시 수가 조금만 늘어나도 계산이 불가능한 수준으로 커지게 된다. 현재까지는 다항 시간 내에 최적해를 찾는 알고리즘은 발견되지 않았기 때문에 TSP의 최적화 문제(정답을 찾는 문제)는 NP-hard이다.

 

TSP의 최단 경로를 묻는 최적화가 문제가 아닌 "총 경로 비용이 K 이하인 경로가 존재하는가?"처럼 결정 문제로 형태를 바꾸면 예/아니오로 답할 수 있고, 다항 시간 검증/환원이 가능한 NP-complete 문제가 된다.

 

즉, 본질적으로는 NP-hard 한 최적화 문제지만, 이를 결정 문제로 변환하면 NP-complete 문제로 볼 수 있다. 실제 적용에서는 정확한 해 대신, 근사 알고리즘이나 동적 계획법을 활용해 현실적인 해를 구하는 방향으로 발전하였다.

 

Held-Karp 알고리즘

작은 규모의 문제에 대해 최적해를 찾는 효율적인 방법을 제공하는 TSP를 해결하기 위한 동적 계획법 기반의 알고리즘이다. 알고리즘의 핵심은 문제를 더 작은 하위 문제로 분할하고, 이 하위 문제들의 해를 저장하여 중복 계산을 피하는 것으로 이때 비트 마스킹을 사용하여 효율적으로 표현한다.

 

문제 풀이의 구조

하위 문제 정의

C(S, j) : 시작 도시(0)에서 출발해 집합 S(0 포함, j 포함)에 속한 도시를 한 번씩 방문, 마지막 도시가 j일 때의 최소 비용 

 

- S: 방문한 도시 집합 (도시 0은 항상 포함)

- j ∈ S, j ≠ 0

 

비트마스킹으로 상태 표현

방문한 도시의 집합 S를 정수로 표현한다

예) s =  0b1011 (0, 1, 3번 도시를 방문한 상태)

 

알고리즘 구성

기저 사례 (Base Case)

더 이상 쪼갤 수 없는 최소 단위 하위 문제이다.

 

두 도시만 방문한 경우(0에서 j로 바로 가는 비용) : C({0, j}, j) = cost(0, j) 

 

점화식 (Recurrence)

작은 하위 문제를 통해 큰 문제를 푸는 공식이다.

 

C(S, j) = min { C(S - {j}, i) + cost(i, j) } (단, i ∈ S - {j})

 

도시 j에 도착하기 직전 도시 i를 고려해, i까지 최소 비용 + i -> j 비용을 더한 값을 구한다.

 

계산 순서

S의 크기를 2 → n까지 증가시키며 모든 C(S, j) 계산한다.

각 상태는 비트마스킹으로 구현한다.

 

최종 결과

모든 도시(V) 방문 후 0으로 돌아오는 최소 경로를 구한다.

 

min { C(V, j) + cost(j, 0) } (단, j ≠ 0)

 

def tsp_dp(dist):
    n = len(dist)
    
    # dp[visited][curr]:
    # 시작 도시 0에서 출발, visited(비트마스킹) 상태의 도시 방문 && 현재 위치 curr일 때 최소 비용
    # 2^n * n -> O(n * 2^n)
    dp = [[float('inf')] * n for _ in range(1 << n)]
    dp[1][0] = 0 # Base Case : 시작 도시(0) 방문 상태

    for visited in range(1 << n): # 총 2^n개의 visited 상태
        for curr in range(n):     # 현재 위치한 도시 (j)
            if not (visited & (1 << curr)):
                continue
            for next in range(n):
                if visited & (1 << next):
                    continue
                next_visited = visited | (1 << next)
                
               	# C(S ∪ {next}, next) = min(C(S, curr) + cost(curr, next))
                dp[next_visited][next] = min(
                    dp[next_visited][next],
                    dp[visited][curr] + dist[curr][next]
                ) # O(n^2 * 2^n)
    
    # 모든 도시 방문 후, 다시 0으로 돌아오는 비용 중 최솟값
    # min(C(V, j) + cost(j, 0))  (j != 0)
    return min(dp[(1 << n) - 1][i] + dist[i][0] for i in range(1, n))

dist = [
    [0, 10, 15, 20],
    [10, 0, 35, 25],
    [15, 35, 0, 30],
    [20, 25, 30, 0]
]

result = tsp_dp(dist)
print(f"최소 이동 비용: {result}")

"""
최소 이동 비용: 80
"""

 

시간 복잡도는 개선되어 O(m^2 * 2^n)이 되지만 여전히 도시 수가 증가했을 때 지수적으로 증가하므로 큰 입력엔 부적합하다.

 

근사 알고리즘

도시 수가 증가하면 계산량이 급격히 증가하는 문제를 해결하기 위한 방법으로, 정확한 최적해는 아니지만 계산 가능한 시간 내에 효율적인 경로를 찾는 방법이다.

 

최소 신장 트리 (Minimum Spanning Tree, MST) 알고리즘

MST는 가중치가 있는 무방향 그래프에서 모든 정점을 연결하면서, 간선 가중치의 총합이 최소가 되는 트리를 말한다.

TSP는 출발점에서 모든 정점을 한 번씩 방문하고, 다시 출발점으로 돌아오는 사이클(순환 경로)을 찾는 문제이지만, MST는 사이클이 없는 트리이다. 하지만 이 MST를 활용하여 다음과 같은 방식으로 TSP 근사 경로를 만들 수 있다.

 

1. 완전 그래프 구성

풀고자 하는 TSP 문제의 원래 그래프가 주어지면, 모든 도시 간의 최단 거리를 간선 가중치로 하는 완전 그래프를 구성한다.

이는 특히 삼각 부등식을 만족하는 Metric TSP에서 유용하다.

 

*  Metric TSP?

- 모든 정점 쌍이 연결되어 있으며, 거리 함수가 삼각 부등식을 만족하는 TSP를 의미한다.

- distance(A, C) ≤ distance(A, B) + distance(B, C)

- 이러한 조건을 만족할 경우, 근사 알고리즘의 성능 보장이 가능하다.

 

2. MST 구성

완전 그래프에 대해 MST를 구성한다. 보통 프림(Prim) 알고리즘이 사용되며, 전체 간선 가중치의 합이 최소가 되도록 트리를 구성한다.

 

3. MST 전위 순회 (Preorder Traversal)

MST를 DFS 방식으로 전위 순회하여 방문 순서를 얻는다. 이 순서는 중복 방문이 가능하며, 이후 중복된 정점을 생략하고 한 번씩만 방문하는 경로로 정제한다. 이를 통해 해밀턴 순환 경로에 근사한 경로를 얻는다.


* 해밀턴 순환 경로

- 해밀턴 경로 : 그래프 내의 모든 정점을 정확히 한 번씩만 방문하는 경로, 시작점과 도착점이 다를 수 있다.(비순환)

- 해밀턴 순환 경로는 해밀턴 경로의 조건에서 시작점으로 다시 돌아오는 순환 경로를 추가한다.

 

4. 쇼트컷 적용 및 최종 경로 구성

방문 순서 중 이미 방문한 정점은 건너뛰고 다음 미방문 정점으로 이동하는 쇼트컷(shortcut)을 적용한다. 이를 통해 중복 없이 정점을 한 번씩만 방문하는 해밀턴 순환 경로가 완성되며, 마지막에 시작 도시로 돌아와 TSP의 근사해를 얻는다.

 

프림 (Prim) 알고리즘

최소 신장 트리를 찾을 때는 대표적으로 프림 알고리즘을 사용한다. 이 알고리즘은 그래프의 한 정점에서 시작해서, MST 바깥에 있는 정점 중 현재 MST에 있는 정점과 가장 가중치가 낮은 간선으로 연결된 정점을 하나씩 추가하면서 MST를 만들어 나가는 방식이다. 모든 정점을 연결하는 트리를 사이클 없이 만들고, 총 간선 가중치의 합이 최소가 되도록 구성한다.

 

1. 정점 중 하나를 시작 정점으로 선택하고 MST에 포함시킨다.

2. 현재 MST에 포함된 정점들과 연결된 간선들 중에서, MST 바깥에 있는 정점으로 연결되며 가중치가 가장 낮은 간선을 선택한다.

3. 선택한 간선의 반대쪽 정점을 MST에 새롭게 추가하고, 해당 간선도 MST에 포함시킨다.

4. 모든 정점이 MST에 포함될 때까지 반복한다.

 

import heapq

# 1. Prim 알고리즘으로 MST 구성
def prim_mst(graph):
    n = len(graph)
    visited = [False] * n
    mst = [[] for _ in range(n)]  # MST를 인접 리스트로 표현
    min_heap = [(0, 0)]  # (가중치, 정점)
    
    while min_heap:
        weight, u = heapq.heappop(min_heap)
        if visited[u]:
            continue
        visited[u] = True
        for v in range(n):
            if not visited[v] and u != v:
                heapq.heappush(min_heap, (graph[u][v], v))
                mst[u].append(v)
                mst[v].append(u)  # 무방향 MST
    return mst

# 2. DFS로 MST 순회하여 TSP 경로 획득
def dfs_path(tree, node, visited, path):
    visited[node] = True
    path.append(node)
    for neighbor in tree[node]:
        if not visited[neighbor]:
            dfs_path(tree, neighbor, visited, path)

# 3. 근사 TSP 실행
def tsp_mst_approx(graph):
    mst = prim_mst(graph)  # MST 생성
    path = []
    visited = [False] * len(graph)
    dfs_path(mst, 0, visited, path)  # DFS로 순회 경로 생성
    path.append(0)  # 출발 도시로 복귀
    total_cost = sum(graph[path[i]][path[i+1]] for i in range(len(path)-1))
    return total_cost, path

# 테스트용 그래프 (대칭 행렬, 삼각 부등식 만족)
graph = [
    [0, 10, 15, 20],
    [10, 0, 35, 25],
    [15, 35, 0, 30],
    [20, 25, 30, 0]
]

cost, path = tsp_mst_approx(graph)
print(f"근사 비용: {cost}")
print(f"근사 경로: {path}")

"""
근사 비용: 95
근사 경로: [0, 1, 2, 3, 0]
"""

 

 

이 알고리즘은 Metric TSP (삼각 부등식 만족) 조건에서만 유효하며, 항상 최적해의 2배 이내의 근사 결과를 보장하는 2-근사 알고리즘(2-approximation)이다.

 

 

최근접 이웃 (Nearest Neighbor) 알고리즘

출발점에서 시작해서 가장 가까운 도시부터 방문해 나가는 방식이다. 정점을 한 번씩만 방문하고, 모든 도시를 돌고 나면 다시 출발점으로 돌아간다. 

 

1. 시작 정점을 임의로 선택

2. 현재 정점에서 가장 가까운 미방문 정점으로 이동

3. 모든 정점을 한 번씩 방문할 때까지 반복

4. 마지막 정점에서 시작 정점으로 돌아와 순환 경로 완성

 

def tsp_nearest_neighbor(graph, start=0):
    n = len(graph)
    visited = [False] * n
    path = [start]
    total_cost = 0
    current = start
    visited[current] = True

    for _ in range(n - 1):
        nearest = None
        min_dist = float('inf')
        for i in range(n):
            if not visited[i] and graph[current][i] < min_dist:
                nearest = i
                min_dist = graph[current][i]
        visited[nearest] = True
        path.append(nearest)
        total_cost += min_dist
        current = nearest

    # 돌아오는 거리 추가
    total_cost += graph[current][start]
    path.append(start)
    return total_cost, path

# 테스트용 대칭 거리 행렬
graph = [
    [0, 10, 15, 20],
    [10, 0, 35, 25],
    [15, 35, 0, 30],
    [20, 25, 30, 0]
]

cost, path = tsp_nearest_neighbor(graph)
print(f"근사 비용: {cost}")
print(f"근사 경로: {path}")

"""
근사 비용: 80
근사 경로: [0, 1, 3, 2, 0]
"""

 

(한 번의 탐색마다 O(n) 거리 계산) * (n개 도시) = O(n^2)의 시간복잡도를 가진다.

 

구현이 간단하고 빠르고 직관적이지만, 초기 시작점에 따라 결과가 달라지며 지역 최적 해에 빠지기 쉽다. 최악의 경우 낮은 수준의 근사율을 가질 수 있어 최적해보다 빠른 근사해가 필요한 시스템에 활용되거나 대규모 TSP문제 등에 활용하거나 최적해를 개선하기 위한 방식들로 개선한다.

 

1. Multi-Start Nearest Neighbor

- n개의 정점을 모두 출발점으로 시도, 최적 경로 선택

- 시간복잡도 O(n^3)까지 증가하지만 품질이 향상된다.

 

2. 2-opt / 3-opt 알고리즘

- 최근접 이웃으로 얻은 경로에서 두 정점 간 경로를 교환하여 더 짧은 경로 탐색

- 지역 최적해 탈출 가능

 

3. 다른 휴리스틱과 결합

- MST 기반 알고리즘, Christofiedes 알고리즘 등과 비교 및 결합 가능

728x90
반응형

'Coding > Algorithm' 카테고리의 다른 글

O(n^2) - 이차 시간  (1) 2025.07.12
O(2^n) - 지수 시간  (1) 2025.07.12
P, NP  (0) 2025.07.08
O(n!) - 팩토리얼 시간  (2) 2025.07.08
빅-오(Big-O) 표기법  (1) 2025.07.08

Polynomial time

다항 시간

다항 시간이란 알고리즘의 실행 시간이 입력 크기 n에 대한 다항식으로 표현되는 경우를 말합니다. 이는 O(n^k ) (k는 양의 상수)와 같은 형태로 나타나는 시간 복잡도를 의미한다.

 

O(n), O(n²), O(n³ + n² + 3), O(log n), O(1) 등은 모두 다항 시간으로 분류되는데 O(log n), O(1)의 경우 엄밀히 말하면 다항식은 아니지만 다항 시간보다 훨씬 효율적이므로 넓은 의미에서 다항 시간 범주에 포함된다.

 

반면 O(2ⁿ), O(n!)과 같은 복잡도는 다항 n이 커질수록 기하급수적으로 실행시간이 폭증하여 다항 시간이 아니며, 이런 알고리즘은 대개 비실용적으로 간주한다.

 

어떤 문제에 대해 다항 시간 내에 항상 정답을 찾을 수 있는 알고리즘이 존재한다면, 그 문제는 Class P에 속한다. 

NP

비결정적 다항 시간(Nondeterministic Polynomial time)이란, 어떤 문제에 대해 정답이 주어졌을 때, 그 정답이 올바른지 다항 시간 내에 검증이 가능한 문제들을 말한다.

 

즉, 해를 찾는 과정은 어려울 수 있지만, 해가 주어졌을 때 그 해를 확인하는 것은 쉽고 빠르다.

 

예를 들어 아주 큰 숫자를 소인수분해하는 것은 시간이 오래 걸리는 어려운 문제이다. 하지만 누군가 소인수의 정답을 알려준다면, 그 답들을 곱해서 원래 숫자가 나오는지 확인하기만 하면 되며 이 곱셈 연산은 다항 시간 안에 완료할 수 있으므로 이런 문제를 NP에 속한다고 한다.

 

NP-complete

NP에 속하는 문제들 중에서 가장 어려운 문제들의 집합이다. 

- NP에 속한다 : 정답이 주어졌을 때 다항 시간 내에 검증이 가능하다.

- NP-환원성 : NP에 속하는 모든 다른 문제들을 다항 시간 내에 NP-complete 문제로 변환할 수 있다.

 

NP-환원성은 핵심적인 특징으로 이는 NP-complete 문제 중 하나라도 다항 시간 내에 해결할 수 있는 알고리즘을 찾는다면, 이론적으로 NP에 속하는 모든 문제도 다항 시간 내에 해결할 수 있게 된다는 의미를 가진다.

 

예) SAT 만족 가능성 문제, 3-CNF SAT, TSP(결정형), 그래프 색칠 문제

 

NP-hard

NP-complete보다 더 넓은 개념으로, NP의 모든 문제만큼 어렵거나 그 이상일 수 있는 문제들이다. NP-complete와 유사하게 NP에 속하는 모든 문제를 다항 시간 내에 자기 자신으로 환원할 수 있어야 하지만 반드시 NP에 속할 필요는 없다. 즉, 해가 주어져도 그 검증조차 다항 시간 안에 불가능할 수 있다. 주로 최적값 찾기 문제나 결정 불가능한 문제들이 NP-hard에 속한다.

 

예) TSP(최적 경로 길이 구하기), 체스 문제, 할터 문제(Halting Problem)

 

┌─── P (다항 시간에 풀 수 있는 문제들)

├─── NP (정답 검증이 다항 시간인 문제들)
│          │

│          └─ NP-complete (NP 중 가장 어려운 문제들)

└─── NP-hard (NP보다 어려울 수도 있는 문제들)
            │

            └─ NP-complete ⊆ NP-hard

 

 

P vs NP

밀레니엄 문제 중 하나로, P와 NP 사이의 관계는 아직도 해결되지 못한 문제이다.

 

- P : 다항 시간 내에 문제를 풀 수 있는 문제들의 집합이다.

- NP : 정답이 주어졌을 때, 그 정답이 다항 시간 내에 검증될 수 있는 문제들의 집합이다.

 

다항 시간 안에 해를 찾을 수 있는 문제라면 당연히 그 해가 맞는지도 다항 시간 안에 검증할 수 있기 때문에 P ⊆ NP는 항상 참이다. 진정한 문제는 P = NP인가 P ≠ NP인가에 대한 질문으로, 정답을 빠르게 검증할 수 있다면 답을 빠르게 구할 수도 있는가?이다.

728x90
반응형

'Coding > Algorithm' 카테고리의 다른 글

O(2^n) - 지수 시간  (1) 2025.07.12
NP - TSP  (1) 2025.07.11
O(n!) - 팩토리얼 시간  (2) 2025.07.08
빅-오(Big-O) 표기법  (1) 2025.07.08
시간 복잡도  (1) 2025.07.08

O(n!) - 팩토리얼 시간

주어진 배열의 원소들을 가능한 모든 순서로 배열하는 것을 순열 생성의 모든 경우의 수이다. 여기서 순열은 일반적인 순열인 직순열로 일직선상의 배열이다.

 

이러한 연산에서 n개 원소는 n! 개의 순열이 존재하고, 각 순열을 생성하는 데 걸리는 시간을 포함한 전체 시간 복잡도는 n! * n = O(n!)이다.

 

순열 생성

배열 [1, 2, 3]가 있을 때, 가능한 모든 순열은 다음과 같다.

 

[1, 2, 3], [1, 3, 2],

[2, 1, 3], [2, 3, 1],

[3, 1, 2], [3, 2, 1]

 

총 3! = 6가지 경우가 존재한다.

 

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

def permutations(arr):
    if len(arr) <= 1:
        return [arr]
    result = []
    for i in range(len(arr)):
        # ':' 슬라이싱, :끝 : 처음부터 끝 인덱스 바로 전까지(끝 비포함), 시작: : 시작 인덱스부터 끝까지
        # => :i i인덱스를 제외한 그 앞의 집합, i+1: 다음 인덱스까지의 집합, 길이 초과하는 경우 빈 배열
        rest = arr[:i] + arr[i+1:] 
        for p in permutations(rest): # 
            result.append([arr[i]] + p) # append : 리스트 객체 맨 끝에 새로운 요소 추가
    return result

print(permutations([1, 2, 3]))

"""
[[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
"""


1. 하나의 인덱스를 고정한다.

2. 나머지 요소들의 순열을 재귀적으로 구한다.

3. 그 앞에 고정 요소를 붙여서 순열을 구성한다.

 

이 방식은 재귀 호출마다 n-1개의 원소로 순열을 생성하며, 전체 시간 복잡도는 O(n!)이다.

 

외판원 문제(TSP, Travelling Salesman Problem)

외판원이 n개의 도시를 모두 방문하고 출발점으로 돌아오는 최단 경로를 찾는 문제이다.

NP-hard 문제의 대표적인 예시로 자주 사용된다.

 

조건

- 각 도시는 정확히 한 번씩만 방문한다.

- 모든 도시 간의 거리가 주어진다.

 

이 문제는 다양한 해결법이 존재하지만 그중에서 브루트포스 접근법이 O(n!)의 시간 복잡도를 가진다.

 

브루트포스

4개 도시 A, B, C, D가 있을 때, 가능한 모든 경로를 확인한다.

A -> B -> C -> D -> A

A -> B -> D -> C -> A

...

 

다시 출발점으로 돌아오기 때문에 원순열로 취급할 수 있어 시작점을 A로 고정하고 모든 경로를 구한다.

나머지 n-1개 도시의 순열을 구한다. (n-1)!

4개 도시 -> (4 - 1)! = 6가지

 

import itertools

def tsp_bruteforce(distance):
    # distance : 2D 배열, distance[i][j] : 도시 i에서 j로의 거리
    n = len(distance)
    cities = list(range(1, n)) # 시작 도시(0번)를 고정한 상태에서 나머지 도시의 순열을 생성

    min_cost = float('inf')
    best_path = None

	# itertools.permutations : 순열을 생성해준다.
    # 내부적으로 최적화가 잘되어있어 메모리 효율이 좋다. 시간복잡도는 그대로 O(n!)
    for path in itertools.permutations(cities):
        current_cost = 0
        current_city =0

        for next_city in path:
            current_cost += distance[current_city][next_city]
            current_city = next_city

        current_cost += distance[current_city][0]

        if current_cost < min_cost:
            min_cost = current_cost
            best_path = [0] + list(path) + [0]

    return min_cost, best_path

distance = [
    [0, 10, 15, 20],
    [10, 0, 35, 25],
    [15, 35, 0, 30],
    [20, 25, 30, 0]
]

city_names = ["A", "B", "C", "D"]

cost, path = tsp_bruteforce(distance)
print(f"Min cost : {cost}")
print(f"Best path : {path}")

named_path = ' -> '.join(city_names[i] for i in path)
print(f"Best path : {named_path}")

"""
Min cost : 80
Best path : [0, 1, 3, 2, 0]
Best path : A -> B -> D -> C -> A
"""

 

순열생성

O((n-1)!), n-1개 도시의 모든 순열 생성 

 

각 경로 비용 계산

O(n), 각 순열마다 n개 도시를 거쳐가는 비용 계산

 

전체 시간복잡도

O(n * (n-1)!) = O(n!)

 

브루트포스의 가장 큰 문제점은 도시의 수가 조금만 늘어나도 실행 시간이 급격하게 증가하여 계산이 불가능해진다.

 

계산 시간 테스트 코드

도시의 개수를 추가하고 경과시간을 체크해 본다.

import time
import itertools
import random

def generate_distance_matrix(n):
    matrix = [[0]*n for _ in range(n)]
    for i in range(n):
        for j in range(i+1, n):
            dist = random.randint(1, 99)
            matrix[i][j] = dist
            matrix[j][i] = dist
    return matrix

def tsp_bruteforce(distance):
    n = len(distance)
    cities = list(range(1, n))

    min_cost = float('inf')
    best_path = None

    for path in itertools.permutations(cities):
        current_cost = 0
        current_city = 0

        for next_city in path:
            current_cost += distance[current_city][next_city]
            current_city = next_city

        current_cost += distance[current_city][0]

        if current_cost < min_cost:
            min_cost = current_cost
            best_path = [0] + list(path) + [0]

    return min_cost, best_path

# 테스트 도시 수: 4 ~ 10
for n in range(4, 11):
    print(f"\n=== TSP for {n} cities ===")
    distance = generate_distance_matrix(n)

    start = time.perf_counter()
    cost, path = tsp_bruteforce(distance)
    end = time.perf_counter()
    elapsed = end - start

    print(f"Min cost: {cost}")
    print(f"Best path (index): {path}")
    print(f"Elapsed time: {elapsed:.6f} seconds")

"""
=== TSP for 4 cities ===
Min cost: 209
Best path (index): [0, 1, 2, 3, 0]
Elapsed time: 0.000019 seconds

=== TSP for 5 cities ===
Min cost: 167
Best path (index): [0, 2, 1, 3, 4, 0]
Elapsed time: 0.000019 seconds

=== TSP for 6 cities ===
Min cost: 159
Best path (index): [0, 3, 1, 2, 5, 4, 0]
Elapsed time: 0.000077 seconds

=== TSP for 7 cities ===
Min cost: 186
Best path (index): [0, 5, 2, 1, 4, 3, 6, 0]
Elapsed time: 0.000502 seconds

=== TSP for 8 cities ===
Min cost: 161
Best path (index): [0, 3, 1, 2, 4, 6, 7, 5, 0]
Elapsed time: 0.019747 seconds

=== TSP for 9 cities ===
Min cost: 172
Best path (index): [0, 4, 5, 8, 2, 7, 3, 1, 6, 0]
Elapsed time: 0.157354 seconds

=== TSP for 10 cities ===
Min cost: 236
Best path (index): [0, 2, 3, 6, 1, 9, 4, 5, 8, 7, 0]
Elapsed time: 1.502671 seconds
"""

 

10개까지의 테스트는 실행이 되었지만 테스트를 위해 사용한 웹 컴파일러는 15초 이상의 작업은 실패로 처리하는데 11개의 도시 테스트에서 실패로 처리되었다.

 

수치를 추정해 보면,

10개의 도시인 경우 10! = 3,628,800개의 경우의 수를 처리하는데 1.5초의 소요 시간이 걸렸다.

11개의 도시는 11! = 39,916,800개로 약 11배가 증가했으므로 추정 소요 시간은 16.5초로 예상된다.

도시 수 순열 수 예상 시간 (초)
10 3,628,800 1.5
11 39,916,800 16.5
12 479,001,600 198+ 초 (약 3.3분)
13 6,227,020,800 약 27~30분
14 87,178,291,200 5시간 이상
 
사용법은 간단하지만 한계가 명확하기 때문에 실용적이지 않아 다른 효율적인 방법으로 대체된다.
 
동적 계획법 (O(n^2 * 2^n))
정확한 해를 구할 수 있는 방법으로, 방문 상태를 비트마스크로 표현하여 중복 계산을 줄인다.
 
근사 알고리즘(다항시간)
최적 해는 아니지만, 일정한 오차 범위 내에서 빠르게 좋은 해를 보장한다.
대표적으로 MST 기반 알고리즘이 있다.
 
휴리스틱 기법
엄밀한 최적 보장은 없지만, 실제 환경에서 유용하게 사용된다. 
유전 알고리즘(GA)이나 시뮬레이티드 어닐링(SA)들이 이에 해당한다.

 

728x90
반응형

'Coding > Algorithm' 카테고리의 다른 글

O(2^n) - 지수 시간  (1) 2025.07.12
NP - TSP  (1) 2025.07.11
P, NP  (0) 2025.07.08
빅-오(Big-O) 표기법  (1) 2025.07.08
시간 복잡도  (1) 2025.07.08

빅-오(Big-O) 표기법

빅-오(Big-O) 표기법은 알고리즘이 입력 크기(n)에 따라 수행 시간이나 연산 횟수가 어떻게 증가하는지를 나타내는 수학적 표기법이다. 보통 최악의 경우(Worst Case) 기준으로 시간 복잡도를 분석하며, 다양한 알고리즘의 효율성을 비교하는 데 활용된다.

 

https://en.wikipedia.org/wiki/File:Comparison_computational_complexity.svg

 

시간 복잡도는 입력 크기에 따른 실행 시간의 증가율에 따라 여러 등급으로 나뉘며 다음은 효율이 낮은 순서부터 정리한 목록이다.

O(n!) - 팩토리얼 시간

가능한 모든 순열을 탐색하는 경우

외판원 문제(TSP), 순열 생성 등

 

O(2^n) - 지수 시간

모든 부분 조합이나 경우의 수를 탐색할 때

부분집합 생성, 피보나치(재귀), DFS/백트래킹 등

 

O(n^2) - 이차 시간

이중 루프를 사용하는 비교 기반 알고리즘

버블 정렬, 선택 정렬 등

 

O(n log n) - 선형 로그 시간

효율적인 비교 기반 정렬 알고리즘의 평균/최선 시간

병합 정렬, 힙 정렬, 퀵 정렬(평균적 경우)

 

O(n) - 선형 시간

입력 전체를 한 번 순회하는 경우

배열을 전체 탐색

 

O(log n) - 로그 시간

입력이 절반씩 줄어드는 알고리즘

이진 탐색, 힘 삽입/삭제 등

 

O(1) - 상수 시간

입력 크기와 무관하게 항상 일정한 시간

배열 인덱스 접근, 해시맵 키 조회

 

 

 

 

 

 

 

728x90
반응형

'Coding > Algorithm' 카테고리의 다른 글

O(2^n) - 지수 시간  (1) 2025.07.12
NP - TSP  (1) 2025.07.11
P, NP  (0) 2025.07.08
O(n!) - 팩토리얼 시간  (2) 2025.07.08
시간 복잡도  (1) 2025.07.08

시간 복잡도(Time Complexity)

시간 복잡도는 알고리즘이 입력 코기(n)에 따라 얼마나 많은 연산을 수행하는지를 수학적으로 표현한 것이다.

보통 알고리즘의 효율성과 실행 속도를 평가하기 위해 사용된다.

 

시간 복잡도는 주로 세 가지 표기법으로 분류된다.

 

빅-오(Big-O)

최악의 경우(Worst Case)를 나타내며, 가장 많이 사용되는 표기법이다. 알고리즘이 가장 오래 걸리는 상황에서의 연산 횟수를 설명한다.

 

빅-오메가(Big-Ω)

최선의 경우(Best Case) 를 나타내며, 가장 적게 걸리는 상황에서의 연산 횟수를 설명한다.

 

빅-세타(Big-Θ)

상한과 하한이 같을 때 사용되며, 정확한 수행 시간을 나타낸다.

 

예시

다음은 1~100 사이에서 무작위 숫자를 뽑고, 해당 숫자를 찾을 때까지 순회하는 알고리즘이다.

 

import random

randNumber = random.randrange(1, 101)

for i in range(1, 101):
    if i == randNumber:
        print(i)
        break

 

Best Case

뽑은 숫자가 1일 경우, 단 1번만에 찾는다 → Ω(1)

 

Average Case

1 일 때 1번

2 일 때 2번

...

n 일 때 n번

 

전체 평균 횟수를 계산하면 1 + 2 + ... + n으로 등차수열을 계산하면 n(n + 1) / 2, 이는 입력 크기 n이 커질수록 선형적으로 증가하므로, Average Case는 O(n)으로 표현한다.

 

Worst Case

뽑은 숫자가 100일 경우, 100번 순회 → O(n)

728x90
반응형

'Coding > Algorithm' 카테고리의 다른 글

O(2^n) - 지수 시간  (1) 2025.07.12
NP - TSP  (1) 2025.07.11
P, NP  (0) 2025.07.08
O(n!) - 팩토리얼 시간  (2) 2025.07.08
빅-오(Big-O) 표기법  (1) 2025.07.08

A* 알고리즘

주어진 출발점에서 목표지점까지 가는 최단 경로를 찾아내는 그래프 탐색 알고리즘 중 하나이다.

최단 경로를 찾는다는 공통점을 가지지만, A*는 데이크스트라에 휴리스틱(Heuristic)이라는 개념을 추가하여 효율성을 높인 알고리즘이다. 본래 A 알고리즘이라고 불렸지만 적절한 휴리스틱에 따라서 최적의 알고리즘이 되기 때문에 A* 알고리즘이라 불린다.

 

데이크스트라와 관계

A*알고리즘은 데이크스트라 알고리즘의 확장 버전이라고 볼 수 있다. 데이크스트라에서 사용하는 시작점으로부터의 실제 거리 g(n)에 목표점까지의 예상 거리 h(n)라는 휴리스틱을 더한 값을 우선순위 기준으로 사용한다.

 

f(n) = g(n) + h(n)

 

g(n) : 시작점에서 현재 노드 n까지의 실제 비용

h(n) : 현재 노드 n에서 목표 노드 goal까지의 휴리스틱 비용(예상 비용)

f(n) : 시작점에서 n을 거쳐 goal까지 가는 총 예상 비용

 

휴리스틱 h(n)이 핵심 역할을 하며, 이 값이 얼마나 목표에 가깝게 추정하느냐에 따라서 A*의 효율성이 결정된다.

 

휴리스틱

가장 대표적으로 사용되는 두 가지 방법으로 맨해튼 거리(Manhattan Distance)와 유클리드 거리(Euclidean Distance)가 있다.

두 가지 방법 모두 특히 격자 기반의 길 찾기 문제나 2D/3D 공간에서의 최단 경로 탐색에 자주 활용된다.

 

맨해튼 거리(Manhattan Distance)

격자에서 한 점에서 다른 점까지 가로 또는 세로로만 이동할 수 있을 때의 최단 거리를 의미한다. 마치 도시의 블록을 따라 이동하는 것과 같다고 하여 맨해튼 거리 또는 택시 거리라고 불린다.

 

계산은 두 점 (x1, y1) , (x2, y2)가 있을 때 두 점 사이의 맨해튼 거리는 |x1 - x2| + |y1 - y2|이다.

 

이동이 수직(상하) 또는 수평(좌우)으로만 가능한 경우와 각 이동에 대한 비용이 모두 동일할 때 적합하다. (미로, 체스, 격자 등)

 

유클리드 거리(Euclidean Distance)

가장 일반적인 직선거리 개념으로 두 점을 잇는 가장 짧은 선의 길이이다.

두 점 (x1, y1) , (x2, y2) 사이의 유클리드 거리는 피타고라스의 정리를 사용하여 계산된다.

 

√ (x1 - x2)^2 + (y1 - y2)^2

 

대각선 이동이 가능한 경우, 이동 비용이 거리에 비례하는 경우에 사용하기 적합하다.

 

허용성

A* 알고리즘에서 휴리스틱 함수가 가져야 할 중요한 속성으로, 최단 경로를 정확하게 찾을 수 있는 여부를 보장하는 기준, 즉 A* 알고리즘의 정확성을 보장하는 핵심 조건이다.

 

허용 가능한 휴리스틱 함수는 어떤 노드에서 목표 노드까지의 예상비용이 실제 최단 거리보다 절대로 크지 않아야 한다.

 

h(n) ≤ 실제 최단 거리

 

h(n) : 현재 노드 n에서 목표 노드까지의 휴리스틱 비용

실제 최단 거리 : 현재 노드 n에서 목표 노드까지의 실제 최단 거리

 

A* 알고리즘이 최단 경로를 항상 찾을 수 있도록 보장하기 때문에 허용 가능한 휴리스틱을 사용하는 것이 중요하다.

 

최단 경로 보장

만약 휴리스틱이 실제 거리보다 과대평가한다면, 때때로 실제 최단 경로가 아닌 다른 경로를 최단 경로로 착각하여 탐색을 조기에 종료할 수 있다. 하지만 허용 가능한 휴리스틱은 항상 실제보다 같거나 작은 값을 예측하므로 항상 실제 최단 경로를 놓치지 않고 탐색하기 된다.

 

효율성과 균형

허용성은 정확성을 보장하지만, 너무 보수적인 휴리스틱은 A*가 데이크스트라처럼 넓은 영역을 탐색하게 만들어 효율성이 떨어질 수 있다. 따라서 이성적인 휴리스틱은 허용 가능하면서도 실제 최단 거리에 최대한 가까운 값을 예측하여, 최단 경로를 보장하면서도 탐색 효율성을 극대화하는 것이다.

 

예) 격자에서 가로/세로 이동만 가능하다고 가정할 때, 맨해튼 거리는 두 점 사이의 실제 최단 거리와 동일하거나 더 짧다. 장애물이 없는 최단 경로는 항상 맨해튼 거리보다 길거나 같을 수 없기 때문에 허용 가능한 휴리스틱이다.

 

예) 두 점 사이의 가장 짧은 직선거리는 유클리드 거리이다. 실제 경로가 장애물 때문에 직선으로 갈 수 없다면, 실제 경로는 유클리드 거리보다 길어질 수밖에 없으며 유클리드 걸리도 실제 최단 거리보다 과대평가하지 않으므로 허용 가능한 휴리스틱이다.

 

A* Manhattan / Euclidean

728x90
반응형

'Computer' 카테고리의 다른 글

Dijkstra algorithm  (0) 2025.05.30
탐색 알고리즘 - BFS/DFS  (0) 2025.05.29
Pathfinding Visualizer - 탐색 알고리즘 시각화 툴  (0) 2025.05.29

데이크스트라(Dijkstra) 알고리즘

데이크스트라 알고리즘은 그래프에서 꼭짓점(노드) 간의 최단 경로를 찾는 알고리즘이다.

기본적으로 단일 출발점(Single-Source)에서 그래프의 다른 모든 꼭짓점까지의 최단 경로를 찾는 데 활용되며, 음의 가중치(negative weights)가 없는 그래프에서만 정확하게 동작한다.

 

https://en.wikipedia.org/wiki/Dijkstra%27s_algorithm

 

 

데이크스트라 기본 형태 (선형 탐색 방식)

데이크스트라 알고리즘의 초기 형태는 우선순위 큐를 사용하지 않고, 일반적인 배열 또는 리스트를 사용하여 구현되었다. 이 방식은 매 단계마다 방문하지 않은 노드 중에서 시작점으로부터의 거리가 가장 짧은 노드를 선형 탐색하여 찾아낸다.

 

시간 복잡도

- 그래프에 V개의 노드가 있을 때, 매번 가장 짧은 노드를 찾기 위해 O(V) 시간이 소요된다.

- 가장 짧은 노드 선택과정을 총 V번 반복하기 때문에, 전체 시간 복잡도는 O(V * V) = O(V^2)이 된다.

 

플로우(일반 배열/리스트 사용)

기본 형태에서는 일반 배열/리스트를 사용하여 구현했는데 이 경우 그래프의 모든 노드를 방문하면서 각 노드를 방문할 때마다 짧은 거리를 찾는 과정이 필요한데 이때 V개의 노드가 있다면 모든 노드를 순회하는 작업이 O(V)의 시간이 걸리고 여기서 매 반복마다 O(V)의 시간이 소요되는 '가장 짧은 거리 노드 선택' 과정이 총 V번 반복되기 때문에 전체 시간 복잡도는 O(V^2)이 된다.

 

 

플로우

dist[] : 각 노드까지의 최단 거리를 저장, infinity 초기화
visited[] : 각 노드를 방문했는지 여부, false 초기화
start_node ( dist = 0 )

 

그래프의 노드 V개를 모두 방문할 때까지 다음 동작을 반복한다.

min_dist = infinity
closest_node = -1;
visited == false 인 노드(index i) 
- dist[i] < min_dist : min_dist = dist[i], closest_node = i

 

선택된 노드는 방문 처리를 하고 visited [closest_node] = true, 이 노드의 최단 거리는 확정됨

만약 closest_node == -1이라면 모든 노드가 방문되었거나 연결되지 않은 경우로 반복을 종료한다.

 

이후 인접 노드 거리를 갱신한다.

closest_node와 연결된 모든 인접 노드에 대해서

visited == false && 
dist[closest_node] + edge_weight(closest_node, neighbor) < dist[neighbor]
(인접 노드를 거치는 것이 현재 저장된 최단 거리보다 더 짧다면 최단 거리를 갱신)

 

모든 반복이 끝나면 최종적으로 dist [] 배열에는 시작 노드로부터 각 노드까지의 최단 거리가 저장된다.

 

우선순위 큐를 활용한 개선된 데이크스트라

데이크스트라 알고리즘의 효율성을 높이기 위해 가장 짧은 거리 노드 선택 과정을 개선한 방식으로 우선순위 큐(Priority Queue)를 사용한다.

 

우선순위 큐는 선입선출 방식인 일반적인 큐와 달리 데이터들이 우선순위를 가지고 있어, 가장 높은(또는 낮은) 우선순위를 가진 데이터가 먼저 나가는 자료구조이다.

 

시간복잡도

- 우선순위 큐는 가장 작은 값 추출(extract_min) 및 값 삽입(insert) 연산을 각각 O(log N) (N은 큐에 있는 원소의 개수)의 시간 복잡도로 수행한다.

- 간선의 개수를 E라고 할 때, 각 간선이 한 번씩 큐에 삽입될 수 있으므로, 전체 시간 복잡도는 O((V+E)logV) 또는 O(ElogV)로 개선된다.

 

플로우

dist[] : 최단 거리 저장 배열, 무한대 초기화
pq(priority_queue) : 우선순위 큐, (거리, 노드) 튜플로 저장, 가장 거리가 짧은 튜플이 항상 최상단에 정렬
시작 노드 : pq <- (0, start_node) 삽입

 

pq에서 거리 값이 가장 작은 튜플을 추출, 짧은 경로가 발견되어 처리되었다면 확정된 노드를 불필요하게 재처리하는 것을 방지하기 위해서 무시하고 다음 반복으로 넘긴다.

current_dist > dist[current_node] continue

 

인접 노드 거리 갱신, 현재 방문 중인 노드와 인접한 노드들에 대해서 해당 간선의 가중치에 대해 반복

dist[current_node] + edge_weight < dist[neighbor]
dist[neighbor] = dist[current_node] + edge_weight
pq <- (dist[neighbor], neighbor) insert

 

current_node를 경유할 때 인접한 노드까지의 거리가 현재 저장된 값보다 더 짧다면 해당 값으로 갱신하고 거리와 노드를 우선순위 큐에 저장한다.

 

pq가 완전히 비워지게 되면 dist [] 배열에는 시작 노드에서 다른 모든 노드까지의 최단거리가 저장된다.

 

Dijkstra

 

728x90
반응형

'Computer' 카테고리의 다른 글

A* Algorithm  (1) 2025.05.30
탐색 알고리즘 - BFS/DFS  (0) 2025.05.29
Pathfinding Visualizer - 탐색 알고리즘 시각화 툴  (0) 2025.05.29

BFS

Breath-First Search, 너비 우선 탐색

트리 자료구조의 순회 방법으로 같은 계층의 모든 노드를 먼저 탐색하고 다음 계층을 탐색하는 방식으로 순차적으로 진행하는 방식이다. 

https://en.wikipedia.org/wiki/Breadth-first_search

 

아직 탐색하지 않은 자식 노드를 추적하고 모든 노드를 탐색하기 위해서 일반적으로 FIFO 방식인 큐를 사용한다.

탐색을 시작할 때 먼저 루트 노드를 큐에 넣는다. 큐에서 가장 앞에 있는 노드를 먼저 탐색하고 그 자식 노드를 모두 큐에 넣는다. 그리고 큐에서 제거를 하면 가장 먼저 넣었던 노드가 빠지게 된다. 그리고 다시 큐의 가장 앞에 있는 노드를 탐색하고 자식을 큐에 넣고 가장 앞의 노드를 제거하는 과정을 큐가 빌 때까지 반복하면 최종적으로 모든 노드를 방문할 수 있게 된다.

 

 

이 방법을 확장하면 사이클 유무, 방향성, 가중치 유무 등에 상관없이 모든 형태를 포함할 수 있는 자유로운 구조로 노드와 간선으로 구성된 구조인 일반 그래프에도 적용할 수 있게 된다. 이때는 부모-자식 관계가 확실한 트리에서와는 달리 중복해서 탐색하는 것을 방지하는 작업이 추가로 필요하다.

 

시각적으로 확인하기 쉬운 이차원 그리드에서 이를 응용하면 특정 셀을 루트 노드로 간주하고 그 인접한 각 셀들을 자식 노드로 치환할 수 있다. 이때 셀들은 중복해서 인접하기 때문에 이를 체크하여 순차적으로 탐색을 이어가면 모든 셀을 중복 없이 탐색할 수 있다.

 

여기서 시작 셀 이외에 또 다른 셀을 도착 지점으로 지정하고 이 셀을 만날 때까지 탐색을 진행하고 각 셀들이 어떤 셀로부터 이어져 온 것인지에 대한 정보를 저장한다면 도착한 셀에서 이전 셀을 타고 올라가면 시작 셀에서부터 도착 셀까지 이어지는 경로를 알 수 있게 된다. 이 과정이 BFS를 응용한 길 찾기 알고리즘이다.

 

 

BFS Pathfinding

 

 

DFS

Depth-First Search, 깊이 우선 탐색

트리 자료구조의 순회 방법으로 한 방향으로 최대한 깊이 탐색한 후, 더 이상 갈 곳이 없으면 이전 노드로 돌아가서 다른 방향을 탐색하는 방식으로 진행한다.

 

https://en.wikipedia.org/wiki/Depth-first_search

 

아직 탐색하지 않은 경로를 추적하고 모든 노드를 탐색하기 위해서 일반적으로 LIFO 방식인 스택을 사용하거나 재귀 함수를 활용한다. 탐색을 시작하면 루트 노드부터 시작해서 자식이 없는 리프 노드가 나올 때까지 스택에 노드를 저장하면서 탐색을 진행한다. 리프노드에 도착하면 스택에서 제거하면서 다시 역으로 거슬러 올라가는 백트래킹 과정을 진행한다. 해당 노드에 아직 탐색하지 않은 다른 자식이 있는지 확인 후 있으면 다시 깊이 탐색을 진행하고 리프에서 다시 역으로 올라가는 작업을 스택이 빌 때까지 반복한다.

 

BFS나 DFS는 트리 구조에 따라서 다르겠지만 BFS는 같은 계층의 모든 노드를 저장하기 때문에 깊이난 얕지만 계층이 넓은 경우 메모리상 불리하고 DFS는 계층이 얇지만 트리가 깊다면 또 불리하기 때문에 구조에 따라 방법을 선택해야 한다.

 

DFS 또한 길 찾기에 적용시킬 수 있는데 BFS는 가장 인접한 노드를 탐색하기 때문에 자연스럽게 최단 경로를 찾게 되는 것과 달리 DFS의 경우에는 최단 경로는 보장하지 않지만 경로 존재 여부나 모든 경로 탐색 등의 경우에 유리하다.

 

DFS Pathfinding

728x90
반응형

'Computer' 카테고리의 다른 글

A* Algorithm  (1) 2025.05.30
Dijkstra algorithm  (0) 2025.05.30
Pathfinding Visualizer - 탐색 알고리즘 시각화 툴  (0) 2025.05.29

+ Recent posts