프로그래머스의 배달 문제다.
주어진 비방향 가중치 그래프에서 출발 노드로부터 일정 비용 이내로 도달할 수 있는 노드들의 갯수를 세는 것이 목적이다. 이전에 풀었던 그래프 문제들은 노드의 연결 정보만 주어졌기 때문에 하나의 사전 자료형을 이용하여 풀 수 있었지만 이번에는 가중치도 같이 주어지기 때문에 별도의 자료구조로 이를 유지해야 한다.
예를 들어 위와 같은 그래프가 주어질 때 1번 노드에서 3의 비용으로 도달할 수 있는 노드는 1, 2, 4, 5가 될 것이다.
이는 결국 1번 노드부터 다른 노드까지의 최단거리를 구하는 것과 동일하다. 처음에는 DFS 탐색을 시도했지만 몇 개의 테스트 케이스가 계속 실패했기 때문에 조사해본 결과 다익스트라 알고리즘을 사용하여 풀어야 한다는 것을 알 수 있었다.
다익스트라는 아직 공부하지 않았기 때문에 이곳을 참조하여 풀 수 있었다.
처음으로 시도했던 DFS 탐색 방식이다. 1번 노드부터 일정 비용 이내로 도달할 수 있는 노드를 탐색한다는 것에서 착안하여 재귀 호출을 이용한 DFS 탐색으로 구현하였는데 아쉽게도 몇몇 테스트 케이스를 실패했다.
import collections
def solution(N, road, K):
global costs, links, max_cost
# 노드 i부터 노드 j까지 이동하는 비용을 저장하는 사전 자료형.
# 키로 노드 i, 값으로 다른 노드까지 이동하는 비용의 리스트를 저장.
# ex) costs[1][3]: 노드 1에서 노드 3까지 이동하는 비용.
costs = {village: [10001]*(N+1) for village in range(1, N+1)}
# 노드 i에서 이동할 수 있는 이웃 노드들을 저장하는 사전 자료형.
# 키로 노드 i, 값으로 이웃 노드를 담는 set을 저장.
links = collections.defaultdict(set)
# 한계 비용.
max_cost = K
# 주어진 그래프 정보를 읽어서 구축.
for road_info in road:
departure, destination, cost = road_info
# 비방향이기 때문에 이웃 노드들을 양쪽 노드에 구축.
links[departure].add(destination)
links[destination].add(departure)
# 마찬가지로 비용도 양쪽 노드에 구축.
# 노드 간 비용은 여러번 주어질 수 있기 때문에 최소의 비용으로 설정.
costs[departure][destination] = min(cost, costs[departure][destination])
costs[destination][departure] = min(cost, costs[destination][departure])
# 한계 비용을 넘지 않고 방문할 수 있는 노드들의 집합.
availables = set()
for neighbor in links[1]: # 1번 노드의 이웃 노드부터 탐색.
# 각 이웃 노드마다 탐색한 결과를 방문할 수 있는 노드들의 집합에 합산.
visited = set([1])
find_available_villages(costs[1][neighbor], neighbor, visited)
availables = availables.union(visited)
# 방문할 수 있는 노드들의 갯수를 반환.
return len(availables)
def find_available_villages(current_cost, current_village, visited_villages):
# 현재 노드까지 도달한 비용이 한계 비용을 초과했거나 이미 방문한 노드일 경우 중단.
if current_cost > max_cost or current_village in visited_villages:
return
# 방문한 노드 집합에 현재 노드를 추가.
visited_villages.add(current_village)
# 현재 노드의 이웃 노드에 다시 재귀 호출 탐색.
for neighbor in links[current_village]:
# 현재까지의 비용과 이웃 노드의 이동 비용의 합을 전달.
find_available_villages(current_cost + costs[current_village][neighbor], neighbor, visited_villages)
프로그래머스는 따로 테스트 케이스를 보여주지 않기 때문에 무슨 반례가 있었는지는 영원히 알 수 없을 것이다.
언급했듯이 이 풀이는 다른 블로그의 풀이를 그대로 작성한 것이다. 이해를 위해 한 줄씩 직접 작성하였고 위키피디아에 정의된 다익스트라 알고리즘과 비교하면서 학습하였다.
다익스트라 알고리즘은 다음과 같다.
이런 다익스트라 알고리즘을 위 문제에 어떻게 적용할 수 있을까? 이 문제에서는 배달 비용 제한이 주어진다. 즉 현재 노드가 방문했던 노드인 것 뿐 아니라 현재 노드까지 탐색 비용이 한계를 초과한다면 탐색을 종료하는 조건을 추가해주면 된다.
import collections
def solution(N, road, K):
# 기준 노드부터 다른 노드까지 최종 비용을 저장하는 배열.
# costs[i] = 1번 노드에서 i번 노드까지의 이동 비용.
costs = [500001] * (N+1)
costs[1] = 0
# 현재 노드의 이웃 노드 목록을 저장하는 집합.
# links[i] = i번 노드의 이웃 노드 집합.
links = collections.defaultdict(set)
# 한 노드에서 이웃 노드까지 이동하는 비용을 저장해 둔 배열.
# fees[i][j] = i번 노드에서 j번 노드까지 이동하는 비용.
fees = [[]]
for _ in range(N):
fees.append([10001] * (N+1))
# 문제에서 주어진 그래프 정보를 파싱.
for road_info in road:
departure, destination, fee = road_info
# 비방향이기 때문에 양쪽 노드 모두에 등록.
links[departure].add(destination)
links[destination].add(departure)
# 노드 간 이동 거리는 복수가 주어질 수 있기 때문에 그중 최소값 사용.
fees[departure][destination] = min(fee, fees[departure][destination])
fees[destination][departure] = min(fee, fees[destination][departure])
# 탐색을 위한 큐(데크) 구축.
queue = collections.deque()
queue.append(1)
# 다익스트라 알고리즘 기반 탐색 진행.
while queue:
current_village = queue.popleft()
# 현재 노드의 이웃 노드들에 대하여 탐색.
for neighbor in links[current_village]:
# 기존에 파악된 비용과 현재 노드에서 다시 계산된 비용 비교.
cost = costs[current_village] + fees[current_village][neighbor]
# 현재 노드에서 이웃 노드로 이동하는 비용: cost
# 다른 노드에서 계산된 해당 노드로 이동하는 비용: costs[neighbor]
# 다른 노드에서 이웃 노드로 이동하는게 더 적은 비용이라면 탐색하지 않음.
# 또는 이동 비용이 문제에서 요구하는 한계를 초과하면 탐색하지 않음.
if cost >= costs[neighbor] or cost > K:
continue
# 위의 조건에 해당되지 않는다면 이웃 노드로 이동하는 비용을 갱신.
costs[neighbor] = cost
# 탐색 대상 노드에 이웃 노드 추가.
queue.append(neighbor)
# 문제에서 요구하는 비용을 만족하는 노드의 갯수 반환.
return len([cost for cost in costs if cost <= K])
위에서 언급한 다익스트라 알고리즘과는 조금 다르게 방문한 노드를 따로 기억하진 않았다. 그러나 [i][j]+[j][k]와 [i][k]를 비교한다는 느낌으로 알고리즘을 구축하고 문제에서 요구하는 비용의 한계도 조건에 추가하는 방식으로 구현한다는 것을 알 수 있었다.
기본적인 출처는 언급했듯이 이곳이다. 내가 스스로 구현한 코드가 아닐지라도 많은 도움이 되는 경험이었다.
위 코드에서 특이한 점은 노드 간 이동 비용을 저장하는 fees 2차원 배열 코드다. 이는 파이썬에서도 얕은 복사, 깊은 복사가 일어나기 때문인데 원래는 다음처럼 작성했었다.
fees = [[100001] * (N+1)] * (N+1)
이는 [100001] * (N+1)로 생성한 리스트 객체를 다시 N+1개 만큼 생성해서 리스트로 저장한다. 이 경우 리스트 안의 리스트 구조를 띄게 되는데 내부의 리스트는 100001이라는 정수값이 N+1개 복사된 것이기 때문에 별 문제가 없지만 외부 리스트의 경우 모든 요소들이 같은 객체를 가리키는 문제가 발생한다.
>>> a=[0]*5
>>> a
[0, 0, 0, 0, 0]
>>> a[1]=12345
>>> a
[0, 12345, 0, 0, 0]
예를 들어 위처럼 정수값을 곱셈 연산자로 여러개 생성한 경우 각각의 요소는 원시 타입이기 때문에 한 요소에 변경이 일어나도 다른 요소에 반영되지 않는다.
>>> b=[[0]*5]*5
>>> pprint(b)
[[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0]]
>>> b[0][1]=12345
>>> pprint(b)
[[0, 12345, 0, 0, 0],
[0, 12345, 0, 0, 0],
[0, 12345, 0, 0, 0],
[0, 12345, 0, 0, 0],
[0, 12345, 0, 0, 0]]
>>>
그러나 위처럼 리스트를 다시 여러개 생성한 경우 얕은 복사가 일어나기 때문에 한 요소가 변경되면 다른 요소까지 같이 변경된다.
>>> id(b[0])
2391005880512
>>> id(b[1])
2391005880512
>>> id(b[2])
2391005880512
>>> id(b[3])
2391005880512
>>> id(b[4])
2391005880512
이는 리스트의 각 요소가 전부 동일한 요소를 가리키고 있기 때문인데 실제로 확인해보면 전부 동일한 id 값을 갖는 것을 볼 수 있다. 그렇기 때문에 다음처럼 수동으로 별도의 리스트를 생성해줘야 한다.
b = []
for _ in range(5):
b.append([0]*5)
이 경우 외부 리스트의 각 요소는 서로 다른 요소기 때문에 이전처럼 서로 간섭하지 않는 것을 볼 수 있다
>>> pprint(b)
[[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0]]
>>> b[0][1]=12345
>>> pprint(b)
[[0, 12345, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0]]
>>> id(b[0])
2391005901440
>>> id(b[1])
2391007229696
>>> id(b[2])
2391007229632
>>> id(b[3])
2391007230336
>>> id(b[4])
2391007230080
다익스트라 알고리즘은 DP처럼 이론적으로만 들어보고 실제로 활용해서 문제를 풀어본 적은 없는 개념인데 이렇게 문제를 베껴서라도 한 번 풀어보니 의사 코드나 개념을 읽는것보다 실제로 코드를 작성하는게 더 이해하기 쉬운 것 같다.
얼른 다양한 분야의 지식을 학습해야겠다.