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 5 #441

Merged
merged 2 commits into from
Sep 15, 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
76 changes: 76 additions & 0 deletions 3sum/haklee.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""TC: O(n^2), SC: O(n^2)

아이디어:
- 합이 0(즉, 고정값)이 되는 세 수를 찾는 것이 문제.
- 세 수 중 하나가 고정되어 있다면, 그리고 숫자들이 들어있는 리스트가 정렬되어 있다면 투 포인터 사용 가능.
- 투 포인터 테크닉은 검색하면 많이 나오므로 아주 자세한 설명은 생략하겠다.
- 이 문제에서는 리스트의 가장 작은 값과 가장 큰 값에 포인터를 둘 것이다.
- 그리고 이 두 값의 합이
- 원하는 결과보다 작으면 작은 값의 포인터를 큰 쪽으로 옮기고,
- 원하는 결과보다 크면 큰 값의 포인터를 작은 쪽으로 옮긴다.
- 이 과정을 반복하면서 원하는 쌍을 찾는 것이 관건.
- 고정된 숫자를 정렬된 리스트의 가장 작은 값부터 큰 값으로 하나씩 옮겨가면 중복 없이 탐색이 가능하다.
- 이때 투 포인터를 쓸 구간은 고정된 숫자 뒤에 오는 숫자들로 둔다.
- 코드를 보는 것이 이해가 더 빠를 것이다..

SC:
- 자세한 설명은 TC 분석에서 확인 가능.
- 종합하면 O(n^2).

TC:
- nums를 sort하는 과정에서 O(n * log(n))
- 정렬된 nums를 모두 순회.
- 그리고 각 순회마다 n-1, n-2, ..., 2 크기의 구간에서 투 포인터 사용.
- 투 포인터를 사용할때 단순 사칙연산 및 비교연산만 사용하므로 O(1).
- 투 포인터 사용시 매 계산마다 포인터 사이의 거리가 1씩 줄어든다(s가 올라가든 e가 내려가든).
- (SC) 매 계산마다 최대 한 번 solution을 추가하는 연산을 한다.
- 그러므로 각 순회마다 C * (n-1), C * (n-2), ..., C * 1의 시간이 들어감.
- (SC) 비슷하게, 매 순회마다 위와 같은 꼴로 solution 개수가 더해질 수 있다.
- 종합하면 O(n^2)
Copy link
Contributor

Choose a reason for hiding this comment

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

설명을 꼼꼼하게 작성해주셔서 코드와 함께 따라가기 정말 좋습니다~
한 가지 알고 있는 부분을 말씀드리면, 복잡도를 분석할 때 output 에 필요한 메모리는 보통 무시하는 것 같습니다!

- 총 O(n^2)
"""

from collections import Counter


class Solution:
def threeSum(self, nums: List[int]) -> List[List[int]]:
# 커팅. 어차피 세 쌍의 숫자에 등장할 수 있는 같은 숫자 개수가 최대 3개이므로,
# 처음 주어진 nums에 같은 숫자가 네 번 이상 등장하면 세 번만 나오도록 바꿔준다.
# 이 처리를 하면 같은 숫자가 많이 반복되는 케이스에서 시간 개선이 있을 수 있다.
# Counter 쓰는 데에 O(n), 새로 tmp_nums 리스트를 만드는 데에 O(n)의 시간이 들어가므로
# 최종적인 시간 복잡도에 영향을 주지는 않는다.
tmp_nums = []
for k, v in Counter(nums).items():
tmp_nums += [k] * min(v, 3)
Comment on lines +38 to +45
Copy link
Contributor

Choose a reason for hiding this comment

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

전처리 배워갑니다 👍


# 여기부터가 주된 구현.
sorted_nums = sorted(tmp_nums) # TC: O(n * log(n))
nums_len = len(tmp_nums)

sol = set()
for i in range(nums_len): # TC: O(n)
if i > 0 and sorted_nums[i] == sorted_nums[i - 1]:
# 커팅. 고정 값이 이미 한 번 사용되었던 값이면 스킵해도 괜찮다.
continue
s, e = i + 1, nums_len - 1
while s < e:
# 이 while문 전체에서 TC O(n).
v = sorted_nums[s] + sorted_nums[e]
if v == -sorted_nums[i]:
# i < s < e 이므로, 이 순서대로 숫자는 정렬된 상태다.
# 즉, 같은 값을 사용한 순서만 다른 쌍을 걱정하지 않아도 된다.
sol.add((sorted_nums[i], sorted_nums[s], sorted_nums[e]))
if v < -sorted_nums[i]:
# s, e의 두 값을 더한 것이 원하는 값보다 작으면, 작은 쪽에 있는 포인터를
# 더 큰 숫자가 있는 쪽으로 옮기면 된다.
# 여기서도 중복 값 커팅을 하려면 할 수 있겠지만, 이 커팅을 안 하려고
# 맨 앞에서 같은 숫자들을 미리 최대한 제거해두었다.
s += 1
else:
# s, e의 두 값을 더한 것이 원하는 값보다 크면, 큰 쪽에 있는 포인터를
# 더 작은 숫자가 있는 쪽으로 옮기면 된다.
# 여기서도 중복 값 커팅을 하려면 할 수 있겠지만, 이 커팅을 안 하려고
# 맨 앞에서 같은 숫자들을 미리 최대한 제거해두었다.
e -= 1
return sol # 타입힌트를 따르지 않아도 제출은 된다...
23 changes: 23 additions & 0 deletions best-time-to-buy-and-sell-stock/haklee.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""TC: O(n), SC: O(1)

아이디어:
- 특정 시점에서 stock을 팔았을때 최고 수익이 나려면 이전 가격 중 가장 낮은 가격에 stock을 사야 한다.
- 모든 시점에 대해 위의 값을 계산하면 전체 기간 중 최고 수익을 구할 수 있다.
- 이를 위해서 특정 시점 이전까지의 가격 중 가장 싼 가격인 minp값을 관리하고,
- 각 시점의 가격에서 minp값을 빼서 `현재 최고 수익`을 구한 다음에,
- `전체 최고 수익`을 max(`현재 최고 수익`, `전체 최고 수익`)으로 업데이트 한다.

SC:
- minp, profit(`전체 최고 수익`) 값을 관리한다. O(1).

TC:
- prices의 가격을 순회하면서 더하기, min, max 연산을 한다. O(n).
"""


