From 351bb590bea02c24a387ce378aeae641e2fc2e07 Mon Sep 17 00:00:00 2001 From: Hak Lee Date: Sun, 25 Aug 2024 23:05:14 +0900 Subject: [PATCH 1/2] solutions --- climbing-stairs/haklee.py | 26 ++++++ coin-change/haklee.py | 51 +++++++++++ combination-sum/haklee.py | 114 +++++++++++++++++++++++++ product-of-array-except-self/haklee.py | 49 +++++++++++ two-sum/haklee.py | 53 ++++++++++++ 5 files changed, 293 insertions(+) create mode 100644 climbing-stairs/haklee.py create mode 100644 coin-change/haklee.py create mode 100644 combination-sum/haklee.py create mode 100644 product-of-array-except-self/haklee.py create mode 100644 two-sum/haklee.py diff --git a/climbing-stairs/haklee.py b/climbing-stairs/haklee.py new file mode 100644 index 00000000..87179ec4 --- /dev/null +++ b/climbing-stairs/haklee.py @@ -0,0 +1,26 @@ +"""TC: O(n), SC: O(1) + +아이디어: +계단의 k번째 칸까지 도달하는 방법의 수를 f(k)라고 하자. +f(k)는 다음의 두 경우의 수를 더한 값이다. + - k-2번째 칸까지 간 다음 두 칸 뜀. 즉, f(k-2) + - k-1번째 칸까지 간 다음 두 칸 뜀. 즉, f(k-1) +즉, f(k) = f(k-2) + f(k-1) + + +SC: +- tabulation 과정에서 값 2개만 계속 유지한다. +- 즉, O(1). + +TC: +- 단순 덧셈 계산(O(1))을 O(n)번 반복한다. +- 즉, O(n). +""" + + +class Solution: + def climbStairs(self, n: int) -> int: + a, b = 1, 1 + for _ in range(n - 1): + a, b = b, a + b + return b diff --git a/coin-change/haklee.py b/coin-change/haklee.py new file mode 100644 index 00000000..6612f9f4 --- /dev/null +++ b/coin-change/haklee.py @@ -0,0 +1,51 @@ +"""TC: O(m*n), SC: O(n) + +coin 종류: m +amount 크기: n + +아이디어: +값 k를 만들때 필요한 최소 동전의 수를 f(k)라고 하자. +f(k)는 다음의 경우 중 제일 작은 값이다. + - 동전 c1을 마지막으로 더해서 k를 만들었다. f(k-c1) + 1개의 동전 필요. + - 동전 c2을 마지막으로 더해서 k를 만들었다. f(k-c2) + 1개의 동전 필요. + - ... + - 동전 cm을 마지막으로 더해서 k를 만들었다. f(k-cm) + 1개의 동전 필요. +즉, f(k) = min(f(k-c1), f(k-c2), ..., f(k-cm)) + 1 + +이때, n보다 작은 모든 i에 대해서 한 번 f(i)값을 계산할 일이 있었으면 이를 저장해두고 사용하는 방법으로 +접근해서 문제를 풀 수 있다. + +SC: +- n보다 작은 모든 i에 대해 f(i)값을 저장해두는 배열 필요. +- 즉, O(n). + +TC: +- 각 f(i)마다 최초 계산시 m개의 아이템을 list에 넣고 min값을 찾는 계산을 한 번 한다. O(m). +- 최초 계산이 아닐 경우 배열에 저장된 값을 가져온다. O(1). +- 각 f(i)는 f(i+c1), f(i+c2), ..., f(i+cm)을 계산할때 호출되는데, 여기에 O(m) + O(1) + ... + O(1) + 만큼의 시간이 소요되므로 종합하면 O(m) + (m+1)*O(1) = O(m) 만큼의 시간이 소요된다. +- 이러한 f(i)값이 총 n개 있다. 즉, O(m*n). +""" + + +class Solution: + def coinChange(self, coins: List[int], amount: int) -> int: + arr = [None for _ in range(amount + 1)] # None값은 아직 계산되지 않았다는 뜻. + arr[0] = 0 # 초기화 + + def dp(target): + if arr[target] is None: # 만약 아직 f(target)이 계산되지 않았다면 + # 모든 동전들 c에 대해 f(target - c)는 다음의 경우들만 유효하다. + # - target이 동전 c의 크기 이상은 되어야 한다. + # - 앞서 계산해본 결과 총 금액 target - c를 구할 수 없는 경우는 무시. + # - 구할 수 없는 것으로 판명된 경우 f(x)의 값이 -1이다. + candidates = [ + v for c in coins if target - c >= 0 and (v := dp(target - c)) >= 0 + ] + # candidates에 유효한 f(target - c)값이 하나도 없으면 f(target)은 -1이다. + # 그게 아니라면 candidates에 들어있는 값 중 제일 적은 수의 동전을 필요로 하는 + # 경우에 1을 더한 값을 f(target)에 넣어둠. + arr[target] = -1 if len(candidates) == 0 else min(candidates) + 1 + return arr[target] + + return dp(amount) diff --git a/combination-sum/haklee.py b/combination-sum/haklee.py new file mode 100644 index 00000000..2e3e418e --- /dev/null +++ b/combination-sum/haklee.py @@ -0,0 +1,114 @@ +"""TC: O(m^n), SC: O(m^n) + +candidates에 있는 값의 개수: m +target의 크기: n + +아이디어: +candidates(이하 cands)에 있는 숫자들을 더해서 k를 만드는 방법을 f(k)라고 하자. +f(k)는 다음의 경우들을 종합한 것이다. + - cands에 있는 c1을 마지막으로 더해서 k를 만들었다. 즉, f(k-c1)에 있는 모든 방법의 끝에 c1을 더함. + - cands에 있는 c2를 마지막으로 더해서 k를 만들었다. 즉, f(k-c2)에 있는 모든 방법의 끝에 c2을 더함. + ... + - cands에 있는 cm을 마지막으로 더해서 k를 만들었다. 즉, f(k-cm)에 있는 모든 방법의 끝에 cm을 더함. + +이렇게 하면 문제는, 하나의 값을 만드는 데에 중복된 경우가 나올 수 있다는 것이다. +e.g.) candidates = [2, 3], target = 5 +f(2) = [[2]] +f(3) = [[3]] +위의 값을 활용해서 f(5)를 구하면 +f(5) = [f(2)의 방법들의 끝에 3을 붙임] + [f(3)의 방법들의 끝에 2를 붙임] + = [[2, 3]] + [[3, 2]] + = [[2, 3], [3, 2]] + +그래서 마지막에 같은 아이템으로 이루어진 리스트를 찾아서 중복을 제거해준다. + +이번 문제에서는 [2, 2, 3], [2, 3, 2], [3, 2, 2] 같이 들어가는 아이템의 순서만 다른 경우를 같은 것으로 +보았기 때문에 마지막에 중복을 제거했지만, 만약 이들을 서로 다른 방법으로 보는 문제가 주어진다면 위의 +결과를 그대로 리턴하면 된다. + + +SC: +- 문제 특성상 f(i)에 들어갈 수 있는 방법의 수는 + - i를 만드는 방법의 길이는 O(i). + - cands에 1이 있고 이 1로 가득 채운 방법 [1, 1, ..., 1]을 생각하면 편하다. + - cands의 최소값이 어떤 상수 x라고 해도 [x, x, ..., x]에는 i/x가 들어가는데, O(i/x)는 O(i). + - 각 방법에 들어있는 아이템은 m가지 경우의 수가 가능. [(c1, c2, ..., cm 중 하나), ..., (c1, c2, ..., cm 중 하나)] + - 즉, f(i)에는 O(m^i)가지 경우가 들어갈 수 있다. +- f(1), f(2), ..., f(n)을 다 더하면 O(m^1) + O(m^2) + ... + O(m^n) = O(m^n)이 된다. +- 즉, O(m^n). + +TC: +- 위의 SC와 같은 방식으로 접근이 가능하다. O(m^n). +""" + + +class Solution: + def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]: + dp = [[] for _ in range(target + 1)] # 초기화. + dp[0] = [[]] # 0을 만드는 방법은 아무 숫자도 넣지 않는 것 한 가지 방법이 있다. + for cur in range(1, target + 1): # f(i)를 1부터 계산해나가면서 채우기 시작. + for cand in candidates: + prev = cur - cand + if prev >= 0: + dp[cur] += [combi + [cand] for combi in dp[prev]] + + # 마지막에 중복된 경우를 제거해준다. + return list(set([tuple(sorted(i)) for i in dp[target]])) + + +""" +아이디어: +중간중간 계산하면서 중복된 값을 제거하면서 f(i)값을 관리하는 식으로 커팅하는 것이 가능하다. +각 방법은 [c1, ...c1, c2, ..., c2, ... , cm, ..., m] 꼴이 되는데, + [ ^ ^ ... ^ ] +각 f(i)마다 위의 `^` 표시를 해둔 곳을 찾는 방법의 수만큼 공간이 필요하다. +이 숫자는 대략 (i choose m-1)이라고 생각할 수 있다. + +그러므로 SC와 TC 모두 O(n choose m) = O((n/m)^m)...? +(ref: https://en.wikipedia.org/wiki/Binomial_coefficient#Bounds_and_asymptotic_formulas) +""" + + +class Solution: + def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]: + dp = [[] for _ in range(target + 1)] + dp[0] = [[]] + for cur in range(1, target + 1): + for cand in candidates: + prev = cur - cand + if prev >= 0: + dp[cur] += [combi + [cand] for combi in dp[prev]] + dp[cur] = list(set([tuple(sorted(i)) for i in dp[cur]])) + return list(set([tuple(sorted(i)) for i in dp[target]])) + + +""" +아이디어: +만들고 나서 중복된 경우를 제거하지 말고, 처음부터 중복된 결과를 만들지 않는 것도 방법이다. +각 방법마다 해당 방법에서 사용한 최대 candidate 인덱스를 달아두고, 이후 해를 구할 때는 해당 +인덱스 이상의 candidate만 추가할 수 있도록 단서를 달아두는 방식으로 구현 가능하다. +e.g.) candidate = [2, 5, 3] +f(10)에 들어있을 수 있는 방법은 +- ([2, 2, 2, 2, 2], 0) : 마지막 아이템 이후에 2, 5, 3 전부 등장 가능. +- ([5, 5], 1) : 마지막 아이템 이후에 5, 3만 등장 가능. +- ([2, 5, 3], 2) : 마지막 아이템 이후에 3만 등장 가능. +- ([2, 2, 3, 3], 2) : 마지막 아이템 이후에 3만 등장 가능. + +SC와 TC는 바로 위에서 O(n choose m)을 계산한 것과 같을 것으로 보인다. +""" + + +class Solution: + def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]: + dp = [[] for _ in range(target + 1)] + dp[0] = [([], 0)] # (방법, 사용 가능한 candidate index 최소값) 쌍. + for cur in range(1, target + 1): + for i in range(len(candidates)): + prev = cur - candidates[i] + if prev >= 0: + dp[cur] += [ + (combi[0] + [candidates[i]], i) + for combi in dp[prev] + if i >= combi[1] + ] + return [i[0] for i in dp[target]] # 방법만 추출해서 리턴한다. diff --git a/product-of-array-except-self/haklee.py b/product-of-array-except-self/haklee.py new file mode 100644 index 00000000..c653893e --- /dev/null +++ b/product-of-array-except-self/haklee.py @@ -0,0 +1,49 @@ +"""TC: O(n), SC: O(n) + +아이디어: +다음의 세 상황 중 하나가 될 것이다. +- 0이 둘 이상 포함. 모든 결과값이 0이 된다. +- 0이 하나 포함. + - 0에 해당하는 인덱스에는 nums에 있는 숫자들 중 0 말고 나머지 숫자들을 전부 곱한 값. + - 그 외 모두 0. +- 0이 하나도 없다. + - nums에 있는 모든 숫자들을 곱한 값을 p라고 할때, 각 인덱스 i마다 p를 nums[i]로 나눈 값. + +그러므로, 먼저 nums에 있는 숫자들을 돌면서 +- 0이 아닌 숫자를 전부 곱한 값 p를 만든다. +- 0이 들어있는 인덱스를 찾는다. + +그 다음 +- 0이 들어있는 인덱스의 길이를 보고 위의 세 상황 중 하나에 대응해서 결과값을 리턴한다. + +SC: +- 0이 아닌 숫자들을 곱한 값 p를 관리할때 O(1) +- 0이 등장하는 인덱스 zero_ind를 관리할때 O(n) +- 결과로 리턴할 값 O(n) +- 종합하면 O(n). + +TC: +- nums값을 한 번 순회하면서 p, zero_ind값을 업데이트 할때 O(n) +- 결과로 리턴할 값 만들때 O(n) +- 종합하면 O(n). +""" + + +class Solution: + def productExceptSelf(self, nums: List[int]) -> List[int]: + zero_ind = [] + p = 1 + for i, e in enumerate(nums): + if e == 0: + zero_ind.append(i) + else: + p *= e + + sol = [0] * len(nums) + if len(zero_ind) > 1: + return sol + elif len(zero_ind) == 1: + sol[zero_ind[0]] = p + return sol + else: + return [p // i for i in nums] diff --git a/two-sum/haklee.py b/two-sum/haklee.py new file mode 100644 index 00000000..16b42d13 --- /dev/null +++ b/two-sum/haklee.py @@ -0,0 +1,53 @@ +"""TC: O(n), SC: O(n) + +아이디어: +만약 nums에 있는 어떤 숫자 i에 대해 target - i도 nums 안에 있다면, 이 두 숫자의 인덱스를 리턴하면 된다. +그런데 이렇게 하면 다음과 같은 예외 상황이 발생할 수 있다. + +case 1) nums = [3, 3], target = 6 +- nums에 3이 두 번 나오면 i도 3이고 target - i도 3인데, 그럼 같은 숫자에 대해서 어떻게 서로 다른 인덱스를 + 가져오는가? +case 2) nums = [2, 3, 4], target = 6 +- nums에 3이 하나밖에 없는데, i = 3일때 i도 nums 안에 있고, target - i = 3이라 이 값도 nums 안에 있다. + 서로 다른 두 아이템을 더해서 6이 되어야 하므로 [1, 1]같은 답을 리턴하면 오답이다. + +위의 문제를 해결하기 위해 다음과 같은 방법을 사용했다. +- nums에 있는 unique한 숫자에 대해 각 숫자의 마지막 인덱스를 dict로 저장한다. +- 만약 nums에 속하는 어떤 수 i에 대해 i와 target - i의 값이 같을 경우, nums 안에 있는 i 중에 제일 앞에 있는 + i의 인덱스를 찾아서 앞서 만든 dict에 있는 인덱스와 비교해서 다른지 확인한다. + +Q) 그런데 만약 i, target - i 값이 서로 다를때 둘 중 하나라도 nums 안에 두 번 이상 등장하면 어떡하지...? +A) 그럴 일은 없다. 솔루션은 유일하기 때문에, i나 target - i가 두 번 이상 나오면 솔루션이 둘 이상이 됨. + + +SC: +- ind_dict를 만들 때랑 nums_set 만들때 각각 O(n). +- 즉, O(n) + +TC: +- ind_dict를 만들때 O(n). +- nums_set에 있는 값의 개수만큼 순회, O(n) + - 내부에서 일어나는 일은 O(1) +- 종합하면 O(n). +""" + + +class Solution: + def twoSum(self, nums: List[int], target: int) -> List[int]: + # 먼저 nums의 각 아이템마다 인덱스를 찾는다. + # 여기서 주의할 것은, nums에 같은 숫자가 두 번 이상 나오면 마지막 아이템의 인덱스가 들어감. + ind_dict = {k: i for i, k in enumerate(nums)} # SC: O(n), TC: O(n) + + # nums에 있는 unique한 값 i마다, + for i in (nums_set := set(nums)): # SC: O(n), TC: O(n) + # 만약 target - i도 nums 안에 있다면, + if target - i in nums_set: + # 그리고 i와 target - i가 서로 다른 숫자라면, + if i != target - i: + # 쉬운 케이스. 그냥 두 숫자의 인덱스를 찾아서 결과로 리턴한다. + return [ind_dict[i], ind_dict[target - i]] + + # 여기에 도달했다면 i와 target - i값이 같음. + if (x := nums.index(i)) != ind_dict[target - i]: + # 첫, 마지막 등장 인덱스가 다를 경우, 이 두 숫자를 리턴. + return [x, ind_dict[target - i]] From 91a5c95b65c42cb577369ae413fe2550677a0a0e Mon Sep 17 00:00:00 2001 From: Hak Lee Date: Tue, 27 Aug 2024 22:49:52 +0900 Subject: [PATCH 2/2] updated: product of array except self --- product-of-array-except-self/haklee.py | 124 ++++++++++++++++++++++--- 1 file changed, 112 insertions(+), 12 deletions(-) diff --git a/product-of-array-except-self/haklee.py b/product-of-array-except-self/haklee.py index c653893e..3735107a 100644 --- a/product-of-array-except-self/haklee.py +++ b/product-of-array-except-self/haklee.py @@ -1,4 +1,4 @@ -"""TC: O(n), SC: O(n) +"""TC: O(n), SC: O(1) 아이디어: 다음의 세 상황 중 하나가 될 것이다. @@ -12,18 +12,19 @@ 그러므로, 먼저 nums에 있는 숫자들을 돌면서 - 0이 아닌 숫자를 전부 곱한 값 p를 만든다. - 0이 들어있는 인덱스를 찾는다. +- 0이 들어있는 인덱스가 둘 이상인지 체크한다. 그 다음 -- 0이 들어있는 인덱스의 길이를 보고 위의 세 상황 중 하나에 대응해서 결과값을 리턴한다. +- 0이 들어있는 인덱스가 둘 이상인지, 0이 들어있는 인덱스가 있는지 보고 상황에 맞게 결과값을 리턴. SC: - 0이 아닌 숫자들을 곱한 값 p를 관리할때 O(1) -- 0이 등장하는 인덱스 zero_ind를 관리할때 O(n) -- 결과로 리턴할 값 O(n) -- 종합하면 O(n). +- 0이 등장하는 인덱스 zero_ind와 둘 이상 있는지 여부 all_zero 플래그를 관리할때 O(1). +- 결과로 리턴할 값 O(n) <- 이건 공간 복잡도 분석에서 제외한다. +- 종합하면 O(1). TC: -- nums값을 한 번 순회하면서 p, zero_ind값을 업데이트 할때 O(n) +- nums값을 한 번 순회하면서 p, zero_ind, all_zero값을 업데이트 할때 O(n) - 결과로 리턴할 값 만들때 O(n) - 종합하면 O(n). """ @@ -31,19 +32,118 @@ class Solution: def productExceptSelf(self, nums: List[int]) -> List[int]: - zero_ind = [] + all_zero = True + zero_ind = None p = 1 for i, e in enumerate(nums): if e == 0: - zero_ind.append(i) + if zero_ind is not None: + break + zero_ind = i else: p *= e - + else: + all_zero = False sol = [0] * len(nums) - if len(zero_ind) > 1: + if all_zero: return sol - elif len(zero_ind) == 1: - sol[zero_ind[0]] = p + elif zero_ind is not None: + sol[zero_ind] = p return sol else: return [p // i for i in nums] + + +""" TC: O(n), SC: O(1) +하지만 윗 솔루션은 문제에서 사용하지 말라고 한 `/`(정확히는 `//`)를 썼다는 문제가 있다. +나누기를 하지 말라고 했으니 이를 어떻게 잘 우회해보자. + +※ 주의: 정밀한 계산에 취약한 접근 방법이므로 구현한 것이 잘 작동할 것이라는 사실에 + 정말 자신 있는 경우가 아니면 이렇게 구현하면 안된다. + +아이디어: +다음의 등식을 보자. + +x / y = 2^(log2(x)) / 2^(log2(y)) = 2^(log2(x)-log2(y)) + +즉, 우리는 나누기 연산을 power, log, - 연산으로 우회 가능하다. + +위의 우회 방법을 활용하기 위해 기존의 접근 방식을 다음과 같이 수정한다. +- 원래는 p를 1로 초기화한 다음 nums에 있는 값들 중 0이 아닌 것들을 전부 곱해줬다. +- 이제 p에는 2^p을 계산했을때 곱셈의 결과값이 나오는 값을 저장할 것이다. 그러므로, + p를 0으로 초기화하고 p에는 nums에 있는 값들 중 0이 아닌 값에 log를 취한 값을 더해준다. + +하지만 0이하의 실수 x에 대해서는 log(x)가 정의되지 않는 문제가 있다. +그러므로 다음과 같은 조치를 취해야 한다. +- nums에 있는 값 x가 0이면 그냥 넘어간다. 이는 기존의 접근 방법과 동일하다. +- nums에 있는 값 x가 0보다 작으면 절대값을 취하고 로그를 씌운다. + 즉, log(|x|)를 계산한다. +- 이 경우 결과에도 -1도 곱해줘야 한다는 사실을 알아야 하므로 이 정보를 + `is_neg`라는 변수로 관리하자. + +주의점: +설명에 등장하는 기호와 아래의 코드에 등장하는 기호를 헷갈리면 안된다. +- 위에서는 power를 `^`기호로 썼지만 python에서는 `**`기호를 쓴다. +- 그리고 python에서 `^`기호는 xor을 의미한다. + +문제점: +- 위의 접근 방법은 이론적으로는 문제될 것이 없지만 안타깝게도 컴퓨터를 통한 연산에서는 + log를 계산한 값의 소수점 뒷 자리수들이 잘려나간다. +- 이로 인해 2^p를 계산한 결과값이 깔끔한 정수가 아니라 더러운 소수가 나온다. 그래서 + 이 숫자가 정답에 근접한 값일 것이라고 굳게 믿고 여기에 `round` 함수를 써서 반올림을 + 해야 원하는 답이 나온다. +- 그런데 생각해보면 nums에 들어있는 값들에 로그를 취하고 더하는 과정에서 이 잘린 소수점 + 값들이 점점 누적되면서 오차가 점점 커질 것이다. 오차가 충분히 커지면 2^p를 계산한 값을 + 반올림 하더라도 우리가 원하는 정확한 답이 나오지 않게 될 것이다. + - e.g.) nums = range(2, 18) 일때 + - expected answer: [177843714048000, 118562476032000, 88921857024000, 71137485619200, ...] + - result: [177843714047999, 118562476031999, 88921857023999, 71137485619200, ...] + - diff: [-1, -1, -1, 0, ...] +- 곱하는 수에 큰 수가 섞여도 문제가 쉽게 발생한다. + - e.g.) nums = [2, 10, 44444444444444] 일때 + - expected answer: [444444444444440, 88888888888888, 20] + - result: [444444444444441, 88888888888888, 20] + - diff: [1, 0, 0] +- 그렇다면 얼마나 많은 숫자를 곱하거나 큰 숫자를 곱하는 상황에서 문제가 발생하는가? + 문제에 주어진 조건 범위 내에서 위의 방법이 잘 작동할 것이라는 사실이 보장되는가? + 이에 대해 생각하는 것은 매우 귀찮은 일이다... + +그런데 +- 대충 관찰을 해보니 숫자들의 곱이 문제에서 주어진 조건인 `The product ... fit in a 32-bit integer.` + 보다 훨씬 큰 경우에만 위의 오차가 치명적인 영향을 준다. +- 그래서 일단 리트코드에 풀이를 던져보았더니 accept 되었다. 나는 leetcode피셜 잘 풀었다고 + 볼 수 있는 것이다. +- 만약 이 풀이가 잘못되었다고 말하고 싶다면 나 말고 문제 조건을 설계한 사람과 테케를 만든 + 사람들에게 돌을 던져라 ¯\_(ツ)_/¯ +""" + +import math + + +class Solution: + def productExceptSelf(self, nums: List[int]) -> List[int]: + all_zero = True + zero_ind = None + p, is_neg = 0, False + for i, e in enumerate(nums): + if e == 0: + if zero_ind is not None: + break + zero_ind = i + else: + is_neg ^= e < 0 + p += math.log2(abs(e)) + else: + all_zero = False + + sol = [0] * len(nums) + if all_zero: + return sol + elif zero_ind is not None: + sol[zero_ind] = round(2**p * (-1) ** (is_neg)) + return sol + else: + return [ + round(2 ** (p - math.log2(abs(i))) * (-1) ** (is_neg ^ (i < 0))) + for i in nums + ]