Jc-alt logo
jc
28 min read
#data structures and algorithms
Table Of Contents

Greedy Intro

LeetCode problems solved with Greedy solutions.

Greedy problems may be the hardest because as they all follow a pattern, nor are they always intuitive. You just have to come up with a solution that you think should work and hope that it works for all test cases.

and the counter point:

in greedy, you always start off by using key observations and reasoning to narrow down your "search space" by eliminating possibilities to get to the answer, i.e. finding scenarios where getting the answer is impossible. in this example, it would be realizing a triplet whose value(s) exceed the target are unusable in the merging process and hence can be skipped. from there, you can derive what's possible / what are the "search spaces" that you actually care about and code up the solution accordingly. greedy problems can be quite unstructured, but the code implementation isn't challenging to grasp (like backtracking) or the solution particularly hard to come by (like dp and figuring out the recurrence relation) so it can be a good and fun topic to practice.

What is Greedy

Money!

Greedy IRL

Money!

Greedy Application: Greedy

Pattern: money!

Ex: money!!

    def money!(n: int) -> int:

        return n+1

53. Maximum Subarray ::3:: - Medium

Topics: Array, Divide and Conquer, Dynamic Programming

Intro

Given an integer array nums, find the with the largest sum, and return its sum.
Follow up: If you have figured out the O(n) solution, try coding another solution using the divide and conquer approach, which is more subtle.

Example InputOutput
nums = [-2,1,-3,4,-1,2,1,-5,4]6
nums = [1]1
nums = [5,4,-1,7,8]23

Constraints:

1 ≤ nums.length ≤ 105

-104 ≤ nums[i] ≤ 104

Abstraction

Given an array, return the sum of the largest subarray.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: Divide and Conquer Bridging Left And Right Linear Scans - Greedy/Greedy

    def maxSubArray(self, nums: List[int]) -> int:

        # Divide & Conquer Approach:
        # The maximum subarray can be found by recursively splitting the array.
        # Key idea:
        #   1. Divide array into left and right halves
        #   2. Maximum subarray is either:
        #       - entirely in left half
        #       - entirely in right half
        #       - crossing the midpoint
        #   3. Combine results from left, right, and cross-midpoint to get current max
        # Recursion continues until single-element subarrays are reached

        # Early Stop vs Cross Midpoint Computation:
        # In Kadane, skipping negative running sums works because we 
        # only care about subarrays ending at current index.
        # In Divide & Conquer, we cannot break, because negative numbers 
        # might still be included in the optimal cross-midpoint subarray.

        # Note:
        # Divide & Conquer approach
        # 1. Process root -> split array into halves
        # 2. Explore Choices -> max subarray is:
        #       - starts at mid and scans into left half
        #       - starts at mid and scans into right half
        #       - starts at mid and includes both left and right half
        #         (sum of the left and right half results)
        # 3. Build -> recursively compute max of a section via max(left, right, cross_sum)
        # Result -> max sum of array

        # Recursive helper to compute max subarray in nums[left:right]
        def helper(left, right):
            
            # Base Case: single element
            # Only one element, max subarray is the element itself
            # tc: O(1), sc: O(1)
            if left == right:
                return nums[left]

            # Split array: compute midpoint
            # This reduces the search space to two halves
            # left half: nums[left:mid] 
            # right half: nums[mid+1:right]
            mid = (left + right) // 2

            # Recursive call on left half
            # Helper computes max subarray entirely in left half
            # tc: O(n/2 * log(n/2)) per left recursive calls (overall summed to O(n log n))
            left_max = helper(left, mid)

            # Recursive call on right half
            # Helper computes max subarray entirely in right half
            # tc: O(n/2 * log(n/2)) per right recursive calls (overall summed to O(n log n))
            right_max = helper(mid + 1, right)

            # Start at mid and linear scan into left half
            left_sum = float('-inf')
            curr = 0
            for i in range(mid, left - 1, -1):
                curr += nums[i]
                left_sum = max(left_sum, curr)

            # Start at mid and linear scan into right half
            right_sum = float('-inf')
            curr = 0
            for i in range(mid + 1, right + 1):
                curr += nums[i]
                right_sum = max(right_sum, curr)

            # Compute max subarray crossing mid:
            # bridge left and right scan results 
            cross_max = left_sum + right_sum

            # return left, right, and middle max to previous call
            return max(left_max, right_max, cross_max)

        # max subarray from [0, n-1]
        res = helper(0, len(nums) - 1)

        # overall: tc O(n log n)
        # overall: sc O(log n) recursion depth
        return res

