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

 

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

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

 

leetcode 46번 ( https://leetcode.com/problems/permutations/ )

 

풀이 1. (교재 참고)

DFS를 재귀 함수로 사용해서 풀 수 있다. 

이때 지금까지 탐색한 경로(노드)를 저장하는 prev_elements최종 탐색한 결과만을 저장하는 results 두 개의 변수를 사용한다. 

 

dfs() 라는 이 내부 함수는 숫자 리스트를 매개변수로 받는다. 그리고 다음에 탐색할 숫자 리스트를 저장하는 next_elements 변수에다가 매개변수로 받은 리스트 값을 할당한다. 

이때 = 를 사용해서 변수를 그대로 할당하면 파이썬에서는 값을 복사하는 것이 아니라 참조하게 되므로, [:]을 통해 할당해 준다. 

next_elements = elements		# assign by reference
next_elements = elements[:]		# assign by value

 

for loop를 돌면서 임의로 원소를 하나 빼낸 뒤 next_elements 리스트에서는 해당 원소를 제거한다. 왜냐하면 현재 탐색한 노드(경로)이므로 다음 dfs() 시행에서는 탐색하지 않을 것이기 때문이다. 

 

반대로 prev_elements 리스트에다가는 해당 원소를 추가한다. 현재 탐색한 노드를 기록해야 하기 때문이다. 

그 다음에 next_elements를 매개변수로 두고 dfs()를 다시 호출한다. 

dfs(next_elements)

 

dfs()의 재귀적 시행은 다음에 탐색할 노드가 없어지면 종료된다. 종료될 때는 그동안 탐색한 노드 경로를 결과를 저장하는 변수인 results에 할당해 준다. 

마찬가지로 results에 값을 추가할 때는 prev_elements의 값을 참조하지 않고, 복사해서 저장해야 하므로 [:]으로 문자열을 슬라이싱 및 복사해서 사용한다. prev_elements는 재귀적으로 dfs()가 시행되면서 계속 변경되는 값이기 때문이다. 

if len(elements) == 0:
	result.append(prev_elements[:])

 

이 모든 과정은 dfs()가 한 번 호출되면 이후에는 재귀적으로 시행되므로 처음에 dfs()를 한 번만 호출해 주면 된다. 

 

다음 예시를 보자. 

nums = [1, 2, 3, 4] 라는 입력이 주어질 때, 출력으로 [1, 2, 3, 4]가 추가되는 경우를 자세히 살펴보자. 

 

step 1. dfs([1, 2, 3, 4])

elements = [1, 2, 3, 4]

prev_elements = [1]

next_elements = [2, 3, 4]

 

step 2. dfs([2, 3, 4])

elements = [2, 3, 4]

prev_elements = [1, 2]

next_elements = [3, 4]

 

step 3. dfs([3, 4])

elements = [3, 4]

prev_elements = [1, 2, 3]

next_elements = [4]

 

step 4. dfs([4])

elements = [4]

prev_elements = [1, 2, 3, 4]

next_elements = []

 

step 5. dfs([])

elements = []

len(elements) == 0 이므로 더 이상 dfs()가 호출되지 않고 results 변수에 prev_elements([1, 2, 3, 4])가 추가된다. 

 

 

풀이 2. (교재 참고)

파이썬에서 기본으로 제공하는 itertools 모듈을 이용해도 풀 수 있다. 

 

itertools에서는 여러 메소드를 제공하는데 그 중 하나가 리스트를 매개변수로 받아서 리스트의 원소에 대해 가능한 순열 리스트를 리턴하는 permutations() 메소드이다. 

itertools.permutations(list_name)

 

permutations() 외에도 가능한 조합 리스트를 리턴하는 combinations(), 중복 조합 리스트를 리턴하는 combinations_with_replacement(), 가능한 cartesian product 경우를 리스트로 리턴하는 product() 등 다양한 메소드가 있다. 

 

이 모듈을 사용하면 코드를 한 줄로 쓸 수 있다. 

return list(itertools.permutations(nums))

 

참고한 포스트

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

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

 

leetcode 17번 ( https://leetcode.com/problems/letter-combinations-of-a-phone-number/ )

 

풀이 1. 

재귀함수를 이용해서 풀 수 있다.

딕셔너리를 이용해서 각 digits의 자리수를 key로, 해당 자리수의 문자열 리스트를 value로 저장한다. 

 

그리고 내부 함수를 선언하여 재귀함수로 사용한다. digits의 맨 뒤의 자리수에서부터 맞는 문자열을 리스트로 리턴하고, 해당 리스트의 앞에다가 붙여 준다. 

 

digits="234"를 예로 들어보자. 

 

"3"과 "4"의 관계를 보면, "4"에서는 뒤의 문자열이 더 이상 없어서 딕셔너리에서 "4"에 해당하는 알파벳 리스트인 ["g","h","i"] 값을 불러온다. 

"3"에서는 "4"에서 리턴된 ["g","h","i"]의 각 요소를 for loop로 순회하면서 각 요소의 앞에다가 "3"에 해당하는 알파벳 리스트인 ["d","e","f"]의 각 요소들을 붙여준다. 

 

전체 코드는 다음과 같다. 

class Solution(object):

    def letterCombinations(self, digits):
        phone = {
            "2": ["a","b","c"],
            "3": ["d","e","f"],
            "4": ["g","h","i"],
            "5": ["j","k","l"],
            "6": ["m","n","o"],
            "7": ["p","q","r","s"],
            "8": ["t","u","v"],
            "9": ["w","x","y","z"]
        }

        def comb(length):
            if length == len(digits)-1:
                return phone[digits[length]]

            result = []
            for i in phone[digits[length]]:
                for j in comb(length+1):
                    result.append(i+j)
            return result

        if digits == "":
            return []
        return comb(0)

 

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

 

leetcode 200번 ( https://leetcode.com/problems/number-of-islands/ )

 

풀이 1. 

그래프 탐색 방법 중 DFS를 이용해서 풀 수 있다. DFS와 BFS는 그래프 중 connected components 문제를 푸는 데도 사용된다. 이 문제도 점 하나가 사방의 주변 점과 연결되면 연결된 육지로, 사방이 물이면 개별적인 섬으로 간주하기 때문에 connected component의 개수를 찾는 문제로 볼 수 있다. 

 

또한 입력이 주어지면 다른 제약 사항이 없는 한 입력을 임의로 바꿔서 풀 수도 있었다. 

이 방법에서는 for loop를 2번 사용해서 입력으로 주어진 grid를 행과 열로 탐색해서 육지(grid[i][j]==1)를 찾는다. 그리고 찾은 육지 위치에 대해 dfs를 실행한다. 

 

dfs 함수는 재귀적으로 실행되어야 하므로 solution 함수 안의 또 다른 함수(내부 함수)로 선언한다. 

내부 함수로 선언된 dfs() 함수는 solution 함수에서 선언된 지역 변수들의 값을 가져오거나 변경할 수 있어서 재귀 방식을 사용할 때 많이 사용된다. 

 

dfs 함수에서는 우선 해당 위치가 유효한지(해당 위치가 물인지, 가로와 세로가 grid의 범위에서 벗어나지 않았는지)를 확인한 다음에 그 위치의 값을 육지에서 물로 바꾼다(1->0). 그리고 그 위치를 기준으로 동서남북으로 dfs 탐색을 진행하면서 사방의 육지를 물로 바꾼다.

 

만약 육지가 여러 개가 아니고 하나였다면 for loop에서 한 번의 dfs() 호출로 모든 육지가 물로 바뀔 것이다. 이 경우 값은 1이다. 

또는 만약 육지가 아예 없었다면 for loop에서 육지를 찾을 수 없으므로 count가 증가하지 않아서 육지의 개수는 0이 될 것이다. 

 

다음 그림은 leetcode의 예제 1번을 그림으로 표현한 것이다. 검정색 점은 육지이고 파란색 점은 물이다. 

for loop로 육지를 탐색한다면 grid[0][0]인 맨 위의 점에 대해서 맨 먼저 dfs() 함수가 호출될 것이다. 

 

 

dfs()가 호출된 경우 해당 위치가 유효한지를 먼저 판단한 뒤, 유효하면 해당 위치의 값을 육지에서 물로 바꾼다.

교재에서 제시한 dfs() 함수에서는 남->북->동->서 방식으로 탐색을 진행하므로, 그 다음엔 한 칸 아래에 있는 점에 대해서 dfs()를 다시 호출한다. 

 

 

dfs()가 계속 호출되면서 아래 그림처럼 모든 점이 육지에서 물로 바뀐다. 

 

 

해당 예시에서는 육지가 1개였으므로 모든 점이 물이 되지만, 육지가 여러 개일 경우 검정 점이 남아있다. 

현재는 (4, 2)에서 dfs()가 동작하지만 이제 사방의 모든 점이 물이므로 dfs()를 더 이상 호출하지 않고 해당 점을 기준으로 호출된 dfs()는 종료될 것이다. 

 

 

그러면 해당 점을 호출하기 전의 위치인 (4, 1)에서 다시 남은 dfs() 코드가 실행될 것이다.

하지만 사방의 모든 점이 물이므로 해당 점에서의 dfs()는 종료될 것이며, (4, 1)을 기준으로 dfs()를 호출한 이전 위치의 dfs() 실행 지점으로 돌아갈 것이다. 

 

이렇게 맨 처음 dfs()를 호출한 (1, 1)에서도 dfs()가 종료되면 (0, 0)이 속한 육지를 물로 바뀌었으니 육지 count가 1 증가한다. 

그리고 for loop가 다시 실행되어 남은 점들에 대해서 육지가 있는지 탐색할 것이다. 하지만 남은 육지가 없으므로 1을 리턴하게 된다. 

 

+ Recent posts