class Solution:
def maxProfit(self, prices: List[int]) -> int:
minp, profit = prices[0], 0
for p in prices:
profit = max(profit, p - (minp := min(minp, p)))
Copy link
Contributor

Choose a reason for hiding this comment

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

image

return profit
50 changes: 50 additions & 0 deletions group-anagrams/haklee.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""TC: O(n * l * log(l)), SC: O(n * l)

전체 문자열 개수 n개, 문자열 최대 길이 l.

아이디어:
- 모든 문자열에 대해 해당 문자열을 이루는 문자 조합으로 고유한 키를 만드는 것이 주된 아이디어.
- 문자열을 해체해서 sort한 다음 이를 바로 tuple로 만들어서 키로 사용했다.
- list를 리턴하라고 되어있는 것을 `dict_values`로 리턴했지만 문제가 생기지 않아서 그냥 제출했다.

SC:
- dict 관리.
- 키값 최대 n개, 각각 최고 길이 l. O(n * l).
- 총 아이템 n개, 각각 최고 길이 l. O(n * l).
- 종합하면 O(n * l).

TC:
- strs에 있는 각 아이템을 sort함. O(l * log(l))
- 위의 과정을 n번 반복.
- 종합하면 O(n * l * log(l)).
"""


class Solution:
def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
d = {}
for s in strs:
# k값 계산이 오른쪽에서 먼저 이루어지는군요?!
d[k] = d.get(k := tuple(sorted(s)), []) + [s]
Copy link
Contributor

Choose a reason for hiding this comment

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

파이썬 코드가 함축적이라서 공간 복잡도 분석이 좀 햇갈려서 질문드려요. 좀 풀어서 보면...

d[k] = [애너그램1, 애너그램2, ..., 애너그램n] + [새로운 애너그램]

위와 같이 리스트 두 개를 더해서 새로운 리스트를 만들어서 사전에 들어있는 기존 리스트를 덮어쓰도록 구현을 하셨는데요.

[애너그램1, 애너그램2, ..., 애너그램n].append(새로운 애너그램)
# 즉, d[k].append(새로운 애너그램)

위와 같이 그냥 사전에 들어있는 기존 리스트에 새로운 애너그램을 추가하도록 구현을 할 수도 있을 것 같아요.

이 두 가지 방식이 메모리 효율 측면에서 유의미한 차이가 있을까요?

Copy link
Contributor

@obzva obzva Sep 11, 2024

Choose a reason for hiding this comment

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

image

궁금해서 확인해보니까 append, +=는 해당 list를 그대로 둔 채로 새 원소를 추가해주는 것 같고 b = b + [..] 방식으로 list를 이어줄 경우엔 아예 새로운 list를 할당해주는 것 같네요

return d.values()


"""TC: O(n * l * log(l)), SC: O(n * l)
각 단어를 sort하는 것보다 단어를 이루고 있는 문자를 카운터로 세어서 이 카운터를 키로 쓰는 것이
시간복잡도에 더 좋을 수도 있다. Counter를 써서 알파벳 개수를 dict로 만든 다음 json.dumps로 str
로 만들어버리자.

실제 이 솔루션을 제출하면 성능이 별로 좋지 않은데, l값이 작아서 위의 과정을 처리하는 데에 오버헤드가
오히려 더 붙기 때문을 추정된다.
"""

from collections import Counter
from json import dumps


class Solution:
def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
d = {}
for s in strs:
d[k] = d.get(k := dumps(Counter(s), sort_keys=True), []) + [s]
return d.values()
38 changes: 38 additions & 0 deletions implement-trie-prefix-tree/haklee.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""
단순한 trie 구현이므로 분석은 생략합니다.
"""


class Trie:
def __init__(self):
self.next: dict[str, Trie] = {}
self.end: bool = False

def insert(self, word: str) -> None:
cur = self