Solution 2: Greedy Kadane Algorithm Textbook Version - Greedy/Greedy

    def maxSubArray(self, nums: List[int]) -> int:

        # Kadane's Algorithm (Greedy / 1D DP):
        # The maximum subarray problem can be solved by iterating through the array
        # and maintaining two key variables:
        #   1. local_max -> maximum subarray sum ending at the current index
        #   2. global_max -> maximum subarray sum seen so far
        # At each step, we either:
        #   - Extend the previous subarray (local_max + nums[i])
        #   - Start a new subarray at current element (nums[i])
        # The invariant: global_max always stores the maximum sum encountered

        # num of nums
        # tc: O(1)
        n = len(nums)

        # local_max: max sum ending at current index
        # global_max: overall max sum
        # sc: O(1)
        local_max = nums[0]
        global_max = nums[0]

        # tc: iterate over n O(n)
        for i in range(1, n):

            # At each index:
            # we are deciding whether to extend the array, 
            # or to stop the array and start a new one at the current index

            # extend previous subarray
            extend_prev = local_max + nums[i]

            # start a new subarray at current element
            start_new = nums[i]

            # choose the better option
            local_max = max(start_new, extend_prev)

            # Update global max if current local_max is greater
            # tc: O(1)
            global_max = max(global_max, local_max)

        # overall: tc O(n)
        # overall: sc O(1)
        return global_max

Solution 3: Greedy Kadane Algorithm Practical Version [TC Opt] - Greedy/Greedy

    def maxSubArray(self, nums: List[int]) -> int:

        # Kadane’s Algorithm (Greedy / Practical):
        # The maximum subarray problem can also be solved by maintaining:
        #   1. current_sum -> running sum of the subarray ending at the current index
        #   2. max_sum -> maximum sum seen so far
        # At each step, we either:
        #   - Extend the previous subarray by adding nums[i] (current_sum + nums[i])
        #   - Reset current_sum to 0 if the previous sum would reduce the total
        # This ensures current_sum always represents the best subarray ending at current index
        # max_sum tracks the overall maximum sum encountered

        # Initialize variables
        # sc: O(1)

        # overall max subarray sum
        max_array_sum = nums[0]

        # max sum ending at current index
        subarray_sum = 0

        # tc: iterate over n O(n)
        for num in nums:

            # Extend the previous subarray to include current index
            # tc: O(1)
            subarray_sum += num

            # Decision point: 
            #   - if current index causes subarray_sum to become negative,
            #     it is better to start a new subarray.
            #   - In other words, if current index causes subarray_sum to 
            #     at least be 0 or positive, it should be included.
            if subarray_sum < 0:
                subarray_sum = 0

            # Update max_sum if current_sum is greater
            if subarray_sum > max_array_sum:
                max_array_sum = subarray_sum

        # overall: tc O(n)
        # overall: sc O(1)
        return max_array_sum

55. Jump Game ::1:: - Medium

Topics: Array, Dynamic Programming, Greedy

Intro

You are given an integer array nums. You are initially positioned at the array's first index, and each element in the array represents your maximum jump length at that position. Return true if you can reach the last index, or false otherwise.

Example InputOutput
nums = [2,3,1,1,4]true
nums = [3,2,1,0,4]false

Constraints:

1 ≤ nums.length ≤ 104

0 ≤ nums[i] ≤ 105

Abstraction

