Skip to content

[haklee] week 4 #432

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

Merged
merged 1 commit into from
Sep 7, 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
36 changes: 36 additions & 0 deletions longest-consecutive-sequence/haklee.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""TC: O(n), SC: O(n)

아이디어:
- nums 안에는 여러 consecutive sequence(이하 cs)가 존재할 것이다(길이 1인 것까지 포함).
- 이 cs는 모두 제일 앞 숫자를 가지고 있다.
- 제일 앞 숫자는 그 숫자 바로 앞에 숫자가 없다는 특징을 가지고 있다.
- 즉, i가 제일 앞 숫자라면 i-1은 nums 안에 없다.
- nums에서 제일 앞 숫자를 찾은 다음, 이 숫자부터 뒤로 이어지는 cs를 찾아서 길이를 구할 수 있다.
- cs의 길이 중 제일 긴 것을 찾아서 리턴하면 된다.

SC:
- set(nums)에서 O(n).
- 위 set에서 모든 아이템을 돌면서 i-1이 set 안에 포함되지 않는 i를 찾아서 리스트로 만들때 O(n).
- 총 O(n).

TC:
- set(nums)에서 O(n).
- 위 set에서 모든 아이템을 돌면서 i-1이 set 안에 포함되지 않는 i를 찾는 데에 O(n).
- 각 cs의 제일 앞 숫자부터 이어지는 숫자들이 set 안에 있는지 체크하는 데에 총 O(n).
- 총 O(n).
"""


class Solution:
def longestConsecutive(self, nums: List[int]) -> int:
s = set(nums) # TC:O(n), SC:O(n)
seq_start_candidate = [i for i in s if i - 1 not in s] # TC:O(n), SC:O(n)
sol = 0

# 아래의 for문 내에서는 s에 속한 아이템에 한 번씩 접근한다. TC:O(n)
for i in seq_start_candidate:
seq_len = 1
while i + seq_len in s:
seq_len += 1
sol = max(seq_len, sol)
return sol
65 changes: 65 additions & 0 deletions maximum-product-subarray/haklee.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""TC: O(n), SC: O(1)

※ 코드를 보고 대충 뭘 했는지 감을 잡은 다음에 아래의 아이디어에서 p, n 구간이 나오는 부분만 보면
좀 더 빠른 이해가 가능하다.

아이디어:
- 곱에 0이 섞이면 아무리 많은 수를 곱해도 결과는 0이다.
- 0이 등장하지 않는 어떤 subarray가 주어졌다고 하자.
- 여기에 음수가 짝수 번 등장하면 모든 숫자를 곱한 것이 가장 큰 곱이 된다.
- 음수가 홀수 번, 총 r번 등장한다고 해보자. 이 배열을 다음과 같이 구조화할 수 있다.
- 양수를 p, 음수를 n이라고 표현하자.
- 이 배열은 [p, ..., p, n, p, ... p, n, ..., n, p, ..., p] 꼴로 표현이 가능하다.
이때, n은 총 r번 등장하고, n은 p가 여러 번(0번도 가능) 등장하는 묶음 사이에 존재한다.
- p가 여러 번(0번도 가능) 등장하는 것을 P라고 묶어서 표현하면 아래와 같이 볼 수 있다.
- [(P), n, (P), n, ..., n, (P), n, (P)]
- n을 짝수 번 곱해야 양수가 나온다. 연속된 값을 최대한 많이 곱하려고 하므로, 한쪽 끝에
등장하는 n을 뺀 나머지 n들을 곱하는 것이 가장 좋은 전략이다.
- 위의 전략에 따라 배열의 숫자를 곱하려고 하면 다음 둘 중 하나가 가장 큰 숫자다.
- [(P), n, (P), n, ..., n, (P), n, (P)]
└───────────────────────┘
이 구간의 숫자들을 모두 곱함
- [(P), n, (P), n, ..., n, (P), n, (P)]
└───────────────────────┘
이 구간의 숫자들을 모두 곱함
- 즉, 앞에서부터 숫자를 계속 곱하면서 max값을 찾은 것, 혹은 뒤에서부터 숫자를 계속
곱하면서 max값을 찾은 것, 둘 중 하나가 위의 구간에서의 최대 곱셈 값이 된다.
- 우리에게 주어진 전체 array는 다음과 같이 표현 가능하다.
- 0이 등장하지 않는 길이 1 이상의 subarray를 (S), 0으로만 이루어진 길이 0 이상의 subarray를
(0)이라고 하면 아래와 같이 표현이 가능하다.
- [(0), (S), (0), (S), ..., (S), (0), (S), (0)]
- 전체 array에서 최대 subarray 곱은 0이거나, 혹은 위의 각 S에서의 최대 곱들 중 최대 값이다.
- 위의 아이디어를 활용하면 다음의 방식으로 최대 subarray의 곱을 찾을 수 있다.
- nums의 앞에서부터 숫자를 하나씩 곱해가면서 최대 곱을 찾음. 단, 중간에 0이 나와서 최대 곱
값이 0이 되었을 경우 이를 다시 1로 바꿔줘서 위의 아이디어를 적용할 수 있도록 세팅.
- 똑같은 작업을 nums의 뒤에서부터 숫자를 하나씩 곱해가면서 진행함.


SC:
- solution을 저장하는 데에 O(1).
- 곱셈 값을 저장하는 데에 O(1).
- 총 O(1).

TC:
- 리스트를 앞에서부터 순회하면서 곱/max 연산. O(n).
- 리스트를 뒤에서부터 순회하면서 곱/max 연산. O(n).
- 총 O(n).
"""


