From 0e4058013e9bef6ede33372b7c8831be15e6f3cf Mon Sep 17 00:00:00 2001 From: Hak Lee Date: Wed, 23 Oct 2024 00:12:30 +0900 Subject: [PATCH 1/2] solutions --- binary-tree-maximum-path-sum/haklee.py | 43 ++++++++ graph-valid-tree/haklee.py | 139 +++++++++++++++++++++++++ insert-interval/haklee.py | 61 +++++++++++ maximum-depth-of-binary-tree/haklee.py | 29 ++++++ reorder-list/haklee.py | 126 ++++++++++++++++++++++ 5 files changed, 398 insertions(+) create mode 100644 binary-tree-maximum-path-sum/haklee.py create mode 100644 graph-valid-tree/haklee.py create mode 100644 insert-interval/haklee.py create mode 100644 maximum-depth-of-binary-tree/haklee.py create mode 100644 reorder-list/haklee.py diff --git a/binary-tree-maximum-path-sum/haklee.py b/binary-tree-maximum-path-sum/haklee.py new file mode 100644 index 000000000..bd331e6d5 --- /dev/null +++ b/binary-tree-maximum-path-sum/haklee.py @@ -0,0 +1,43 @@ +"""TC: O(), SC: O() + + +아이디어: +- + +SC: +- + +TC: +- +""" + + +# Definition for a binary tree node. +# class TreeNode: +# def __init__(self, val=0, left=None, right=None): +# self.val = val +# self.left = left +# self.right = right +class Solution: + def maxPathSum(self, root: Optional[TreeNode]) -> int: + sol = [-1001] # 노드의 최소값보다 1 작은 값. 현 문제 세팅에서 -inf 역할을 함. + + def try_get_best_path(node): + if node is None: + # 노드가 비어있을때 경로 없음. 이때 이 노드로부터 얻을 수 있는 최대 경로 값을 + # 0으로 칠 수 있다. + return 0 + + # 왼쪽, 오른쪽 노드로부터 얻을 수 있는 최대 경로 값. + l = max(try_get_best_path(node.left), 0) + r = max(try_get_best_path(node.right), 0) + + # 현 노드를 다리 삼아서 양쪽 자식 노드의 경로를 이었을때 나올 수 있는 경로 값이 + # 최대 경로일 수도 있다. 이 값을 현 솔루션과 비교해서 업데이트 해준다. + sol[0] = max(node.val + l + r, sol[0]) + + # 현 노드의 부모 노드가 `이 노드를 통해 얻을 수 있는 최대 경로 값`으로 사용할 값을 리턴. + return node.val + max(l, r) + + try_get_best_path(root) + return sol[0] diff --git a/graph-valid-tree/haklee.py b/graph-valid-tree/haklee.py new file mode 100644 index 000000000..28b0abfcb --- /dev/null +++ b/graph-valid-tree/haklee.py @@ -0,0 +1,139 @@ +""" +아이디어: +- 트리여야 하므로 엣지 개수가 n-1개여야 한다. +- 엣지 개수가 n-1개이므로 만약 중간에 사이클이 있다면 트리가 연결이 안 된다. + - 연결이 안 되었으니 트리는 아니고... 몇 조각으로 쪼개진 그래프가 된다. 여튼, valid tree가 + 아니게 된다. + - spanning tree 만들 때를 생각해보자. 엣지 하나를 더할 때마다 노드가 하나씩 트리에 추가되어야 + 엣지 n-1개로 노드 n개를 겨우 만들 수 있는데, 중간에 새로운 노드를 추가 안하고 엄한 곳에 + 엣지를 써서 사이클을 만들거나 하면 모든 노드를 연결할 방법이 없다. + - 위의 예시보다 좀 더 일반적으로는 union-find 알고리즘에서 설명하는 union 시행으로도 설명이 + 가능하다. union-find에서는 처음에 n개의 노드들의 parent가 자기 자신으로 세팅되어 있는데, + 즉, 모든 노드들이 n개의 그룹으로 나뉘어있는데, 여기서 union을 한 번 시행할 때마다 그룹이 1개 + 혹은 0개 줄어들 수 있다. 그런데 위 문제에서는 union을 엣지 개수 만큼, 즉, n-1회 시행할 수 있으므로, + 만약 union 시행에서 그룹의 개수가 줄어들지 않는 경우(즉, 엣지 연결을 통해 사이클이 생길 경우)가 + 한 번이라도 발생하면 union 시행 후 그룹의 개수가 2 이상이 되어 노드들이 서로 연결되지 않아 트리를 + 이루지 못한다. +""" + +"""TC: O(), SC: O() + +n은 주어진 노드의 개수, e는 주어진 엣지의 개수. + +아이디어(이어서): +- union-find 아이디어를 그대로 활용한다. + - 나이브한 접근: + - union을 통해서 엣지로 연결된 두 집합을 합친다. + - find를 통해서 0번째 노드와 모든 노드들이 같은 집합에 속해있는지 확인한다. + - 더 좋은 구현: + - union 시행 중 같은 집합에 속한 두 노드를 합치려고 하는 것을 발견하면 False 리턴 + +SC: +- + +TC: +- +""" + + +class Solution: + """ + @param n: An integer + @param edges: a list of undirected edges + @return: true if it's a valid tree, or false + """ + + def valid_tree(self, n, edges): + # write your code here + + # union find + parent = list(range(n)) + + def find(x): + if x == parent[x]: + return x + + parent[x] = find(parent[x]) + return parent[x] + + def union(a, b): + pa = find(a) + pb = find(b) + parent[pb] = pa + + # 원래는 값을 리턴하지 않아도 되지만, 같은 집합에 속한 노드를 + # union하려는 상황을 판별하기 위해 값 리턴. + return pa == pb + + if len(edges) != n - 1: + # 트리에는 엣지가 `(노드 개수) - 1`개 만큼 있다. + # 이 조건 만족 안하면 커팅. + return False + + # 나이브한 구현: + # - 모든 엣지로 union 시행 + # - find로 모든 노드가 0번 노드와 같은 집합에 속해있는지 확인 + + # for e in edges: + # union(*e) + + # return all(find(0) == find(i) for i in range(n)) + + # 더 좋은 구현: + # - union 시행 중 같은 집합에 속한 두 노드를 합치려고 하는 것을 발견하면 False 리턴 + for e in edges: + if union(*e): + return False + + return True + + +"""TC: O(), SC: O() + +n은 주어진 노드의 개수, e는 주어진 엣지의 개수. + +아이디어(이어서): +- union-find를 쓰면 union을 여러 번 시행해야 하는데 이 과정에서 시간을 많이 잡아먹는것 같다. +- 트리를 잘 이뤘는지 확인하려면 한 노드에서 시작해서 dfs를 돌려서 모든 노드들에 도달 가능한지 + 체크하면 되는데, 이게 시간복잡도에 더 유리하지 않을까? + +SC: +- + +TC: +- +""" + + +class Solution: + """ + @param n: An integer + @param edges: a list of undirected edges + @return: true if it's a valid tree, or false + """ + + def valid_tree(self, n, edges): + # write your code here + if len(edges) != n - 1: + # 트리에는 엣지가 `(노드 개수) - 1`개 만큼 있다. + # 이 조건 만족 안하면 커팅. + return False + + adj_list = [[] for _ in range(n)] + for a, b in edges: + adj_list[a].append(b) + adj_list[b].append(a) + + visited = [False for _ in range(n)] + + def dfs(node): + visited[node] = True + for adj in adj_list[node]: + if not visited[adj]: + dfs(adj) + + # 한 노드에서 출발해서 모든 노드가 visted 되어야 주어진 엣지들로 트리를 만들 수 있다. + # 아무 노드에서나 출발해도 되는데 0번째 노드를 선택하자. + dfs(0) + + return all(visited) diff --git a/insert-interval/haklee.py b/insert-interval/haklee.py new file mode 100644 index 000000000..92d90ff31 --- /dev/null +++ b/insert-interval/haklee.py @@ -0,0 +1,61 @@ +"""TC: O(n), SC: O(1) + +n은 intervals로 주어진 인터벌의 개수. + +아이디어: +- 주어진 인터벌들을 앞에서부터 순회하면서 새 인터벌(newInterval)과 겹치는지 보고, + - 겹치면 합친다. 합친 인터벌로 newInterval을 업데이트 한다. + - 안 겹치면 newInterval과 현재 확인 중인 인터벌(curInterval) 중에 필요한 인터벌을 + 결과 리스트에 넣어주어야 한다. +- 안 겹치면, + - 이때, curInterval이 newInterval보다 앞에 있으면 이후 인터벌들 중 newInterval과 합쳐야 하는 + 인터벌이 존재할 수 있다. newInterval은 건드리지 않고 curInterval만 결과 리스트에 넣는다. + - curInterval이 newInterval보다 뒤에 있으면 newInterval을 결과 리스트에 더해주고, 그 다음 + curInterval도 결과 리스트에 더해주어야 한다. + - 그런데 curInterval이 들어있는 리스트가 정렬되어 있으므로, 이후에 순회할 curInterval + 중에는 더 이상 newInterval과 겹칠 인터벌이 없다. newInterval은 이제 더 이상 쓰이지 + 않으므로 None으로 바꿔준다. + +SC: +- newInterval 값만 업데이트 하면서 관리. O(1). + +TC: +- intervals에 있는 아이템을 순회하면서 매번 체크하는 시행이 O(1). +- 위의 시행을 intervals에 있는 아이템 수만큼 진행하므로 O(n). +""" + + +class Solution: + def insert( + self, intervals: List[List[int]], newInterval: List[int] + ) -> List[List[int]]: + res = [] + for curInterval in intervals: + if newInterval: + # 아직 newInterval이 None으로 변경되지 않았다. + if curInterval[1] < newInterval[0]: + # cur, new가 겹치지 않고, curInterval이 더 앞에 있음. + res.append(curInterval) + elif curInterval[0] > newInterval[1]: + # cur, new가 겹치지 않고, newInterval이 더 앞에 있음. + res.append(newInterval) + res.append(curInterval) + newInterval = None + else: + # 겹치는 부분 존재. newInterval을 확장한다. + newInterval = [ + min(curInterval[0], newInterval[0]), + max(curInterval[1], newInterval[1]), + ] + else: + # 더 이상 newInterval과 연관된 작업을 하지 않는다. 순회 중인 + # curInterval을 결과 리스트에 더하고 끝. + res.append(curInterval) + + if newInterval: + # intervals에 있는 마지막 아이템이 newInterval과 겹쳤을 경우 아직 + # 결과 리스트에 newInterval이 더해지지 않고 앞선 순회가 종료되었을 + # 수 있다. 이 경우 newInterval이 아직 None이 아니므로 리스트에 더해준다. + res.append(newInterval) + + return res diff --git a/maximum-depth-of-binary-tree/haklee.py b/maximum-depth-of-binary-tree/haklee.py new file mode 100644 index 000000000..8beef6f0a --- /dev/null +++ b/maximum-depth-of-binary-tree/haklee.py @@ -0,0 +1,29 @@ +"""TC: O(n), SC: O(h) + +h는 주어진 트리의 높이, n은 주어진 트리의 노드 개수. + +아이디어: +특정 노드의 깊이는 `max(오른쪽 깊이, 왼쪽 깊이) + 1`이다. 이렇게 설명하자니 부모 노드의 깊이 값이 +자식의 깊이 값보다 더 큰 것이 이상하긴 한데... 큰 맥락에서 무슨 말을 하고 싶은지는 이해가 가능하다고 +본다. + +SC: +- 호출 스택은 트리의 높이(...혹은 깊이)만큼 커진다. O(h). + +TC: +- 모든 노드를 방문한다. O(n). +""" + + +# Definition for a binary tree node. +# class TreeNode: +# def __init__(self, val=0, left=None, right=None): +# self.val = val +# self.left = left +# self.right = right +class Solution: + def maxDepth(self, root: Optional[TreeNode]) -> int: + def get_depth(node: Optional[TreeNode]) -> int: + return max(get_depth(node.left), get_depth(node.right)) + 1 if node else 0 + + return get_depth(root) diff --git a/reorder-list/haklee.py b/reorder-list/haklee.py new file mode 100644 index 000000000..2b55a018d --- /dev/null +++ b/reorder-list/haklee.py @@ -0,0 +1,126 @@ +"""TC: O(n^2), SC: O(1) + +n은 주어진 리스트의 노드 개수. + +아이디어: +아래의 절차를 반복한다. +- 리스트가 주어져있다. 여기에 head가 존재한다. (*로 표시) + - 1 -> 2 -> 3 -> 4 -> 5 + * +- 리스트의 끝 노드를 떼어낸다. + - 1 -> 2 -> 3 -> 4 5 + * +- head의 next를 떼어낸 노드로 바꾼다. + - 1 2 -> 3 -> 4 + *└─> 5 +- head의 next의 next를 원래 head의 next였던 노드로 바꾼다. + - 1 ┌─> 2 -> 3 -> 4 + *└─> 5 +- head를 새로 만들어진 head의 next의 next로 바꾼다. + - 1 ┌─> 2 -> 3 -> 4 + └─> 5 * + +코드에서는 위의 아이디어를 cur_head, cur_next, end 같은 변수를 써서 구현했다. 이때 end 노드를 +구하기 위한 함수를 따로 구현했는데, 자세한 내용은 코드를 참조하면 된다. + +SC: +- 마지막 노드를 가져오는 `pop_end` 함수에서 end, result 변수 관리에 O(1). +- cur_head, cur_next, end 등의 변수 관리에 O(1). +- 종합하면 O(1). + +TC: +- 한 번 끝 노드를 떼어서 head에 붙이고 head의 next였던 노드를 끝 노드에 붙이는 시행에 O(1) +- ... 인 것처럼 보이지만 끝 노드를 구하는 데에 head 뒤에 달린 노드 개수 만큼을 순회해야 한다. +- 처음 리스트의 길이가 n에서 시작해서 한 번의 작업 시행에 탐색해야 하는 리스트 길이가 2씩 줄어든다. +- n + (n-2) + ... = O(n^2). 자세한 식 유도는 생략하겠다. +""" + + +# Definition for singly-linked list. +# class ListNode: +# def __init__(self, val=0, next=None): +# self.val = val +# self.next = next +class Solution: + def reorderList(self, head: Optional[ListNode]) -> None: + """ + Do not return anything, modify head in-place instead. + """ + + def pop_end(node: Optional[ListNode]) -> Optional[ListNode]: + end = node + while end.next and end.next.next: + # end의 next의 next가 없으면, 즉, end의 next가 리스트의 끝이면 멈춘다. + # 이름을 end라고 지어놓아서 헷갈릴 수 있는데, 여기서 구하고자 하는 end는 + # 뒤에서 두 번째 노드다... + end = end.next + + # 찐 마지막 노드를 result에 넣어놓는다. + result = end.next + + # 찐 마지막 노드를 리스트에서 제거한다. 마지막에서 두 번째 노드의 next를 + # None으로 바꾸면 연결이 끊어짐. + end.next = None + + # 찐 마지막 노드를 리턴. + return result + + cur_head = head + while cur_head and cur_head.next and cur_head.next.next: + cur_next = cur_head.next + end = pop_end(cur_head) + cur_head.next = end + end.next = cur_next + cur_head = end.next + + +"""TC: O(n), SC: O(n) + +n은 주어진 리스트의 노드 개수. + +아이디어: +- 앞의 아이디어는 다 좋은데 끝 노드를 찾기 위해 리스트 전체를 순회해야 해서 TC가 너무 커진다. +- 리스트 순회를 안 하고 싶으면 리스트에 있는 노드 값을 한 번 순회하면서 죄다 파이썬의 리스트에 + 넣어놓고 필요한 값을 꺼내서 결과 리스트를 만들면 되는 것이 아닐까? +- 파이썬 리스트는 주어진 singly-linked list와는 달리 인덱스로 접근이 가능하므로, 투포인터를 써서 + 새로 결과 리스트를 만들어서 리턴하자. + +SC: +- 주어진 singly-linked list를 순회하면서 값을 뽑아서 파이썬 리스트에 넣음. O(n). + +TC: +- 투포인터를 써서 모든 아이템에 한 번씩만 접근하여 새로 노드 만듦. O(n). +""" + + +# Definition for singly-linked list. +# class ListNode: +# def __init__(self, val=0, next=None): +# self.val = val +# self.next = next +class Solution: + def reorderList(self, head: Optional[ListNode]) -> None: + """ + Do not return anything, modify head in-place instead. + """ + if not head: + return + + nums = [] + cur_head = head + while cur_head: + nums.append(cur_head.val) + cur_head = cur_head.next + + head.val = nums[0] + cur_head = head + ptr = [1, len(nums) - 1] + i = 1 + while ptr[0] <= ptr[1]: + cur_head.next = ListNode(nums[ptr[i]]) + cur_head = cur_head.next + if i == 0: + ptr[i] += 1 + else: + ptr[i] -= 1 + i = (i + 1) % 2 From 12ee5b59aade5adcb3c5cd4a0f5e7252a3dad3d5 Mon Sep 17 00:00:00 2001 From: Hak Lee Date: Thu, 24 Oct 2024 02:35:47 +0900 Subject: [PATCH 2/2] analysis --- binary-tree-maximum-path-sum/haklee.py | 27 +++++++++++--- graph-valid-tree/haklee.py | 49 ++++++++++++++++++-------- 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/binary-tree-maximum-path-sum/haklee.py b/binary-tree-maximum-path-sum/haklee.py index bd331e6d5..75057e067 100644 --- a/binary-tree-maximum-path-sum/haklee.py +++ b/binary-tree-maximum-path-sum/haklee.py @@ -1,14 +1,33 @@ -"""TC: O(), SC: O() +"""TC: O(n), SC: O(h) 아이디어: -- +- 각 노드를 부모, 혹은 자식 노드의 관점에서 분석할 수 있다. +- 부모 노드의 관점에서 경로를 만들때: + - 부모 노드는 양쪽 자식 노드에 연결된 경로를 잇는 다리 역할을 할 수 있다. + - 이때 자식 노드는 + - 경로에 포함되지 않아도 된다. 이 경우 path에 0만큼 기여하는 것으로 볼 수 있다. + - 자식 노드의 두 자식 노드 중 한 쪽의 경로와 부모 노드를 이어주는 역할을 한다. + 아래서 좀 더 자세히 설명. +- 자식 노드의 관점에서 경로를 만들때: + - 자식 노드는 부모 노드와 연결될 수 있어야 한다. + - 그렇기 때문에 자신의 자식 노드 중 한 쪽과만 연결되어있을 수 있다. 만약 부모 노드와 + 본인의 양쪽 자식 노드 모두와 연결되어 있으면 이 노드가 세 갈림길이 되어서 경로를 만들 + 수 없기 때문. +- 위의 분석을 통해 최대 경로를 만들고 싶다면, 다음의 함수를 root를 기준으로 재귀적으로 실행한다. + - 특정 node가 부모 노드가 되었다고 했을때 본인의 값에 두 자식의 max(최대 경로, 0) 값을 더해서 + 경로를 만들어본다. 이 값이 기존 solution보다 클 경우 solution을 업데이트. + - 특정 node가 자식 노드가 될 경우 본인의 두 자식 중 더 큰 경로를 부모에 제공해야 한다. + 본인의 값에 max(왼쪽 경로, 오른쪽 경로)을 더해서 리턴. SC: -- +- solution값을 관리한다. O(1). +- 호출 스택은 트리의 높이만큼 쌓일 수 있다. O(h). +- 종합하면 O(h). TC: -- +- 각 노드에서 O(1) 시간이 소요되는 작업 수행. +- 모든 노드에 접근하므로 O(n). """ diff --git a/graph-valid-tree/haklee.py b/graph-valid-tree/haklee.py index 28b0abfcb..b9fb43841 100644 --- a/graph-valid-tree/haklee.py +++ b/graph-valid-tree/haklee.py @@ -16,7 +16,7 @@ 이루지 못한다. """ -"""TC: O(), SC: O() +"""TC: O(n * α(n)), SC: O(n) n은 주어진 노드의 개수, e는 주어진 엣지의 개수. @@ -27,12 +27,17 @@ - find를 통해서 0번째 노드와 모든 노드들이 같은 집합에 속해있는지 확인한다. - 더 좋은 구현: - union 시행 중 같은 집합에 속한 두 노드를 합치려고 하는 것을 발견하면 False 리턴 +- union-find는 [Disjoint-set data structure - Wikipedia](https://en.wikipedia.org/wiki/Disjoint-set_data_structure) + 를 기반으로 구현했다. 여기에 time complexity 관련 설명이 자세하게 나오는데 궁금하면 참고. SC: -- +- union-find에서 쓸 parent 정보만 관리한다. 각 노드마다 parent 노드(인덱스), rank를 관리하므로 O(n). TC: -- +- union 과정에 union by rank 적용시 O(α(n)) 만큼의 시간이 든다. 이때 α(n)은 inverse Ackermann function + 으로, 매우 느린 속도로 늘어나므로 사실상 상수라고 봐도 무방하다. +- union 시행을 최대 e번 진행하므로 O(e * α(n)). +- e = n-1 이므로 O(n * α(n)). """ @@ -48,22 +53,36 @@ def valid_tree(self, n, edges): # union find parent = list(range(n)) + rank = [0] * n - def find(x): + def find(x: int) -> bool: if x == parent[x]: return x - parent[x] = find(parent[x]) + parent[x] = find(parent[x]) # path-compression return parent[x] - def union(a, b): + def union(a: int, b: int) -> bool: + # 원래는 값을 리턴하지 않아도 되지만, 같은 집합에 속한 노드를 + # union하려는 상황을 판별하기 위해 값 리턴. + pa = find(a) pb = find(b) + + # union by rank + if pa == pb: + # parent가 같음. rank 작업 안 해도 된다. + return True + + if rank[pa] < rank[pb]: + pa, pb = pb, pa + parent[pb] = pa - # 원래는 값을 리턴하지 않아도 되지만, 같은 집합에 속한 노드를 - # union하려는 상황을 판별하기 위해 값 리턴. - return pa == pb + if rank[pa] == rank[pb]: + rank[pa] += 1 + + return False if len(edges) != n - 1: # 트리에는 엣지가 `(노드 개수) - 1`개 만큼 있다. @@ -84,24 +103,26 @@ def union(a, b): for e in edges: if union(*e): return False - + return True -"""TC: O(), SC: O() +"""TC: O(n), SC: O(n) n은 주어진 노드의 개수, e는 주어진 엣지의 개수. 아이디어(이어서): -- union-find를 쓰면 union을 여러 번 시행해야 하는데 이 과정에서 시간을 많이 잡아먹는것 같다. - 트리를 잘 이뤘는지 확인하려면 한 노드에서 시작해서 dfs를 돌려서 모든 노드들에 도달 가능한지 체크하면 되는데, 이게 시간복잡도에 더 유리하지 않을까? SC: -- +- adjacency list를 관리한다. O(e). +- 호출 스택은 탐색을 시작하는 노드로부터 사이클이 나오지 않는 경로의 최대 길이만큼 깊어질 수 있다. + 최악의 경우 O(n). +- 이때 e = n-1 이므로 종합하면 O(n). TC: -- +- 각 노드에 접근하는 과정에 O(1). 이런 노드를 최악의 경우 n개 접근해야 하므로 O(n). """