백준 : 2098 외판원 순회
문제 분석
비트마스크와 DP를 이용해 모든 도시를 한 번씩 방문하고 다시 출발지로 돌아오는 최소 비용을 구하는 문제로, 방문 상태를 비트마스크로 관리하면서 현재 도시와 방문 경로를 기반으로 dp 테이블을 갱신해 최적 경로를 찾는다.
비트마스크 활용
n = 도시 개수
비트마스크를 사용하여 0과 1로 도시 n개의 방문 여부를 관리한다.
비트마스크는 도시 개수 n만큼의 자리수를 가지는 이진수로 표현된다.
예시: 0111
비트마스크를 읽는 방법은 오른쪽 → 왼쪽 이다.
(오른쪽이 출발 도시, 왼쪽으로 갈수록 번호가 높은 도시)
- 0 : 도시 3 방문 안 함
- 1 : 도시 2 방문함
- 1 : 도시 1 방문함
- 1 : 도시 0 (출발지) 방문함 (시작 시 초기 세팅)
tsp(visited, current)
에서 초기 호출은 tsp(1, 0)
이다.
여기서 visited = 1
은 이진수로 0001
, 즉 0번 도시만 방문한 상태를 의미한다.
자기 자신을 방문했다고 표시하는 이유
문제는 "모든 도시를 한 번씩 방문하고 출발 도시로 다시 돌아오는 최소 비용"을 요구한다.
따라서 출발 시에 이미 출발 도시를 방문했다고 처리해야 올바르게 방문 횟수를 관리할 수 있다.
시작 도시는 반드시 언젠가 돌아올 예정이므로, 출발할 때 미리 방문했다고 표시한다.
다시 돌아오지 못하더라도 이 방문 처리는 변하지 않고 그대로 유지된다.
Top-Down : 시간초과
재귀와 메모이제이션으로는 55% 에서 시간 초과로 넘어가지 못했다. for문에서 next_city를 돌면서 tsp를 호출하고, visited 가 4개 도시 중 2개만 켜진 상태에서 동일한 visited + current 조합이 서로 다른 경로로 여러 번 재귀 호출 된다. 메모이제이션으로 저장하더라도, 저장하기 “전까지” 중복 호출이 발생해서 시간 초과가 난다.
n = int(input())
cost = [list(map(int, input().split())) for _ in range(n)]
INF = float('inf')
# 도시 n개에 따라 방문 여부 2**n가지로 표현 가능 (이진수)
dp = [[INF]*n for _ in range (1 << n)]
def tsp (visited, current):
# 종료 조건
if visited == (1 << n) - 1 :
return cost[current][0] or INF
# 메모이제이션
if dp[visited][current] != INF :
return dp[visited][current]
for next_city in range(n):
if visited & (1 << next_city) or cost[current][next_city] == 0 :
continue
dp[visited][current] = min(
dp[visited][current],
tsp(visited | (1 << next_city), next_city) + cost[current][next_city]
)
return dp[visited][current]
print(tsp(1, 0))
코드 분석
- 종료 조건 : 모든 도시를 방문했으면 출발지로 돌아가는 비용 반환
if visited == (1 << n) - 1 :
- ✔️ 모든 도시를 방문했는지 확인
- n = 4 일때 ( 1 << n ) 은 10000
- 10000 - 1 = 01111
- 즉 visited의 숫자가 모두 1일때, 모든 도시를 방문했다고 생각한다.
return cost[current][0] or INF
cost[current][0]
: 현재 도시에서 출발지로 가는 비용- 만약 길이 없으면
or INF
를 사용하도록 설정
- 메모이제이션 : 한번 계산한 값은 다시 계산하지 않는다
if dp[visited][current] ≠ INF : return dp[visited][current]
✔️ 이미 계산한 상태면 바로 반환
- 다음 도시 탐색
for next_city in range(n)
다음으로 갈 수 있는 도시 후보들을 전부 다 확인한다
- 가지치기 (방문 여부 & 길 존재 여부 확인)
if visited & (1 << next_city) or cost[current][next_city] == 0
여기서 체크하는 2 가지
1) 이미 방문했는지?
- visited & ( 1 << next_city )
- visited 에서 next_city 비트가 켜져있으면 건너뛴다 (방문함)
2) 길이 존재하는지?
- cost[current][next_city] == 0
- current 에서 next_city로 가는 길이 있는지?
- 길이 없다면 (가중치 0) 이동 불가하니 건너뛴다
✅ 둘 중 하나라도 참이라면 continue로 다음 도시를 검사한다
- 비용 계산 및 dp 테이블 갱신
dp[visited][current]
다음 도시로 이동했을때 비용을 계산하고, 현재 최소 비용과 비교해서 업데이트 한다
검사하는 조건
1) visited | (1 << next_city)
- visited를 업데이트하고 next_city를 방문했다고 기록한다
- 비트 OR 연산으로 방문 표시를 추가 ( | = OR )
2) tsp(새로운 visited, next_city)
- 재귀 호출로 다음 도시로 이동해서 남은 경로 비용을 계산한다
3) cost[current][next_city]
- 현재 도시에서 다음 도시로 이동하는 비용
✅ 현재의 최소 비용과 비교해서 더 작은 값으로 업데이트 한다
Bottom-up : 통과
import sys
input = sys.stdin.readline
n = int(input())
cost = [list(map(int, input().split())) for _ in range(n)]
INF = float('inf')
# 도시 n개에 따라 방문 여부 2**n가지로 표현 가능 (이진수)
dp = [[INF]*n for _ in range (1 << n)]
dp[1][0] = 0 # 출발: 도시 0에서 시작하고, 방문 기록은 도시 0만 방문한 상태
def tsp (visited, current):
for visited in range((1<<n)):
for current in range (n):
if dp[visited][current] == INF :
continue
for next_city in range (n):
if visited & (1 << next_city) :
continue
if cost[current][next_city] == 0 :
continue
next_visited = visited | (1 << next_city)
dp[next_visited][next_city] = min (
dp[next_visited][next_city],
dp[visited][current] + cost[current][next_city]
)
return dp[visited][current]
tsp(1, 0)
visited_all = (1<<n) - 1
answer = INF
for city in range(n):
if cost[city][0] == 0 :
continue
answer = min (answer, dp[visited_all][city] + cost[city][0])
print(answer)
코드 분석
DP 테이블 채우기 (탐색)
탐색은 바텀업 방식으로 진행한다.
for visited in range((1 << n))
: 방문 상태를 모두 순회한다.for current in range(n)
: 현재 도시를 순회한다.if dp[visited][current] == INF
: 아직 도달하지 못한 상태면 스킵한다.for next_city in range(n)
: 다음으로 이동할 도시 후보들을 모두 검사한다.- 가지치기 조건으로 두 가지를 확인한다.
- 5-1.
visited & (1 << next_city)
: 이미 방문한 도시는 패스 - 5-2.
cost[current][next_city] == 0
: 현재 도시에서 next_city 로 가는 길이 없는 경우 패스
- 5-1.
- 다음 방문 상태는
next_visited = visited | (1 << next_city)
로 갱신한다. - dp 갱신은
dp[next_visited][next_city] = min(dp[next_visited][next_city], dp[visited][current] + cost[current][next_city])
으로, 기존 값과 현재 경로의 비용을 비교하여 최소값으로 업데이트한다.
최종 정답 계산
- 모든 도시를 방문한 상태는
visited_all = (1 << n) - 1
로 나타낸다. for city in range(n)
으로 마지막 도시를 순회하면서answer = min(answer, dp[visited_all][city] + cost[city][0])
로 최소 비용을 구한다.- 출력은
print(answer)
로 수행한다.
Bottom-Up + BFS (큐) : 통과
from collections import deque
import sys
input = sys.stdin.readline
n = int(input())
cost = [list(map(int, input().split())) for _ in range(n)]
INF = float('inf')
dp = [[INF]*n for _ in range (1 << n)]
# 큐 초기화
queue = deque()
dp[1][0] = 0
queue.append((1, 0))
# 큐가 빌때까지 반복
while queue :
visited, current = queue.popleft()
# 다음 도시를 탐색
for next_city in range(n):
# 이미 방문했으면 패스
if visited & (1 << next_city):
continue
# 길이 없으면 패스
if cost[current][next_city] == 0 :
continue
# 새로운 visited 갱신
next_visited = visited | (1 << next_city)
# 새로운 비용 계산
new_cost = dp[visited][current] + cost[current][next_city]
# 새로운 비용이, 기존 비용보다 작으면 dp를 갱신하고 큐에 추가
if dp[next_visited][next_city] > new_cost :
dp[next_visited][next_city] = new_cost
queue.append((next_visited, next_city))
answer = INF
visited_all = (1 << n) - 1
# 모든 도시를 방문한 상태에서 출발 도시로 돌아오는 비용 확인
for city in range(n):
if cost[city][0] == 0 :
continue
answer = min(answer, dp[visited_all][city] + cost[city][0])
print(answer)
비트마스크 DP 를 활용해서 외판원 순회 문제를 해결하는 방식 중 하나는 "큐를 사용하여 visited 상태를 확장" 하는 방법이다. 기존 bottom-up 방식에서는 for visited ~
으로 순서를 정해준 방법과의 차이점이다.
코드 분석
큐를 이용하면, next_visited 를 큐에 추가하면서 visited 가 커지는 방향으로 탐색할 수 있다.
이 과정은 BFS 와 유사하다. 상태 공간을 넓혀 가면서 점점 visited 비트가 더 많이 켜진 상태로 확장한다
시간복잡도
- 다만 기존의 bottom-up 방식보다는 더 느리다.
- 탑-다운 방식의 중복 호출을 제거했지만, 큐 연산 오버헤드 + 메모리 접근이 이루어지기 때문이다.
- 큐에서 상태를 pop (O(1))
- 다음 상태를 append (O(1))
- push/pop의 cost가 누적되고, 이 과정이 메모리 locality가 떨어진다.
- 이론적으로는 시간 복잡도는 O(n^2 * 2^n)으로 동일.
- 초기 상태:
- visited = 0001 (도시 A 만 방문)
- current = A
- dp[visited][current] = 0
- 큐에 (visited, current) 를 추가
- 큐가 빌 때까지 반복:
- 큐에서 상태 (visited, current) 를 pop (O(1))
- visited 에서 갈 수 있는 next_city 를 모두 탐색
next_visited = visited | (1 << next_city)
로 방문한 도시를 추가new_cost = dp[visited][current] + cost[current][next_city]
- 만약 new_cost 가 현재 dp[next_visited][next_city] 보다 작으면, dp 를 갱신하고 큐에 push
- 탐색 방향:
- 큐에 next_visited 를 넣으면서 visited 비트가 점점 켜지는 방향으로 확장
- visited = 1111 (모든 도시 방문) 상태까지 큐에 쌓인다
- visited = 1111 인 상태는 여러 번 큐에 들어가지만, dp 테이블은 "최소 비용" 으로만 갱신되므로 불필요한 갱신은 일어나지 않는다.
- 최종 정답 구하기:
- visited = 1111 (모든 도시 방문) 상태에서 마지막 도시 city 를 for 문으로 순회
dp[visited_all][city] + cost[city][0]
로 출발 도시로 돌아오는 비용을 계산answer = min(answer, 위의 값)
- for 문을 다 돌고 나면 answer 에는 최소 비용만 남는다
While 문의 경로 탐색
결론 : dp[1111][C] 인 경우가 최소 경로로 채택된다.
이때 마지막 A → … → C → A 로 갈 수 있는 경로는 노란색으로 표기해 둔 경로가 있다.
A → B → D → C → A 로 가는 경우
A → D → B → C → A 로 가는 경우
이 중 비용이 최소인 경우는 첫 번째로 답인 35를 도출할 수 있다.
'IT 성장기 (교육이수) > 알고리즘 문제풀이' 카테고리의 다른 글
백준 : 1379 강의실2 (0) | 2025.04.10 |
---|---|
백준 : 2665 미로 만들기 (0) | 2025.03.31 |
백준 : 14888 연산자 끼워넣기 (0) | 2025.03.30 |
백준 : 1260 DFS와 BFS (0) | 2025.03.29 |
백준 : 1197 최소 스패닝 트리 (0) | 2025.03.29 |