본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 12. 4. · 13 Views
알고리즘 설계 전략 완벽 가이드
초급 개발자를 위한 알고리즘 설계 전략 입문서입니다. 분할 정복, 동적 프로그래밍, 탐욕 알고리즘 등 핵심 설계 기법을 실무 예제와 함께 쉽게 설명합니다.
목차
1. 알고리즘 설계의 기본 개념
어느 날 김개발 씨가 면접 준비를 하다가 문득 고민에 빠졌습니다. "코드는 돌아가게 짤 수 있는데, 좋은 알고리즘이란 대체 뭘까?" 단순히 동작하는 코드와 잘 설계된 알고리즘의 차이점이 궁금해졌습니다.
알고리즘 설계란 문제를 해결하기 위한 단계별 절차를 체계적으로 만드는 과정입니다. 마치 요리사가 레시피를 만드는 것과 같습니다.
같은 요리도 어떤 순서로, 어떤 방법으로 만드느냐에 따라 맛과 효율이 달라지듯, 알고리즘도 설계에 따라 성능이 천차만별입니다.
다음 코드를 살펴봅시다.
# 1부터 n까지의 합을 구하는 두 가지 방법
def sum_loop(n):
# 방법 1: 반복문 사용 - O(n) 시간 복잡도
total = 0
for i in range(1, n + 1):
total += i
return total
def sum_formula(n):
# 방법 2: 수학 공식 사용 - O(1) 시간 복잡도
# 가우스 공식: n * (n + 1) / 2
return n * (n + 1) // 2
# 결과는 같지만 효율은 완전히 다릅니다
print(sum_loop(1000000)) # 느림
print(sum_formula(1000000)) # 즉시 계산
김개발 씨는 입사 준비 중인 취업 준비생입니다. 코딩 테스트를 준비하면서 한 가지 의문이 들었습니다.
분명 정답을 출력하는 코드를 작성했는데, 왜 시간 초과가 나는 걸까요? 온라인 스터디에서 만난 선배 개발자 박시니어 씨에게 질문했습니다.
"제 코드가 틀린 건 아닌 것 같은데, 왜 통과가 안 될까요?" 박시니어 씨가 답했습니다. "정답을 맞히는 것과 효율적으로 맞히는 것은 완전히 다른 문제야.
알고리즘 설계의 기본부터 다시 살펴볼 필요가 있어." 그렇다면 알고리즘 설계란 정확히 무엇일까요? 쉽게 비유하자면, 알고리즘은 마치 네비게이션과 같습니다.
서울에서 부산까지 가는 방법은 수없이 많습니다. 고속도로를 탈 수도 있고, 국도로 갈 수도 있으며, 기차나 비행기를 이용할 수도 있습니다.
모두 목적지에 도착한다는 결과는 같지만, 걸리는 시간과 비용은 천차만별입니다. 알고리즘 설계도 마찬가지입니다.
1부터 100만까지의 합을 구한다고 생각해봅시다. 가장 직관적인 방법은 하나씩 더하는 것입니다.
1+2+3+... 이렇게 100만 번의 덧셈을 수행하면 됩니다.
하지만 어린 시절의 가우스는 다르게 생각했습니다. 1+100=101, 2+99=101...
이런 쌍이 50개 있으니 101 곱하기 50이면 된다는 것을 발견했습니다. 100만 번의 연산이 단 한 번의 곱셈으로 줄어든 것입니다.
위의 코드를 살펴보면 이 차이가 명확하게 드러납니다. sum_loop 함수는 n번의 반복을 수행합니다.
n이 커질수록 시간도 비례해서 늘어납니다. 반면 sum_formula 함수는 n이 아무리 커도 단 한 번의 계산으로 끝납니다.
실제 현업에서 이 차이는 더욱 극명하게 나타납니다. 사용자가 10명일 때는 비효율적인 알고리즘도 문제없이 돌아갑니다.
하지만 사용자가 100만 명이 되면 어떨까요? 서버가 버티지 못하고 다운될 수도 있습니다.
좋은 알고리즘 설계의 핵심은 문제의 본질을 파악하는 것입니다. 단순히 코드를 작성하기 전에, 이 문제를 해결하는 더 나은 방법이 없는지 고민해야 합니다.
박시니어 씨의 조언을 들은 김개발 씨는 깨달았습니다. "아, 코드를 짜기 전에 어떻게 풀지를 먼저 생각해야 하는군요!" 맞습니다.
알고리즘 설계는 코딩의 시작점입니다. 좋은 설계 없이 좋은 코드는 나올 수 없습니다.
실전 팁
💡 - 코드를 작성하기 전에 종이에 해결 방법을 먼저 그려보세요
- 같은 문제를 다른 방식으로 풀 수 있는지 항상 고민하세요
- 입력 크기가 커졌을 때 어떻게 될지 미리 생각해보세요
2. 정확성 성능 확장성 고려
김개발 씨가 첫 프로젝트를 마치고 코드 리뷰를 받았습니다. 선배가 물었습니다.
"이 코드, 데이터가 100배 늘어나면 어떻게 될 것 같아요?" 김개발 씨는 한 번도 그런 상황을 생각해본 적이 없었습니다.
좋은 알고리즘은 세 가지 기준을 만족해야 합니다. 정확성은 올바른 결과를 보장하고, 성능은 빠른 실행 시간과 적은 메모리 사용을 의미하며, 확장성은 데이터가 늘어나도 안정적으로 동작함을 뜻합니다.
이 세 가지의 균형이 핵심입니다.
다음 코드를 살펴봅시다.
import time
def find_duplicates_naive(arr):
# O(n^2) - 정확하지만 느림
duplicates = []
for i in range(len(arr)):
for j in range(i + 1, len(arr)):
if arr[i] == arr[j] and arr[i] not in duplicates:
duplicates.append(arr[i])
return duplicates
def find_duplicates_optimized(arr):
# O(n) - 정확하고 빠르며 확장 가능
seen = set()
duplicates = set()
for item in arr:
if item in seen:
duplicates.add(item)
seen.add(item)
return list(duplicates)
# 테스트: 10000개 요소에서 중복 찾기
test_data = list(range(5000)) + list(range(5000))
# 최적화된 버전이 수백 배 빠릅니다
김개발 씨는 중복 데이터를 찾는 기능을 구현했습니다. 테스트 데이터 100개로 확인해보니 잘 동작합니다.
뿌듯한 마음으로 코드 리뷰를 요청했습니다. 박시니어 씨가 코드를 살펴보더니 질문을 던졌습니다.
"김개발 씨, 이 코드가 실제 운영 환경에서 100만 개의 데이터를 처리해야 한다면 어떻게 될까요?" 김개발 씨는 당황했습니다. 그런 상황은 생각해본 적이 없었기 때문입니다.
알고리즘을 평가할 때는 세 가지 기준이 있습니다. 첫 번째는 정확성입니다.
당연한 이야기지만, 틀린 답을 빠르게 내는 것은 의미가 없습니다. 알고리즘은 반드시 올바른 결과를 보장해야 합니다.
두 번째는 성능입니다. 성능은 주로 시간 복잡도와 공간 복잡도로 측정합니다.
시간 복잡도는 알고리즘이 얼마나 빠른지, 공간 복잡도는 메모리를 얼마나 사용하는지를 나타냅니다. 세 번째는 확장성입니다.
이것이 바로 박시니어 씨가 지적한 부분입니다. 지금은 잘 동작해도, 데이터가 10배, 100배 늘어났을 때도 괜찮을까요?
비유하자면, 확장성은 마치 식당의 주방 시스템과 같습니다. 손님이 10명일 때 잘 돌아가는 주방이 있다고 합시다.
하지만 갑자기 손님이 1000명으로 늘어나면 어떨까요? 주문 시스템이 마비되고, 요리사가 혼란에 빠지며, 결국 식당 전체가 멈출 수 있습니다.
위의 코드에서 find_duplicates_naive 함수는 이중 반복문을 사용합니다. 데이터가 n개라면 n곱하기n번, 즉 n제곱번의 비교를 수행합니다.
1000개면 100만 번, 10000개면 1억 번의 연산이 필요합니다. 반면 find_duplicates_optimized 함수는 set 자료구조를 활용합니다.
set에서 검색은 평균적으로 O(1), 즉 상수 시간에 가능합니다. 따라서 전체 시간 복잡도는 O(n)으로, 데이터 개수에 비례합니다.
실제로 테스트해보면 그 차이가 극명합니다. 10000개의 데이터에서 첫 번째 방법은 수 초가 걸리지만, 두 번째 방법은 찰나의 순간에 끝납니다.
하지만 주의할 점이 있습니다. 최적화된 방법은 set이라는 추가 메모리를 사용합니다.
이처럼 시간과 공간은 종종 트레이드오프 관계에 있습니다. 상황에 따라 어느 쪽을 우선시할지 판단해야 합니다.
김개발 씨는 박시니어 씨의 설명을 듣고 코드를 수정했습니다. "앞으로는 항상 '만약 데이터가 엄청 많아지면?'이라고 스스로에게 물어봐야겠어요." 박시니어 씨가 미소 지었습니다.
"그게 바로 알고리즘적 사고의 시작이야."
실전 팁
💡 - 항상 "입력이 100만 개라면?"이라고 자문해보세요
- 시간 복잡도와 공간 복잡도의 트레이드오프를 이해하세요
- 실제 예상 데이터 크기를 기준으로 적절한 알고리즘을 선택하세요
3. 분할 정복
김개발 씨가 대용량 파일을 정렬해야 하는 과제를 받았습니다. 100만 개의 숫자를 정렬하려니 도무지 방법이 떠오르지 않습니다.
박시니어 씨가 힌트를 줬습니다. "코끼리를 냉장고에 넣으려면 어떻게 해야 할까?"
분할 정복은 큰 문제를 작은 부분 문제로 나누고, 각각을 해결한 뒤 합치는 전략입니다. 마치 큰 피자를 여러 조각으로 나눠 먹는 것처럼, 복잡한 문제도 잘게 쪼개면 쉬워집니다.
대표적인 예로 병합 정렬과 퀵 정렬이 있습니다.
다음 코드를 살펴봅시다.
def merge_sort(arr):
# 기저 조건: 길이가 1 이하면 이미 정렬됨
if len(arr) <= 1:
return arr
# 분할(Divide): 배열을 반으로 나눔
mid = len(arr) // 2
left = merge_sort(arr[:mid]) # 왼쪽 반 정렬
right = merge_sort(arr[mid:]) # 오른쪽 반 정렬
# 정복(Conquer): 정렬된 두 배열을 병합
return merge(left, right)
def merge(left, right):
result = []
i = j = 0
# 두 배열을 비교하며 작은 것부터 추가
while i < len(left) and j < len(right):
if left[i] <= right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
result.extend(left[i:])
result.extend(right[j:])
return result
박시니어 씨의 질문에 김개발 씨는 어리둥절했습니다. "코끼리를 냉장고에요?
그게 가능한가요?" 박시니어 씨가 웃으며 답했습니다. "세 단계로 나누면 돼.
첫째, 냉장고 문을 연다. 둘째, 코끼리를 넣는다.
셋째, 문을 닫는다. 물론 농담이지만, 핵심은 큰 문제를 작은 단계로 나누는 것이야." 이것이 바로 **분할 정복(Divide and Conquer)**의 핵심 아이디어입니다.
분할 정복은 세 단계로 이루어집니다. 첫째, 분할(Divide) 단계에서 문제를 더 작은 부분 문제로 나눕니다.
둘째, 정복(Conquer) 단계에서 작은 문제들을 각각 해결합니다. 셋째, 결합(Combine) 단계에서 해결된 결과들을 합쳐 최종 답을 만듭니다.
비유하자면, 마치 회사의 조직 구조와 같습니다. CEO 혼자서 모든 일을 처리할 수 없습니다.
그래서 여러 부서로 나누고, 각 부서에서 맡은 일을 처리한 뒤, 결과를 종합해서 회사 전체가 돌아갑니다. 병합 정렬(Merge Sort)은 분할 정복의 교과서적인 예입니다.
100만 개의 숫자를 정렬해야 한다고 생각해봅시다. 한 번에 정렬하기는 막막합니다.
하지만 이렇게 생각하면 어떨까요? 50만 개씩 둘로 나눕니다.
각각을 정렬합니다. 정렬된 두 배열을 합칩니다.
50만 개도 많다면? 다시 25만 개씩 나눕니다.
이렇게 계속 나누다 보면 결국 1개짜리 배열이 됩니다. 1개짜리 배열은 이미 정렬되어 있습니다!
위 코드에서 merge_sort 함수를 살펴봅시다. 먼저 배열의 길이가 1 이하인지 확인합니다.
이것이 기저 조건입니다. 재귀 함수에서 가장 중요한 부분이죠.
그다음 배열을 반으로 나눕니다. mid 인덱스를 기준으로 왼쪽과 오른쪽으로 분할합니다.
각각에 대해 다시 merge_sort를 호출합니다. 이것이 재귀의 마법입니다.
merge 함수는 정렬된 두 배열을 하나로 합칩니다. 두 배열의 맨 앞 요소를 비교해서 작은 것을 먼저 결과에 추가합니다.
이 과정을 반복하면 정렬된 하나의 배열이 완성됩니다. 병합 정렬의 시간 복잡도는 **O(n log n)**입니다.
이전에 본 O(n^2) 정렬과 비교하면 엄청난 차이입니다. 100만 개 데이터에서 O(n^2)은 1조 번의 연산이 필요하지만, O(n log n)은 약 2000만 번이면 됩니다.
김개발 씨가 말했습니다. "아, 그래서 큰 데이터를 정렬할 때 병합 정렬이나 퀵 정렬을 쓰는 거군요!" 박시니어 씨가 덧붙였습니다.
"맞아. 그리고 이 분할 정복 아이디어는 정렬뿐만 아니라 이진 탐색, 거듭제곱 계산 등 수많은 곳에서 활용돼."
실전 팁
💡 - 문제가 커서 막막할 때는 "절반으로 나누면 어떨까?"라고 생각해보세요
- 재귀 함수를 사용할 때는 반드시 기저 조건을 먼저 정의하세요
- 분할 정복은 병렬 처리와도 궁합이 좋습니다
4. 동적 프로그래밍
김개발 씨가 피보나치 수열을 구하는 재귀 함수를 작성했습니다. 작은 숫자에서는 잘 동작하더니, 50번째 피보나치 수를 구하려 하자 컴퓨터가 멈춰버렸습니다.
뭐가 잘못된 걸까요?
**동적 프로그래밍(DP)**은 복잡한 문제를 간단한 하위 문제로 나누어 풀되, 이미 계산한 결과를 저장해두고 재사용하는 기법입니다. 마치 메모장에 계산 결과를 적어두고 필요할 때 꺼내 보는 것과 같습니다.
중복 계산을 피해 효율을 극대화합니다.
다음 코드를 살펴봅시다.
# 비효율적인 재귀: 같은 계산을 반복
def fib_naive(n):
if n <= 1:
return n
return fib_naive(n-1) + fib_naive(n-2) # 중복 계산 발생!
# 동적 프로그래밍: 메모이제이션 활용
def fib_dp(n, memo={}):
if n in memo:
return memo[n] # 이미 계산했으면 저장된 값 반환
if n <= 1:
return n
# 계산 결과를 저장하고 반환
memo[n] = fib_dp(n-1, memo) + fib_dp(n-2, memo)
return memo[n]
# 상향식(Bottom-up) 동적 프로그래밍
def fib_bottom_up(n):
if n <= 1:
return n
dp = [0] * (n + 1)
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i-1] + dp[i-2] # 작은 문제부터 순서대로
return dp[n]
김개발 씨는 피보나치 수열이 뭔지 알고 있었습니다. 1, 1, 2, 3, 5, 8...
앞의 두 수를 더해 다음 수를 만드는 유명한 수열입니다. 재귀로 구현하면 아주 깔끔합니다.
하지만 문제가 생겼습니다. fib(10)은 순식간에 나오는데, fib(40)은 한참 걸리고, fib(50)은 아예 끝나지 않습니다.
도대체 왜 그럴까요? 박시니어 씨가 화이트보드에 그림을 그렸습니다.
"fib(5)를 계산하려면 fib(4)와 fib(3)이 필요해. fib(4)를 계산하려면 fib(3)과 fib(2)가 필요하고.
여기서 문제가 보여?" 김개발 씨가 눈을 크게 떴습니다. "fib(3)을 두 번 계산하네요!" "맞아.
그리고 이게 기하급수적으로 늘어나. fib(50)을 구하려면 fib(48)을 두 번, fib(47)을 세 번, fib(46)을 다섯 번...
결국 수십억 번의 중복 계산이 발생해." 이것이 바로 동적 프로그래밍이 해결하는 문제입니다. 비유하자면, 동적 프로그래밍은 마치 수학 시험에서 계산 과정을 연습장에 적어두는 것과 같습니다.
2+3=5를 한번 계산했으면, 다음에 2+3이 나올 때 다시 계산하지 않고 연습장을 보면 됩니다. 동적 프로그래밍에는 두 가지 접근법이 있습니다.
첫 번째는 **메모이제이션(Memoization)**으로, 하향식(Top-down) 접근법이라고도 합니다. 큰 문제부터 시작해서 필요한 작은 문제를 풀되, 이미 푼 문제는 저장해두고 재사용합니다.
두 번째는 **타뷸레이션(Tabulation)**으로, 상향식(Bottom-up) 접근법입니다. 가장 작은 문제부터 차례대로 풀어나가면서 테이블을 채워갑니다.
위 코드에서 fib_dp 함수는 메모이제이션을 사용합니다. memo라는 딕셔너리에 계산 결과를 저장합니다.
함수가 호출되면 먼저 memo에 결과가 있는지 확인합니다. 있으면 바로 반환하고, 없으면 계산한 뒤 저장합니다.
fib_bottom_up 함수는 타뷸레이션 방식입니다. dp 배열을 만들고, dp[0]=0, dp[1]=1부터 시작해서 순서대로 채워나갑니다.
재귀 호출이 없어 스택 오버플로우 걱정도 없습니다. 결과적으로 시간 복잡도가 O(2^n)에서 **O(n)**으로 극적으로 개선됩니다.
fib(50)도 순식간에 계산됩니다. 동적 프로그래밍이 적용 가능하려면 두 가지 조건이 필요합니다.
첫째, 최적 부분 구조 - 큰 문제의 최적해가 작은 문제의 최적해로 구성됩니다. 둘째, 중복되는 하위 문제 - 같은 작은 문제가 여러 번 반복됩니다.
김개발 씨가 물었습니다. "그럼 모든 문제에 DP를 적용할 수 있나요?" "아니, 두 조건을 만족해야 해.
하지만 놀라울 정도로 많은 문제들이 이 조건을 만족하지. 최단 경로, 배낭 문제, 문자열 편집 거리...
코딩 테스트에서도 단골이야."
실전 팁
💡 - "이 계산을 전에 한 적 있는가?"라는 질문이 DP의 시작점입니다
- 재귀로 먼저 구현한 뒤 메모이제이션을 추가하면 쉽습니다
- 공간 복잡도를 줄이려면 필요한 값만 저장하는 최적화를 고려하세요
5. 탐욕 알고리즘
김개발 씨가 거스름돈 문제를 풀고 있습니다. 손님에게 730원을 거슬러 줘야 하는데, 동전 개수를 최소화하려면 어떻게 해야 할까요?
500원, 100원, 50원, 10원 동전이 있습니다.
**탐욕 알고리즘(Greedy Algorithm)**은 매 순간 가장 좋아 보이는 선택을 하는 전략입니다. 마치 눈앞의 가장 큰 케이크 조각을 먼저 집는 것과 같습니다.
항상 최적해를 보장하지는 않지만, 특정 조건에서는 빠르고 효율적으로 정답을 찾을 수 있습니다.
다음 코드를 살펴봅시다.
def min_coins_greedy(amount, coins):
"""
탐욕 알고리즘으로 최소 동전 개수 구하기
주의: 특정 동전 시스템에서만 최적해 보장
"""
coins.sort(reverse=True) # 큰 동전부터 사용
result = []
for coin in coins:
while amount >= coin:
result.append(coin)
amount -= coin
return result
# 한국 동전 시스템: 탐욕이 최적해
coins_kr = [500, 100, 50, 10]
print(min_coins_greedy(730, coins_kr)) # [500, 100, 100, 10, 10, 10]
# 주의: 모든 경우에 최적은 아님
# 예: coins = [1, 3, 4], amount = 6
# 탐욕: 4+1+1 = 3개, 최적: 3+3 = 2개
김개발 씨는 직관적으로 답을 알고 있었습니다. 500원 1개, 100원 2개, 10원 3개.
총 6개의 동전으로 730원을 만들 수 있습니다. 하지만 왜 이게 최소인지 설명하라고 하니 막막했습니다.
박시니어 씨가 설명했습니다. "그게 바로 탐욕 알고리즘이야.
매 순간 가장 큰 동전부터 사용하는 거지." 탐욕 알고리즘은 이름 그대로 욕심쟁이 전략입니다. 전체를 보지 않고, 지금 이 순간에 가장 좋아 보이는 선택을 합니다.
미래를 고려하지 않습니다. 현재만 봅니다.
비유하자면, 마치 등산할 때 항상 가장 가파른 방향으로 올라가는 것과 같습니다. 매 발걸음마다 가장 높이 올라갈 수 있는 방향을 선택합니다.
이렇게 하면 빠르게 정상에 도달할 수 있습니다. 하지만 가끔은 잠시 내려갔다가 올라가야 더 높은 봉우리에 갈 수 있는 경우도 있습니다.
위 코드를 살펴봅시다. 먼저 동전을 큰 순서대로 정렬합니다.
그다음 가장 큰 동전부터 시작해서, 가능한 한 많이 사용합니다. 500원으로 더 이상 못 쓰면 100원으로 넘어가고, 100원도 못 쓰면 50원으로...
이렇게 진행합니다. 한국의 동전 시스템(10, 50, 100, 500)에서는 이 탐욕 전략이 항상 최적해를 보장합니다.
이유가 뭘까요? 각 동전이 작은 동전의 배수이기 때문입니다.
500은 100의 5배, 100은 50의 2배, 50은 10의 5배입니다. 하지만 주의해야 합니다.
탐욕 알고리즘이 항상 최적해를 주지는 않습니다. 예를 들어 동전이 1원, 3원, 4원짜리만 있고 6원을 만들어야 한다고 합시다. 탐욕 알고리즘은 4+1+1=6, 즉 3개의 동전을 사용합니다.
하지만 최적해는 3+3=6, 즉 2개의 동전입니다. 그렇다면 탐욕 알고리즘은 언제 써야 할까요?
탐욕 선택 속성과 최적 부분 구조를 만족할 때 사용합니다. 탐욕 선택 속성이란, 현재의 최선의 선택이 이후의 선택에 영향을 주지 않는다는 것입니다.
최적 부분 구조는 앞서 동적 프로그래밍에서도 봤던 조건입니다. 탐욕 알고리즘의 대표적인 예로는 활동 선택 문제, 허프만 코딩, 크루스칼/프림 알고리즘(최소 신장 트리), 다익스트라 알고리즘(최단 경로) 등이 있습니다.
김개발 씨가 물었습니다. "그럼 탐욕이 안 되는 문제는 어떻게 알 수 있나요?" 박시니어 씨가 답했습니다.
"반례를 찾아보는 거야. 탐욕 전략을 적용했을 때 최적이 아닌 경우가 하나라도 있으면, 동적 프로그래밍 같은 다른 방법을 써야 해." 탐욕 알고리즘의 장점은 단순함과 속도입니다.
복잡한 계산 없이 직관적으로 구현할 수 있고, 대부분 O(n) 또는 O(n log n)의 시간 복잡도를 가집니다.
실전 팁
💡 - 탐욕 전략을 세웠다면 반드시 반례가 없는지 검증하세요
- 증명이 어렵다면 작은 예제로 직접 테스트해보세요
- 탐욕이 안 되면 동적 프로그래밍을 고려하세요
6. 외판원 문제 해결
김개발 씨가 배달 최적화 시스템을 개발하게 되었습니다. 10개의 배달 지점을 모두 방문하고 돌아오는 최단 경로를 찾아야 합니다.
단순해 보이는 이 문제가 왜 그토록 어렵다는 걸까요?
**외판원 문제(TSP, Traveling Salesman Problem)**는 모든 도시를 한 번씩 방문하고 출발점으로 돌아오는 최단 경로를 찾는 문제입니다. 도시가 n개일 때 가능한 경로는 (n-1)!/2개로, 완전 탐색은 사실상 불가능합니다.
동적 프로그래밍과 근사 알고리즘으로 접근합니다.
다음 코드를 살펴봅시다.
import sys
from itertools import permutations
def tsp_bruteforce(dist):
"""완전 탐색: O(n!) - 작은 입력에서만 사용 가능"""
n = len(dist)
cities = range(1, n) # 0번 도시에서 출발
min_cost = sys.maxsize
for perm in permutations(cities):
cost = dist[0][perm[0]] # 출발
for i in range(len(perm)-1):
cost += dist[perm[i]][perm[i+1]]
cost += dist[perm[-1]][0] # 복귀
min_cost = min(min_cost, cost)
return min_cost
def tsp_dp(dist):
"""동적 프로그래밍: O(n^2 * 2^n) - 비트마스크 활용"""
n = len(dist)
INF = float('inf')
# dp[visited][i]: visited 집합을 방문하고 i에 있을 때 최소 비용
dp = [[INF] * n for _ in range(1 << n)]
dp[1][0] = 0 # 시작점
for visited in range(1 << n):
for curr in range(n):
if dp[visited][curr] == INF:
continue
for next_city in range(n):
if visited & (1 << next_city):
continue
new_visited = visited | (1 << next_city)
dp[new_visited][next_city] = min(
dp[new_visited][next_city],
dp[visited][curr] + dist[curr][next_city]
)
# 모든 도시 방문 후 시작점 복귀
all_visited = (1 << n) - 1
return min(dp[all_visited][i] + dist[i][0] for i in range(n))
김개발 씨는 처음에 이 문제를 가볍게 봤습니다. "그냥 모든 경로를 다 계산해서 가장 짧은 거 고르면 되는 거 아니에요?" 박시니어 씨가 고개를 저었습니다.
"10개 도시면 경로가 몇 개일 것 같아?" 김개발 씨가 계산해봤습니다. 첫 번째 도시 다음에 갈 수 있는 곳이 9개, 그다음 8개...
9! = 362,880개.
이 정도면 컴퓨터가 금방 계산할 수 있을 것 같습니다. "20개면?" 19!
= 약 1.2경. 슈퍼컴퓨터로도 수십 년이 걸립니다.
이것이 바로 **외판원 문제(TSP)**가 유명한 이유입니다. 문제 자체는 이해하기 쉽지만, 도시 수가 조금만 늘어나도 계산량이 폭발적으로 증가합니다.
이런 문제를 NP-난해(NP-hard) 문제라고 합니다. 비유하자면, TSP는 마치 여행 계획을 세우는 것과 같습니다.
유럽 10개 도시를 여행한다고 할 때, 어떤 순서로 방문해야 비행기값이 가장 적게 들까요? 직관적으로는 알기 어렵습니다.
위 코드에서 두 가지 접근법을 보여줍니다. 첫 번째 tsp_bruteforce는 완전 탐색입니다.
모든 경로를 다 확인합니다. 정확하지만, 도시가 10개만 넘어도 현실적이지 않습니다.
두 번째 tsp_dp는 동적 프로그래밍을 활용합니다. 핵심 아이디어는 비트마스크를 사용해 방문한 도시들의 집합을 표현하는 것입니다.
비트마스크란 무엇일까요? 도시가 4개 있다면, 이진수 0101은 0번과 2번 도시를 방문했다는 뜻입니다.
1111은 모든 도시를 방문한 상태입니다. 이렇게 하면 집합을 정수 하나로 표현할 수 있습니다.
dp[visited][curr]는 "visited 집합에 해당하는 도시들을 방문하고 현재 curr에 있을 때의 최소 비용"을 저장합니다. 이미 계산한 부분 문제를 재사용하므로, 시간 복잡도가 O(n!)에서 **O(n^2 * 2^n)**으로 개선됩니다.
여전히 지수적이지만, 실제로는 엄청난 차이입니다. 20개 도시에서 O(n!)은 약 10^18이지만, O(n^2 * 2^n)은 약 4억입니다.
현대 컴퓨터로 수 분 내에 계산 가능합니다. 하지만 도시가 더 많아지면 어떨까요?
실제 물류 회사는 수백 개의 배달지를 처리해야 합니다. 이때는 근사 알고리즘이나 휴리스틱을 사용합니다.
대표적으로 최근접 이웃 알고리즘이 있습니다. 현재 위치에서 가장 가까운 미방문 도시로 이동하는 탐욕 전략입니다.
최적해는 아니지만, 빠르게 괜찮은 해를 찾을 수 있습니다. 김개발 씨가 물었습니다.
"그럼 실제 배달 앱에서는 어떻게 하나요?" "여러 기법을 조합해. 도시를 클러스터로 묶고, 각 클러스터 내에서 최적화하고, 지역 탐색으로 해를 개선하는 식이지.
구글 OR-Tools 같은 라이브러리를 활용하기도 해."
실전 팁
💡 - TSP는 NP-난해이므로 큰 입력에서는 근사 알고리즘을 고려하세요
- 비트마스크 DP는 집합을 다루는 많은 문제에 활용됩니다
- 실무에서는 OR-Tools, Concorde 같은 검증된 라이브러리를 사용하세요
7. 선형 프로그래밍 기초
김개발 씨가 리소스 할당 문제를 맡게 되었습니다. 서버 A와 B가 있고, 각각 처리 비용과 용량이 다릅니다.
예산 내에서 최대 처리량을 얻으려면 각 서버를 얼마나 사용해야 할까요?
**선형 프로그래밍(Linear Programming, LP)**은 선형 제약 조건 하에서 선형 목적 함수를 최적화하는 수학적 기법입니다. 마치 정해진 예산 내에서 최대 만족을 얻는 쇼핑처럼, 제약 조건을 만족하면서 목표를 달성하는 최적의 조합을 찾습니다.
다음 코드를 살펴봅시다.
from scipy.optimize import linprog
"""
문제: 두 제품 X, Y 생산량 최적화
- 이익: X는 개당 40원, Y는 개당 30원
- 제약조건:
- 기계A 시간: 2X + Y <= 40시간
- 기계B 시간: X + 2Y <= 50시간
- X, Y >= 0
"""
# 목적함수 계수 (최대화를 위해 음수로)
# linprog는 최소화이므로 -40x - 30y를 최소화 = 40x + 30y 최대화
c = [-40, -30]
# 부등식 제약조건 Ax <= b
A_ub = [
[2, 1], # 2X + Y <= 40
[1, 2], # X + 2Y <= 50
]
b_ub = [40, 50]
# 변수 범위 (0 이상)
bounds = [(0, None), (0, None)]
# 최적화 실행
result = linprog(c, A_ub=A_ub, b_ub=b_ub, bounds=bounds, method='highs')
print(f"최적 생산량: X={result.x[0]:.1f}, Y={result.x[1]:.1f}")
print(f"최대 이익: {-result.fun:.1f}원")
# 출력: X=10.0, Y=20.0, 최대 이익=1000.0원
김개발 씨가 운영팀에서 요청을 받았습니다. "서버 비용을 최소화하면서 처리량을 최대로 올려주세요." 막연해 보이는 이 문제, 어떻게 접근해야 할까요?
박시니어 씨가 화이트보드에 그래프를 그렸습니다. "이런 최적화 문제는 선형 프로그래밍으로 접근할 수 있어." 선형 프로그래밍은 최적화 문제를 푸는 강력한 수학적 도구입니다.
1940년대에 개발되어 이후 경제학, 물류, 금융, 공학 등 수많은 분야에서 활용되고 있습니다. 비유하자면, 선형 프로그래밍은 마치 다이어트 식단 짜기와 같습니다.
칼로리, 단백질, 지방 등의 제약 조건이 있고, 비용을 최소화하면서 영양 요구를 충족해야 합니다. 어떤 음식을 얼마나 먹어야 최적일까요?
이것이 바로 LP가 해결하는 문제입니다. LP의 구성 요소를 살펴봅시다.
첫째, 결정 변수가 있습니다. 우리가 결정해야 하는 값입니다.
위 코드에서는 제품 X와 Y의 생산량입니다. 둘째, 목적 함수가 있습니다.
최대화하거나 최소화하고 싶은 대상입니다. 예제에서는 이익(40X + 30Y)을 최대화합니다.
셋째, 제약 조건이 있습니다. 반드시 만족해야 하는 조건들입니다.
기계 A의 가용 시간이 40시간으로 제한되는 것처럼요. 핵심은 이 모든 것이 선형이라는 점입니다.
목적 함수도, 제약 조건도 모두 1차식입니다. X^2이나 XY 같은 항이 없습니다.
이 선형성 덕분에 효율적인 알고리즘이 가능합니다. 위 코드에서는 scipy의 linprog 함수를 사용합니다.
주의할 점은 linprog가 최소화를 기본으로 한다는 것입니다. 최대화하려면 목적 함수에 음수를 곱합니다.
A_ub와 b_ub는 부등식 제약조건을 나타냅니다. 2X + Y <= 40, X + 2Y <= 50을 행렬 형태로 표현한 것입니다.
결과를 해석해봅시다. 최적 생산량은 X=10, Y=20입니다.
이때 최대 이익은 4010 + 3020 = 1000원입니다. 제약 조건을 확인해보면, 기계 A: 210 + 20 = 40시간(딱 맞음), 기계 B: 10 + 220 = 50시간(딱 맞음).
이렇게 제약 조건을 정확히 만족하는 점을 기본 가능해라고 합니다. LP의 최적해는 항상 이런 꼭짓점에서 발견된다는 것이 수학적으로 증명되어 있습니다.
실무에서 LP는 어디에 쓰일까요? 항공사의 승무원 스케줄링, 공장의 생산 계획, 금융 포트폴리오 최적화, 배송 경로 계획 등 무궁무진합니다.
김개발 씨가 물었습니다. "근데 실제 문제는 이것보다 훨씬 복잡하지 않나요?" "맞아.
변수가 정수여야 하면 정수 프로그래밍, 목적 함수가 비선형이면 비선형 프로그래밍으로 확장돼. 하지만 기본 LP를 이해하는 게 첫걸음이야."
실전 팁
💡 - Python에서는 scipy.optimize.linprog, PuLP, Google OR-Tools 등을 활용하세요
- 문제를 수식으로 정의하는 것이 가장 중요한 단계입니다
- 제약 조건이 모순되면 해가 없고, 무한하면 최적해가 없습니다
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
Helm 마이크로서비스 패키징 완벽 가이드
Kubernetes 환경에서 마이크로서비스를 효율적으로 패키징하고 배포하는 Helm의 핵심 기능을 실무 중심으로 학습합니다. Chart 생성부터 릴리스 관리까지 체계적으로 다룹니다.
보안 아키텍처 구성 완벽 가이드
프로젝트의 보안을 처음부터 설계하는 방법을 배웁니다. AWS 환경에서 VPC부터 WAF, 암호화, 접근 제어까지 실무에서 바로 적용할 수 있는 보안 아키텍처를 단계별로 구성해봅니다.
AWS Organizations 완벽 가이드
여러 AWS 계정을 체계적으로 관리하고 통합 결제와 보안 정책을 적용하는 방법을 실무 스토리로 쉽게 배워봅니다. 초보 개발자도 바로 이해할 수 있는 친절한 설명과 실전 예제를 제공합니다.
AWS KMS 암호화 완벽 가이드
AWS KMS(Key Management Service)를 활용한 클라우드 데이터 암호화 방법을 초급 개발자를 위해 쉽게 설명합니다. CMK 생성부터 S3, EBS 암호화, 봉투 암호화까지 실무에 필요한 모든 내용을 담았습니다.
AWS Secrets Manager 완벽 가이드
AWS에서 데이터베이스 비밀번호, API 키 등 민감한 정보를 안전하게 관리하는 Secrets Manager의 핵심 개념과 실무 활용법을 배워봅니다. 초급 개발자도 쉽게 따라할 수 있도록 실전 예제와 함께 설명합니다.