class Solution:
def maxProduct(self, nums: List[int]) -> int:
sol = nums[0]
p = 1
for i in nums:
if p == 0:
p = 1
p *= i
sol = max(p, sol)
p = 1
for i in reversed(nums):
if p == 0:
p = 1
p *= i
sol = max(p, sol)
return sol
26 changes: 26 additions & 0 deletions missing-number/haklee.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""TC: O(n), SC: O(n)

아이디어:
- [0, ..., n]에서 한 숫자만 빠져있는 상황.
- [0, ..., n]을 set으로 만든 다음 특정 숫자가 이 set에 있는지 체크하면 된다.
- 그런데 그렇게 구현하나 위 set에서 set(nums)를 빼고 남은 숫자를 취하나 최악의 경우
같은 성능이 나올테니 더 코드가 짧아지도록 후자의 방식으로 구현해보자.


SC:
- [0, ..., n]으로 set을 만드는 데에 O(n).
- set(nums)에서 O(n).
- 총 O(n).

TC:
- [0, ..., n]으로 set을 만드는 데에 O(n).
- set(nums)에서 O(n).
- set에 difference(아래 코드에서는 `-`)를 하는 데에 O(n).
- set에 pop을 하는 데에 O(1).
- 총 O(n).
"""


class Solution:
def missingNumber(self, nums: List[int]) -> int:
return (set(range(len(nums) + 1)) - set(nums)).pop()
24 changes: 24 additions & 0 deletions valid-palindrome/haklee.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""TC: O(n), SC: O(n)

아이디어:
문자열을 보고 숫자 혹은 알파벳인 문자만 뽑아서 새 문자열을 만들고, 대문자는 소문자로 바꾼다.
구현이 어렵지는 않지만 귀찮을 수 있는데, python에는 위 과정을 `isalnum()`, `lower()` 함수로
쉽게 처리할 수 있다. 마지막으로 새로 만든 문자열을 뒤집어서 원래 문자열과 같은지 확인하면 된다.


SC:
- 문자열을 필요한 문자만 남기는 과정에서 O(n).
- 문자열을 뒤집어서 저장. O(n).
- 즉, O(n).

TC:
- 문자열을 필요한 문자만 남기는 과정에서 O(n).
- 문자열 뒤집기. O(n).
- 새로 만든 문자열과 뒤집은 문자열 palindrome 체크. O(n).
- 즉, O(n).
"""