Given an array, starting at the first index, determine if its possible to reach the end of the array, if a number at an index represents that max jump index possible from that index.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: Greedy Dynamic Programming Rolling Variable Early Exit - Greedy/Greedy

    def canJump(self, nums: List[int]) -> bool:
        
        # Greedy / 1D DP Approach:
        # We want to determine if we can reach the last index of the array.
        # Key idea:
        #   1. Maintain a rolling variable 'farthest' that represents 
        #      the farthest reachable index so far
        #   2. Iterate through the array:
        #       - If 'farthest' < current index, we cannot reach the current index, we got stuck
        #       - Otherwise, update 'farthest' to include jumps from current index
        #       - If last index <= 'farthest', we have passed the end, we can early exit
        
        # This is effectively a linear greedy scan, 
        # using previously computed reachability to avoid recomputation

        # number of indexes
        # tc: O(1)
        n = len(nums)

        # Rolling Variable:
        # farthest index currently reachable
        # sc: O(1)
        farthest = 0

        # tc: iterate over n O(n)
        for i in range(n):

            # Check: is current index past what we can reach
            # Implies: we cannot jump to current index, we got stuck
            # tc: O(1)
            if farthest < i:
                return False

            # Implies: we are able to jump to the current index
            # Then: add the jumping distance from the current index to our farthest counter
            # tc: O(1)
            farthest = max(farthest, i + nums[i])

            # Check: have we passed the last index
            # Implies: we can early exit, we reached the end
            # tc: O(1)
            if n-1 <= farthest:
                return True

        # Implies: ran out of index to jump from,
        #          we never reached the end

        # overall: tc O(n)
        # overall: sc O(1)
        return False

45. Jump Game II ::1:: - Medium

Topics: Array, Dynamic Programming, Greedy

Intro

You are given a 0-indexed array of integers nums of length n.
You are initially positioned at index 0. Each element nums[i] represents the maximum length of a forward jump from index i. In other words, if you are at index i, you can jump to any index (i + j) where: 0 < = j < = nums[i] and i + j < n Return the minimum number of jumps to reach index n - 1. The test cases are generated such that you can reach index n - 1.

Example InputOutput
nums = [2,3,1,1,4]2
nums = [2,3,0,1,4]2

Constraints:

1 ≤ nums.length ≤ 104

0 ≤ nums[i] ≤ 1000

It's guaranteed that you can reach nums[n - 1].

Abstraction

Given an array, starting at the first index, determine if its possible to reach the end of the array, if a number at an index represents that max jump index possible from that index.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: Greedy Layer By Layer - Greedy/Greedy

    def jump(self, nums: List[int]) -> int:
        
        # Greedy / Layer-by-Layer Expansion (BFS Analogy):
        # We want the minimum number of jumps to reach the last index.
        
        # Key Idea:
        # Treat the array like levels in BFS.
        #   - All indexes reached in k jumps are in the same k layer 
        #   - When we finishing checking all indexes within the k layer, we will determine
        #     the rightmost index we can jump to from any index in this k layer

        # We keep track of:
        #   1. current_end: boundaries of current k layer
        #   2. farthest:    farthest index reachable from this layer 
        
        # When we reach 'current_end', we:
        #   - Increment jump count
        #   - Move to next layer by setting current_end = farthest
        
        # This works because we always greedily expand the next layer
        # as far as possible before committing to another jump.

        n = len(nums)

        # Single Index: no jumps needed
        if n == 1:
            return 0

        # Tracker for current number of jumps
        k_jumps = 0
        
        # We check every k layer, to fins the rightmost index we can jump to,
        # we use layer_boundary to mark the end of the current layer we are checking
        k_layer_boundary = 0

        # While checking k layer, we track of the furthest index we can reach from this layer
        farthest = 0

        # We are guaranteed to reach the last index,
        # can simply iterate without checking for unreachable indexes
        # tc: iterate over n O(n)
        for i in range(n-1):

            # Check: candidate i within layer k, how far can we jump
            reach_from_i = i + nums[i]

            # Check: does candidate i provide a farther jump than the current
            #        farthest possible for this k layer
            farthest = max(farthest, i + nums[i])

            # Check: does our farthest jump pass the last index
            # Implies: found shortest number of jumps
            if farthest >= n - 1:
                return k_jumps + 1

            # Check: have we finished checking all indexes within this k layer
            # Implies: we have checked all of our options
            # Then: jump to the rightmost index we have found for this k layer
            if i == k_layer_boundary:

                # update jump count
                k_jumps += 1

                # mark next layer as the farthest jump
                k_layer_boundary = farthest

        # overall: tc O(n)
        # overall: sc O(1)
        return k_jumps

