정보/레벨 0

알고리즘의 시간 복잡도와 공간 복잡도의 개념, 빅오 표기법에 대해 설명해주세요.

밤새는 탐험가89 2024. 9. 24. 05:39

자주 사용되는 정렬 알고리즘(예: 퀵 정렬, 병합 정렬)의 동작 원리와 시간 복잡도를 설명해주세요.

 

정렬 알고리즘

 

정렬 알고리즘은 데이터를 특정 기준에 따라 순서대로 나열하는 방법을 정의합니다. 일반적으로 숫자나 문자열의 오름차순 또는 내림차순으로 정렬합니다. 정렬 알고리즘의 종류는 여러 가지가 있으며, 각각의 동작 방식과 시간 복잡도가 다릅니다.

 

퀵 정렬 (Quick Sort)

 

동작 원리:

  1. 피벗 선택: 배열에서 하나의 요소를 피벗(pivot)으로 선택합니다. 피벗은 보통 첫 번째, 마지막, 중간 요소 중 하나로 선택됩니다.
  2. 분할: 배열을 두 부분으로 나눕니다. 피벗보다 작은 값은 왼쪽 배열에, 큰 값은 오른쪽 배열에 위치하게 합니다. 이 과정에서 피벗은 정렬된 위치에 놓입니다.
  3. 재귀적 정렬: 피벗을 제외한 왼쪽과 오른쪽 배열에 대해 다시 퀵 정렬을 적용합니다. 이 과정을 반복하여 모든 부분 배열이 정렬될 때까지 계속합니다.

피벗의 역할:

  • 피벗은 배열을 분할하는 기준이 됩니다. 적절한 피벗을 선택하는 것이 퀵 정렬의 성능에 큰 영향을 미칩니다. 최악의 경우(예: 이미 정렬된 배열에서 첫 번째 요소를 피벗으로 선택할 경우) 시간 복잡도가 O(n²)로 떨어질 수 있습니다. 하지만 평균적으로는 O(n log n)의 성능을 보입니다.

 

예시

  1. 배열: [3, 6, 8, 10, 1, 2, 1]
  2. 피벗: 3 (첫 번째 요소로 선택)
  3. 분할: [1, 2, 1] (피벗보다 작은 값들), [6, 8, 10] (피벗보다 큰 값들) + 피벗 3
  4. 재귀 호출: [1, 2, 1]과 [6, 8, 10] 각각에 대해 퀵 정렬 수행
  5. 결과: [1, 1, 2, 3, 6, 8, 10]

 

병합 정렬 (Merge Sort)

 

병합 정렬은 다른 정렬 알고리즘과 다르게 분할 정복(Divide and Conquer) 방식을 사용합니다.

 

동작 원리:

  1. 분할: 배열을 반으로 나눕니다. 이 과정을 더 이상 나눌 수 없을 때까지 반복합니다. 즉, 각 부분 배열이 1개 요소가 될 때까지 나눕니다.
  2. 정렬 및 병합: 분할된 각 부분 배열을 정렬하고, 두 개의 정렬된 배열을 비교하면서 하나의 배열로 병합합니다.

시간 복잡도: O(n log n) (항상)

 

 

선택 정렬 (Selection Sort)

 

동작 원리:

  1. 배열에서 가장 작은 요소를 찾습니다.
  2. 이 요소를 배열의 첫 번째 요소와 교환합니다.
  3. 남은 배열에서 다시 가장 작은 요소를 찾아 두 번째 위치와 교환합니다.
  4. 이 과정을 반복하여 전체 배열이 정렬될 때까지 진행합니다.

시간 복잡도: O(n²) (항상)

 

삽입 정렬 (Insertion Sort)

 

동작 원리:

  1. 배열의 두 번째 요소부터 시작하여, 각 요소를 정렬된 부분 배열에 삽입합니다.
  2. 삽입할 위치를 찾아 해당 위치에 요소를 넣고, 나머지 요소들은 오른쪽으로 이동합니다.
  3. 이 과정을 반복합니다.

시간 복잡도: O(n²) (최악의 경우)

 

요약

  • 퀵 정렬: 피벗을 선택해 배열을 분할하고 재귀적으로 정렬.
  • 병합 정렬: 배열을 분할하고 정렬 후 병합.
  • 선택 정렬: 가장 작은 요소를 찾아 위치를 교환.
  • 삽입 정렬: 요소를 정렬된 부분에 삽입.

 

 

 시간 복잡도는 알고리즘의 실행 시간과 입력 크기 간의 관계를 나타내는 지표입니다. 주로 알고리즘의 효율성을 평가하는 데 사용되며, 입력 크기에 따라 알고리즘이 얼마나 빨리 수행되는지를 설명합니다.

 