for c in word:
cur.next[c] = cur.next.get(c, Trie())
cur = cur.next[c]

cur.end = True

def search(self, word: str) -> bool:
cur = self

for c in word:
if c not in cur.next:
return False
cur = cur.next[c]

return cur.end

def startsWith(self, prefix: str) -> bool:
cur = self

for c in prefix:
if c not in cur.next:
return False
cur = cur.next[c]

return True
105 changes: 105 additions & 0 deletions word-break/haklee.py
Copy link
Contributor

Choose a reason for hiding this comment

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

이전 문제의 trie 응용한거 좋네요! 👍

Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""TC: ?, SC: O(w * l + s^2)

쪼개고자 하는 단어의 길이 s, wordDict에 들어가는 단어 개수 w, wordDict에 들어가는 단어 최대 길이 l

아이디어:
- trie를 구현하는 문제에서 만든 클래스를 여기서 한 번 사용해보자.
- 주어진 단어들을 전부 trie에 집어넣는다.
- 쪼개려고 하는 단어를 trie를 통해서 매칭한다. (`Trie` 클래스의 `find_prefix_indices` 메소드)
- 단어를 앞에서부터 한 글자씩 매칭하면서
- 중간에 end가 있는 노드를 만나면 `prefix_indices`에 값을 추가한다. "이 단어는 이 index
에서 쪼개질 수 있어요!" 하는 의미를 담은 index라고 보면 된다.
- 글자 매칭이 실패하면 매칭 종료.
- 매칭이 끝나고 나서 `prefix_indices`를 리턴한다.
- e.g.) wordDict = ["leet", "le", "code"], s = "leetcode"일때
- 첫 글자 l 매칭. 아무 일도 일어나지 않음.
- 다음 글자 e 매칭. 이 노드는 end가 true다. "le"에 대응되기 때문. prefix_indices에 2 추가.
- 다음 글자 e 매칭. 아무 일도 일어나지 않음.
- 다음 글자 t 매칭. 이 노드는 "leet"에 대응되어 end가 true다. prefix_indices에 4 추가.
- 다음 글자 c 매칭. 매칭 실패 후 종료.
- prefix_indices = [2, 4]를 리턴.
- 위의 매칭 과정이 끝나면 주어진 단어를 쪼갤 수 있는 방법들이 생긴다.
- 쪼개진 단어에서 뒷 부분을 취한다.
- e.g.) wordDict = ["leet", "le", "code"], s = "leetcode", prefix_indices = [2, 4]
- prefix_indices의 각 아이템을 돌면서
- s[2:]를 통해서 "le/etcode" 중 뒷 부분 "etcode"를 취할 수 있다.
- s[4:]를 통해서 "leet/code" 중 뒷 부분 "code"를 취할 수 있다.
- 만약 뒷 부분이 빈 문자열("")이 될 경우 탐색에 성공한 것이다.
- 코드 상에서는 빈 문자열로 탐색을 시도할 경우 탐색 성공의 의미로 true 반환.
- 만약 prefix_indices가 빈 리스트로 온다면 쪼갤 수 있는 방법이 없다는 뜻이므로 탐색 실패다.
- 그 외에는 취한 뒷 부분들에 대해 각각 다시 쪼개는 것을 시도한다.
- 위의 과정에서 쪼개는 것을 이미 실패한 단어를 fail_list라는 set으로 관리하여 중복 연산을 막는다.
- 즉, memoization을 활용.

SC:
- trie 생성. 최악의 경우 O(w * l)
- fail list에 들어갈 수 있는 단어 길이
- 1, 2, ..., s
- O(s^2)
- 이걸 전체 단어를 저장하는 것 대신 맨 앞 글자의 index를 저장하는 식으로 구현하면 O(s)가 될 것이다.
여기에서는 구현 생략.
- find함수의 호출 스택 최악의 경우 한 글자씩 앞에서 빼면서 탐색 시도, O(s)
- 종합하면 O(w * l) + O(s^2) + O(s) = O(w * l + s^2)

TC:
- ???
"""


class Trie:
def __init__(self):
self.next: dict[str, Trie] = {}
self.end: bool = False

def insert(self, word: str) -> None:
cur = self

for c in word:
cur.next[c] = cur.next.get(c, Trie())
cur = cur.next[c]

cur.end = True

def find_prefix_indices(self, word: str) -> list[str]:
prefix_indices = []
ind = 0
cur = self

for c in word:
ind += 1
if c not in cur.next:
break
cur = cur.next[c]
if cur.end:
prefix_indices.append(ind)

return prefix_indices


class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
# init
trie = Trie()
for word in wordDict:
trie.insert(word)

fail_list = set()

# recursive find
def find(word: str) -> bool:
# 단어의 앞에서 쪼갤 수 있는 경우를 전부 찾아서 쪼개고
# 뒤에 남은 단어를 다시 쪼개는 것을 반복한다.
if word == "":
return True

if word in fail_list:
return False

cut_indices = trie.find_prefix_indices(word)
result = any([find(word[i:]) for i in cut_indices])
if not result:
fail_list.add(word)

return result

return find(s)