class Solution:
def isPalindrome(self, s: str) -> bool:
return (l := [c.lower() for c in s if c.isalnum()]) == l[::-1]
Copy link
Member

Choose a reason for hiding this comment

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

뭡니까 이거 ㅋㅋㅋ 약 오르네요 ㅎㅎㅎ

61 changes: 61 additions & 0 deletions word-search/haklee.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""TC: O(m * n * (4^l)), SC: O(m * n)

아이디어:
- 격자판의 각 칸을 노드로, 이웃한 칸들의 관계를 엣지로 생각하면 격자판을 그래프로 볼 수 있다.
- 위 그래프에서 dfs를 돌린다.
- 이때, 기존에 방문했던 노드를 재방문하지 않도록 visited라는 2차원 배열을 같이 관리해주자.

SC:
- visited 배열에서 O(m * n)
- 호출 스택은 찾고자 하는 단어의 길이 l, 즉, O(l).
- 그런데 l이 격자 전체 칸 개수보다 클 수 없으므로 무시 가능.
- 총 O(m * n).
Comment on lines +11 to +12
Copy link
Member

Choose a reason for hiding this comment

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

l이 격자 전체 칸 개수보다 클 수 없다고 해서 무시하는 것 보다는 O(m * n + l)이 더 정확한 분석이 아닐까요? 시간 복잡도 분석하실 때는 l을 무시하시지 않으셨잖아요.

Copy link
Contributor Author

@haklee haklee Sep 6, 2024

Choose a reason for hiding this comment

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

시간복잡도에서는 곱해야 하니까 l을 표현해줄 필요가 있는데, 공간복잡도는 다음과 같이 생각했습니다.

  • 설명을 따라가면 공간복잡도는 O(m * n) + O(l)이 되어야 합니다.
  • 그런데 이때 호출 스택이 m * n + 1보다 커질 수 없습니다. 왜냐하면 모든 칸을 다 탐색한 상태에서는 더 이상 탐색할 수 있는 칸이 없어서 False를 리턴하게 되어 호출 스택이 더 길어지지 않기 때문입니다.
  • 그렇다면 저 위에서 말한 호출 스택 크기 O(l)은 아무리 커도 O(m * n)보다 커질 수 없으므로 O(l) < O(m * n)을 만족합니다. 쓰다 보니까 O(l)이라고 하는 것에 약간 오해의 소지가 있네요.. 그냥 호출 스택 크기라고 하는 것이 나을 뻔했습니다.
  • 그리고 호출 스택 크기는 당연하게도 O(1)보다는 큽니다.
  • 정리하면 O(1) < 호출 스택 크기 < O(m * n)를 만족하게 됩니다.
  • 그러므로 O(m * n) + O(1) = O(m * n) < O(m * n) + 호출 스택 크기 < O(m * n) + O(m * n) = O(2 * m * n) = O(m * n)이 됩니다. 즉, 전체 공간 복잡도는 O(m * n)이 됩니다.

Copy link
Member

@DaleSeo DaleSeo Sep 6, 2024

Choose a reason for hiding this comment

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

아하 그렇군요! 자세히 사고 과정을 설명해주셔서 감사합니다 🙇‍♂️ 제 생각이 짧았네요...

Copy link
Contributor

Choose a reason for hiding this comment

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

이렇게 볼 수 있군요..!

Copy link
Member

@DaleSeo DaleSeo Sep 10, 2024

Choose a reason for hiding this comment

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

@haklee @obzva 제가 오늘 알고달레 글을 정정하려다가 이 거에 대해서 다시 생각해보게 되었는데요. 우리가 너무 호출 스택의 크기에만 초점을 맞춰서 논의한 게 아닌가 하는 생각이 들었습니다. 사실 이 답안의 공간 복잡도는 다음 2가지 측면에서 분석이 되야할 것 같아요.

  1. visited 리스트가 차지하는 메모리: O(m * n)
  2. search 재귀 함수의 호출 스택이 차지하는 메모리: O(l)