134. Gas Station ::1:: - Medium

Topics: Array, Greedy

Intro

There are n gas stations along a circular route, where the amount of gas at the ith station is gas[i]. You have a car with an unlimited gas tank and it costs cost[i] of gas to travel from the ith station to its next (i + 1)th station. You begin the journey with an empty tank at one of the gas stations. Given two integer arrays gas and cost, return the starting gas station's index if you can travel around the circuit once in the clockwise direction, otherwise return -1. If there exists a solution, it is guaranteed to be unique.

Example InputOutput
gas = [1,2,3,4,5], cost = [3,4,5,1,2]3
gas = [2,3,4], cost = [3,4,3]-1

Constraints:

n == gas.length == cost.length

1 ≤ nums[i] ≤ 105

0 ≤ gas[i], cost[i] ≤ 104

The input is generated such that the answer is unique.

Abstraction

Given two arrays gas[i] and cost[i], find the starting index where if you begin with 0 fuel, you can travel the entire circle without running out of gas. Return -1 if not possible.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: Greedy - Greedy/Greedy

    def canCompleteCircuit(self, gas: List[int], cost: List[int]) -> int:
        
        # Greedy / Linear Scan Approach:
        # We want to determine the unique starting station (if any)
        # from which we can complete a full circuit.

        # Key Ideas:
        #   1. If total gas < total cost, completing the circuit is impossible.
        #   2. Traverse stations while maintaining a running fuel balance (tank).
        #   3. If tank becomes negative at station i, then:
        #       - No station between the previous start and i can be a valid start.
        #       - We reset the start to i + 1 and restart the tank.
        #   4. If a solution exists, it is guaranteed to be unique.

        # number of stations
        # tc: O(1)
        n = len(gas)

        # Direct Boundary Check:
        # If total gas is less than total cost, it's impossible to complete the loop
        # tc: O(n)
        if sum(gas) < sum(cost):
            return -1

        # Rolling Variable:
        # tank -> current gas balance while traversing
        # sc: O(1)
        tank = 0

        # Candidate starting station
        start = 0

        # tc: iterate over n O(n)
        for i in range(n):


            # Build From Previous:
            # Update current gas balance after traveling from station i
            # tc: O(1)
            tank += gas[i] - cost[i]

            # Explore Choices:
            # If tank < 0, we cannot reach station i+1 from current start
            # Implies:
            #   - Any station between 'start' and i is also invalid as a start
            #   - Reset start to i + 1 and reset tank
            # tc: O(1)
            if tank < 0:
                tank = 0
                start = i + 1

        # Result:
        # Since total gas >= total cost, the final 'start' is guaranteed to work

        # overall: tc O(n) 
        # overall: sc O(1)
        return start

846. Hand of Straights ::1:: - Medium

Topics: Array, Hash Table, Greedy, Sorting

Intro

Alice has some number of cards and she wants to rearrange the cards into groups so that each group is of size groupSize, and consists of groupSize consecutive cards. Given an integer array hand where hand[i] is the value written on the ith card and an integer groupSize, return true if she can rearrange the cards, or false otherwise.

Example InputOutput
hand = [1,2,3,6,2,3,4,7,8], groupSize = 3true
hand = [1,2,3,4,5], groupSize = 4false

Constraints:

1 ≤ hand.length ≤ 104

0 ≤ hand[i] ≤ 109

1 ≤ groupSize ≤ hand.length

Abstraction

Given an array and a run length, determine if its possible to rearrange the cards of run length that are increasing.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: Greedy - Greedy/Greedy

    def isNStraightHand(self, hand: List[int], groupSize: int) -> bool:
        
        # Note:
        # Greedy with HashMap
        # total cards must be divisible by groupSize
        # sort cards
        # group cards from low to high
        # for each group start, grab all cards needed in group
        # Result -> return True if all groups formed

        n = len(hand)

        # total cards must be divisible by groupSize
        if n % groupSize != 0:
            return False

        # frequency of each card
        count = Counter(hand)

        # process cards in ascending order
        for runStartCard in sorted(count):

            # attempt to form group starting at card
            while count[runStartCard] > 0:

                # attempt to grab all cards for current run starting from card
                for nextCard in range(runStartCard, runStartCard + groupSize):

                    # no run is possible 
                    if count[nextCard] == 0:
                        return False

                    # decrease count
                    count[nextCard] -= 1

        # overall: time complexity 
        # overall: space complexity
        return True

