DSA Patterns

Learn DSA one pattern at a time. For each pattern: read the "use this when" line, try the questions in order (Easy, then Medium, then Hard), then open the solution to compare. Tick what you've solved. Progress is saved in this browser. Want the topic-wise list instead? Open the classic DSA sheet.

0 / 0 solved
Show:
1. Arrays

The base of everything. 14 patterns, mostly about scanning smarter than a double loop.

Traversal

Use this when the question says find the max, count, or check all elements, and one pass with a running answer is enough.

EasyLeetCode #121
Solution

Walk the prices once. Keep the lowest price seen so far in `lowest`. At each day, the best profit if you sell today is `price - lowest`. The answer is the largest of those values, so no second loop over earlier days is needed.

Complexity: O(n) time, O(1) extra space

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        lowest = float("inf")
        best = 0
        for price in prices:                  # one pass, keep a running answer
            lowest = min(lowest, price)       # cheapest day so far
            best = max(best, price - lowest)  # profit if we sell today
        return best
MediumLeetCode #134
Solution

One pass keeps two running numbers: `tank` for the current attempt and `total` for the whole circle. When `tank` drops below zero, no station from the current start up to here can work as a start. So the next start must be `i + 1`, and `tank` resets. If `total` is never negative, the last start found is the answer.

Complexity: O(n) time, O(1) extra space

class Solution:
    def canCompleteCircuit(self, gas: List[int], cost: List[int]) -> int:
        total = tank = start = 0
        for i in range(len(gas)):             # single pass with running totals
            gain = gas[i] - cost[i]
            total += gain                     # balance of the whole circle
            tank += gain                      # balance of the current attempt
            if tank < 0:                      # this start failed: restart after i
                start = i + 1
                tank = 0
        return start if total >= 0 else -1
HardLeetCode #135
Solution

Every child needs at least one candy, so start with a list of ones. A left to right pass fixes the rule against the left neighbour: a higher rating gets one more than the left child. A right to left pass fixes the right neighbour and keeps the larger value with `max`. Each pass is a plain traversal, and the sum of the list is the answer.

Complexity: O(n) time, O(n) extra space

class Solution:
    def candy(self, ratings: List[int]) -> int:
        n = len(ratings)
        candies = [1] * n
        for i in range(1, n):                         # pass 1: compare with the left neighbour
            if ratings[i] > ratings[i - 1]:
                candies[i] = candies[i - 1] + 1
        for i in range(n - 2, -1, -1):                # pass 2: compare with the right neighbour
            if ratings[i] > ratings[i + 1]:
                candies[i] = max(candies[i], candies[i + 1] + 1)
        return sum(candies)

Two Pointers

Use this when the array is sorted (or the answer depends on the two ends) and you need a pair, a best pair, or a boundary walk.

MediumLeetCode #167
Solution

Put `l` at the start and `r` at the end. If the sum is too small, only moving `l` right can raise it. If the sum is too big, only moving `r` left can lower it. Every skipped pair is provably useless, so one pass finds the answer.

Complexity: O(n) time, O(1) extra space

class Solution:
    def twoSum(self, numbers: List[int], target: int) -> List[int]:
        l, r = 0, len(numbers) - 1
        while l < r:
            s = numbers[l] + numbers[r]
            if s == target:
                return [l + 1, r + 1]    # the problem is 1-indexed
            if s < target:               # too small: move l right
                l += 1
            else:                        # too big: move r left
                r -= 1
        return []
MediumLeetCode #11
Solution

The array is not sorted, but the two ends give the widest container, so start there. The area is limited by the shorter wall. Moving the taller wall inward can only shrink the width and cannot raise the height limit. So always move the pointer at the shorter wall.

Complexity: O(n) time, O(1) extra space

class Solution:
    def maxArea(self, height: List[int]) -> int:
        l, r = 0, len(height) - 1
        best = 0
        while l < r:
            best = max(best, min(height[l], height[r]) * (r - l))
            if height[l] < height[r]:    # the shorter wall limits the area, so move it
                l += 1
            else:
                r -= 1
        return best
HardLeetCode #42
Solution

Water above a bar depends on the lower of the tallest bars on its left and right. Keep `left_max` and `right_max` while the pointers walk inward. Always move the side with the lower bar, because that side's max is the one that limits its water. Add `max - height` for the bar you move past.

Complexity: O(n) time, O(1) extra space

class Solution:
    def trap(self, height: List[int]) -> int:
        l, r = 0, len(height) - 1
        left_max = right_max = water = 0
        while l < r:
            if height[l] < height[r]:             # the lower side decides the water level
                left_max = max(left_max, height[l])
                water += left_max - height[l]
                l += 1
            else:
                right_max = max(right_max, height[r])
                water += right_max - height[r]
                r -= 1
        return water

Sliding Window: Fixed

Use this when the question asks about every sub-array of exactly size k: max sum, average, or a count.

EasyLeetCode #643
Solution

Compute the sum of the first k numbers once. Then slide: add the new element `nums[i]` and subtract the one that left, `nums[i - k]`. Never re-add k numbers. Keep the best sum and divide by k at the end.

Complexity: O(n) time, O(1) extra space

class Solution:
    def findMaxAverage(self, nums: List[int], k: int) -> float:
        window = sum(nums[:k])
        best = window
        for i in range(k, len(nums)):
            window += nums[i] - nums[i - k]    # add the new element, drop the old one
            best = max(best, window)
        return best / k
MediumLeetCode #1423
Solution

Taking k cards from the two ends means the cards you leave form one block in the middle of size n - k. To maximise what you take, minimise what you leave. That block is a fixed-size window, so slide it and track the smallest sum. The answer is the total minus that smallest sum.

Complexity: O(n) time, O(1) extra space

class Solution:
    def maxScore(self, cardPoints: List[int], k: int) -> int:
        n = len(cardPoints)
        size = n - k                          # cards left behind form a fixed-size window
        window = sum(cardPoints[:size])
        smallest = window
        for i in range(size, n):
            window += cardPoints[i] - cardPoints[i - size]   # slide by one
            smallest = min(smallest, window)
        return sum(cardPoints) - smallest
HardLeetCode #239
Solution

A fixed window of size k moves right, and we need its maximum each time. Keep a deque of indices whose values decrease from front to back. Before adding `nums[i]`, pop smaller values from the back, because they can never be a maximum again. The front is the maximum, and it is removed once its index leaves the window.

Complexity: O(n) time, O(k) extra space

from collections import deque

class Solution:
    def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
        dq = deque()                          # indices; values decrease front to back
        out = []
        for i, x in enumerate(nums):
            while dq and nums[dq[-1]] <= x:   # smaller values can never be the max again
                dq.pop()
            dq.append(i)
            if dq[0] <= i - k:                # front index slid out of the window
                dq.popleft()
            if i >= k - 1:                    # first full window reached
                out.append(nums[dq[0]])
        return out

Sliding Window: Variable

Use this when the question asks for the longest or shortest contiguous sub-array (or the number of them) that satisfies a condition.

MediumLeetCode #209
Solution

Grow the window by moving `r` and adding `nums[r]`. While the sum is at least the target, the window is valid, so record its length and shrink from the left. All numbers are positive, so shrinking always lowers the sum and the method is safe. Each index enters and leaves the window once.

Complexity: O(n) time, O(1) extra space

from math import inf

class Solution:
    def minSubArrayLen(self, target: int, nums: List[int]) -> int:
        l = total = 0
        best = inf
        for r in range(len(nums)):
            total += nums[r]                   # grow right
            while total >= target:             # window valid: try to shrink
                best = min(best, r - l + 1)
                total -= nums[l]
                l += 1
        return 0 if best == inf else best
MediumLeetCode #1004
Solution

We want the longest window that holds at most k zeros. Move `r` right and count each zero added. While the window has more than k zeros, move `l` right, and lower the count when a zero leaves. After that the window is valid, so its length is a candidate for the answer.

Complexity: O(n) time, O(1) extra space

class Solution:
    def longestOnes(self, nums: List[int], k: int) -> int:
        l = zeros = best = 0
        for r, x in enumerate(nums):
            if x == 0:
                zeros += 1                     # grow right
            while zeros > k:                   # window is bad: shrink left
                if nums[l] == 0:
                    zeros -= 1
                l += 1
            best = max(best, r - l + 1)        # window is valid here
        return best
HardLeetCode #992
Solution

"Exactly k distinct" is not monotonic, but "at most k distinct" is, so a variable window works for it. Count the sub-arrays with at most k distinct values: for each `r`, every start from `l` to `r` is valid, which adds `r - l + 1`. The answer is `at_most(k) - at_most(k - 1)`. The `count` dict tracks the values inside the window.

Complexity: O(n) time, O(k) extra space

from collections import defaultdict

class Solution:
    def subarraysWithKDistinct(self, nums: List[int], k: int) -> int:
        def at_most(limit):
            count = defaultdict(int)
            l = total = 0
            for r, x in enumerate(nums):
                count[x] += 1                         # grow right
                while len(count) > limit:             # too many distinct values: shrink left
                    count[nums[l]] -= 1
                    if count[nums[l]] == 0:
                        del count[nums[l]]
                    l += 1
                total += r - l + 1                    # all windows ending at r that start in [l, r]
            return total
        return at_most(k) - at_most(k - 1)

Prefix Sum

Use this when you must answer many range-sum questions, or count sub-arrays by their sum (negatives allowed).

EasyLeetCode #303
Solution

Build `prefix` once, where `prefix[i]` is the sum of the first i numbers and `prefix[0]` is 0. The sum of `nums[left..right]` is `prefix[right + 1] - prefix[left]`. The build costs O(n), and every query after that is O(1).

Complexity: O(n) build, O(1) per query, O(n) space

class NumArray:
    def __init__(self, nums: List[int]):
        self.prefix = [0]
        for x in nums:
            self.prefix.append(self.prefix[-1] + x)   # prefix[i] = sum of the first i numbers

    def sumRange(self, left: int, right: int) -> int:
        return self.prefix[right + 1] - self.prefix[left]   # whole prefix minus the part before left
MediumLeetCode #560
Solution

The sum of a sub-array ending here equals k when an earlier prefix equals `prefix - k`. Keep a dict `seen` that counts how often each prefix sum has occurred. At each element, add `seen[prefix - k]` to the answer, then record the current prefix. This works with negatives, where a sliding window fails.

Complexity: O(n) time, O(n) extra space

from collections import defaultdict

class Solution:
    def subarraySum(self, nums: List[int], k: int) -> int:
        seen = defaultdict(int)
        seen[0] = 1                        # the empty prefix
        prefix = count = 0
        for x in nums:
            prefix += x                    # running prefix sum
            count += seen[prefix - k]      # earlier prefixes that leave a sub-array summing to k
            seen[prefix] += 1
        return count
HardLeetCode #862
Solution

Negatives rule out a plain sliding window, so build the prefix sums instead. A sub-array from i to j-1 has sum `prefix[j] - prefix[i]`. Keep a deque of start indices whose prefix values increase. When `prefix[j]` is at least k above the front, pop the front and record the length. A larger older prefix can never be a better start than a smaller newer one, so pop those from the back.

Complexity: O(n) time, O(n) extra space

from collections import deque
from math import inf

class Solution:
    def shortestSubarray(self, nums: List[int], k: int) -> int:
        prefix = [0]
        for x in nums:
            prefix.append(prefix[-1] + x)               # prefix sums handle negatives
        dq = deque()                                    # start indices, prefix values increasing
        best = inf
        for j, p in enumerate(prefix):
            while dq and p - prefix[dq[0]] >= k:        # sub-array sum is big enough
                best = min(best, j - dq.popleft())
            while dq and prefix[dq[-1]] >= p:           # a smaller, newer start beats these
                dq.pop()
            dq.append(j)
        return -1 if best == inf else best

Difference Array

Use this when you apply many range updates (add v to a[l..r]) and read the final array only once.

MediumLeetCode #1109
Solution

Each booking adds `seats` to every flight from `first` to `last`. In `diff`, add `seats` at the start index and subtract it right after the end index. Each booking costs O(1). One running total over `diff` rebuilds the final seat count for every flight.

Complexity: O(n + m) time for m bookings, O(n) space

class Solution:
    def corpFlightBookings(self, bookings: List[List[int]], n: int) -> List[int]:
        diff = [0] * (n + 1)
        for first, last, seats in bookings:
            diff[first - 1] += seats      # start adding here (0-indexed)
            diff[last] -= seats           # stop adding after the last flight
        out, running = [], 0
        for i in range(n):
            running += diff[i]            # running total of diff rebuilds the array
            out.append(running)
        return out
MediumLeetCode #1094
Solution

Each trip adds passengers at the pickup point and removes them at the drop-off point. Record that in `diff`: plus at `start`, minus at `end`. A running total over the positions is the number of passengers in the car at each point. If it ever passes the capacity, the answer is False.

Complexity: O(n + L) time for n trips and L = 1001 positions, O(L) space

class Solution:
    def carPooling(self, trips: List[List[int]], capacity: int) -> bool:
        diff = [0] * 1002
        for people, start, end in trips:
            diff[start] += people          # passengers get in
            diff[end] -= people            # and get out at end
        load = 0
        for change in diff:
            load += change                 # running total = passengers in the car
            if load > capacity:
                return False
        return True
HardLeetCode #995
Solution

Scan left to right and flip greedily: if the bit at i is 0 after earlier flips, a flip must start here. Do not touch the k bits. Mark the flip in a difference array: `active` counts flips covering index i, and `diff[i + k]` removes this flip when it ends. A bit is 0 after flips when `nums[i] + active` is even. If a flip would run past the end, the answer is -1.

Complexity: O(n) time, O(n) extra space

class Solution:
    def minKBitFlips(self, nums: List[int], k: int) -> int:
        n = len(nums)
        diff = [0] * (n + 1)                     # diff[i] = change in the number of active flips at i
        flips = active = 0
        for i in range(n):
            active += diff[i]                    # flips covering index i
            if (nums[i] + active) % 2 == 0:      # the bit is 0 now: a flip must start here
                if i + k > n:
                    return -1
                flips += 1
                active += 1                      # range add on [i, i + k - 1] ...
                diff[i + k] -= 1                 # ... that ends before i + k
        return flips

Kadane's Algorithm

Use this when the question asks for the best contiguous sub-array (max sum, max product, circular) and the array can hold negatives.

MediumLeetCode #53
Solution

At each number there are two choices: extend the run so far, or start fresh at this number. `cur = max(x, cur + x)` picks the better one. If the running sum is negative, it can only hurt, so it is dropped. `best` keeps the largest `cur` seen, and an all-negative array still works.

Complexity: O(n) time, O(1) extra space

class Solution:
    def maxSubArray(self, nums: List[int]) -> int:
        cur = best = nums[0]
        for x in nums[1:]:
            cur = max(x, cur + x)       # extend the run or restart here
            best = max(best, cur)
        return best
MediumLeetCode #152
Solution

Same extend-or-restart choice, but a negative number can turn the smallest product into the largest. So keep both `cur_max` and `cur_min` ending at the current index. For each `x`, the new max and min come from `x`, `cur_max * x` and `cur_min * x`. `best` keeps the largest `cur_max`.

Complexity: O(n) time, O(1) extra space

class Solution:
    def maxProduct(self, nums: List[int]) -> int:
        cur_max = cur_min = best = nums[0]
        for x in nums[1:]:
            candidates = (x, cur_max * x, cur_min * x)    # restart, or extend with max or min
            cur_max, cur_min = max(candidates), min(candidates)
            best = max(best, cur_max)
        return best
MediumLeetCode #918
Solution

The best sub-array is either a normal one, or it wraps around the ends. A wrapping one equals the total minus the smallest middle sub-array. Run Kadane twice in one pass: once for the max run and once for the min run. The answer is `max(best_max, total - best_min)`, except when all numbers are negative, where the answer is `best_max`.

Complexity: O(n) time, O(1) extra space

class Solution:
    def maxSubarraySumCircular(self, nums: List[int]) -> int:
        cur_max = cur_min = best_max = best_min = nums[0]
        for x in nums[1:]:
            cur_max = max(x, cur_max + x)         # Kadane for the largest run
            best_max = max(best_max, cur_max)
            cur_min = min(x, cur_min + x)         # Kadane for the smallest run
            best_min = min(best_min, cur_min)
        if best_max < 0:                          # all negative: total - best_min would be an empty pick
            return best_max
        return max(best_max, sum(nums) - best_min)

Hashing / Frequency Map

Use this when the question asks have I seen this, how many times, or which pair, and a dict or set gives O(1) lookups.

EasyLeetCode #1
Solution

For each number, the partner we need is `target - x`. Look it up in the dict `seen`, which maps value to index. If it is there, we are done. If not, store the current number and move on. One pass, because the partner always appears earlier or later and is found from the second of the two.

Complexity: O(n) time, O(n) extra space

class Solution:
    def twoSum(self, nums: List[int], target: int) -> List[int]:
        seen = {}                          # value -> index
        for i, x in enumerate(nums):
            if target - x in seen:         # O(1) lookup of the partner
                return [seen[target - x], i]
            seen[x] = i
        return []
MediumLeetCode #128
Solution

Put all numbers in a set for O(1) membership tests. Only start counting at a number `x` where `x - 1` is not in the set, since that is the start of a run. From there, step up while `x + length` is in the set. Each number is visited by at most one run, so the total work is linear and no sort is needed.

Complexity: O(n) time, O(n) extra space

class Solution:
    def longestConsecutive(self, nums: List[int]) -> int:
        values = set(nums)                  # O(1) membership
        best = 0
        for x in values:
            if x - 1 not in values:         # x is the start of a run
                length = 1
                while x + length in values:
                    length += 1
                best = max(best, length)
        return best
HardLeetCode #149
Solution

Fix a point i and count, in a frequency map, how many other points share each direction from it. Two points are on one line through i exactly when their reduced direction `(dx, dy)` is the same. Reduce with `gcd` and fix the sign so equal slopes get equal keys, which avoids float errors. The biggest count plus point i itself is the best for i.

Complexity: O(n^2) time, O(n) extra space

from collections import defaultdict

import math

class Solution:
    def maxPoints(self, points: List[List[int]]) -> int:
        n = len(points)
        if n <= 2:
            return n
        best = 0
        for i in range(n):
            slopes = defaultdict(int)                   # direction -> how many points
            for j in range(i + 1, n):
                dx = points[j][0] - points[i][0]
                dy = points[j][1] - points[i][1]
                g = math.gcd(dx, dy)
                dx, dy = dx // g, dy // g               # reduced direction, no floats
                if dx < 0 or (dx == 0 and dy < 0):      # one sign convention per line
                    dx, dy = -dx, -dy
                slopes[(dx, dy)] += 1
            best = max(best, max(slopes.values(), default=0) + 1)
        return best

Sorting + Searching

Use this when the order of the input does not matter for the answer, so sorting first puts equal values together and unlocks two pointers or binary search.

EasyLeetCode #350
Solution

Sort both arrays, so equal values sit next to each other. Walk one pointer in each array. Move the pointer at the smaller value. When the values match, keep that value once and move both pointers, which matches duplicates one for one.

Complexity: O(n log n + m log m) time, O(1) extra space besides the output

class Solution:
    def intersect(self, nums1: List[int], nums2: List[int]) -> List[int]:
        nums1.sort()                        # sort first: equal values sit together
        nums2.sort()
        i = j = 0
        out = []
        while i < len(nums1) and j < len(nums2):
            if nums1[i] < nums2[j]:         # move the pointer at the smaller value
                i += 1
            elif nums1[i] > nums2[j]:
                j += 1
            else:                           # match: use one copy from each side
                out.append(nums1[i])
                i += 1
                j += 1
        return out
MediumLeetCode #15
Solution

Sort the array. Fix the first number `nums[i]`, then run two pointers on the rest to find pairs that sum to its negative. Because the array is sorted, equal values are adjacent, so skipping repeats removes duplicate triplets. Sorting costs O(n log n), and the loops cost O(n^2).

Complexity: O(n^2) time, O(1) extra space besides the output

class Solution:
    def threeSum(self, nums: List[int]) -> List[List[int]]:
        nums.sort()                                  # sorting unlocks two pointers and the dedupe skips
        out = []
        for i in range(len(nums) - 2):
            if i > 0 and nums[i] == nums[i - 1]:     # skip repeated first values
                continue
            l, r = i + 1, len(nums) - 1
            while l < r:
                s = nums[i] + nums[l] + nums[r]
                if s < 0:
                    l += 1
                elif s > 0:
                    r -= 1
                else:
                    out.append([nums[i], nums[l], nums[r]])
                    l += 1
                    while l < r and nums[l] == nums[l - 1]:   # skip repeated second values
                        l += 1
        return out
HardLeetCode #354
Solution

Sort by width ascending, and for equal widths by height descending, so two envelopes of the same width can never both be in one increasing run. The question is now the longest strictly increasing sequence of heights. Keep `tails` and use binary search (`bisect_left`) to place each height, which is the search half of the pattern.

Complexity: O(n log n) time, O(n) extra space

from bisect import bisect_left

class Solution:
    def maxEnvelopes(self, envelopes: List[List[int]]) -> int:
        envelopes.sort(key=lambda e: (e[0], -e[1]))   # width up; same width: height down
        tails = []                                    # tails[i] = smallest end of a run of length i + 1
        for _, h in envelopes:
            i = bisect_left(tails, h)                 # binary search for where h fits
            if i == len(tails):
                tails.append(h)
            else:
                tails[i] = h
        return len(tails)

Binary Search

Use this when the array is sorted (or sorted then rotated) and you want a position or a value in O(log n).

EasyLeetCode #35
Solution

This is exactly the lower bound: the first index whose value is at least the target. Keep the search range `[lo, hi)` and look at `mid`. If `nums[mid] >= target`, the answer is at `mid` or before, so `hi = mid`. Otherwise it is after `mid`, so `lo = mid + 1`. The range halves each step.

Complexity: O(log n) time, O(1) extra space

class Solution:
    def searchInsert(self, nums: List[int], target: int) -> int:
        lo, hi = 0, len(nums)            # the answer can be len(nums)
        while lo < hi:
            mid = (lo + hi) // 2
            if nums[mid] >= target:      # first index with nums[i] >= target
                hi = mid
            else:
                lo = mid + 1
        return lo
MediumLeetCode #33
Solution

After a rotation, at least one half around `mid` is still sorted. Check which half is sorted by comparing `nums[lo]` with `nums[mid]`. If the target lies inside the sorted half, search that half. Otherwise search the other half. That still discards half the array each step.

Complexity: O(log n) time, O(1) extra space

class Solution:
    def search(self, nums: List[int], target: int) -> int:
        lo, hi = 0, len(nums) - 1
        while lo <= hi:
            mid = (lo + hi) // 2
            if nums[mid] == target:
                return mid
            if nums[lo] <= nums[mid]:                   # left half is sorted
                if nums[lo] <= target < nums[mid]:
                    hi = mid - 1
                else:
                    lo = mid + 1
            else:                                       # right half is sorted
                if nums[mid] < target <= nums[hi]:
                    lo = mid + 1
                else:
                    hi = mid - 1
        return -1
HardLeetCode #4
Solution

The median splits the merged data into a left half and a right half. Binary search how many elements `i` the left half takes from the shorter array, so `j = half - i` come from the other. The split is correct when each side's last left value is at most the other side's first right value. If not, move `i` by halving, which gives O(log(min(m, n))).

Complexity: O(log(min(m, n))) time, O(1) extra space

from math import inf

class Solution:
    def findMedianSortedArrays(self, nums1: List[int], nums2: List[int]) -> float:
        if len(nums1) > len(nums2):
            nums1, nums2 = nums2, nums1          # binary search on the shorter array
        m, n = len(nums1), len(nums2)
        half = (m + n + 1) // 2
        lo, hi = 0, m
        while lo <= hi:
            i = (lo + hi) // 2                   # elements taken from nums1 for the left half
            j = half - i                         # elements taken from nums2
            left1 = nums1[i - 1] if i > 0 else -inf
            right1 = nums1[i] if i < m else inf
            left2 = nums2[j - 1] if j > 0 else -inf
            right2 = nums2[j] if j < n else inf
            if left1 > right2:                   # took too many from nums1
                hi = i - 1
            elif left2 > right1:                 # took too few from nums1
                lo = i + 1
            else:
                if (m + n) % 2:
                    return float(max(left1, left2))
                return (max(left1, left2) + min(right1, right2)) / 2
        return 0.0

Binary Search on Answer

Use this when the question says minimise the maximum or find the smallest value that works, and a yes/no check flips from no to yes exactly once.

EasyLeetCode #69
Solution

We are not searching an array but the possible answers 0..x. The check `mid * mid <= x` is true for small values and false for large ones, so it flips once. Keep the largest value for which the check is true. Use the upper middle so `lo = mid` always makes progress.

Complexity: O(log x) time, O(1) extra space

class Solution:
    def mySqrt(self, x: int) -> int:
        lo, hi = 0, x
        while lo < hi:
            mid = (lo + hi + 1) // 2      # upper middle so the loop always shrinks
            if mid * mid <= x:            # ok(mid): mid is not too big
                lo = mid
            else:
                hi = mid - 1
        return lo
MediumLeetCode #875
Solution

The answer is the eating speed. Write `ok(speed)`: total hours at that speed is the sum of `ceil(pile / speed)`, and it must be at most h. A faster speed never needs more hours, so `ok` is monotonic. Binary search speeds from 1 to the largest pile for the smallest speed where `ok` is true.

Complexity: O(n log max(piles)) time, O(1) extra space

class Solution:
    def minEatingSpeed(self, piles: List[int], h: int) -> int:
        def ok(speed):                                     # monotonic: faster never needs more hours
            return sum((p + speed - 1) // speed for p in piles) <= h
        lo, hi = 1, max(piles)                             # the answer is in [1, max(piles)]
        while lo < hi:
            mid = (lo + hi) // 2
            if ok(mid):                                    # works: try slower
                hi = mid
            else:
                lo = mid + 1
        return lo
HardLeetCode #410
Solution

Minimise the largest sub-array sum is a "minimise the maximum" question, so search the answer. `ok(limit)` greedily cuts a new part whenever adding the next number would pass `limit`, and returns whether at most k parts are enough. A bigger limit never needs more parts, so the check is monotonic. The search range runs from `max(nums)` to `sum(nums)`.

Complexity: O(n log(sum(nums))) time, O(1) extra space

class Solution:
    def splitArray(self, nums: List[int], k: int) -> int:
        def ok(limit):                       # can we cut into at most k parts, each sum <= limit?
            parts, cur = 1, 0
            for x in nums:
                if cur + x > limit:
                    parts += 1
                    cur = 0
                cur += x
            return parts <= k
        lo, hi = max(nums), sum(nums)        # the answer lies between these
        while lo < hi:
            mid = (lo + hi) // 2
            if ok(mid):                      # works: try a smaller limit
                hi = mid
            else:
                lo = mid + 1
        return lo

Merge Intervals

Use this when the question is about overlapping ranges: merge them, insert one, or keep a set of disjoint ranges.

MediumLeetCode #56
Solution

Sort the intervals by start. After sorting, a new interval can only overlap the last merged one. If `start <= out[-1][1]`, extend the end with `max`. Otherwise push a new interval. One pass after the sort is enough.

Complexity: O(n log n) time, O(n) extra space

class Solution:
    def merge(self, intervals: List[List[int]]) -> List[List[int]]:
        intervals.sort()                              # sort by start
        out = [intervals[0]]
        for start, end in intervals[1:]:
            if start <= out[-1][1]:                   # overlaps the last merged interval
                out[-1][1] = max(out[-1][1], end)     # max keeps [1, 10] + [2, 3] as [1, 10]
            else:
                out.append([start, end])
        return out
MediumLeetCode #57
Solution

The list is already sorted, so no sort is needed. Copy every interval that ends before the new one starts. Then merge every interval that overlaps into the new one by taking the min start and max end. Finally copy the rest, which all start after the new interval ends.

Complexity: O(n) time, O(n) extra space

class Solution:
    def insert(self, intervals: List[List[int]], newInterval: List[int]) -> List[List[int]]:
        out = []
        i, n = 0, len(intervals)
        while i < n and intervals[i][1] < newInterval[0]:     # entirely before the new interval
            out.append(intervals[i])
            i += 1
        while i < n and intervals[i][0] <= newInterval[1]:    # overlaps: merge into the new interval
            newInterval = [min(newInterval[0], intervals[i][0]), max(newInterval[1], intervals[i][1])]
            i += 1
        out.append(newInterval)
        out.extend(intervals[i:])                              # entirely after
        return out
HardLeetCode #352
Solution

Keep the merged, disjoint intervals sorted in two lists: `starts` and `ends`. Each new number is the interval `[v, v]`. Binary search (`bisect_right`) finds the neighbours, and the number either is already covered, touches the left interval, touches the right one, touches both (merge them), or stands alone.

Complexity: addNum O(log n) search plus O(n) list shift, getIntervals O(n)

from bisect import bisect_right

class SummaryRanges:
    def __init__(self):
        self.starts = []                    # sorted starts of the disjoint intervals
        self.ends = []                      # ends[i] belongs to starts[i]

    def addNum(self, value: int) -> None:
        i = bisect_right(self.starts, value)        # first interval that starts after value
        if i > 0 and self.ends[i - 1] >= value:     # already covered
            return
        touch_left = i > 0 and self.ends[i - 1] == value - 1
        touch_right = i < len(self.starts) and self.starts[i] == value + 1
        if touch_left and touch_right:              # value bridges two intervals: merge them
            self.ends[i - 1] = self.ends[i]
            del self.starts[i]
            del self.ends[i]
        elif touch_left:
            self.ends[i - 1] = value
        elif touch_right:
            self.starts[i] = value
        else:                                       # stands alone: new interval [value, value]
            self.starts.insert(i, value)
            self.ends.insert(i, value)

    def getIntervals(self) -> List[List[int]]:
        return [[s, e] for s, e in zip(self.starts, self.ends)]

Cyclic Sort

Use this when the numbers lie in the range 0..n or 1..n and you must find a missing or duplicate number in O(1) space.

EasyLeetCode #268
Solution

The values are 0..n with one missing. Put each value `j` at index `j` by swapping, and skip the value n because it has no slot. After that, the first index `i` where `nums[i] != i` is the missing number. If every slot is right, the answer is n.

Complexity: O(n) time, O(1) extra space

class Solution:
    def missingNumber(self, nums: List[int]) -> int:
        i, n = 0, len(nums)
        while i < n:
            j = nums[i]                          # the slot where nums[i] belongs
            if j < n and nums[j] != nums[i]:     # not home yet: swap it into place
                nums[i], nums[j] = nums[j], nums[i]
            else:
                i += 1
        for i in range(n):
            if nums[i] != i:                     # first slot holding the wrong value
                return i
        return n
MediumLeetCode #442
Solution

Values are 1..n, so value `v` belongs at index `v - 1`. Swap each value toward its own slot; if that slot already holds the same value, move on. When all swaps are done, any index `i` that holds a value other than `i + 1` holds a duplicate of a value that is already at home.

Complexity: O(n) time, O(1) extra space besides the output

class Solution:
    def findDuplicates(self, nums: List[int]) -> List[int]:
        i = 0
        while i < len(nums):
            j = nums[i] - 1                      # the slot where nums[i] belongs
            if nums[j] != nums[i]:               # not home yet: swap it into place
                nums[i], nums[j] = nums[j], nums[i]
            else:
                i += 1
        return [x for i, x in enumerate(nums) if x != i + 1]   # a wrong value here is a duplicate
HardLeetCode #41
Solution

The smallest missing positive must be in 1..n+1, so only values in 1..n matter. For each index, keep swapping `nums[i]` to slot `nums[i] - 1` while it is in range and that slot does not already hold it. Negatives, zeros and big values stay where they are. Then the first index with `nums[i] != i + 1` gives the answer `i + 1`.

Complexity: O(n) time, O(1) extra space

class Solution:
    def firstMissingPositive(self, nums: List[int]) -> int:
        n = len(nums)
        for i in range(n):
            while 1 <= nums[i] <= n and nums[nums[i] - 1] != nums[i]:   # value belongs in range and not home
                j = nums[i] - 1
                nums[i], nums[j] = nums[j], nums[i]                      # send it to its own slot
        for i in range(n):
            if nums[i] != i + 1:
                return i + 1
        return n + 1

Matrix Traversal

Use this when the input is a grid and you walk neighbours with a direction array and bounds check, shrink four boundaries, or transpose and reverse.

EasyLeetCode #661
Solution

For every cell, visit its neighbours with direction vectors `(dr, dc)` and a bounds check, so border cells have fewer neighbours. The cell itself is the offset `(0, 0)`. Add the values and count the cells that were inside the grid, then use integer division for the average.

Complexity: O(rows * cols) time, O(1) extra space besides the output

class Solution:
    def imageSmoother(self, img: List[List[int]]) -> List[List[int]]:
        rows, cols = len(img), len(img[0])
        DIRS = [(dr, dc) for dr in (-1, 0, 1) for dc in (-1, 0, 1)]   # the cell and its 8 neighbours
        out = [[0] * cols for _ in range(rows)]
        for r in range(rows):
            for c in range(cols):
                total = count = 0
                for dr, dc in DIRS:
                    nr, nc = r + dr, c + dc
                    if 0 <= nr < rows and 0 <= nc < cols:             # bounds check
                        total += img[nr][nc]
                        count += 1
                out[r][c] = total // count
        return out
MediumLeetCode #54
Solution

Keep four boundaries: `top`, `bottom`, `left`, `right`. Walk the top row, then the right column, then the bottom row, then the left column, and shrink the matching boundary after each side. Check `top <= bottom` and `left <= right` before the last two sides so a single row or column is not read twice. Stop when the boundaries cross.

Complexity: O(rows * cols) time, O(1) extra space besides the output

class Solution:
    def spiralOrder(self, matrix: List[List[int]]) -> List[int]:
        top, bottom = 0, len(matrix) - 1
        left, right = 0, len(matrix[0]) - 1
        out = []
        while top <= bottom and left <= right:
            for c in range(left, right + 1):           # top row, left to right
                out.append(matrix[top][c])
            top += 1                                    # shrink after each side
            for r in range(top, bottom + 1):           # right column, top to bottom
                out.append(matrix[r][right])
            right -= 1
            if top <= bottom:                           # a single row left: do not walk it twice
                for c in range(right, left - 1, -1):   # bottom row, right to left
                    out.append(matrix[bottom][c])
                bottom -= 1
            if left <= right:                           # a single column left: do not walk it twice
                for r in range(bottom, top - 1, -1):   # left column, bottom to top
                    out.append(matrix[r][left])
                left += 1
        return out
MediumLeetCode #48
Solution

Rotating 90 degrees clockwise is two moves. First transpose: swap `matrix[r][c]` with `matrix[c][r]` for every pair above the diagonal. Then reverse each row. Both steps work in place, so no second matrix is needed.

Complexity: O(n^2) time, O(1) extra space

class Solution:
    def rotate(self, matrix: List[List[int]]) -> None:
        n = len(matrix)
        for r in range(n):
            for c in range(r + 1, n):                                   # transpose: swap across the diagonal
                matrix[r][c], matrix[c][r] = matrix[c][r], matrix[r][c]
        for row in matrix:
            row.reverse()                                               # then reverse each row
2. Strings

A string is an array of characters. Most array patterns carry over, plus a few string-only ones.

Character Frequency

Use this when the answer depends on how many times each character appears, not on where it appears.

EasyLeetCode #387
Solution

Count every letter once with Counter. Then walk the string a second time and return the first index whose count is 1. The counting pass turns "is this letter unique?" into a dictionary lookup, so the whole thing is two passes.

Complexity: O(n) time, O(1) extra space (at most 26 letters)

from collections import Counter

class Solution:
    def firstUniqChar(self, s: str) -> int:
        count = Counter(s)                # pass 1: how many times each letter appears
        for i, ch in enumerate(s):        # pass 2: first letter whose count is exactly 1
            if count[ch] == 1:
                return i
        return -1
MediumLeetCode #451
Solution

Build the frequency table with Counter. most_common() lists the letters from the highest count to the lowest. Repeat each letter by its count and join the pieces. The order of the original string does not matter here, only the counts do.

Complexity: O(n + k log k) time, O(n) space (k = number of distinct characters)

from collections import Counter

class Solution:
    def frequencySort(self, s: str) -> str:
        count = Counter(s)                                        # character frequency
        return "".join(ch * n for ch, n in count.most_common())   # highest count first
MediumLeetCode #767
Solution

Count the letters. If one letter appears more than (n + 1) // 2 times, no arrangement works, so return "". Otherwise write the letters from most frequent to least frequent into positions 0, 2, 4, ... and then 1, 3, 5, ... The most frequent letter takes the even slots first, which keeps equal letters apart.

Complexity: O(n + k log k) time, O(n) space (k = number of distinct characters)

from collections import Counter

class Solution:
    def reorganizeString(self, s: str) -> str:
        count = Counter(s)
        if max(count.values()) > (len(s) + 1) // 2:   # too frequent to ever be separated
            return ""
        res = [""] * len(s)
        idx = 0
        for ch, n in count.most_common():             # most frequent letter first
            for _ in range(n):
                res[idx] = ch
                idx += 2                              # fill the even slots, then the odd slots
                if idx >= len(s):
                    idx = 1
        return "".join(res)

Two Pointers

Use this when you can walk a string from both ends, or with a read pointer and a write pointer, and every step settles one position.

EasyLeetCode #344
Solution

Put one pointer at each end. Swap the two characters, then move both pointers toward the middle until they meet. Each character is touched once and no extra array is needed, because the swap happens in place.

Complexity: O(n) time, O(1) extra space

class Solution:
    def reverseString(self, s: List[str]) -> None:
        l, r = 0, len(s) - 1
        while l < r:                       # pointers walk toward each other
            s[l], s[r] = s[r], s[l]
            l += 1
            r -= 1
EasyLeetCode #844
Solution

Building both final strings costs O(n) extra space. Instead, put one pointer at the end of each string and move it left past every character that a "#" erases. The helper keeps a skip counter: "#" adds one, and a letter with skip above zero is erased. Then compare the two surviving characters and step both pointers left.

Complexity: O(n + m) time, O(1) extra space

class Solution:
    def backspaceCompare(self, s: str, t: str) -> bool:
        def next_valid(string: str, i: int) -> int:
            skip = 0
            while i >= 0:                  # walk left past erased characters
                if string[i] == "#":
                    skip += 1
                elif skip:
                    skip -= 1
                else:
                    break
                i -= 1
            return i

        i, j = len(s) - 1, len(t) - 1      # one pointer per string, starting at the ends
        while i >= 0 or j >= 0:
            i, j = next_valid(s, i), next_valid(t, j)
            if i >= 0 and j >= 0:
                if s[i] != t[j]:
                    return False
            elif i >= 0 or j >= 0:         # one string ran out of letters first
                return False
            i -= 1
            j -= 1
        return True
MediumLeetCode #443
Solution

Use a read pointer to scan each group of equal characters and a write pointer to build the answer in the same array. The write pointer never gets ahead of the read pointer, so nothing unread is overwritten. After each group, write the character, and if the group is longer than 1, write the digits of its length one by one. Return the write pointer as the new length.

Complexity: O(n) time, O(1) extra space

class Solution:
    def compress(self, chars: List[str]) -> int:
        write = read = 0
        while read < len(chars):
            ch, start = chars[read], read
            while read < len(chars) and chars[read] == ch:   # read pointer scans the whole group
                read += 1
            chars[write] = ch                                # write pointer records the result
            write += 1
            if read - start > 1:
                for digit in str(read - start):
                    chars[write] = digit
                    write += 1
        return write

Fixed Sliding Window

Use this when the question looks at every substring of one fixed length, so you can slide by one step: add the new character and drop the old one.

EasyLeetCode #2379
Solution

Every window of length k needs as many recolors as it has white blocks. Count the whites in the first window. Then slide right: add one if the new block is white and subtract one if the block that left was white. The answer is the smallest white count seen.

Complexity: O(n) time, O(1) extra space

class Solution:
    def minimumRecolors(self, blocks: str, k: int) -> int:
        whites = blocks[:k].count("W")        # whites in the first window
        best = whites
        for i in range(k, len(blocks)):
            if blocks[i] == "W":              # new block enters on the right
                whites += 1
            if blocks[i - k] == "W":          # old block leaves on the left
                whites -= 1
            best = min(best, whites)
        return best
MediumLeetCode #438
Solution

An anagram of p is any substring of s with length len(p) and the same letter counts. Keep a Counter for the current window. For each new character, add it on the right, and once the window is longer than len(p), remove the character on the left. When the window Counter equals the Counter of p, record the start index.

Complexity: O(n) time (comparing two Counters costs at most 26), O(1) extra space

from collections import Counter

class Solution:
    def findAnagrams(self, s: str, p: str) -> List[int]:
        need, win, out = Counter(p), Counter(), []
        k = len(p)                             # fixed window length
        for i, ch in enumerate(s):
            win[ch] += 1                       # character enters on the right
            if i >= k:
                old = s[i - k]                 # character leaves on the left
                win[old] -= 1
                if win[old] == 0:
                    del win[old]
            if win == need:
                out.append(i - k + 1)
        return out
HardLeetCode #30
Solution

All words have the same length wl, so a valid substring is exactly k words long (k = number of words). Split s into wl-letter chunks for each of the wl possible starting offsets. On each chunk list, slide a window of exactly k chunks: add the new chunk, drop the oldest, and compare the window Counter with the Counter of words. A match at chunk j means the substring starts at offset + (j - k + 1) * wl.

Complexity: O(n * (wl + d)) time (d = distinct words), O(n / wl + k) extra space

from collections import Counter

class Solution:
    def findSubstring(self, s: str, words: List[str]) -> List[int]:
        k, wl = len(words), len(words[0])
        need, out = Counter(words), []
        for offset in range(wl):                     # words may start at any of wl alignments
            win = Counter()
            chunks = [s[i:i + wl] for i in range(offset, len(s) - wl + 1, wl)]
            for j, w in enumerate(chunks):
                win[w] += 1                          # new word enters on the right
                if j >= k:                           # window is fixed at k words
                    old = chunks[j - k]
                    win[old] -= 1                    # oldest word leaves on the left
                    if win[old] == 0:
                        del win[old]
                if win == need:
                    out.append(offset + (j - k + 1) * wl)
        return out

Variable Sliding Window

Use this when you need the longest or shortest substring that stays valid, so you grow the right end and shrink the left end while the window is broken.

MediumLeetCode #3
Solution

Walk the right end r over the string. Store the last index of every character in a dictionary. If the current character was last seen inside the window, move the left end l to one past that index. The window s[l..r] never holds a repeat, so its length is a candidate for the answer.

Complexity: O(n) time, O(min(n, alphabet)) extra space

class Solution:
    def lengthOfLongestSubstring(self, s: str) -> int:
        last, l, best = {}, 0, 0
        for r, ch in enumerate(s):             # r grows the window
            if ch in last and last[ch] >= l:   # repeat inside the window
                l = last[ch] + 1               # jump past the previous copy
            last[ch] = r
            best = max(best, r - l + 1)
        return best
MediumLeetCode #424
Solution

A window can be turned into one repeated letter if its length minus the count of its most common letter is at most k. Grow the window on the right and update the count map. While the window needs more than k replacements, remove the leftmost character and move l right. The longest window that stays valid is the answer.

Complexity: O(26 * n) time, O(1) extra space

from collections import defaultdict

class Solution:
    def characterReplacement(self, s: str, k: int) -> int:
        count, l, best = defaultdict(int), 0, 0
        for r, ch in enumerate(s):
            count[ch] += 1
            while (r - l + 1) - max(count.values()) > k:   # too many letters to replace: shrink
                count[s[l]] -= 1
                l += 1
            best = max(best, r - l + 1)
        return best
HardLeetCode #76
Solution

need counts the letters of t, and missing counts how many letters of t the window still lacks. Grow r and decrease the counts; a negative count means the window has a spare copy. When missing reaches 0 the window is valid, so move l right while the letter at l is spare, and record the length. Then drop s[l] to break the window and keep searching.

Complexity: O(n + m) time, O(1) extra space (alphabet size)

from collections import Counter

class Solution:
    def minWindow(self, s: str, t: str) -> str:
        need = Counter(t)
        missing = len(t)                       # letters of t not yet covered by the window
        l, best_len, best_start = 0, len(s) + 1, 0
        for r, ch in enumerate(s):
            if need[ch] > 0:
                missing -= 1
            need[ch] -= 1                      # negative means a spare copy inside the window
            if missing == 0:                   # window is valid: shrink from the left
                while need[s[l]] < 0:
                    need[s[l]] += 1
                    l += 1
                if r - l + 1 < best_len:
                    best_len, best_start = r - l + 1, l
                need[s[l]] += 1                # drop s[l] to break the window
                missing += 1
                l += 1
        return s[best_start:best_start + best_len] if best_len <= len(s) else ""

HashMap / HashSet

Use this when you keep asking "have I seen this character, substring or word before, and where?" and a dictionary or set can answer in O(1).

EasyLeetCode #205
Solution

Two strings are isomorphic if the letters pair up one to one. Keep one dictionary from s to t and one from t to s. For each position, if either dictionary already holds a different partner, return False. Otherwise store the pair in both.

Complexity: O(n) time, O(1) extra space (bounded by the alphabet)

class Solution:
    def isIsomorphic(self, s: str, t: str) -> bool:
        s_to_t, t_to_s = {}, {}
        for a, b in zip(s, t):
            if s_to_t.get(a, b) != b or t_to_s.get(b, a) != a:   # a clash in either direction
                return False
            s_to_t[a] = b
            t_to_s[b] = a
        return True
MediumLeetCode #187
Solution

Slide over every substring of length 10. A set called seen remembers each one. If a substring is already in seen, it has now appeared twice, so put it in the answer set. Using a second set keeps each repeated sequence in the output only once.

Complexity: O(10 * n) time, O(10 * n) space

class Solution:
    def findRepeatedDnaSequences(self, s: str) -> List[str]:
        seen, repeated = set(), set()
        for i in range(len(s) - 9):
            chunk = s[i:i + 10]
            if chunk in seen:              # second time we meet this 10-letter substring
                repeated.add(chunk)
            seen.add(chunk)
        return list(repeated)
HardLeetCode #336
Solution

Store every word with its index in a dictionary. For each word w, try every cut into left and right. If left is a palindrome, the reverse of right placed in front of w makes a palindrome, so look up the reverse of right in the dictionary. If right is a palindrome, the reverse of left placed after w works the same way. The dictionary lookup replaces checking every other word.

Complexity: O(n * L^2) time, O(n * L) space (L = longest word)

class Solution:
    def palindromePairs(self, words: List[str]) -> List[List[int]]:
        index = {w: i for i, w in enumerate(words)}      # word -> position, for O(1) lookup
        def is_pal(x: str) -> bool:
            return x == x[::-1]

        out = []
        for i, w in enumerate(words):
            for cut in range(len(w) + 1):
                left, right = w[:cut], w[cut:]
                if is_pal(left):                         # words[j] + w, with words[j] = reverse(right)
                    j = index.get(right[::-1])
                    if j is not None and j != i:
                        out.append([j, i])
                if cut < len(w) and is_pal(right):       # w + words[j], with words[j] = reverse(left)
                    j = index.get(left[::-1])
                    if j is not None and j != i:
                        out.append([i, j])
        return out

Anagram Pattern

Use this when two strings must match letter for letter in any order, so equal letter counts (or an equal sorted form) is the test.

EasyLeetCode #242
Solution

Two strings are anagrams when every letter has the same count in both. Use a 26-slot array: add one for each letter of s and subtract one for each letter of t. If the lengths match and every slot ends at 0, the counts are equal.

Complexity: O(n) time, O(1) extra space

class Solution:
    def isAnagram(self, s: str, t: str) -> bool:
        if len(s) != len(t):
            return False
        count = [0] * 26                       # one slot per lowercase letter
        for a, b in zip(s, t):
            count[ord(a) - ord("a")] += 1
            count[ord(b) - ord("a")] -= 1
        return not any(count)                  # all zeros means the same letters
MediumLeetCode #49
Solution

Anagrams share the same letter counts, so the count tuple works as a dictionary key. For each word, build its 26-slot count, turn it into a tuple, and append the word to the list stored under that key. Each list in the dictionary is then one group, and no sorting of letters is needed.

Complexity: O(total characters) time, O(total characters) space

from collections import defaultdict

class Solution:
    def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
        groups = defaultdict(list)
        for word in strs:
            count = [0] * 26
            for ch in word:
                count[ord(ch) - ord("a")] += 1
            groups[tuple(count)].append(word)  # same counts means same key
        return list(groups.values())
MediumLeetCode #1347
Solution

Count the letters of s, then subtract the letters of t. A positive number means s has that many copies that t lacks. Each missing copy costs one replacement in t, so the answer is the sum of the positive differences. Letters where t has extra copies are matched by those same replacements, so they are not counted twice.

Complexity: O(n) time, O(1) extra space

from collections import Counter

class Solution:
    def minSteps(self, s: str, t: str) -> int:
        diff = Counter(s)
        diff.subtract(t)                       # count(s) - count(t) per letter
        return sum(v for v in diff.values() if v > 0)

Palindrome Pattern

Use this when the question is about a string that reads the same both ways: compare from the ends inward, or grow outward from every centre.

EasyLeetCode #125
Solution

Put one pointer at each end. Move each pointer past characters that are not letters or digits. Compare the two characters in lower case; any mismatch means False. Move both pointers inward until they cross.

Complexity: O(n) time, O(1) extra space

class Solution:
    def isPalindrome(self, s: str) -> bool:
        l, r = 0, len(s) - 1
        while l < r:
            while l < r and not s[l].isalnum():   # skip non-alphanumerics
                l += 1
            while l < r and not s[r].isalnum():
                r -= 1
            if s[l].lower() != s[r].lower():      # compare from the ends inward
                return False
            l += 1
            r -= 1
        return True
MediumLeetCode #5
Solution

Every palindrome has a centre: a single character (odd length) or the gap between two characters (even length). For each index c, try both centres and expand outward while the two ends match. When the loop stops, s[l+1:r] is the palindrome for that centre; keep the longest. There are 2n - 1 centres and each expansion costs at most O(n).

Complexity: O(n^2) time, O(1) extra space

class Solution:
    def longestPalindrome(self, s: str) -> str:
        best = ""
        for c in range(len(s)):
            for l, r in ((c, c), (c, c + 1)):                 # odd centre, even centre
                while l >= 0 and r < len(s) and s[l] == s[r]:
                    l -= 1                                    # grow outward
                    r += 1
                if r - l - 1 > len(best):
                    best = s[l + 1:r]
        return best
HardLeetCode #132
Solution

Let cuts[i] be the fewest cuts for the first i characters, starting from the worst case of i - 1. Expand around every centre. Each time s[l..r] is a palindrome, the first r + 1 characters can be made from the first l characters plus one more piece, so cuts[r+1] = min(cuts[r+1], cuts[l] + 1). Setting cuts[0] = -1 makes a palindrome that starts at index 0 cost zero cuts.

Complexity: O(n^2) time, O(n) space

class Solution:
    def minCut(self, s: str) -> int:
        n = len(s)
        cuts = list(range(-1, n))              # cuts[i] = fewest cuts for s[:i]; worst case i - 1
        for c in range(n):
            for l, r in ((c, c), (c, c + 1)):  # odd centre, even centre
                while l >= 0 and r < n and s[l] == s[r]:
                    cuts[r + 1] = min(cuts[r + 1], cuts[l] + 1)   # s[l..r] is one palindrome piece
                    l -= 1
                    r += 1
        return cuts[n]

Substring Pattern

Use this when you must count or measure substrings and can reason about where each one starts or ends, instead of listing all O(n^2) of them.

EasyLeetCode #696
Solution

A valid substring is some 0s followed by the same number of 1s, or the other way round. So it sits on the border between two neighbouring runs of equal characters. Two touching runs of lengths a and b give min(a, b) valid substrings. Track the previous and current run lengths and add min(prev, cur) every time a run ends.

Complexity: O(n) time, O(1) extra space

class Solution:
    def countBinarySubstrings(self, s: str) -> int:
        prev, cur, total = 0, 1, 0          # lengths of the previous run and the current run
        for i in range(1, len(s)):
            if s[i] == s[i - 1]:
                cur += 1                    # the same run continues
            else:
                total += min(prev, cur)     # two touching runs give min(prev, cur) substrings
                prev, cur = cur, 1
        return total + min(prev, cur)       # close the last pair of runs
MediumLeetCode #1358
Solution

Fix the end index r and ask how many start indices make a valid substring. The substring must reach back to the latest a, the latest b and the latest c, so every start up to the oldest of those three positions works. That count is min(last positions) + 1. Add it for every r, and nothing is added until all three letters have appeared.

Complexity: O(n) time, O(1) extra space

class Solution:
    def numberOfSubstrings(self, s: str) -> int:
        last = {"a": -1, "b": -1, "c": -1}      # latest index of each letter
        total = 0
        for r, ch in enumerate(s):
            last[ch] = r
            # valid starts for the substring ending at r: 0 .. min(last)
            total += min(last.values()) + 1
        return total
HardLeetCode #828
Solution

Instead of looking at each substring, ask how many substrings each character occurrence is the only copy of its letter in. For an occurrence at index k with previous copy at p and next copy at q, the substring may start in (p, k] and end in [k, q). That gives (k - p) * (q - k). Store the positions of every letter with sentinels -1 and len(s), then add this product for each occurrence.

Complexity: O(n) time, O(n) space

from collections import defaultdict

class Solution:
    def uniqueLetterString(self, s: str) -> int:
        positions = defaultdict(lambda: [-1])    # indices of each letter, sentinel -1 in front
        for i, ch in enumerate(s):
            positions[ch].append(i)
        total = 0
        for idx in positions.values():
            idx.append(len(s))                   # sentinel after the last copy
            for k in range(1, len(idx) - 1):
                # substrings where this copy is the only one of its letter
                total += (idx[k] - idx[k - 1]) * (idx[k + 1] - idx[k])
        return total

String Matching

Use this when you must find a pattern inside a text, or compare a string with its own prefixes, and the text pointer should never move backwards.

EasyLeetCode #28
Solution

This is textbook KMP. First build the LPS table of the needle: lps[i] is the longest proper prefix of needle[:i+1] that is also a suffix. Then scan the haystack once, keeping k = how many needle characters match now. On a mismatch, fall back with k = lps[k-1] instead of moving the haystack pointer back. When k equals the needle length, the match started at i - k + 1.

Complexity: O(n + m) time, O(m) extra space

class Solution:
    def strStr(self, haystack: str, needle: str) -> int:
        lps, k = [0] * len(needle), 0               # KMP failure table of the needle
        for i in range(1, len(needle)):
            while k and needle[i] != needle[k]:
                k = lps[k - 1]
            if needle[i] == needle[k]:
                k += 1
            lps[i] = k
        k = 0                                       # needle characters matched so far
        for i, ch in enumerate(haystack):           # i never moves backwards
            while k and ch != needle[k]:
                k = lps[k - 1]                      # fall back inside the needle
            if ch == needle[k]:
                k += 1
            if k == len(needle):
                return i - k + 1
        return -1
EasyLeetCode #459
Solution

Build the LPS table of s itself. lps[-1] is the length of the longest proper prefix that is also a suffix. The smallest repeating block then has length n - lps[-1]. The string is made of repeated blocks only if lps[-1] is above 0 and that block length divides n.

Complexity: O(n) time, O(n) extra space

class Solution:
    def repeatedSubstringPattern(self, s: str) -> bool:
        n = len(s)
        lps, k = [0] * n, 0                       # prefix function of s itself
        for i in range(1, n):
            while k and s[i] != s[k]:
                k = lps[k - 1]
            if s[i] == s[k]:
                k += 1
            lps[i] = k
        period = n - lps[-1]                      # length of the smallest repeating block
        return lps[-1] > 0 and n % period == 0
HardLeetCode #214
Solution

We may only add characters in front, so we need the longest prefix of s that is already a palindrome. Build s + "#" + reverse(s) and compute its LPS table. The last LPS value is exactly the length of the longest prefix of s that equals a suffix of reverse(s), which means a palindromic prefix. Reverse the rest of s and put it in front.

Complexity: O(n) time, O(n) extra space

class Solution:
    def shortestPalindrome(self, s: str) -> str:
        combined = s + "#" + s[::-1]
        lps, k = [0] * len(combined), 0           # KMP failure table
        for i in range(1, len(combined)):
            while k and combined[i] != combined[k]:
                k = lps[k - 1]
            if combined[i] == combined[k]:
                k += 1
            lps[i] = k
        longest = lps[-1]                         # length of the longest palindromic prefix of s
        return s[longest:][::-1] + s
HardLeetCode #1044
Solution

This is Rabin-Karp inside a binary search on the length. If a duplicate of length L exists, one of length L - 1 exists too, so binary search the length. For one length, roll a polynomial hash across the string in O(1) per step and store hashes in a dictionary. When a hash repeats, compare the actual substrings to rule out a collision.

Complexity: O(n log n) expected time, O(n) space

class Solution:
    def longestDupSubstring(self, s: str) -> str:
        n, base, mod = len(s), 131, (1 << 61) - 1
        nums = [ord(c) - 96 for c in s]
        def find(length: int) -> int:                  # start of a duplicate of this length, or -1
            power = pow(base, length, mod)
            h = 0
            for i in range(length):
                h = (h * base + nums[i]) % mod
            seen = {h: [0]}
            for i in range(1, n - length + 1):
                h = (h * base + nums[i + length - 1] - nums[i - 1] * power) % mod   # rolling hash
                for j in seen.get(h, []):
                    if s[j:j + length] == s[i:i + length]:   # verify on a hash match
                        return i
                seen.setdefault(h, []).append(i)
            return -1

        lo, hi, best = 1, n - 1, ""
        while lo <= hi:                                # binary search on the length
            mid = (lo + hi) // 2
            i = find(mid)
            if i != -1:
                best, lo = s[i:i + mid], mid + 1
            else:
                hi = mid - 1
        return best

Stack-based String Problems

Use this when the most recent unfinished piece must be closed or resolved first, as with brackets, nested encodings and expressions.

EasyLeetCode #20
Solution

Push every opening bracket on a stack. When a closing bracket arrives, the top of the stack must be its matching opener, so pop and compare. If the stack is empty at a closer or the pair does not match, return False. At the end the stack must be empty, otherwise some opener was never closed.

Complexity: O(n) time, O(n) extra space

class Solution:
    def isValid(self, s: str) -> bool:
        pair, stack = {")": "(", "]": "[", "}": "{"}, []
        for ch in s:
            if ch in pair:                          # closer: top of stack must be its opener
                if not stack or stack.pop() != pair[ch]:
                    return False
            else:
                stack.append(ch)                    # opener: wait for its closer
        return not stack
MediumLeetCode #394
Solution

Keep the text built so far in cur and the repeat count being read in num. On "[", push (cur, num) on the stack and start fresh. On "]", pop the saved text and count, and set cur to saved text plus cur repeated count times. Letters are appended to cur and digits build num, so nested brackets resolve from the inside out.

Complexity: O(output length) time, O(n) extra space

class Solution:
    def decodeString(self, s: str) -> str:
        stack, cur, num = [], "", 0
        for ch in s:
            if ch.isdigit():
                num = num * 10 + int(ch)            # counts can have several digits
            elif ch == "[":
                stack.append((cur, num))            # save the outer state
                cur, num = "", 0
            elif ch == "]":
                prev, k = stack.pop()               # restore it and expand
                cur = prev + cur * k
            else:
                cur += ch
        return cur
HardLeetCode #726
Solution

Keep a stack of Counters, one per open bracket. Read an atom name and its optional count, and add it to the Counter on top. On "(", push a new Counter. On ")", read the number after it, pop the Counter, multiply every count in it, and merge it into the Counter below. At the end, sort the atom names and write each count only when it is above 1.

Complexity: O(n^2) time in the worst case, O(n) space

from collections import Counter

class Solution:
    def countOfAtoms(self, formula: str) -> str:
        stack = [Counter()]                         # one Counter per open bracket
        i, n = 0, len(formula)
        while i < n:
            ch = formula[i]
            if ch == "(":
                stack.append(Counter())             # open: start a new level
                i += 1
            elif ch == ")":
                i += 1
                j = i
                while j < n and formula[j].isdigit():
                    j += 1
                mult = int(formula[i:j] or 1)
                i = j
                top = stack.pop()                   # close: multiply and merge into the parent
                for atom, c in top.items():
                    stack[-1][atom] += c * mult
            else:
                j = i + 1
                while j < n and formula[j].islower():
                    j += 1
                atom = formula[i:j]
                i = j
                while j < n and formula[j].isdigit():
                    j += 1
                stack[-1][atom] += int(formula[i:j] or 1)
                i = j
        total = stack[0]
        return "".join(a + (str(total[a]) if total[a] > 1 else "") for a in sorted(total))
3. Linked List

No random access, so every trick is about pointers: moving them at different speeds, reversing them, or anchoring them with a dummy node.

Fast & Slow Pointers

Use this when one pointer must move twice as fast as another so the slow one lands in the middle, or the two meet inside a loop.

EasyLeetCode #202
Solution

Repeatedly replacing a number by the sum of its squared digits makes a chain, and a chain behaves like a linked list. It either reaches 1 or falls into a loop. Slow takes one step per round and fast takes two. If fast reaches 1 the number is happy. If slow and fast meet before that, there is a loop that does not contain 1.

Complexity: O(log n) time, O(1) extra space

class Solution:
    def isHappy(self, n: int) -> bool:
        def next_number(x: int) -> int:
            total = 0
            while x:
                x, digit = divmod(x, 10)
                total += digit * digit
            return total

        slow, fast = n, next_number(n)
        while fast != 1 and slow != fast:          # meeting without hitting 1 means a loop
            slow = next_number(slow)               # slow: 1 step
            fast = next_number(next_number(fast))  # fast: 2 steps
        return fast == 1
EasyLeetCode #234
Solution

A list cannot be read backwards, so fast and slow pointers first find the middle: when fast reaches the end, slow is at the middle. The second half is then reversed in place with the save, flip, advance steps. Now the first half and the reversed second half are walked together and compared value by value. For an odd length the middle node is compared with itself, which is harmless.

Complexity: O(n) time, O(1) extra space

class Solution:
    def isPalindrome(self, head: Optional[ListNode]) -> bool:
        slow = fast = head
        while fast and fast.next:                  # slow ends at the middle
            slow, fast = slow.next, fast.next.next
        prev = None
        while slow:                                # reverse the second half
            nxt = slow.next
            slow.next = prev
            prev, slow = slow, nxt
        left, right = head, prev
        while right:                               # compare outside-in
            if left.val != right.val:
                return False
            left, right = left.next, right.next
        return True
MediumLeetCode #143
Solution

The target order is first, last, second, second-last, and so on. Fast and slow pointers find the middle and the list is cut there. The second half is reversed so that its nodes come out last-first. Then the two halves are woven together one node at a time, saving both next pointers before relinking.

Complexity: O(n) time, O(1) extra space

class Solution:
    def reorderList(self, head: Optional[ListNode]) -> None:
        if not head:
            return
        slow = fast = head
        while fast and fast.next:                  # 1. find the middle
            slow, fast = slow.next, fast.next.next
        prev, cur = None, slow.next
        slow.next = None                           # cut into two halves
        while cur:                                 # 2. reverse the second half
            cur.next, prev, cur = prev, cur, cur.next
        first, second = head, prev
        while second:                              # 3. weave the halves together
            next_first, next_second = first.next, second.next
            first.next = second
            second.next = next_first
            first, second = next_first, next_second

Reverse Linked List

Use this when the direction of next pointers must be flipped, in the whole list, in a window, or in one half.

EasyLeetCode #206
Solution

Walk the list with prev and cur. For each node, save cur.next first, then point cur.next back at prev, then move both names forward. When cur falls off the end, prev is the new head. Saving nxt before the flip is what keeps the rest of the list from being lost.

Complexity: O(n) time, O(1) extra space

class Solution:
    def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
        prev, cur = None, head
        while cur:
            nxt = cur.next             # 1. save
            cur.next = prev            # 2. flip
            prev, cur = cur, nxt       # 3. advance
        return prev
MediumLeetCode #92
Solution

Walk to the node right before position left, called before, using a dummy so left = 1 works. Run the same save, flip, advance loop, but only for right - left + 1 nodes. After the loop, prev is the new start of the window and cur is the first node after it. Two assignments reconnect the window: the old first node points to cur, and before points to prev.

Complexity: O(n) time, O(1) extra space

class Solution:
    def reverseBetween(self, head: Optional[ListNode], left: int, right: int) -> Optional[ListNode]:
        dummy = ListNode(0, head)
        before = dummy
        for _ in range(left - 1):                  # stop right before the window
            before = before.next
        prev, cur = None, before.next
        for _ in range(right - left + 1):          # reverse only the window
            nxt = cur.next
            cur.next = prev
            prev, cur = cur, nxt
        before.next.next = cur                     # old window start -> node after window
        before.next = prev                         # before -> new window start
        return dummy.next
MediumLeetCode #445
Solution

The most significant digit comes first, but addition must start from the least significant digit. Reversing both lists puts the digits in the order addition needs. The loop adds digit by digit with a carry. Each new digit is pushed on the front of the result, which reverses the answer back into the right order. This version reverses the input lists, which LeetCode allows.

Complexity: O(n + m) time, O(1) extra space

class Solution:
    def addTwoNumbers(self, l1: Optional[ListNode], l2: Optional[ListNode]) -> Optional[ListNode]:
        def reverse(head):
            prev, cur = None, head
            while cur:
                nxt = cur.next         # save
                cur.next = prev        # flip
                prev, cur = cur, nxt   # advance
            return prev

        a, b = reverse(l1), reverse(l2)            # least significant digit first
        carry = 0
        result = None
        while a or b or carry:
            total = carry + (a.val if a else 0) + (b.val if b else 0)
            carry, digit = divmod(total, 10)
            result = ListNode(digit, result)       # push on the front: reverses the answer back
            a = a.next if a else None
            b = b.next if b else None
        return result

Dummy Node

Use this when the head may be removed, replaced or built from scratch, so the first node should be handled like every other node.

EasyLeetCode #203
Solution

If the head itself has the target value, there is no previous node to rewire. A dummy node placed before the head gives every real node a previous node. The loop looks at cur.next: if it matches, it is unlinked and cur stays put, otherwise cur moves on. Return dummy.next, because the head may have changed.

Complexity: O(n) time, O(1) extra space

class Solution:
    def removeElements(self, head: Optional[ListNode], val: int) -> Optional[ListNode]:
        dummy = ListNode(0, head)                  # the head can now be removed like any node
        cur = dummy
        while cur.next:
            if cur.next.val == val:
                cur.next = cur.next.next           # unlink, stay on cur
            else:
                cur = cur.next
        return dummy.next                          # not dummy
MediumLeetCode #86
Solution

Build two separate chains, one for nodes smaller than x and one for the rest, each with its own dummy node. Walk the original list once and append every node to the right chain, keeping the original order. Finally set big.next to None so the old tail does not leave a loop, and link the small chain to the start of the big chain. Return small_dummy.next.

Complexity: O(n) time, O(1) extra space

class Solution:
    def partition(self, head: Optional[ListNode], x: int) -> Optional[ListNode]:
        small_dummy, big_dummy = ListNode(), ListNode()   # one dummy per chain
        small, big = small_dummy, big_dummy
        while head:
            if head.val < x:
                small.next = head
                small = small.next
            else:
                big.next = head
                big = big.next
            head = head.next
        big.next = None                            # cut the old tail so no loop appears
        small.next = big_dummy.next                # small chain, then big chain
        return small_dummy.next
MediumLeetCode #82
Solution

Every value that appears more than once is removed completely, so even the first node may disappear. A dummy node gives the head a previous node. prev is the last node known to stay. When cur starts a run of equal values, skip the whole run and set prev.next to the node after it. Otherwise cur is unique, so prev moves onto it.

Complexity: O(n) time, O(1) extra space

class Solution:
    def deleteDuplicates(self, head: Optional[ListNode]) -> Optional[ListNode]:
        dummy = ListNode(0, head)                  # the first node may be a duplicate too
        prev = dummy                               # last node we are sure to keep
        cur = head
        while cur:
            if cur.next and cur.next.val == cur.val:
                dup = cur.val
                while cur and cur.val == dup:      # skip the whole run
                    cur = cur.next
                prev.next = cur
            else:
                prev, cur = cur, cur.next
        return dummy.next

Two Pointers

Use this when two pointers must walk the same list at once, as neighbours, as odd and even positions, or as the two ends of a section to cut out.

EasyLeetCode #83
Solution

The list is sorted, so equal values sit next to each other. cur and cur.next are the two pointers, always neighbours. If they hold the same value, unlink cur.next and compare again. If they differ, move cur forward. The head is never removed, so no dummy node is needed.

Complexity: O(n) time, O(1) extra space

class Solution:
    def deleteDuplicates(self, head: Optional[ListNode]) -> Optional[ListNode]:
        cur = head
        while cur and cur.next:                    # compare cur with its neighbour
            if cur.next.val == cur.val:
                cur.next = cur.next.next           # drop the duplicate
            else:
                cur = cur.next
        return head
MediumLeetCode #328
Solution

Two pointers, odd and even, each build their own chain from the same list. Each step, odd jumps over the even node and even jumps over the odd node. Because they skip each other, the nodes at odd positions and at even positions end up in two chains without any new nodes. The last line attaches the saved head of the even chain to the tail of the odd chain.

Complexity: O(n) time, O(1) extra space

class Solution:
    def oddEvenList(self, head: Optional[ListNode]) -> Optional[ListNode]:
        if not head:
            return head
        odd, even = head, head.next
        even_head = even                           # remember where the even chain starts
        while even and even.next:
            odd.next = even.next                   # odd jumps over even
            odd = odd.next
            even.next = odd.next                   # even jumps over odd
            even = even.next
        odd.next = even_head                       # odd chain, then even chain
        return head
MediumLeetCode #1669
Solution

Two pointers mark the edges of the part to cut out: before is the node right ahead of index a, and after is the node right past index b. Both are found by walking from list1, so the cut part is skipped without touching it. A third pointer finds the tail of list2. Then before points to list2 and the tail of list2 points to after.

Complexity: O(n + m) time, O(1) extra space

class Solution:
    def mergeInBetween(self, list1: ListNode, a: int, b: int, list2: ListNode) -> ListNode:
        before = list1
        for _ in range(a - 1):                     # pointer 1: node right before index a
            before = before.next
        after = before
        for _ in range(b - a + 2):                 # pointer 2: node right after index b
            after = after.next
        tail = list2
        while tail.next:                           # last node of list2
            tail = tail.next
        before.next = list2                        # splice list2 in
        tail.next = after
        return list1

Merge Two Lists

Use this when two sorted lists must become one sorted list, or a list can be split, sorted in halves and merged back.

EasyLeetCode #21
Solution

Put a dummy node before the result and keep a tail pointer at its end. Compare the two current heads, attach the smaller one to tail, and advance that list. When one list runs out, attach the rest of the other in one step. Return dummy.next.

Complexity: O(n + m) time, O(1) extra space

class Solution:
    def mergeTwoLists(self, list1: Optional[ListNode], list2: Optional[ListNode]) -> Optional[ListNode]:
        dummy = tail = ListNode()                  # dummy removes the "which head first" case
        while list1 and list2:
            if list1.val <= list2.val:
                tail.next, list1 = list1, list1.next
            else:
                tail.next, list2 = list2, list2.next
            tail = tail.next
        tail.next = list1 or list2                 # attach the leftover list
        return dummy.next
MediumLeetCode #148
Solution

This is merge sort, which suits linked lists because merging needs no extra array. Fast and slow pointers find the end of the first half, and the list is cut there. Each half is sorted by a recursive call. The two sorted halves are then combined with the same dummy-and-tail merge used for two sorted lists.

Complexity: O(n log n) time, O(log n) space for the recursion

class Solution:
    def sortList(self, head: Optional[ListNode]) -> Optional[ListNode]:
        if not head or not head.next:
            return head
        slow, fast = head, head.next              # slow stops at the end of the first half
        while fast and fast.next:
            slow, fast = slow.next, fast.next.next
        second = slow.next
        slow.next = None                          # cut into two lists
        return self.merge(self.sortList(head), self.sortList(second))

    def merge(self, a: Optional[ListNode], b: Optional[ListNode]) -> Optional[ListNode]:
        dummy = tail = ListNode()
        while a and b:
            if a.val <= b.val:
                tail.next, a = a, a.next
            else:
                tail.next, b = b, b.next
            tail = tail.next
        tail.next = a or b
        return dummy.next
HardLeetCode #23
Solution

Merging k lists one after another would re-walk the early lists many times. Instead, merge them in pairs: lists 0 and 1, lists 2 and 3, and so on, using the two-list merge. Each round halves the number of lists, so there are about log k rounds and every node is touched once per round. A min-heap of the k heads is the other common way to do it.

Complexity: O(N log k) time for N nodes in total, O(1) extra space

class Solution:
    def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
        if not lists:
            return None
        while len(lists) > 1:                      # each round halves the number of lists
            merged = []
            for i in range(0, len(lists), 2):
                a = lists[i]
                b = lists[i + 1] if i + 1 < len(lists) else None
                merged.append(self.merge(a, b))
            lists = merged
        return lists[0]

    def merge(self, a: Optional[ListNode], b: Optional[ListNode]) -> Optional[ListNode]:
        dummy = tail = ListNode()
        while a and b:
            if a.val <= b.val:
                tail.next, a = a, a.next
            else:
                tail.next, b = b, b.next
            tail = tail.next
        tail.next = a or b
        return dummy.next

Cycle Detection

Use this when you must know whether following next pointers, or any rule like i -> nums[i], ever loops back on itself.

EasyLeetCode #141
Solution

Slow moves 1 step and fast moves 2. Without a cycle, fast reaches the end and the loop stops. With a cycle, both end up circling inside it and fast gains one step per round, so it must land on slow. The check slow is fast detects that meeting.

Complexity: O(n) time, O(1) extra space

class Solution:
    def hasCycle(self, head: Optional[ListNode]) -> bool:
        slow = fast = head
        while fast and fast.next:
            slow, fast = slow.next, fast.next.next
            if slow is fast:                       # fast lapped slow inside a cycle
                return True
        return False
MediumLeetCode #287
Solution

Read the array as a linked list where index i points to nums[i]. Two indices hold the duplicate value, so two nodes point to the same next node, and that makes a cycle whose entrance is the duplicate. Phase one: slow and fast pointers walk until they meet inside the cycle. Phase two: reset slow to the start and move both one step at a time. They meet at the cycle entrance, which is the duplicate. Starting from index 0 is safe because no value is 0, so nothing points back to it.

Complexity: O(n) time, O(1) extra space

class Solution:
    def findDuplicate(self, nums: List[int]) -> int:
        slow = fast = 0
        while True:                                # phase 1: meet inside the cycle
            slow = nums[slow]
            fast = nums[nums[fast]]
            if slow == fast:
                break
        slow = 0                                   # phase 2: restart one pointer from the head
        while slow != fast:
            slow = nums[slow]
            fast = nums[fast]
        return slow                                # the cycle entrance is the duplicate
MediumLeetCode #457
Solution

Each index points to the index (i + nums[i]) mod n, so the array is a graph where every node has one next node. Run slow and fast pointers from each unvisited start. A step is only allowed when the next value has the same sign, and a step that returns to the same index is a loop of length 1, which does not count. If slow meets fast, a valid loop exists. Otherwise, zero out the dead path so no start is explored twice.

Complexity: O(n) time, O(1) extra space

class Solution:
    def circularArrayLoop(self, nums: List[int]) -> bool:
        n = len(nums)

        def step(i: int, forward: bool) -> int:
            j = (i + nums[i]) % n
            if j == i or nums[j] == 0 or (nums[j] > 0) != forward:
                return -1                          # self-loop, dead cell or direction change
            return j

        for start in range(n):
            if nums[start] == 0:
                continue
            forward = nums[start] > 0
            slow = fast = start
            while True:
                slow = step(slow, forward)         # 1 step
                fast = step(fast, forward)         # 2 steps
                if fast != -1:
                    fast = step(fast, forward)
                if slow == -1 or fast == -1:
                    break
                if slow == fast:
                    return True
            i = start                              # mark this dead path so it is not retried
            while nums[i] != 0 and (nums[i] > 0) == forward:
                j = (i + nums[i]) % n
                nums[i] = 0
                i = j
        return False

Find Middle

Use this when you need the middle node in one pass, without knowing the length of the list first.

EasyLeetCode #876
Solution

Slow moves 1 step while fast moves 2. When fast has nothing left to jump over, it has covered the whole list while slow has covered half of it. So slow is at the middle. With an even length the loop ends one step later, which gives the second middle as the question asks.

Complexity: O(n) time, O(1) extra space

class Solution:
    def middleNode(self, head: Optional[ListNode]) -> Optional[ListNode]:
        slow = fast = head
        while fast and fast.next:
            slow, fast = slow.next, fast.next.next   # slow: 1 step, fast: 2 steps
        return slow                                  # 2nd middle for even length
MediumLeetCode #2095
Solution

To delete a node, the node before it must be known. Slow starts one node behind the head, on a dummy, while fast starts at the head. They move at the usual 1 and 2 steps, so when fast ends, slow sits exactly before the middle. Then slow.next = slow.next.next removes it. The dummy also covers a one-node list, whose only node is the middle.

Complexity: O(n) time, O(1) extra space

class Solution:
    def deleteMiddle(self, head: Optional[ListNode]) -> Optional[ListNode]:
        dummy = ListNode(0, head)                  # lets a one-node list lose its only node
        slow, fast = dummy, head                   # slow trails one node behind the middle
        while fast and fast.next:
            slow, fast = slow.next, fast.next.next
        slow.next = slow.next.next                 # unlink the middle
        return dummy.next
MediumLeetCode #2130
Solution

Node i and node n - 1 - i are twins, so the pairs run from the outside in. Fast and slow pointers find the middle, which is where the second half starts. Reversing the second half lets both halves be read from the front, so twins line up. Walk them together and keep the largest sum.

Complexity: O(n) time, O(1) extra space

class Solution:
    def pairSum(self, head: Optional[ListNode]) -> int:
        slow = fast = head
        while fast and fast.next:                  # slow lands on the start of the second half
            slow, fast = slow.next, fast.next.next
        prev = None
        while slow:                                # reverse the second half
            nxt = slow.next
            slow.next = prev
            prev, slow = slow, nxt
        best = 0
        first, second = head, prev
        while second:                              # twins are now read in step
            best = max(best, first.val + second.val)
            first, second = first.next, second.next
        return best

Remove Nth Node

Use this when you need the n-th node from the end in a single pass, by keeping two pointers a fixed gap apart.

MediumLeetCode #19
Solution

Start both pointers on a dummy node and move fast n steps ahead, which sets the gap. Then move both together until fast is on the last node. Now slow is the node right before the one to delete, so slow.next = slow.next.next removes it. The dummy makes removing the head work with no special case.

Complexity: O(n) time, O(1) extra space

class Solution:
    def removeNthFromEnd(self, head: Optional[ListNode], n: int) -> Optional[ListNode]:
        dummy = ListNode(0, head)
        slow = fast = dummy
        for _ in range(n):                         # open a gap of n nodes
            fast = fast.next
        while fast.next:                           # slide the gap to the end
            slow, fast = slow.next, fast.next
        slow.next = slow.next.next                 # slow is right before the target
        return dummy.next
MediumLeetCode #1721
Solution

Two nodes are needed: the k-th from the start and the k-th from the end. The first is found by walking k - 1 steps. For the second, keep fast on that first node and start slow at the head, so the gap is fixed at k - 1. Move both until fast reaches the last node, and slow is then the k-th node from the end. Swapping the two values is enough.

Complexity: O(n) time, O(1) extra space

class Solution:
    def swapNodes(self, head: Optional[ListNode], k: int) -> Optional[ListNode]:
        first = head
        for _ in range(k - 1):                     # k-th node from the start
            first = first.next
        slow, fast = head, first                   # fast is already k - 1 steps ahead
        while fast.next:
            slow, fast = slow.next, fast.next
        first.val, slow.val = slow.val, first.val  # slow is the k-th node from the end
        return head
MediumLeetCode #61
Solution

Rotating right by k means the last k nodes move to the front. Count the length first and reduce k with k %= length, since a full turn changes nothing. Then use the gap idea: move fast k steps ahead, and slide both until fast is on the last node. slow is now the new tail, slow.next is the new head. Cut after slow and join the old tail to the old head.

Complexity: O(n) time, O(1) extra space

class Solution:
    def rotateRight(self, head: Optional[ListNode], k: int) -> Optional[ListNode]:
        if not head or not head.next:
            return head
        length, cur = 1, head
        while cur.next:                            # count the nodes
            cur = cur.next
            length += 1
        k %= length                                # a full turn changes nothing
        if k == 0:
            return head
        slow = fast = head
        for _ in range(k):                         # gap of k nodes
            fast = fast.next
        while fast.next:                           # slow stops on the new tail
            slow, fast = slow.next, fast.next
        new_head = slow.next
        slow.next = None                           # cut after the new tail
        fast.next = head                           # old tail -> old head
        return new_head

Intersection

Use this when two lists may join into one shared tail and you must find the first shared node without extra memory.

EasyLeetCode #160
Solution

Pointer p walks list A and then list B. Pointer q walks list B and then list A. Both walk the same total distance, lenA + lenB, so the different lengths cancel out. They arrive at the shared node on the same step, or they both reach None together if the lists never join. The test p is not q ends the loop in either case.

Complexity: O(n + m) time, O(1) extra space

class Solution:
    def getIntersectionNode(self, headA: ListNode, headB: ListNode) -> Optional[ListNode]:
        p, q = headA, headB
        while p is not q:
            p = p.next if p else headB             # after A, continue on B
            q = q.next if q else headA             # after B, continue on A
        return p                                   # shared node, or None
MediumLeetCode #142
Solution

First find any node m inside the cycle with fast and slow pointers. Cut the list right after m. Now there are two lists that both end at m: one starting at head, one starting at the old m.next. They share a tail that begins exactly at the cycle entrance. Run the two-list intersection walk on them, then restore the link, and the shared node is the answer.

Complexity: O(n) time, O(1) extra space

class Solution:
    def detectCycle(self, head: Optional[ListNode]) -> Optional[ListNode]:
        slow = fast = head
        while fast and fast.next:                  # step 1: meet inside the cycle
            slow, fast = slow.next, fast.next.next
            if slow is fast:
                break
        else:
            return None                            # no cycle
        loop_start = slow.next                     # second list starts after the meeting node
        slow.next = None                           # cut: two lists, both ending at the meeting node
        p, q = head, loop_start
        while p is not q:                          # step 2: the intersection walk
            p = p.next if p else loop_start
            q = q.next if q else head
        slow.next = loop_start                     # put the cycle back
        return p                                   # first shared node = cycle entrance

Reverse in Groups

Use this when a list must be reversed block by block, with a fixed or growing block size, and a short last block needs its own rule.

MediumLeetCode #24
Solution

A pair is a group of size 2. If fewer than two nodes remain, return head unchanged. Otherwise solve the rest of the list first with a recursive call and attach it to the first node of the pair. Then flip the pair so the second node points to the first, and return the second node as the new head of this piece.

Complexity: O(n) time, O(n) space for the recursion

class Solution:
    def swapPairs(self, head: Optional[ListNode]) -> Optional[ListNode]:
        if not head or not head.next:              # fewer than 2 nodes: leave as is
            return head
        first, second = head, head.next
        first.next = self.swapPairs(second.next)   # the rest is already solved
        second.next = first                        # flip the pair
        return second
MediumLeetCode #2074
Solution

The groups have sizes 1, 2, 3, and so on, and the last group can be shorter than its size. For each group, first count how many nodes it really has. Reverse it only if that count is even. The reversal is the same flip loop as for reverse-in-k, where prev starts as the node after the group so the reversed part is already linked to the rest. before always points to the last node of the finished part.

Complexity: O(n) time, O(1) extra space

class Solution:
    def reverseEvenLengthGroups(self, head: Optional[ListNode]) -> Optional[ListNode]:
        before, size = head, 2                     # first group (size 1) never changes
        while before.next:
            node, count = before, 0
            while count < size and node.next:      # real length of this group
                node, count = node.next, count + 1
            if count % 2 == 0:
                tail = before.next                 # first node becomes the group's tail
                prev, cur = node.next, before.next # prev starts as the rest of the list
                for _ in range(count):             # reverse `count` nodes
                    cur.next, prev, cur = prev, cur, cur.next
                before.next = prev
                before = tail
            else:
                before = node
            size += 1
        return head
HardLeetCode #25
Solution

For each block, first check that k nodes really exist ahead of before, and return the list as it is if not, so a short last block stays unchanged. Then reverse exactly k nodes with the save, flip, advance loop, where prev starts as the node after the block so the reversed block is already linked to the rest. Link before to the new block head, and move before to the block's old first node, which is now its tail.

Complexity: O(n) time, O(1) extra space

class Solution:
    def reverseKGroup(self, head: Optional[ListNode], k: int) -> Optional[ListNode]:
        dummy = ListNode(0, head)
        before = dummy                             # last node of the finished part
        while True:
            node = before
            for _ in range(k):                     # is there a full group of k?
                node = node.next
                if not node:
                    return dummy.next              # short tail stays as is
            tail = before.next                     # first node becomes the group's tail
            prev, cur = node.next, before.next     # prev starts as the rest of the list
            for _ in range(k):                     # flip k nodes
                cur.next, prev, cur = prev, cur, cur.next
            before.next = prev
            before = tail
4. Stack

Last in, first out. Use it when the most recent unfinished item must be handled first.

Basic Stack

Use this when the most recent unfinished item must be handled first, such as undoing a step, evaluating postfix or processing nested groups.

EasyLeetCode #682
Solution

Each valid score is one item on the stack. A number is pushed, `C` pops the last score (undo), `D` pushes double of `scores[-1]` and `+` pushes `scores[-1] + scores[-2]`. The answer is the sum of what is left on the stack at the end.

Complexity: O(n) time, O(n) extra space

class Solution:
    def calPoints(self, operations: List[str]) -> int:
        scores = []                                  # stack of valid round scores
        for op in operations:
            if op == "+":
                scores.append(scores[-1] + scores[-2])   # peek the top two
            elif op == "D":
                scores.append(2 * scores[-1])            # peek the top
            elif op == "C":
                scores.pop()                             # undo the last score
            else:
                scores.append(int(op))                   # push a new score
        return sum(scores)
MediumLeetCode #150
Solution

Push every number onto the stack. An operator works on the two most recent numbers, so pop the right operand first, then the left, and push the result back. The single item left at the end is the answer. Division must truncate toward zero, which is why the code uses `int(a / b)`.

Complexity: O(n) time, O(n) extra space

class Solution:
    def evalRPN(self, tokens: List[str]) -> int:
        st = []                              # stack of operands
        for t in tokens:
            if t in ("+", "-", "*", "/"):
                b = st.pop()                 # right operand is on top
                a = st.pop()                 # left operand is below it
                if t == "+":
                    st.append(a + b)
                elif t == "-":
                    st.append(a - b)
                elif t == "*":
                    st.append(a * b)
                else:
                    st.append(int(a / b))    # truncate toward zero
            else:
                st.append(int(t))            # push a number
        return st[0]
HardLeetCode #224
Solution

Scan the string once, building the current number and remembering the sign of the next term. On `(` push the result so far and its sign onto the stack and start a fresh sub-total. On `)` close the sub-total, pop the saved pair and fold it in with `prev_result + prev_sign * result`. The stack remembers each unfinished outer expression, so nesting can be any depth.

Complexity: O(n) time, O(n) extra space

class Solution:
    def calculate(self, s: str) -> int:
        st = []                              # saved (result, sign) for each open "("
        result, num, sign = 0, 0, 1
        for ch in s:
            if ch.isdigit():
                num = num * 10 + int(ch)
            elif ch in "+-":
                result += sign * num         # finish the previous term
                num = 0
                sign = 1 if ch == "+" else -1
            elif ch == "(":
                st.append((result, sign))    # push the unfinished outer expression
                result, sign = 0, 1
            elif ch == ")":
                result += sign * num
                num = 0
                prev_result, prev_sign = st.pop()    # resume the outer expression
                result = prev_result + prev_sign * result
        return result + sign * num

Parentheses Matching

Use this when brackets must close in the right order: push each opener, and on a closer pop the latest opener and compare.

EasyLeetCode #20
Solution

Push every opener. When a closer arrives, the latest unclosed opener must be its partner, so pop and compare with the `pairs` map. If the stack is empty or the pair is wrong, the string is invalid. At the end the stack must be empty.

Complexity: O(n) time, O(n) extra space

class Solution:
    def isValid(self, s: str) -> bool:
        pairs = {")": "(", "]": "[", "}": "{"}
        st = []
        for ch in s:
            if ch in pairs:                          # a closer
                if not st or st.pop() != pairs[ch]:  # latest opener must match
                    return False
            else:
                st.append(ch)                        # an opener
        return not st                                # nothing left unclosed
MediumLeetCode #1249
Solution

Use the stack to hold the indices of `(` that have not found a partner yet. A `)` with an empty stack has no opener, so blank it out. After the scan, every index still on the stack is an unmatched `(`, so blank those too. What remains is valid with the fewest removals.

Complexity: O(n) time, O(n) extra space

class Solution:
    def minRemoveToMakeValid(self, s: str) -> str:
        chars = list(s)
        st = []                              # indices of "(" still waiting for a ")"
        for i, ch in enumerate(chars):
            if ch == "(":
                st.append(i)
            elif ch == ")":
                if st:
                    st.pop()                 # matched with the latest opener
                else:
                    chars[i] = ""            # no opener for this closer: drop it
        for i in st:
            chars[i] = ""                    # leftover openers: drop them
        return "".join(chars)
HardLeetCode #32
Solution

Keep indices on the stack, with `-1` at the bottom as the position just before the current valid stretch. Push the index of every `(`. On `)` pop; if the stack is now empty, this `)` is unmatched, so push its index as the new base. Otherwise the valid stretch ends at `i` and starts after `st[-1]`, so its length is `i - st[-1]`.

Complexity: O(n) time, O(n) extra space

class Solution:
    def longestValidParentheses(self, s: str) -> int:
        st = [-1]                            # index just before the current valid stretch
        best = 0
        for i, ch in enumerate(s):
            if ch == "(":
                st.append(i)
            else:
                st.pop()                     # match with the latest opener (or the base)
                if not st:
                    st.append(i)             # unmatched ")": new base
                else:
                    best = max(best, i - st[-1])
        return best

Monotonic Stack

Use this when each element's answer depends on the nearest bigger or smaller element on one side, so you keep the stack sorted and pop whatever the new value beats.

MediumLeetCode #853
Solution

Sort the cars by position and walk from the one closest to the target. Each car has an arrival time if nothing blocks it. If its time is longer than the time on top of the stack, it can never catch the fleet ahead, so it starts a new fleet and is pushed. Otherwise it joins the fleet ahead. The stack stays strictly increasing in time and its size is the number of fleets.

Complexity: O(n log n) time, O(n) extra space

class Solution:
    def carFleet(self, target: int, position: List[int], speed: List[int]) -> int:
        cars = sorted(zip(position, speed), reverse=True)   # closest to the target first
        st = []                                             # arrival time of each fleet
        for pos, spd in cars:
            t = (target - pos) / spd
            if not st or t > st[-1]:         # slower than the fleet ahead: new fleet
                st.append(t)                 # stack stays increasing
            # otherwise it catches up and merges into the fleet ahead
        return len(st)
MediumLeetCode #456
Solution

Scan from the right and keep a decreasing stack. When the current value `x` is bigger than the stack top, the popped values are smaller numbers to its right, so `x` can be the "3" and the largest popped value is the best "2", stored in `third`. Any later value that is smaller than `third` is the "1", so the answer is True. Each number is pushed and popped once.

Complexity: O(n) time, O(n) extra space

from math import inf

class Solution:
    def find132pattern(self, nums: List[int]) -> bool:
        third = -inf                         # best "2" so far: it has a bigger "3" on its left
        st = []                              # decreasing stack, scanning from the right
        for x in reversed(nums):
            if x < third:                    # x is the "1"
                return True
            while st and st[-1] < x:         # x beats these: x is their "3"
                third = st.pop()             # last pop is the largest candidate for "2"
            st.append(x)
        return False
HardLeetCode #42
Solution

Keep a stack of bar indices with decreasing heights. When a taller bar `h` arrives, each shorter bar popped is a valley floor. The new stack top is its left wall and `h` is its right wall. That layer holds width times (lower wall minus floor) of water, and the layers add up to the total.

Complexity: O(n) time, O(n) extra space

class Solution:
    def trap(self, height: List[int]) -> int:
        st = []                              # indices of bars, heights decreasing
        water = 0
        for i, h in enumerate(height):
            while st and height[st[-1]] < h: # h is taller: the top is a valley floor
                floor = st.pop()
                if not st:
                    break                    # no left wall, nothing is trapped
                left = st[-1]
                width = i - left - 1
                water += width * (min(height[left], h) - height[floor])
            st.append(i)
        return water

Next Greater Element

Use this when you need, for each element, the first larger value to its right, or how many steps away it is.

EasyLeetCode #496
Solution

Run the decreasing-stack loop over `nums2`. Whenever a new value `x` is larger than the top, `x` is the next greater element of the popped value, so record it in a dict. Values never popped have no next greater element. Then look up each value of `nums1` in the dict.

Complexity: O(n + m) time, O(n) extra space

class Solution:
    def nextGreaterElement(self, nums1: List[int], nums2: List[int]) -> List[int]:
        nxt = {}                             # value -> its next greater value in nums2
        st = []                              # decreasing stack of values
        for x in nums2:
            while st and st[-1] < x:         # x is the answer for everything it beats
                nxt[st.pop()] = x
            st.append(x)
        return [nxt.get(x, -1) for x in nums1]
MediumLeetCode #739
Solution

Keep a stack of day indices whose temperatures are decreasing, so they are the days still waiting for a warmer day. When day `i` is warmer than the top, pop that day `j` and its answer is `i - j`. Push `i` and carry on. Days left on the stack never get a warmer day and keep 0.

Complexity: O(n) time, O(n) extra space

class Solution:
    def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
        ans = [0] * len(temperatures)
        st = []                              # indices of days waiting, temperatures decreasing
        for i, t in enumerate(temperatures):
            while st and temperatures[st[-1]] < t:   # today is warmer than the top
                j = st.pop()
                ans[j] = i - j               # day j found its warmer day
            st.append(i)
        return ans
MediumLeetCode #503
Solution

The array is circular, so walk it twice (`2 * n` steps) and read values with `i % n`. Use the same decreasing stack of indices and answer each popped index with the new value. Only push indices during the first lap, so every index is pushed once. Indices still on the stack after two laps have no greater element and keep -1.

Complexity: O(n) time, O(n) extra space

class Solution:
    def nextGreaterElements(self, nums: List[int]) -> List[int]:
        n = len(nums)
        ans = [-1] * n
        st = []                              # indices, values decreasing
        for i in range(2 * n):               # two laps simulate the circle
            x = nums[i % n]
            while st and nums[st[-1]] < x:   # x is the next greater of the top
                ans[st.pop()] = x
            if i < n:
                st.append(i)                 # push only in the first lap
        return ans

Next Smaller Element

Use this when you need the first smaller value to the right, or when a bigger item must be dropped as soon as a smaller one shows up.

EasyLeetCode #1475
Solution

Keep an increasing stack of indices, flipping the rule from next greater to next smaller. When the current price `p` is less than or equal to the top price, `p` is the discount for that item, so pop it and subtract. Items never popped get no discount and keep their full price.

Complexity: O(n) time, O(n) extra space

class Solution:
    def finalPrices(self, prices: List[int]) -> List[int]:
        ans = prices[:]                      # full price unless a discount is found
        st = []                              # indices, prices increasing
        for i, p in enumerate(prices):
            while st and prices[st[-1]] >= p:    # p is the next smaller-or-equal price
                j = st.pop()
                ans[j] = prices[j] - p       # apply the discount
            st.append(i)
        return ans
MediumLeetCode #402
Solution

To make the number small, a digit that is bigger than the one after it should go. Keep an increasing stack of digits. When the new digit is smaller than the top, pop the top (it just found its next smaller digit) and use up one removal. If removals are left at the end, cut them from the tail, then strip leading zeros.

Complexity: O(n) time, O(n) extra space

class Solution:
    def removeKdigits(self, num: str, k: int) -> str:
        st = []                              # digits, non-decreasing
        for d in num:
            while k and st and st[-1] > d:   # d is smaller: drop the bigger digit before it
                st.pop()
                k -= 1
            st.append(d)
        if k:
            st = st[:-k]                     # stack is non-decreasing, so cut the largest tail
        return "".join(st).lstrip("0") or "0"
MediumLeetCode #316
Solution

Build the smallest result with an increasing stack of letters, using the next-smaller rule: a bigger letter on top is dropped when a smaller letter arrives. The extra idea is that a letter may only be dropped if it appears again later, which `last[...] > i` checks. The `seen` set keeps each letter in the stack at most once.

Complexity: O(n) time, O(1) extra space (26 letters)

class Solution:
    def removeDuplicateLetters(self, s: str) -> str:
        last = {c: i for i, c in enumerate(s)}   # last position of each letter
        st, seen = [], set()
        for i, c in enumerate(s):
            if c in seen:
                continue                     # already placed in the best spot
            while st and st[-1] > c and last[st[-1]] > i:   # bigger on top and it comes again later
                seen.discard(st.pop())
            st.append(c)
            seen.add(c)
        return "".join(st)

Previous Greater/Smaller

Use this when each element needs the nearest smaller or bigger value on its left, which is what sits on top of the stack right after popping.

MediumLeetCode #907
Solution

Count how many subarrays have `arr[mid]` as their minimum. Keep an increasing stack. When `mid` is popped by index `i`, the top after popping is the previous smaller element, so `left` is read from `st[-1]`, and `i` is the next smaller. `mid` is the minimum of `(mid - left) * (i - mid)` subarrays. A sentinel at the end pops everything.

Complexity: O(n) time, O(n) extra space

class Solution:
    def sumSubarrayMins(self, arr: List[int]) -> int:
        st, total = [], 0                    # st: indices, values increasing
        for i in range(len(arr) + 1):
            cur = arr[i] if i < len(arr) else -1     # sentinel pops everything
            while st and arr[st[-1]] > cur:  # cur is the next smaller of the top
                mid = st.pop()
                left = st[-1] if st else -1  # previous smaller: top after popping
                total += arr[mid] * (mid - left) * (i - mid)
            st.append(i)
        return total % (10**9 + 7)
MediumLeetCode #1856
Solution

Treat each element as the minimum of a subarray and stretch it as far as it stays the minimum. When `mid` is popped by index `i`, the previous smaller element is `st[-1]` and the next smaller is `i`. The subarray runs from `st[-1] + 1` to `i - 1`, and a prefix-sum array gives its sum in O(1). Take the best `nums[mid] * sum`, and apply the modulo only at the end.

Complexity: O(n) time, O(n) extra space

class Solution:
    def maxSumMinProduct(self, nums: List[int]) -> int:
        prefix = [0]
        for x in nums:
            prefix.append(prefix[-1] + x)    # prefix[i] = sum of nums[:i]
        st, best = [], 0                     # st: indices, values increasing
        for i in range(len(nums) + 1):
            cur = nums[i] if i < len(nums) else 0    # sentinel 0 is below every value
            while st and nums[st[-1]] >= cur:        # cur is the next smaller (or equal)
                mid = st.pop()
                left = st[-1] + 1 if st else 0       # just after the previous smaller
                best = max(best, nums[mid] * (prefix[i] - prefix[left]))
            st.append(i)
        return best % (10**9 + 7)
MediumLeetCode #2104
Solution

A subarray's range is its max minus its min, so the answer is the sum of all maxes minus the sum of all mins. The sum of mins comes from the previous-smaller and next-smaller stack loop. The max of a subarray is the min of the negated array, so the same helper gives the sum of maxes. Two passes of the same loop solve the problem in O(n).

Complexity: O(n) time, O(n) extra space

from math import inf

class Solution:
    def subArrayRanges(self, nums: List[int]) -> int:
        def sum_of_mins(a):
            st, total = [], 0                # st: indices, values increasing
            for i in range(len(a) + 1):
                cur = a[i] if i < len(a) else -inf   # sentinel pops everything
                while st and a[st[-1]] > cur:
                    mid = st.pop()
                    left = st[-1] if st else -1      # previous smaller: top after popping
                    total += a[mid] * (mid - left) * (i - mid)
                st.append(i)
            return total

        # max of a subarray is the min of the negated array
        return -sum_of_mins([-x for x in nums]) - sum_of_mins(nums)

Stock Span

Use this when you need how many consecutive earlier items are not bigger than the current one, which is how far you can stretch to the left.

MediumLeetCode #901
Solution

Store `(price, span)` pairs on a decreasing stack. A new price swallows every earlier day whose price is less than or equal to it, so pop them and add their spans to today's span of 1. Push the new pair. Each price is pushed and popped at most once, so the cost per call is amortised O(1).

Complexity: O(1) amortised per call, O(n) extra space

class StockSpanner:
    def __init__(self):
        self.st = []                         # (price, span), prices strictly decreasing

    def next(self, price: int) -> int:
        span = 1                             # today counts
        while self.st and self.st[-1][0] <= price:
            span += self.st.pop()[1]         # absorb the span of each smaller-or-equal day
        self.st.append((price, span))
        return span
MediumLeetCode #795
Solution

The stock span of `nums[i]` counts the subarrays ending at `i` where `nums[i]` is the maximum: `i - p`, with `p` the previous greater index. Those count only if `nums[i]` lies in `[left, right]`. Every subarray that starts at or before `p` has the same maximum as the ones ending at `p`, so add `ways[p]`. The stack finds `p` for each index.

Complexity: O(n) time, O(n) extra space

class Solution:
    def numSubarrayBoundedMax(self, nums: List[int], left: int, right: int) -> int:
        st = []                              # indices, values strictly decreasing
        ways = [0] * len(nums)               # ways[i]: good subarrays ending at i
        for i, x in enumerate(nums):
            while st and nums[st[-1]] <= x:  # pop days that are not greater than x
                st.pop()
            p = st[-1] if st else -1         # previous greater element
            span = i - p                     # stock span of x
            ways[i] = span if left <= x <= right else 0
            if p >= 0:
                ways[i] += ways[p]           # longer subarrays keep the maximum of p
            st.append(i)
        return sum(ways)
MediumLeetCode #1504
Solution

Turn each row into a histogram of consecutive ones above it. For column `j`, count the all-ones rectangles whose bottom-right corner is at `(row, j)`. The stack gives `p`, the previous column with a smaller height, which is the stock span idea. Rectangles within `(p, j]` number `heights[j] * (j - p)`, and those reaching past `p` are already counted in `count[p]`.

Complexity: O(m * n) time, O(n) extra space

class Solution:
    def numSubmat(self, mat: List[List[int]]) -> int:
        n = len(mat[0])
        heights = [0] * n
        answer = 0
        for row in mat:
            for j in range(n):
                heights[j] = heights[j] + 1 if row[j] else 0   # histogram for this row
            st = []                          # column indices, heights increasing
            count = [0] * n                  # rectangles with bottom-right corner (row, j)
            for j in range(n):
                while st and heights[st[-1]] >= heights[j]:
                    st.pop()
                if st:
                    p = st[-1]               # previous column with a smaller height
                    count[j] = count[p] + heights[j] * (j - p)
                else:
                    count[j] = heights[j] * (j + 1)    # stretches to the left edge
                st.append(j)
                answer += count[j]
        return answer

Largest Rectangle in Histogram

Use this when each bar is the shortest in some rectangle and you need how far that rectangle can stretch to the left and right.

HardLeetCode #84
Solution

Keep an increasing stack of bar indices. When a shorter bar at `i` arrives, the popped bar has found its right limit `i`, and its left limit is the new top `st[-1]` (or -1). The widest rectangle with the popped bar as the shortest is `height * (i - left - 1)`. Appending a 0 as a sentinel forces every remaining bar to be popped.

Complexity: O(n) time, O(n) extra space

class Solution:
    def largestRectangleArea(self, heights: List[int]) -> int:
        st, best = [], 0                     # st: indices of bars, heights increasing
        for i, h in enumerate(heights + [0]):    # sentinel 0 flushes the stack
            while st and heights[st[-1]] > h:    # bar i is the next smaller of the top
                height = heights[st.pop()]
                left = st[-1] if st else -1      # previous smaller bar
                best = max(best, height * (i - left - 1))
            st.append(i)
        return best
HardLeetCode #85
Solution

Build a histogram row by row: a column grows by 1 on a "1" and resets to 0 on a "0". After each row, the biggest all-ones rectangle that ends on that row is the largest rectangle in that histogram. Reuse the same stack routine for every row and keep the best.

Complexity: O(rows * cols) time, O(cols) extra space

class Solution:
    def maximalRectangle(self, matrix: List[List[str]]) -> int:
        if not matrix:
            return 0
        heights = [0] * len(matrix[0])
        best = 0
        for row in matrix:
            for j, cell in enumerate(row):
                heights[j] = heights[j] + 1 if cell == "1" else 0   # histogram for this row
            best = max(best, self.largest_in_histogram(heights))
        return best

    def largest_in_histogram(self, heights: List[int]) -> int:
        st, best = [], 0                     # st: indices of bars, heights increasing
        for i, h in enumerate(heights + [0]):    # sentinel 0 flushes the stack
            while st and heights[st[-1]] > h:
                height = heights[st.pop()]
                left = st[-1] if st else -1
                best = max(best, height * (i - left - 1))
            st.append(i)
        return best
HardLeetCode #1793
Solution

The score is `min * length` of a subarray that must contain index `k`, which is a rectangle in the histogram `nums`. Run the largest-rectangle loop, but only count a rectangle when it covers `k`, that is `left < k < i`. The popped bar is the minimum of that rectangle, and its width is `i - left - 1`.

Complexity: O(n) time, O(n) extra space

class Solution:
    def maximumScore(self, nums: List[int], k: int) -> int:
        st, best = [], 0                     # st: indices of bars, heights increasing
        for i, h in enumerate(nums + [0]):   # sentinel 0 flushes the stack
            while st and nums[st[-1]] > h:
                mid = st.pop()               # mid is the minimum of the rectangle
                left = st[-1] if st else -1  # previous smaller bar
                if left < k < i:             # the rectangle must contain index k
                    best = max(best, nums[mid] * (i - left - 1))
            st.append(i)
        return best

Min Stack

Use this when a stack must also answer a question about everything beneath its top, such as the minimum or the most frequent value, in O(1).

MediumLeetCode #155
Solution

Store a pair `(value, min_so_far)` in every entry. The new minimum is the smaller of the pushed value and the minimum of the entry below it. `pop` just removes the top pair, which restores the earlier minimum automatically. `getMin` reads the second item of the top pair.

Complexity: O(1) time per operation, O(n) extra space

class MinStack:
    def __init__(self):
        self.st = []                         # (value, min_so_far)

    def push(self, val: int) -> None:
        m = min(val, self.st[-1][1]) if self.st else val   # min of everything beneath, plus val
        self.st.append((val, m))

    def pop(self) -> None:
        self.st.pop()

    def top(self) -> int:
        return self.st[-1][0]

    def getMin(self) -> int:
        return self.st[-1][1]                # the top remembers the minimum
MediumLeetCode #1381
Solution

Same idea as Min Stack: keep extra information next to each entry. `inc[i]` is a pending add that applies to `st[0..i]`. `increment` only changes one cell, so it is O(1). On `pop`, add the pending value to the popped item and pass it down to the entry below, so it is still applied to the rest.

Complexity: O(1) time per operation, O(maxSize) extra space

class CustomStack:
    def __init__(self, maxSize: int):
        self.max_size = maxSize
        self.st = []
        self.inc = []                        # inc[i]: pending add for st[0..i]

    def push(self, x: int) -> None:
        if len(self.st) < self.max_size:
            self.st.append(x)
            self.inc.append(0)

    def pop(self) -> int:
        if not self.st:
            return -1
        add = self.inc.pop()
        if self.inc:
            self.inc[-1] += add              # hand the pending add to the entry below
        return self.st.pop() + add

    def increment(self, k: int, val: int) -> None:
        i = min(k, len(self.st)) - 1
        if i >= 0:
            self.inc[i] += val               # remember it in one cell: O(1)
HardLeetCode #895
Solution

The stack must answer "most frequent value, ties by most recent". Keep one stack per frequency: when a value reaches count `f`, push it onto `groups[f]`. The top of `groups[max_freq]` is the answer, and the value stays in the lower groups for its smaller counts. Each entry stores what is needed to answer in O(1), like the Min Stack idea.

Complexity: O(1) time per operation, O(n) extra space

from collections import defaultdict

class FreqStack:
    def __init__(self):
        self.freq = defaultdict(int)         # value -> current count
        self.groups = defaultdict(list)      # count -> stack of values that reached it
        self.max_freq = 0

    def push(self, val: int) -> None:
        self.freq[val] += 1
        f = self.freq[val]
        self.max_freq = max(self.max_freq, f)
        self.groups[f].append(val)           # val joins the stack for its new count

    def pop(self) -> int:
        val = self.groups[self.max_freq].pop()   # most recent among the most frequent
        self.freq[val] -= 1
        if not self.groups[self.max_freq]:
            self.max_freq -= 1               # that frequency level is now empty
        return val
5. Queue / Deque

First in, first out (queue), or add/remove at both ends (deque).

Basic Queue

Use this when items must be handled in the order they arrived, or when old items expire from the front while new ones join at the back.

EasyLeetCode #933
Solution

Every ping joins the back of a deque, and the deque always holds the calls from the last 3000 ms.
Calls are added in increasing time order, so the oldest call is always at the front.
The while loop pops expired calls from the front with popleft, which is O(1). The answer is the size of the deque.

Complexity: O(1) amortized per ping, O(W) space where W is the number of calls in the window

from collections import deque

class RecentCounter:
    def __init__(self):
        self.q = deque()

    def ping(self, t: int) -> int:
        self.q.append(t)                    # newest call joins the back
        while self.q[0] < t - 3000:         # oldest calls leave from the front
            self.q.popleft()
        return len(self.q)
EasyLeetCode #225
Solution

A queue gives out the oldest item first, but a stack must give out the newest item first.
After each push, rotate the queue: move every older item from the front to the back, so the new item ends up at the front.
Then pop is just popleft and top is the front item. The rotation loop in push is the key line.

Complexity: push O(n), pop O(1), top O(1), empty O(1); O(n) space

from collections import deque

class MyStack:
    def __init__(self):
        self.q = deque()

    def push(self, x: int) -> None:
        self.q.append(x)
        for _ in range(len(self.q) - 1):         # send every older item behind x
            self.q.append(self.q.popleft())

    def pop(self) -> int:
        return self.q.popleft()                  # the front is the newest item

    def top(self) -> int:
        return self.q[0]

    def empty(self) -> bool:
        return not self.q
MediumLeetCode #649
Solution

Keep two queues holding the positions of the Radiant and Dire senators, in voting order.
Each round, take the front senator of each queue. The one with the smaller position votes first and bans the other.
The winner goes back to the end of its queue with position + n, which means "I vote again next round". The senator who was banned is not re-added.
The loop ends when one queue is empty.

Complexity: O(n) time, O(n) space

from collections import deque

class Solution:
    def predictPartyVictory(self, senate: str) -> str:
        n = len(senate)
        radiant, dire = deque(), deque()
        for i, party in enumerate(senate):
            (radiant if party == "R" else dire).append(i)
        while radiant and dire:
            r, d = radiant.popleft(), dire.popleft()
            if r < d:
                radiant.append(r + n)       # winner re-joins the back for the next round
            else:
                dire.append(d + n)
        return "Radiant" if radiant else "Dire"

Circular Queue

Use this when you need a fixed-size buffer that reuses freed slots by wrapping indices with modulo, or when positions on a ring wrap around.

MediumLeetCode #622
Solution

Store the items in a fixed array and track only head (index of the front item) and size.
The slot for a new item is (head + size) % cap, so after the end of the array it wraps back to index 0 and reuses freed slots.
Tracking size tells full (size == cap) from empty (size == 0), which two indices alone cannot do.

Complexity: O(1) time per operation, O(k) space

class MyCircularQueue:
    def __init__(self, k: int):
        self.data = [0] * k
        self.cap = k
        self.head = 0                  # index of the front item
        self.size = 0                  # track size to tell full from empty

    def enQueue(self, value: int) -> bool:
        if self.isFull():
            return False
        self.data[(self.head + self.size) % self.cap] = value    # wrap with modulo
        self.size += 1
        return True

    def deQueue(self) -> bool:
        if self.isEmpty():
            return False
        self.head = (self.head + 1) % self.cap                   # freed slot is reused later
        self.size -= 1
        return True

    def Front(self) -> int:
        return -1 if self.isEmpty() else self.data[self.head]

    def Rear(self) -> int:
        return -1 if self.isEmpty() else self.data[(self.head + self.size - 1) % self.cap]

    def isEmpty(self) -> bool:
        return self.size == 0

    def isFull(self) -> bool:
        return self.size == self.cap
MediumLeetCode #641
Solution

Same fixed array with head and size, but now both ends can grow.
Insert at the back writes to (head + size) % cap. Insert at the front first moves head one step left with (head - 1) % cap, which wraps from 0 to the last index.
Deleting from the back only shrinks size. Deleting from the front moves head right.

Complexity: O(1) time per operation, O(k) space

class MyCircularDeque:
    def __init__(self, k: int):
        self.data = [0] * k
        self.cap = k
        self.head = 0
        self.size = 0

    def insertFront(self, value: int) -> bool:
        if self.isFull():
            return False
        self.head = (self.head - 1) % self.cap      # step left, wrapping 0 -> cap - 1
        self.data[self.head] = value
        self.size += 1
        return True

    def insertLast(self, value: int) -> bool:
        if self.isFull():
            return False
        self.data[(self.head + self.size) % self.cap] = value
        self.size += 1
        return True

    def deleteFront(self) -> bool:
        if self.isEmpty():
            return False
        self.head = (self.head + 1) % self.cap
        self.size -= 1
        return True

    def deleteLast(self) -> bool:
        if self.isEmpty():
            return False
        self.size -= 1
        return True

    def getFront(self) -> int:
        return -1 if self.isEmpty() else self.data[self.head]

    def getRear(self) -> int:
        return -1 if self.isEmpty() else self.data[(self.head + self.size - 1) % self.cap]

    def isEmpty(self) -> bool:
        return self.size == 0

    def isFull(self) -> bool:
        return self.size == self.cap
MediumLeetCode #1823
Solution

The friends sit on a ring, so counting k steps from the current spot must wrap past the end of the list.
The line idx = (idx + k - 1) % len(friends) is the same modulo wrap as in the circular queue.
That index is the friend who leaves. Counting restarts at the friend now sitting at the same index, so idx is kept as it is.
Repeat until one friend remains.

Complexity: O(n^2) time (each pop shifts the list), O(n) space

class Solution:
    def findTheWinner(self, n: int, k: int) -> int:
        friends = list(range(1, n + 1))
        idx = 0
        while len(friends) > 1:
            idx = (idx + k - 1) % len(friends)    # walk k steps around the ring
            friends.pop(idx)                      # next count starts at this same index
        return friends[0]

Sliding Window Maximum

Use this when you need the maximum of every window of size k as it slides across an array, or the best earlier value inside a window that moves with the current position.

HardLeetCode #239
Solution

Keep a deque of indices whose values are decreasing, so the front index is always the window maximum.
For each new x, pop smaller values off the back, because they can never be a maximum again while x is in the window.
Pop the front if its index has left the window (dq[0] <= i - k). Once i >= k - 1, record nums[dq[0]].
Each index enters and leaves the deque once, so the whole pass is O(n) instead of O(nk).

Complexity: O(n) time, O(k) extra space

from collections import deque

class Solution:
    def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
        dq, out = deque(), []                  # dq holds indices, values decreasing
        for i, x in enumerate(nums):
            while dq and nums[dq[-1]] <= x:    # smaller values behind x are useless
                dq.pop()
            dq.append(i)
            if dq[0] <= i - k:                 # front index left the window
                dq.popleft()
            if i >= k - 1:
                out.append(nums[dq[0]])        # front is the window maximum
        return out
HardLeetCode #1425
Solution

Let dp[i] be the best sum of a valid subsequence that ends at index i. Then dp[i] = nums[i] + max(0, best dp among the previous k indices).
That "best dp in the last k indices" is a sliding window maximum, so keep a deque of indices with decreasing dp values.
Pop the front when it is more than k behind i, read the maximum from dp[dq[0]], then push i after popping smaller dp values.
The answer is the largest dp[i].

Complexity: O(n) time, O(n) space

from math import inf

from collections import deque

class Solution:
    def constrainedSubsetSum(self, nums: List[int], k: int) -> int:
        dp = [0] * len(nums)
        dq = deque()                              # indices, dp values decreasing
        best = -inf
        for i, x in enumerate(nums):
            if dq and dq[0] < i - k:              # front is more than k behind
                dq.popleft()
            dp[i] = x + (dp[dq[0]] if dq and dp[dq[0]] > 0 else 0)
            while dq and dp[dq[-1]] <= dp[i]:     # smaller dp values are never the max again
                dq.pop()
            dq.append(i)
            best = max(best, dp[i])
        return best
HardLeetCode #1499
Solution

For i < j the value is yi + yj + xj - xi, which splits into (yi - xi) + (yj + xj). For each j we want the largest (yi - xi) among earlier points with xj - xi <= k.
That is a sliding window maximum, where the window is defined by the x distance instead of by index count.
The deque keeps (yi - xi, xi) with decreasing first values. Pop from the front while xj - xi > k, then the front gives the best partner for j.

Complexity: O(n) time, O(n) space

from math import inf

from collections import deque

class Solution:
    def findMaxValueOfEquation(self, points: List[List[int]], k: int) -> int:
        dq = deque()                              # (y - x, x), y - x decreasing
        best = -inf
        for x, y in points:
            while dq and x - dq[0][1] > k:        # too far to the left
                dq.popleft()
            if dq:
                best = max(best, dq[0][0] + y + x)    # best (yi - xi) + (yj + xj)
            while dq and dq[-1][0] <= y - x:      # smaller y - x is never the max again
                dq.pop()
            dq.append((y - x, x))
        return best

Monotonic Deque

Use this when you must keep reading the minimum or maximum of a range whose left edge moves, such as a variable window or a range of earlier prefix sums.

MediumLeetCode #1438
Solution

A window is valid when its max minus its min is at most limit. Keep two deques of indices: one with decreasing values (front = window max) and one with increasing values (front = window min).
Move right one step at a time. While the window is invalid, move left forward and pop any front index that fell off the left edge.
The longest valid window seen is the answer.

Complexity: O(n) time, O(n) space

from collections import deque

class Solution:
    def longestSubarray(self, nums: List[int], limit: int) -> int:
        maxq, minq = deque(), deque()          # indices; maxq values decreasing, minq increasing
        left = best = 0
        for right, x in enumerate(nums):
            while maxq and nums[maxq[-1]] <= x:
                maxq.pop()
            maxq.append(right)
            while minq and nums[minq[-1]] >= x:
                minq.pop()
            minq.append(right)
            while nums[maxq[0]] - nums[minq[0]] > limit:    # window invalid: shrink from the left
                left += 1
                if maxq[0] < left:
                    maxq.popleft()
                if minq[0] < left:
                    minq.popleft()
            best = max(best, right - left + 1)
        return best
MediumLeetCode #918
Solution

A circular subarray is an ordinary subarray of nums + nums that is at most n long. Build prefix sums over the doubled array.
For each end j, the best sum is prefix[j] minus the smallest prefix[i] with j - n <= i < j. That is a window minimum over prefix values.
Keep a deque of indices with increasing prefix values. Pop the front when it is more than n behind j, and read the minimum from the front.

Complexity: O(n) time, O(n) space

from math import inf

from collections import deque

class Solution:
    def maxSubarraySumCircular(self, nums: List[int]) -> int:
        n = len(nums)
        prefix = [0]
        for x in nums + nums:                     # prefix sums of the doubled array
            prefix.append(prefix[-1] + x)
        dq = deque([0])                           # indices, prefix values increasing
        best = -inf
        for j in range(1, 2 * n + 1):
            if dq[0] < j - n:                     # subarray would be longer than n
                dq.popleft()
            best = max(best, prefix[j] - prefix[dq[0]])    # front = smallest prefix in range
            while dq and prefix[dq[-1]] >= prefix[j]:
                dq.pop()
            dq.append(j)
        return best
HardLeetCode #862
Solution

With prefix sums, a subarray (i, j] has sum prefix[j] - prefix[i]. We want the closest i for which this is at least k. Negative numbers rule out a plain two-pointer window.
Keep a deque of indices with increasing prefix values. While the front gives sum >= k, record j - i and pop it: any later j would only be longer.
Before pushing j, pop back indices whose prefix is >= prefix[j]. They start longer and smaller-sum subarrays than j, so they are never better.

Complexity: O(n) time, O(n) space

from math import inf

from collections import deque

class Solution:
    def shortestSubarray(self, nums: List[int], k: int) -> int:
        prefix = [0]
        for x in nums:
            prefix.append(prefix[-1] + x)
        dq = deque()                               # indices, prefix values increasing
        best = inf
        for j, p in enumerate(prefix):
            while dq and p - prefix[dq[0]] >= k:   # valid: the front cannot do better later
                best = min(best, j - dq.popleft())
            while dq and prefix[dq[-1]] >= p:      # keep the deque increasing
                dq.pop()
            dq.append(j)
        return best if best != inf else -1

BFS using Queue

Use this when you need the fewest steps in an unweighted graph, grid or word-transformation puzzle, or must visit nodes level by level.

EasyLeetCode #1971
Solution

Build an adjacency list, then run BFS from the source with a queue and a seen set.
Mark a node as seen when you push it, not when you pop it, so no node enters the queue twice.
If the destination is ever popped, a path exists. If the queue empties first, it does not.

Complexity: O(V + E) time, O(V + E) space

from collections import deque

class Solution:
    def validPath(self, n: int, edges: List[List[int]], source: int, destination: int) -> bool:
        adj = [[] for _ in range(n)]
        for a, b in edges:
            adj[a].append(b)
            adj[b].append(a)
        seen, q = {source}, deque([source])
        while q:
            u = q.popleft()
            if u == destination:
                return True
            for v in adj[u]:
                if v not in seen:
                    seen.add(v)                   # mark seen on push
                    q.append(v)
        return False
MediumLeetCode #994
Solution

Put every rotten orange in the queue first (multi-source BFS), and count the fresh ones.
Each pass of the for _ in range(len(q)) loop handles one level of the queue, which is one minute of rotting spread.
A fresh neighbour is turned rotten and pushed in the same step, so it is never queued twice.
If fresh oranges are left when the queue empties, return -1.

Complexity: O(rows * cols) time and space

from collections import deque

class Solution:
    def orangesRotting(self, grid: List[List[int]]) -> int:
        rows, cols = len(grid), len(grid[0])
        q, fresh = deque(), 0
        for r in range(rows):
            for c in range(cols):
                if grid[r][c] == 2:
                    q.append((r, c))              # all rotten oranges start together
                elif grid[r][c] == 1:
                    fresh += 1
        minutes = 0
        while q and fresh:
            for _ in range(len(q)):               # one level = one minute
                r, c = q.popleft()
                for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
                    nr, nc = r + dr, c + dc
                    if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
                        grid[nr][nc] = 2          # mark seen on push
                        fresh -= 1
                        q.append((nr, nc))
            minutes += 1
        return minutes if fresh == 0 else -1
HardLeetCode #127
Solution

Treat each word as a node, with an edge to every word that differs by one letter. The shortest ladder is the shortest path in an unweighted graph, so BFS finds it.
Neighbours are not stored: for each position try all 26 letters and keep the result if it is in the word set and not yet seen.
Process the queue level by level, with steps counting words in the ladder. The first time endWord is popped, steps is the answer.

Complexity: O(N * L^2 * 26) time, O(N * L) space (N words of length L)

from collections import deque

class Solution:
    def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int:
        words = set(wordList)
        if endWord not in words:
            return 0
        q, seen = deque([beginWord]), {beginWord}
        steps = 1
        while q:
            for _ in range(len(q)):               # one level = one more word in the ladder
                word = q.popleft()
                if word == endWord:
                    return steps
                for i in range(len(word)):
                    for ch in "abcdefghijklmnopqrstuvwxyz":
                        nxt = word[:i] + ch + word[i + 1:]
                        if nxt in words and nxt not in seen:
                            seen.add(nxt)         # mark seen on push
                            q.append(nxt)
            steps += 1
        return 0
6. HashMap / HashSet

Trade memory for speed: O(1) average lookup turns many O(n²) searches into O(n).

Frequency Counting

Use this when the question asks "how many times", the majority element or the top K most frequent items.

EasyLeetCode #169
Solution

Count every value once with Counter. The majority element appears more than n/2 times, so it has the highest count.
The key line is `max(counts, key=counts.get)`, which picks the value with the biggest count.

Complexity: O(n) time, O(n) extra space

class Solution:
    def majorityElement(self, nums: List[int]) -> int:
        counts = Counter(nums)                  # frequency map built in one pass
        return max(counts, key=counts.get)      # the value with the highest count
MediumLeetCode #347
Solution

Build the frequency map first, then group values by how often they occur. `buckets[freq]` holds all values with that count.
A count can never exceed n, so the buckets are an array of size n + 1. Reading it from the highest index down gives the most frequent values first, without sorting.

Complexity: O(n) time, O(n) extra space

class Solution:
    def topKFrequent(self, nums: List[int], k: int) -> List[int]:
        counts = Counter(nums)                          # value -> frequency
        buckets = [[] for _ in range(len(nums) + 1)]    # index = frequency
        for value, freq in counts.items():
            buckets[freq].append(value)
        result = []
        for freq in range(len(buckets) - 1, 0, -1):     # highest frequency first
            for value in buckets[freq]:
                result.append(value)
                if len(result) == k:
                    return result
        return result
HardLeetCode #895
Solution

Keep two maps. `freq[val]` counts how many copies of val are in the stack. `stacks[f]` is a stack of the values that reached frequency f, in push order.
On push, bump the count and add the value to the stack for its new frequency. On pop, take from the stack at the highest frequency, so ties go to the most recent push.
The key lines are `self.stacks[f].append(val)` and `self.stacks[self.max_freq].pop()`.

Complexity: O(1) time per operation, O(n) extra space

from collections import defaultdict

class FreqStack:
    def __init__(self):
        self.freq = Counter()                # value -> how many copies are in the stack
        self.stacks = defaultdict(list)      # frequency f -> values in the order they reached f
        self.max_freq = 0

    def push(self, val: int) -> None:
        self.freq[val] += 1
        f = self.freq[val]
        self.max_freq = max(self.max_freq, f)
        self.stacks[f].append(val)           # val now also lives at level f

    def pop(self) -> int:
        val = self.stacks[self.max_freq].pop()   # most frequent, most recent
        self.freq[val] -= 1
        if not self.stacks[self.max_freq]:
            self.max_freq -= 1               # that level is empty, drop one level
        return val

Duplicate Detection

Use this when the question asks "is there any repeat?" or "what is the first repeat?".

EasyLeetCode #217
Solution

Walk the array and keep a set of values already seen. If the current value is already in the set, a repeat exists, so return True.
The key line is `if x in seen`. A set lookup is O(1), so one pass is enough.

Complexity: O(n) time, O(n) extra space

class Solution:
    def containsDuplicate(self, nums: List[int]) -> bool:
        seen = set()
        for x in nums:
            if x in seen:             # seen before: found a duplicate
                return True
            seen.add(x)
        return False
EasyLeetCode #219
Solution

A set is not enough here because the two equal values must also be close. Use a dict from value to the last index where it appeared.
When x shows up again, compare `i - last_index[x]` with k. Always overwrite the stored index with the latest one, because the latest copy is the closest to any later copy.

Complexity: O(n) time, O(n) extra space

class Solution:
    def containsNearbyDuplicate(self, nums: List[int], k: int) -> bool:
        last_index = {}                                    # value -> latest index
        for i, x in enumerate(nums):
            if x in last_index and i - last_index[x] <= k:   # repeat that is close enough
                return True
            last_index[x] = i                              # keep only the newest position
        return False
HardLeetCode #220
Solution

Now "equal" becomes "within valueDiff", so a plain set fails. Split the number line into buckets of width valueDiff + 1. Two numbers in the same bucket are always close enough.
A close pair can also sit in neighbouring buckets, so check bucket b - 1 and b + 1 by value. The dict holds only the last indexDiff numbers, so old buckets are deleted as the window slides.

Complexity: O(n) time, O(indexDiff) extra space

class Solution:
    def containsNearbyAlmostDuplicate(self, nums: List[int], indexDiff: int, valueDiff: int) -> bool:
        width = valueDiff + 1            # two numbers in one bucket differ by at most valueDiff
        buckets = {}                     # bucket id -> the one value in the window
        for i, x in enumerate(nums):
            b = x // width               # floor division keeps negatives in the right bucket
            if b in buckets:
                return True
            if b - 1 in buckets and x - buckets[b - 1] <= valueDiff:
                return True
            if b + 1 in buckets and buckets[b + 1] - x <= valueDiff:
                return True
            buckets[b] = x
            if i >= indexDiff:           # slide the window: forget nums[i - indexDiff]
                del buckets[nums[i - indexDiff] // width]
        return False

Two Sum

Use this when you need two items that combine to a target, and the input is unsorted.

EasyLeetCode #1
Solution

For each x, ask whether target - x has already been seen. Store value to index in a dict as you scan.
Insert x only AFTER the check, so an element can never pair with itself. This turns the O(n^2) pair search into one pass.

Complexity: O(n) time, O(n) extra space

class Solution:
    def twoSum(self, nums: List[int], target: int) -> List[int]:
        idx = {}                                   # value -> index
        for i, x in enumerate(nums):
            if target - x in idx:                  # complement already seen
                return [idx[target - x], i]
            idx[x] = i                             # insert AFTER checking: no self-pairing
        return []
MediumLeetCode #532
Solution

This is two sum with a difference. For each distinct x, the partner is x + k, so look it up in a Counter instead of a set.
Count each pair once by only looking upward (x + k, never x - k). When k is 0 the partner is x itself, so the value must appear at least twice.

Complexity: O(n) time, O(n) extra space

class Solution:
    def findPairs(self, nums: List[int], k: int) -> int:
        counts = Counter(nums)
        pairs = 0
        for x in counts:                       # each distinct value once
            if k == 0:
                if counts[x] > 1:              # needs a second copy of itself
                    pairs += 1
            elif x + k in counts:              # look up the complement x + k
                pairs += 1
        return pairs
MediumLeetCode #454
Solution

Four arrays are too many to search at once, so split them into two halves. Count every a + b from the first two arrays in a Counter.
Then for every c + d from the last two arrays, add the count of -(c + d). Each lookup is the two-sum complement idea, applied to pair sums.

Complexity: O(n^2) time, O(n^2) extra space

class Solution:
    def fourSumCount(self, nums1: List[int], nums2: List[int], nums3: List[int], nums4: List[int]) -> int:
        pair_sums = Counter(a + b for a in nums1 for b in nums2)   # every a + b and how often it occurs
        count = 0
        for c in nums3:
            for d in nums4:
                count += pair_sums[-(c + d)]       # complement lookup: a + b = -(c + d)
        return count

Prefix Sum + HashMap

Use this when you must count sub-arrays whose sum meets a condition and the numbers may be negative.

MediumLeetCode #560
Solution

A sub-array sum equals k when two prefix sums differ by k: P[j] - P[i] = k. So for each new prefix, count how many earlier prefixes equal total - k.
The dict counts every prefix seen so far. Seed it with {0: 1} for the empty prefix so sub-arrays starting at index 0 are counted.
Negatives are fine because only equal prefix values matter, not growth of the sum.

Complexity: O(n) time, O(n) extra space

class Solution:
    def subarraySum(self, nums: List[int], k: int) -> int:
        count, total, seen = 0, 0, {0: 1}          # {0: 1} = the empty prefix
        for x in nums:
            total += x
            count += seen.get(total - k, 0)        # earlier prefixes that end a sub-array summing to k
            seen[total] = seen.get(total, 0) + 1
        return count
MediumLeetCode #974
Solution

Two prefixes give a sub-array divisible by k when they have the same remainder mod k. So store prefix remainders instead of raw prefix sums.
For each new remainder, add how many earlier prefixes had the same one. Python's % never returns a negative number, so negative values need no special case.

Complexity: O(n) time, O(k) extra space

class Solution:
    def subarraysDivByK(self, nums: List[int], k: int) -> int:
        count, prefix, seen = 0, 0, {0: 1}         # empty prefix has remainder 0
        for x in nums:
            prefix = (prefix + x) % k              # store the prefix mod k, not the sum
            count += seen.get(prefix, 0)           # same remainder earlier = divisible gap
            seen[prefix] = seen.get(prefix, 0) + 1
        return count
HardLeetCode #1074
Solution

Fix a left column and a right column. Adding up each row between them turns the matrix slice into a 1D array, one number per row.
Counting sub-arrays of that array that sum to target is exactly subarray-sum-equals-k, with the same {0: 1} map. Row prefix sums make each row total O(1).

Complexity: O(cols^2 * rows) time, O(rows * cols) extra space

from itertools import accumulate

class Solution:
    def numSubmatrixSumTarget(self, matrix: List[List[int]], target: int) -> int:
        rows, cols = len(matrix), len(matrix[0])
        row_prefix = [[0] + list(accumulate(row)) for row in matrix]   # sum of row[l..r] = p[r+1] - p[l]
        count = 0
        for left in range(cols):
            for right in range(left, cols):        # fix the two columns
                total, seen = 0, {0: 1}            # now run subarray-sum-equals-k down the rows
                for r in range(rows):
                    total += row_prefix[r][right + 1] - row_prefix[r][left]
                    count += seen.get(total - target, 0)
                    seen[total] = seen.get(total, 0) + 1
        return count

Subarray Sum

Use this when you need the longest, shortest or any sub-array with a sum property, so the map stores an index per prefix value.

MediumLeetCode #525
Solution

Map each 0 to -1 and each 1 to +1. A sub-array with equal 0s and 1s is then a sub-array with sum 0, so two equal running balances bound it.
Store only the FIRST index of each balance, because the earliest index gives the longest span. Seed {0: -1} for the empty prefix.

Complexity: O(n) time, O(n) extra space

class Solution:
    def findMaxLength(self, nums: List[int]) -> int:
        first_index = {0: -1}                      # balance 0 before the array starts
        balance, best = 0, 0
        for i, x in enumerate(nums):
            balance += 1 if x == 1 else -1         # map 0 to -1
            if balance in first_index:
                best = max(best, i - first_index[balance])   # equal balance: equal 0s and 1s between
            else:
                first_index[balance] = i           # first index only: gives the longest span
        return best
MediumLeetCode #523
Solution

A sub-array sum is a multiple of k when two prefix sums have the same remainder mod k. Store the first index of each remainder, with {0: -1} for the empty prefix.
When a remainder repeats, the gap `i - first_index[remainder]` must be at least 2 to meet the length rule. Do not overwrite the stored index when the gap is too short, so later matches still see the earliest one.

Complexity: O(n) time, O(min(n, k)) extra space

class Solution:
    def checkSubarraySum(self, nums: List[int], k: int) -> bool:
        first_index = {0: -1}                      # remainder 0 before the array
        remainder = 0
        for i, x in enumerate(nums):
            remainder = (remainder + x) % k        # prefix sum mod k
            if remainder in first_index:
                if i - first_index[remainder] >= 2:    # same remainder, length at least 2
                    return True
            else:
                first_index[remainder] = i         # keep the earliest index only
        return False
MediumLeetCode #1590
Solution

Let need = total % p. We must remove a sub-array whose sum leaves remainder need, and we want the shortest one. If need is 0, remove nothing.
At index i, the start of that sub-array is an earlier prefix with remainder (prefix - need) % p. Store the LAST index of each remainder, because the latest start gives the shortest sub-array.
Removing the whole array is not allowed, so a result equal to n becomes -1.

Complexity: O(n) time, O(min(n, p)) extra space

class Solution:
    def minSubarray(self, nums: List[int], p: int) -> int:
        need = sum(nums) % p                       # remainder the removed part must have
        if need == 0:
            return 0
        last_index = {0: -1}                       # prefix remainder -> latest index
        prefix, best = 0, len(nums)
        for i, x in enumerate(nums):
            prefix = (prefix + x) % p
            want = (prefix - need) % p             # earlier prefix that leaves a gap of remainder `need`
            if want in last_index:
                best = min(best, i - last_index[want])
            last_index[prefix] = i                 # latest index: shortest gap later
        return best if best < len(nums) else -1

Longest Consecutive Sequence

Use this when you need runs of consecutive values in an unsorted array, in O(n) without sorting.

MediumLeetCode #128
Solution

Put all numbers in a set. Only start counting at a number with no predecessor, meaning `x - 1` is not in the set.
From that start, walk up with `y + 1 in s` and measure the run. Each number is walked over only by the one run that starts it, so total work is O(n).

Complexity: O(n) time, O(n) extra space

class Solution:
    def longestConsecutive(self, nums: List[int]) -> int:
        s, best = set(nums), 0
        for x in s:
            if x - 1 not in s:                 # x starts a run
                y = x
                while y + 1 in s:              # walk the run upward
                    y += 1
                best = max(best, y - x + 1)
        return best
MediumLeetCode #2501
Solution

The run steps are squares instead of +1: x, x*x, x^4 and so on. Put the numbers in a set, and only start at a number that is not the square of another number in the set.
From a start, keep squaring while the result is in the set. Values are at most 10^5, so a run is at most a few steps long and total work stays O(n).
A streak needs length 2 or more, otherwise return -1.

Complexity: O(n) time, O(n) extra space

import math

class Solution:
    def longestSquareStreak(self, nums: List[int]) -> int:
        values = set(nums)
        best = 0
        for x in values:
            root = math.isqrt(x)
            if root * root == x and root in values:   # x has a predecessor: not a run start
                continue
            length, y = 1, x
            while y * y in values:                    # walk the run: x, x^2, x^4, ...
                y *= y
                length += 1
            best = max(best, length)
        return best if best >= 2 else -1
MediumLeetCode #846
Solution

Each group is a run of groupSize consecutive cards. Count the cards, then go through the values in increasing order.
The smallest value left has no usable x - 1, so it must start a run, and it must start `counts[x]` runs. Take that many of each of x ... x + groupSize - 1, and fail if any of them has too few.

Complexity: O(n log n) time, O(n) extra space

class Solution:
    def isNStraightHand(self, hand: List[int], groupSize: int) -> bool:
        if len(hand) % groupSize:
            return False
        counts = Counter(hand)
        for x in sorted(counts):                   # smallest value first
            if counts[x] == 0:
                continue
            need = counts[x]                       # x must start this many runs
            for y in range(x, x + groupSize):
                if counts[y] < need:               # a card in the run is missing
                    return False
                counts[y] -= need
        return True

Group Anagrams

Use this when you must bucket items that are "the same" under some rule, by building a canonical key for each item.

EasyLeetCode #242
Solution

Two words are anagrams when they have the same letter counts, so the count map is the canonical key of a word.
Build the key for both words and compare. A different length rules the pair out early.

Complexity: O(n) time, O(1) extra space (at most 26 letters)

class Solution:
    def isAnagram(self, s: str, t: str) -> bool:
        if len(s) != len(t):
            return False
        return Counter(s) == Counter(t)            # same letter counts = same key
MediumLeetCode #49
Solution

Give every word a canonical key: a tuple of 26 letter counts. Anagrams have the same counts, so they get the same key.
Use a defaultdict(list) keyed by that tuple and append each word to its bucket. The tuple is hashable, so it works as a dict key, and building it costs O(length) instead of the O(length log length) of sorting.

Complexity: O(n * L) time, O(n * L) extra space, where L is the longest word

from collections import defaultdict

class Solution:
    def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
        groups = defaultdict(list)
        for word in strs:
            counts = [0] * 26
            for ch in word:
                counts[ord(ch) - ord('a')] += 1
            groups[tuple(counts)].append(word)     # same counts = same key = same group
        return list(groups.values())
MediumLeetCode #893
Solution

A move swaps two letters at positions with the same parity. So even-position letters can be reordered freely, and so can odd-position letters, but they never mix.
The canonical key is the pair (sorted even letters, sorted odd letters). Strings in the same group share the key, so the answer is the number of distinct keys in a set.

Complexity: O(n * L log L) time, O(n * L) extra space

class Solution:
    def numSpecialEquivGroups(self, words: List[str]) -> int:
        keys = set()
        for w in words:
            even = ''.join(sorted(w[0::2]))        # even positions can be reordered freely
            odd = ''.join(sorted(w[1::2]))         # odd positions can be reordered freely
            keys.add((even, odd))                  # canonical key of the group
        return len(keys)

Frequency-based Problems

Use this when the answer depends on counts, such as "first non-repeating", "sort by frequency" or "isomorphic", so you count first and read the counts in a second pass.

EasyLeetCode #387
Solution

Pass one counts every character with Counter. Pass two walks the string in order and returns the first index whose count is 1.
Order comes from the second pass over the string, not from the dict. The key line is `if counts[ch] == 1`.

Complexity: O(n) time, O(1) extra space (at most 26 letters)

class Solution:
    def firstUniqChar(self, s: str) -> int:
        counts = Counter(s)                    # pass 1: count everything
        for i, ch in enumerate(s):             # pass 2: read the counts in string order
            if counts[ch] == 1:
                return i
        return -1
EasyLeetCode #205
Solution

Each character in s must always map to the same character in t, and no two characters may share a target. Keep one dict per direction.
Scan both strings together. If either dict already holds a different partner for the current pair, the strings are not isomorphic.

Complexity: O(n) time, O(1) extra space (bounded by the alphabet)

class Solution:
    def isIsomorphic(self, s: str, t: str) -> bool:
        s_to_t, t_to_s = {}, {}                    # both directions must stay consistent
        for a, b in zip(s, t):
            if s_to_t.get(a, b) != b or t_to_s.get(b, a) != a:   # a different partner was recorded
                return False
            s_to_t[a] = b
            t_to_s[b] = a
        return True
MediumLeetCode #451
Solution

First count each character. The second step reads those counts: order the (character, count) pairs from the highest count down.
Repeat each character `n` times and join. Counter.most_common() already returns the pairs sorted by count, and there are at most 62 distinct characters, so the sort is cheap.

Complexity: O(n) time, O(n) extra space

class Solution:
    def frequencySort(self, s: str) -> str:
        counts = Counter(s)                                    # pass 1: count
        return ''.join(ch * n for ch, n in counts.most_common())   # pass 2: highest count first
7. Recursion

A function that solves a problem by calling itself on a smaller one. Backtracking is recursion that tries, then undoes.

Basic Recursion

Use this when a problem is defined in terms of a smaller copy of itself and you can name the smallest case that needs no more work.

EasyLeetCode #509
Solution

fib(k) is defined by fib(k-1) and fib(k-2), so each call hands two smaller problems to itself.
The base case `k < 2` stops the calls: fib(0) = 0 and fib(1) = 1.
Plain double recursion is exponential because the same k is solved again and again. The `memo` dictionary caches each answer, so every k is solved once.

Complexity: O(n) time, O(n) extra space

class Solution:
    def fib(self, n: int) -> int:
        memo = {}
        def go(k):
            if k < 2: return k                  # base case: fib(0) = 0, fib(1) = 1
            if k in memo: return memo[k]        # already solved: reuse it
            memo[k] = go(k - 1) + go(k - 2)     # two smaller calls, trust them
            return memo[k]
        return go(n)
MediumLeetCode #50
Solution

x^n is (x^(n/2)) squared, times one more x when n is odd. That is one smaller call on half the power, so the depth is only about log n.
The base case is `n == 0`, which returns 1. A negative power is flipped once with `1 / myPow(x, -n)`.
Computing `half` once and reusing it is the key line: calling myPow twice would undo the speed-up.

Complexity: O(log n) time, O(log n) stack space

class Solution:
    def myPow(self, x: float, n: int) -> float:
        if n < 0:
            return 1 / self.myPow(x, -n)        # negative power: flip once
        if n == 0:
            return 1.0                          # base case
        half = self.myPow(x, n // 2)            # one smaller call on half the power
        if n % 2:
            return half * half * x              # odd power needs one extra x
        return half * half
HardLeetCode #10
Solution

Ask one question: does `s[i:]` match `p[j:]`? Each answer comes from a smaller question on shorter suffixes.
The base case is an empty pattern, which only matches an empty string. When the pattern has `x*`, there are two smaller calls: skip the whole `x*`, or use `x` once and stay on the same `x*`.
Both branches revisit the same (i, j) pairs, so `lru_cache` turns the exponential recursion into a table of at most len(s) * len(p) answers.

Complexity: O(m * n) time, O(m * n) space for m = len(s), n = len(p)

from functools import lru_cache

class Solution:
    def isMatch(self, s: str, p: str) -> bool:
        @lru_cache(None)
        def go(i, j):                           # does s[i:] match p[j:]?
            if j == len(p):
                return i == len(s)              # base case: pattern is used up
            first = i < len(s) and p[j] in (s[i], '.')
            if j + 1 < len(p) and p[j + 1] == '*':
                # skip "x*" entirely, or use x once and stay on the same "x*"
                return go(i, j + 2) or (first and go(i + 1, j))
            return first and go(i + 1, j + 1)   # plain char: both advance
        return go(0, 0)

Pick / Not Pick

Use this when every element gives a yes-or-no choice (take it or leave it) and you need to count, collect or test the outcomes.

EasyLeetCode #401
Solution

Each of the 10 LEDs is one element: pick it (turn it on) or not pick it (leave it off). `left` counts how many LEDs still have to be turned on.
When `left` reaches 0, the picked LEDs form a time, so the leaf condition is "valid time" and it is saved.
A time with hours above 11 or minutes above 59 can only get worse, so the first line of `go` prunes it.

Complexity: O(2^L) time for L = 10 LEDs (a constant), O(L) stack space

class Solution:
    def readBinaryWatch(self, turnedOn: int) -> List[str]:
        leds = [8, 4, 2, 1, 32, 16, 8, 4, 2, 1]     # first 4 are hours, last 6 are minutes
        out = []
        def go(i, left, hours, minutes):
            if hours > 11 or minutes > 59: return   # prune: not a real time
            if left == 0:
                out.append(f"{hours}:{minutes:02d}")    # leaf: all needed LEDs are on
                return
            if i == len(leds): return               # ran out of LEDs
            if i < 4:
                go(i + 1, left - 1, hours + leds[i], minutes)       # pick an hour LED
            else:
                go(i + 1, left - 1, hours, minutes + leds[i])       # pick a minute LED
            go(i + 1, left, hours, minutes)         # not pick
        go(0, turnedOn, 0, 0)
        return out
MediumLeetCode #494
Solution

Giving every number a sign is the same as picking a group that gets "+". If the numbers sum to `total`, that group must add up to `need = (total + target) / 2`.
So each number is picked (subtract it from `remain`) or not picked, and the leaf condition is `remain == 0`, which counts as one way.
Two different paths often reach the same (i, remain), so the `memo` dictionary stops the 2^n blow-up.

Complexity: O(n * need) time, O(n * need) space

class Solution:
    def findTargetSumWays(self, nums: List[int], target: int) -> int:
        total = sum(nums)
        if abs(target) > total or (total + target) % 2:
            return 0                            # no split can reach target
        need = (total + target) // 2            # the "+" group must add up to need
        memo = {}
        def go(i, remain):
            if i == len(nums):
                return 1 if remain == 0 else 0  # leaf: count it if the sum is hit
            if (i, remain) in memo: return memo[(i, remain)]
            ways = go(i + 1, remain)                        # not pick nums[i]
            if nums[i] <= remain:
                ways += go(i + 1, remain - nums[i])         # pick nums[i]
            memo[(i, remain)] = ways
            return ways
        return go(0, need)
HardLeetCode #301
Solution

Each character is one choice. A bracket can be picked (kept) or not picked (removed). Letters are always kept.
First count how many `(` and `)` must go (`left_rem`, `right_rem`). The "not pick" branch only exists while a removal is still owed, and the "pick" branch drops a `)` that has no open `(`. These two checks prune most of the 2^n tree.
At the leaf, accept the string when no removals are owed and no `(` is left open. A `set` removes duplicate strings.

Complexity: O(2^n) time in the worst case, O(n) stack space

class Solution:
    def removeInvalidParentheses(self, s: str) -> List[str]:
        left_rem = right_rem = 0                # how many ( and ) must be removed
        for ch in s:
            if ch == '(':
                left_rem += 1
            elif ch == ')':
                if left_rem: left_rem -= 1
                else: right_rem += 1
        out, path = set(), []
        def go(i, left_rem, right_rem, open_count):
            if i == len(s):
                if left_rem == 0 and right_rem == 0 and open_count == 0:
                    out.add(''.join(path))      # leaf: balanced, nothing owed
                return
            ch = s[i]
            if ch == '(' and left_rem > 0:      # not pick: remove this (
                go(i + 1, left_rem - 1, right_rem, open_count)
            if ch == ')' and right_rem > 0:     # not pick: remove this )
                go(i + 1, left_rem, right_rem - 1, open_count)
            if ch == ')' and open_count == 0:
                return                          # prune: a ) with no ( can never be kept
            path.append(ch)                     # pick: keep it
            go(i + 1, left_rem, right_rem, open_count + (ch == '(') - (ch == ')'))
            path.pop()                          # undo
        go(0, left_rem, right_rem, 0)
        return list(out)

Subsets

Use this when you need every possible group of elements and the order inside a group does not matter.

EasyLeetCode #1863
Solution

Walk the array once and make the pick / not pick choice for each number. After the last index, the path is one finished subset.
Instead of storing the subset, carry its running XOR down the calls: `xor_so_far ^ nums[i]` when picked, `xor_so_far` when not.
At the leaf return that XOR, and add the answers of the two branches. There are 2^n leaves, one per subset.

Complexity: O(2^n) time, O(n) stack space

class Solution:
    def subsetXORSum(self, nums: List[int]) -> int:
        def go(i, xor_so_far):
            if i == len(nums):
                return xor_so_far               # one finished subset: its XOR
            pick = go(i + 1, xor_so_far ^ nums[i])      # pick nums[i]
            not_pick = go(i + 1, xor_so_far)            # not pick
            return pick + not_pick
        return go(0, 0)
MediumLeetCode #78
Solution

This is the template itself. At index i, append `nums[i]` and recurse (pick), pop it, and recurse again (not pick).
When `i == len(nums)` the path holds one subset, so save a copy `path[:]`. Saving `path` itself would leave every result empty.
Every element has two choices, so there are 2^n subsets.

Complexity: O(n * 2^n) time, O(n) extra space besides the output

class Solution:
    def subsets(self, nums: List[int]) -> List[List[int]]:
        out = []
        def go(i, path):
            if i == len(nums):
                out.append(path[:])             # save a copy, not the list itself
                return
            path.append(nums[i]); go(i + 1, path)   # pick
            path.pop();           go(i + 1, path)   # not pick
        go(0, [])
        return out
MediumLeetCode #90
Solution

Sort first so equal numbers sit next to each other. Pick / not pick would now build the same subset twice, for example {2} from either 2.
Fix it in the "not pick" branch: if you leave out `nums[i]`, move `j` past every copy of that value. This is the same as skipping `a[i] == a[i-1]` at the same level.
Picking still goes to `i + 1`, so each value is used 0, 1, 2, ... times and every subset appears once.

Complexity: O(n * 2^n) time, O(n) extra space besides the output

class Solution:
    def subsetsWithDup(self, nums: List[int]) -> List[List[int]]:
        nums.sort()                             # equal values become neighbours
        out = []
        def go(i, path):
            if i == len(nums):
                out.append(path[:])
                return
            path.append(nums[i]); go(i + 1, path)   # pick
            path.pop()
            j = i + 1
            while j < len(nums) and nums[j] == nums[i]:
                j += 1                          # not pick: skip every copy of this value
            go(j, path)
        go(0, [])
        return out

Subsequences

Use this when you need pieces of a sequence that keep their order but may skip elements, and a condition at the end decides which ones count.

EasyLeetCode #392
Solution

Build the subsequence of `t` that equals `s`. At each step the next char of `t` is either skipped (not pick) or used to match `s[i]` (pick).
`t.find(s[i], j)` skips every unusable char of `t` in one move, so the recursion only goes one level deeper per matched char.
The base case is `i == len(s)`: every char of `s` was matched. If `find` returns -1, `t` ran out first.

Complexity: O(len(t)) time, O(len(s)) stack space

class Solution:
    def isSubsequence(self, s: str, t: str) -> bool:
        def go(i, j):
            if i == len(s):
                return True                     # base case: all of s matched
            j = t.find(s[i], j)                 # not pick: skip chars that are not s[i]
            if j == -1:
                return False                    # t ran out first
            return go(i + 1, j + 1)             # pick it, then match the rest
        return go(0, 0)
MediumLeetCode #491
Solution

Every subsequence is a pick / not pick walk, and the leaf rule is "length at least 2". Picking `nums[i]` is only allowed when it is not smaller than the last picked value.
Duplicates are the twist. Skip `nums[i]` only when it differs from the last picked value. If it is equal, picking it already covers that subsequence, so skipping would repeat it.
No `set` of results is needed: the two rules together make each subsequence appear exactly once.

Complexity: O(n * 2^n) time, O(n) extra space besides the output

class Solution:
    def findSubsequences(self, nums: List[int]) -> List[List[int]]:
        out, path = [], []
        def go(i):
            if i == len(nums):
                if len(path) >= 2:
                    out.append(path[:])         # leaf condition: at least 2 elements
                return
            if not path or nums[i] >= path[-1]:     # pick only if the order stays non-decreasing
                path.append(nums[i]); go(i + 1)
                path.pop()
            if not path or nums[i] != path[-1]:     # not pick, unless picking an equal value covers it
                go(i + 1)
        go(0)
        return out
HardLeetCode #115
Solution

Count the ways to build `t[j:]` from `s[i:]`. For each char of `s` there is a not pick branch (`go(i + 1, j)`), and a pick branch only when `s[i] == t[j]`.
The base cases: `j == len(t)` means t is fully built, which is one way; `i == len(s)` first means zero ways.
Many (i, j) pairs repeat, so `lru_cache` keeps it polynomial. The depth can pass 1000, so the recursion limit is raised.

Complexity: O(m * n) time, O(m * n) space for m = len(s), n = len(t)

import sys
from functools import lru_cache

class Solution:
    def numDistinct(self, s: str, t: str) -> int:
        sys.setrecursionlimit(10000)            # depth can reach len(s) + len(t)
        @lru_cache(None)
        def go(i, j):                           # ways to build t[j:] from s[i:]
            if j == len(t): return 1            # t fully built: one way
            if i == len(s): return 0            # s used up first: no way
            ways = go(i + 1, j)                 # not pick s[i]
            if s[i] == t[j]:
                ways += go(i + 1, j + 1)        # pick s[i] to match t[j]
            return ways
        return go(0, 0)

Permutations

Use this when order matters and every element is used exactly once, so each position can take any unused element.

MediumLeetCode #46
Solution

Fill the positions one by one. At each position loop over all indices and skip the ones with `used[i]` set.
Choose: mark the element used and append it. Explore: recurse. Un-choose: pop it and clear the mark.
When the path has the same length as `nums`, save a copy `cur[:]`. There are n! results.

Complexity: O(n * n!) time, O(n) extra space besides the output

class Solution:
    def permute(self, nums: List[int]) -> List[List[int]]:
        out, used, cur = [], [False] * len(nums), []
        def go():
            if len(cur) == len(nums):
                out.append(cur[:])              # copy, not cur itself
                return
            for i in range(len(nums)):
                if used[i]: continue            # each element once per permutation
                used[i] = True; cur.append(nums[i])     # choose
                go()                                    # explore
                cur.pop(); used[i] = False              # un-choose
        go()
        return out
MediumLeetCode #47
Solution

Same loop as plain permutations, but equal numbers would build the same permutation more than once.
Sort first. Then skip `nums[i]` when it equals `nums[i-1]` and `nums[i-1]` is not used: that copy has to wait for its twin, so equal values are always placed left to right.
Everything else (mark, recurse, unmark, copy the path) is unchanged.

Complexity: O(n * n!) time, O(n) extra space besides the output

class Solution:
    def permuteUnique(self, nums: List[int]) -> List[List[int]]:
        nums.sort()                             # equal values become neighbours
        out, used, cur = [], [False] * len(nums), []
        def go():
            if len(cur) == len(nums):
                out.append(cur[:])
                return
            for i in range(len(nums)):
                if used[i]: continue
                if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:
                    continue                    # skip a duplicate whose twin is unused
                used[i] = True; cur.append(nums[i])
                go()
                cur.pop(); used[i] = False      # undo
        go()
        return out
HardLeetCode #996
Solution

This is permutations-ii with one more rule. Place the elements one by one, and skip a duplicate whose twin is unused.
Prune early: a candidate is only allowed when `prev + nums[i]` is a perfect square, so dead branches stop after one step.
Nothing needs to be stored. The leaf (`placed == n`) returns 1, and each call adds up the counts of its children.

Complexity: O(n * n!) time worst case, much less with pruning; O(n) stack space

import math

class Solution:
    def numSquarefulPerms(self, nums: List[int]) -> int:
        nums.sort()                             # equal values become neighbours
        n = len(nums)
        used = [False] * n
        def is_square(x):
            r = math.isqrt(x)
            return r * r == x
        def go(prev, placed):
            if placed == n:
                return 1                        # leaf: one valid arrangement
            count = 0
            for i in range(n):
                if used[i]: continue
                if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:
                    continue                    # skip duplicate
                if placed and not is_square(prev + nums[i]):
                    continue                    # prune: neighbour sum must be a square
                used[i] = True                  # choose
                count += go(nums[i], placed + 1)    # explore
                used[i] = False                 # un-choose
            return count
        return go(0, 0)

Combination

Use this when order does not matter, so you only look forward from a start index and never build both [1,2] and [2,1].

MediumLeetCode #77
Solution

Choose k numbers from 1..n. Each call loops `i` from `start` to n, appends i, and recurses with `i + 1`, so only larger numbers can follow.
That forward-only start index is what stops [1,2] and [2,1] both appearing.
When the path has k numbers, save a copy. The `break` prunes loops where too few numbers are left to reach k.

Complexity: O(k * C(n, k)) time, O(k) extra space besides the output

class Solution:
    def combine(self, n: int, k: int) -> List[List[int]]:
        out = []
        def go(start, path):
            if len(path) == k:
                out.append(path[:])             # k chosen: save a copy
                return
            for i in range(start, n + 1):
                if n - i + 1 < k - len(path):
                    break                       # prune: not enough numbers left
                path.append(i)                  # choose
                go(i + 1, path)                 # explore, only look forward
                path.pop()                      # un-choose
        go(1, [])
        return out
MediumLeetCode #39
Solution

This is the template from the notes. The loop starts at `start`, so only the current or later candidates are used and no order repeats.
The same number may be used again, so the recursive call passes `i`, not `i + 1`.
Save the path when `remain == 0`. Skip any candidate bigger than `remain`: that is the early prune.

Complexity: O(n^(target / min)) time in the worst case, O(target / min) stack space

class Solution:
    def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
        out = []
        def go(start, path, remain):
            if remain == 0:
                out.append(path[:])             # found a combination
                return
            for i in range(start, len(candidates)):
                if candidates[i] > remain: continue     # prune
                path.append(candidates[i])              # choose
                go(i, path, remain - candidates[i])     # i, not i + 1: reuse allowed
                path.pop()                              # un-choose
        go(0, [], target)
        return out
MediumLeetCode #40
Solution

Each candidate may be used once, so the recursive call moves on with `i + 1`. Sort first so duplicates sit together.
Inside one loop, skip `candidates[i]` when `i > start` and it equals the previous one. Taking the same value twice at the same level would repeat a combination.
Because the list is sorted, `break` replaces `continue`: once one candidate is too big, so are all later ones.

Complexity: O(2^n) time, O(n) stack space

class Solution:
    def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
        candidates.sort()                       # duplicates become neighbours
        out = []
        def go(start, path, remain):
            if remain == 0:
                out.append(path[:])
                return
            for i in range(start, len(candidates)):
                if candidates[i] > remain: break            # sorted, so the rest are too big
                if i > start and candidates[i] == candidates[i - 1]:
                    continue                                # same value at the same level
                path.append(candidates[i])
                go(i + 1, path, remain - candidates[i])     # i + 1: each number once
                path.pop()
        go(0, [], target)
        return out

Backtracking

Use this when you build an answer step by step and can undo a step (and cut the branch) as soon as the partial answer cannot work.

MediumLeetCode #22
Solution

Build the string one character at a time with the three steps: choose (append), explore (recurse), un-choose (pop).
Two conditions prune the tree. Add `(` only while fewer than n are used, and add `)` only while it would not close more than has been opened.
Because bad branches are never entered, every string that reaches length 2n is already valid.

Complexity: O(4^n / sqrt(n)) time (Catalan number of results), O(n) stack space

class Solution:
    def generateParenthesis(self, n: int) -> List[str]:
        out, path = [], []
        def go(open_used, close_used):
            if len(path) == 2 * n:
                out.append(''.join(path))       # complete and valid by construction
                return
            if open_used < n:                   # prune: at most n opening brackets
                path.append('(')                # choose
                go(open_used + 1, close_used)   # explore
                path.pop()                      # un-choose
            if close_used < open_used:          # prune: never close more than opened
                path.append(')')
                go(open_used, close_used + 1)
                path.pop()
        go(0, 0)
        return out
MediumLeetCode #131
Solution

Cut the string from left to right. At each call, loop over every end position and take the piece `s[start:end]`.
Prune: only recurse when the piece is a palindrome. A bad first piece can never lead to a valid partition.
Choose (append the piece), explore (continue from `end`), un-choose (pop). When `start` reaches the end, save a copy of the path.

Complexity: O(n * 2^n) time, O(n) stack space

class Solution:
    def partition(self, s: str) -> List[List[str]]:
        out, path = [], []
        def go(start):
            if start == len(s):
                out.append(path[:])             # whole string is cut into palindromes
                return
            for end in range(start + 1, len(s) + 1):
                piece = s[start:end]
                if piece != piece[::-1]: continue   # prune: piece is not a palindrome
                path.append(piece)              # choose
                go(end)                         # explore
                path.pop()                      # un-choose
        go(0)
        return out
HardLeetCode #51
Solution

Place one queen per row. For each column, check whether it is already attacked: the column set, the `row - c` diagonal set, or the `row + c` diagonal set.
Choose (add the column and both diagonals), explore the next row, then un-choose by removing them all again.
Attacked squares are skipped at once, so most of the n^n placements are never tried. A full board is built only at the leaf.

Complexity: O(n!) time, O(n) extra space besides the output

class Solution:
    def solveNQueens(self, n: int) -> List[List[str]]:
        out, queens = [], []                    # queens[r] = column of the queen in row r
        cols, diag1, diag2 = set(), set(), set()
        def go(row):
            if row == n:
                out.append(['.' * c + 'Q' + '.' * (n - c - 1) for c in queens])
                return
            for c in range(n):
                if c in cols or row - c in diag1 or row + c in diag2:
                    continue                    # prune: this square is attacked
                cols.add(c); diag1.add(row - c); diag2.add(row + c)     # choose
                queens.append(c)
                go(row + 1)                                             # explore
                queens.pop()                                            # un-choose
                cols.remove(c); diag1.remove(row - c); diag2.remove(row + c)
        go(0)
        return out

Recursion on Trees

Use this when the answer for a node comes from the answers of its left and right subtrees, or from information passed down to them.

EasyLeetCode #104
Solution

Bottom-up: the base case is `None`, which has depth 0. Otherwise ask the left child and the right child, then combine.
The depth of a node is 1 plus the larger depth of its two subtrees. This is the `height` example from the notes.

Complexity: O(n) time, O(h) stack space for tree height h

class Solution:
    def maxDepth(self, root: Optional[TreeNode]) -> int:
        if not root:
            return 0                            # base case: empty tree
        left = self.maxDepth(root.left)         # ask the left subtree
        right = self.maxDepth(root.right)       # ask the right subtree
        return 1 + max(left, right)             # combine
EasyLeetCode #112
Solution

Top-down: pass information down through a parameter. Each call subtracts the node's value and hands the smaller `remain` to its children.
The base cases are `None` (no path, False) and a leaf (the path is complete, so check `remain == 0`).
Only a leaf can end a path, so `1 -> 2` with target 1 is False even though the root alone equals 1. Either child may succeed, so combine with `or`.

Complexity: O(n) time, O(h) stack space for tree height h

class Solution:
    def hasPathSum(self, root: Optional[TreeNode], targetSum: int) -> bool:
        if not root:
            return False                        # base case: no node, no path
        remain = targetSum - root.val           # pass the smaller target down
        if not root.left and not root.right:
            return remain == 0                  # a leaf ends the path
        return self.hasPathSum(root.left, remain) or self.hasPathSum(root.right, remain)
HardLeetCode #124
Solution

Bottom-up with a twist. `gain(node)` returns the best sum of a path that starts at that node and goes down one side only, because a parent can only continue along one branch.
At each node a path may bend: `node.val + left + right` is a candidate for the overall answer, stored in `best`. A negative child gain is replaced by 0, which means "do not take that branch".
The value returned upward is `node.val + max(left, right)`, while the bent path is only recorded in `best`.

Complexity: O(n) time, O(h) stack space for tree height h

from math import inf

class Solution:
    def maxPathSum(self, root: Optional[TreeNode]) -> int:
        best = -inf
        def gain(node):
            nonlocal best
            if not node:
                return 0                        # base case: empty tree adds nothing
            left = max(gain(node.left), 0)      # drop a branch that only hurts
            right = max(gain(node.right), 0)
            best = max(best, node.val + left + right)   # path that bends at this node
            return node.val + max(left, right)          # going up, take one side only
        gain(root)
        return best
8. Binary Tree

Each node has at most two children. Nearly every problem is a traversal with a small twist.

DFS

Use this when the answer for a node depends on its two subtrees (or on what you met on the way down), so you recurse left and right and combine the results.

EasyLeetCode #226
Solution

Inverting a tree means every node swaps its two children. DFS handles that one node at a time: recurse into both children first, then swap the two results. The line `root.left, root.right = right, left` is the whole trick. Every node is touched once.

Complexity: O(n) time, O(h) extra space (recursion stack, h = tree height)

class Solution:
    def invertTree(self, root: Optional[TreeNode]) -> Optional[TreeNode]:
        if not root:                         # base case: empty subtree
            return None
        left = self.invertTree(root.left)    # DFS into both children
        right = self.invertTree(root.right)
        root.left, root.right = right, left  # swap the two inverted subtrees
        return root
EasyLeetCode #101
Solution

A tree is symmetric when its left and right subtrees are mirror images. Run DFS on two nodes at the same time: `a` walks the left side and `b` walks the right side. The outer children must match each other, and the inner children must match each other. Any mismatch in value or shape returns False immediately.

Complexity: O(n) time, O(h) extra space

class Solution:
    def isSymmetric(self, root: Optional[TreeNode]) -> bool:
        def mirror(a, b):                    # DFS on a pair of nodes
            if not a and not b:
                return True
            if not a or not b:
                return False
            return (a.val == b.val and
                    mirror(a.left, b.right) and    # outer pair
                    mirror(a.right, b.left))       # inner pair
        return not root or mirror(root.left, root.right)
MediumLeetCode #1448
Solution

Some answers depend on the path from the root, not only on the subtree. Pass the largest value seen so far down as a parameter: `best`. A node is good when its value is at least `best`. Update `best` before recursing into the children, so each branch carries its own maximum.

Complexity: O(n) time, O(h) extra space

from math import inf

class Solution:
    def goodNodes(self, root: TreeNode) -> int:
        def dfs(node, best):                 # best = max value on the path so far
            if not node:
                return 0
            good = 1 if node.val >= best else 0
            best = max(best, node.val)       # carry the new maximum down
            return good + dfs(node.left, best) + dfs(node.right, best)
        return dfs(root, -inf)

BFS / Level Order

Use this when the question talks about levels, rows or the nearest-to-the-root node, so you process the tree one level at a time with a queue.

EasyLeetCode #637
Solution

Put the root in a queue and read `len(q)` before the inner loop. That freezes the size of the current level. Add up exactly that many nodes, pushing their children for the next round. Divide the level total by `size` and append it.

Complexity: O(n) time, O(w) extra space (w = widest level)

from collections import deque

class Solution:
    def averageOfLevels(self, root: Optional[TreeNode]) -> List[float]:
        if not root:
            return []
        out, q = [], deque([root])
        while q:
            size = len(q)                    # freeze: exactly this level
            total = 0
            for _ in range(size):
                n = q.popleft()
                total += n.val
                if n.left:  q.append(n.left)
                if n.right: q.append(n.right)
            out.append(total / size)
        return out
MediumLeetCode #102
Solution

This is the template itself. Take `len(q)`, pop that many nodes into a `level` list, and push their children. After the inner loop, the list holds one full level, so append it to the answer. An empty tree returns an empty list.

Complexity: O(n) time, O(w) extra space (w = widest level)

from collections import deque

class Solution:
    def levelOrder(self, root: Optional[TreeNode]) -> List[List[int]]:
        if not root:
            return []
        out, q = [], deque([root])
        while q:
            level = []
            for _ in range(len(q)):          # exactly this level
                n = q.popleft()
                level.append(n.val)
                if n.left:  q.append(n.left)
                if n.right: q.append(n.right)
            out.append(level)
        return out
MediumLeetCode #103
Solution

The traversal is the same level-by-level BFS. The one extra idea is a flag `left_to_right` that flips after every level. When the flag is False, store the level reversed: `level[::-1]`. The queue itself always runs left to right, so the children are pushed the normal way.

Complexity: O(n) time, O(w) extra space

from collections import deque

class Solution:
    def zigzagLevelOrder(self, root: Optional[TreeNode]) -> List[List[int]]:
        if not root:
            return []
        out, q, left_to_right = [], deque([root]), True
        while q:
            level = []
            for _ in range(len(q)):          # exactly this level
                n = q.popleft()
                level.append(n.val)
                if n.left:  q.append(n.left)
                if n.right: q.append(n.right)
            out.append(level if left_to_right else level[::-1])
            left_to_right = not left_to_right    # flip direction for the next level
        return out

Preorder

Use this when you must handle a node before its children, for example when copying a tree, rebuilding one from a traversal, or writing it out.

EasyLeetCode #144
Solution

Preorder means node, left, right. In the DFS template, put the `out.append` before the two recursive calls. Nothing else changes between the three orders.

Complexity: O(n) time, O(h) extra space

class Solution:
    def preorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
        out = []
        def dfs(n):
            if not n:
                return
            out.append(n.val)                # PRE: visit the node first
            dfs(n.left)
            dfs(n.right)
        dfs(root)
        return out
MediumLeetCode #105
Solution

In a preorder list the next unused value is always the root of the subtree being built. Find that value in the inorder list (a hash map gives its index) to learn where the left side ends. Build the left subtree first, then the right, because preorder lists the left subtree before the right one. The iterator `pre` hands out roots in exactly that order.

Complexity: O(n) time, O(n) extra space

class Solution:
    def buildTree(self, preorder: List[int], inorder: List[int]) -> Optional[TreeNode]:
        index_of = {v: i for i, v in enumerate(inorder)}
        pre = iter(preorder)                 # preorder yields roots one by one
        def build(lo, hi):                   # builds from inorder[lo..hi]
            if lo > hi:
                return None
            root = TreeNode(next(pre))       # PRE: next value is this subtree's root
            mid = index_of[root.val]
            root.left = build(lo, mid - 1)   # left first, matching preorder
            root.right = build(mid + 1, hi)
            return root
        return build(0, len(inorder) - 1)
HardLeetCode #297
Solution

Serialize in preorder: write the node, then its left subtree, then its right subtree. Write `#` for every missing child, so the reader knows exactly where each subtree ends. Deserialize reads the tokens in the same preorder using an iterator, so each call to `build` consumes precisely the tokens of one subtree.

Complexity: O(n) time, O(n) extra space

class Codec:
    def serialize(self, root: Optional[TreeNode]) -> str:
        out = []
        def dfs(n):
            if not n:
                out.append('#')              # marker for an empty child
                return
            out.append(str(n.val))           # PRE: write the node before its children
            dfs(n.left)
            dfs(n.right)
        dfs(root)
        return ','.join(out)

    def deserialize(self, data: str) -> Optional[TreeNode]:
        tokens = iter(data.split(','))
        def build():
            t = next(tokens)                 # read in the same preorder
            if t == '#':
                return None
            node = TreeNode(int(t))
            node.left = build()
            node.right = build()
            return node
        return build()

Inorder

Use this when the tree is a BST and you need its values in sorted order, the k-th value, or the place where the order breaks.

EasyLeetCode #94
Solution

Inorder means left, node, right. In the DFS template, put the `out.append` between the two recursive calls. On a BST this visits values in sorted order.

Complexity: O(n) time, O(h) extra space

class Solution:
    def inorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
        out = []
        def dfs(n):
            if not n:
                return
            dfs(n.left)
            out.append(n.val)                # IN: visit between the children
            dfs(n.right)
        dfs(root)
        return out
MediumLeetCode #230
Solution

Inorder on a BST gives sorted values, so the k-th smallest is the k-th node visited. Use the same left, node, right order with an explicit stack, so you can stop as soon as the count reaches zero. The line `k -= 1` sits at the visit step. You never walk the part of the tree after the answer.

Complexity: O(h + k) time, O(h) extra space

class Solution:
    def kthSmallest(self, root: Optional[TreeNode], k: int) -> int:
        stack, node = [], root
        while stack or node:
            while node:                      # go left as far as possible
                stack.append(node)
                node = node.left
            node = stack.pop()               # IN: visit after the left side is done
            k -= 1
            if k == 0:
                return node.val              # stop early at the k-th visit
            node = node.right
MediumLeetCode #99
Solution

A valid BST read inorder is sorted. Two swapped values create one or two places where a value is smaller than the one before it. Walk inorder and keep `prev`. The first time `prev.val > node.val`, remember `prev` as `first`. On every such break, set `second = node`, so the last break wins. Swap the two values at the end.

Complexity: O(n) time, O(h) extra space

class Solution:
    def recoverTree(self, root: Optional[TreeNode]) -> None:
        first = second = prev = None
        stack, node = [], root
        while stack or node:
            while node:
                stack.append(node)
                node = node.left
            node = stack.pop()               # IN: nodes arrive in inorder
            if prev and prev.val > node.val: # sorted order broke here
                if not first:
                    first = prev             # the larger value, too early
                second = node                # the smaller value, too late
            prev = node
            node = node.right
        first.val, second.val = second.val, first.val

Postorder

Use this when a node can only be decided after both children are done, such as deleting, pruning, or combining answers from below.

EasyLeetCode #145
Solution

Postorder means left, right, node. In the DFS template, put the `out.append` after both recursive calls. Every node is recorded only after its whole subtree.

Complexity: O(n) time, O(h) extra space

class Solution:
    def postorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
        out = []
        def dfs(n):
            if not n:
                return
            dfs(n.left)
            dfs(n.right)
            out.append(n.val)                # POST: visit after both children
        dfs(root)
        return out
MediumLeetCode #814
Solution

A subtree should be removed when it contains no 1. You cannot know that for a node until its children have been pruned. So recurse into both children first and reassign them. Then, after the recursive calls (the postorder spot), a node that has no children left and a value of 0 returns None.

Complexity: O(n) time, O(h) extra space

class Solution:
    def pruneTree(self, root: Optional[TreeNode]) -> Optional[TreeNode]:
        if not root:
            return None
        root.left = self.pruneTree(root.left)     # children first
        root.right = self.pruneTree(root.right)
        if not root.left and not root.right and root.val == 0:
            return None                      # POST: now a useless leaf, cut it off
        return root
HardLeetCode #968
Solution

Greedy works from the bottom, so it needs postorder. Each node reports a state to its parent: 0 = not covered, 1 = has a camera, 2 = covered without one. If any child is not covered, this node must hold a camera. If a child has a camera, this node is covered. Otherwise this node is not covered yet and the parent will deal with it. A root that comes back as 0 needs one more camera.

Complexity: O(n) time, O(h) extra space

class Solution:
    def minCameraCover(self, root: Optional[TreeNode]) -> int:
        NOT_COVERED, HAS_CAMERA, COVERED = 0, 1, 2
        cameras = 0
        def dfs(n):
            nonlocal cameras
            if not n:
                return COVERED               # an empty child needs nothing
            l, r = dfs(n.left), dfs(n.right) # POST: children report first
            if l == NOT_COVERED or r == NOT_COVERED:
                cameras += 1                 # a child is exposed: camera here
                return HAS_CAMERA
            if l == HAS_CAMERA or r == HAS_CAMERA:
                return COVERED
            return NOT_COVERED               # leave it to the parent
        if dfs(root) == NOT_COVERED:
            cameras += 1
        return cameras

Height / Depth

Use this when the answer is a number of levels, or when a decision depends on how deep a subtree goes.

EasyLeetCode #104
Solution

The height of a node is one more than the taller of its two children, and an empty tree has height 0. That sentence is the code. Each call returns the height of its subtree to its parent, so the root ends up with the answer.

Complexity: O(n) time, O(h) extra space

class Solution:
    def maxDepth(self, root: Optional[TreeNode]) -> int:
        if not root:
            return 0                         # empty tree has height 0
        return 1 + max(self.maxDepth(root.left), self.maxDepth(root.right))
EasyLeetCode #111
Solution

The tempting `1 + min(left, right)` is wrong when a node has only one child, because the empty side would count as depth 0. A path must end at a leaf. So when one child is missing, follow the other one. The line `return 1 + l + r` works because the missing side contributes 0.

Complexity: O(n) time, O(h) extra space

class Solution:
    def minDepth(self, root: Optional[TreeNode]) -> int:
        if not root:
            return 0
        l, r = self.minDepth(root.left), self.minDepth(root.right)
        if not root.left or not root.right:  # only one child: must follow it
            return 1 + l + r                 # one of l, r is 0
        return 1 + min(l, r)
MediumLeetCode #222
Solution

In a complete tree, at least one side of every node is a perfect tree. Measure the height down the leftmost edge and down the rightmost edge. If they are equal, the whole tree is perfect and has `2 ** h - 1` nodes, with no further recursion. Otherwise recurse into both children. One of them is perfect and returns at once, so only one path goes deep.

Complexity: O(log^2 n) time, O(log n) extra space

class Solution:
    def countNodes(self, root: Optional[TreeNode]) -> int:
        if not root:
            return 0
        left_h, node = 0, root
        while node:                          # height along the leftmost edge
            left_h += 1
            node = node.left
        right_h, node = 0, root
        while node:                          # height along the rightmost edge
            right_h += 1
            node = node.right
        if left_h == right_h:                # perfect tree: count by formula
            return 2 ** left_h - 1
        return 1 + self.countNodes(root.left) + self.countNodes(root.right)

Diameter

Use this when the best answer is a path that can bend at any node (left arm plus right arm), so each node returns one arm upward and updates a global best.

EasyLeetCode #543
Solution

This is the template. The recursive function returns a height, but at every node it also updates `best` with `l + r`, the longest path that bends at that node. Returning only the taller arm upward keeps the heights correct. One postorder pass gives O(n) instead of recomputing heights at every node.

Complexity: O(n) time, O(h) extra space

class Solution:
    def diameterOfBinaryTree(self, root: Optional[TreeNode]) -> int:
        best = 0
        def h(n):
            nonlocal best
            if not n:
                return 0
            l, r = h(n.left), h(n.right)
            best = max(best, l + r)          # path THROUGH this node
            return 1 + max(l, r)             # height to return up
        h(root)
        return best
MediumLeetCode #687
Solution

Same shape as the diameter, with one extra rule: an arm only extends when the child has the same value as the node. Each call returns the longest same-value arm going down from `n`. If a child's value differs, that arm is reset to 0. The path bending at `n` has length `l + r`, which updates `best`.

Complexity: O(n) time, O(h) extra space

class Solution:
    def longestUnivaluePath(self, root: Optional[TreeNode]) -> int:
        best = 0
        def arm(n):                          # longest same-value arm down from n
            nonlocal best
            if not n:
                return 0
            l, r = arm(n.left), arm(n.right)
            l = l + 1 if n.left and n.left.val == n.val else 0
            r = r + 1 if n.right and n.right.val == n.val else 0
            best = max(best, l + r)          # path through this node
            return max(l, r)                 # one arm goes up
        arm(root)
        return best
HardLeetCode #124
Solution

This is the diameter with sums instead of edge counts, and with negative values to handle. Each call returns the best one-armed sum going down from the node, and `max(..., 0)` drops an arm that would lower the total. The path that bends at the node is `n.val + l + r`, which updates `best`. Only `n.val + max(l, r)` can go up to the parent.

Complexity: O(n) time, O(h) extra space

from math import inf

class Solution:
    def maxPathSum(self, root: Optional[TreeNode]) -> int:
        best = -inf
        def gain(n):
            nonlocal best
            if not n:
                return 0
            l = max(gain(n.left), 0)         # ignore an arm that hurts
            r = max(gain(n.right), 0)
            best = max(best, n.val + l + r)  # path through this node
            return n.val + max(l, r)         # one arm goes up
        gain(root)
        return best

Balanced Tree

Use this when you must check or build a tree whose two subtrees never differ in height by more than one.

EasyLeetCode #110
Solution

Checking balance node by node would recompute heights again and again. Instead, compute the height in one postorder pass and return -1 as a sentinel for "unbalanced somewhere below". A node is unbalanced if a child already returned -1 or the two heights differ by more than 1. The sentinel passes up to the root.

Complexity: O(n) time, O(h) extra space

class Solution:
    def isBalanced(self, root: Optional[TreeNode]) -> bool:
        def h(n):                            # -1 means "unbalanced"
            if not n:
                return 0
            l, r = h(n.left), h(n.right)
            if l < 0 or r < 0 or abs(l - r) > 1:
                return -1                    # pass the sentinel upward
            return 1 + max(l, r)
        return h(root) >= 0
EasyLeetCode #108
Solution

To build a tree whose two sides stay equal in size, make the middle element the root. The left half becomes the left subtree and the right half becomes the right subtree, recursively. Sizes of the two sides differ by at most one at every node, so the heights do too. Because the array is sorted, the result is also a valid BST.

Complexity: O(n) time, O(log n) extra space (recursion stack)

class Solution:
    def sortedArrayToBST(self, nums: List[int]) -> Optional[TreeNode]:
        def build(lo, hi):                   # builds from nums[lo:hi]
            if lo >= hi:
                return None
            mid = (lo + hi) // 2             # middle as root keeps both sides balanced
            root = TreeNode(nums[mid])
            root.left = build(lo, mid)
            root.right = build(mid + 1, hi)
            return root
        return build(0, len(nums))
MediumLeetCode #1382
Solution

Two steps. First, an inorder walk of the BST collects its values in sorted order. Second, rebuild a tree from that sorted list by always taking the middle as the root, which keeps both sides equal in size. The result holds the same values and is height-balanced.

Complexity: O(n) time, O(n) extra space

class Solution:
    def balanceBST(self, root: TreeNode) -> TreeNode:
        values = []
        def inorder(n):                      # BST inorder = sorted values
            if not n:
                return
            inorder(n.left)
            values.append(n.val)
            inorder(n.right)
        inorder(root)
        def build(lo, hi):                   # builds from values[lo:hi]
            if lo >= hi:
                return None
            mid = (lo + hi) // 2             # middle as root keeps it balanced
            node = TreeNode(values[mid])
            node.left = build(lo, mid)
            node.right = build(mid + 1, hi)
            return node
        return build(0, len(values))

Path Sum

Use this when the question is about root-to-leaf (or downward) paths and a running total, so you pass the remaining target down as you go.

EasyLeetCode #112
Solution

Carry the remaining target as a parameter: at each node subtract `n.val` before going to the children. At a leaf, the path works when the leaf value equals what is left. A node with only one child is not a leaf, so an empty side must return False, not True.

Complexity: O(n) time, O(h) extra space

class Solution:
    def hasPathSum(self, root: Optional[TreeNode], targetSum: int) -> bool:
        if not root:
            return False
        if not root.left and not root.right:         # leaf: path ends here
            return root.val == targetSum
        remaining = targetSum - root.val             # subtract as you go down
        return (self.hasPathSum(root.left, remaining) or
                self.hasPathSum(root.right, remaining))
MediumLeetCode #113
Solution

Now every valid path must be listed, so use backtracking on top of the top-down walk. Keep one shared `path` list: append the node on the way down and `pop()` it on the way back. At a leaf with nothing left to subtract, copy the path with `path[:]`. Without the copy, later changes would alter the saved answer.

Complexity: O(n^2) time in the worst case (copying paths), O(h) extra space besides the output

class Solution:
    def pathSum(self, root: Optional[TreeNode], targetSum: int) -> List[List[int]]:
        out, path = [], []
        def dfs(n, remaining):
            if not n:
                return
            path.append(n.val)               # choose this node
            remaining -= n.val               # subtract as you go down
            if not n.left and not n.right and remaining == 0:
                out.append(path[:])          # copy, the list keeps changing
            dfs(n.left, remaining)
            dfs(n.right, remaining)
            path.pop()                       # undo the choice
        dfs(root, targetSum)
        return out
MediumLeetCode #437
Solution

Paths may start and end anywhere, as long as they go downward. Keep the running sum from the root, `prefix`. A path ending at this node with sum `targetSum` exists once for each earlier prefix equal to `prefix - targetSum`, so look that up in the counter. Add the current prefix before recursing and remove it on the way back, so only ancestors are counted.

Complexity: O(n) time, O(h) extra space

from collections import Counter

class Solution:
    def pathSum(self, root: Optional[TreeNode], targetSum: int) -> int:
        seen = Counter({0: 1})               # prefix sums on the current root path
        def dfs(n, prefix):
            if not n:
                return 0
            prefix += n.val
            count = seen[prefix - targetSum] # paths ending at this node
            seen[prefix] += 1
            count += dfs(n.left, prefix) + dfs(n.right, prefix)
            seen[prefix] -= 1                # leaving this node
            return count
        return dfs(root, 0)

Lowest Common Ancestor

Use this when the answer hangs on the lowest node where two things (given nodes, or pairs of leaves) sit on different sides, so you search both sides and see where they split.

MediumLeetCode #236
Solution

Search both subtrees for `p` and `q`. A call returns the node itself if it is `p` or `q`, otherwise whatever the sides found. If both sides return something, `p` and `q` are on different sides, so this node is the split and the answer. If only one side found something, pass it up unchanged.

Complexity: O(n) time, O(h) extra space

class Solution:
    def lowestCommonAncestor(self, root: 'TreeNode', p: 'TreeNode', q: 'TreeNode') -> 'TreeNode':
        if not root or root is p or root is q:   # found a target (or fell off)
            return root
        left = self.lowestCommonAncestor(root.left, p, q)
        right = self.lowestCommonAncestor(root.right, p, q)
        if left and right:                   # p and q are on different sides: split here
            return root
        return left or right                 # otherwise pass up what was found
MediumLeetCode #2096
Solution

The shortest route between two nodes always climbs to their lowest common ancestor and then walks down. Find that ancestor with the same idiom: return the node if it matches, ask both children, and the node that gets answers from both sides is the split. Then find the downward path from it to each value. The path to `startValue` becomes one `U` per step, and the path to `destValue` is kept as `L` and `R`.

Complexity: O(n) time, O(h) extra space

class Solution:
    def getDirections(self, root: Optional[TreeNode], startValue: int, destValue: int) -> str:
        def lca(node):
            if not node or node.val == startValue or node.val == destValue:
                return node                  # found a target (or fell off)
            left, right = lca(node.left), lca(node.right)
            if left and right:               # the two values are on different sides: split here
                return node
            return left or right             # otherwise pass up what was found

        def find_path(node, target, moves):  # fills moves with L/R steps from node down to target
            if not node:
                return False
            if node.val == target:
                return True
            for child, step in ((node.left, 'L'), (node.right, 'R')):
                moves.append(step)
                if find_path(child, target, moves):
                    return True
                moves.pop()                  # undo the step when the target is not below
            return False

        top = lca(root)
        to_start, to_dest = [], []
        find_path(top, startValue, to_start)
        find_path(top, destValue, to_dest)
        return 'U' * len(to_start) + ''.join(to_dest)   # climb to the split, then walk down
MediumLeetCode #1530
Solution

Every pair of leaves has exactly one lowest common ancestor, the node where the two leaves are on different sides. So each call returns an array `counts`, where `counts[d]` is the number of leaves exactly `d` steps below that node. At a node, every leaf on the left pairs with every leaf on the right, and the path between them is one step to each side plus their depths. Add `left[i] * right[j]` to the answer when that length is at most `distance`, then pass the merged counts up shifted by one.

Complexity: O(n * distance^2) time, O(h * distance) extra space

class Solution:
    def countPairs(self, root: TreeNode, distance: int) -> int:
        pairs = 0

        def dfs(node):                       # counts[d] = leaves exactly d steps below node
            nonlocal pairs
            counts = [0] * (distance + 1)
            if not node:
                return counts
            if not node.left and not node.right:
                counts[0] = 1
                return counts
            left, right = dfs(node.left), dfs(node.right)
            for i in range(distance + 1):
                for j in range(distance + 1):
                    if i + j + 2 <= distance:         # path = (i + 1) + (j + 1) steps through this node
                        pairs += left[i] * right[j]   # this node is the split for these pairs
            for d in range(distance):                 # deeper leaves can never be part of a good pair
                counts[d + 1] = left[d] + right[d]
            return counts

        dfs(root)
        return pairs

Boundary Traversal

Use this when the question is about the outer edge of the tree (its leaves, its left side or its right side), as in a boundary walk.

EasyLeetCode #872
Solution

A boundary walk has three parts: the left edge, the leaves, and the right edge. This question is the leaves part. Visit left before right, and record a node only when it has no children. That gives the bottom edge in left-to-right order, so two trees match when their leaf lists are equal.

Complexity: O(n) time, O(n) extra space

class Solution:
    def leafSimilar(self, root1: Optional[TreeNode], root2: Optional[TreeNode]) -> bool:
        def leaves(n, out):
            if not n:
                return out
            if not n.left and not n.right:   # a leaf belongs to the bottom edge
                out.append(n.val)
            leaves(n.left, out)              # left before right keeps left-to-right order
            leaves(n.right, out)
            return out
        return leaves(root1, []) == leaves(root2, [])
EasyLeetCode #404
Solution

This is the left-edge part of a boundary walk: a leaf that hangs off the left side of its parent. Pass a flag `is_left` down with every call. A node with no children counts only when its flag is True. The root has no parent, so it starts with False.

Complexity: O(n) time, O(h) extra space

class Solution:
    def sumOfLeftLeaves(self, root: Optional[TreeNode]) -> int:
        def dfs(n, is_left):                 # is_left: n hangs off its parent's left
            if not n:
                return 0
            if not n.left and not n.right:   # a leaf: count it only on the left edge
                return n.val if is_left else 0
            return dfs(n.left, True) + dfs(n.right, False)
        return dfs(root, False)
MediumLeetCode #199
Solution

This is the right-edge part of a boundary walk: the nodes you would see from the right. Walk down depth by depth, trying the right child before the left child. The first node reached at a new depth is the rightmost one there, so record it when `depth == len(edge)`. If the right side runs out, the walk falls back to the left child.

Complexity: O(n) time, O(h) extra space

class Solution:
    def rightSideView(self, root: Optional[TreeNode]) -> List[int]:
        edge = []
        def walk(n, depth):
            if not n:
                return
            if depth == len(edge):           # first node seen at this depth = rightmost
                edge.append(n.val)
            walk(n.right, depth + 1)         # right first, so the edge wins
            walk(n.left, depth + 1)
        walk(root, 0)
        return edge

Vertical Order

Use this when you must group or report nodes by column, so you tag each node with a column number while walking the tree.

EasyLeetCode #993
Solution

This introduces the key move of the pattern: each queue entry carries a tag next to the node. Here the tag is the parent. Process the tree level by level and record the parent of `x` and of `y` if they appear on this level. Cousins are on the same level with different parents. If only one of them shows up on a level, their depths differ and the answer is False.

Complexity: O(n) time, O(w) extra space (w = widest level)

from collections import deque

class Solution:
    def isCousins(self, root: Optional[TreeNode], x: int, y: int) -> bool:
        q = deque([(root, None)])            # each node carries a tag: its parent
        while q:
            found = {}                       # value -> parent, for this level only
            for _ in range(len(q)):
                node, parent = q.popleft()
                if node.val == x or node.val == y:
                    found[node.val] = parent
                if node.left:  q.append((node.left, node))
                if node.right: q.append((node.right, node))
            if len(found) == 2:              # both on this level
                return found[x] is not found[y]
            if found:                        # only one here: different depths
                return False
        return False
MediumLeetCode #655
Solution

Every node has a fixed cell in the grid, so tag each queue entry with `(node, row, col)`. The root sits in the middle column of row 0. A child is one row lower and moves left or right by an offset that halves each row. Walk the tree breadth first and write each value into `grid[row][col]`.

Complexity: O(h * 2^h) time and space (the size of the grid)

from collections import deque

class Solution:
    def printTree(self, root: Optional[TreeNode]) -> List[List[str]]:
        def height(n):                       # number of levels
            return 1 + max(height(n.left), height(n.right)) if n else 0
        rows = height(root)
        cols = 2 ** rows - 1
        grid = [[""] * cols for _ in range(rows)]
        q = deque([(root, 0, (cols - 1) // 2)])      # tag: (node, row, col)
        while q:
            node, row, col = q.popleft()
            grid[row][col] = str(node.val)
            step = (cols + 1) >> (row + 2)           # column offset halves each row
            if node.left:  q.append((node.left,  row + 1, col - step))
            if node.right: q.append((node.right, row + 1, col + step))
        return grid
HardLeetCode #987
Solution

Walk the tree breadth first with `(node, row, col)`. The root is column 0, a left child is `col - 1`, a right child is `col + 1`. Collect `(row, value)` pairs in a dict keyed by column. Sorting each column's pairs puts shallower nodes first and breaks ties at the same row by value. Output the columns from left to right.

Complexity: O(n log n) time, O(n) extra space

from collections import deque, defaultdict

class Solution:
    def verticalTraversal(self, root: Optional[TreeNode]) -> List[List[int]]:
        if not root:
            return []
        cols = defaultdict(list)
        q = deque([(root, 0, 0)])            # tag: (node, row, col)
        while q:
            node, row, col = q.popleft()
            cols[col].append((row, node.val))    # keep the row for tie-breaking
            if node.left:  q.append((node.left,  row + 1, col - 1))
            if node.right: q.append((node.right, row + 1, col + 1))
        return [[v for _, v in sorted(cols[c])] for c in sorted(cols)]
9. Binary Search Tree

A binary tree where left < node < right holds for every node. That ordering gives O(h) search, and sorted inorder.

Search

Use this when you look for a value, or a range of values, in a BST and can throw away one whole subtree at every comparison.

EasyLeetCode #700
Solution

This is the textbook BST search. Compare val with the current node: smaller goes left, larger goes right.
The loop `while node and node.val != val` stops on a match or when it falls off the tree.
Only one side is ever visited, so the cost is the height of the tree.

Complexity: O(h) time, O(1) extra space

class Solution:
    def searchBST(self, root: Optional[TreeNode], val: int) -> Optional[TreeNode]:
        node = root
        while node and node.val != val:                          # stop on a match or on a missing child
            node = node.left if val < node.val else node.right   # discard the side that cannot hold val
        return node
EasyLeetCode #938
Solution

Searching for a whole range is the same search, but sometimes both sides stay open. The BST order tells us which sides to skip.
If node.val is not above low, everything on its left is too small, so the left side is skipped.
If node.val is not below high, everything on its right is too big, so the right side is skipped.

Complexity: O(n) time in the worst case (O(h + k) for k nodes in range), O(h) extra space

class Solution:
    def rangeSumBST(self, root: Optional[TreeNode], low: int, high: int) -> int:
        total = 0
        stack = [root]
        while stack:
            node = stack.pop()
            if not node:
                continue
            if low <= node.val <= high:
                total += node.val
            if node.val > low:       # the left side can still hold values >= low
                stack.append(node.left)
            if node.val < high:      # the right side can still hold values <= high
                stack.append(node.right)
        return total
MediumLeetCode #2476
Solution

For each query we need the largest value that is at most q and the smallest value that is at least q. That is a BST search that remembers the last left turn and the last right turn.
Walking down costs O(h) per query, and h can be n on a skewed tree. So the inorder walk flattens the tree once into sorted values.
Then `bisect_left` makes the same smaller-or-larger decision in O(log n) per query.

Complexity: O(n + q log n) time, O(n) extra space

from bisect import bisect_left

class Solution:
    def closestNodes(self, root: Optional[TreeNode], queries: List[int]) -> List[List[int]]:
        values = []
        stack, node = [], root
        while stack or node:                  # iterative inorder: values come out sorted
            while node:
                stack.append(node)
                node = node.left
            node = stack.pop()
            values.append(node.val)
            node = node.right
        answer = []
        for q in queries:
            i = bisect_left(values, q)        # first index with values[i] >= q
            if i < len(values) and values[i] == q:
                answer.append([q, q])
                continue
            floor = values[i - 1] if i > 0 else -1
            ceil = values[i] if i < len(values) else -1
            answer.append([floor, ceil])
        return answer

Insert

Use this when you must add values to a BST, or rebuild a BST from an insertion order: follow the search path until a child is missing and put the new node there.

MediumLeetCode #701
Solution

Insert is a search that ends at an empty spot. The recursive call `insert(root.left, val)` returns the (possibly new) root of that side.
When `root` is None, the search has ended and `TreeNode(val)` is created there. Assigning the result back to root.left or root.right links it in.

Complexity: O(h) time, O(h) recursion stack

class Solution:
    def insertIntoBST(self, root: Optional[TreeNode], val: int) -> Optional[TreeNode]:
        if not root:                                      # the search ended: the new node goes here
            return TreeNode(val)
        if val < root.val:
            root.left = self.insertIntoBST(root.left, val)    # smaller goes left
        else:
            root.right = self.insertIntoBST(root.right, val)  # larger goes right
        return root
MediumLeetCode #1008
Solution

A preorder list shows every parent before its children. So inserting the values one by one, in the given order, rebuilds the exact tree.
The helper `insert` is the same search-then-place routine as the previous question. With n at most 100, the O(n * h) cost is fine.

Complexity: O(n * h) time, O(h) extra space

class Solution:
    def bstFromPreorder(self, preorder: List[int]) -> Optional[TreeNode]:
        root = None
        for value in preorder:             # parents come first, so plain insertion rebuilds the tree
            root = self.insert(root, value)
        return root

    def insert(self, node: Optional[TreeNode], value: int) -> TreeNode:
        if not node:                       # the search ended: place the new node
            return TreeNode(value)
        if value < node.val:
            node.left = self.insert(node.left, value)
        else:
            node.right = self.insert(node.right, value)
        return node
HardLeetCode #1569
Solution

Inserting nums in order builds one BST. The first value is the root, smaller values go left, larger go right.
Any other order gives the same tree if the root stays first and each side keeps its own internal order. The two sides can mix freely.
Choosing which positions the left values take gives comb(len - 1, len(left)) ways. Multiply that over every subtree, then subtract 1 for the original order.

Complexity: O(n^2) time, O(n) extra space

from math import comb

class Solution:
    def numOfWays(self, nums: List[int]) -> int:
        MOD = 10 ** 9 + 7
        ways = 1
        stack = [nums]                       # explicit stack: a skewed tree would be too deep to recurse
        while stack:
            seq = stack.pop()
            if len(seq) <= 2:                # 0, 1 or 2 values: only one valid order
                continue
            root = seq[0]                    # the first value is the root, as in repeated insert
            smaller = [x for x in seq[1:] if x < root]
            larger = [x for x in seq[1:] if x > root]
            ways = ways * comb(len(seq) - 1, len(smaller)) % MOD   # mix the two sides
            stack.append(smaller)
            stack.append(larger)
        return (ways - 1) % MOD              # remove the original order itself

Delete

Use this when you must remove a node from a BST and keep it valid: each recursive call returns the new root of its subtree, and the parent links to it.

MediumLeetCode #450
Solution

Search for the key with the usual left or right choice. On a match there are three cases.
With no left child or no right child, return the other child and it takes the node's place. With two children, copy the inorder successor (smallest value of the right subtree) into the node.
Then delete that successor from the right subtree with the same function.

Complexity: O(h) time, O(h) recursion stack

class Solution:
    def deleteNode(self, root: Optional[TreeNode], key: int) -> Optional[TreeNode]:
        if not root:
            return None
        if key < root.val:
            root.left = self.deleteNode(root.left, key)
        elif key > root.val:
            root.right = self.deleteNode(root.right, key)
        else:
            if not root.left:                # 0 or 1 child: the other child takes its place
                return root.right
            if not root.right:
                return root.left
            successor = root.right
            while successor.left:            # inorder successor: smallest in the right subtree
                successor = successor.left
            root.val = successor.val
            root.right = self.deleteNode(root.right, successor.val)
        return root
MediumLeetCode #669
Solution

Trimming is delete in bulk, and the BST order decides what to drop. If root.val is below low, the root and its whole left side are out, so return the trimmed right side.
If root.val is above high, return the trimmed left side. Otherwise keep the root and trim both sides, assigning the results back.

Complexity: O(n) time, O(h) recursion stack

class Solution:
    def trimBST(self, root: Optional[TreeNode], low: int, high: int) -> Optional[TreeNode]:
        if not root:
            return None
        if root.val < low:                   # root and everything on its left are too small
            return self.trimBST(root.right, low, high)
        if root.val > high:                  # root and everything on its right are too big
            return self.trimBST(root.left, low, high)
        root.left = self.trimBST(root.left, low, high)     # root stays: trim both sides
        root.right = self.trimBST(root.right, low, high)
        return root

Validate BST

Use this when you must check or repair BST order: every node has to fit a (low, high) range inherited from all its ancestors, not only from its parent.

MediumLeetCode #98
Solution

Comparing a node only with its children misses a deep node that breaks an ancestor's bound. So pass a (low, high) range down.
Going left, the node's value becomes the new high. Going right, it becomes the new low. A node outside its range fails the whole tree.

Complexity: O(n) time, O(h) recursion stack

class Solution:
    def isValidBST(self, root: Optional[TreeNode]) -> bool:
        def valid(node, low, high):
            if not node:
                return True
            if not (low < node.val < high):          # must sit inside the inherited range
                return False
            return (valid(node.left, low, node.val)      # left side: values below node
                    and valid(node.right, node.val, high))  # right side: values above node
        return valid(root, float("-inf"), float("inf"))
MediumLeetCode #99
Solution

Inorder of a BST must be increasing, so two swapped values show up as one or two dips in the inorder sequence.
Walk inorder with a `prev` node. The first dip gives `first = prev`, and the last dip gives `second = node`. Swapping their values repairs the tree.

Complexity: O(n) time, O(h) extra space

class Solution:
    def recoverTree(self, root: Optional[TreeNode]) -> None:
        first = second = prev = None
        stack, node = [], root
        while stack or node:                 # inorder walk: values should only go up
            while node:
                stack.append(node)
                node = node.left
            node = stack.pop()
            if prev and prev.val > node.val:     # a dip: these two are out of order
                if not first:
                    first = prev
                second = node
            prev = node
            node = node.right
        first.val, second.val = second.val, first.val   # swap the two wrong values
HardLeetCode #1373
Solution

Here the tree is not a BST, so we test every subtree. A bottom-up check keeps the (low, high) range as the min and max of each subtree, instead of passing it down.
Each call returns (is_bst, min, max, sum). A node is a BST root when both sides are BSTs and left_max < node.val < right_min.
The best sum seen at any valid root is the answer, and 0 stands for the empty tree.

Complexity: O(n) time, O(h) recursion stack

from math import inf

class Solution:
    def maxSumBST(self, root: Optional[TreeNode]) -> int:
        best = 0
        def dfs(node):
            nonlocal best
            if not node:
                return True, inf, -inf, 0            # empty tree: fits any range
            left_ok, left_min, left_max, left_sum = dfs(node.left)
            right_ok, right_min, right_max, right_sum = dfs(node.right)
            if left_ok and right_ok and left_max < node.val < right_min:   # the range check
                total = left_sum + right_sum + node.val
                best = max(best, total)
                return True, min(left_min, node.val), max(right_max, node.val), total
            return False, -inf, inf, 0
        dfs(root)
        return best

Kth Smallest/Largest

Use this when a BST question asks for the kth smallest or largest value, or anything about values in sorted order: inorder visits values in increasing order, and reverse inorder in decreasing order.

EasyLeetCode #530
Solution

Inorder visits a BST in increasing order, so the closest two values are always neighbours in that order.
Walk inorder and keep the previous value in `prev`. Each step compares the current value with prev, so no sorting or extra list is needed.

Complexity: O(n) time, O(h) extra space

class Solution:
    def getMinimumDifference(self, root: Optional[TreeNode]) -> int:
        best = float("inf")
        prev = None
        stack, node = [], root
        while stack or node:                 # inorder walk: values arrive sorted
            while node:
                stack.append(node)
                node = node.left
            node = stack.pop()
            if prev is not None:
                best = min(best, node.val - prev)   # only neighbours can be closest
            prev = node.val
            node = node.right
        return best
MediumLeetCode #230
Solution

Inorder visits values from smallest to largest, so the kth node visited is the kth smallest.
The stack walk goes as far left as possible, then pops a node and counts it. Counting down `k` to 0 lets us stop early without visiting the rest of the tree.

Complexity: O(h + k) time, O(h) extra space

class Solution:
    def kthSmallest(self, root: Optional[TreeNode], k: int) -> int:
        stack, node = [], root
        while stack or node:
            while node:                      # go as far left as possible: smallest first
                stack.append(node)
                node = node.left
            node = stack.pop()
            k -= 1
            if k == 0:                       # this is the kth node in sorted order
                return node.val
            node = node.right
MediumLeetCode #538
Solution

Each node must become its value plus every larger value. That is a running sum taken from the largest value downward.
Reverse inorder (right, node, left) visits values from largest to smallest, the kth largest direction. Keep `running`, add each node's value to it, and write it back.

Complexity: O(n) time, O(h) extra space

class Solution:
    def convertBST(self, root: Optional[TreeNode]) -> Optional[TreeNode]:
        running = 0
        stack, node = [], root
        while stack or node:                 # reverse inorder: right, node, left
            while node:
                stack.append(node)
                node = node.right            # largest values first
            node = stack.pop()
            running += node.val              # sum of this value and all larger ones
            node.val = running
            node = node.left
        return root

LCA in BST

Use this when you need the lowest common ancestor in a BST: it is the first node where p and q stop going the same way.

MediumLeetCode #235
Solution

Walk down from the root. If both p and q are smaller than the node, they are both in the left subtree, so go left. If both are larger, go right.
The first node where they split, or where the node equals one of them, is the LCA. No recursion and no extra memory are needed.

Complexity: O(h) time, O(1) extra space

class Solution:
    def lowestCommonAncestor(self, root: 'TreeNode', p: 'TreeNode', q: 'TreeNode') -> 'TreeNode':
        node = root
        while node:
            if p.val < node.val and q.val < node.val:       # both on the left
                node = node.left
            elif p.val > node.val and q.val > node.val:     # both on the right
                node = node.right
            else:                                           # they split here
                return node
MediumLeetCode #236
Solution

Without BST order we cannot choose a side by value, so we search both sides. The idea is still to find where p and q split.
A call returns p or q when it finds one in its subtree, and None otherwise. If left and right both return something, p and q are on different sides, so this node is the LCA.

Complexity: O(n) time, O(h) recursion stack

class Solution:
    def lowestCommonAncestor(self, root: 'TreeNode', p: 'TreeNode', q: 'TreeNode') -> 'TreeNode':
        if not root or root is p or root is q:    # found a target, or ran out of tree
            return root
        left = self.lowestCommonAncestor(root.left, p, q)
        right = self.lowestCommonAncestor(root.right, p, q)
        if left and right:                        # one on each side: the split point
            return root
        return left or right
MediumLeetCode #1123
Solution

There are no p and q here, but the idea of the split point is the same. Each call returns (depth, lca) for its subtree.
If one side is deeper, the deepest leaves are all on that side, so pass its answer up. If both sides are equally deep, the deepest leaves are on both sides, so the current node is the LCA.

Complexity: O(n) time, O(h) recursion stack

class Solution:
    def lcaDeepestLeaves(self, root: Optional[TreeNode]) -> Optional[TreeNode]:
        def dfs(node):                           # returns (depth, lca of the deepest leaves)
            if not node:
                return 0, None
            left_depth, left_lca = dfs(node.left)
            right_depth, right_lca = dfs(node.right)
            if left_depth > right_depth:         # deepest leaves are all on the left
                return left_depth + 1, left_lca
            if right_depth > left_depth:         # deepest leaves are all on the right
                return right_depth + 1, right_lca
            return left_depth + 1, node          # equal depth: the paths split here
        return dfs(root)[1]

Sorted Array → BST

Use this when you must build a height-balanced BST from sorted values: make the middle element the root and build each half the same way.

EasyLeetCode #108
Solution

Taking the middle element as the root puts half the values on each side, so the two subtrees have almost equal height. The sorted order already matches the BST order.
`build(lo, hi)` works on indices instead of slices, so no copies are made.

Complexity: O(n) time, O(log n) recursion stack

class Solution:
    def sortedArrayToBST(self, nums: List[int]) -> Optional[TreeNode]:
        def build(lo, hi):                   # builds a tree from nums[lo:hi]
            if lo >= hi:
                return None
            mid = (lo + hi) // 2             # the middle becomes the root: balanced
            return TreeNode(nums[mid], build(lo, mid), build(mid + 1, hi))
        return build(0, len(nums))
MediumLeetCode #109
Solution

A linked list has no random access, so the middle is costly to find again and again. Copy the values into an array once, then it is the same question as the array version.
`build(lo, hi)` takes the middle index as the root and recurses on each half.

Complexity: O(n) time, O(n) extra space

class Solution:
    def sortedListToBST(self, head: Optional[ListNode]) -> Optional[TreeNode]:
        values = []
        while head:                          # copy the list into an array for random access
            values.append(head.val)
            head = head.next
        def build(lo, hi):
            if lo >= hi:
                return None
            mid = (lo + hi) // 2             # the middle is the root: balanced
            return TreeNode(values[mid], build(lo, mid), build(mid + 1, hi))
        return build(0, len(values))
MediumLeetCode #1382
Solution

Inorder of a BST is already sorted, so flatten the skewed tree into a sorted list first. That gives the "sorted array" input.
Then rebuild with the middle as root. The values are the same, only the shape changes to height-balanced.

Complexity: O(n) time, O(n) extra space

class Solution:
    def balanceBST(self, root: TreeNode) -> TreeNode:
        values = []
        stack, node = [], root
        while stack or node:                 # iterative inorder: sorted values
            while node:
                stack.append(node)
                node = node.left
            node = stack.pop()
            values.append(node.val)
            node = node.right
        def build(lo, hi):
            if lo >= hi:
                return None
            mid = (lo + hi) // 2             # the middle is the root: balanced
            return TreeNode(values[mid], build(lo, mid), build(mid + 1, hi))
        return build(0, len(values))
10. Heap / Priority Queue

Gives the smallest (or largest) item in O(1) and removes it in O(log n). Use it when you keep asking for "the best so far".

Min Heap

Use this when you keep asking for the smallest item so far while new items are pushed in between the asks.

EasyLeetCode #3264
Solution

Each round needs the smallest number, and its value changes after the round. That is a push and pop on a min heap. Store (value, index) so that equal values are popped in index order, as the problem asks. Pop the smallest, multiply it, and push it back with `heappush`. Then write the heap contents back into their original positions.

Complexity: O((n + k) log n) time, O(n) extra space

import heapq

class Solution:
    def getFinalState(self, nums: List[int], k: int, multiplier: int) -> List[int]:
        heap = [(x, i) for i, x in enumerate(nums)]   # (value, index): ties pop the smaller index first
        heapq.heapify(heap)                           # build the min heap in O(n)
        for _ in range(k):
            x, i = heapq.heappop(heap)                # smallest value so far
            heapq.heappush(heap, (x * multiplier, i)) # it goes back with its new value
        result = [0] * len(nums)
        for x, i in heap:
            result[i] = x                             # heap order is not array order, so use the index
        return result
MediumLeetCode #1845
Solution

This is a design question where `reserve` always needs the smallest free seat. Keep all free seat numbers in a min heap. `reserve` is `heappop`, and `unreserve` is `heappush` of the seat that came back. The heap is the whole data structure, so each call costs O(log n).

Complexity: O(n) to build, O(log n) per reserve or unreserve, O(n) space

import heapq

class SeatManager:
    def __init__(self, n: int):
        self.free = list(range(1, n + 1))    # every seat starts free
        heapq.heapify(self.free)             # min heap of free seat numbers

    def reserve(self) -> int:
        return heapq.heappop(self.free)      # the smallest free seat

    def unreserve(self, seatNumber: int) -> None:
        heapq.heappush(self.free, seatNumber)   # the seat becomes free again
HardLeetCode #407
Solution

Water inside the grid is held in by the lowest wall around it. Put the whole border in a min heap and always pop the lowest border cell. Look at its unseen neighbours: a neighbour lower than the popped cell holds `h - height` water. The neighbour then joins the border with height `max(h, height)`. Popping the lowest wall first is what guarantees the water level is right.

Complexity: O(m n log(m n)) time, O(m n) space

import heapq

class Solution:
    def trapRainWater(self, heightMap: List[List[int]]) -> int:
        rows, cols = len(heightMap), len(heightMap[0])
        heap = []                                 # min heap of border cells: (height, row, col)
        seen = [[False] * cols for _ in range(rows)]
        for r in range(rows):
            for c in range(cols):
                if r in (0, rows - 1) or c in (0, cols - 1):
                    heapq.heappush(heap, (heightMap[r][c], r, c))
                    seen[r][c] = True
        water = 0
        while heap:
            h, r, c = heapq.heappop(heap)         # the lowest wall on the current border
            for nr, nc in ((r + 1, c), (r - 1, c), (r, c + 1), (r, c - 1)):
                if 0 <= nr < rows and 0 <= nc < cols and not seen[nr][nc]:
                    seen[nr][nc] = True
                    water += max(0, h - heightMap[nr][nc])    # the wall h holds this much water
                    heapq.heappush(heap, (max(h, heightMap[nr][nc]), nr, nc))
        return water

Max Heap

Use this when you keep asking for the largest item so far, so you store negated values in Python's min heap.

EasyLeetCode #1046
Solution

Every round smashes the two heaviest stones, so you need the largest value again and again. Store `-stone` so `heappop` returns the heaviest. Pop twice, negate both, and push the difference back if there is one. The loop ends with at most one stone left.

Complexity: O(n log n) time, O(n) space

import heapq

class Solution:
    def lastStoneWeight(self, stones: List[int]) -> int:
        heap = [-s for s in stones]               # max heap: store negatives
        heapq.heapify(heap)
        while len(heap) > 1:
            first = -heapq.heappop(heap)          # heaviest stone
            second = -heapq.heappop(heap)         # second heaviest
            if first != second:
                heapq.heappush(heap, -(first - second))   # the leftover goes back in
        return -heap[0] if heap else 0
MediumLeetCode #767
Solution

To avoid equal neighbours, always place the letter with the most copies left. Keep (negated count, letter) in a max heap. After placing a letter, hold it out for one turn so it cannot be placed twice in a row, then push it back with one fewer copy. If the heap is empty while a letter is still held, no valid order exists and the answer is an empty string.

Complexity: O(n log a) time where a is the alphabet size, O(n) space for the answer

import heapq
from collections import Counter

class Solution:
    def reorganizeString(self, s: str) -> str:
        heap = [(-count, ch) for ch, count in Counter(s).items()]   # max heap by count
        heapq.heapify(heap)
        result = []
        held = None                                # the letter we just used, waiting one turn
        while heap or held:
            if not heap:
                return ""                          # only the held letter is left, it would touch itself
            count, ch = heapq.heappop(heap)        # letter with the most copies left
            result.append(ch)
            if held:
                heapq.heappush(heap, held)         # the previous letter may be used again
                held = None
            if count + 1 < 0:                      # counts are negative: still has copies left
                held = (count + 1, ch)
        return "".join(result)
HardLeetCode #871
Solution

Drive as far as the fuel allows, and remember the fuel of every station you pass in a max heap. You do not need to decide at the station. When you cannot go further, pop the biggest tank you passed and act as if you had refuelled there. Each pop costs one stop, and the greedy choice of the biggest tank gives the fewest stops. If the heap is empty and you are still short, return -1.

Complexity: O(n log n) time, O(n) space

import heapq

class Solution:
    def minRefuelStops(self, target: int, startFuel: int, stations: List[List[int]]) -> int:
        reach = startFuel                          # farthest position we can get to so far
        passed = []                                # max heap (negated) of fuel at stations we could stop at
        stops = i = 0
        while reach < target:
            while i < len(stations) and stations[i][0] <= reach:
                heapq.heappush(passed, -stations[i][1])   # in hindsight we may refuel here
                i += 1
            if not passed:
                return -1                          # stuck: no station left to use
            reach += -heapq.heappop(passed)        # take the biggest tank we passed
            stops += 1
        return stops

Kth Largest/Smallest

Use this when the question asks for the kth largest or kth smallest value and you do not need the full sorted order.

EasyLeetCode #703
Solution

Keep a min heap that holds only the k largest numbers seen so far. Its top is the smallest of those winners, which is exactly the kth largest. After each `heappush`, if the heap has more than k items, pop the smallest. Each `add` then costs O(log k), and no sorting is ever done.

Complexity: O(log k) per add, O(k) space

import heapq

class KthLargest:
    def __init__(self, k: int, nums: List[int]):
        self.k = k
        self.heap = []                          # min heap of the k largest numbers so far
        for x in nums:
            self.add(x)

    def add(self, val: int) -> int:
        heapq.heappush(self.heap, val)
        if len(self.heap) > self.k:
            heapq.heappop(self.heap)            # kick out the smallest
        return self.heap[0]                     # smallest of the k winners = kth largest
MediumLeetCode #215
Solution

This is the same idea as the stream version, applied to a list. Push each number into a min heap and pop whenever the size passes k. What remains is the k largest numbers, and `heap[0]` is the kth largest. This is O(n log k), better than sorting when k is small. For the kth smallest you would keep a max heap of size k instead.

Complexity: O(n log k) time, O(k) space

import heapq

class Solution:
    def findKthLargest(self, nums: List[int], k: int) -> int:
        heap = []                               # min heap holding the k largest numbers
        for x in nums:
            heapq.heappush(heap, x)
            if len(heap) > k:
                heapq.heappop(heap)             # drop the smallest
        return heap[0]                          # the kth largest
MediumLeetCode #2343
Solution

Each query asks for the kth smallest trimmed number, with ties broken by the smaller index. For the kth smallest, flip the idea: keep a max heap of size k by pushing negated keys. The key is `(trimmed value, index)`, so the tuple itself settles ties. After pushing, pop when the size passes k. The top is then the largest of the k smallest, which is the kth smallest.

Complexity: O(q n log k) time, O(k) extra space

import heapq

class Solution:
    def smallestTrimmedNumbers(self, nums: List[str], queries: List[List[int]]) -> List[int]:
        answer = []
        for k, trim in queries:
            heap = []                                     # max heap (negated) of the k smallest keys
            for i, num in enumerate(nums):
                value = int(num[-trim:])                  # keep the rightmost `trim` digits
                heapq.heappush(heap, (-value, -i))        # (value, index) compared as a pair
                if len(heap) > k:
                    heapq.heappop(heap)                   # drop the largest key
            answer.append(-heap[0][1])                    # top = kth smallest, read its index
        return answer

Top K Elements

Use this when you need the K best items (most frequent, closest, highest scoring), not only the Kth one.

MediumLeetCode #347
Solution

Count each value first, then pick the K counts that are largest. Push (count, value) into a min heap and pop when the size passes k, so the least frequent candidate is kicked out. What is left in the heap is the top K. This is O(n log k) instead of sorting all the distinct values.

Complexity: O(n log k) time, O(n) space

import heapq
from collections import Counter

class Solution:
    def topKFrequent(self, nums: List[int], k: int) -> List[int]:
        heap = []                                      # min heap of (count, value), at most k items
        for value, count in Counter(nums).items():
            heapq.heappush(heap, (count, value))
            if len(heap) > k:
                heapq.heappop(heap)                    # kick out the least frequent
        return [value for count, value in heap]
MediumLeetCode #973
Solution

The K closest points are the K smallest distances, so keep a max heap of size k. Push the negated squared distance, and pop when the size passes k. The popped point is the farthest of the current K, so it cannot be a winner. Squared distance gives the same order as the real distance, so no square root is needed.

Complexity: O(n log k) time, O(k) space

import heapq

class Solution:
    def kClosest(self, points: List[List[int]], k: int) -> List[List[int]]:
        heap = []                                      # max heap (negated distance) of the k closest points
        for x, y in points:
            dist = x * x + y * y                       # squared distance, same order as the real one
            heapq.heappush(heap, (-dist, x, y))
            if len(heap) > k:
                heapq.heappop(heap)                    # drop the farthest point
        return [[x, y] for _, x, y in heap]
HardLeetCode #1383
Solution

Sort engineers by efficiency from high to low. When you reach an engineer, that engineer's efficiency is the smallest in the team, so only speed is left to choose. Keep the K largest speeds in a min heap with a running sum, and pop the slowest when the size passes k. The performance for this step is `speed_sum * eff`, and the answer is the best step.

Complexity: O(n log n) time, O(n) space

import heapq

class Solution:
    def maxPerformance(self, n: int, speed: List[int], efficiency: List[int], k: int) -> int:
        engineers = sorted(zip(efficiency, speed), reverse=True)   # best efficiency first
        heap = []                                  # min heap of the k biggest speeds so far
        speed_sum = best = 0
        for eff, spd in engineers:
            heapq.heappush(heap, spd)
            speed_sum += spd
            if len(heap) > k:
                speed_sum -= heapq.heappop(heap)   # drop the slowest engineer
            best = max(best, speed_sum * eff)      # eff is the lowest efficiency in this team
        return best % (10 ** 9 + 7)

K-way Merge

Use this when you have K sorted lists or rows and need their merged order, or the smallest values across all of them.

MediumLeetCode #378
Solution

Every row is a sorted list, so this is a K-way merge of the rows. Put the first element of each row in a min heap as (value, row, col). Pop the smallest, then push the next element of the same row. After k - 1 pops, the top of the heap is the kth smallest. The heap never holds more than one entry per row.

Complexity: O(k log min(n, k)) time, O(min(n, k)) space

import heapq

class Solution:
    def kthSmallest(self, matrix: List[List[int]], k: int) -> int:
        n = len(matrix)
        heap = [(matrix[r][0], r, 0) for r in range(min(n, k))]   # head of each row: (value, row, col)
        heapq.heapify(heap)
        for _ in range(k - 1):
            value, r, c = heapq.heappop(heap)                     # smallest head
            if c + 1 < n:
                heapq.heappush(heap, (matrix[r][c + 1], r, c + 1))   # refill from the same row
        return heap[0][0]
HardLeetCode #23
Solution

Put the head node of each list in a min heap. Pop the smallest node, attach it to the answer, and push that node's next node. The list index `i` sits between the value and the node, so two equal values never make Python compare ListNode objects. The heap holds at most k nodes, so each of the N nodes costs O(log k).

Complexity: O(N log k) time for N nodes in total, O(k) extra space

import heapq

class Solution:
    def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
        heap = [(node.val, i, node) for i, node in enumerate(lists) if node]   # head of each list
        heapq.heapify(heap)                          # list id i breaks ties, nodes are never compared
        dummy = tail = ListNode()
        while heap:
            _, i, node = heapq.heappop(heap)         # smallest head among all lists
            tail.next = node
            tail = node
            if node.next:
                heapq.heappush(heap, (node.next.val, i, node.next))   # refill from the same list
        return dummy.next
HardLeetCode #632
Solution

A range that covers all K lists must contain one number from each list. Keep one candidate per list in a min heap, and track the largest candidate in `current_max`. The heap top is the smallest candidate, so `[low, current_max]` is the best range for this moment. To shrink the range, advance the list that holds the smallest candidate. Stop when that list runs out.

Complexity: O(N log k) time for N numbers in total, O(k) space

import heapq

class Solution:
    def smallestRange(self, nums: List[List[int]]) -> List[int]:
        heap = [(row[0], i, 0) for i, row in enumerate(nums)]   # one candidate per list
        heapq.heapify(heap)
        current_max = max(row[0] for row in nums)               # largest candidate right now
        best = [heap[0][0], current_max]
        while True:
            low, i, j = heapq.heappop(heap)                     # smallest candidate
            if current_max - low < best[1] - best[0]:
                best = [low, current_max]                       # a tighter range that covers all lists
            if j + 1 == len(nums[i]):
                return best                                     # this list is used up, no more full ranges
            nxt = nums[i][j + 1]
            current_max = max(current_max, nxt)
            heapq.heappush(heap, (nxt, i, j + 1))               # refill from the same list

Two Heaps

Use this when two groups need their own best-first order at once, such as items still waiting and items ready now.

MediumLeetCode #1942
Solution

Two heaps run side by side. `free` holds the empty chair numbers, smallest first. `busy` holds (leave time, chair) for seated friends, earliest leaver first. Handle friends in arrival order. Before seating one, move every chair whose friend has left from `busy` to `free`. Then `heappop(free)` is the smallest empty chair.

Complexity: O(n log n) time, O(n) space

import heapq

class Solution:
    def smallestChair(self, times: List[List[int]], targetFriend: int) -> int:
        order = sorted(range(len(times)), key=lambda i: times[i][0])   # friends by arrival time
        free = list(range(len(times)))              # heap 1: empty chairs, smallest number first
        heapq.heapify(free)
        busy = []                                   # heap 2: (leave time, chair) of seated friends
        for i in order:
            arrive, leave = times[i]
            while busy and busy[0][0] <= arrive:    # these friends have left, their chairs are empty
                heapq.heappush(free, heapq.heappop(busy)[1])
            chair = heapq.heappop(free)             # smallest empty chair
            if i == targetFriend:
                return chair
            heapq.heappush(busy, (leave, chair))
MediumLeetCode #2462
Solution

Only the first `candidates` and the last `candidates` workers can be hired, so give each end its own min heap, `front` and `back`. Each round, compare the two tops and hire the cheaper one. A tie goes to `front`, which holds the smaller index. After a hire, slide that end inward by pushing the next unseen worker into the same heap.

Complexity: O((c + k) log c) time for c = candidates, O(c) space

import heapq

class Solution:
    def totalCost(self, costs: List[int], k: int, candidates: int) -> int:
        n = len(costs)
        front = costs[:candidates]                       # heap 1: first candidates workers
        back = costs[max(candidates, n - candidates):]   # heap 2: last candidates workers
        heapq.heapify(front)
        heapq.heapify(back)
        lo, hi = candidates, n - candidates - 1          # next unseen worker from each end
        total = 0
        for _ in range(k):
            if not back or (front and front[0] <= back[0]):   # ties go to the smaller index
                total += heapq.heappop(front)
                if lo <= hi:
                    heapq.heappush(front, costs[lo])          # slide the front window inward
                    lo += 1
            else:
                total += heapq.heappop(back)
                if lo <= hi:
                    heapq.heappush(back, costs[hi])           # slide the back window inward
                    hi -= 1
        return total
Solution

Use two heaps for two groups of projects. `locked` is a min heap by capital for projects you cannot afford yet. `ready` is a max heap by profit for projects you can afford now. Each round, move every project whose capital is at most `w` from `locked` to `ready`. Then take the most profitable ready project and add its profit to `w`, which may unlock more.

Complexity: O((n + k) log n) time, O(n) space

import heapq

class Solution:
    def findMaximizedCapital(self, k: int, w: int, profits: List[int], capital: List[int]) -> int:
        locked = list(zip(capital, profits))        # heap 1: not affordable yet, cheapest first
        heapq.heapify(locked)
        ready = []                                  # heap 2: affordable, biggest profit first (negated)
        for _ in range(k):
            while locked and locked[0][0] <= w:     # move newly affordable projects across
                cap, profit = heapq.heappop(locked)
                heapq.heappush(ready, -profit)
            if not ready:
                break                               # nothing affordable, stop early
            w += -heapq.heappop(ready)              # take the most profitable project
        return w

Median from Data Stream

Use this when numbers keep arriving (or a window slides) and you must report the median after every change.

HardLeetCode #295
Solution

Split the numbers into a lower half in a max heap `lo` (negated) and an upper half in a min heap `hi`. In `addNum`, push into `lo`, move its biggest to `hi`, and move one back if `hi` got bigger. This keeps `lo` the same size as `hi` or one larger, and every number in `lo` is at most every number in `hi`. The median is then at the tops, so `findMedian` is O(1).

Complexity: O(log n) per addNum, O(1) per findMedian, O(n) space

import heapq

class MedianFinder:
    def __init__(self):
        self.lo = []                                   # max heap (negated): the lower half
        self.hi = []                                   # min heap: the upper half

    def addNum(self, num: int) -> None:
        heapq.heappush(self.lo, -num)
        heapq.heappush(self.hi, -heapq.heappop(self.lo))     # move the biggest of lo up to hi
        if len(self.hi) > len(self.lo):                      # keep lo the same size or one bigger
            heapq.heappush(self.lo, -heapq.heappop(self.hi))

    def findMedian(self) -> float:
        if len(self.lo) > len(self.hi):
            return float(-self.lo[0])                        # odd count: the top of lo
        return (-self.lo[0] + self.hi[0]) / 2                # even count: average of the two tops
HardLeetCode #480
Solution

Use the same two heaps, `small` (a negated max heap) and `large`, but now numbers also leave the window. A heap cannot remove from the middle, so record the leaver in `buried` and only pop it when it reaches a heap top (`prune`). Track the live size of each heap separately, because the real sizes include buried values. After each add and remove, `rebalance` so `small` is the same size as `large` or one larger. The median is then read from the tops.

Complexity: O(n log k) time, O(n) space

import heapq
from collections import defaultdict

class Solution:
    def medianSlidingWindow(self, nums: List[int], k: int) -> List[float]:
        small, large = [], []                  # small: max heap (negated) lower half, large: min heap upper half
        buried = defaultdict(int)              # numbers that left the window but are still inside a heap
        size = [0, 0]                          # live sizes of small and large

        def prune(heap, sign):                 # pop buried numbers sitting on top of a heap
            while heap and buried[sign * heap[0]] > 0:
                buried[sign * heap[0]] -= 1
                heapq.heappop(heap)

        result = []
        for i, x in enumerate(nums):
            if not small or x <= -small[0]:    # add x to the half it belongs to
                heapq.heappush(small, -x); size[0] += 1
            else:
                heapq.heappush(large, x); size[1] += 1
            if i >= k:                         # the oldest number leaves the window
                out = nums[i - k]
                buried[out] += 1               # lazy delete: remove it later
                size[0 if out <= -small[0] else 1] -= 1
                prune(small, -1); prune(large, 1)
            if size[0] > size[1] + 1:          # rebalance: move one top across
                heapq.heappush(large, -heapq.heappop(small)); size[0] -= 1; size[1] += 1
                prune(small, -1)
            elif size[0] < size[1]:
                heapq.heappush(small, -heapq.heappop(large)); size[1] -= 1; size[0] += 1
                prune(large, 1)
            if i >= k - 1:
                result.append(float(-small[0]) if k % 2 else (-small[0] + large[0]) / 2)
        return result
11. Graph

Nodes and edges. Most problems hide a graph: grids, dependencies, word ladders, networks.

BFS

Use this when you need the fewest steps in an unweighted graph or grid, or when nodes must be handled level by level.

MediumLeetCode #1926
Solution

The maze is a grid graph where each open cell joins its four open neighbours, so the fewest steps to an exit is a BFS from the entrance.
The queue holds (row, col, steps). The line maze[nr][nc] = '+' turns a cell into a wall when it is pushed, which is the seen mark.
The first open border cell found (other than the entrance, which is already marked) is reached by the fewest steps, so return steps + 1 at once.
If the queue empties without reaching a border cell, there is no exit.

Complexity: O(rows * cols) time and space

from collections import deque

class Solution:
    def nearestExit(self, maze: List[List[str]], entrance: List[int]) -> int:
        rows, cols = len(maze), len(maze[0])
        q = deque([(entrance[0], entrance[1], 0)])
        maze[entrance[0]][entrance[1]] = '+'         # the entrance is seen, so it never counts as an exit
        while q:
            r, c, steps = q.popleft()
            for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
                nr, nc = r + dr, c + dc
                if 0 <= nr < rows and 0 <= nc < cols and maze[nr][nc] == '.':
                    if nr in (0, rows - 1) or nc in (0, cols - 1):
                        return steps + 1             # BFS: the first exit found is the nearest
                    maze[nr][nc] = '+'               # mark seen when pushed
                    q.append((nr, nc, steps + 1))
        return -1
MediumLeetCode #542
Solution

Searching from every 1 for its nearest 0 would repeat work. Reverse it: start from all the 0 cells together (multi-source BFS) and let the distance spread outwards.
Every 0 is queued with distance 0 and every 1 starts as -1, meaning not seen yet.
The first time a cell is reached, dist[nr][nc] == -1 turns false and its value is dist[r][c] + 1, which is already the shortest distance.

Complexity: O(rows * cols) time and space

from collections import deque

class Solution:
    def updateMatrix(self, mat: List[List[int]]) -> List[List[int]]:
        rows, cols = len(mat), len(mat[0])
        dist = [[-1] * cols for _ in range(rows)]    # -1 means not seen yet
        q = deque()
        for r in range(rows):
            for c in range(cols):
                if mat[r][c] == 0:
                    dist[r][c] = 0
                    q.append((r, c))                 # every 0 is a BFS source
        while q:
            r, c = q.popleft()
            for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
                nr, nc = r + dr, c + dc
                if 0 <= nr < rows and 0 <= nc < cols and dist[nr][nc] == -1:
                    dist[nr][nc] = dist[r][c] + 1    # first arrival is the shortest distance
                    q.append((nr, nc))
        return dist
HardLeetCode #815
Solution

The nodes to search over are the buses, not the stops, because the answer counts buses taken. Riding one bus reaches every stop on its route for the cost of 1.
First map each stop to the routes that pass through it. Then run BFS where the queue holds (stop, buses taken so far).
From a stop, board every route not yet used, and push each new stop on that route with buses + 1.
The seen_routes set matters: a route is scanned once, so the total work stays linear in the number of stops listed.

Complexity: O(total stops across all routes) time and space

from collections import deque, defaultdict

class Solution:
    def numBusesToDestination(self, routes: List[List[int]], source: int, target: int) -> int:
        if source == target:
            return 0
        stop_routes = defaultdict(list)              # stop -> indexes of routes through it
        for i, route in enumerate(routes):
            for stop in route:
                stop_routes[stop].append(i)
        q = deque([(source, 0)])
        seen_stops = {source}
        seen_routes = set()
        while q:
            stop, buses = q.popleft()
            for i in stop_routes[stop]:
                if i in seen_routes:
                    continue
                seen_routes.add(i)                   # each route is boarded at most once
                for nxt in routes[i]:
                    if nxt == target:
                        return buses + 1             # BFS level = number of buses taken
                    if nxt not in seen_stops:
                        seen_stops.add(nxt)
                        q.append((nxt, buses + 1))
        return -1

DFS

Use this when you must explore everything reachable, list or count paths, or try choices and backtrack.

EasyLeetCode #733
Solution

Start at (sr, sc) and recurse into the four neighbours that still have the old colour.
Painting a cell with the new colour is also the seen mark, so no cell is visited twice.
The early return when old == color matters: if nothing would change, the recursion would never stop.

Complexity: O(rows * cols) time, O(rows * cols) recursion stack in the worst case

class Solution:
    def floodFill(self, image: List[List[int]], sr: int, sc: int, color: int) -> List[List[int]]:
        old = image[sr][sc]
        if old == color:                     # nothing to paint, and no seen mark would ever be set
            return image
        rows, cols = len(image), len(image[0])

        def dfs(r, c):
            if not (0 <= r < rows and 0 <= c < cols) or image[r][c] != old:
                return
            image[r][c] = color              # painting doubles as the seen mark
            for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
                dfs(r + dr, c + dc)          # explore each neighbour fully before the next

        dfs(sr, sc)
        return image
MediumLeetCode #797
Solution

Listing paths is DFS with backtracking: append the next node to path, recurse, then pop it.
When the DFS reaches the last node, copy the current path into the answer (the copy matters because path keeps changing).
The graph is a DAG, so there are no cycles and no seen set is needed. A seen set would hide valid paths that share a node.

Complexity: O(2^n * n) time in the worst case, O(n) extra space for the path

class Solution:
    def allPathsSourceTarget(self, graph: List[List[int]]) -> List[List[int]]:
        target = len(graph) - 1
        paths, path = [], [0]

        def dfs(u):
            if u == target:
                paths.append(path[:])        # copy: path is reused by later branches
                return
            for v in graph[u]:
                path.append(v)               # choose
                dfs(v)                       # explore
                path.pop()                   # un-choose (backtrack)

        dfs(0)
        return paths
HardLeetCode #332
Solution

The tickets are directed edges and the route must use every one exactly once, so this is a DFS that walks edges, not nodes.
From JFK, always take the smallest destination next. Add an airport to route only after all of its tickets are used (post-order).
Reading route backwards gives the itinerary, because the airport where we get stuck is the last stop.
Sorting in reverse order lets list.pop() hand out the smallest name in O(1).

Complexity: O(E log E) time, O(E) space

from collections import defaultdict

class Solution:
    def findItinerary(self, tickets: List[List[str]]) -> List[str]:
        adj = defaultdict(list)
        for src, dst in sorted(tickets, reverse=True):   # reverse sort: pop() gives the smallest name
            adj[src].append(dst)
        route = []

        def dfs(airport):
            while adj[airport]:
                dfs(adj[airport].pop())                  # use each ticket once
            route.append(airport)                        # post-order: add after all tickets are used

        dfs("JFK")
        return route[::-1]

Connected Components

Use this when the question asks how many separate groups, islands or provinces there are, or how big each group is.

MediumLeetCode #547
Solution

Cities are nodes and isConnected is an adjacency matrix.
Loop over every city. Each time one is unseen, a new province starts, so run dfs to mark its whole group and add 1.
The number of times dfs is launched from the outer loop is the number of components.

Complexity: O(n^2) time, O(n) space

class Solution:
    def findCircleNum(self, isConnected: List[List[int]]) -> int:
        n = len(isConnected)
        seen = set()

        def dfs(u):
            seen.add(u)
            for v in range(n):
                if isConnected[u][v] and v not in seen:
                    dfs(v)

        provinces = 0
        for city in range(n):            # loop over all nodes
            if city not in seen:         # an unseen node starts a new component
                dfs(city)                # flood-fill its whole group
                provinces += 1
        return provinces
MediumLeetCode #200
Solution

Same idea on a grid: each cell is a node and its neighbours are the 4 direction vectors.
Scan every cell. A land cell that has not been visited starts a new island, so run dfs and add 1.
The dfs sinks each land cell it visits (writes "0"), which acts as the seen mark and stops the same island being counted twice.

Complexity: O(rows * cols) time and space

class Solution:
    def numIslands(self, grid: List[List[str]]) -> int:
        rows, cols = len(grid), len(grid[0])

        def dfs(r, c):
            if not (0 <= r < rows and 0 <= c < cols) or grid[r][c] != "1":
                return
            grid[r][c] = "0"                     # sink the land so it is never counted again
            for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
                dfs(r + dr, c + dc)

        islands = 0
        for r in range(rows):
            for c in range(cols):
                if grid[r][c] == "1":            # unseen land: a new component
                    dfs(r, c)
                    islands += 1
        return islands
HardLeetCode #827
Solution

First find the components: run dfs on each island, label its cells with a new id (2, 3, ...) and store its area in size.
Then try every water cell. Flipping it joins the distinct islands around it, so the new area is 1 plus their sizes.
The set touching removes duplicates, so one island that borders the cell on two sides is counted once.
If there is no water, the answer is the biggest island, which is the starting value of best.

Complexity: O(n^2) time and space

class Solution:
    def largestIsland(self, grid: List[List[int]]) -> int:
        n = len(grid)
        dirs = ((1, 0), (-1, 0), (0, 1), (0, -1))
        size = {}                                    # island id -> area

        def dfs(r, c, island_id):
            grid[r][c] = island_id                   # label the cell with its component id
            area = 1
            for dr, dc in dirs:
                nr, nc = r + dr, c + dc
                if 0 <= nr < n and 0 <= nc < n and grid[nr][nc] == 1:
                    area += dfs(nr, nc, island_id)
            return area

        next_id = 2                                  # ids start at 2 so they differ from 0 and 1
        for r in range(n):
            for c in range(n):
                if grid[r][c] == 1:
                    size[next_id] = dfs(r, c, next_id)
                    next_id += 1
        best = max(size.values(), default=0)
        for r in range(n):
            for c in range(n):
                if grid[r][c] == 0:
                    touching = {grid[r + dr][c + dc] for dr, dc in dirs
                                if 0 <= r + dr < n and 0 <= c + dc < n and grid[r + dr][c + dc] > 1}
                    best = max(best, 1 + sum(size[i] for i in touching))   # flip this water cell
        return best

Cycle Detection

Use this when you must know whether a graph has a cycle: prerequisites that can never finish, a loop in a grid, or a walk that returns to itself.

MediumLeetCode #207
Solution

A prerequisite cycle is a directed cycle, so a node seen before is not enough. Use DFS with three states: unvisited, in the current path, done.
Reaching a node that is in the current path means a cycle, so the courses cannot all be finished.
Setting a node to DONE after its neighbours are explored means it is never explored again.

Complexity: O(V + E) time, O(V + E) space

from collections import defaultdict

class Solution:
    def canFinish(self, numCourses: int, prerequisites: List[List[int]]) -> bool:
        adj = defaultdict(list)
        for course, pre in prerequisites:
            adj[pre].append(course)
        UNVISITED, IN_PATH, DONE = 0, 1, 2
        state = [UNVISITED] * numCourses

        def has_cycle(u):
            state[u] = IN_PATH                  # u is on the current DFS path
            for v in adj[u]:
                if state[v] == IN_PATH:         # back to a node on our own path: cycle
                    return True
                if state[v] == UNVISITED and has_cycle(v):
                    return True
            state[u] = DONE                     # fully explored, safe to skip next time
            return False

        return not any(state[c] == UNVISITED and has_cycle(c) for c in range(numCourses))
MediumLeetCode #1559
Solution

This graph is undirected, so the rule is: a cycle exists when the walk meets a seen cell that is not the cell it came from.
Each stack entry carries its parent (pr, pc). Skipping the parent is the key line.
Any other same-letter neighbour that is already seen closes a loop. In a grid, such a loop always has at least 4 cells, so no extra length check is needed.
The loop uses an explicit stack, so a large grid cannot overflow the recursion limit.

Complexity: O(rows * cols) time and space

class Solution:
    def containsCycle(self, grid: List[List[str]]) -> bool:
        rows, cols = len(grid), len(grid[0])
        seen = [[False] * cols for _ in range(rows)]
        for sr in range(rows):
            for sc in range(cols):
                if seen[sr][sc]:
                    continue
                seen[sr][sc] = True
                stack = [(sr, sc, -1, -1)]              # (row, col, parent row, parent col)
                while stack:
                    r, c, pr, pc = stack.pop()
                    for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
                        nr, nc = r + dr, c + dc
                        if not (0 <= nr < rows and 0 <= nc < cols) or grid[nr][nc] != grid[r][c]:
                            continue
                        if (nr, nc) == (pr, pc):        # the cell we came from is not a cycle
                            continue
                        if seen[nr][nc]:                # seen and not our parent: cycle
                            return True
                        seen[nr][nc] = True
                        stack.append((nr, nc, r, c))
        return False
HardLeetCode #2360
Solution

Every node has at most one outgoing edge, so each walk is a single path that either ends or runs into a node already seen.
Give each node a global time stamp when it is first reached. Remember the stamp where the current walk began (begin).
If the walk lands on a node with dist[u] >= begin, that node is on the current path (the "in-path" state), so the cycle length is clock - dist[u].
If it lands on a node from an earlier walk, that node is finished (the "done" state) and gives no new cycle. Each node is stamped once.

Complexity: O(n) time, O(n) space

class Solution:
    def longestCycle(self, edges: List[int]) -> int:
        n = len(edges)
        dist = [0] * n                      # time stamp of first visit, 0 = unvisited
        clock = 1
        best = -1
        for start in range(n):
            if dist[start]:
                continue
            begin = clock                   # stamps from this walk are >= begin
            u = start
            while u != -1 and not dist[u]:
                dist[u] = clock
                clock += 1
                u = edges[u]
            if u != -1 and dist[u] >= begin:        # landed on the current path: a new cycle
                best = max(best, clock - dist[u])
        return best

Bipartite Graph

Use this when nodes must be split into two sides so that no edge joins two nodes on the same side (conflicts, dislikes, odd cycles).

MediumLeetCode #785
Solution

Two-colour the graph with BFS. A start node gets colour 0, and each unseen neighbour gets the opposite colour with color[u] ^ 1.
If an edge joins two nodes with the same colour, there is an odd cycle, so the answer is False.
The outer loop over s starts a new BFS in every component, because the graph may be disconnected.

Complexity: O(V + E) time, O(V) space

from collections import deque

class Solution:
    def isBipartite(self, graph: List[List[int]]) -> bool:
        n = len(graph)
        color = [-1] * n
        for s in range(n):                          # graph may be disconnected
            if color[s] != -1:
                continue
            color[s] = 0
            q = deque([s])
            while q:
                u = q.popleft()
                for v in graph[u]:
                    if color[v] == -1:
                        color[v] = color[u] ^ 1     # neighbours get the opposite colour
                        q.append(v)
                    elif color[v] == color[u]:      # same colour on both ends: odd cycle
                        return False
        return True
MediumLeetCode #886
Solution

Each dislike pair is an edge. Splitting people into two groups where nobody shares a group with someone they dislike is the same as 2-colouring this graph.
Build the adjacency list (people are numbered from 1, so it has n + 1 slots), then colour each component with BFS.
Meeting a neighbour with the same colour means an odd cycle of dislikes, so the answer is False.

Complexity: O(n + E) time, O(n + E) space

from collections import deque, defaultdict

class Solution:
    def possibleBipartition(self, n: int, dislikes: List[List[int]]) -> bool:
        adj = defaultdict(list)
        for a, b in dislikes:
            adj[a].append(b)
            adj[b].append(a)
        color = [-1] * (n + 1)
        for s in range(1, n + 1):
            if color[s] != -1:
                continue
            color[s] = 0
            q = deque([s])
            while q:
                u = q.popleft()
                for v in adj[u]:
                    if color[v] == -1:
                        color[v] = color[u] ^ 1     # put the disliked person in the other group
                        q.append(v)
                    elif color[v] == color[u]:      # both would be in the same group: impossible
                        return False
        return True
HardLeetCode #2493
Solution

Every edge must join nodes in neighbouring groups, so groups behave like BFS levels and each component must be bipartite.
BFS from a node: an edge between two nodes on the same level means an odd cycle, so return -1.
For one component, the number of BFS levels from a start node is the number of groups it can form. Try every node as the start and keep the best.
The component is identified by its smallest node label. Add the best value of each component.

Complexity: O(n * (n + m)) time, O(n + m) space

from collections import deque

class Solution:
    def magnificentSets(self, n: int, edges: List[List[int]]) -> int:
        adj = [[] for _ in range(n + 1)]
        for u, v in edges:
            adj[u].append(v)
            adj[v].append(u)

        def bfs(src):
            level = {src: 0}
            q = deque([src])
            while q:
                u = q.popleft()
                for v in adj[u]:
                    if v not in level:
                        level[v] = level[u] + 1
                        q.append(v)
                    elif level[v] == level[u]:      # same level on both ends: odd cycle
                        return -1, 0
            return max(level.values()) + 1, min(level)   # groups, component id

        best = {}                                    # component id -> most groups
        for start in range(1, n + 1):
            groups, comp = bfs(start)
            if groups == -1:
                return -1
            best[comp] = max(best.get(comp, 0), groups)
        return sum(best.values())

Topological Sort

Use this when an edge means "A must come before B" and you need a valid order, a cycle check, or the longest chain through the dependencies.

MediumLeetCode #210
Solution

Kahn's algorithm. Count each course's prerequisites (in-degree) and start the queue with the courses that have none.
Pop a course, append it to order, and lower the in-degree of every course it unlocks. A course whose in-degree reaches 0 joins the queue.
If order ends up shorter than numCourses, the rest are stuck in a cycle, so return an empty list.

Complexity: O(V + E) time, O(V + E) space

from collections import deque, defaultdict

class Solution:
    def findOrder(self, numCourses: int, prerequisites: List[List[int]]) -> List[int]:
        adj, indeg = defaultdict(list), [0] * numCourses
        for course, pre in prerequisites:
            adj[pre].append(course)             # edge: pre -> course
            indeg[course] += 1
        q = deque(i for i in range(numCourses) if indeg[i] == 0)
        order = []
        while q:
            u = q.popleft()
            order.append(u)
            for v in adj[u]:
                indeg[v] -= 1                   # one prerequisite of v is now done
                if indeg[v] == 0:
                    q.append(v)
        return order if len(order) == numCourses else []    # short = cycle exists
MediumLeetCode #2115
Solution

Make each ingredient a node with an edge to every recipe that needs it. A recipe's in-degree is the number of ingredients it needs.
The queue starts with the supplies, which need nothing. When a recipe's in-degree drops to 0 it can be made, so record it and push it, because it may be an ingredient of another recipe.
Recipes in a cycle, or missing an ingredient, never reach in-degree 0 and are left out.

Complexity: O(V + E) time, O(V + E) space

from collections import deque, defaultdict

class Solution:
    def findAllRecipes(self, recipes: List[str], ingredients: List[List[str]], supplies: List[str]) -> List[str]:
        adj = defaultdict(list)                  # ingredient -> recipes that need it
        indeg = {}
        for recipe, needs in zip(recipes, ingredients):
            indeg[recipe] = len(needs)
            for item in needs:
                adj[item].append(recipe)
        q = deque(supplies)                      # supplies are the nodes with in-degree 0
        made = []
        while q:
            item = q.popleft()
            for recipe in adj[item]:
                indeg[recipe] -= 1
                if indeg[recipe] == 0:           # every ingredient is available
                    made.append(recipe)
                    q.append(recipe)
        return made
HardLeetCode #2050
Solution

A course can start only after all its prerequisites finish, so it starts at the latest finish time among them.
Process the courses in Kahn's order and keep start[v] = the largest finish time seen so far for v. When u is popped its finish time is start[u] + time[u].
The answer is the largest finish time. This is the longest path in a DAG, computed while taking the topological order.

Complexity: O(n + E) time, O(n + E) space

from collections import deque, defaultdict

class Solution:
    def minimumTime(self, n: int, relations: List[List[int]], time: List[int]) -> int:
        adj, indeg = defaultdict(list), [0] * n
        for prev, nxt in relations:
            adj[prev - 1].append(nxt - 1)
            indeg[nxt - 1] += 1
        start = [0] * n                          # earliest month each course can begin
        q = deque(i for i in range(n) if indeg[i] == 0)
        answer = 0
        while q:
            u = q.popleft()
            end = start[u] + time[u]
            answer = max(answer, end)
            for v in adj[u]:
                start[v] = max(start[v], end)    # v waits for its slowest prerequisite
                indeg[v] -= 1
                if indeg[v] == 0:
                    q.append(v)
        return answer

Shortest Path

Use this when every move costs 1 (BFS) or only 0 or 1 (0-1 BFS with a deque), and the state may need more than the node itself.

MediumLeetCode #1091
Solution

Every move costs 1, so BFS finds the shortest path. The only change from a normal grid BFS is 8 neighbours, which two loops over (-1, 0, 1) produce.
The queue entry carries the path length so far, and the first time the bottom-right cell is popped, that length is the answer.
Writing 1 into a cell when it is queued marks it as seen. A blocked start or end cell returns -1 at once.

Complexity: O(n^2) time, O(n^2) space

from collections import deque

class Solution:
    def shortestPathBinaryMatrix(self, grid: List[List[int]]) -> int:
        n = len(grid)
        if grid[0][0] == 1 or grid[n - 1][n - 1] == 1:
            return -1
        grid[0][0] = 1                              # mark seen
        q = deque([(0, 0, 1)])                      # (row, col, path length so far)
        while q:
            r, c, dist = q.popleft()
            if r == n - 1 and c == n - 1:
                return dist                         # BFS: first arrival is the shortest
            for dr in (-1, 0, 1):
                for dc in (-1, 0, 1):               # 8 directions
                    nr, nc = r + dr, c + dc
                    if 0 <= nr < n and 0 <= nc < n and grid[nr][nc] == 0:
                        grid[nr][nc] = 1
                        q.append((nr, nc, dist + 1))
        return -1
MediumLeetCode #1129
Solution

The best way into a node depends on the colour of the last edge used, so a BFS state is (node, colour of last edge), not the node alone.
From a state, only edges of the other colour may be taken: nxt = color ^ 1. Start at node 0 with both colours so either colour can go first.
The first time a node is popped in any state, its step count is the shortest alternating path. The seen set is over states.

Complexity: O(n + E) time, O(n + E) space

from collections import deque, defaultdict

class Solution:
    def shortestAlternatingPaths(self, n: int, redEdges: List[List[int]], blueEdges: List[List[int]]) -> List[int]:
        adj = {0: defaultdict(list), 1: defaultdict(list)}   # 0 = red, 1 = blue
        for u, v in redEdges:
            adj[0][u].append(v)
        for u, v in blueEdges:
            adj[1][u].append(v)
        answer = [-1] * n
        q = deque([(0, 0, 0), (0, 1, 0)])           # (node, colour of last edge, steps)
        seen = {(0, 0), (0, 1)}                     # states, not only nodes
        while q:
            u, color, steps = q.popleft()
            if answer[u] == -1:
                answer[u] = steps                   # first arrival is the shortest
            nxt = color ^ 1                         # next edge must have the other colour
            for v in adj[nxt][u]:
                if (v, nxt) not in seen:
                    seen.add((v, nxt))
                    q.append((v, nxt, steps + 1))
        return answer
HardLeetCode #2290
Solution

Stepping into an empty cell costs 0 and into an obstacle costs 1, so edge weights are only 0 or 1. That is the case for 0-1 BFS.
Use a deque instead of a heap: a cost-0 move goes to the front (appendleft), a cost-1 move goes to the back. The deque stays ordered by distance, like Dijkstra's heap but in O(1) per push.
dist[r][c] is the fewest obstacles removed to reach that cell, and a cell is re-queued only when its distance improves.

Complexity: O(rows * cols) time and space

from collections import deque

class Solution:
    def minimumObstacles(self, grid: List[List[int]]) -> int:
        rows, cols = len(grid), len(grid[0])
        dist = [[float("inf")] * cols for _ in range(rows)]
        dist[0][0] = 0
        dq = deque([(0, 0)])
        while dq:
            r, c = dq.popleft()
            for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
                nr, nc = r + dr, c + dc
                if 0 <= nr < rows and 0 <= nc < cols:
                    cost = dist[r][c] + grid[nr][nc]     # entering an obstacle costs 1
                    if cost < dist[nr][nc]:
                        dist[nr][nc] = cost
                        if grid[nr][nc] == 0:
                            dq.appendleft((nr, nc))      # weight 0: front of the deque
                        else:
                            dq.append((nr, nc))          # weight 1: back of the deque
        return dist[rows - 1][cols - 1]

Dijkstra

Use this when edges have non-negative weights (or a cost that never goes down) and you need the cheapest route from one source.

MediumLeetCode #743
Solution

Directed weighted graph, one source k, all weights non-negative: the textbook Dijkstra case.
Keep a min heap of (distance, node). Pop the closest one; if d > dist[u] it is a stale entry, so skip it.
Otherwise relax every outgoing edge and push any improved distance. The signal reaches everyone at the largest distance, or -1 if some node stays at infinity.

Complexity: O((V + E) log V) time, O(V + E) space

import heapq
from math import inf
from collections import defaultdict

class Solution:
    def networkDelayTime(self, times: List[List[int]], n: int, k: int) -> int:
        adj = defaultdict(list)
        for u, v, w in times:
            adj[u].append((v, w))
        dist = [inf] * (n + 1)
        dist[k] = 0
        heap = [(0, k)]
        while heap:
            d, u = heapq.heappop(heap)          # closest unsettled node
            if d > dist[u]:
                continue                        # stale entry
            for v, w in adj[u]:
                if d + w < dist[v]:             # relax the edge
                    dist[v] = d + w
                    heapq.heappush(heap, (dist[v], v))
        slowest = max(dist[1:])
        return slowest if slowest < inf else -1
MediumLeetCode #1976
Solution

Run Dijkstra from city 0, and also keep ways[v], the number of shortest paths that reach v.
If d + t is strictly shorter than dist[v], the old paths are worse, so replace: ways[v] = ways[u]. If it is equal, another shortest path exists, so add: ways[v] += ways[u].
Every road takes at least 1 minute, so ways[u] is complete by the time u is popped. Take the answer modulo 10^9 + 7.

Complexity: O((V + E) log V) time, O(V + E) space

import heapq
from math import inf
from collections import defaultdict

class Solution:
    def countPaths(self, n: int, roads: List[List[int]]) -> int:
        MOD = 10**9 + 7
        adj = defaultdict(list)
        for u, v, t in roads:
            adj[u].append((v, t))
            adj[v].append((u, t))
        dist = [inf] * n
        dist[0] = 0
        ways = [0] * n
        ways[0] = 1
        heap = [(0, 0)]
        while heap:
            d, u = heapq.heappop(heap)
            if d > dist[u]:
                continue                        # stale entry
            for v, t in adj[u]:
                if d + t < dist[v]:             # strictly shorter: forget the old count
                    dist[v] = d + t
                    ways[v] = ways[u]
                    heapq.heappush(heap, (dist[v], v))
                elif d + t == dist[v]:          # tie: one more shortest path
                    ways[v] = (ways[v] + ways[u]) % MOD
        return ways[n - 1] % MOD
HardLeetCode #2577
Solution

A cell can only be entered when the clock has reached its value, and you can wait only by stepping back and forth, which costs 2 minutes at a time. So the arrival time into a neighbour keeps the parity of t + 1.
The earliest arrival is max(t + 1, value + (value - t - 1) % 2). Costs never decrease, so Dijkstra with this arrival formula still works.
If both cells next to the start are locked past minute 1, you cannot even bounce, so the answer is -1. Otherwise every cell is reachable.

Complexity: O(rows * cols * log(rows * cols)) time, O(rows * cols) space

import heapq
from math import inf

class Solution:
    def minimumTime(self, grid: List[List[int]]) -> int:
        if grid[0][1] > 1 and grid[1][0] > 1:          # cannot make the first move or wait at the start
            return -1
        rows, cols = len(grid), len(grid[0])
        dist = [[inf] * cols for _ in range(rows)]
        dist[0][0] = 0
        heap = [(0, 0, 0)]                             # (time, row, col)
        while heap:
            t, r, c = heapq.heappop(heap)
            if t > dist[r][c]:
                continue                               # stale entry
            if r == rows - 1 and c == cols - 1:
                return t
            for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
                nr, nc = r + dr, c + dc
                if 0 <= nr < rows and 0 <= nc < cols:
                    # wait by bouncing back and forth: arrival keeps the parity of t + 1
                    arrive = max(t + 1, grid[nr][nc] + (grid[nr][nc] - t - 1) % 2)
                    if arrive < dist[nr][nc]:
                        dist[nr][nc] = arrive
                        heapq.heappush(heap, (arrive, nr, nc))
        return -1

Bellman-Ford

Use this when you need single-source shortest paths with a cap on the number of edges, with negative weights, or when relaxing every edge in rounds is the simplest model.

MediumLeetCode #787
Solution

Bellman-Ford with a fixed number of rounds: after round i, dist holds the cheapest price using at most i flights.
At most k stops means at most k + 1 flights, so run exactly k + 1 rounds and relax every edge in each.
Each round reads from dist but writes to a copy (nxt), so one round cannot chain two flights together. That copy is the key line.

Complexity: O(k * E) time, O(n) space

from math import inf

class Solution:
    def findCheapestPrice(self, n: int, flights: List[List[int]], src: int, dst: int, k: int) -> int:
        dist = [inf] * n
        dist[src] = 0
        for _ in range(k + 1):                       # k stops = k + 1 flights
            nxt = dist[:]                            # read old values, write new ones
            for u, v, price in flights:
                if dist[u] + price < nxt[v]:         # relax the edge
                    nxt[v] = dist[u] + price
            dist = nxt
        return dist[dst] if dist[dst] < inf else -1
MediumLeetCode #1514
Solution

Same relax-every-edge loop, but it maximises a product of probabilities instead of minimising a sum. best[v] is the highest probability found so far to reach v.
The edges are undirected, so each one is relaxed in both directions.
A best path has at most n - 1 edges and probabilities never exceed 1, so n - 1 rounds are enough. Stop early when a full pass changes nothing. On the largest inputs Dijkstra with a max heap is faster.

Complexity: O(n * E) time worst case (usually far less with the early exit), O(n) space

class Solution:
    def maxProbability(self, n: int, edges: List[List[int]], succProb: List[float], start_node: int, end_node: int) -> float:
        best = [0.0] * n
        best[start_node] = 1.0
        for _ in range(n - 1):
            changed = False
            for (u, v), p in zip(edges, succProb):
                if best[u] * p > best[v]:            # relax u -> v
                    best[v] = best[u] * p
                    changed = True
                if best[v] * p > best[u]:            # relax v -> u (undirected)
                    best[u] = best[v] * p
                    changed = True
            if not changed:                          # nothing improved: done early
                break
        return best[end_node]
HardLeetCode #1928
Solution

Bellman-Ford again, but the rounds are minutes instead of edge counts: dp[t][v] is the cheapest cost to be standing in city v at exactly minute t.
For each minute t, relax every road both ways: arriving at v at minute t from u at minute t - w costs dp[t - w][u] plus the fee of v.
The answer is the cheapest dp[t][n - 1] over all t up to maxTime, or -1 if none is finite.

Complexity: O(maxTime * E) time, O(maxTime * n) space

from math import inf

class Solution:
    def minCost(self, maxTime: int, edges: List[List[int]], passingFees: List[int]) -> int:
        n = len(passingFees)
        dp = [[inf] * n for _ in range(maxTime + 1)]   # dp[t][v]: cheapest way to be in v at minute t
        dp[0][0] = passingFees[0]
        for t in range(1, maxTime + 1):
            for u, v, w in edges:                      # relax every road in this round
                if w <= t:
                    dp[t][v] = min(dp[t][v], dp[t - w][u] + passingFees[v])
                    dp[t][u] = min(dp[t][u], dp[t - w][v] + passingFees[u])
        best = min(dp[t][n - 1] for t in range(maxTime + 1))
        return best if best < inf else -1

Floyd-Warshall

Use this when the graph is small (a few hundred nodes at most) and you need the shortest distance, or only reachability, between every pair.

MediumLeetCode #1334
Solution

The question needs the distance from every city to every other city, and n is at most 100, so run Floyd-Warshall to fill dist[i][j].
The loop over k, the intermediate city, must be the outermost one. Then count, for each city, how many others are within the threshold.
Pick the city with the fewest; on a tie take the larger index, which is why the comparison is <=.

Complexity: O(n^3) time, O(n^2) space

from math import inf

class Solution:
    def findTheCity(self, n: int, edges: List[List[int]], distanceThreshold: int) -> int:
        dist = [[inf] * n for _ in range(n)]
        for i in range(n):
            dist[i][i] = 0
        for u, v, w in edges:
            dist[u][v] = dist[v][u] = w
        for k in range(n):                           # k (the intermediate node) is the OUTER loop
            for i in range(n):
                for j in range(n):
                    if dist[i][k] + dist[k][j] < dist[i][j]:
                        dist[i][j] = dist[i][k] + dist[k][j]
        best_city, fewest = -1, inf
        for i in range(n):
            reach = sum(1 for j in range(n) if j != i and dist[i][j] <= distanceThreshold)
            if reach <= fewest:                      # ties go to the larger index
                best_city, fewest = i, reach
        return best_city
MediumLeetCode #1462
Solution

Floyd-Warshall can also compute reachability. Let reach[i][j] be True when course i is a prerequisite of course j, directly or through a chain.
Use the same three loops with k outermost, but replace min/+ with or/and: reach[i][j] becomes True if reach[i][k] and reach[k][j].
After that, every query is one table lookup.

Complexity: O(n^3 + q) time, O(n^2) space

class Solution:
    def checkIfPrerequisite(self, numCourses: int, prerequisites: List[List[int]], queries: List[List[int]]) -> List[bool]:
        n = numCourses
        reach = [[False] * n for _ in range(n)]
        for a, b in prerequisites:
            reach[a][b] = True                       # direct prerequisite
        for k in range(n):                           # intermediate course is the OUTER loop
            for i in range(n):
                for j in range(n):
                    if reach[i][k] and reach[k][j]:  # i -> k -> j
                        reach[i][j] = True
        return [reach[a][b] for a, b in queries]
HardLeetCode #2642
Solution

Keep the full distance table and build it once with Floyd-Warshall. A query is then a lookup.
When an edge (u, v, w) is added, any new shortest path uses it once, so it has the form i -> u -> v -> j. One O(n^2) sweep over all pairs with dist[i][u] + w + dist[v][j] updates the table, no need to rerun all three loops.
A distance of infinity is reported as -1.

Complexity: constructor O(n^3), addEdge O(n^2), shortestPath O(1); O(n^2) space

from math import inf

class Graph:
    def __init__(self, n: int, edges: List[List[int]]):
        self.n = n
        self.dist = [[inf] * n for _ in range(n)]
        for i in range(n):
            self.dist[i][i] = 0
        for u, v, w in edges:
            self.dist[u][v] = min(self.dist[u][v], w)
        for k in range(n):                            # Floyd-Warshall, k outermost
            for i in range(n):
                for j in range(n):
                    if self.dist[i][k] + self.dist[k][j] < self.dist[i][j]:
                        self.dist[i][j] = self.dist[i][k] + self.dist[k][j]

    def addEdge(self, edge: List[int]) -> None:
        u, v, w = edge
        d = self.dist
        for i in range(self.n):
            for j in range(self.n):
                # a new shortest path uses the new edge once: i -> u -> v -> j
                d[i][j] = min(d[i][j], d[i][u] + w + d[v][j])

    def shortestPath(self, node1: int, node2: int) -> int:
        d = self.dist[node1][node2]
        return -1 if d == inf else d

Minimum Spanning Tree

Use this when you must connect all nodes with the least total cost (V - 1 edges, no cycle), or decide which edges every cheapest tree needs.

MediumLeetCode #1584
Solution

Every pair of points is an edge weighted by Manhattan distance, so the graph is dense and the answer is the weight of its MST. Prim fits a dense graph.
Grow the tree from point 0. key[v] is the cheapest edge from the tree to v. Each step adds the outside point with the smallest key and adds that key to the total, then updates the keys of the rest.
A plain scan finds the smallest key, so no heap is needed: O(n) per step and O(n^2) overall, instead of sorting about n^2 / 2 edges.

Complexity: O(n^2) time, O(n) space

from math import inf

class Solution:
    def minCostConnectPoints(self, points: List[List[int]]) -> int:
        n = len(points)
        key = [inf] * n                          # cheapest edge from the tree to each point
        key[0] = 0
        in_tree = [False] * n
        total = 0
        for _ in range(n):
            u = -1
            for v in range(n):                   # cheapest point still outside the tree
                if not in_tree[v] and (u == -1 or key[v] < key[u]):
                    u = v
            in_tree[u] = True
            total += key[u]                      # this edge joins u to the tree
            for v in range(n):
                if not in_tree[v]:
                    d = abs(points[u][0] - points[v][0]) + abs(points[u][1] - points[v][1])
                    key[v] = min(key[v], d)      # u may now be the closest tree point to v
        return total
HardLeetCode #1579
Solution

Alice and Bob each need a spanning tree, so keep one DSU per person. Each person's tree needs exactly n - 1 useful unions, and every other edge can be removed.
An edge usable by both (type 3) serves two people at once, so take those first, the way Kruskal takes the cheapest edge first. Then add the single-person edges.
An edge is kept only if union returns True for someone. If either person ends with fewer than n - 1 unions, the graph is not fully traversable and the answer is -1. Otherwise removable = total edges - kept edges.

Complexity: O(E * alpha(n)) time, O(n) space

class DSU:
    def __init__(self, n):
        self.p = list(range(n))
        self.sz = [1] * n

    def find(self, x):
        while self.p[x] != x:
            self.p[x] = self.p[self.p[x]]       # path compression
            x = self.p[x]
        return x

    def union(self, a, b):
        a, b = self.find(a), self.find(b)
        if a == b:
            return False                        # already connected: this edge is useless
        if self.sz[a] < self.sz[b]:
            a, b = b, a
        self.p[b] = a
        self.sz[a] += self.sz[b]
        return True

class Solution:
    def maxNumEdgesToRemove(self, n: int, edges: List[List[int]]) -> int:
        alice, bob = DSU(n + 1), DSU(n + 1)
        alice_joins = bob_joins = kept = 0
        for t, u, v in edges:                   # shared edges first
            if t == 3:
                a, b = alice.union(u, v), bob.union(u, v)
                alice_joins += a
                bob_joins += b
                kept += a or b
        for t, u, v in edges:                   # then edges for one person only
            if t == 1 and alice.union(u, v):
                alice_joins += 1
                kept += 1
            elif t == 2 and bob.union(u, v):
                bob_joins += 1
                kept += 1
        if alice_joins != n - 1 or bob_joins != n - 1:   # someone cannot reach everyone
            return -1
        return len(edges) - kept
HardLeetCode #1489
Solution

Write a helper mst(skip, force) that runs Kruskal over the edges sorted by weight, optionally skipping one edge or forcing one in first. It returns the tree weight, or infinity if the tree cannot be built.
An edge is critical if skipping it makes the MST heavier or impossible: every MST needs it. If it is not critical but forcing it in still gives the base weight, it is pseudo-critical: some MST uses it, not all.
With at most 200 edges, running Kruskal about twice per edge is fast enough.

Complexity: O(E^2 * alpha(n)) time, O(n + E) space

from math import inf

class DSU:
    def __init__(self, n):
        self.p = list(range(n))
        self.sz = [1] * n

    def find(self, x):
        while self.p[x] != x:
            self.p[x] = self.p[self.p[x]]       # path compression
            x = self.p[x]
        return x

    def union(self, a, b):
        a, b = self.find(a), self.find(b)
        if a == b:
            return False
        if self.sz[a] < self.sz[b]:
            a, b = b, a
        self.p[b] = a
        self.sz[a] += self.sz[b]
        return True

class Solution:
    def findCriticalAndPseudoCriticalEdges(self, n: int, edges: List[List[int]]) -> List[List[int]]:
        order = sorted(range(len(edges)), key=lambda i: edges[i][2])    # edge indexes, lightest first

        def mst(skip=-1, force=-1):
            dsu = DSU(n)
            total = joined = 0
            if force != -1:                      # take this edge before anything else
                u, v, w = edges[force]
                dsu.union(u, v)
                total, joined = w, 1
            for i in order:
                if i == skip:
                    continue
                u, v, w = edges[i]
                if dsu.union(u, v):              # Kruskal: keep edges that join two groups
                    total += w
                    joined += 1
            return total if joined == n - 1 else inf

        base = mst()
        critical, pseudo = [], []
        for i in range(len(edges)):
            if mst(skip=i) > base:               # without it the MST is worse: critical
                critical.append(i)
            elif mst(force=i) == base:           # usable in some MST: pseudo-critical
                pseudo.append(i)
        return [critical, pseudo]

Kruskal

Use this when edges can be sorted by weight and you take each one that joins two different groups, or when queries ask what is connected using only edges up to some weight.

MediumLeetCode #1631
Solution

A route's effort is its largest height step, so we want to join the two corners using the smallest possible largest edge. That is exactly what Kruskal builds.
Make an edge between each pair of neighbouring cells, weighted by the height difference, and sort them. Union them in that order.
The first edge after which find(0) == find(target) is the answer: every lighter edge is already used, so no better route exists.

Complexity: O(E log E) time with E about 2 * rows * cols, O(E) space

class DSU:
    def __init__(self, n):
        self.p = list(range(n))
        self.sz = [1] * n

    def find(self, x):
        while self.p[x] != x:
            self.p[x] = self.p[self.p[x]]       # path compression
            x = self.p[x]
        return x

    def union(self, a, b):
        a, b = self.find(a), self.find(b)
        if a == b:
            return False
        if self.sz[a] < self.sz[b]:
            a, b = b, a
        self.p[b] = a
        self.sz[a] += self.sz[b]
        return True

class Solution:
    def minimumEffortPath(self, heights: List[List[int]]) -> int:
        rows, cols = len(heights), len(heights[0])
        edges = []                                # (height difference, cell a, cell b)
        for r in range(rows):
            for c in range(cols):
                if r + 1 < rows:
                    edges.append((abs(heights[r][c] - heights[r + 1][c]), r * cols + c, (r + 1) * cols + c))
                if c + 1 < cols:
                    edges.append((abs(heights[r][c] - heights[r][c + 1]), r * cols + c, r * cols + c + 1))
        edges.sort()                              # Kruskal: lightest edge first
        dsu = DSU(rows * cols)
        target = rows * cols - 1
        for effort, a, b in edges:
            dsu.union(a, b)
            if dsu.find(0) == dsu.find(target):   # the corners became connected
                return effort                     # this edge is the biggest step on the best route
        return 0                                  # a single cell needs no effort
HardLeetCode #1697
Solution

Sort the edges by weight and answer the queries offline in order of increasing limit. One DSU is reused for all of them.
For each query, move the pointer e forward and union every edge whose weight is strictly below the limit. This is Kruskal paused at each limit.
Then find(p) == find(q) says whether p and q are connected using only edges that light. Answers are stored back at the query's original index.

Complexity: O(E log E + Q log Q) time, O(n + Q) space

class DSU:
    def __init__(self, n):
        self.p = list(range(n))
        self.sz = [1] * n

    def find(self, x):
        while self.p[x] != x:
            self.p[x] = self.p[self.p[x]]       # path compression
            x = self.p[x]
        return x

    def union(self, a, b):
        a, b = self.find(a), self.find(b)
        if a == b:
            return False
        if self.sz[a] < self.sz[b]:
            a, b = b, a
        self.p[b] = a
        self.sz[a] += self.sz[b]
        return True

class Solution:
    def distanceLimitedPathsExist(self, n: int, edgeList: List[List[int]], queries: List[List[int]]) -> List[bool]:
        edgeList.sort(key=lambda e: e[2])                              # Kruskal order: lightest first
        order = sorted(range(len(queries)), key=lambda i: queries[i][2])   # smallest limit first
        dsu = DSU(n)
        answer = [False] * len(queries)
        e = 0
        for qi in order:
            p, q, limit = queries[qi]
            while e < len(edgeList) and edgeList[e][2] < limit:        # add every edge below the limit
                dsu.union(edgeList[e][0], edgeList[e][1])
                e += 1
            answer[qi] = dsu.find(p) == dsu.find(q)
        return answer
HardLeetCode #2421
Solution

A good path starts and ends on equal values and never passes a larger one. Give each edge the weight max(vals[u], vals[v]) and process edges in that order, as Kruskal does.
Each component remembers its peak value (top) and how many nodes hold it (count). When two components merge and have the same peak, every pair of peak nodes, one from each side, is a new good path: count[a] * count[b].
If the peaks differ, the lower side can never end a good path with the higher side, so only the larger peak's count is kept. Every single node is also a good path, so paths starts at n.

Complexity: O(E log E) time, O(n) space

class DSU:
    def __init__(self, n):
        self.p = list(range(n))
        self.sz = [1] * n

    def find(self, x):
        while self.p[x] != x:
            self.p[x] = self.p[self.p[x]]       # path compression
            x = self.p[x]
        return x

    def union(self, a, b):
        a, b = self.find(a), self.find(b)
        if a == b:
            return False
        if self.sz[a] < self.sz[b]:
            a, b = b, a
        self.p[b] = a
        self.sz[a] += self.sz[b]
        return True

class Solution:
    def numberOfGoodPaths(self, vals: List[int], edges: List[List[int]]) -> int:
        n = len(vals)
        dsu = DSU(n)
        top = vals[:]                           # top[root]: biggest value in the component
        count = [1] * n                         # count[root]: how many nodes hold that value
        edges.sort(key=lambda e: max(vals[e[0]], vals[e[1]]))   # Kruskal: by edge weight
        paths = n                               # every single node is a good path
        for u, v in edges:
            a, b = dsu.find(u), dsu.find(v)
            if a == b:
                continue
            peak = max(top[a], top[b])
            if top[a] == top[b]:                # equal peaks: pair them up
                paths += count[a] * count[b]
            ways = (count[a] if top[a] == peak else 0) + (count[b] if top[b] == peak else 0)
            dsu.union(a, b)
            root = dsu.find(a)
            top[root], count[root] = peak, ways
        return paths

Prim

Use this when you can grow one region from a start node and must always take the cheapest (or best) node on its border, which a heap hands out.

MediumLeetCode #2812
Solution

First run a multi-source BFS from every thief to get dist, the distance of each cell to the nearest thief.
Then grow a region from (0, 0) Prim-style: the heap holds the border cells, and each step takes the safest one (max heap, so values are negated).
The path is only as safe as its least safe cell, so keep the running minimum of the popped values. When the bottom-right cell is popped, that minimum is the answer: while a safer border cell exists it is taken first.

Complexity: O(n^2 log n) time, O(n^2) space

import heapq
from collections import deque

class Solution:
    def maximumSafenessFactor(self, grid: List[List[int]]) -> int:
        n = len(grid)
        dirs = ((1, 0), (-1, 0), (0, 1), (0, -1))
        dist = [[-1] * n for _ in range(n)]          # distance to the nearest thief
        q = deque()
        for r in range(n):
            for c in range(n):
                if grid[r][c] == 1:
                    dist[r][c] = 0
                    q.append((r, c))
        while q:                                     # multi-source BFS from all thieves
            r, c = q.popleft()
            for dr, dc in dirs:
                nr, nc = r + dr, c + dc
                if 0 <= nr < n and 0 <= nc < n and dist[nr][nc] == -1:
                    dist[nr][nc] = dist[r][c] + 1
                    q.append((nr, nc))
        heap = [(-dist[0][0], 0, 0)]                 # border of the grown region, safest first
        seen = [[False] * n for _ in range(n)]
        seen[0][0] = True
        safest = dist[0][0]
        while heap:
            neg, r, c = heapq.heappop(heap)          # Prim: best cell on the border
            safest = min(safest, -neg)               # a path is only as safe as its worst cell
            if r == n - 1 and c == n - 1:
                return safest
            for dr, dc in dirs:
                nr, nc = r + dr, c + dc
                if 0 <= nr < n and 0 <= nc < n and not seen[nr][nc]:
                    seen[nr][nc] = True
                    heapq.heappush(heap, (-dist[nr][nc], nr, nc))
        return 0
HardLeetCode #778
Solution

The water level must be at least as high as every cell on the path, so the answer is the smallest possible highest cell on a route to the corner.
Grow a region from (0, 0) with a min heap of border cells keyed by elevation. Always pop the lowest border cell, as Prim picks the cheapest edge leaving the tree.
Keep time = max(time, elevation) over the popped cells. When the bottom-right cell is popped, time is the answer, because every lower border cell was already used.

Complexity: O(n^2 log n) time, O(n^2) space

import heapq

class Solution:
    def swimInWater(self, grid: List[List[int]]) -> int:
        n = len(grid)
        heap = [(grid[0][0], 0, 0)]              # border cells keyed by elevation
        seen = {(0, 0)}
        time = 0
        while heap:
            h, r, c = heapq.heappop(heap)        # Prim: lowest cell on the border
            time = max(time, h)                  # water must reach this cell's height
            if r == n - 1 and c == n - 1:
                return time
            for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
                nr, nc = r + dr, c + dc
                if 0 <= nr < n and 0 <= nc < n and (nr, nc) not in seen:
                    seen.add((nr, nc))
                    heapq.heappush(heap, (grid[nr][nc], nr, nc))
        return time

DSU / Union-Find

Use this when groups keep merging and you must ask "are these two in the same group?", especially with edges that arrive one at a time or in sorted order.

MediumLeetCode #684
Solution

The input is a tree plus one extra edge. Add the edges one by one with union.
union returns False when both ends already share a leader, which means this edge would close a cycle. The first such edge is the answer.
The first failing union is the edge that completes the cycle, which is the one that appears last in the input among the cycle's edges, as the problem asks.

Complexity: O(n * alpha(n)) time, O(n) space

class DSU:
    def __init__(self, n):
        self.p = list(range(n))
        self.sz = [1] * n

    def find(self, x):
        while self.p[x] != x:
            self.p[x] = self.p[self.p[x]]       # path compression
            x = self.p[x]
        return x

    def union(self, a, b):
        a, b = self.find(a), self.find(b)
        if a == b:
            return False                        # already connected: this edge closes a cycle
        if self.sz[a] < self.sz[b]:
            a, b = b, a                         # union by size: attach the small tree under the big one
        self.p[b] = a
        self.sz[a] += self.sz[b]
        return True

class Solution:
    def findRedundantConnection(self, edges: List[List[int]]) -> List[int]:
        dsu = DSU(len(edges) + 1)               # nodes are numbered 1..n
        for u, v in edges:
            if not dsu.union(u, v):             # same group already: redundant edge
                return [u, v]
        return []
MediumLeetCode #721
Solution

Make each account a node. Two accounts that share an email belong to the same person, so union them.
owner remembers the first account that held each email. When an email is seen again, union the current account with that owner.
At the end, group the emails by the leader of their account, sort each group, and put the name in front.

Complexity: O(N * L * alpha(N) + E log E) time for N accounts, E emails; O(E) space

from collections import defaultdict

class DSU:
    def __init__(self, n):
        self.p = list(range(n))
        self.sz = [1] * n

    def find(self, x):
        while self.p[x] != x:
            self.p[x] = self.p[self.p[x]]       # path compression
            x = self.p[x]
        return x

    def union(self, a, b):
        a, b = self.find(a), self.find(b)
        if a == b:
            return False
        if self.sz[a] < self.sz[b]:
            a, b = b, a
        self.p[b] = a
        self.sz[a] += self.sz[b]
        return True

class Solution:
    def accountsMerge(self, accounts: List[List[str]]) -> List[List[str]]:
        dsu = DSU(len(accounts))
        owner = {}                                   # email -> first account that had it
        for i, acc in enumerate(accounts):
            for email in acc[1:]:
                if email in owner:
                    dsu.union(i, owner[email])       # shared email: same person
                else:
                    owner[email] = i
        groups = defaultdict(list)
        for email, i in owner.items():
            groups[dsu.find(i)].append(email)        # group emails by the leader account
        return [[accounts[root][0]] + sorted(emails) for root, emails in groups.items()]
HardLeetCode #2092
Solution

Sort the meetings by time and handle all meetings of the same minute together. Within a minute, union the two people of every meeting, so the secret spreads along any chain of meetings.
After that minute, a person who is not in person 0's group never actually got the secret, so undo their unions by resetting them to their own group (the reset step).
At the end, everyone whose leader is the same as person 0's leader knows the secret.

Complexity: O(M log M + M * alpha(n)) time, O(n) space

class DSU:
    def __init__(self, n):
        self.p = list(range(n))
        self.sz = [1] * n

    def find(self, x):
        while self.p[x] != x:
            self.p[x] = self.p[self.p[x]]       # path compression
            x = self.p[x]
        return x

    def union(self, a, b):
        a, b = self.find(a), self.find(b)
        if a == b:
            return False
        if self.sz[a] < self.sz[b]:
            a, b = b, a
        self.p[b] = a
        self.sz[a] += self.sz[b]
        return True

    def reset(self, x):
        self.p[x] = x                           # leave the group, become a singleton again
        self.sz[x] = 1

class Solution:
    def findAllPeople(self, n: int, meetings: List[List[int]], firstPerson: int) -> List[int]:
        dsu = DSU(n)
        dsu.union(0, firstPerson)
        meetings.sort(key=lambda m: m[2])               # earliest meeting first
        i = 0
        while i < len(meetings):
            j = i
            while j < len(meetings) and meetings[j][2] == meetings[i][2]:
                j += 1                                  # meetings[i:j] share the same minute
            for x, y, _ in meetings[i:j]:
                dsu.union(x, y)
            for x, y, _ in meetings[i:j]:
                if dsu.find(x) != dsu.find(0):          # no secret reached them: undo their merge
                    dsu.reset(x)
                    dsu.reset(y)
            i = j
        return [p for p in range(n) if dsu.find(p) == dsu.find(0)]
12. Greedy

Make the locally best choice at each step and never look back. It only works when a local best leads to a global best.

Activity Selection

Use this when you must pick as many non-conflicting intervals (activities with a start and an end) as possible.

MediumLeetCode #646
Solution

Each pair is an activity that runs from left to right. Sort by the right end so the pair that finishes earliest comes first. Take a pair only when its left end is strictly greater than the right end of the last pair taken (strict, because the chain rule is b < c). The key line is `if start > last_end`. Finishing early leaves the most room for the pairs that come later.

Complexity: O(n log n) time, O(1) extra space

class Solution:
    def findLongestChain(self, pairs: List[List[int]]) -> int:
        pairs.sort(key=lambda p: p[1])          # earliest FINISH first
        count, last_end = 0, float("-inf")
        for start, end in pairs:
            if start > last_end:                # fits after the last pick
                count += 1
                last_end = end
        return count
MediumLeetCode #452
Solution

A balloon is an interval and an arrow is a single point. Sort by the right end and shoot the first arrow at the right end of the first balloon, because that point hits the most balloons that overlap it. A balloon whose start is at or before the last shot is already burst. Only a balloon with `start > last_shot` needs a new arrow, so the arrows used equal the activities picked by earliest finish.

Complexity: O(n log n) time, O(1) extra space

class Solution:
    def findMinArrowShots(self, points: List[List[int]]) -> int:
        points.sort(key=lambda p: p[1])         # earliest end first
        arrows, last_shot = 0, float("-inf")
        for start, end in points:
            if start > last_shot:               # not burst by the last arrow
                arrows += 1
                last_shot = end                 # shoot at this balloon's end
        return arrows
HardLeetCode #1520
Solution

First turn each letter into one interval. It starts at the first copy of the letter and must grow to cover the last copy of every letter inside it. If the scan meets a letter that first appears before `start`, this start is not self-contained and is dropped. After that it is plain activity selection: sort by end (shorter first when ends tie) and take each interval that starts after the last taken end. Earliest finish gives the most substrings, and the nested shorter ones give the smallest total length.

Complexity: O(26 * n) time, O(n) extra space for the answer

class Solution:
    def maxNumOfSubstrings(self, s: str) -> List[str]:
        first, last = {}, {}
        for i, ch in enumerate(s):
            first.setdefault(ch, i)
            last[ch] = i
        candidates = []                         # (end, -start) of every valid interval
        for ch in first:
            start, end = first[ch], last[ch]
            i, valid = start, True
            while i <= end:                     # grow end to hold every copy of letters inside
                if first[s[i]] < start:         # needs a letter from before start
                    valid = False
                    break
                end = max(end, last[s[i]])
                i += 1
            if valid:
                candidates.append((end, -start))
        candidates.sort()                       # earliest end first, shorter first on ties
        result, last_end = [], -1
        for end, neg_start in candidates:
            if -neg_start > last_end:           # activity selection: starts after the last pick
                result.append(s[-neg_start:end + 1])
                last_end = end
        return result

Fractional Knapsack

Use this when you fill a limited capacity with items that have a value per unit of cost, so taking the best ratio first is safe.

EasyLeetCode #1710
Solution

Every box weighs 1, so the value-to-weight ratio of a box type is its units per box. Sort the box types by that ratio, best first. For each type take `min(boxes, truckSize)`, which is the "whole items, then a fraction of the last one" step. Stop when the truck is full.

Complexity: O(n log n) time, O(1) extra space

class Solution:
    def maximumUnits(self, boxTypes: List[List[int]], truckSize: int) -> int:
        boxTypes.sort(key=lambda b: b[1], reverse=True)   # best units per box first
        total = 0
        for boxes, units in boxTypes:
            take = min(boxes, truckSize)        # all of this type, or what still fits
            total += take * units
            truckSize -= take
            if truckSize == 0:
                break
        return total
MediumLeetCode #1833
Solution

Every bar is worth 1, so its value per coin is 1 / cost and the best ratio is the cheapest bar. Sort the costs ascending and buy while the coins last. The bars cannot be split, but this is safe because all values are equal: a cheaper bar never blocks a better choice. The `break` fires at the first bar that does not fit, since every later bar costs at least as much.

Complexity: O(n log n) time, O(1) extra space

class Solution:
    def maxIceCream(self, costs: List[int], coins: int) -> int:
        costs.sort()                            # best ratio (cheapest) first
        bars = 0
        for cost in costs:
            if cost > coins:                    # this and every later bar is too expensive
                break
            coins -= cost
            bars += 1
        return bars
HardLeetCode #857
Solution

Everyone in the group is paid at the same wage-per-quality rate, and that rate must be the highest ratio in the group. So sort workers by ratio, cheapest first, and treat the current worker's ratio as the group rate. A max-heap keeps the k smallest qualities seen so far; when it grows past k, pop the largest quality. The cost for the group is `ratio * quality_sum`, and the smallest of these over all workers is the answer.

Complexity: O(n log n) time, O(n) extra space

from heapq import heappush, heappop

class Solution:
    def mincostToHireWorkers(self, quality: List[int], wage: List[int], k: int) -> float:
        workers = sorted(zip(quality, wage), key=lambda w: w[1] / w[0])   # lowest ratio first
        heap = []                               # max-heap of qualities (stored negated)
        quality_sum = 0
        best = float("inf")
        for q, w in workers:
            ratio = w / q                       # highest ratio in the group so far
            heappush(heap, -q)
            quality_sum += q
            if len(heap) > k:
                quality_sum += heappop(heap)    # drop the largest quality (popped value is negative)
            if len(heap) == k:
                best = min(best, ratio * quality_sum)
        return best

Interval Scheduling

Use this when you must keep the most tasks that fit on one timeline, or count how many to remove so the rest do not clash.

MediumLeetCode #435
Solution

Keeping the most intervals is activity selection: sort by end time and keep every interval whose start is at or after the last kept end (touching is allowed). Whatever you skip has to be removed. The answer is the dual, `len(intervals) - kept`. Sorting by end is the safe key because it leaves the most room for later intervals.

Complexity: O(n log n) time, O(1) extra space

class Solution:
    def eraseOverlapIntervals(self, intervals: List[List[int]]) -> int:
        intervals.sort(key=lambda x: x[1])      # sort by end time
        kept, last_end = 0, float("-inf")
        for start, end in intervals:
            if start >= last_end:               # starts after the last kept one ends
                kept += 1
                last_end = end
        return len(intervals) - kept            # everything else must go
MediumLeetCode #1353
Solution

You can attend one event per day, on any day inside its range. Walk the days in order, and push the end day of every event that has started onto a min heap. Drop the ends that are already in the past, then attend the event that ends soonest with `heappop`. Earliest deadline first is the interval scheduling rule applied day by day. When nothing is open, jump straight to the next start day.

Complexity: O(n log n) time, O(n) extra space

from heapq import heappush, heappop

class Solution:
    def maxEvents(self, events: List[List[int]]) -> int:
        events.sort()                           # by start day
        open_ends = []                          # min-heap of end days of started events
        i, day, attended = 0, 0, 0
        while i < len(events) or open_ends:
            if not open_ends:
                day = events[i][0]              # idle: jump to the next start
            while i < len(events) and events[i][0] <= day:
                heappush(open_ends, events[i][1])
                i += 1
            while open_ends and open_ends[0] < day:
                heappop(open_ends)              # expired, can no longer be attended
            if open_ends:
                heappop(open_ends)              # attend the one that ends soonest
                attended += 1
            day += 1
        return attended
HardLeetCode #630
Solution

Each course is a task with a length and a deadline. Sort by deadline and add the courses one at a time, tracking the total time used. If the total passes the current deadline, remove the longest course taken so far with a max-heap. Removing the longest frees the most time and keeps the count as high as before. The number of courses left in the heap is the answer.

Complexity: O(n log n) time, O(n) extra space

from heapq import heappush, heappop

class Solution:
    def scheduleCourse(self, courses: List[List[int]]) -> int:
        courses.sort(key=lambda c: c[1])        # earliest deadline first
        taken = []                              # max-heap of durations (stored negated)
        time = 0
        for duration, last_day in courses:
            heappush(taken, -duration)
            time += duration
            if time > last_day:                 # too late: drop the longest course taken
                time += heappop(taken)          # popped value is negative
        return len(taken)

Merge Intervals

Use this when intervals may overlap and you need to combine them, measure the covered length, or drop the ones hidden inside another.

EasyLeetCode #495
Solution

Each attack poisons the half-open interval [t, t + duration). The attack times are already sorted by start, so no sort step is needed. Compare each new interval with the end of the last one, `last_end`. Only the part after `last_end` is new poison time, so add `end - max(start, last_end)`. That is the overlap check of Merge Intervals, used to measure the union instead of building it.

Complexity: O(n) time, O(1) extra space

class Solution:
    def findPoisonedDuration(self, timeSeries: List[int], duration: int) -> int:
        total = 0
        last_end = 0                              # end of the last interval seen
        for t in timeSeries:                      # already sorted by start
            start, end = t, t + duration
            total += end - max(start, last_end)   # count only the part not already covered
            last_end = end
        return total
MediumLeetCode #1288
Solution

Sort by start, and on equal starts put the longer interval first. Now an interval can only be covered by one that came earlier. Keep `max_end`, the end of the last interval kept. If the current end is not past `max_end`, the interval is inside an earlier one and is skipped. Otherwise it is kept and becomes the new last interval.

Complexity: O(n log n) time, O(1) extra space

class Solution:
    def removeCoveredIntervals(self, intervals: List[List[int]]) -> int:
        intervals.sort(key=lambda x: (x[0], -x[1]))   # start ascending, longer first on ties
        kept = 0
        max_end = 0                                   # end of the last interval kept
        for start, end in intervals:
            if end > max_end:                         # sticks out past every earlier interval
                kept += 1
                max_end = end
        return kept
HardLeetCode #715
Solution

Keep the tracked ranges as sorted, disjoint, half-open intervals in two parallel lists, `starts` and `ends`. `addRange` uses `bisect` to find every interval that overlaps or touches the new one, then merges them all into a single interval. That is the merge step of Merge Intervals. `removeRange` finds the overlapped intervals and keeps only their left and right leftovers. `queryRange` checks the one interval that starts at or before `left`.

Complexity: O(log n) search plus O(n) list splice per call

from bisect import bisect_left, bisect_right

class RangeModule:
    def __init__(self):
        self.starts = []                     # sorted, disjoint half-open intervals [start, end)
        self.ends = []

    def addRange(self, left: int, right: int) -> None:
        i = bisect_left(self.ends, left)     # first interval ending at or after left
        j = bisect_right(self.starts, right) # intervals i..j-1 overlap or touch [left, right)
        if i < j:                            # merge them with the new range
            left = min(left, self.starts[i])
            right = max(right, self.ends[j - 1])
        self.starts[i:j] = [left]
        self.ends[i:j] = [right]

    def queryRange(self, left: int, right: int) -> bool:
        i = bisect_right(self.starts, left) - 1   # last interval starting at or before left
        return i >= 0 and self.ends[i] >= right

    def removeRange(self, left: int, right: int) -> None:
        i = bisect_right(self.ends, left)    # first interval ending after left
        j = bisect_left(self.starts, right)  # intervals i..j-1 overlap [left, right)
        new_starts, new_ends = [], []
        if i < j:
            if self.starts[i] < left:        # keep the piece left of the removed range
                new_starts.append(self.starts[i])
                new_ends.append(left)
            if self.ends[j - 1] > right:     # keep the piece right of the removed range
                new_starts.append(right)
                new_ends.append(self.ends[j - 1])
        self.starts[i:j] = new_starts
        self.ends[i:j] = new_ends

Minimum Platforms

Use this when you need the most things happening at once, such as rooms, platforms, or seats required.

MediumLeetCode #2406
Solution

The minimum number of groups equals the maximum number of intervals that overlap at one moment. Sort the starts and the ends separately and sweep the starts. Before counting a new start, free every interval whose end is strictly before it (`ends[j] < t`, because touching ends still count as overlapping here). The largest running count is the answer.

Complexity: O(n log n) time, O(n) extra space

class Solution:
    def minGroups(self, intervals: List[List[int]]) -> int:
        starts = sorted(iv[0] for iv in intervals)      # arrivals
        ends = sorted(iv[1] for iv in intervals)        # departures
        need = best = 0
        j = 0
        for t in starts:
            while ends[j] < t:                  # that interval is over: its group is free
                need -= 1
                j += 1
            need += 1                           # this interval needs a group
            best = max(best, need)
        return best
MediumLeetCode #1094
Solution

Turn each trip into two events: people get on at the start and get off at the end. Sort all events by position. At the same stop, the negative change (getting off) sorts before the positive one, which handles a stop where one trip ends and another begins. Add each change to a running count and fail as soon as it goes over the capacity.

Complexity: O(n log n) time, O(n) extra space

class Solution:
    def carPooling(self, trips: List[List[int]], capacity: int) -> bool:
        events = []
        for people, start, end in trips:
            events.append((start, people))      # people get on
            events.append((end, -people))       # people get off
        events.sort()                           # same stop: get-off (negative) comes first
        onboard = 0
        for _, change in events:
            onboard += change                   # sweep: how many are on the car now
            if onboard > capacity:
                return False
        return True
HardLeetCode #732
Solution

The answer after each booking is the maximum number of bookings that overlap at one time, which is the minimum-platforms quantity. Store +1 at each start and -1 at each end in a dictionary. Sweep the keys in sorted order with a running sum and keep the maximum. Bookings are half-open, so an end and a start at the same time cancel out in one key.

Complexity: O(n^2 log n) time over n calls, O(n) extra space

from collections import defaultdict

class MyCalendarThree:
    def __init__(self):
        self.delta = defaultdict(int)           # +1 where a booking starts, -1 where it ends

    def book(self, startTime: int, endTime: int) -> int:
        self.delta[startTime] += 1
        self.delta[endTime] -= 1
        overlap = best = 0
        for t in sorted(self.delta):            # sweep the time points in order
            overlap += self.delta[t]
            best = max(best, overlap)
        return best

Jump Game

Use this when each position tells you how far you can reach and you need to know if, or in how few steps, you can get to the end.

MediumLeetCode #55
Solution

Keep one number, `far`, the furthest index reachable so far. Walk left to right. If the current index is beyond `far`, nothing could land here, so the end is unreachable. Otherwise update `far = max(far, i + x)`. You never need to remember which jump got you here.

Complexity: O(n) time, O(1) extra space

class Solution:
    def canJump(self, nums: List[int]) -> bool:
        far = 0                                 # furthest index reachable so far
        for i, x in enumerate(nums):
            if i > far:                         # cannot even reach index i
                return False
            far = max(far, i + x)
        return True
MediumLeetCode #45
Solution

Think of the jumps as levels. `cur_end` is the edge of what the current number of jumps can reach, and `far` is the best reach with one more jump. Reaching `cur_end` forces a jump, so count it and move the edge to `far`. Choosing the landing spot that reaches furthest is the greedy step, and the input is guaranteed reachable.

Complexity: O(n) time, O(1) extra space

class Solution:
    def jump(self, nums: List[int]) -> int:
        jumps = 0
        cur_end = 0                             # edge reachable with `jumps` jumps
        far = 0                                 # furthest reach with one more jump
        for i in range(len(nums) - 1):
            far = max(far, i + nums[i])
            if i == cur_end:                    # forced to jump: take the best landing spot
                jumps += 1
                cur_end = far
        return jumps
HardLeetCode #1326
Solution

Turn each tap into a jump. For every left end, store the furthest right end any tap covering that left end can reach. The garden is the line 0..n, and covering it with the fewest taps is Jump Game II with `far` and `cur_end`. If `far` cannot move past the current position, there is a gap and the answer is -1.

Complexity: O(n) time, O(n) extra space

class Solution:
    def minTaps(self, n: int, ranges: List[int]) -> int:
        farthest = [0] * (n + 1)                # farthest[l] = furthest right end of a tap starting at l
        for i, r in enumerate(ranges):
            left, right = max(0, i - r), min(n, i + r)
            farthest[left] = max(farthest[left], right)
        taps = cur_end = far = 0
        for i in range(n):
            far = max(far, farthest[i])         # best reach using taps that start at or before i
            if i == cur_end:                    # the current taps end here: open one more
                if far <= i:                    # nothing extends the covered area: gap
                    return -1
                taps += 1
                cur_end = far
        return taps

Gas Station

Use this when a running total (tank, balance, energy) must never drop below the limit, and a failure tells you where to restart.

EasyLeetCode #1413
Solution

Track the running total and the lowest value it ever reaches, like watching the lowest point of a fuel tank. The starting value only has to lift that lowest point up to 1. So the answer is `1 - lowest`, and `lowest` starts at 0 so the answer is never below 1. It is one pass with no search.

Complexity: O(n) time, O(1) extra space

class Solution:
    def minStartValue(self, nums: List[int]) -> int:
        total = lowest = 0                      # running total and its lowest point
        for x in nums:
            total += x
            lowest = min(lowest, total)
        return 1 - lowest                       # start high enough that every total stays >= 1
MediumLeetCode #134
Solution

First check `sum(gas) >= sum(cost)`; if it fails no start works. Then drive once around with a running `tank`. When the tank goes negative at station i, no station between the old start and i can be the start either, so move `start` to i + 1 and reset the tank. The first check guarantees that the last candidate left standing is valid.

Complexity: O(n) time, O(1) extra space

class Solution:
    def canCompleteCircuit(self, gas: List[int], cost: List[int]) -> int:
        if sum(gas) < sum(cost):                # not enough fuel in total
            return -1
        start = tank = 0
        for i in range(len(gas)):
            tank += gas[i] - cost[i]
            if tank < 0:                        # failed: no start in [start, i] works
                start = i + 1
                tank = 0
        return start
HardLeetCode #871
Solution

Keep `tank` as the furthest position you can reach with the fuel gathered so far. Walk the stations in order, with the target added as a last stop. If the next stop is out of reach, you should have refueled earlier at the passed station with the most fuel, so pop it from a max-heap and count a stop. If the heap runs empty before you can reach the stop, the trip is impossible.

Complexity: O(n log n) time, O(n) extra space

from heapq import heappush, heappop

class Solution:
    def minRefuelStops(self, target: int, startFuel: int, stations: List[List[int]]) -> int:
        heap = []                               # max-heap of fuel at stations already passed
        tank = startFuel                        # furthest position reachable so far
        stops = 0
        for position, fuel in stations + [[target, 0]]:
            while tank < position:              # out of reach: refuel at the best passed station
                if not heap:
                    return -1
                tank -= heappop(heap)           # popped value is negative
                stops += 1
            heappush(heap, -fuel)
        return stops

Job Sequencing

Use this when jobs have a limit (deadline, difficulty, cooldown, or capital) and a profit, and you take the best-paying job that is allowed.

MediumLeetCode #826
Solution

A worker may do any job up to their ability, so the limit is the difficulty and the goal is the highest profit inside it. Sort the jobs by difficulty and the workers by ability. For each worker, unlock every job they can do and keep the best profit seen so far in `best`. That best profit is added to the total, and the job pointer only moves forward.

Complexity: O(n log n + m log m) time, O(n) extra space

class Solution:
    def maxProfitAssignment(self, difficulty: List[int], profit: List[int], worker: List[int]) -> int:
        jobs = sorted(zip(difficulty, profit))  # easiest job first
        best = i = total = 0
        for ability in sorted(worker):
            while i < len(jobs) and jobs[i][0] <= ability:
                best = max(best, jobs[i][1])    # best profit among jobs allowed so far
                i += 1
            total += best
        return total
MediumLeetCode #621
Solution

Jobs of one type must be at least n slots apart, so the busiest type decides the layout. Place it in `max_freq` rounds. The first `max_freq - 1` rounds each have n + 1 slots, and the last round only needs one slot for each type tied for busiest. Other tasks fill the idle slots, and if there are more tasks than slots the answer is the number of tasks.

Complexity: O(n) time, O(1) extra space (26 letters)

from collections import Counter

class Solution:
    def leastInterval(self, tasks: List[str], n: int) -> int:
        counts = Counter(tasks)
        max_freq = max(counts.values())
        num_busiest = sum(1 for c in counts.values() if c == max_freq)
        slots = (max_freq - 1) * (n + 1) + num_busiest   # rounds of n+1 slots, busiest tasks first
        return max(slots, len(tasks))           # no idle time needed if tasks fill every slot
Solution

Choose up to k projects to maximise capital, where a project needs enough capital to start. Sort projects by the capital they need. Each round, push the profit of every project you can now afford onto a max-heap, then take the biggest profit with `heappop`. This is "highest profit first among the jobs that are allowed", and taking a profit can unlock new projects.

Complexity: O(n log n + k log n) time, O(n) extra space

from heapq import heappush, heappop

class Solution:
    def findMaximizedCapital(self, k: int, w: int, profits: List[int], capital: List[int]) -> int:
        projects = sorted(zip(capital, profits))    # cheapest requirement first
        affordable = []                             # max-heap of profits (stored negated)
        i = 0
        for _ in range(k):
            while i < len(projects) and projects[i][0] <= w:
                heappush(affordable, -projects[i][1])   # unlock everything we can pay for
                i += 1
            if not affordable:
                break                               # nothing can be started
            w -= heappop(affordable)                # take the highest profit
        return w
13. Dynamic Programming

Recursion + remembering. If a problem has overlapping subproblems and optimal substructure, solve each subproblem once.

1D DP

Use this when the answer for position i depends only on a few earlier positions, so one row of results is enough.

EasyLeetCode #338
Solution

State: dp[i] is the number of 1 bits in the number i. Shifting i right by one drops its last bit, so i >> 1 is a smaller number whose answer is already filled in. The transition is dp[i] = dp[i >> 1] + (i & 1). The base case is dp[0] = 0.

Complexity: O(n) time, O(n) space

class Solution:
    def countBits(self, n: int) -> List[int]:
        dp = [0] * (n + 1)                 # dp[i] = number of 1 bits in i
        for i in range(1, n + 1):
            dp[i] = dp[i >> 1] + (i & 1)   # i without its last bit, plus that last bit
        return dp
MediumLeetCode #983
Solution

State: dp[d] is the cheapest way to cover every travel day up to day d. On a day you do not travel, nothing is owed, so dp[d] = dp[d - 1]. On a travel day, take the best of three choices: a 1-day pass after dp[d - 1], a 7-day pass after dp[d - 7], or a 30-day pass after dp[d - 30]. The array is indexed by calendar day, so each cell only looks back at fixed offsets.

Complexity: O(last day) time, O(last day) space

class Solution:
    def mincostTickets(self, days: List[int], costs: List[int]) -> int:
        travel = set(days)
        last = days[-1]
        dp = [0] * (last + 1)              # dp[d] = min cost to cover all travel days up to day d
        for d in range(1, last + 1):
            if d not in travel:
                dp[d] = dp[d - 1]          # no travel today, nothing to pay
                continue
            dp[d] = min(dp[d - 1] + costs[0],              # 1-day pass
                        dp[max(0, d - 7)] + costs[1],      # 7-day pass
                        dp[max(0, d - 30)] + costs[2])     # 30-day pass
        return dp[last]
HardLeetCode #32
Solution

State: dp[i] is the length of the longest valid substring that ends exactly at index i. It is 0 unless s[i] is ')'. If s[i-1] is '(', we close a fresh pair: dp[i] = dp[i-2] + 2. If s[i-1] is ')', the block ending at i-1 has length dp[i-1], so the character right before that block is at j = i - dp[i-1] - 1. If s[j] is '(', it matches s[i], and dp[i] = dp[i-1] + 2 + dp[j-1] glues the blocks on both sides.

Complexity: O(n) time, O(n) space

class Solution:
    def longestValidParentheses(self, s: str) -> int:
        n = len(s)
        dp = [0] * n                       # dp[i] = longest valid substring ending exactly at i
        for i in range(1, n):
            if s[i] != ')':
                continue
            if s[i - 1] == '(':            # the "()" case
                dp[i] = (dp[i - 2] if i >= 2 else 0) + 2
            else:                          # the "))" case: find the '(' that matches s[i]
                j = i - dp[i - 1] - 1
                if j >= 0 and s[j] == '(':
                    dp[i] = dp[i - 1] + 2 + (dp[j - 1] if j >= 1 else 0)
        return max(dp, default=0)

Climbing Stairs

Use this when you count the ways, or the cheapest way, to reach step n and every move comes from one or two steps back.

EasyLeetCode #70
Solution

State: dp[i] is the number of ways to stand on step i. You arrive from step i-1 (one step) or step i-2 (two steps), so dp[i] = dp[i-1] + dp[i-2]. Only the last two values are needed, so two variables replace the array. The base cases are one way to be on step 0 and one way to be on step 1.

Complexity: O(n) time, O(1) extra space

class Solution:
    def climbStairs(self, n: int) -> int:
        prev2, prev1 = 1, 1                # ways to reach step 0 and step 1
        for _ in range(2, n + 1):
            prev2, prev1 = prev1, prev1 + prev2    # dp[i] = dp[i-1] + dp[i-2]
        return prev1
EasyLeetCode #746
Solution

The same two-step shape, but now each cell stores a minimum instead of a count. dp[i] is the cheapest cost to stand on step i, where the top is index len(cost). You come from step i-1 and pay cost[i-1], or from step i-2 and pay cost[i-2]. Start values are 0 because you may begin on step 0 or step 1 for free.

Complexity: O(n) time, O(1) extra space

class Solution:
    def minCostClimbingStairs(self, cost: List[int]) -> int:
        prev2 = prev1 = 0                  # cost to stand on step 0 and step 1 (free start)
        for i in range(2, len(cost) + 1):
            # arrive from one step back (pay cost[i-1]) or two steps back (pay cost[i-2])
            prev2, prev1 = prev1, min(prev1 + cost[i - 1], prev2 + cost[i - 2])
        return prev1
MediumLeetCode #91
Solution

Think of the string as stairs: dp[i] is the number of ways to decode the first i characters. The last character alone is one move (valid if it is not '0'), and the last two characters together are a two-step move (valid if they read 10 to 26). So dp[i] adds dp[i-1] and dp[i-2] under those two conditions. Two variables hold the last two values.

Complexity: O(n) time, O(1) extra space

class Solution:
    def numDecodings(self, s: str) -> int:
        prev2 = 1                          # dp[0]: one way to decode the empty prefix
        prev1 = 0 if s[0] == '0' else 1    # dp[1]
        for i in range(2, len(s) + 1):
            ways = 0
            if s[i - 1] != '0':            # last character alone is a letter 1-9
                ways += prev1
            if 10 <= int(s[i - 2:i]) <= 26:    # last two characters together are a letter 10-26
                ways += prev2
            prev2, prev1 = prev1, ways
        return prev1

House Robber

Use this when you pick values from a line to maximise a total and no two neighbours may both be picked.

MediumLeetCode #198
Solution

State: dp[i] is the best total using the first i houses. For house i you either skip it (keep dp[i-1]) or rob it (add its value to dp[i-2]). So dp[i] = max(dp[i-1], dp[i-2] + a[i]), which is the key line. Only two previous values are needed.

Complexity: O(n) time, O(1) extra space

class Solution:
    def rob(self, nums: List[int]) -> int:
        prev2 = prev1 = 0
        for x in nums:
            # skip this house (prev1) or rob it and add the best from two houses back
            prev2, prev1 = prev1, max(prev1, prev2 + x)
        return prev1
MediumLeetCode #213
Solution

The houses form a circle, so the first and last house are neighbours and cannot both be robbed. Split into two straight streets: one without the first house and one without the last. Run the plain house robber recurrence on each and take the larger answer. A single house is a special case.

Complexity: O(n) time, O(n) space for the two slices

class Solution:
    def rob(self, nums: List[int]) -> int:
        if len(nums) == 1:
            return nums[0]

        def rob_line(a):                   # plain house robber on a straight street
            prev2 = prev1 = 0
            for x in a:
                prev2, prev1 = prev1, max(prev1, prev2 + x)
            return prev1

        # the circle breaks into two lines: skip the first house, or skip the last
        return max(rob_line(nums[1:]), rob_line(nums[:-1]))
MediumLeetCode #740
Solution

Taking a value x earns x times its count and forbids x-1 and x+1. Put the total earnings of each value v into earn[v]. Now the values sit on a line, and picking v forbids its neighbours v-1 and v+1, which is exactly house robber. Run the same two-variable recurrence over earn.

Complexity: O(n + max(nums)) time, O(max(nums)) space

class Solution:
    def deleteAndEarn(self, nums: List[int]) -> int:
        earn = [0] * (max(nums) + 1)
        for x in nums:
            earn[x] += x                   # earn[v] = total points from taking every copy of v
        prev2 = prev1 = 0
        for e in earn:                     # house robber over values: v-1 and v cannot both be taken
            prev2, prev1 = prev1, max(prev1, prev2 + e)
        return prev1

Fibonacci

Use this when each term is a fixed sum of the previous two or three terms, so only that many variables need to be kept.

EasyLeetCode #509
Solution

State: fib(k) is the k-th Fibonacci number. The transition is fib(k) = fib(k-1) + fib(k-2) with base cases 0 and 1. This version is top-down: the plain recursion repeats the same calls, and @lru_cache remembers each answer so every k is computed once.

Complexity: O(n) time, O(n) space

from functools import lru_cache


class Solution:
    def fib(self, n: int) -> int:
        @lru_cache(None)                   # TOP-DOWN: remember each fib(k) after the first call
        def go(k):
            if k < 2:
                return k                   # base cases: fib(0) = 0, fib(1) = 1
            return go(k - 1) + go(k - 2)
        return go(n)
EasyLeetCode #1137
Solution

The same idea with three terms: T(i) = T(i-1) + T(i-2) + T(i-3). The base cases are T0 = 0, T1 = 1, T2 = 1. Keep the last three values in three variables and slide them forward, so no array is needed.

Complexity: O(n) time, O(1) extra space

class Solution:
    def tribonacci(self, n: int) -> int:
        if n < 3:
            return 1 if n else 0
        a, b, c = 0, 1, 1                  # T(i-3), T(i-2), T(i-1)
        for _ in range(3, n + 1):
            a, b, c = b, c, a + b + c      # new term is the sum of the previous three
        return c
MediumLeetCode #790
Solution

State: dp[n] is the number of ways to tile a 2 x n board. Counting by how the right end is closed gives dp[n] = dp[n-1] + dp[n-2] + 2 * (dp[n-3] + ... + dp[0]). Subtracting the same formula for n-1 collapses it to dp[n] = 2 * dp[n-1] + dp[n-3], a Fibonacci-style recurrence. Take the result modulo 10^9 + 7.

Complexity: O(n) time, O(n) space

class Solution:
    def numTilings(self, n: int) -> int:
        MOD = 10 ** 9 + 7
        dp = [1, 1, 2]                     # ways to tile widths 0, 1 and 2
        for i in range(3, n + 1):
            dp.append((2 * dp[i - 1] + dp[i - 3]) % MOD)   # only earlier cells matter
        return dp[n]

0/1 Knapsack

Use this when every item can be taken at most once and you must stay inside one or more capacities while maximising or minimising a total.

MediumLeetCode #474
Solution

Each string is an item that costs some zeros and some ones, and every item is worth 1. The capacity has two dimensions, so dp[a][b] is the most strings you can pick with at most a zeros and b ones. For each string, loop both capacities backwards so the same string is never counted twice. The key line is dp[a][b] = max(dp[a][b], dp[a - zeros][b - ones] + 1).

Complexity: O(len(strs) * m * n) time, O(m * n) space

class Solution:
    def findMaxForm(self, strs: List[str], m: int, n: int) -> int:
        dp = [[0] * (n + 1) for _ in range(m + 1)]    # dp[a][b] = most strings using <= a zeros and <= b ones
        for s in strs:
            zeros = s.count('0')
            ones = len(s) - zeros
            for a in range(m, zeros - 1, -1):         # BACKWARDS in both capacities: use each string once
                for b in range(n, ones - 1, -1):
                    dp[a][b] = max(dp[a][b], dp[a - zeros][b - ones] + 1)
        return dp[m][n]
HardLeetCode #2742
Solution

Hiring the paid painter for wall i paints that wall and gives the free painter time[i] units, so it clears time[i] + 1 walls in total for cost[i]. So each wall is an item with weight time[i] + 1 and price cost[i], and you need total weight at least n for the least price. dp[j] is the cheapest cost to get j walls painted, and an index below 0 is treated as 0. Loop j backwards so each paid painter is hired once.

Complexity: O(n^2) time, O(n) space

class Solution:
    def paintWalls(self, cost: List[int], time: List[int]) -> int:
        n = len(cost)
        INF = float('inf')
        dp = [0] + [INF] * n               # dp[j] = min cost to get j walls painted
        for c, t in zip(cost, time):       # item: weight t + 1, price c
            for j in range(n, 0, -1):      # BACKWARDS: each paid painter used at most once
                dp[j] = min(dp[j], dp[max(0, j - t - 1)] + c)
        return dp[n]
HardLeetCode #879
Solution

Each crime is an item used at most once, with members as its weight. The twist is that profit is also tracked, and any profit above minProfit counts the same, so profit is capped at minProfit. dp[g][p] is the number of schemes using exactly g members with capped profit p. For each crime, loop g downwards so the crime is used once, and add dp[g - members][p] into dp[g][min(minProfit, p + gain)].

Complexity: O(len(group) * n * minProfit) time, O(n * minProfit) space

class Solution:
    def profitableSchemes(self, n: int, minProfit: int, group: List[int], profit: List[int]) -> int:
        MOD = 10 ** 9 + 7
        # dp[g][p] = schemes using exactly g members with profit capped at minProfit
        dp = [[0] * (minProfit + 1) for _ in range(n + 1)]
        dp[0][0] = 1                       # the empty scheme
        for members, gain in zip(group, profit):
            for g in range(n, members - 1, -1):          # BACKWARDS: each crime used once
                for p in range(minProfit + 1):
                    q = min(minProfit, p + gain)         # profit above minProfit is all the same
                    dp[g][q] = (dp[g][q] + dp[g - members][p]) % MOD
        return sum(dp[g][minProfit] for g in range(n + 1)) % MOD

Unbounded Knapsack

Use this when items can be reused any number of times and you want the fewest pieces, the best value, or the number of ways to fill an exact total.

MediumLeetCode #279
Solution

The items are the perfect squares 1, 4, 9, ... and each can be used again and again. dp[a] is the fewest squares that sum to a. For each a, try every square that fits: dp[a] = min(dp[a], dp[a - square] + 1). Because dp[a - square] may already use the same square, reuse is allowed.

Complexity: O(n * sqrt(n)) time, O(n) space

import math


class Solution:
    def numSquares(self, n: int) -> int:
        squares = [i * i for i in range(1, math.isqrt(n) + 1)]
        INF = float('inf')
        dp = [0] + [INF] * n               # dp[a] = fewest perfect squares that sum to a
        for a in range(1, n + 1):
            for sq in squares:             # unbounded: any square may be used again
                if sq > a:
                    break
                dp[a] = min(dp[a], dp[a - sq] + 1)
        return dp[n]
MediumLeetCode #343
Solution

This is rod cutting. dp[i] is the best product when i is split into at least two positive parts. Cut off a first piece of size j, then either stop (product j * (i - j)) or keep splitting the rest (product j * dp[i - j]). Since dp[i - j] may start with the same size j again, pieces are reusable, which is the unbounded shape.

Complexity: O(n^2) time, O(n) space

class Solution:
    def integerBreak(self, n: int) -> int:
        dp = [0] * (n + 1)                 # dp[i] = best product when i is split into 2 or more parts
        for i in range(2, n + 1):
            for j in range(1, i):          # first piece has size j; it may repeat inside dp[i - j]
                dp[i] = max(dp[i], j * (i - j), j * dp[i - j])
        return dp[n]
MediumLeetCode #2466
Solution

Each move appends a block of zero '0's or one '1's, and blocks can be used any number of times. dp[i] is the number of ways to build a string of length exactly i, so dp[i] = dp[i - zero] + dp[i - one] when those lengths are not negative. The base case is dp[0] = 1. The answer adds dp[i] for every length from low to high, modulo 10^9 + 7.

Complexity: O(high) time, O(high) space

class Solution:
    def countGoodStrings(self, low: int, high: int, zero: int, one: int) -> int:
        MOD = 10 ** 9 + 7
        dp = [1] + [0] * high              # dp[i] = ways to build a string of length exactly i
        for i in range(1, high + 1):
            if i >= zero:
                dp[i] += dp[i - zero]      # last block was zero '0's
            if i >= one:
                dp[i] += dp[i - one]       # last block was one '1's
            dp[i] %= MOD
        return sum(dp[low:high + 1]) % MOD

Subset Sum

Use this when the question is whether some chosen subset of the numbers adds up to an exact target.

MediumLeetCode #416
Solution

Two equal halves need an even total, and then one half must sum to total / 2. So the question becomes: is there a subset with sum target? dp[s] is true when some subset sums to s. For each number, loop s downwards so the number is used once: dp[s] = dp[s] or dp[s - x].

Complexity: O(n * total) time, O(total) space

class Solution:
    def canPartition(self, nums: List[int]) -> bool:
        total = sum(nums)
        if total % 2:
            return False                   # an odd total cannot split into two equal halves
        target = total // 2
        dp = [True] + [False] * target     # dp[s] = some subset sums to s
        for x in nums:
            for s in range(target, x - 1, -1):    # BACKWARDS: each number used once
                dp[s] = dp[s] or dp[s - x]
        return dp[target]
MediumLeetCode #2915
Solution

Subset sum, but instead of true or false each cell remembers a count. dp[s] is the most numbers that add up to exactly s, and minus infinity means s cannot be made. For each number loop s downwards so it is used once: dp[s] = max(dp[s], dp[s - x] + 1). If the target is unreachable, return -1.

Complexity: O(n * target) time, O(target) space

class Solution:
    def lengthOfLongestSubsequence(self, nums: List[int], target: int) -> int:
        NEG = float('-inf')
        dp = [0] + [NEG] * target          # dp[s] = most numbers that add to s (NEG: impossible)
        for x in nums:
            for s in range(target, x - 1, -1):    # BACKWARDS: each number used once
                dp[s] = max(dp[s], dp[s - x] + 1)
        return dp[target] if dp[target] > 0 else -1
HardLeetCode #805
Solution

If a group of k numbers has the same average as the whole array, its sum must be k * total / n. So ask, for each size k up to n / 2, whether some subset of exactly k numbers has that sum. dp[k] is a bitmask where bit s is set if k numbers can sum to s. For each number, go through k backwards and set dp[k] |= dp[k - 1] << x. The other group then automatically has the same average.

Complexity: O(n^2) big-integer shifts of up to sum(nums) bits, O(n * sum) bits of space

class Solution:
    def splitArraySameAverage(self, nums: List[int]) -> bool:
        n, total = len(nums), sum(nums)
        half = n // 2
        dp = [0] * (half + 1)              # dp[k]: bit s is set if some k numbers sum to s
        dp[0] = 1
        for x in nums:
            for k in range(half, 0, -1):   # BACKWARDS over k: each number used once
                dp[k] |= dp[k - 1] << x
        for k in range(1, half + 1):
            # k numbers must sum to k * average, which has to be a whole number
            if total * k % n == 0 and (dp[k] >> (total * k // n)) & 1:
                return True
        return False

Partition DP

Use this when you split the numbers into two groups and care about their difference or about +/- sign choices, which turns into a subset-sum target.

MediumLeetCode #1049
Solution

Smashing stones ends with one stone whose weight is the difference of two groups: some stones count as plus and the rest as minus. So split the stones into two groups with the smallest possible difference. Find the heaviest group that does not exceed total / 2 using 0/1 subset sum; the answer is total - 2 * that weight.

Complexity: O(n * total) time, O(total) space

class Solution:
    def lastStoneWeightII(self, stones: List[int]) -> int:
        total = sum(stones)
        half = total // 2
        dp = [True] + [False] * half       # dp[s] = some group of stones weighs exactly s
        for x in stones:
            for s in range(half, x - 1, -1):      # BACKWARDS: each stone goes in one group once
                dp[s] = dp[s] or dp[s - x]
        best = max(s for s in range(half + 1) if dp[s])    # heaviest group not above half
        return total - 2 * best            # the other group minus this one
MediumLeetCode #494
Solution

Giving each number a + or - splits the array into a plus group P and a minus group N. Then P - N = target and P + N = total, so P = (total + target) / 2. The problem becomes: count the subsets whose sum is P. dp[s] is the number of subsets with sum s, filled with s going downwards so each number is used once.

Complexity: O(n * total) time, O(total) space

class Solution:
    def findTargetSumWays(self, nums: List[int], target: int) -> int:
        total = sum(nums)
        if abs(target) > total or (total + target) % 2:
            return 0
        need = (total + target) // 2       # P - N = target and P + N = total, so P = (total + target) / 2
        dp = [1] + [0] * need              # dp[s] = number of subsets with sum s
        for x in nums:
            for s in range(need, x - 1, -1):      # BACKWARDS: each number used once
                dp[s] += dp[s - x]
        return dp[need]
HardLeetCode #956
Solution

Two supports of equal height means splitting some rods into two groups with equal sum. Track states by the difference d between the two supports, and store the height of the shorter one. For each rod you may skip it, weld it to the taller support (d grows by r), or weld it to the shorter one (the new difference is |d - r|, and the shorter side gains min(d, r)). The answer is the stored height at difference 0.

Complexity: O(n * S) time, O(S) space, where S is the sum of the rods

class Solution:
    def tallestBillboard(self, rods: List[int]) -> int:
        best = {0: 0}                      # best[d] = tallest "shorter support" when the supports differ by d
        for r in rods:
            nxt = dict(best)               # option 1: skip this rod
            for d, shorter in best.items():
                nxt[d + r] = max(nxt.get(d + r, 0), shorter)            # weld to the taller support
                nd = abs(d - r)
                nxt[nd] = max(nxt.get(nd, 0), shorter + min(d, r))      # weld to the shorter support
            best = nxt
        return best[0]

Coin Change

Use this when you build an amount from reusable coins and need the fewest coins, the number of combinations, or the number of orderings.

MediumLeetCode #322
Solution

State: dp[a] is the fewest coins that make amount a, with infinity meaning impossible. Try every coin as the last coin: dp[a] = min(dp[a], dp[a - c] + 1). The base case is dp[0] = 0. If dp[amount] is still infinity, return -1.

Complexity: O(amount * len(coins)) time, O(amount) space

class Solution:
    def coinChange(self, coins: List[int], amount: int) -> int:
        INF = float("inf")
        dp = [0] + [INF] * amount          # dp[a] = fewest coins that make amount a
        for a in range(1, amount + 1):
            for c in coins:
                if c <= a:
                    dp[a] = min(dp[a], dp[a - c] + 1)    # use coin c as the last coin
        return dp[amount] if dp[amount] != INF else -1
MediumLeetCode #518
Solution

Now count the combinations, where 1+2 and 2+1 are the same. dp[a] is the number of combinations that make amount a, with dp[0] = 1. Put the coins in the OUTER loop and the amount in the inner loop, going forwards so a coin can repeat. Each coin is added to the table once, in a fixed order, which stops different orderings from being counted.

Complexity: O(amount * len(coins)) time, O(amount) space

class Solution:
    def change(self, amount: int, coins: List[int]) -> int:
        dp = [1] + [0] * amount            # dp[a] = number of combinations that make amount a
        for c in coins:                    # coins in the OUTER loop: 1+2 and 2+1 count once
            for a in range(c, amount + 1): # forwards, so a coin may be reused
                dp[a] += dp[a - c]
        return dp[amount]
MediumLeetCode #377
Solution

This counts orderings, so 1+2 and 2+1 are different. dp[t] is the number of ordered sequences that sum to t, with dp[0] = 1. Put the amount in the OUTER loop and the numbers in the inner loop. For every t, every number may be the last one, so each order is counted separately: dp[t] += dp[t - x].

Complexity: O(target * len(nums)) time, O(target) space

class Solution:
    def combinationSum4(self, nums: List[int], target: int) -> int:
        dp = [1] + [0] * target            # dp[t] = number of ordered sequences that sum to t
        for t in range(1, target + 1):     # amount in the OUTER loop: 1+2 and 2+1 count separately
            for x in nums:
                if x <= t:
                    dp[t] += dp[t - x]     # x is the last number of the sequence
        return dp[target]

Grid DP

Use this when you move through a grid (usually right and down) and each cell's answer comes from its top and left neighbours.

MediumLeetCode #62
Solution

State: dp[r][c] is the number of ways to reach cell (r, c). You can only arrive from above or from the left, so dp[r][c] = dp[r-1][c] + dp[r][c-1]. The first row and first column have exactly one way each, so the table starts filled with 1s.

Complexity: O(m * n) time, O(m * n) space

class Solution:
    def uniquePaths(self, m: int, n: int) -> int:
        dp = [[1] * n for _ in range(m)]   # first row and first column: exactly one way
        for r in range(1, m):
            for c in range(1, n):
                dp[r][c] = dp[r - 1][c] + dp[r][c - 1]    # arrive from above or from the left
        return dp[-1][-1]
MediumLeetCode #64
Solution

Same two-neighbour idea, but each cell now stores a minimum cost. The cheapest path to a cell is its own value plus the smaller of the cheapest path to the cell above and to the cell on the left. The grid is overwritten in place, and cells outside the grid count as infinity so the first row and column work too.

Complexity: O(m * n) time, O(1) extra space

class Solution:
    def minPathSum(self, grid: List[List[int]]) -> int:
        R, C = len(grid), len(grid[0])
        for r in range(R):
            for c in range(C):
                if r == c == 0:
                    continue
                up = grid[r - 1][c] if r else float("inf")
                left = grid[r][c - 1] if c else float("inf")
                grid[r][c] += min(up, left)    # best way in, plus this cell's cost
        return grid[-1][-1]
HardLeetCode #174
Solution

Filling from the top-left fails, because the health needed now depends on what happens later. So fill backwards from the princess. dp[r][c] is the minimum health needed when entering cell (r, c). It equals the smaller of the two next cells' needs minus this cell's value, and never less than 1. The extra border row and column of infinity keep the edges simple.

Complexity: O(m * n) time, O(m * n) space

class Solution:
    def calculateMinimumHP(self, dungeon: List[List[int]]) -> int:
        R, C = len(dungeon), len(dungeon[0])
        INF = float("inf")
        # dp[r][c] = minimum health needed on entering (r, c)
        dp = [[INF] * (C + 1) for _ in range(R + 1)]
        dp[R][C - 1] = dp[R - 1][C] = 1    # after the princess's room we need at least 1 hp
        for r in range(R - 1, -1, -1):     # fill BACKWARDS from the princess
            for c in range(C - 1, -1, -1):
                need = min(dp[r + 1][c], dp[r][c + 1]) - dungeon[r][c]
                dp[r][c] = max(1, need)    # health must never drop below 1
        return dp[0][0]

LCS

Use this when two strings or arrays are compared and the answer is built from the best match between their prefixes.

MediumLeetCode #1143
Solution

State: dp[i][j] is the LCS length of the first i characters of A and the first j characters of B. If the last characters match, extend the diagonal: dp[i-1][j-1] + 1. If they differ, drop one character from either string and take the better result: max(dp[i-1][j], dp[i][j-1]). The extra row and column of zeros are the base case.

Complexity: O(m * n) time, O(m * n) space

class Solution:
    def longestCommonSubsequence(self, text1: str, text2: str) -> int:
        m, n = len(text1), len(text2)
        dp = [[0] * (n + 1) for _ in range(m + 1)]   # dp[i][j] = LCS of text1[:i] and text2[:j]
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if text1[i - 1] == text2[j - 1]:
                    dp[i][j] = dp[i - 1][j - 1] + 1               # match: extend the diagonal
                else:
                    dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])    # mismatch: drop one character
        return dp[m][n]
MediumLeetCode #718
Solution

This is the substring version: the shared part must be contiguous. dp[i][j] is the length of the common run that ends at nums1[i-1] and nums2[j-1]. On a match extend the diagonal, dp[i-1][j-1] + 1. On a mismatch the run is broken, so the cell stays 0 instead of taking a max like LCS. Keep the largest cell seen.

Complexity: O(m * n) time, O(m * n) space

class Solution:
    def findLength(self, nums1: List[int], nums2: List[int]) -> int:
        m, n = len(nums1), len(nums2)
        dp = [[0] * (n + 1) for _ in range(m + 1)]   # dp[i][j] = common run ending at nums1[i-1], nums2[j-1]
        best = 0
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if nums1[i - 1] == nums2[j - 1]:
                    dp[i][j] = dp[i - 1][j - 1] + 1  # match: extend the diagonal
                    best = max(best, dp[i][j])
                # mismatch: the run is broken, the cell stays 0 (unlike LCS)
        return best
HardLeetCode #1092
Solution

The shortest string that contains both inputs as subsequences writes each shared character once, so its length is len(A) + len(B) - LCS. Build the LCS table first. Then walk back from dp[m][n]: on a match write that character once and move diagonally; otherwise write the character from the side with the larger table value and move that way. Leftover characters are copied at the end.

Complexity: O(m * n) time, O(m * n) space

class Solution:
    def shortestCommonSupersequence(self, str1: str, str2: str) -> str:
        m, n = len(str1), len(str2)
        dp = [[0] * (n + 1) for _ in range(m + 1)]   # the LCS table of the two strings
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if str1[i - 1] == str2[j - 1]:
                    dp[i][j] = dp[i - 1][j - 1] + 1
                else:
                    dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
        i, j, out = m, n, []
        while i > 0 and j > 0:             # walk back through the table, building the answer reversed
            if str1[i - 1] == str2[j - 1]:
                out.append(str1[i - 1])    # shared character is written once
                i -= 1
                j -= 1
            elif dp[i - 1][j] >= dp[i][j - 1]:
                out.append(str1[i - 1])
                i -= 1
            else:
                out.append(str2[j - 1])
                j -= 1
        out.extend(reversed(str1[:i]))     # one of these two leftovers is empty
        out.extend(reversed(str2[:j]))
        return ''.join(reversed(out))

LIS

Use this when you need the longest strictly increasing subsequence, or a problem that reduces to sorting by one key and then increasing on another.

MediumLeetCode #300
Solution

Keep an array tails where tails[k] is the smallest possible last value of an increasing subsequence of length k+1. For each number x, binary search for the first tail that is not smaller than x. If there is none, x extends the longest subsequence, so append it. Otherwise x replaces that tail, which keeps the tails as small as possible. The length of tails is the answer, but tails itself is not the real subsequence.

Complexity: O(n log n) time, O(n) space

from bisect import bisect_left


class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        tails = []                         # tails[k] = smallest tail of an increasing subsequence of length k+1
        for x in nums:
            i = bisect_left(tails, x)      # first tail >= x (bisect_right would allow equal values)
            if i == len(tails):
                tails.append(x)            # x extends the longest subsequence
            else:
                tails[i] = x               # x gives a smaller tail for length i+1
        return len(tails)
MediumLeetCode #673
Solution

Counting needs more than the tails array, so use the O(n^2) form. length[i] is the LIS length ending at i, and count[i] is how many such subsequences end at i. For each earlier j with a smaller value: a longer chain replaces length[i] and copies count[j]; an equal-length chain adds count[j]. At the end, add the counts of every index whose length equals the maximum.

Complexity: O(n^2) time, O(n) space

class Solution:
    def findNumberOfLIS(self, nums: List[int]) -> int:
        n = len(nums)
        length = [1] * n                   # length[i] = LIS length ending at i
        count = [1] * n                    # count[i] = number of LIS of that length ending at i
        for i in range(n):
            for j in range(i):
                if nums[j] < nums[i]:
                    if length[j] + 1 > length[i]:
                        length[i] = length[j] + 1
                        count[i] = count[j]          # a longer chain: restart the count
                    elif length[j] + 1 == length[i]:
                        count[i] += count[j]         # another way to reach the same length
        best = max(length)
        return sum(c for l, c in zip(length, count) if l == best)
HardLeetCode #354
Solution

An envelope fits inside another only if both width and height are strictly smaller. Sort by width ascending, so only heights still need checking, and now the task is the LIS of the heights. For equal widths sort heights descending, so two envelopes of the same width can never both be chosen. Then run the tails and binary search routine on the heights.

Complexity: O(n log n) time, O(n) space

from bisect import bisect_left


class Solution:
    def maxEnvelopes(self, envelopes: List[List[int]]) -> int:
        envelopes.sort(key=lambda e: (e[0], -e[1]))    # same width: taller first, so they cannot chain
        tails = []                         # LIS tails over the heights
        for _, h in envelopes:
            i = bisect_left(tails, h)
            if i == len(tails):
                tails.append(h)
            else:
                tails[i] = h
        return len(tails)

Edit Distance

Use this when you turn one string into another with deletes, inserts or replaces and want the lowest cost.

MediumLeetCode #583
Solution

This is edit distance with only deletions allowed. dp[i][j] is the fewest deletions to make the first i characters of word1 equal the first j characters of word2. If the last characters match, the cost is dp[i-1][j-1]. Otherwise delete the last character of one word: 1 + min(dp[i-1][j], dp[i][j-1]). The first row and column are the cost of deleting everything.

Complexity: O(m * n) time, O(m * n) space

class Solution:
    def minDistance(self, word1: str, word2: str) -> int:
        m, n = len(word1), len(word2)
        dp = [[0] * (n + 1) for _ in range(m + 1)]   # dp[i][j] = deletions to make word1[:i] equal word2[:j]
        for i in range(m + 1):
            dp[i][0] = i                   # delete all i characters
        for j in range(n + 1):
            dp[0][j] = j
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if word1[i - 1] == word2[j - 1]:
                    dp[i][j] = dp[i - 1][j - 1]                          # match: free
                else:
                    dp[i][j] = 1 + min(dp[i - 1][j], dp[i][j - 1])       # delete from word1 or from word2
        return dp[m][n]
MediumLeetCode #72
Solution

dp[i][j] is the fewest operations to turn the first i characters of word1 into the first j characters of word2. A match costs nothing, so dp[i][j] = dp[i-1][j-1]. On a mismatch take the best of three moves plus 1: delete (dp[i-1][j]), insert (dp[i][j-1]) or replace (dp[i-1][j-1]). Row 0 and column 0 are the cost of inserting or deleting everything.

Complexity: O(m * n) time, O(m * n) space

class Solution:
    def minDistance(self, word1: str, word2: str) -> int:
        m, n = len(word1), len(word2)
        dp = [[0] * (n + 1) for _ in range(m + 1)]
        for i in range(m + 1):
            dp[i][0] = i                   # delete all i characters
        for j in range(n + 1):
            dp[0][j] = j                   # insert all j characters
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if word1[i - 1] == word2[j - 1]:
                    dp[i][j] = dp[i - 1][j - 1]
                else:
                    dp[i][j] = 1 + min(dp[i - 1][j],        # delete
                                       dp[i][j - 1],        # insert
                                       dp[i - 1][j - 1])    # replace
        return dp[m][n]
MediumLeetCode #712
Solution

Same deletion table, but each deletion costs the ASCII value of the character, not 1. dp[i][j] is the cheapest deletion cost to make s1[:i] equal s2[:j]. A match costs dp[i-1][j-1]. Otherwise pay for deleting s1[i-1] or s2[j-1], whichever leaves the cheaper rest. The first row and column are the sums of the ASCII values of the prefixes.

Complexity: O(m * n) time, O(m * n) space

class Solution:
    def minimumDeleteSum(self, s1: str, s2: str) -> int:
        m, n = len(s1), len(s2)
        dp = [[0] * (n + 1) for _ in range(m + 1)]   # dp[i][j] = cheapest deletions to make s1[:i] equal s2[:j]
        for i in range(1, m + 1):
            dp[i][0] = dp[i - 1][0] + ord(s1[i - 1])     # delete all of s1[:i]
        for j in range(1, n + 1):
            dp[0][j] = dp[0][j - 1] + ord(s2[j - 1])     # delete all of s2[:j]
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if s1[i - 1] == s2[j - 1]:
                    dp[i][j] = dp[i - 1][j - 1]
                else:
                    dp[i][j] = min(dp[i - 1][j] + ord(s1[i - 1]),     # delete from s1
                                   dp[i][j - 1] + ord(s2[j - 1]))     # delete from s2
        return dp[m][n]

Palindromic DP

Use this when you work with palindromes inside a string and the answer for s[i..j] depends on the shorter interval s[i+1..j-1].

MediumLeetCode #5
Solution

State: dp[i][j] is true when s[i..j] is a palindrome. It holds if s[i] == s[j] and the inside s[i+1..j-1] is a palindrome (or is shorter than 2 characters). Fill the table by increasing interval length, so the shorter inside is always ready. Remember the start and length of the longest true interval.

Complexity: O(n^2) time, O(n^2) space

class Solution:
    def longestPalindrome(self, s: str) -> str:
        n = len(s)
        dp = [[False] * n for _ in range(n)]     # dp[i][j] = s[i..j] is a palindrome
        start, best = 0, 1
        for length in range(1, n + 1):           # interval DP: shorter intervals first
            for i in range(n - length + 1):
                j = i + length - 1
                if s[i] == s[j] and (length <= 2 or dp[i + 1][j - 1]):
                    dp[i][j] = True              # equal ends around a palindrome
                    if length > best:
                        start, best = i, length
        return s[start:start + best]
MediumLeetCode #516
Solution

Characters may now be skipped. dp[i][j] is the longest palindromic subsequence inside s[i..j]. If the two ends match, wrap them around the inside answer: dp[i+1][j-1] + 2. If not, drop one end and take the better: max(dp[i+1][j], dp[i][j-1]). Fill by increasing interval length, starting from the single characters (answer 1).

Complexity: O(n^2) time, O(n^2) space

class Solution:
    def longestPalindromeSubseq(self, s: str) -> int:
        n = len(s)
        dp = [[0] * n for _ in range(n)]   # dp[i][j] = longest palindromic subsequence in s[i..j]
        for i in range(n):
            dp[i][i] = 1                   # one character is a palindrome
        for length in range(2, n + 1):     # interval DP: shorter pieces are ready first
            for i in range(n - length + 1):
                j = i + length - 1
                if s[i] == s[j]:
                    dp[i][j] = dp[i + 1][j - 1] + 2                  # ends match: wrap the inside
                else:
                    dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])       # drop one end
        return dp[0][n - 1]
HardLeetCode #132
Solution

Two tables work together. First fill pal[i][j] by interval length, as before. Then cuts[j] is the fewest cuts for the prefix s[0..j]: if the whole prefix is a palindrome it is 0, otherwise try every last piece s[i..j] that is a palindrome and take cuts[i-1] + 1. The palindrome table makes each piece check a single lookup.

Complexity: O(n^2) time, O(n^2) space

class Solution:
    def minCut(self, s: str) -> int:
        n = len(s)
        pal = [[False] * n for _ in range(n)]    # pal[i][j] = s[i..j] is a palindrome (interval DP)
        for length in range(1, n + 1):
            for i in range(n - length + 1):
                j = i + length - 1
                pal[i][j] = s[i] == s[j] and (length <= 2 or pal[i + 1][j - 1])
        cuts = [0] * n                       # cuts[j] = fewest cuts for the prefix s[0..j]
        for j in range(n):
            if pal[0][j]:
                continue                     # the whole prefix is a palindrome: no cut
            cuts[j] = min(cuts[i - 1] + 1 for i in range(1, j + 1) if pal[i][j])   # last piece is s[i..j]
        return cuts[-1]

DP on Trees

Use this when a tree answer depends on a choice at each node, so every node returns a small tuple of states to its parent.

EasyLeetCode #543
Solution

A postorder DFS returns the height of each subtree, so the parent can build on its children's answers. At every node, the longest path that bends there is left height + right height, so update the best answer with that. Then return 1 + max(left, right) to the parent. Each node is visited once.

Complexity: O(n) time, O(h) space for the recursion

class Solution:
    def diameterOfBinaryTree(self, root: Optional[TreeNode]) -> int:
        best = 0

        def depth(node):                   # postorder: returns the height of this subtree
            nonlocal best
            if not node:
                return 0
            left, right = depth(node.left), depth(node.right)
            best = max(best, left + right)     # longest path that bends at this node
            return 1 + max(left, right)

        depth(root)
        return best
MediumLeetCode #337
Solution

Each node returns two numbers: the best total if its house is robbed (take) and the best total if it is skipped (skip). If this node is robbed, both children must be skipped, so take = val + left skip + right skip. If it is skipped, each child is free to choose its better option, so skip = max(left) + max(right). The answer is the larger of the two values at the root.

Complexity: O(n) time, O(h) space

class Solution:
    def rob(self, root: Optional[TreeNode]) -> int:
        def go(node):                      # postorder: returns (take, skip)
            if not node:
                return (0, 0)
            lt, ls = go(node.left)
            rt, rs = go(node.right)
            take = node.val + ls + rs      # rob this house: the children must be skipped
            skip = max(lt, ls) + max(rt, rs)    # skip it: each child chooses freely
            return (take, skip)

        return max(go(root))
HardLeetCode #968
Solution

Each node returns three costs for its subtree, where the whole subtree below the node is always covered. a is the cost if this node has a camera, b is the cost if it has no camera but a child covers it, and c is the cost if it has no camera and is not covered yet, so the parent must cover it. A missing child is (infinity, 0, 0). The combinations are: a = 1 + best of each child, b = at least one child has a camera, c = both children are in state b. The answer is min(a, b) at the root.

Complexity: O(n) time, O(h) space

class Solution:
    def minCameraCover(self, root: Optional[TreeNode]) -> int:
        INF = float('inf')

        def go(node):                      # postorder: returns (camera here, covered by a child, not covered)
            if not node:
                return (INF, 0, 0)
            la, lb, lc = go(node.left)
            ra, rb, rc = go(node.right)
            a = 1 + min(la, lb, lc) + min(ra, rb, rc)    # camera here: children can be in any state
            b = min(la + ra, la + rb, lb + ra)           # no camera here: a child must have one
            c = lb + rb                                  # not covered: children covered without a camera
            return (a, b, c)

        a, b, _ = go(root)
        return min(a, b)

DP on Strings

Use this when you match or split a string against a dictionary or a pattern and dp[i] describes its first i characters.

MediumLeetCode #139
Solution

State: dp[i] is true when the first i characters can be split into dictionary words. The last word is s[j:i] for some j, so dp[i] is true if dp[j] is true and s[j:i] is in the set. Only lengths up to the longest word need checking. The base case is dp[0] = True for the empty prefix.

Complexity: O(n * L) substring checks, where L is the longest word length; O(n) space

class Solution:
    def wordBreak(self, s: str, wordDict: List[str]) -> bool:
        words = set(wordDict)
        max_len = max(map(len, wordDict))
        dp = [True] + [False] * len(s)     # dp[i] = the first i characters can be split into words
        for i in range(1, len(s) + 1):
            for j in range(max(0, i - max_len), i):    # the last word is s[j:i]
                if dp[j] and s[j:i] in words:
                    dp[i] = True
                    break
        return dp[len(s)]
MediumLeetCode #97
Solution

State: dp[i][j] is true when s3[:i+j] is an interleaving of s1[:i] and s2[:j]. The last character of s3[:i+j] must come from s1 or from s2. It comes from s1 if dp[i-1][j] is true and s1[i-1] equals that character; similarly for s2 with dp[i][j-1]. If the lengths do not add up, the answer is false at once.

Complexity: O(m * n) time, O(m * n) space

class Solution:
    def isInterleave(self, s1: str, s2: str, s3: str) -> bool:
        m, n = len(s1), len(s2)
        if m + n != len(s3):
            return False
        dp = [[False] * (n + 1) for _ in range(m + 1)]   # dp[i][j] = s3[:i+j] interleaves s1[:i] and s2[:j]
        dp[0][0] = True
        for i in range(m + 1):
            for j in range(n + 1):
                if i > 0 and dp[i - 1][j] and s1[i - 1] == s3[i + j - 1]:
                    dp[i][j] = True        # the last character of s3 came from s1
                if j > 0 and dp[i][j - 1] and s2[j - 1] == s3[i + j - 1]:
                    dp[i][j] = True        # the last character of s3 came from s2
        return dp[m][n]
HardLeetCode #10
Solution

State: dp[i][j] is true when s[:i] matches p[:j]. A normal character or '.' matches one character, so dp[i][j] = dp[i-1][j-1] if they agree. For 'x*', either use zero copies, dp[i][j-2], or one more copy when x matches s[i-1], dp[i-1][j]. Row 0 handles patterns like a*b* that match the empty string.

Complexity: O(m * n) time, O(m * n) space

class Solution:
    def isMatch(self, s: str, p: str) -> bool:
        m, n = len(s), len(p)
        dp = [[False] * (n + 1) for _ in range(m + 1)]   # dp[i][j] = s[:i] matches p[:j]
        dp[0][0] = True
        for j in range(2, n + 1):          # patterns like a*b*c* can match the empty string
            if p[j - 1] == '*':
                dp[0][j] = dp[0][j - 2]
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if p[j - 1] == '*':
                    dp[i][j] = dp[i][j - 2]                          # '*' means zero copies of the previous char
                    if p[j - 2] == '.' or p[j - 2] == s[i - 1]:
                        dp[i][j] = dp[i][j] or dp[i - 1][j]          # or one more copy of it
                elif p[j - 1] == '.' or p[j - 1] == s[i - 1]:
                    dp[i][j] = dp[i - 1][j - 1]                      # single character matches
        return dp[m][n]
14. Trie (prefix tree)

A tree where each edge is a character, so words sharing a prefix share a path. Prefix questions cost O(length of word), not O(number of words).

Insert

Use this when many words must be stored so that words with the same start share one path, and you want to keep extra facts (an end flag, a count) on the nodes while you add them.

EasyLeetCode #14
Solution

Insert every string into one trie, so the start shared by all strings is one path from the root.
Walk down while the node has exactly one child and no word ends at it. Each step adds one letter to the answer.
The key line is `n = n.kids.setdefault(ch, Trie())` in insert: it reuses a path that exists or grows a new branch.
The walk stops when the path forks (the strings differ) or a word ends (the shortest string is used up).

Complexity: O(total characters) time, O(total characters) space

class Trie:
    def __init__(self):
        self.kids, self.end = {}, False

    def insert(self, w):
        n = self
        for ch in w:
            n = n.kids.setdefault(ch, Trie())   # reuse the shared path or grow a branch
        n.end = True


class Solution:
    def longestCommonPrefix(self, strs: List[str]) -> str:
        root = Trie()
        for w in strs:
            root.insert(w)
        prefix, n = [], root
        while len(n.kids) == 1 and not n.end:   # one way forward and no word stops here
            ch, n = next(iter(n.kids.items()))
            prefix.append(ch)
        return "".join(prefix)
MediumLeetCode #208
Solution

Each node holds a `kids` dict and an `end` flag. insert walks the word one letter at a time and creates any missing child with setdefault.
The last node gets `end = True`, which marks "a word stops here".
search and startsWith both use the `_walk` helper that follows the letters and returns the final node, or None if a letter is missing.
search also needs `end` to be True, so "app" is not found after inserting only "apple". startsWith only needs the path to exist.

Complexity: O(L) time per operation, O(total characters) space

class Trie:
    def __init__(self):
        self.kids, self.end = {}, False

    def insert(self, word: str) -> None:
        n = self
        for ch in word:
            n = n.kids.setdefault(ch, Trie())   # one node per letter
        n.end = True                            # a word stops at this node

    def _walk(self, s):
        n = self
        for ch in s:
            if ch not in n.kids:
                return None
            n = n.kids[ch]
        return n

    def search(self, word: str) -> bool:
        n = self._walk(word)
        return bool(n and n.end)                # path must exist AND a word must end there

    def startsWith(self, prefix: str) -> bool:
        return self._walk(prefix) is not None   # path alone is enough
HardLeetCode #2416
Solution

Give each node a `count` instead of an end flag, and add 1 to it every time an insert passes through the node.
After all words are inserted, the count at a node is the number of words that start with the prefix spelled by that path.
The score of one word is the sum of the counts along its own path, one count for each of its prefixes.
The key line is `n.count += 1` inside the insert loop.

Complexity: O(total characters) time, O(total characters) space

class Trie:
    def __init__(self):
        self.kids, self.count = {}, 0


class Solution:
    def sumPrefixScores(self, words: List[str]) -> List[int]:
        root = Trie()
        for w in words:
            n = root
            for ch in w:
                n = n.kids.setdefault(ch, Trie())
                n.count += 1                    # one more word passes through this prefix
        scores = []
        for w in words:
            n, total = root, 0
            for ch in w:
                n = n.kids[ch]
                total += n.count                # words that start with this prefix
            scores.append(total)
        return scores

Search

Use this when you must know whether a whole word is stored, or which stored words end while you read a string, which means checking the end flag at each step.

EasyLeetCode #2255
Solution

Insert every word and keep a number at the node where it stops, so a word that appears twice counts twice.
Then walk `s` once from the root. Each time the walk lands on a node whose `end` is above 0, those words are prefixes of `s`.
Add `n.end` to the total at that point, and stop as soon as the next letter has no child.

Complexity: O(total characters of words + |s|) time, O(total characters of words) space

class Trie:
    def __init__(self):
        self.kids, self.end = {}, 0             # end = how many words stop at this node


class Solution:
    def countPrefixes(self, words: List[str], s: str) -> int:
        root = Trie()
        for w in words:
            n = root
            for ch in w:
                n = n.kids.setdefault(ch, Trie())
            n.end += 1                          # duplicate words each count
        n, total = root, 0
        for ch in s:                            # one walk along s
            if ch not in n.kids:
                break
            n = n.kids[ch]
            total += n.end                      # words ending here are prefixes of s
        return total
MediumLeetCode #648
Solution

Insert every root into a trie and set `end` on the last node of each root.
For each word of the sentence, walk down it and return the letters read so far the moment a node has `end` set. The first end flag on the way is the shortest root.
If a letter has no child, no root starts this word, so the word is kept as it is.

Complexity: O(characters in dictionary + characters in sentence) time, O(characters in dictionary) space

class Trie:
    def __init__(self):
        self.kids, self.end = {}, False


class Solution:
    def replaceWords(self, dictionary: List[str], sentence: str) -> str:
        root = Trie()
        for w in dictionary:
            n = root
            for ch in w:
                n = n.kids.setdefault(ch, Trie())
            n.end = True

        def shortest_root(word):
            n = root
            for i, ch in enumerate(word):
                if ch not in n.kids:
                    return word                 # no root starts this word
                n = n.kids[ch]
                if n.end:
                    return word[:i + 1]         # first end flag = shortest root
            return word

        return " ".join(shortest_root(w) for w in sentence.split())
HardLeetCode #1032
Solution

A word matches when it is a suffix of the letters seen so far, so insert each word reversed.
On every query, add the letter to the stream and walk the trie from the newest letter backwards.
If the walk reaches a node with `end` set, some word is a suffix of the stream, so return True.
The walk is never longer than the longest word, because it follows trie edges and the trie is that deep.

Complexity: O(L) time per query where L is the longest word, O(total characters) space

class Trie:
    def __init__(self):
        self.kids, self.end = {}, False


class StreamChecker:
    def __init__(self, words: List[str]):
        self.root = Trie()
        for w in words:
            n = self.root
            for ch in reversed(w):              # insert each word backwards
                n = n.kids.setdefault(ch, Trie())
            n.end = True
        self.stream = []

    def query(self, letter: str) -> bool:
        self.stream.append(letter)
        n = self.root
        for ch in reversed(self.stream):        # newest letter first
            n = n.kids.get(ch)
            if n is None:
                return False
            if n.end:                           # a stored word ends the stream
                return True
        return False

Prefix Search

Use this when you need the count, the sum or the list of stored words that begin with a prefix: walk to the prefix node, then read or explore what sits below it.

EasyLeetCode #2185
Solution

Add 1 to a `count` on every node an insert passes through, so each node knows how many words go through it.
To answer a prefix question, walk to the prefix node and read its count. If a letter is missing on the way, the answer is 0.
Every word below that node starts with the prefix, so one read replaces checking each word.

Complexity: O(total characters) time, O(total characters) space

class Trie:
    def __init__(self):
        self.kids, self.count = {}, 0


class Solution:
    def prefixCount(self, words: List[str], pref: str) -> int:
        root = Trie()
        for w in words:
            n = root
            for ch in w:
                n = n.kids.setdefault(ch, Trie())
                n.count += 1                    # words passing through this node
        n = root
        for ch in pref:                         # walk to the prefix node
            if ch not in n.kids:
                return 0
            n = n.kids[ch]
        return n.count
MediumLeetCode #677
Solution

Keep a `total` on each node: the sum of the values of all keys whose path goes through that node.
insert adds `delta` along the key's path, where delta is the new value minus the old value of that key. This way inserting an existing key replaces its value instead of adding to it.
sum walks to the prefix node and returns its total, or 0 if the path breaks.

Complexity: O(L) time per operation, O(total characters) space

class Trie:
    def __init__(self):
        self.kids, self.total = {}, 0           # total = sum of values of keys below this node


class MapSum:
    def __init__(self):
        self.root = Trie()
        self.values = {}

    def insert(self, key: str, val: int) -> None:
        delta = val - self.values.get(key, 0)  # re-inserting a key replaces its old value
        self.values[key] = val
        n = self.root
        for ch in key:
            n = n.kids.setdefault(ch, Trie())
            n.total += delta

    def sum(self, prefix: str) -> int:
        n = self.root
        for ch in prefix:                       # walk to the prefix node
            if ch not in n.kids:
                return 0
            n = n.kids[ch]
        return n.total
MediumLeetCode #1268
Solution

Insert every product and store the product string on the node where it ends.
For each typed letter, move one step down the trie to the prefix node. Once the path breaks, every longer prefix has no suggestions.
Then run a DFS below the prefix node with children in alphabetical order and stop after 3 words. A node's own word comes before its children, which is dictionary order.
The DFS uses a stack, because a product can be 3000 letters long and deep recursion would fail.

Complexity: O(total characters) to build, then each prefix visits only nodes until 3 words are found

class Trie:
    def __init__(self):
        self.kids, self.word = {}, None         # word is stored where a product ends


class Solution:
    def suggestedProducts(self, products: List[str], searchWord: str) -> List[List[str]]:
        root = Trie()
        for p in products:
            n = root
            for ch in p:
                n = n.kids.setdefault(ch, Trie())
            n.word = p

        def first_three(node):                  # DFS below the prefix node, alphabetical
            found, stack = [], [node]
            while stack and len(found) < 3:
                cur = stack.pop()
                if cur.word:
                    found.append(cur.word)
                for ch in sorted(cur.kids, reverse=True):   # push big letters first so small ones pop first
                    stack.append(cur.kids[ch])
            return found

        answer, n = [], root
        for ch in searchWord:
            n = n.kids.get(ch) if n is not None else None   # once the path breaks it stays broken
            answer.append(first_three(n) if n is not None else [])
        return answer

Starts With

Use this when you only need to know whether some stored word begins with a prefix, so you walk the path and ignore the end flag.

EasyLeetCode #1455
Solution

Insert each word of the sentence and save, on every node, the position of the first word that reached it (`n.first`).
To ask "does some word start with searchWord", only the path matters: walk searchWord and return -1 if a letter has no child.
The position saved on the last node is the leftmost word that starts with searchWord.

Complexity: O(total characters) time, O(total characters) space

class Trie:
    def __init__(self):
        self.kids, self.first = {}, 0           # first = position of the first word through this node


class Solution:
    def isPrefixOfWord(self, sentence: str, searchWord: str) -> int:
        root = Trie()
        for pos, w in enumerate(sentence.split(), start=1):
            n = root
            for ch in w:
                n = n.kids.setdefault(ch, Trie())
                if n.first == 0:
                    n.first = pos               # remember the leftmost word to reach this prefix
        n = root
        for ch in searchWord:                   # starts-with: only the path matters
            if ch not in n.kids:
                return -1
            n = n.kids[ch]
        return n.first
MediumLeetCode #3043
Solution

Turn each number of arr1 into a string of digits and insert it, so every prefix of every number becomes a path in the trie.
For each number of arr2, walk its digits and stop when the next digit has no child. That is a starts-with check that failed.
The number of steps taken is the longest prefix it shares with some number of arr1. Keep the maximum over arr2.

Complexity: O((n + m) * D) time, O(n * D) space, where D is the number of digits

class Trie:
    def __init__(self):
        self.kids = {}


class Solution:
    def longestCommonPrefix(self, arr1: List[int], arr2: List[int]) -> int:
        root = Trie()
        for x in arr1:
            n = root
            for ch in str(x):
                n = n.kids.setdefault(ch, Trie())   # every prefix of x becomes a path
        best = 0
        for y in arr2:
            n, depth = root, 0
            for ch in str(y):
                if ch not in n.kids:            # starts-with fails here
                    break
                n = n.kids[ch]
                depth += 1
            best = max(best, depth)
        return best
HardLeetCode #745
Solution

A query asks for a prefix and a suffix at once. For every word and every suffix of it, insert the key `suffix + "{" + word`, so one path spells both parts.
A query walks `suff + "{" + pref`, which is a starts-with check on that key.
Each node saves the largest word index that passed through it. Words go in order, so a later index overwrites an earlier one.
The query returns the index saved on the last node, or -1 if the walk breaks.

Complexity: O(n * L^2) to build, O(L) per query, O(n * L^2) space

class WordFilter:
    def __init__(self, words: List[str]):
        self.root = {}                          # a node is a dict; "idx" holds the largest index through it
        for idx, w in enumerate(words):
            for i in range(len(w) + 1):         # every suffix, including the empty one
                key = w[i:] + "{" + w           # suffix, separator, whole word
                n = self.root
                for ch in key:
                    n = n.setdefault(ch, {})
                    n["idx"] = idx              # a later word overwrites an earlier one

    def f(self, pref: str, suff: str) -> int:
        n = self.root
        for ch in suff + "{" + pref:            # starts-with on the combined key
            if ch not in n:
                return -1
            n = n[ch]
        return n["idx"]

Word Dictionary

Use this when a query holds wildcards or allows a wrong letter, so at those positions you branch over every child with DFS.

MediumLeetCode #211
Solution

addWord is a normal insert. search calls a recursive helper `_dfs(word, i)` on the node reached so far.
A normal letter follows its single child, or fails if there is none. A "." tries every child and succeeds if any branch does, which is the DFS from the notes.
When all letters are used, return the node's end flag, so "b" is not found when only "bad" was added.

Complexity: add O(L); search O(L) with no dots, O(26^d * L) with d dots

class WordDictionary:
    def __init__(self):
        self.kids, self.end = {}, False

    def addWord(self, word: str) -> None:
        n = self
        for ch in word:
            n = n.kids.setdefault(ch, WordDictionary())
        n.end = True

    def search(self, word: str) -> bool:
        return self._dfs(word, 0)

    def _dfs(self, w, i):
        if i == len(w):
            return self.end                     # all letters used: must be a word end
        ch = w[i]
        if ch == ".":                           # wildcard: try every child
            return any(c._dfs(w, i + 1) for c in self.kids.values())
        return ch in self.kids and self.kids[ch]._dfs(w, i + 1)
MediumLeetCode #676
Solution

Treat the one allowed change as a wildcard that may be used once. buildDict is a normal insert.
`_dfs(w, i, changed)` goes down the trie. A child whose letter equals `w[i]` continues with the same flag.
A child with a different letter is taken only if no change was used yet, and then `changed` becomes True.
At the end of the word the node must be a word end and `changed` must be True, so an exact match does not count.

Complexity: O(26 * L^2) time per search worst case, O(total characters) space

class MagicDictionary:
    def __init__(self):
        self.kids, self.end = {}, False

    def buildDict(self, dictionary: List[str]) -> None:
        for w in dictionary:
            n = self
            for ch in w:
                n = n.kids.setdefault(ch, MagicDictionary())
            n.end = True

    def search(self, searchWord: str) -> bool:
        return self._dfs(searchWord, 0, False)

    def _dfs(self, w, i, changed):
        if i == len(w):
            return self.end and changed         # a word end, with exactly one change made
        for ch, child in self.kids.items():
            if ch == w[i]:
                if child._dfs(w, i + 1, changed):       # keep the letter
                    return True
            elif not changed:
                if child._dfs(w, i + 1, True):          # spend the one change here
                    return True
        return False
HardLeetCode #472
Solution

Put all words into a trie. For each word, `can_build(start)` walks the trie along `w[start:]`.
Each time the walk passes a node with `end` set, it may cut the word there and try to build the rest. That is one branch per cut point, like the dot branching in a wildcard search.
A memo on `start` stops the branches from repeating work.
The check `whole` stops a word from counting as one piece of itself, so at least two words are needed.

Complexity: O(sum of L^2) time over all words, O(total characters) space

class Trie:
    def __init__(self):
        self.kids, self.end = {}, False


class Solution:
    def findAllConcatenatedWordsInADict(self, words: List[str]) -> List[str]:
        root = Trie()
        for w in words:
            n = root
            for ch in w:
                n = n.kids.setdefault(ch, Trie())
            n.end = True

        def can_build(w, start, memo):
            if start == len(w):
                return True
            if start in memo:
                return memo[start]
            n, ok = root, False
            for j in range(start, len(w)):
                n = n.kids.get(w[j])            # walk the trie along the word
                if n is None:
                    break
                whole = start == 0 and j == len(w) - 1     # the word itself is not a piece
                if n.end and not whole and can_build(w, j + 1, memo):   # cut here, build the rest
                    ok = True
                    break
            memo[start] = ok
            return ok

        return [w for w in words if can_build(w, 0, {})]

Word Search

Use this when you must find words inside a grid, so you walk the grid and the trie together and stop at once when the trie has no such branch.

MediumLeetCode #79
Solution

Walk the grid and the word together. The index `i` says how many letters are matched, which plays the role of the depth in a trie.
From a cell equal to `word[i]`, try the four neighbours with `i + 1`. Mark the cell as used first and restore it on the way back.
One word is a trie with a single path, so no trie is needed here. The next question has many words and uses the same DFS with a real trie.

Complexity: O(R * C * 3^L) time, O(L) space for the recursion

class Solution:
    def exist(self, board: List[List[str]], word: str) -> bool:
        rows, cols = len(board), len(board[0])

        def dfs(r, c, i):                       # i = letters of word matched so far
            if i == len(word):
                return True
            if not (0 <= r < rows and 0 <= c < cols) or board[r][c] != word[i]:
                return False                    # no branch for this letter
            saved, board[r][c] = board[r][c], "#"      # mark the cell as used
            found = (dfs(r + 1, c, i + 1) or dfs(r - 1, c, i + 1)
                     or dfs(r, c + 1, i + 1) or dfs(r, c - 1, i + 1))
            board[r][c] = saved                 # undo the mark
            return found

        return any(dfs(r, c, 0) for r in range(rows) for c in range(cols))
HardLeetCode #212
Solution

Build a trie of all the words and store each word on the node where it ends.
Run one grid DFS and step to a neighbour only if its letter is a child of the current trie node. Dead prefixes are never explored.
When a node holds a word, add it to the answer and clear it so it is reported once.
After exploring a node, delete it from its parent if it has no children left, which prunes later searches.

Complexity: O(R * C * 3^L) worst case, far less in practice because of the pruning

class Trie:
    def __init__(self):
        self.kids, self.word = {}, None         # word is stored where a word ends


class Solution:
    def findWords(self, board: List[List[str]], words: List[str]) -> List[str]:
        root = Trie()
        for w in words:
            n = root
            for ch in w:
                n = n.kids.setdefault(ch, Trie())
            n.word = w
        rows, cols, found = len(board), len(board[0]), []

        def dfs(r, c, parent):
            ch = board[r][c]
            node = parent.kids[ch]
            if node.word:
                found.append(node.word)
                node.word = None                # report each word once
            board[r][c] = "#"                   # mark the cell as used
            for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
                nr, nc = r + dr, c + dc
                if 0 <= nr < rows and 0 <= nc < cols and board[nr][nc] in node.kids:   # follow trie edges only
                    dfs(nr, nc, node)
            board[r][c] = ch                    # undo the mark
            if not node.kids:
                del parent.kids[ch]             # prune the dead branch

        for r in range(rows):
            for c in range(cols):
                if board[r][c] in root.kids:
                    dfs(r, c, root)
        return found

Maximum XOR

Use this when you must find the pair (or the best partner for a number) with the largest XOR, which a binary trie answers by taking the opposite bit at each level.

EasyLeetCode #2932
Solution

Two numbers x <= y are a strong pair when y <= 2 * x. Sort the array and slide a window: add each y to a binary trie, then remove numbers from the left while `2 * x < y`.
Each trie node keeps a count of the numbers passing through it, so `add(x, -1)` can remove a number again.
To find the best partner of y, walk its bits from the top and take the opposite-bit child whenever it exists with count above 0. That sets this bit of the XOR to 1.
The window always holds y itself, so the result is at least 0.

Complexity: O(n log n + n * B) time, O(n * B) space, where B is the bit length of the largest number

class Solution:
    def maximumStrongPairXor(self, nums: List[int]) -> int:
        nums.sort()
        bits = max(nums).bit_length()
        root = [None, None, 0]                  # node = [child for bit 0, child for bit 1, count]

        def add(x, delta):                      # delta = +1 to insert, -1 to remove
            n = root
            for b in range(bits - 1, -1, -1):
                bit = (x >> b) & 1
                if n[bit] is None:
                    n[bit] = [None, None, 0]
                n = n[bit]
                n[2] += delta

        def best_partner(x):
            n, cur = root, 0
            for b in range(bits - 1, -1, -1):
                bit = (x >> b) & 1
                want = n[bit ^ 1]               # opposite bit, if a number still has it
                if want is not None and want[2] > 0:
                    cur |= 1 << b
                    n = want
                else:
                    n = n[bit]
            return cur

        best, left = 0, 0
        for y in nums:
            add(y, 1)
            while nums[left] * 2 < y:           # x is too small to pair with y
                add(nums[left], -1)
                left += 1
            best = max(best, best_partner(y))
        return best
MediumLeetCode #421
Solution

Insert every number into a binary trie as a path of bits, from the top bit down to bit 0.
For each number, walk the trie from the top. `want = bit ^ 1` is the opposite bit. If that child exists, take it and set that bit of the result, because a higher bit is worth more than all lower bits together.
If it does not exist, follow the same bit and leave the result bit at 0. The best result over all numbers is the answer.
The trie is stored in two flat int arrays, `child[0]` and `child[1]`, where a node is just an index. Nested dicts would work too, but they use hundreds of MB on the largest inputs.

Complexity: O(n * B) time, O(n * B) space in compact int arrays, where B is the bit length of the largest number (at most 31)

from array import array

class Solution:
    def findMaximumXOR(self, nums: List[int]) -> int:
        bits = max(nums).bit_length()
        child = (array('i', [0]), array('i', [0]))      # child[bit][node] = id of that child, 0 = none
        for x in nums:                                  # insert as bit paths, top bit first
            node = 0
            for b in range(bits - 1, -1, -1):
                bit = (x >> b) & 1
                if not child[bit][node]:
                    child[0].append(0)
                    child[1].append(0)
                    child[bit][node] = len(child[0]) - 1
                node = child[bit][node]
        best = 0
        for x in nums:
            node, cur = 0, 0
            for b in range(bits - 1, -1, -1):
                bit = (x >> b) & 1
                want = bit ^ 1                          # opposite bit makes this XOR bit a 1
                if child[want][node]:
                    cur |= 1 << b
                    node = child[want][node]
                else:
                    node = child[bit][node]
            best = max(best, cur)
        return best
HardLeetCode #1707
Solution

Sort the queries by their limit, smallest first, and sort nums. Walk a pointer through nums and insert every number that is at most the current limit, so the trie holds only allowed numbers. This is the offline trick: no query ever sees a number it is not allowed to use.
Then answer the query exactly like Maximum XOR of Two Numbers: from the top bit down, prefer the child with the opposite bit.
If no number is allowed yet (the pointer is still at 0), the answer is -1.
The trie lives in two flat int arrays instead of nested dicts, which keeps memory small on 100,000 numbers.

Complexity: O((n + q) * 30 + n log n + q log q) time, O(n * 30) space in compact int arrays

from array import array

class Solution:
    def maximizeXor(self, nums: List[int], queries: List[List[int]]) -> List[int]:
        nums.sort()
        order = sorted(range(len(queries)), key=lambda i: queries[i][1])   # smallest limit first
        child = (array('i', [0]), array('i', [0]))      # child[bit][node] = id of that child, 0 = none
        answer, p = [-1] * len(queries), 0
        for qi in order:
            x, limit = queries[qi]
            while p < len(nums) and nums[p] <= limit:   # trie holds only numbers <= limit
                node = 0
                for b in range(29, -1, -1):
                    bit = (nums[p] >> b) & 1
                    if not child[bit][node]:
                        child[0].append(0)
                        child[1].append(0)
                        child[bit][node] = len(child[0]) - 1
                    node = child[bit][node]
                p += 1
            if p == 0:
                continue                                # nothing allowed yet: answer stays -1
            node, cur = 0, 0
            for b in range(29, -1, -1):
                bit = (x >> b) & 1
                want = bit ^ 1                          # opposite bit makes this XOR bit a 1
                if child[want][node]:
                    cur |= 1 << b
                    node = child[want][node]
                else:
                    node = child[bit][node]
            answer[qi] = cur
        return answer
15. Bit Manipulation

Work directly on the binary digits. Fast, O(1) space, and the source of many neat tricks.

AND / OR / XOR

Use this when the question compares or combines the binary digits of numbers: AND keeps shared 1s, OR keeps any 1, XOR keeps the bits that differ.

EasyLeetCode #693
Solution

In an alternating number such as 10101, every bit differs from the bit above it. So n ^ (n >> 1) turns it into all 1s (11111). Any two equal neighbours leave a 0 below the top bit instead. A run of 1s plus one is a power of two, so x & (x + 1) == 0 tests for all 1s.

Complexity: O(1) time, O(1) extra space

class Solution:
    def hasAlternatingBits(self, n: int) -> bool:
        x = n ^ (n >> 1)           # XOR with itself shifted: alternating bits become all 1s
        return x & (x + 1) == 0    # AND with x + 1 is 0 only when x is 1...1
MediumLeetCode #201
Solution

A bit in the AND of a range is 0 as soon as it changes anywhere in the range. The bits that change are the ones below the point where left and right first differ. Shift both numbers right until they are equal; what is left is the shared prefix. Shifting it back with left << shift puts the zeros back in the low bits.

Complexity: O(log right) time, O(1) extra space

class Solution:
    def rangeBitwiseAnd(self, left: int, right: int) -> int:
        shift = 0
        while left < right:      # differing low bits would AND to 0, so drop them
            left >>= 1
            right >>= 1
            shift += 1
        return left << shift     # shared prefix, low bits restored as zeros
MediumLeetCode #371
Solution

XOR adds two numbers bit by bit while ignoring carries. AND shows where both bits are 1, and shifting that left by one puts each carry in its column. Repeat until no carry is left. Python ints have no fixed width, so & mask keeps 32 bits, and the last line turns a pattern with the sign bit set back into a negative number.

Complexity: O(1) time (at most 32 rounds), O(1) extra space

class Solution:
    def getSum(self, a: int, b: int) -> int:
        mask = 0xFFFFFFFF                # Python never overflows, so cut to 32 bits ourselves
        while b:
            carry = (a & b) << 1         # AND finds columns where both bits are 1; carry moves left
            a = (a ^ b) & mask           # XOR is the sum without carries
            b = carry & mask
        return a if a <= 0x7FFFFFFF else ~(a ^ mask)   # sign bit set: rebuild the negative number

Odd/Even

Use this when the next step depends on whether a number is odd or even: x & 1 tests it and x >> 1 halves it.

EasyLeetCode #1342
Solution

The last bit tells odd from even: num & 1. An odd number loses 1, which clears that last bit. An even number is halved with num >>= 1. Each pass is one step, so the loop runs at most about twice the number of bits.

Complexity: O(log num) time, O(1) extra space

class Solution:
    def numberOfSteps(self, num: int) -> int:
        steps = 0
        while num:
            if num & 1:        # odd: last bit is 1, subtract 1
                num -= 1
            else:              # even: shifting right halves it
                num >>= 1
            steps += 1
        return steps
MediumLeetCode #50
Solution

Write the exponent in binary. Every 1 bit means one power of x (x, x squared, x to the 4th, ...) goes into the answer, and n & 1 tests the lowest bit. Squaring x and halving n with n >>= 1 moves to the next bit. This takes about log n multiplications instead of n. A negative exponent becomes 1 / x with a positive exponent.

Complexity: O(log n) time, O(1) extra space

class Solution:
    def myPow(self, x: float, n: int) -> float:
        if n < 0:
            x, n = 1 / x, -n
        result = 1.0
        while n:
            if n & 1:        # odd exponent: this bit contributes the current x
                result *= x
            x *= x           # next bit stands for x squared
            n >>= 1          # halve the exponent
        return result
MediumLeetCode #397
Solution

An even number is always halved. For an odd number, look at its last two bits with n & 3. If they are 01, subtract 1 so a 0 is left behind. If they are 11, add 1: the carry clears the whole run of trailing 1s at once. The number 3 is the exception, because 3 -> 2 -> 1 is shorter than 3 -> 4 -> 2 -> 1.

Complexity: O(log n) time, O(1) extra space

class Solution:
    def integerReplacement(self, n: int) -> int:
        steps = 0
        while n > 1:
            if n & 1 == 0:                  # even: halve
                n >>= 1
            elif n == 3 or n & 3 == 1:      # odd ending in 01: subtract 1
                n -= 1
            else:                           # odd ending in 11: add 1 to carry through the 1s
                n += 1
            steps += 1
        return steps

Set/Unset Bit

Use this when you must read, set, clear or flip single bits: build a mask with 1 << i, then use & to read, | to set, & ~ to clear and ^ to flip.

EasyLeetCode #476
Solution

A mask of 1s exactly as wide as the number is (1 << num.bit_length()) - 1. XOR with that mask flips every bit of num and leaves the bits above it alone. For 5 (101) the mask is 111, and 101 ^ 111 = 010, which is 2.

Complexity: O(1) time, O(1) extra space

class Solution:
    def findComplement(self, num: int) -> int:
        mask = (1 << num.bit_length()) - 1   # 1s over every bit of num, e.g. 101 -> 111
        return num ^ mask                    # XOR toggles each of those bits
EasyLeetCode #190
Solution

Read bit i of n with (n >> i) & 1. Set the mirrored position 31 - i in the result with |=. After 32 rounds the bit order is reversed. Bits that are 0 need no work, because result starts at 0.

Complexity: O(1) time (32 rounds), O(1) extra space

class Solution:
    def reverseBits(self, n: int) -> int:
        result = 0
        for i in range(32):
            bit = (n >> i) & 1            # read bit i
            result |= bit << (31 - i)     # set the mirrored bit in the result
        return result
MediumLeetCode #1318
Solution

Handle each bit position on its own. Read bit i of a, b and c with (x >> i) & 1. If c has a 1, at least one of a and b must have a 1, which costs one flip when both are 0. If c has a 0, both a and b must be 0, so every 1 among them costs one flip. Add up the flips for all 31 positions.

Complexity: O(1) time (31 positions), O(1) extra space

class Solution:
    def minFlips(self, a: int, b: int, c: int) -> int:
        flips = 0
        for i in range(31):
            bit_a = (a >> i) & 1          # read bit i of each number
            bit_b = (b >> i) & 1
            bit_c = (c >> i) & 1
            if bit_c == 1:
                if bit_a == 0 and bit_b == 0:
                    flips += 1            # set one of them
            else:
                flips += bit_a + bit_b    # clear every 1
        return flips
HardLeetCode #1611
Solution

The two moves can be undone, so going from n to 0 costs the same as going from 0 to n. The shortest route from 0 to the single top bit 100...0 (value 2^k) takes 2^(k+1) - 1 moves and passes through every number with that many bits, including n. The part of the route after n is the same problem for n with its top bit cleared, which is n ^ top. So the answer is 2 * top - 1 minus the answer for n ^ top.

Complexity: O(log n) time, O(log n) stack space

class Solution:
    def minimumOneBitOperations(self, n: int) -> int:
        if n == 0:
            return 0
        top = 1 << (n.bit_length() - 1)     # highest set bit
        # 0 -> top takes 2*top - 1 moves and passes through n; the rest is the cost of n without its top bit
        return 2 * top - 1 - self.minimumOneBitOperations(n ^ top)    # n ^ top clears the top bit

Count Set Bits

Use this when you must count the 1 bits of a number, or how many bits differ between numbers: n & (n - 1) removes the lowest set bit.

EasyLeetCode #191
Solution

n & (n - 1) clears the lowest set bit of n. Repeat until n is 0, and the loop runs once per 1 bit. The number of loops is the answer. This is Brian Kernighan's method, and it skips the 0 bits entirely.

Complexity: O(k) time where k is the number of set bits, O(1) extra space

class Solution:
    def hammingWeight(self, n: int) -> int:
        count = 0
        while n:
            n &= n - 1       # drop the lowest set bit
            count += 1       # one loop per set bit
        return count
EasyLeetCode #338
Solution

Dropping the last bit of i gives i >> 1, a smaller number whose count is already stored. The count for i is that count plus the dropped bit (i & 1). Fill the array from small to large, so each answer is one lookup and one addition.

Complexity: O(n) time, O(n) space for the answer

class Solution:
    def countBits(self, n: int) -> List[int]:
        dp = [0] * (n + 1)
        for i in range(1, n + 1):
            dp[i] = dp[i >> 1] + (i & 1)    # bits of i = bits of i // 2, plus its last bit
        return dp
MediumLeetCode #477
Solution

Hamming distance counts the bits that differ, so count them per bit position instead of per pair. At bit i, if ones numbers have a 1 and n - ones have a 0, then ones * (n - ones) pairs differ at that position. Summing over the 30 bit positions avoids checking every pair.

Complexity: O(30 * n) time, O(1) extra space

class Solution:
    def totalHammingDistance(self, nums: List[int]) -> int:
        n, total = len(nums), 0
        for i in range(30):                            # values are below 2^30
            ones = sum((x >> i) & 1 for x in nums)     # how many numbers have bit i set
            total += ones * (n - ones)                 # each (1, 0) pair differs at bit i
        return total

Power of 2

Use this when you must test for a power of 2 (or 4), or hit sums that are powers of 2: a power of 2 has exactly one set bit, so n & (n - 1) == 0.

EasyLeetCode #231
Solution

A power of two has exactly one set bit. n & (n - 1) removes that bit and leaves 0. The check n > 0 rules out 0 and negative numbers, which would otherwise pass or confuse the test.

Complexity: O(1) time, O(1) extra space

class Solution:
    def isPowerOfTwo(self, n: int) -> bool:
        return n > 0 and n & (n - 1) == 0    # one set bit: clearing it leaves 0
EasyLeetCode #342
Solution

A power of four is a power of two whose single bit sits at an even position (0, 2, 4, ...). First check for one set bit with n & (n - 1) == 0. Then AND with 0x55555555, which has a 1 at every even position, and require a non-zero result.

Complexity: O(1) time, O(1) extra space

class Solution:
    def isPowerOfFour(self, n: int) -> bool:
        # one set bit (power of 2) that also lies on an even position (0x5555... mask)
        return n > 0 and n & (n - 1) == 0 and n & 0x55555555 != 0
MediumLeetCode #1711
Solution

A sum of two items is at most 2^21, so only 22 powers of two matter: 1 << k for k from 0 to 21. For each item x and each power, look up how many earlier items equal (1 << k) - x in a Counter. Counting only earlier items counts each pair once. The result is taken modulo 10^9 + 7 as the problem asks.

Complexity: O(22 * n) time, O(n) space

class Solution:
    def countPairs(self, deliciousness: List[int]) -> int:
        seen = Counter()
        pairs = 0
        for x in deliciousness:
            for k in range(22):                  # powers of two up to 2^21
                pairs += seen[(1 << k) - x]      # earlier items that complete x to a power of 2
            seen[x] += 1
        return pairs % (10 ** 9 + 7)

XOR Patterns

Use this when pairs or repeats should cancel out (a single number, a missing number, parity): XOR everything, because x ^ x = 0 and x ^ 0 = x.

EasyLeetCode #136
Solution

XOR does not depend on order, and x ^ x = 0, so every pair cancels itself. After result ^= x runs over the whole array, only the number that appears once is left. No set or hash map is needed.

Complexity: O(n) time, O(1) extra space

class Solution:
    def singleNumber(self, nums: List[int]) -> int:
        result = 0
        for x in nums:
            result ^= x      # equal pairs cancel to 0, and x ^ 0 = x
        return result
EasyLeetCode #268
Solution

Start result at n = len(nums), then XOR in every index i and every value x. Each number from 0 to n shows up once as an index (n as the start value) and once as a value, except the missing one, which shows up only as an index. All matching pairs cancel and the missing number is left.

Complexity: O(n) time, O(1) extra space

class Solution:
    def missingNumber(self, nums: List[int]) -> int:
        result = len(nums)            # n has no index of its own
        for i, x in enumerate(nums):
            result ^= i ^ x           # index and value cancel unless one is missing
        return result
MediumLeetCode #260
Solution

XOR of everything gives a ^ b, where a and b are the two numbers that appear once. They differ at every set bit of a ^ b, so xor_all & -xor_all picks one such bit. Splitting the numbers by that bit puts a and b in different groups, and each pair stays together in one group. XOR-ing one group gives a, and xor_all ^ a gives b.

Complexity: O(n) time, O(1) extra space

class Solution:
    def singleNumber(self, nums: List[int]) -> List[int]:
        xor_all = 0
        for x in nums:
            xor_all ^= x                  # pairs cancel, leaving a ^ b
        low_bit = xor_all & -xor_all      # lowest set bit: a and b differ here
        a = 0
        for x in nums:
            if x & low_bit:               # one group: numbers that have this bit
                a ^= x                    # pairs cancel inside the group, leaving a
        return [a, xor_all ^ a]
MediumLeetCode #1371
Solution

A vowel count is even exactly when its parity bit is 0. Keep a 5-bit mask and flip a vowel's bit with mask ^= bit[ch] each time it appears. Two prefixes with the same mask have an even number of every vowel between them. Store the first index of each mask; the best answer is the largest i - first_seen[mask].

Complexity: O(n) time, O(1) extra space (at most 32 masks)

class Solution:
    def findTheLongestSubstring(self, s: str) -> int:
        bit = {'a': 1, 'e': 2, 'i': 4, 'o': 8, 'u': 16}
        first_seen = {0: -1}               # mask -> earliest index; the empty prefix has mask 0
        mask, best = 0, 0
        for i, ch in enumerate(s):
            if ch in bit:
                mask ^= bit[ch]            # flip the parity of this vowel
            if mask in first_seen:
                best = max(best, i - first_seen[mask])   # same mask: every vowel between is even
            else:
                first_seen[mask] = i
        return best

Bit Masking

Use this when a small set (letters, skills, flags) can be packed into one integer with one bit per member, so union, overlap and subset tests become a single | or &.

EasyLeetCode #1832
Solution

Give each letter its own bit and set it with seen |= 1 << (ord(ch) - ord('a')). Repeated letters do no harm, because setting a bit twice changes nothing. The sentence is a pangram when all 26 low bits are set, which is the value (1 << 26) - 1.

Complexity: O(n) time, O(1) extra space

class Solution:
    def checkIfPangram(self, sentence: str) -> bool:
        seen = 0
        for ch in sentence:
            seen |= 1 << (ord(ch) - ord('a'))    # set this letter's bit
        return seen == (1 << 26) - 1             # all 26 bits set
MediumLeetCode #318
Solution

Turn each word into a 26-bit mask of its letters. Two words share no letter exactly when the AND of their masks is 0, which replaces a letter-by-letter comparison with one operation. Try every pair and keep the best product of lengths.

Complexity: O(L + n^2) time where L is the total number of letters, O(n) space

class Solution:
    def maxProduct(self, words: List[str]) -> int:
        masks = []
        for word in words:
            mask = 0
            for ch in word:
                mask |= 1 << (ord(ch) - ord('a'))    # the word's letters as one mask
            masks.append(mask)
        best = 0
        for i in range(len(words)):
            for j in range(i + 1, len(words)):
                if masks[i] & masks[j] == 0:         # no common letter
                    best = max(best, len(words[i]) * len(words[j]))
        return best
HardLeetCode #1125
Solution

Give each required skill a bit, and turn each person into a mask of their skills. The dictionary best maps a skill mask to the smallest team found for it. For each person, OR their mask into every existing entry and keep the shorter team for the merged mask. The answer is the entry whose mask has every skill bit set.

Complexity: O(people * 2^skills) time, O(2^skills) space

class Solution:
    def smallestSufficientTeam(self, req_skills: List[str], people: List[List[str]]) -> List[int]:
        index = {skill: i for i, skill in enumerate(req_skills)}
        full = (1 << len(req_skills)) - 1
        best = {0: []}                             # skill mask -> smallest team with exactly these skills
        for i, skills in enumerate(people):
            person = 0
            for skill in skills:
                person |= 1 << index[skill]        # the person's skills as a mask
            for mask, team in list(best.items()):
                merged = mask | person             # OR joins the two skill sets
                if merged not in best or len(best[merged]) > len(team) + 1:
                    best[merged] = team + [i]
        return best[full]

Subsets using Bits

Use this when n is small (about 20 or less) and you must visit every subset: count a mask from 0 to 2^n - 1, where bit i says whether item i is in.

EasyLeetCode #1863
Solution

Each mask from 0 to 2^n - 1 is one subset: bit i set means nums[i] is in it. For every mask, XOR the chosen items together and add the result to the total. There are 2^n masks and n bits to test in each, so no recursion is needed.

Complexity: O(n * 2^n) time, O(1) extra space

class Solution:
    def subsetXORSum(self, nums: List[int]) -> int:
        n, total = len(nums), 0
        for mask in range(1 << n):               # every mask is one subset
            xor = 0
            for i in range(n):
                if (mask >> i) & 1:              # bit i set: nums[i] is in this subset
                    xor ^= nums[i]
            total += xor
        return total
MediumLeetCode #78
Solution

The same loop builds the subsets themselves. For each mask from 0 to 2^n - 1, collect nums[i] for every bit i that is set. Mask 0 gives the empty subset and the all-ones mask gives the full set. All 2^n subsets come out with no recursion.

Complexity: O(n * 2^n) time, O(n * 2^n) space for the output

class Solution:
    def subsets(self, nums: List[int]) -> List[List[int]]:
        n, result = len(nums), []
        for mask in range(1 << n):                                         # 0 .. 2^n - 1
            result.append([nums[i] for i in range(n) if (mask >> i) & 1])  # bit i set: take nums[i]
        return result
HardLeetCode #1723
Solution

A mask is a set of jobs. work[mask] is the total time of those jobs, and best[mask] is the smallest possible largest workload using the workers added so far. A new worker takes some submask of mask; submasks are visited with sub = (sub - 1) & mask. The cost of that split is the larger of the new worker's load and the best result for the remaining jobs. Visiting every submask of every mask costs 3^n per worker.

Complexity: O(k * 3^n) time, O(2^n) space

class Solution:
    def minimumTimeRequired(self, jobs: List[int], k: int) -> int:
        n = len(jobs)
        work = [0] * (1 << n)                          # work[mask] = total time of the jobs in mask
        for mask in range(1, 1 << n):
            low = (mask & -mask).bit_length() - 1      # index of the lowest set bit
            work[mask] = work[mask & (mask - 1)] + jobs[low]
        best = work[:]                                 # one worker does every job in mask
        for _ in range(min(k, n) - 1):                 # add one more worker per round
            nxt = best[:]
            for mask in range(1, 1 << n):
                sub = mask
                while sub:                             # every non-empty submask of mask
                    cost = max(best[mask ^ sub], work[sub])    # new worker takes sub
                    if cost < nxt[mask]:
                        nxt[mask] = cost
                    sub = (sub - 1) & mask             # next smaller submask
            best = nxt
        return best[(1 << n) - 1]