제가 재귀 함수의 호출 스택이 차지하는 메모리를 @haklee 님과 다르게 O(l)로 생각하는 이유는 다음과 같아요. 저는 3가지 경우로 나누어서 분석을 해봤어요.

  1. 단어가 격자에 존재하는 경우: 호출 스택 깊이 = l
  2. 단어가 격자에 존재하는 않는 경우: 호출 스택 깊이 < l
  3. 단어가 너무 길어서 격자가 품을 수 없는 경우: 호출 스택 깊이 = m * n < l

결국 스택 깊이의 상하선을 결정짓는 것은 m * n보다는 l로 보는 것이 더 합리적이라는 결론을 내렸어요.

제가 over thinking 했을까요? 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@DaleSeo 늦은 확인 및 답변 죄송합니다..! ㅠㅠ
저도 위에서 세 가지 경우로 나눠서 분석하신 것과 같은 생각을 했는데, 제가 호출 스택의 깊이를 신경쓰지 않고 공간복잡도를 계산했던 이유는 호출 스택으로 도출되는 공간복잡도가 O(m * n)보다 작은데 visited 리스트에서 O(m * n)을 이미 먹고 있어서 자연스럽게 무시할 수 있게 되어서 였습니다.
위에서 말씀하신 것에 따르면 호출 스택 깊이는 O(min(l, m*n)) 같이 표현하는 것은 어떨까요?


TC:
- visited 배열 세팅, O(m * n)
- dfs, 최악의 경우
- 단어를 찾는 시도를 하는 데에 4^l 만큼의 탐색이 걸림
- 그런데 답을 찾을 수 있는 시작 칸을 하필 제일 마지막으로 탐색한 경우 위의 시도를 m*n번 해야함
- 즉, O(m * n * (4^l))
- 총 O(m * n * (4^l))
"""


class Solution:
def exist(self, board: List[List[str]], word: str) -> bool:
r, c = len(board), len(board[0])
visited = [[False for _ in range(c)] for _ in range(r)]

def search(ind: int, pos: tuple[int, int]) -> bool:
if ind == len(word):
# 찾는 데에 성공.
return True

if not (0 <= pos[0] < r and 0 <= pos[1] < c):
# 격자판을 벗어남.
return False

if visited[pos[0]][pos[1]]:
# 이미 방문함.
return False

if word[ind] != board[pos[0]][pos[1]]:
# 글자가 안 맞음.
return False

visited[pos[0]][pos[1]] = True # 방문한 것으로 체크

found = (
search(ind + 1, (pos[0] - 1, pos[1])) # 상
or search(ind + 1, (pos[0] + 1, pos[1])) # 하
or search(ind + 1, (pos[0], pos[1] - 1)) # 좌
or search(ind + 1, (pos[0], pos[1] + 1)) # 우
) # 다음 글자 찾기
Copy link
Contributor

Choose a reason for hiding this comment

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

오 이부분 제가 딱 원하던 방식이었는데 이렇게 하면 되는군요

...
for (let i = 0; i < 4; i++) {
  const [dh, dw] = directives[i];
  if (dfs(h + dh, w + dw, index + 1)) {
    return true;
  }
...

취향 문제겠지만, 제 풀이중에 이 부분이 정말 마음에 들지 않았는데 덕분에 아래처럼 수정 할 수 있을것같습니다!

const found = (
  dfs(h + 1, w, index + 1) ||
  dfs(h - 1, w, index + 1) ||
  dfs(h, w + 1, index + 1) ||
  dfs(h, w - 1, index + 1)
);


# 앞에서 못 찾았을 경우에는 방문을 해제해야 한다.
# 찾은 경우에는 방문을 해제하든 말든 상관 없음.
visited[pos[0]][pos[1]] = False

return found

return any(search(0, (i, j)) for i in range(r) for j in range(c))