Solution 2: Greedy Heap - Greedy/Greedy

    def isNStraightHand(self, hand: List[int], groupSize: int) -> bool:
        # Note:
        # Greedy using min heap and frequency map
        # 0. Direct Boundary -> total cards must be divisible by groupSize
        # 1. Process root -> build frequency map and min-heap of unique cards
        # 2. Build From Previous -> repeatedly form groups starting from smallest card
        # 3. Explore Choices -> check consecutive cards and update frequencies
        # Result -> return True if all groups can be formed

        n = len(hand)

        # check if total cards can be divided into complete groups
        if n % groupSize != 0:
            return False

        # frequency map of cards
        counter = {}
        for h in hand:
            counter[h] = 1 + counter.get(h, 0)

        # min heap of unique cards
        heap = list(counter.keys())
        heapq.heapify(heap)

        # repeatedly form groups
        while heap:

            first = heap[0]  # smallest available card

            # try to form a consecutive run starting from first
            for h in range(first, first + groupSize):

                # cannot form run if card missing
                if h not in counter:
                    return False

                # decrement frequency
                counter[h] -= 1

                # remove from heap if no cards left
                if counter[h] == 0:

                    # ensure heap order is maintained
                    if h != heap[0]:
                        return False
                    heapq.heappop(heap)

        # all runs successfully formed

        # overall: time complexity
        # overall: space complexity
        return True

1899. Merge Triplets to Form Target Triplet ::1:: - Medium

Topics: Array, Greedy

Intro

A triplet is an array of three integers. You are given a 2D integer array triplets, where triplets[i] = [ai, bi, ci] describes the ith triplet. You are also given an integer array target = [x, y, z] that describes the
triplet you want to obtain. To obtain target, you may apply the following operation on triplets any number of times (possibly zero): Choose two indices (0-indexed) i and j (i != j) and update triplets[j] to become [max(ai, aj), max(bi, bj), max(ci, cj)]. For example, if triplets[i] = [2, 5, 3] and triplets[j] = [1, 7, 5], triplets[j] will be updated to [max(2, 1), max(5, 7), max(3, 5)] = [2, 7, 5]. Return true if it is possible to obtain the target triplet [x, y, z] as an element of triplets, or false otherwise.

Example InputOutput
triplets = [[2,5,3],[1,8,4],[1,7,5]], target = [2,7,5]true
triplets = [[3,4,5],[4,5,6]], target = [3,2,5]false
triplets = [[2,5,3],[2,3,4],[1,2,5],[5,2,3]], target = [5,5,5]true

Constraints:

1 ≤ triplets.length ≤ 105

triplets[i].length == target.length == 3

1 ≤ ai, bi, ci, x, y, z ≤ 1000

Abstraction

Given a list of triplets and a target triplet, determine if you can select and merge triplets such that each element of the target is matched exactly by taking the maximum across chosen triplets.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: Greedy - Greedy/Greedy

    def mergeTriplets(self, triplets: List[List[int]], target: List[int]) -> bool:

        # Note:
        # Greedy validation

        # instead of simulating all operations, you just need to 
        # ensure that for each coordinate of the target, there is at least 
        # one triplet that contributes the required value without exceeding it.

        # grab target values
        x, y, z = target

        # coverage of each target index
        good = [False, False, False]

        for a, b, c in triplets:

            # skip if triplet exceeds target in any dimension as 
            # merging triplet would overshoot the target since we use max()
            if a > x or b > y or c > z:
                continue

            # mark contribution toward target dimensions
            if a == x:
                good[0] = True
            if b == y:
                good[1] = True
            if c == z:
                good[2] = True

            # early exit -> all dimensions matched
            if all(good):
                return True

        # overall: time complexity O(n)
        # overall: space complexity O(1)
        return all(good)

763. Partition Labels ::1:: - Medium

Topics: Hash Table, Two Pointers, String, Greedy

Intro

