Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[haklee] week 11 #550

Merged
merged 2 commits into from
Oct 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions binary-tree-maximum-path-sum/haklee.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""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).
"""


# 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]
160 changes: 160 additions & 0 deletions graph-valid-tree/haklee.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"""
아이디어:
- 트리여야 하므로 엣지 개수가 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(n * α(n)), SC: O(n)

n은 주어진 노드의 개수, e는 주어진 엣지의 개수.

아이디어(이어서):
- union-find 아이디어를 그대로 활용한다.
- 나이브한 접근:
- union을 통해서 엣지로 연결된 두 집합을 합친다.
- 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)).
"""


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))
rank = [0] * n

def find(x: int) -> bool:
if x == parent[x]:
return x

parent[x] = find(parent[x]) # path-compression
return parent[x]

def union(a: int, b: int) -> bool:
# 원래는 값을 리턴하지 않아도 되지만, 같은 집합에 속한 노드를
# union하려는 상황을 판별하기 위해 값 리턴.

pa = find(a)
pb = find(b)

# union by rank
if pa == pb:
# parent가 같음. rank 작업 안 해도 된다.
return True
Comment on lines +73 to +75
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

두 노드의 부모 노드가 같다면 바로 트리가 아니라고 판단 할 수 있군요 ㅎㅎ 간선이 중북되어 들어오면 어쩌나 했는데 입력 조건에 중복된 간선은 없다는 전제가 있군요
유니온 파인드 풀이 잘 봤습니다!


if rank[pa] < rank[pb]:
pa, pb = pb, pa

parent[pb] = pa

if rank[pa] == rank[pb]:
rank[pa] += 1

return False

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(n), SC: O(n)

n은 주어진 노드의 개수, e는 주어진 엣지의 개수.

아이디어(이어서):
- 트리를 잘 이뤘는지 확인하려면 한 노드에서 시작해서 dfs를 돌려서 모든 노드들에 도달 가능한지
체크하면 되는데, 이게 시간복잡도에 더 유리하지 않을까?

SC:
- adjacency list를 관리한다. O(e).
- 호출 스택은 탐색을 시작하는 노드로부터 사이클이 나오지 않는 경로의 최대 길이만큼 깊어질 수 있다.
최악의 경우 O(n).
- 이때 e = n-1 이므로 종합하면 O(n).

TC:
- 각 노드에 접근하는 과정에 O(1). 이런 노드를 최악의 경우 n개 접근해야 하므로 O(n).
"""


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)
61 changes: 61 additions & 0 deletions insert-interval/haklee.py
Original file line number Diff line number Diff line change
@@ -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).
Copy link
Member

@jdalma jdalma Oct 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

결과 배열인 res를 사용하여서 O(n)의 내용도 추가될 수 있을 것 같습니다 ㅎㅎ

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

공간복잡도를 분석할때 리턴하는 변수를 포함 안 시키는 것이 일반적이라고 들었는데, 이건 각종 알고리즘 분석 글들을 찾아보다 보면 사람들마다 다르게 적용하고 있는 것 같습니다.
저는 어차피 알고리즘 돌릴때 리턴하는 값에 대한 공간 복잡도가 크면(예를 들어, O(n^2)) 중간 과정에 사용하는 변수들에 대한 공간 복잡도가 작은 것이(예를 들어, O(n)) 실제 알고리즘을 돌리는 환경에서 별 이득이 없으니 그냥 리턴 값까지 포함해서 분석하는 것이 맞다고 생각했는데, 알고리즘을 튜링 머신의 관점에서 봤을때 공간 복잡도는 read/write에 필요한 테이프라는 말이 일리가 있다고 생각하여 알고리즘에 필요한 추가 공간만 공간복잡도에 포함시키는 것으로 결정하고 분석을 작성하고 있습니다.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 단순히 메모리라는 영역을 사용한다면 항상 포함해왔는데 이런 관점도 있군요 ㅎㅎ 처음알게 된 내용이라 신선하네요 감사합니다


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)
Comment on lines +34 to +53
Copy link
Member

@jdalma jdalma Oct 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

엄청 간단하고 직관적이게 해결하셨네요 👍 이 풀이를 참고하여 해결하였습니당 ㅎㅎ


if newInterval:
# intervals에 있는 마지막 아이템이 newInterval과 겹쳤을 경우 아직
# 결과 리스트에 newInterval이 더해지지 않고 앞선 순회가 종료되었을
# 수 있다. 이 경우 newInterval이 아직 None이 아니므로 리스트에 더해준다.
res.append(newInterval)

return res
29 changes: 29 additions & 0 deletions maximum-depth-of-binary-tree/haklee.py
Original file line number Diff line number Diff line change
@@ -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)
Loading