17강: 과거의 기억으로 미래를 바꾼다, 동적 계획법(Dynamic Programming)
AI 사용 안내: 이 포스팅은
Gemini 3.1 Pro Deep Think Mode언어 모델을 활용하여 작성되었습니다.
17강: 과거의 기억으로 미래를 바꾼다, 동적 계획법(Dynamic Programming)
안녕하세요! 재준봇입니다!
여러분, 환영합니다! 알고리즘의 세계를 탐험하다 보면, “이거 진짜 사람 머리에서 나온 거 맞아?” 싶을 정도로 소름 돋는 천재적인 기법들을 만나게 됩니다. 오늘 우리가 배울 알고리즘이 바로 그 주인공입니다. 이거 모르면 코딩 테스트에서 절대 살아남을 수 없습니다. 진짜 큰일 납니다!
바로 동적 계획법, 영문으로는 Dynamic Programming(줄여서 DP)입니다.
이름만 들으면 뭔가 엄청 역동적이고 쉴 새 없이 움직이는 최첨단 시스템 같은 게 떠오르시나요? 사실 이 이름에는 엄청난 반전이 숨어 있습니다. 오늘은 이 반전 매력의 동적 계획법이 무엇인지, 왜 모든 개발자들이 이 알고리즘 앞에서 무릎을 꿇고 경의를 표하는지 아주 길고 자세하게 파헤쳐 보겠습니다. 진짜 신기하죠? 자, 준비되셨다면 바로 시작하겠습니다!
1. 동적 계획법(DP)이 도대체 뭔가요?
동적 계획법의 핵심을 단 한 문장으로 요약하면 이렇습니다.
“과거의 뼈아픈 실수를 반복하지 마라… 아니, 과거의 복잡한 계산을 반복하지 마라!”
컴퓨터는 무척 순종적이라서, 똑같은 계산을 시키면 백 번이고 천 번이고 처음부터 다시 계산합니다. 가장 대표적인 예시로 앞의 두 숫자를 더해 다음 숫자를 만드는 피보나치 수열을 생각해 보겠습니다. (1, 1, 2, 3, 5, 8, 13 …)
만약 컴퓨터에게 50번째 피보나치 숫자를 구해달라고 단순하게 명령하면 어떻게 될까요? 50번째를 알기 위해 49번째와 48번째를 계산하고, 49번째를 알기 위해 또 48번째와 47번째를 계산합니다. 어라? 48번째를 구하는 똑같은 과정을 수없이 반복하고 있네요! 이렇게 되면 시간 복잡도가 무려 지수 시간인 O(2^N)으로 폭발해 버립니다. 숫자가 조금만 커져도 우주가 멸망할 때까지 계산만 하고 있을 겁니다.
이때 구원자처럼 등장하는 것이 바로 동적 계획법입니다. 이 알고리즘은 “한 번 힘들게 계산한 결과는 마법의 수첩에 쓱 적어두고, 다음에 또 필요하면 다시 계산하지 말고 수첩에서 정답만 꺼내 쓰자!”라는 아주 단순하고 강력한 아이디어입니다. 이 기법을 컴퓨터 과학에서는 메모이제이션(Memoization)이라고 부릅니다. 기억하다는 뜻의 단어에서 알파벳 r이 빠진 형태이니 스펠링에 주의하세요!
이렇게 수첩을 활용하면 수십만 년이 걸릴 계산을 단 1초도 안 걸리는 O(N) 수준으로 극적으로 단축시킬 수 있습니다.
초보자 폭풍 질문!
“선생님! 도대체 왜 이름이 ‘동적 계획법’인가요? 수첩에 적는 거랑 동적(Dynamic)인 거랑 무슨 상관이 있는지 도저히 모르겠어요!”
아주 예리하고 훌륭한 질문입니다! 사실 이 알고리즘의 창시자인 리처드 벨만이라는 학자가 있었는데요. 당시에 연구 자금을 지원해 주던 정부 기관의 높으신 분들이 ‘수학’이나 ‘연구’라는 단어를 아주 지루해하고 싫어했다고 합니다.
그래서 연구 예산을 넉넉하게 따내기 위해, 뭔가 있어 보이고 반박할 수 없을 만큼 멋진 단어를 찾다가 당시 유행하던 느낌의 “Dynamic”이라는 단어를 그냥 폼나게 가져다 붙인 것뿐이랍니다! 즉, 이름에 엄청난 수학적 의미가 있는 게 아닙니다. 우리는 그냥 “기억하며 풀기 알고리즘” 정도로 이해하면 완벽합니다!
“그럼 저번에 배운 그리디 알고리즘이랑은 뭐가 다른가요?”
그리디 알고리즘은 미래를 생각하지 않고 지금 당장 눈앞에 있는 가장 달콤한 것만 고르는 방식입니다. 속도는 엄청나게 빠르지만, 복잡한 상황에서는 항상 완벽한 정답을 보장하지는 않죠. 반면 동적 계획법은 모든 가능성을 치밀하게 다 따져보되, 한 번 확인한 경로는 모두 수첩에 기록해서 중복된 노가다를 피하는 완벽주의자입니다. 조금 더 신중하지만, 무조건 100% 완벽한 정답을 찾아냅니다!
2. 오늘의 실전 문제: 좀비 아포칼립스, 완벽한 생존 배낭을 꾸려라!
개념만 줄줄 읽으면 지루하니까, 여러분이 직접 심장이 쫄깃해지는 영화 속 주인공이 되었다고 상상하고 짜릿한 상황을 문제로 만들어 보았습니다.
[상황 설명]
갑작스럽게 정체불명의 좀비 바이러스가 도시에 퍼졌습니다. 여러분은 안전 구역으로 탈출하기 위해 은신처의 물건들을 챙겨야 합니다. 눈앞에는 생존에 필수적인 4가지 아이템이 놓여 있지만, 치명적인 문제가 하나 있습니다. 여러분이 메고 있는 낡은 배낭은 최대 15kg의 무게까지만 견딜 수 있습니다. 이 무게를 1g이라도 초과하면 배낭이 찢어져서 모든 짐을 잃고 좀비의 밥이 되고 맙니다!
각 물건은 딱 1개씩만 있고 반으로 쪼갤 수 없습니다. (가져가거나, 놔두거나 둘 중 하나입니다.) 또한 각 물건마다 무게와 ‘생존 가치 점수’가 매겨져 있습니다. 배낭이 견딜 수 있는 무게 안에서, 챙겨가는 물건들의 생존 가치 합이 가장 높아지도록 짐을 싸야 합니다.
[눈앞에 놓인 생존 아이템 목록]
최고급 구급상자: 무게 4kg / 생존 가치 10점
고칼로리 전투식량: 무게 3kg / 생존 가치 8점
티타늄 야구방망이: 무게 5kg / 생존 가치 14점
특수 방호복: 무게 8kg / 생존 가치 20점
여러분의 배낭 제한 하중은 딱 15kg입니다. 과연 어떤 물건들을 챙겨야 극한의 상황에서 살아남을 확률이 가장 높아질까요?
실무주의보
“아하! 그냥 무게당 생존 가치가 제일 높은(가성비 좋은) 물건부터 무조건 배낭에 쑤셔 넣으면 되는 거 아닌가요? 방망이나 전투식량부터 챙겨야겠네요!”
땡! 여기서 틀리면 바로 게임 오버입니다. 현업에서 이런 쪼갤 수 없는 최적화 문제(일명 0-1 배낭 문제)를 만났을 때 가성비 위주의 그리디 알고리즘을 쓰면 완벽하게 망합니다.
가성비 순서대로 티타늄 야구방망이(5kg)와 고칼로리 전투식량(3kg)을 담고, 남은 공간에 최고급 구급상자(4kg)를 담아볼까요? 총 무게는 12kg이고 가치의 합은 32점이 됩니다. 배낭의 남은 용량은 3kg이라서 8kg짜리 방호복은 쳐다볼 수도 없죠.
하지만 전체 경우를 똑똑하게 고려한다면 어떨까요? 특수 방호복(8kg), 티타늄 야구방망이(5kg)를 담는 조합을 찾아낼 수 있습니다! 이 두 가지를 합치면 무게는 13kg이 되고, 가치의 합은 무려 34점이 됩니다! 그리디로 구한 32점보다 훨씬 높고, 이게 바로 우리가 도달할 수 있는 완벽한 최고점입니다.
실무에서도 제한된 서버 메모리 안에 프로세스를 할당하거나, 한정된 예산으로 최고의 마케팅 채널 조합을 찾을 때 이 원리가 튼튼한 뼈대처럼 사용됩니다. 수백만 건의 데이터를 감당하는 환경에서 동적 계획법적 사고는 선택이 아닌 필수입니다!
이제 이 문제를 동적 계획법으로 어떻게 우아하게 해결할 수 있을지 직접 머리를 맞대고 고민해 볼 시간입니다. 머릿속으로 여러분만의 과거 기록 수첩을 그려보세요. 충분히 고민했다면 아래의 토글을 열어 완벽한 해결 방안과 상세한 정답 코드를 확인해 보세요!
해결 방안 및 정답 코드 보기 (클릭)
해결 방안 가이드 (알고리즘 설계)
이 문제를 완벽하게 풀기 위해 가장 중요한 핵심은, 압도적으로 거대한 전체 문제를 작은 문제로 쪼개고, 점화식(수학적 규칙)을 찾아내어 2차원 표(수첩)를 채우는 것입니다.
가로축(열) 만들기: 배낭의 임시 제한 무게를 나타냅니다. 0kg부터 시작해서 우리가 버틸 수 있는 최대 무게인 15kg까지 1kg 단위로 칸을 만듭니다.
세로축(행) 만들기: 아이템을 하나씩 추가로 발견하며 고려하는 단계를 나타냅니다. (아무것도 안 넣었을 때, 구급상자만 고려할 때, 구급상자+전투식량을 고려할 때 등등)
이제 표의 특정 칸을 채워봅시다. 이 칸이 의미하는 바는 “특정 물건까지 살펴보았고, 배낭의 허용 무게가 얼마일 때 얻을 수 있는 최대 가치”입니다.
어떤 물건을 배낭에 넣으려고 할 때, 우리는 딱 두 가지 상황만 마주하게 됩니다.
상황 A: 현재 물건이 너무 무거워서 배낭의 임시 한도에 아예 들어가지 않는 경우
이럴 땐 어떻게 할까요? 고민할 필요 없이 현재 물건을 포기하고, 이전 물건까지만 고려했을 때의 최댓값(수첩의 바로 윗칸 값)을 그대로 물려받으면 됩니다.
상황 B: 현재 물건을 배낭에 넣을 수 있는 용량이 되는 경우
여기서 아주 중요한 선택의 기로에 놓입니다. 이 물건을 담는 게 이득일까요? 아니면 담지 않고 예전 상태를 유지하는 게 이득일까요? 만약 물건을 담는다면, 현재 물건의 가치를 더하고, 배낭 용량에서 현재 물건의 무게만큼을 빼야 합니다. 그리고 ‘그 남은 여유 용량을 과거에 어떻게 최고로 채웠는지’ 수첩의 윗줄에서 찾아봐야 합니다!
즉, “안 담았을 때의 과거 가치”와 “담았을 때의 가치(과거의 최적해 + 현재 가치)” 중 더 큰 값을 수첩에 꼼꼼히 기록하면 끝입니다! 이 과정을 마지막 칸까지 반복하면 놀랍게도 최종 정답이 등장합니다.
파이썬 정답 코드 및 상세 해설
def survive_zombie_apocalypse(max_weight, items):
"""
좀비 아포칼립스 생존 배낭을 꾸리는 완벽한 DP 알고리즘 (0-1 배낭 문제)
매개변수:
max_weight (int): 낡은 배낭이 버틸 수 있는 최대 무게
items (list of tuples): 물건 리스트. 각 요소는 (이름, 무게, 가치) 형태
"""
# 창고에 있는 물품의 총 개수를 구합니다.
n = len(items)
# DP 테이블(마법의 2차원 수첩) 생성 및 초기화
# 행: 0부터 n까지 (물건의 개수 + 1)
# 열: 0부터 max_weight까지 (최대 무게 + 1)
# 아직 아무것도 담지 않았으므로 초기값은 모두 0으로 세팅합니다.
dp = [[0] * (max_weight + 1) for _ in range(n + 1)]
# 1번째 물건부터 차례대로 하나씩 꺼내어 신중하게 검토합니다.
for i in range(1, n + 1):
# 현재 검토 중인 물건의 이름, 무게, 가치를 꺼내옵니다.
# 파이썬 리스트의 인덱스는 0부터 시작하므로 i - 1을 사용합니다.
item_name, item_weight, item_value = items[i - 1]
# 배낭의 임시 한도(w)를 1kg부터 최대 한도까지 조금씩 늘려가며 수첩을 채웁니다.
for w in range(1, max_weight + 1):
# 상황 A: 현재 물건이 너무 무거워서 배낭의 임시 한도(w)에 아예 못 담는 경우
if item_weight > w:
# 못 담으니까, 이전 물건까지 검토했던 결과를 그대로 가져옵니다.
dp[i][w] = dp[i - 1][w]
# 상황 B: 배낭에 물건을 담을 수 있는 충분한 공간이 있는 경우
else:
# 1. 안 담았을 때의 가치 (이전 상태를 그대로 유지)
value_if_not_included = dp[i - 1][w]
# 2. 담았을 때의 가치
# 배낭에서 현재 물건 무게를 뺀 나머지 공간(w - item_weight)을
# 채웠던 과거의 최적 가치에, 현재 물건의 가치를 더합니다.
value_if_included = dp[i - 1][w - item_weight] + item_value
# 둘 중 더 생존 확률을 높여주는 엄청난 값을 수첩에 기록합니다.
dp[i][w] = max(value_if_not_included, value_if_included)
# 이중 반복문이 끝나고 수첩의 맨 오른쪽 아래 칸에 도달하면, 그것이 바로 최종 정답입니다!
return dp[n][max_weight]
# 직접 짜릿하게 테스트해 봅시다!
if __name__ == "__main__":
# 가방이 찢어지지 않는 최대 하중
knapsack_capacity = 15
# 물품 목록: (이름, 무게, 가치)
survival_items = [
("최고급 구급상자", 4, 10),
("고칼로리 전투식량", 3, 8),
("티타늄 야구방망이", 5, 14),
("특수 방호복", 8, 20)
]
# 위에서 만든 생존 함수를 호출하여 완벽한 결과를 계산합니다.
max_survival_value = survive_zombie_apocalypse(knapsack_capacity, survival_items)
# 계산된 결과를 출력합니다. 정답은 34점이 나와야 합니다!
print("=== 생존 배낭 분석 완료 ===")
print(f"배낭의 최대 허용 하중: {knapsack_capacity}kg")
print(f"달성 가능한 최대 생존 가치: {max_survival_value}점")
한계점 및 실무 개선점
우리가 방금 작성한 코드는 개념을 이해하기에는 아주 훌륭하고 모범적으로 작동합니다. 하지만 방대한 트래픽이 몰리는 실무 환경이나 빡빡한 하드웨어 제약 조건이 있는 시스템에 이 로직을 그대로 적용하려면 한 단계 더 높은 수준의 최적화를 고민해야 합니다.
1. 메모리 폭발의 위험성 (공간 복잡도의 1차원 배열 압축)
위 코드에서는 물건의 개수(N)와 배낭의 최대 용량(W)을 곱한 크기만큼의 거대한 2차원 배열을 메모리에 만들었습니다. 만약 실무에서 다루는 데이터가 물건 10만 개에, 제한 예산이나 무게가 100만 단위로 커진다면 어떻게 될까요? 불쌍한 서버의 램(RAM) 메모리가 버티지 못하고 펑 터져버리는 메모리 부족(Out Of Memory) 장애가 발생하게 됩니다.
수식을 가만히 매의 눈으로 관찰해 보면, 현재 줄(행)을 계산할 때 필요한 정보는 오직 ‘바로 윗줄의 과거 기록’뿐이라는 사실을 깨달을 수 있습니다. 그 이전의 낡은 기록들은 더 이상 쓸모가 없죠. 따라서 2차원 배열 대신 크기가 W+1인 1차원 배열 단 1개만 만들고, 무게를 뒤에서부터 역순으로 갱신해 나가면 공간 복잡도를 O(N*W)에서 O(W)로 획기적으로 압축할 수 있습니다. 대규모 데이터를 처리하는 백엔드 서버 환경에서는 이러한 메모리 절약 테크닉이 서버 유지 비용을 아껴주는 엄청난 실무 팁입니다!
2. 데이터 단위가 실수형(소수점)일 때의 예외 처리
우리가 작성한 수첩의 가로축은 0, 1, 2 같은 딱 떨어지는 정수형 인덱스로 구성됩니다. 만약 실무의 금융 데이터나 센서 데이터처럼 무게가 1.5kg, 3.1415kg 같이 소수점으로 정밀하게 들어온다면 어떻게 될까요? 배열의 인덱스로는 실수를 사용할 수 없으므로 파이썬에서 곧바로 치명적인 에러가 발생합니다.
이럴 때는 모든 무게 데이터에 10이나 100을 곱해서 스케일링 전처리를 통해 정수로 변환한 뒤 계산을 수행하고, 결과가 나온 뒤에 다시 원래 상태로 복원하는 과정을 거치거나, 분기 한정법(Branch and Bound) 같은 다른 탐색 알고리즘을 융합하는 예외 처리(Edge Case) 방안을 실무 설계 시 반드시 고려해야 합니다.