주요 개념

  1. 최악의 경우(Worst Case):
    • 알고리즘이 실행되는 데 가장 오랜 시간이 걸리는 경우입니다. 주로 이 값을 기준으로 시간 복잡도를 분석합니다.
  2. 평균적인 경우(Average Case):
    • 입력이 무작위로 주어졌을 때 평균적으로 소요되는 시간입니다.
  3. 최선의 경우(Best Case):
    • 알고리즘이 가장 빨리 실행되는 경우입니다. 하지만 일반적으로 최악의 경우 분석이 더 중요합니다.

일반적인 시간 복잡도 클래스

  1. O(1): 상수 시간
    • 입력 크기에 관계없이 일정한 시간이 걸립니다. 예: 배열의 특정 요소 접근.
  2. O(log n): 로그 시간
    • 입력 크기가 커질수록 실행 시간이 천천히 증가합니다. 예: 이진 탐색.
  3. O(n): 선형 시간
    • 입력 크기에 비례하여 실행 시간이 증가합니다. 예: 배열 순회.
  4. O(n log n): 선형 로그 시간
    • 입력 크기와 그 로그에 비례하여 실행 시간이 증가합니다. 예: 퀵 정렬, 병합 정렬.
  5. O(n²): 이차 시간
    • 입력 크기의 제곱에 비례하여 실행 시간이 증가합니다. 예: 선택 정렬, 삽입 정렬.
  6. O(2^n): 지수 시간
    • 입력 크기가 증가할수록 실행 시간이 매우 빠르게 증가합니다. 예: 피보나치 수열을 재귀적으로 계산하는 경우.
  7. O(n!): 팩토리얼 시간
    • 매우 비효율적인 경우로, 입력 크기가 작을 때만 사용 가능한 알고리즘입니다. 예: 외판원 문제의 완전 탐색.

 

Big O 표기법

 

Big O 표기법은 알고리즘의 시간 복잡도를 표현할 때 사용되는 수학적 기법으로, 최악의 경우 성능을 설명합니다. 복잡도를 간략화하여 주요 항목만 남기고 상수 계수와 낮은 차수 항은 생략합니다. 예를 들어:

  • O(3n² + 5n + 7) → O(n²)

결론

 

시간 복잡도는 알고리즘의 효율성을 이해하는 데 매우 중요한 개념입니다. 특정 문제에 적합한 알고리즘을 선택할 때 시간 복잡도를 고려하면 성능을 극대화할 수 있습니다.

 

 

 

이진 탐색의 원리와 시간 복잡도에 대해 설명해주세요

 

이진 탐색(Binary Search)은 정렬된 배열에서 특정 값을 효율적으로 찾는 알고리즘입니다. 

 

이진 탐색 원리

 

이진 탐색은 분할 정복(Divide and Conquer) 방식을 사용하여 검색 범위를 절반씩 줄여가며 진행합니다. 다음은 이진 탐색의 기본 동작 원리입니다.

  1. 정렬된 배열 필요: 이진 탐색을 수행하기 위해서는 배열이 반드시 정렬되어 있어야 합니다.
  2. 초기 설정: 배열의 시작 인덱스와 끝 인덱스를 설정합니다.
    • low = 0 (배열의 첫 번째 인덱스)
    • high = 배열의 길이 - 1 (배열의 마지막 인덱스)
  3. 중간 요소 확인:
    • mid = (low + high) / 2 (중간 인덱스)
    • 배열의 중간 요소와 찾고자 하는 값을 비교합니다.
  4. 비교 결과에 따른 행동:
    • 중간 요소가 찾고자 하는 값과 같으면: 값을 찾은 것이므로 탐색을 종료합니다.
    • 중간 요소가 찾고자 하는 값보다 작으면: low를 mid + 1로 설정하여 오른쪽 절반을 탐색합니다.
    • 중간 요소가 찾고자 하는 값보다 크면: high를 mid - 1로 설정하여 왼쪽 절반을 탐색합니다.
  5. 반복: 위의 과정을 반복하여 값을 찾거나, low가 high보다 클 때까지 진행합니다.

예시

  1. 정렬된 배열: [1, 3, 5, 7, 9, 11]
  2. 찾고자 하는 값: 7