You are given a string s. We want to partition the string string into as many parts as possible so that each letter appears in at most one part. For example, the string "ababcc" can be partitioned into ["abab", "cc"], but partitions such as ["aba", "bcc"] or ["ab", "ab", "cc"] are invalid. Note that the partition is done so that after concatenating all the parts in order, the resultant string should be s. Return a list of integers representing the size of these parts.

Example InputOutput
s = "ababcbacadefegdehijhklij"[9, 7, 8]
s = "eccbbbbdec"[10]

Constraints:

1 ≤ s.length ≤ 500

s consists of lowercase English letters.

Abstraction

Given a string, determine what length the subarray should be in order to have every character appear in each subarray

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: Greedy - Greedy/Greedy

    def partitionLabels(self, s: str) -> List[int]:

        # Note:
        # Greedy partitioning
        # 0. Direct Length -> empty string boundary covered implicitly
        # 1. Precompute last occurrence of each character
        # 2. Process root -> scan through string with two pointers
        # 3. Expand window -> current partition must at least reach the furthest last occurrence seen so far
        # 4. Build Partition -> when current index == window end, cut partition
        # 5. Result -> return lengths of all partitions

        # effectively gives the last occurrence of each character in the string,
        # as last occurrence of char will overwrite index previous 
        last_index = {char: i for i, char in enumerate(s)}

        partitions = []

        # starting index of current partition
        start = 0 

        # Furthest index that the current partition must reach to 
        # include all characters seen so far
        # Thus, when the iteration index i reaches end, it means the 
        # current partition contains all occurrences of the characters seen 
        # in this window, so you can "cut" the partition:
        end = 0

        # iterate left to right
        for i, char in enumerate(s):

            # As we go left to right, if a character appears multiple times, 
            # the dictionary keeps updating its value to the latest index

            # ensures the current partition extends at least until the 
            # last occurrence of every character seen so far.
            end = max(end, last_index[char])

            # reached the last occurrence of every character seen so far,
            # safe to cut
            if i == end:
                partitions.append(end - start + 1)
                start = i + 1

        # overall: time complexity
        # overall: space complexity
        return partitions   

678. Valid Parenthesis String ::2:: - Medium

Topics: String, Dynamic Programming, Stack, Greedy

Intro

Given a string s containing only three types of characters: '(', ')' and '', return true if s is valid. The following rules define a valid string: Any left parenthesis '(' must have a corresponding right parenthesis ')'. Any right parenthesis ')' must have a corresponding left parenthesis '('. Left parenthesis '(' must go before the corresponding right parenthesis ')'. '' could be treated as a single right parenthesis ')' or a single left parenthesis '(' or an empty string "".

Example InputOutput
s = "()"true
s = "(*)"true
s = "(*))"true

Constraints:

1 ≤ s.length ≤ 100

s[i] is '(', ')' or '*'

Abstraction

Given a string, determine if the parenthesis are valid.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: Greedy - Greedy/Greedy

    def checkValidString(self, s: str) -> bool:
        # Note:
        # Greedy balance with range tracking
        # empty string is valid (covered implicitly)
        # maintain lower and upper bound of open '(' count
        # Bounds:
        #    '(' increases both lower and upper
        #    ')' decreases both (but lower cannot go below 0)
        #    '*' can be '(', ')' or empty
        #         -> lower decreases by 1 (min 0), upper increases by 1
        # if upper < 0 at any point, invalid
        # Result -> valid if lower == 0 at the end

        # parenthesis count
        lower = 0 
        upper = 0

        # iterate
        for char in s:

            # '(' increases both lower and upper
            if char == "(":
                lower += 1
                upper += 1

            # ')' decreases both (but lower cannot go below 0)
            elif char == ")":
                lower = max(lower - 1, 0)
                upper -= 1

            # '*' can be '(', ')' or empty
            # lower decreases by 1 (min 0), upper increases by 1
            else:
                lower = max(lower - 1, 0)  # treat as ')'
                upper += 1                 # treat as '('

            # if upper < 0 at any point, invalid
            if upper < 0:
                return False

        # lower == 0 means all opens can be matched

        # overall: time complexity
        # overall: space complexity
        return lower == 0