diff --git a/Problem1.py b/Problem1.py new file mode 100644 index 00000000..563b0f88 --- /dev/null +++ b/Problem1.py @@ -0,0 +1,120 @@ +## Problem1 +# Combination Sum (https://leetcode.com/problems/combination-sum/) +import copy +from typing import List + +class Solution: + def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]: + """ + Regular Recursive Solution: + + TIME COMPLEXITY: O(2^(T/M)) + - Let T be the target value and M be the minimum value among the candidates. + - In the worst-case scenario (e.g., target = 10, candidates = [1]), the recursion tree + can go as deep as T/M. At each step in our backtracking, we make 2 decisions + (include or exclude). This leads to a loose upper bound of O(2^(T/M)) operations. + + SPACE COMPLEXITY: O(T/M) + - The maximum depth of the recursive call stack is T/M (if we keep subtracting + the smallest element). + - The `path` array will also store at most T/M elements at any given time. + - Note: This space complexity excludes the space required to hold the final `result` output. + """ + result = [] + + def helper(target, path, index): + nonlocal result, candidates + + # --- BASE CASES --- + # If target perfectly reaches 0, we found a valid combination. + if target == 0: + # We must append a copy of the path. + # Tip: Using `list(path)` or `path[:]` is slightly faster and standard + # compared to `copy.deepcopy(path)`. + result.append(copy.deepcopy(path)) + return + + # If we've run out of candidates to check OR the target becomes negative + # (meaning our current sum exceeds the target), prune this branch. + if index == len(candidates) or target < 0: + return + + # --- LOGIC (BACKTRACKING) --- + + # Case 1: EXCLUDE the element at the current index. + # We skip the current candidate entirely and move on to explore the `index + 1`. + # Target and path remain unchanged. + helper(target, path, index + 1) + + # Case 2: INCLUDE the element at the current index. + # We add the current candidate to our path. + path.append(candidates[index]) + + # We subtract the candidate's value from the target. + # Notice we pass `index` (not `index + 1`) because we are allowed + # to choose the same candidate an unlimited number of times. + helper(target - candidates[index], path, index) + + # BACKTRACK: Remove the candidate we just added to clean up the `path` + # state before returning to the previous recursive frame. + path.pop() + + # Initiate the recursive backtracking with the initial target, empty path, and starting index 0 + helper(target, [], 0) + + return result + + + + """ + For-Loop Based Recursive Solution (Backtracking): + + TIME COMPLEXITY: O(N^(T/M)) + - Let N be the number of candidates, T be the target, and M be the minimum candidate value. + - The maximum depth of our recursion tree is T/M (which happens if we repeatedly pick the smallest element). + - At each level of the tree, the for-loop branches out up to N times. + - This provides a loose upper bound of O(N^(T/M)). In reality, it runs much faster because the target shrinks and the number of iterations decreases as the `pivot` moves forward. + + SPACE COMPLEXITY: O(T/M) + - The recursive call stack goes as deep as the maximum length of a combination, which is T/M. + - The `path` list will also hold at most T/M elements at any given time. + - Note: This space complexity excludes the memory required to hold the final `result` arrays. + """ + # result = [] + + # def helper(target, pivot, path): + # # --- BASE CASES --- + # # If the target is exactly 0, the current path sums up to the initial target perfectly. + # if target == 0: + # # We append a deepcopy of the path so that subsequent `pop()` operations + # # don't alter the result we just saved. + # result.append(copy.deepcopy(path)) + # return + + # candidatesLen = len(candidates) + # # If our current sum exceeded the target (target < 0) or we are out of bounds, + # # we simply prune this branch and stop exploring. + # if pivot == candidatesLen or target < 0: + # return + + # # --- LOGIC (FOR-LOOP BACKTRACKING) --- + # # The `pivot` ensures we only pick candidates from the current index onwards. + # # This is crucial for avoiding duplicate combinations (e.g., picking [2,3] then [3,2]). + # for i in range(pivot, candidatesLen): + + # # 1. CHOOSE: Add the current candidate to our combination path + # path.append(candidates[i]) + + # # 2. EXPLORE: Recurse downwards with the newly reduced target. + # # Notice we pass `i` as the next pivot, NOT `i + 1`. This is the key to + # # allowing the same candidate to be chosen an unlimited number of times. + # helper(target - candidates[i], i, path) + + # # 3. BACKTRACK: Pop the last candidate off the path so we can cleanly + # # move on to the next candidate `candidates[i+1]` in the next iteration of the loop. + # path.pop() + + # # Initiate the recursive helper with the initial target, starting pivot index 0, and an empty path + # helper(target, 0, []) + + # return result \ No newline at end of file diff --git a/Problem2.py b/Problem2.py new file mode 100644 index 00000000..273f712a --- /dev/null +++ b/Problem2.py @@ -0,0 +1,70 @@ +## Problem2 +# Expression Add Operators(https://leetcode.com/problems/expression-add-operators/) +class Solution: + """ + Time Complexity: O(N * 4^N) + - For a string of length N, there are N-1 spaces between digits. + - At each space, we can make 1 of 4 choices: No operator (extend the number), '+', '-', or '*'. + - This gives an upper bound of 4^(N-1) valid expressions. + - At each step, string concatenation takes O(N) time. + - Thus, the total time complexity is bounded by O(N * 4^N). + + Space Complexity: O(N) auxiliary space + - The maximum depth of the recursion tree is N (when we process one digit at a time). + - At each recursive call, the intermediate string `path` takes O(N) space. + - (Note: If we count the memory required to store the final `result` array, + the total space would be O(N * 4^N) in the worst case). + """ + def addOperators(self, num: str, target: int) -> List[str]: + result = [] + + # pivot: the current index in 'num' we are processing + # path: the string expression we have built so far + # calc: the total evaluated value of the 'path' expression + # tail: the last term added to 'calc' (needed to respect multiplication precedence) + def helper(pivot, path, calc, tail): + + # Base Case: We have consumed all digits in the string + if pivot == len(num): + # If the evaluated expression matches our target, add it to results + if calc == target: + result.append(path) + return + + # Try exploring all possible next numbers by slicing from 'pivot' to 'i' + for i in range(pivot, len(num)): + + # Logic: Prevent numbers with leading zeros (e.g., "05", "00") + # If the slice starts with '0' and we try to extend it beyond length 1, + # it's invalid. We break because any longer slice will also be invalid. + if i > pivot and num[pivot] == '0': + break + + # Extract the current substring and convert it to an integer + currStr = num[pivot:i+1] + currInt = int(currStr) + + # Logic: If we are at the very start of the string, we can't place + # an operator before the first number. We just initialize the state. + if pivot == 0: + helper(i + 1, currStr, currInt, currInt) + else: + # Choice 1: Addition + # Add currInt to the total calculation. The new tail is just currInt. + helper(i + 1, path + "+" + currStr, calc + currInt, currInt) + + # Choice 2: Subtraction + # Subtract currInt from the calculation. The new tail is -currInt. + helper(i + 1, path + "-" + currStr, calc - currInt, -currInt) + + # Choice 3: Multiplication + # Multiplication has higher precedence! We must "undo" the last addition/subtraction + # by subtracting 'tail' from 'calc', then we add the multiplied result of (tail * currInt). + # The new tail for the next operation becomes (tail * currInt). + helper(i + 1, path + "*" + currStr, (calc - tail) + (tail * currInt), tail * currInt) + + # Edge Case: Only start backtracking if the input string is not empty + if num: + helper(0, "", 0, 0) + + return result \ No newline at end of file