진행 과정:

  • 첫 번째 검사:
    • low = 0, high = 5, mid = 2 (값 5)
    • 5 < 7 → 오른쪽 절반으로 이동 (low = 3)
  • 두 번째 검사:
    • low = 3, high = 5, mid = 4 (값 9)
    • 9 > 7 → 왼쪽 절반으로 이동 (high = 3)
  • 세 번째 검사:
    • low = 3, high = 3, mid = 3 (값 7)
    • 7 = 7 → 값 찾음!

시간 복잡도

이진 탐색의 시간 복잡도는 다음과 같습니다:

  • O(log n): 매번 탐색할 때마다 배열의 크기가 절반으로 줄어들기 때문에, 비교 횟수는 로그 스케일로 감소합니다. 따라서, 이진 탐색은 매우 효율적입니다.

결론

 

이진 탐색은 정렬된 배열에서 특정 값을 찾는 데 매우 효과적인 방법입니다. 시간 복잡도가 O(log n)으로 낮기 때문에, 대규모 데이터에서도 빠르게 검색할 수 있는 장점이 있습니다. 다만, 이진 탐색을 사용하기 위해서는 반드시 배열이 정렬되어 있어야 한다는 점을 기억해야 합니다.

 

 

 

다이나믹 프로그래밍(Dynamic Programming)의 개념을 설명해주세요.

 

 다이나믹 프로그래밍(Dynamic Programming, DP)은 복잡한 문제를 더 작은 부분 문제들로 나누어 해결하는 알고리즘 기법입니다. 부분 문제의 해답을 저장하고 이를 재사용함으로써 중복 계산을 피하고, 문제를 더 효율적으로 해결할 수 있습니다.

 

핵심 개념

  1. 중복되는 부분 문제 (Overlapping Subproblems): 큰 문제를 해결하는 과정에서 동일한 작은 문제들이 반복해서 발생합니다. 이 작은 문제들의 결과를 저장해 두고 필요할 때 다시 사용함으로써 계산을 줄입니다. 이 저장된 값들을 활용하는 것을 **메모이제이션(Memoization)**이라고 합니다.
  2. 최적 부분 구조 (Optimal Substructure): 문제의 최적해는 부분 문제의 최적해로부터 구할 수 있어야 합니다. 즉, 큰 문제를 해결하기 위해 작은 문제들의 최적해를 결합하는 구조입니다.

다이나믹 프로그래밍의 접근 방식

  1. 탑다운(Top-Down): 재귀를 이용하여 큰 문제를 작은 문제로 나누고, 그 결과를 메모이제이션 기법을 통해 저장합니다. 이미 계산된 부분 문제의 답을 재사용하여 성능을 높입니다.
  2. 바텀업(Bottom-Up): 작은 문제부터 차례대로 해결하면서, 그 결과를 배열 등에 저장해 두고, 점점 큰 문제를 해결해 나갑니다. 재귀 호출을 사용하지 않으며, 반복문을 통해 문제를 해결합니다.

다이나믹 프로그래밍의 예

  1. 피보나치 수열: 재귀로 계산하면 같은 값이 여러 번 계산되지만, DP를 이용해 이전 계산 결과를 저장함으로써 효율적으로 계산할 수 있습니다.
  2. 최장 공통 부분 수열(LCS): 두 문자열에서 공통으로 나타나는 가장 긴 부분 수열을 찾는 문제로, 각 문자열을 쪼개가며 부분 문제로 해결합니다.
  3. 배낭 문제 (Knapsack Problem): 일정한 무게 제한 내에서 가치를 최대화하기 위해 선택할 수 있는 물건의 조합을 찾는 문제로, 작은 무게 제한에 대한 최적의 답을 저장해가며 해결할 수 있습니다.

 

DP의 개념을 이해하는 데 도움이 될 수 있는 문제

 

문제 설명: 최대 부분 배열 합 (Maximum Subarray Sum)

 

주어진 배열에서 연속된 부분 배열 중에서 합이 가장 큰 값을 구하는 문제입니다.

 

예시:

배열 [-2, 1, -3, 4, -1, 2, 1, -5, 4]이 주어졌을 때, 최대 부분 배열은 [4, -1, 2, 1]입니다. 이 부분 배열의 합은 6입니다.

 

 이 문제는 다이나믹 프로그래밍을 사용하여 효율적으로 해결할 수 있습니다. 우리는 배열을 순차적으로 탐색하면서, 각 위치에서 끝나는 최대 부분 배열의 합을 기록해나가고, 이를 이용해 전체 배열의 최대 부분 배열 합을 구할 수 있습니다.

 

