* 해당 포스트는 <파이썬 알고리즘 인터뷰> 공부 후 정리 목적으로 작성되었습니다. *

 

leetcode 104번 ( https://leetcode.com/problems/maximum-depth-of-binary-tree/ )

 

교재를 참고하지 않은 풀이: 재귀

 

input으로는 TreeNode 타입의 root 노드 하나가 주어지고, 이를 통해 트리 전체의 깊이를 알아내는 문제이다. 

root 노드부터 아래로 내려가면서 트리 전체를 탐색해야 하므로 재귀 함수로 구현할 수 있다. 

 

탐색하는 노드의 값이 없거나, 탐색하는 노드의 left, right child 노드가 모두 없다면 탐색을 중단하고 현재까지 탐색한 깊이 값을 리턴한다.

탐색하는 노드의 값이 있다면 현재 깊이에 1을 더해준다. 

 

특정 노드의 높이는 현재 노드에서부터 leaf 노드에 도달하는 데 필요한 레벨(경로) 개수이다. 이때 left subtree와 right subtree의 leaf까지의 경로 수가 다를 수 있는데 이 경우 더 긴 쪽을 골라야 한다. 

 

class Solution(object):

    def maxDepth(self, root):

        if root is None:
            return 0
        
        def traverse(node, count):
            if node is None:
                return count

            if node.left is None and node.right is None:
                return count
            
            return max(traverse(node.left, count+1), traverse(node.right, count+1))

        return traverse(root, 1)

 

교재를 참고하지 않은 풀이: BFS

트리의 레벨을 탐색하는 경우 BFS로도 풀 수 있다. 

DFS는 스택으로 구현하며 LIFO 구조이고, BFS는 큐로 구현하므로 FIFO 구조이다. 

root 노드에서 시작해서 가장 위의 노드들부터 큐에 넣을 것이고, FIFO 구조이므로 그 노드들이 먼저 나올 것이다. 

 

참고로 파이썬에서 큐를 구현할 때는 데크(deque) 자료형을 사용하는 것이 일반적이다. 파이썬이 아닌 C언어로 구현된 모듈이기도 하고, 정확히는 큐가 아닌 데크이기 때문에 맨 앞과 맨 뒤에서 원소를 제거, 추가하는 것이 모두 가능하기 때문이다. 

 

맨 처음 root 노드를 큐에 넣은 상태가 시작 상태이다. 

deque = collections.deque([root])

 

BFS는 재귀함수로 구현할 수 없으므로 반복문을 사용한다. while문으로 큐가 빌 때까지 실행한다. 

먼저 큐의 맨 앞에서 원소를 하나 뺀다. 맨 처음은 root 노드가 될 것이다. 

그리고 이 노드의 left, right child가 있는지를 각각 확인하고 있다면 큐의 맨 뒤에 추가한다. 

current = queue.popleft()

if current.left:
    queue.append(current.left)
if current.right:
    queue.append(current.right)

 

주의할 점은 현재 큐에 있는 모든 노드를 대상으로 해당 노드들의 left, right child를 추가해야 한다. 그래야 큐에 레벨 순서대로 노드들이 들어올 수 있다. 

즉 while문이 한 번 실행될 때마다 이전 레벨의 노드를 큐의 앞쪽에서부터 전부 제거하고, 그 다음 레벨의 노드를 큐의 맨 뒤에 추가한다고 볼 수 있다. 현재 큐에 있는 모든 노드들에 대해서 위의 로직을 적용하려면, while문 내부에 별도의 for loop를 선언해서 큐의 길이만큼 반복해 주면 된다. 

 

* 해당 포스트는 <파이썬 알고리즘 인터뷰> 공부 후 정리 목적으로 작성되었습니다. *

 

leetcode 787번 ( https://leetcode.com/problems/cheapest-flights-within-k-stops/ )

 

DFS를 사용한 풀이(시도 but 실패)

더보기
class Solution(object):

    def findCheapestPrice(self, n, flights, src, dst, k):
        visited = set()
        graph = collections.defaultdict(list)
        for a, b, c in flights:
            graph[a].append([b, c])

        totalPrice = [2**31 -1]

        def dfs(node, price, times):
            if node in visited:
                return
            if node == dst:
                totalPrice[0] = min(totalPrice[0], price)
            if times > k:
                return
            else:
                times += 1
                while graph[node]:
                    entry = graph[node].pop(0)
                    dfs(entry[0], entry[1]+price, times)
                times -= 1
                visited.add(node)

        dfs(src, 0, 0)
        if (totalPrice is None) or totalPrice[0] == 2**31-1:
            return -1
        return totalPrice[0]

 

접근 방법

스택으로 DFS를 구현하고 다익스트라 알고리즘을 사용해서 풀 수 있다. 

 

다익스트라의 경우 이전 문제처럼 최소 힙을 구현한 heapq 모듈을 사용해서 풀 수 있다. 

다익스트라 알고리즘은 단순히 노드를 순회하면서 거리(dist) 값을 더 적은 것으로 업데이트하는 것이 아니라서 헷갈릴 수 있다. 탐색할 다음 노드를 정할 때 임의로 정하지 않고, 현재 노드에서 가장 거리가 적은 노드로 업데이트를 해야 한다. 

직접 구현하는 방법도 있지만 대부분 가장 거리가 적은 다음 노드를 불러올 때 최소 힙 등이 구현된 모듈을 사용하면 시간을 절약할 수 있다. 

 

구체적인 순서

1. 탐색 중인 노드에 대해서 출발점부터 현재 노드까지의 거리(price)와 탐색한 횟수(k) 정보를 저장하고 있는 변수를 만든다. 이때 key는 현재 노드, value는 리스트 형식으로 [현재 노드와 인접한 노드, 그 노드까지의 거리]를 나타낸다.  

graph = collections.defaultdict(list)
    for a, b, c in flights:
        graph[a].append([b, c])

 

2. heapq 모듈을 적용하기 위한 튜플-리스트 변수(여기서는 Q)를 선언한다. 이때 리스트 내부의 튜플은 (거리, 현재 노드, 탐색 횟수)를 정보로 갖고 있다. 거리를 제일 앞에 두는 이유는 heapq 모듈에 원소를 넣고 정렬할 때 튜플의 가장 앞 순서를 가장 우선으로 정렬하기 때문이다. 

times = 0
Q = [(0, src, times)]

 

그리고 스택으로 DFS 탐색을 할 경우 while문을 사용하며 스택 변수에 원소가 없어질 때까지 특정 로직을 반복한다. 

while Q:
    // 내부 로직

 

while문 내부 로직

3. min heap의 특성상 힙 내부에서 가장 거리가 작은 값만 리턴한다. 따라서 이 값들 중 dst(도착점)과 같은 노드가 있다면 탐색을 종료해도 무방하다. -> 해당 로직 만들기

price, node, times = heapq.heappop(Q)
if node == dst:
    return price

 

4. 또한 최소 힙에서 나온 탐색 횟수의 값이 제한된 탐색 횟수(k)보다 많다면 해당 노드에 대해선 DFS 탐색을 더 이상 실행하지 않는다. 

5. 위 조건을 만족할 경우 해당 노드의 주변 노드에 대해 탐색을 실행한다. 해당 노드의 주변 노드가 무엇인지 알기 위해서는 1번에서 선언한 변수를 사용한다. 

if times <= k:    
    times += 1
    for b, c in graph[node]:
        alternative = price + c
        heapq.heappush(Q, (alternative, b, times))

 

6. while문이 끝나도 도착점을 찾지 못했으면 -1을 리턴한다. 

 

불필요한 탐색 방지 로직

(다른 블로그를 참고했습니다.)

그런데 이대로 구현하면 input 개수가 많은 테스트케이스에 의해서 타임아웃이 뜬다. 로직은 맞지만 불필요한 탐색이 실행되고 있기 때문이다. (여러 노드가 얽힌 경우 순환 구조가 만들어지면서 이미 탐색한 노드를 또 탐색하게 된다.) 따라서 추가적인 로직이 필요하다. 

이미 탐색을 진행했고 노드의 값(price)이 최소 힙에서 나온 값보다 작다면, 이미 최소값에 대해서 탐색이 끝난 노드이므로 추가로 탐색할 필요가 없다. 구현하기 위해서는 어떤 노드를 탐색했고, 해당 노드의 값이 얼마인지를 저장하는 변수가 필요하므로 빈 딕셔너리 변수를 선언하자. 

visited = {}
if node not in visited or visited[node] < times:
    visited[node] = times

 

 

참고한 포스트

https://8iggy.tistory.com/115

 

 

 

 

* 해당 포스트는 <파이썬 알고리즘 인터뷰> 공부 후 정리 목적으로 작성되었습니다. *

 

leetcode 743번 ( https://leetcode.com/problems/network-delay-time/ )

 

시도

더보기
class Solution(object):

    def networkDelayTime(self, times, n, k):
        if n == 1:
            return 0

        queue = collections.deque()
        visited = set()
        graph = collections.defaultdict(list)
        for a, b, c in times:
            graph[a].append([b, c])

        dist = collections.defaultdict(lambda:float('inf'))
        dist[k] = 0
        count = 0
        queue.append(k)

        while queue:
            node = queue.popleft()
            for k, v in graph[node]:
                if k not in visited:
                    dist[k] = min(dist[k], dist[node]+v)
                    queue.append(k)
            visited.add(node)
        
        result = max(dist, key=lambda x:dist[x])
        return dist[result] if len(dist) == n  else -1

 

풀이. (교재 참고)

다익스트라(Dijkstra) 알고리즘을 구현하는 문제이다. 

다익스트라 알고리즘은 BFS 탐색으로 구현되며, DFS와 달리 재귀함수로 처리할 수 없다. BFS 탐색을 할 때에는 큐를 사용한다. 

 

노드 A에 대해서 BFS를 실행하는 경우, A의 주변 노드의 값을 모두 업데이트하고 그 주변 노드들을 큐에 추가한다.

이후 A를 큐에서 제거하는 방식으로 구현한다. 

 

다만 A를 제거하고 큐에서 다음 값을 불러올 때는 값이 가장 작은 노드 순서대로 불러와야 한다. 

이런 정렬을 매번 따로 할 수도 있겠으나 교재에서는 힙(heap)heappush(), heappop()을 사용해서 힙 내부에서 자동으로 정렬이 되도록 구현하였다. 

 

우선, 힙을 사용하기 전에 노드 A와 A에서 신호를 보낼 수 있는 노드, 신호를 보내는 데 걸리는 시간 정보를 더 효율적인 자료구조에 넣을 필요가 있다. 

딕셔너리를 사용해서 노드 A를 key값으로, 뒤의 두 값을 하나의 튜플로 묶어서 A를 key값으로 하는 리스트의 원소로 추가하자. 

graph = collections.defaultdict(list)

for u, v, w in times:
    graph[u].append((v, w))

 

출발 노드 k로부터 노드 A까지 신호가 도달하는 시간을 저장하는 변수도 필요하다. O(1) 만에 조회할 수 있는 딕셔너리 형식으로 선언한다. 

값을 변경할 때는 기존의 값과, 주변 노드의 시간을 참고해서 새로 계산된 값 중에서 더 작은 값으로만 변경될 수 있도록 해야 한다. 

여기서는 힙을 사용하기 때문에 힙 내부에서 이미 정렬이 되고, 시간이 오래 걸리는 경우 힙의 뒤 순서에 위치하게 된다. 또한 이미 탐색한 노드는 탐색하지 않는 방법으로 이런 오류가 발생할 가능성을 없앴다. 

 

또한 모든 노드를 탐색할 수 있는 경로가 없다면 -1을 리턴해야 한다. 모든 노드가 탐색되지 않은 경우 dist 딕셔너리의 길이가 n개 미만일 것이므로, dist 길이를 확인해서 -1을 리턴할지, 최댓값을 리턴할지를 판단하자. 

return max(dist.values()) if len(dist) == n else -1

 

* 해당 포스트는 <파이썬 알고리즘 인터뷰> 공부 후 정리 목적으로 작성되었습니다. *

 

leetcode 207번 ( https://leetcode.com/problems/course-schedule/description/ )

 

풀이 (교재 참고)

그래프를 DFS로 탐색하면서 순환 구조(cycle)가 있는지 판별하는 문제이다. 

예를 들어서 코스 A, B, C가 있을 때 A->B, B->C, C->A 식으로 선수강과목이 있다면 모든 코스를 수강할 수 없다. A, B, C를 그래프의 각 노드로 보았을 때 순환 구조가 있기 때문이다. 

 

이를 확인하기 위해서는 한 과목이 다른 어떤 과목들을 선수강과목으로 갖고 있는지를 정리한 변수가 필요하다. 

해시 테이블(hash table)로 구현된 딕셔너리 자료형을 사용해서 key 값으로는 과목을, value 값으로는 해당 과목의 선수강과목 리스트를 갖게 하자. 

courses = collections.defaultdict(list)

 

예를 들어 A과목이 B, C과목을 선수강과목으로 갖고 있다면 다음과 같이 표현할 수 있다. 

courses[A] = [B, C]

 

이후 A과목의 선수강과목인 B, C 과목에 대해서 DFS 탐색을 하고, 또 B과목과 C과목의 선수강과목들에 대해서 DFS 탐색을 하고... 이런 식으로 재귀적으로 DFS 탐색을 하는 과정에서 중간에 A나 B, C과목이 중복으로 탐색된다면 그래프 내에 순환구조가 있는 것이므로 False를 리턴한다. 전혀 중복이 없다면 True를 리턴한다. 

 

중첩 함수 dfs()를 선언해서 cycle이 있으면 False, 없으면 True를 리턴하도록 한다. 

이때 dfs()는 개별 노드를 매개변수로 받고, 한 노드와 연결된 다른 노드들에 대해서 DFS 탐색을 모두 실행했을 때 cycle이 전부 없다면 True, 하나라도 있다면 False를 리턴한다. 

def dfs(node):
    if cycle:
    	return False
    else:
    	return True

 

순환 구조를 탐색하기 위해서는 현재 탐색중인 DFS 경로에 어떤 노드들이 있는지를 계속 저장하고, 새 노드를 추가할 때마다 탐색 중인 DFS 경로에 이미 있는 노드와 겹치지 않는지 확인하면 된다. 

탐색하는 노드들은 순환이 존재하지 않는 한 전부 다른 노드들이므로 set()을 사용해서 집합 형태로 선언해도 된다. 

traced = set()

traced.add(node)
dfs(node)
traced.remove(node)

 

그리고 한 경로의 DFS 탐색이 끝나면 해당 노드를 traced에서 제거해야 한다. 제거하지 않으면 이후에 다른 경로로 DFS 탐색을 할 때, 전혀 다른 경로에서 탐색했던 노드를 cycle로 잘못 판단할 수 있다. 

 

그런데 이 경우 DFS 탐색을 여러 번 하면서 이미 탐색했던 노드를 불필요하게 여러 번 탐색할 수 있다. 따라서 이미 탐색한 노드는 더 이상 DFS 탐색을 하지 않고, True(cycle 없음)를 리턴해 주면 탐색 시간을 줄일 수 있다. 

visited = set()

dfs(node)
visited.add(node)

 

위에서 선언한 traced와 visited를 사용해서, 만약 노드가 traced에 있다면 탐색 중인 경로에 있었던 것이므로 순환 구조가 있는 것이다. 이 경우 False를 리턴한다. 

만약 노드가 visited에 있다면, 이미 해당 노드에 대해선 DFS 탐색이 완료된 노드이므로 더 이상 탐색을 할 필요가 없다. 그러므로 True를 리턴한다. 

def dfs(node):
    if node in traced:
        return False
    if node in visited:
        return True

 

확인을 한 이후 해당 노드에 대해서 DFS 탐색을 시작한다. 

우선 traced 변수에 노드를 추가하고, 해당 노드의 모든 선수강과목들에 대해서 DFS 탐색을 한다. 하나라도 False가 리턴되었다면(cycle이 있다면) False를 리턴한다. 

traced.add(node)

for c in courses[node]:
    if not dfs(c):
        return False

 

전부 True가 리턴되었다면 해당 노드에 대해서는 순환구조가 없는 것이므로 더 이상 경로를 탐색하지 않을 것이기 때문에 traced 변수에서 해당 노드를 제거한다. 

또한 앞으로 해당 노드는 다시 탐색할 필요가 없기 떄문에 visited 변수에다가 해당 노드를 추가한다. 

traced.remove(node)
visited.add(node)

 

dfs() 함수를 이렇게 구현한 뒤, for loop로 딕셔너리 courses를 순회하면서 선수강과목이 있는 모든 노드에 대해서 DFS 탐색을 실행하면 된다. 

for elem in list(courses):
    if not dfs(elem):
        return False

return True

 

+ 부록

마지막 for loop로 순회하는 부분에서, 딕셔너리 변수를 그대로 쓰지 않고 list()로 한 번 묶은 이유는 무엇일까?

딕셔너리 변수를 그대로 사용할 경우, 'iteration 중간에 딕셔너리의 크기가 변경되었다'는 오류가 발생한다. 그 이유는 dict()이 아니라 collections.defaultdict()으로 딕셔너리를 선언했기 때문이다. 

 

collections.defaultdict()은 dict()와 달리 조회하는 key값이 없을 경우 오류를 리턴하지 않고 key-value 쌍을 기본으로 생성한다. 그러므로 딕셔너리 값이 변경되기 때문에 에러가 발생한 것이다. 

 

list()를 사용하면 id가 다른 딕셔너리의 복사본을 만들 수 있고, courses는 변경되지만 list(courses)는 원본이 아닌 복사본이라 변경되지 않으므로 에러가 발생하지 않는다. 

 

* 해당 포스트는 <파이썬 알고리즘 인터뷰> 공부 후 정리 목적으로 작성되었습니다. *

 

leetcode 332번 ( https://leetcode.com/problems/reconstruct-itinerary/ )

 

시도한 방법

풀이에는 실패하였지만 시도한 방법은 기록해 두었다. 전체 코드는 다음과 같다. 

더보기
class Solution(object):

    def findItinerary(self, tickets):

        def dfs(elem, path):
            if len(path) >= (len(tickets)+1):
                result.append(path[:])
                return
            
            for e in route[elem]:
                route[elem].remove(e)
                dfs(e, path + [e])
                route[elem].insert(0, e)

        result = []
        min_result = None
        route = collections.defaultdict(list)
        for t in tickets:
            route[t[0]].append(t[1])
        
        for k in route.keys():
            dfs(k, [k])

        min_result = min(result, key=lambda x: "".join(x))
        return min_result

 

풀이 1. (교재 참고)

시도한 방법처럼 그래프에서 DFS를 통해 재귀적으로 탐색한다. 다만 앞서 시도한 방법에서는 마지막에 min() 함수로 알파벳 순 정렬을 하려고 했지만, 교재에서는 아예 주어지는 tickets 리스트를 알파벳 순서대로 정렬한 뒤에 탐색하고 있다. 

전자의 경우 탐색 가능한 여러 개의 방법이 전부 result라는 변수에 담기는 반면, 후자의 경우는 알파벳 순서상 제일 빠른 방법만 담기게 되어서 후에 추가로 정렬을 안 해도 된다. 

 

여기서도 딕셔너리 변수 graph를 만들어서 key값 노드에서 이동할 수 있는 노드들을 리스트 형태로 value에 추가한다. 

예를 들어서 tickets 변수에 ["AAA", "BBB"], ["AAA", "CCC"] 가 포함되어 있다면,

graph["AAA"] = ["BBB", "CCC"] 가 된다. 

for key, value in sorted(tickets):
    graph[key].append(value)

 

graph 딕셔너리에 key값이 있는지 없는지 확인하지 않고 바로 value 값을 추가할 수 있는 이유는 graph 변수를 dict()이 아니라 collections.defaultdict()으로 선언하였기 때문이다. 

collections.defaultdict()의 경우 찾는 key가 없어도 오류를 리턴하지 않는다. 반면 dict()은 없는 key를 조회하면 오류가 발생한다. 

 

이제 dfs()를 호출해서 탐색을 시작한다.

하나의 티켓을 사용할 경우 graph 변수에서 해당 (key, value) 쌍을 제거해서 다음번에는 사용할 수 없도록 한다. 

앞서 graph의 각 key에 해당하는 value인 리스트의 각 원소들을 알파벳 순으로 정렬하였기 때문에, 탐색할 때도 앞에서부터 탐색해 주어야 한다.

 

리스트는 투 포인터가 아니기 때문에 pop(제거하려는 index) 으로 특정 인덱스의 값을 제거할 수 있다. 맨 앞의 원소를 제거하려면 pop(0)을 사용하고, 이 때의 시간복잡도는 O(n)이다. 리스트를 순회하면서 특정 인덱스를 찾아야 하기 때문이다. 

 

while graph[a]:
    dfs(graph[a].pop(0))

 

탐색할 경로가 더 이상 없을 경우 현재 위치의 노드부터 결과 변수인 route에 추가한다. 

그러면 경로의 맨 끝 지점부터 추가되기 때문에, 값을 리턴할 때에는 route 리스트의 역순으로 리턴하여야 한다. 

문자열 슬라이싱을 사용하면 간단하게 입력할 수 있다. 

return route[::-1]

 

풀이 2. (교재 참고)

두 번째 풀이는 첫 번째 풀이와 거의 같은데, 첫 번째 풀이 속도를 높이는 방법이다. 

앞서 알파벳 순으로 tickets를 정렬한 뒤 pop(0)을 사용하면서 O(n)이 소요되었다. 리스트가 작은 경우 속도에 큰 영향이 없지만 크기가 정말 클 경우에는 이런 연산에 따라서도 속도가 느려질 수 있다. 

 

리스트에서 pop(0)이 아니라 pop()을 사용하려면 알파벳을 역순으로 정렬한다. 

for key, value in sorted(tickets, reverse=True):
    graph[key].append(value)

 

풀이 3. (교재 참고)

재귀를 사용하지 않는 방법도 있다. 

재귀 문제를 사용하지 않고 while 문으로 처리하는 경우 스택을 많이 사용한다. 

 

앞의 방법처럼 route 변수는 결과를 저장하는 데 사용하고, stack 변수를 새로 선언해서 지금까지 탐색한 경로를 저장해두는 장소로 사용한다. 

처음 시작점은 "JFK"이므로 스택에 JFK를 먼저 넣는다. 

 

그 외의 tickets 리스트를 알파벳 순으로 정렬해서 사용하거나, 딕셔너리 graph 변수로 경로 정보를 저장한다거나, 경로를 탐색할 때마다 graph[key]에서 값을 하나씩 제거하는 것은 위의 방법과 똑같다. 

 

우선 스택에 원소가 있는지 확인한다. 

스택에 원소가 있다면, graph에서 해당 원소를 key값으로 조회했을 때 남은 value가 있는지를 확인한다. = 탐색할 경로가 있는지를 확인한다. 

둘 다 해당한다면, 스택 맨 위의 원소의 value값들 중 가장 앞에 있는(알파벳순이 제일 빠른) 값을 graph에서 제거하고, 그 값을 stack에 추가한다. 

 

리트코드에서 제시하는 1번 예시를 간단하게 보자. 

tickets = [["JFK","SFO"],["JFK","ATL"],["SFO","ATL"],["ATL","JFK"],["ATL","SFO"]]

 

시작점이 "JFK"이므로 이 값에 대해서만 계산해 보자. "JFK" 의 value 값을 알파벳순으로 정렬해서 넣는다면,

graph["JFK"] = ["ATL", "SFO"]

 

초기 상태의 stack 변수에는 JFK만 있으므로 JFK가 스택의 맨 위의 원소다. 

해당 원소를 graph key로 했을 때 해당하는 값들 중 알파벳순으로 빠른 값을 불러오면 "ATL"이다. 

graph["JFK"] 의 값 리스트에서는 이 "ATL"을 제거하고, 이 값을 stack 변수에는 추가한다. 

그러면 stack = ["JFK", "ATL"] 이 된다. 

 

이처럼 스택 변수는 탐색하는 노드들을 모두 저장한다. 

이후 이 경로를 route 변수로 옮기는데, 스택의 특성상 pop()으로 값을 옮기면 route에는 역순으로 옮겨지게 된다. 

그러므로 역순 슬라이싱[::-1]을 사용해서 순서를 뒤집어서 리턴해 준다. 

 

* 해당 포스트는 <파이썬 알고리즘 인터뷰> 공부 후 정리 목적으로 작성되었습니다. *

 

leetcode 78번 ( https://leetcode.com/problems/subsets/ )

 

풀이 1. 

원소들의 집합 nums이 매개변수로 주어졌을 때 nums의 원소로 만들 수 있는 모든 부분집합들을 리턴하는 문제이다. 

 

이전에 풀었던 조합 문제에다가 로직을 추가하는 방법으로도 풀 수 있다. 

dfs(elements, start, k)

 

이전에 중첩 함수 dfs()를 선언하는 방식으로 배열과 숫자 k가 주어졌을 때 배열에서 k개의 요소를 조합하는 문제를 풀었었다. 조합은 순열처럼 순서를 고려하지 않기 때문에, 앞에 온 원소가 포함되는 조합의 경우를 모두 고려하였다면 나머지 남은 경우에서는 앞의 원소가 포함되지 않는다. (조합의 합 문제와 같은 원리이다.)

그래서 이번에도 중첩 함수 dfs()를 그대로 사용한다. 

 

부분집합은 원소가 0개인 공집합부터 n개의 모든 원소가 들어있는 집합까지를 전부 포함한다. 

 

nums = [1, 2, 3]일 때를 예로 들어보자. nums의 부분집합에 포함되는 원소들은 다음과 같다. 

- 원소가 0개인 공집합: [] => combination(nums, 0)

- 원소가 1개인 집합: [1], [2], [3] => combination(nums, 1)

- 원소가 2개인 집합: [1, 2], [2, 3], [1, 3] => combination(nums, 2)

- 원소가 3개인 집합: [1, 2, 3] => combinations(nums, 3)

이렇게 총 8개의 집합 원소들이 부분집합에 포함된다. 

 

더 확장해서 nums = [A1, A2, A3, ... , An]인 경우도 마찬가지다. 

조합에서 0개를 뽑는 경우부터 n개를 뽑는 경우까지를 for loop로 계산하고 각각 더해준다. 

 

전체 코드는 다음과 같다. 

class Solution(object):

    def subsets(self, nums):

        subset = [[]] 
        result = []
        
        # nested function for combination calculation
        def dfs(elements, start, k):
            if k == 0:
                result.append(elements[:])
                return

            for i in range(start, len(nums)):
                elements.append(nums[i])
                dfs(elements, i+1, k-1)
                elements.remove(nums[i])

        # combination(nums, 0) to combination(nums, n)
        for j in range(1, len(nums)+1):
            result = []
            dfs([], 0, j)
            subset.extend(result[:])

        return subset

 

+ 또한 두 개의 리스트 L1, L2가 있을 때 L1 뒤에 L2를 이어 붙이고 싶다면 extend()를 사용한다. 

L1.extend(L2)

 

풀이 2. (교재 참조)

위의 풀이처럼 DFS를 사용해서 더 간단하게 문제를 풀 수 있다. 

nums의 원소들로 트리를 그려 놓고, 트리를 DFS로 탐색할 때의 모든 탐색 경로를 부분집합으로 볼 수도 있다. 

 

nums = [A1, A2, A3, A4]일 때, 4개의 forest의 root는 각각 A1, A2, A3, A4이다. 

이 4개의 forest들이 하나의 root를 둔 전체 트리로 합쳐진다고 생각하자. 

그리고 그 트리를 DFS로 탐색한다고 해 보자. 

 

1. combinations(nums, 0)

root 노드를 제외하고는 어떤 노드도 탐색되지 않은 상황이다. 

 

2. combinations(nums, 1)

root 노드에서 한 칸 탐색할 수 있는 모든 경우이다. 

부분집합에서 1개의 원소가 들어있는 집합들이 여기에 해당한다. 

 

3. combinations(nums, 2)

root 노드에서 두 번 DFS를 실행했을 때 가능한 경우이다. 

부분집합에서 2개의 원소가 들어있는 집합들이 여기에 해당한다. 

 

4. combinations(nums, 3)

root 노드에서 DFS를 세 번 실행한 경우이며, 부분집합에서 3개의 원소가 들어있는 집합들이 여기에 해당한다. 

 

5. combinations(nums, 4)

root 노드에서 DFS를 네 번 실행한 경우이며, 여기서는 nums의 원소가 총 4개이므로 DFS 탐색이 가능한 최대 범위이다. 따라서 모든 원소들이 포함되어 있다. 

 

'알고리즘' 카테고리의 다른 글

ch12-8(leetcode 207). 코스 스케줄  (0) 2023.01.24
ch12-7(leetcode 332). 일정 재구성  (0) 2023.01.19
ch12-5(leetcode 39). 조합의 합  (0) 2023.01.18
ch12-4(leetcode 76). 조합  (0) 2023.01.18
ch12-3(leetcode 46). 순열  (0) 2023.01.15

* 해당 포스트는 <파이썬 알고리즘 인터뷰> 공부 후 정리 목적으로 작성되었습니다. *

 

leetcode 39번 ( https://leetcode.com/problems/combination-sum/ )

 

풀이 1. (교재 참고)

중첩 함수(nested function)를 선언하고 그 함수를 재귀적으로 호출하면 풀 수 있다. 

 

주어진 배열 candidates의 각 원소를 횟수 제한 없이 사용해서 정확히 target의 값을 만드는 문제이다. 

unbounded knapsack 문제와도 유사하다. 

 

예를 들어 candidates = [2, 3, 4], target = 7인 상황에서 맨 처음 2를 선택했다고 해 보자. 

이 경우 각 원소를 한 번만 써야하는 것이 아니므로 똑같이 [2, 3, 4]의 값을 사용해서 7-2인 5를 만들어 내면 되는 문제를 풀면 된다. 

 

즉 candidates[] 배열에서 target T를 만드는 문제에서 candidates의 i번째 원소인 candidates[i]를 선택한다고 가정하면, 이 문제는 candidates[] 배열에서 T-candidates[i] 만큼의 값을 만드는 문제와 같아진다. 

이를 재귀 함수를 호출하는 방식으로 풀 수 있다. 

def combinationSum(self, candidates, target):
    result = []
	
    # recursion function
    def dfs():
        # function

 

그리고 이 문제는 combination sum이기 때문에 순열처럼 하나의 경우의 수에 들어있는 원소의 순서를 고려하지 않는다. 순열이 아니라 조합 문제이다. 

그렇기 때문에 이전의 조합 문제에서처럼 앞의 원소가 조합에 포함된 경우를 이미 계산했다면, 그 다음부터는 뒤의 원소들만 조합에 포함된다고 가정하고 계산하면 된다. 

 

예를 들어 [A1, A2, A3, A4, A5] 원소를 가지고 3개를 선택하는 조합을 만든다고 해 보자. (combinations(5, 3) = 5C3)

여기서 A1이 선택되는 경우만 계산하면 [A1, A2, A3], [A1, A2, A4], [A1, A2, A5], [A1, A3, A4], [A1, A3, A5], [A1, A4, A5] 다음과 같다. 

5개의 원소를 중 3개를 조합으로 뽑는 경우는 총 10개이고, 나머지 경우[A2, A3, A4], [A2, A3, A5], [A2, A4, A5], [A3, A4, A5]이다. 나머지 경우에는 모두 A1이 포함되지 않는다. 

combination sum도 조합이므로 한 원소가 포함되는 경우를 이미 계산하면 그 다음부터는 그 다음에 오는 원소들만 포함한다고 가정할 수 있다. 

 

그러므로 재귀함수 dfs()는 원소를 더해서 완성해야 할 값(csum)뿐만 아니라 어느 원소부터 조합에 포함시킬지(index)도 매개변수로 받아야 한다. 

그리고 dfs()를 호출할수록 원소가 추가되므로, 해당 원소들의 값을 저장해 두었다가 후에 결과(results)에 추가하기 위해서는 각 재귀 호출마다 현재 target을 만들기 위해 저장된 값 리스트(path)도 매개변수로 넘겨 주어야 한다. 

def dfs(csum, index, path):

 

그러나 이렇게만 가정하면 계속해서 합이 늘어나는 무한루프에 갇히게 된다. 재귀함수를 사용할 때는 반드시 재귀를 멈추는 조건인 base case를 명시해 주어야 한다. 

문제에서는 원소를 더했을 때 target 값과 정확히 일치해야 하므로, target 값과 일치하면 결과에다 더하고, target 값을 초과했다면 그냥 종료(return)시켜야 한다. 

# base case
if total < 0:
    return
elif total == 0:
    result.append(path)
    return
    
# recursion case

 

교재에서 제시한 풀이를 보면 중첩 함수 dfs()는 어떤 값을 리턴하지는 않는다. 다만 result라는 결과 변수는 dfs() 함수 밖과 정답 함수(combinationSum) 안에 선언된 로컬 변수이므로, dfs()에서 result에 값을 추가하면 로컬 변수인 result에도 그 결과가 반영된다. 

따라서 결과 변수 result를 그대로 리턴하면 된다. 

 

하지만 주의할 점은, 외부 함수 안에서 선언된 로컬 변수의 값을 중첩 함수 안에서 변경한다고 해서 그 외부 로컬변수의 값이 항상 변경되지는 않는다는 것이다. 

앞서 result의 값이 변경된 이유는 result는 리스트 형식이라서 값이 변경될 수 있는 가변 객체(mutable object)이기 때문이다. 리스트와 달리 값이 변경될 수 없는 객체(immutable object)인 문자열 등의 경우 중첩 함수에서 값을 바꾸려고 시도하면 값이 바뀌지 않고, 해당 중첩 함수에서만 사용할 수 있는 새로운 로컬 변수가 된다. 

 

 

참고한 포스트

https://www.geeksforgeeks.org/unbounded-knapsack-repetition-items-allowed/

 

'알고리즘' 카테고리의 다른 글

ch12-7(leetcode 332). 일정 재구성  (0) 2023.01.19
ch12-6(leetcode 78). 부분 집합  (0) 2023.01.19
ch12-4(leetcode 76). 조합  (0) 2023.01.18
ch12-3(leetcode 46). 순열  (0) 2023.01.15
ch12-2(leetcode 17). 전화번호 문자 조합  (0) 2023.01.15

* 해당 포스트는 <파이썬 알고리즘 인터뷰> 공부 후 정리 목적으로 작성되었습니다. *

 

leetcode 77번 ( https://leetcode.com/problems/combinations/ )

 

방법 1. 

itertools 모듈을 이용한다. 

 

itertools.combinations(n, k)

해당 모듈의 메소드는 총 n개의 원소 중 k개를 뽑는 조합의 경우의 수를 리스트로 리턴해 준다. 

def combine(self, n, k):
    return itertools.combinations(range(1, n+1), k)

 

방법 2. 

이전에 풀었던 순열 문제와 같은 방법으로, 내부 함수 dfs()를 선언한다. 

dfs(elements, k)에서는 elements에 포함된 n개의 원소 중 k개를 하나씩 뽑는다. 

뽑은 원소는 추가된 원소이므로 prev_elements에 추가하고, 다음에 탐색할 원소에서는 제외되므로 next_elements에서는 제외시킨다. 

 

시간 초과로 성공하지는 못했지만 정답을 리턴한다. 전체 코드는 다음과 같다. 

def combine(self, n, k):
        result = set()
        prev_elements = []

        def dfs(elements, k):
            if len(elements) == 0 or k == 0:
                contains = False
                for r in result:
                    a = set(tuple(prev_elements[:]))
                    b = set(r)
                    if len(a.difference(b)) == 0:
                        contains = True
                        break
                if not contains:
                    result.add(tuple(prev_elements[:]))
                return

            next_elements = elements[:]
            for e in elements:
                prev_elements.append(e)
                next_elements.remove(e)
                dfs(next_elements, k-1)
                prev_elements.remove(e)
                next_elements.append(e)

        dfs(range(1, n+1), k)
        return result

 

방법 3. (교재 참고)

순열과 달리 조합은 순서를 생각하지 않는다. 

그러므로 앞에서부터(1부터) 원소를 하나씩 추가해 왔다면 다음에 추가할 원소는 지금 있는 원소들보다는 뒤에 위치해 있다고 볼 수 있다. 

그러므로 dfs()의 매개변수로 몇 번째 인덱스를 기준으로 새로운 dfs()를 시작할지를 추가로 입력해 준다. 

 dfs(원소_리스트(elements), 다음_탐색_기준점(start), 남은_개수(k))

 

그러면 k개의 원소들을 모두 추가했을 때(k=0일 때) 추가로 results 변수에 있는 원소와 중복되는 것이 없는지 확인을 하지 않아도 된다. 

 

 

참고한 포스트

https://docs.python.org/3/tutorial/datastructures.html

https://docs.python.org/3/library/itertools.html#itertools.combinations

https://docs.python.org/3/library/copy.html

 

'알고리즘' 카테고리의 다른 글

ch12-6(leetcode 78). 부분 집합  (0) 2023.01.19
ch12-5(leetcode 39). 조합의 합  (0) 2023.01.18
ch12-3(leetcode 46). 순열  (0) 2023.01.15
ch12-2(leetcode 17). 전화번호 문자 조합  (0) 2023.01.15
ch12-1(leetcode 200). 섬의 개수  (0) 2023.01.14

+ Recent posts