알고리즘 설명:

  1. 초기화: 첫 번째 원소에서 시작해, 그 원소 자체가 최대 부분 배열의 합이 됩니다.
  2. 점화식: 배열의 각 원소에 대해, 그 원소를 포함한 현재 부분 배열의 최대 합은 이전 위치에서의 최대 부분 배열의 합에 현재 원소를 더한 값과 현재 원소 중 더 큰 값이 됩니다.
    • 즉, dp[i] = max(dp[i-1] + arr[i], arr[i])로 계산할 수 있습니다.
  3. 최종 답: 배열을 한 번 순회하면서 모든 위치에서의 최대 부분 배열 합을 계산한 후, 그중 가장 큰 값을 반환합니다.
func maxSubArray(_ nums: [Int]) -> Int {
    var currentMax = nums[0]   // 첫 번째 원소가 현재 최대 값
    var globalMax = nums[0]    // 전체 최대 값을 첫 번째 원소로 초기화

    // 배열을 순회하면서 부분 배열의 최대 합을 계산
    for i in 1..<nums.count {
        // 현재 원소를 포함한 부분 배열의 최대 합을 갱신
        currentMax = max(nums[i], currentMax + nums[i])
        
        // 전체 최대 값을 갱신
        globalMax = max(globalMax, currentMax)
    }
    
    return globalMax
}

let array = [-2, 1, -3, 4, -1, 2, 1, -5, 4]
let result = maxSubArray(array)
print("최대 부분 배열의 합: \(result)")  // 출력: 최대 부분 배열의 합: 6

 

코드 설명:

  1. currentMax: 현재 위치까지의 최대 부분 배열 합을 저장합니다.
  2. globalMax: 전체 배열에서의 최대 부분 배열 합을 저장합니다.
  3. 각 반복문에서 currentMax는 nums[i]를 포함한 부분 배열의 최대 합으로 갱신되고, globalMax는 그 중에서 가장 큰 값을 유지합니다.

동작 과정:

  • 배열이 [-2, 1, -3, 4, -1, 2, 1, -5, 4]인 경우, 각각의 위치에서 최대 부분 배열의 합은 다음과 같이 계산됩니다:
    • [-2] → currentMax = -2
    • [1] → currentMax = 1
    • [-2, 1] → currentMax = 1
    • [-2, 1, -3] → currentMax = 1
    • [-2, 1, -3, 4] → currentMax = 4
    • [4, -1] → currentMax = 3
    • [4, -1, 2] → currentMax = 5
    • [4, -1, 2, 1] → currentMax = 6
    • 최종적으로 globalMax = 6.

 

예시 1: 피보나치 수열

피보나치 수열은 각 항이 이전 두 항의 합으로 정의됩니다. 수열은 다음과 같습니다:
0, 1, 1, 2, 3, 5, 8, 13, 21, ...

즉, F(n) = F(n-1) + F(n-2)로 계산됩니다.
이때 피보나치 수열의 n번째 수를 구한다고 가정해보겠습니다.

단순 재귀로 해결할 경우

재귀적으로 fib(n-1)과 fib(n-2)를 호출하며 계산하는 방식은 간단하지만 비효율적입니다. F(5)를 구하기 위해 F(3)과 F(4)를 계산해야 하고, 또 F(3)을 구하려면 F(1)과 F(2)를 다시 계산하는 등, 중복 계산이 많이 발생합니다.

이 경우 시간 복잡도가 O(2^n)으로 커지며, n이 커질수록 계산 속도가 급격히 느려집니다.

동적 프로그래밍으로 해결할 경우 (Top-Down 방식)

동적 프로그래밍을 사용하여 메모이제이션 기법으로 중복 계산을 피할 수 있습니다. 이미 계산한 결과를 배열이나 딕셔너리에 저장해두고 재사용하는 방식입니다.

# 메모이제이션을 사용한 피보나치 수열 코드 예시 (Top-Down)
memo = {0: 0, 1: 1}

def fib(n):
    if n in memo:
        return memo[n]
    memo[n] = fib(n - 1) + fib(n - 2)
    return memo[n]

print(fib(10))  # 결과: 55

 

 

fib(10)을 구할 때 fib(9)과 fib(8)을 저장해두고 재사용하기 때문에, 불필요한 재계산이 줄어듭니다. 이로 인해 시간 복잡도는 O(n)으로 크게 개선됩니다.

바텀업 방식

반복문을 사용해 작은 수부터 차례대로 결과를 저장해나가며 계산할 수도 있습니다.

 

def fib(n):
    dp = [0, 1] + [0] * (n - 1)
    for i in range(2, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]
    return dp[n]

print(fib(10))  # 결과: 55