Jc-alt logo
jc
LeetCode: Heaps Priority Queue

LeetCode: Heaps Priority Queue

··
125 min read
·data structures and algorithms

Heaps intro

LeetCode problems with heap solutions.

What is a Heap

Heaps are specialized complete binary tree structures used to prioritize data.

A heap maintains a partial order property:

  1. MinHeap: Parent nodes are less than or equal to their children (smallest element at root)
  2. MaxHeap: Parent nodes are greater than or equal to their children (largest element at root)

The tree is complete: all levels are fully filled except possible the last, which is filled left to right

Peek(): efficient access to min/max element in O(1) time Insert()/Pop(): Heap edit in O(log n) time

Heap Characteristics

Heaps are characterized by:

  1. Nodes: Contain values arranged by heap property
  2. Complete Tree: Shape property ensures tree is always balanced
  3. Heap Property: Parent value ≤ (minHeap) or Parent value ≥ children values
  4. Nothing beyond heap property: No ordering between siblings or subtrees maintained, other than parent child from heap property
  5. Array: Implemented as array for space and cache efficiency

Heap Representation

Tree heap represented as an array:

    Array Form
    Index:  0   1   2   3   4   5   6
    Value: [3,  5,  8,  9, 10, 12, 15]

    Tree Form

            (0)3
            /     \
        (1)5       (2)8
        /   \      /   \
    (3)9  (4)10 (5)12 (6)15

Heap IRL

Priority Queues: scheduling, bandwidth management Graph algorithms: Dijkstra's shortest path, Prim's MST Event simulation: process events by priority/time

Heap Application: Top K Element Extraction

We can maintain a heap to quickly retrieve the top K largest or smallest elements without fully sorting the input.

Ex: Find the kth largest element in array

    def kthLargest(nums, k):
        heap = []
        for num in nums:
            heapq.heappush(heap, num)
            if len(heap) > k:
                heapq.heappop(heap)  # Keep only top K elements
        return heap[0]

    # Example: kthLargest([3, 2, 1, 5, 6, 4], 2) -> 5

Heap Application: Dual Heap Balancing Representing Secondary Property

We can use two heaps, maxHeap for lower half of values and a minHeap for the upper half, to maintain a property while adding and removing elements. Pattern supports quick lookups of medians, balance points, or range constraints.

Ex: Maintain median of stream.

class MedianFinder:
    def __init__(self):
        self.low = []   # Max-heap (invert values)
        self.high = []  # Min-heap
    
    def addNum(self, num: int) -> None:
        heapq.heappush(self.low, -num)
        heapq.heappush(self.high, -heapq.heappop(self.low))
        if len(self.low) < len(self.high):
            heapq.heappush(self.low, -heapq.heappop(self.high))
    
    def findMedian(self) -> float:
        if len(self.low) > len(self.high):
            return -self.low[0]
        return (-self.low[0] + self.high[0]) / 2        

Heap Application: K Way Merge for Sorted Streams

A minHeap can efficiently merge multiple sorted lists or streams by always extracting the next smallest element across all inputs.

Ex: Merge k sorted lists into one sorted lists

    def mergeKSorted(lists):
        heap = []
        for i, lst in enumerate(lists):
            if lst:
                heapq.heappush(heap, (lst[0], i, 0))
        
        result = []
        while heap:
            val, list_idx, elem_idx = heapq.heappop(heap)
            result.append(val)
            if elem_idx + 1 < len(lists[list_idx]):
                heapq.heappush(heap, (lists[list_idx][elem_idx+1], list_idx, elem_idx+1))
        return result

    # mergeKSorted([[1,4,5],[1,3,4],[2,6]]) -> [1,1,2,3,4,4,5,6]   

Heap Application: Best First Search or Breadth First Search with Priority

A heap can drive a search algorithm where you can expand the 'best' candidate first: A*, Dijkstra's, Prims, etc. In a non negative weighted graph context: a minHeap can be used to expand on the closest node first in a This always ensures that the next node popped from the heap has the smallest known distance from the source.

Ex: Dijkstra's Algorithm MinHeap

def dijkstra(graph, start):
    
    # Graph: Node -> list of (neighbor, weight) tuples
    # start: Starting node for shortest path search

    # Result: 
    # dist: shortest distance from start to all other nodes

    # Init all distances to infinity, 
    # except start node which has distance 0
    dist = {node: float('inf') for node in graph}
    dist[start] = 0

    # minHeap priority queue stores tuples of (distance, node)
    # iterate from start node and commence exploring neighbors
    heap = [(0, start)]

    # while there are nodes to process in heap
    while heap:
        
        # grab node with smallest known distance from start
        d, node = heapq.heappop(heap)

        # Skip if we already have already explored a shorter
        # path to the current node
        if d > dist[node]:
            continue

        # Check each neighbor and see if we have found 
        # a shorter path through the current node
        for nei, w in graph[node]:
            
            # grab new distance
            nd = d + w

            # if shorter path found, update and push to heap
            if nd < dist[nei]:
                dist[nei] = nd
                heapq.heappush(heap, (nd, nei))

    # overall: time complexity
    # overall: space complexity
    return dist

Heap Application: Kth element Within Iterating Sliding Window

A heap can track max/min within a sliding window efficiently when combined with lazy deletion or index tracking.

Ex: Find maximum in each sliding windows

    def max_sliding_window(nums, k):

        # maxHeap via minHeap with negative values
        # initialize with first k elements
        heap = [(-nums[i], i) for i in range(k)]
        heapq.heapify(heap)

        # root of heap is the largest in the current window
        res = [-heap[0][0]]
        
        # iterate sliding window forward
        for i in range(k, len(nums)):

            # push new element to heap
            heapq.heappush(heap, (-nums[i], i))

            # remove top element if they are outside current window
            while heap[0][1] <= i - k:
                heapq.heappop(heap)

            # after removing out of window elements, heap root is max
            # within the current window
            res.append(-heap[0][0])

        return res

Heap Application: Interval Scheduling Optimization

A minHeap can track the earliest finishing times among active intervals, enabling optimal scheduling of jobs or meetings without conflicts.

Ex: Find the minimum number of meeting rooms required

    def minMeetingRooms(intervals):

        # Sort intervals by their start time,
        # ensures we process meetings in chronological order
        intervals.sort(key=lambda x: x[0])

        # minHeap to track end time of ongoing meetings,
        # storing the earliest ending meeting at the top
        heap = []

        # iterate over rooms in earliest starting order
        for start, end in intervals:

            # if heap is not empty, and the earliest ending meeting the earliest meeting ends
            # ends before or exactly when the new meeting starts,
            # then we can remove the earliest ending meeting 
            # (essentially freeing up a room)
            if heap and heap[0] <= start:
                # pop meeting that ended the earliest
                heapq.heappop(heap)

            # 'start' the current meeting by pushing it to the heap
            heapq.heappush(heap, end)

        # last heap size is the number meetings running
        # concurrently after all meetings have started
        return len(heap)

    # Example: 
    # minMeetingRooms([[0,30],[5,10],[15,20]]) -> 2

215. Kth Largest Element in an Array ::2:: - Medium

Topics: Array, Math, Divide and Conquer, Geometry, Sorting, Heap (Priority Queue), Quickselect

Intro

Given an integer array nums and an integer k, return the kth largest element in the array. Note that it is the kth largest element in the sorted order, not the kth distinct element. Can you solve it without sorting?

Example InputOutput
nums = [3,2,1,5,6,4], k = 25
nums = [3,2,3,1,2,4,5,5,6], k = 44

Constraints:

1 ≤ k ≤ nums.length ≤ 105

-104 ≤ nums[i] ≤ 104

Abstraction

Given an array and a fixed size heap of size k, return the kth largest element in the array.

(keep only the k largest elements in a minHeap, so the root is the kth largest) (push while the heap has space, once full, replace the root only if the new value beats it)

(Monotonic: the root only ever increases or stays the same, since the heap only keeps values that beat the current kth largest, so smaller values can be ignored for good)

(Partition: after splitting around a pivot, the kth largest lives in exactly one of bigger, equal, or smaller, so recurse into that one side and discard the other two)

(Count what the heap holds) len(minHeap) = "how many of the largest elements have I kept so far? (at most k)"

(Read the answer from the root) minHeap[0] = "what is the smallest of my k largest, which is exactly the kth largest?"

(Count what bigger holds, for quick select) len(bigger) = "how many elements rank above the pivot? if k lte this, the answer is in bigger"

(Count what equal holds, for quick select) len(bigger) + len(equal) = "how many elements rank at or above the pivot? if k lte this, the answer is the pivot"

Pseudocode

  oh! pseudocode hasn't been written yet, try another card! :)

Solution 1: [Heap] Min Heap Of Size K Tracks The Kth Largest Element - Heap/Heap

    def findKthLargest(self, nums: list[int], k: int) -> int:

        # MinHeap (Fixed Size K)

        # Heap Representation:
        #   - Maintain a minHeap holding the k largest elements seen so far
        #   - Root (minHeap[0]) is the smallest of those k elements,
        #     which is exactly the kth largest element overall
        #   - Heap is valid when it holds at most k elements

        # Idea:
        #   - The kth largest element is the smallest of the top k elements
        #   - Keep only the top k elements in a minHeap, so the root is always the answer
        #   - If the heap has fewer than k elements, push the new num
        #   - If the heap is full, the new num only matters if it beats the root,
        #     since anything smaller can never be in the top k
        #   - Replace the root with the new num to evict the old kth largest
        #   - After every num has been seen, the root is the answer

        # MinHeap of the k largest elements seen so far
        minHeap = []

        # Array Pointer
        index = 0

        # tc: O(n log k)
        while index < len(nums):

            # Current num
            num = nums[index]

            # Heap still has space, push num
            # k = 2, minHeap = [3], num = 2 -> [2, 3]
            # tc: O(log k)
            if len(minHeap) < k:
                heapq.heappush(minHeap, num)

            # Heap is full, only keep num if it beats the current kth largest
            else:

                # Replace root if num is larger:
                # k = 2, minHeap = [2, 3]
                #   num = 1 -> 1 < 2, ignored, heap stays [2, 3]
                #   num = 5 -> 5 > 2, replace root -> [3, 5]
                # tc: O(log k)
                if num > minHeap[0]:
                    heapq.heapreplace(minHeap, num)

            # Iterate array
            index += 1

        # Kth largest element (root)
        kthLargest = minHeap[0]

        # overall: tc O(n log k)
        # overall: sc O(k)
        return kthLargest

Solution 2: [Quick Select] Three Way Partition That Ignores Duplicates (Recurse Into One Side) - Heap/Heap

    def findKthLargest(self, nums: list[int], k: int) -> int:

        # Quick Select (Three Way Partition)

        # Search Representation:
        #   - nums is the current search space
        #   - k is the rank (1 = largest) of the target inside the current nums
        #   - Search space is valid while it still contains the kth largest

        # Idea:
        #   - Pick a random pivot and split nums into bigger, equal, and smaller
        #   - If k <= len(bigger), the kth largest is inside bigger, recurse there
        #   - If k <= len(bigger) + len(equal), the kth largest is the pivot itself,
        #     so every duplicate of the pivot is resolved in one step
        #   - Otherwise the kth largest is inside smaller, recurse there with
        #     k reduced by the number of elements ranked above it
        #   - Only one side is ever searched, so the work shrinks every call

        # Why Three Way (Duplicates):
        #   - A two way partition (<= pivot vs > pivot) leaves every copy of the pivot
        #     on one side, so with many duplicates the pivot barely shrinks the search space
        #   - Random pivots don't help, since every pivot has the same value
        #   - Each call then removes almost nothing, so time degrades to O(n^2) (TLE)
        #     and recursion depth can hit the limit
        #   - The equal bucket is never recursed into: if k lands inside it, the answer
        #     is the pivot, so all copies of that value cost one pass and then disappear
        #   - With no duplicates it costs nothing extra, still O(n) per pass

        # Helper that searches one partition for the kth largest
        def quickSelect(nums: list[int], k: int) -> int:

            # Random pivot
            pivot = random.choice(nums)

            # Partitions
            bigger = []
            equal = []
            smaller = []

            # Array Pointer
            index = 0

            # Partition nums around pivot
            # tc: O(n)
            while index < len(nums):

                # Current num
                num = nums[index]

                # Sort num into its partition
                if num > pivot:
                    bigger.append(num)
                elif num == pivot:
                    equal.append(num)
                else:
                    smaller.append(num)

                # Iterate array
                index += 1

            # Kth largest is inside bigger, recurse
            # nums = [3, 2, 1, 5, 6, 4], k = 2, pivot = 4
            # bigger = [5, 6], k <= 2 -> search [5, 6] for 2nd largest
            if k <= len(bigger):
                return quickSelect(bigger, k)

            # Kth largest is the pivot (duplicates resolved in one step):
            # bigger = [6], equal = [5], k = 2
            # k <= 1 + 1 -> the 2nd largest is 5
            if k <= len(bigger) + len(equal):
                return pivot

            # Kth largest is inside smaller, recurse with k adjusted:
            # nums = [3, 2, 1, 5, 6, 4], k = 5, pivot = 4
            # bigger = [5, 6], equal = [4], smaller = [3, 2, 1]
            # 5 > 2 + 1 -> search [3, 2, 1] for k = 5 - 2 - 1 = 2nd largest
            newK = k - len(bigger) - len(equal)
            return quickSelect(smaller, newK)

        # Kth largest element
        kthLargest = quickSelect(nums, k)

        # overall: tc O(n) average (n + n/2 + n/4 + ...), O(n^2) worst case with bad pivots
        # overall: sc O(n) for the partition lists, plus O(log n) expected recursion depth
        return kthLargest

703. Kth Largest Element in a Stream ::1:: - Easy

Topics: Tree, Design, Binary Search Tree, Heap (Priority Queue), Binary Tree, Data Stream

Intro

You are part of a university admissions office and need to keep
track of the kth highest test score from applicants in real-time. This helps to determine cut-off marks for interviews and admissions dynamically as new applicants submit their scores. You are tasked to implement a class which, for a given integer k, maintains a stream of test scores and continuously returns the kth highest test score after a new score has been submitted. More specifically, we are looking for the kth highest score in the sorted list of all scores. Implement the KthLargest class: KthLargest(int k, int[] nums) Initializes the object with the integer k and the stream of test scores nums. int add(int val) Adds a new test score val to the stream and returns the element representing the kth largest element in the pool of test scores so far.

Example InputOutput
["KthLargest", "add", "add", "add", "add", "add"] [[3, [4, 5, 8, 2]], [3], [5], [10], [9], [4]][null, 4, 5, 5, 8, 8]
["KthLargest", "add", "add", "add", "add"] [[4, [7, 7, 7, 7, 8, 3]], [2], [10], [9], [9]][null, 7, 7, 7, 8]

Constraints:

0 ≤ nums.length ≤ 104

1 ≤ k ≤ nums.length + 1

-104 ≤ nums[i] ≤ 104

-104 ≤ val ≤ 104

At most 104 calls will be make to add.

Abstraction

Given a stream of numbers, use a fixed size heap of size k, return the kth largest element seen so far after each new number is added.

(MinHeap)

(keep only the k largest elements in a minHeap, so the root is the kth largest) (push while the heap has space, once full, replace the root only if the new value beats it)

(Monotonic: the root only ever increases or stays the same, since the heap only keeps values that beat the current kth largest, so smaller values can be ignored for good)

(Count what the heap holds) len(minHeap) = "how many of the largest elements have I kept so far? (at most k)"

(Read the answer from the root) minHeap[0] = "what is the smallest of my k largest, which is exactly the kth largest?"

Pseudocode

  oh! pseudocode hasn't been written yet, try another card! :)

Solution 1: [Heap] Min Heap Of Size K Tracks The Kth Largest Element - Heap/Heap

class KthLargest:

    # MinHeap (Fixed Size K)

    # Heap Representation:
    #   - Maintain a minHeap holding the k largest elements seen so far
    #   - Root (minHeap[0]) is the smallest of those k elements,
    #     which is exactly the kth largest element overall
    #   - Heap is valid when it holds at most k elements

    # Idea:
    #   - The kth largest element is the smallest of the top k elements
    #   - Keep only the top k elements in a minHeap, so the root is always the answer
    #   - If the heap has fewer than k elements, push the new value
    #   - If the heap is full, the new value only matters if it beats the root,
    #     since anything smaller can never be in the top k
    #   - Replace the root with the new value to evict the old kth largest
    #   - Return the root after every add

    # KthLargest():
    #   - Initializes the minHeap
    #   - Streams nums into the heap one at a time through add

    # add():
    #   - Adds a number to the minHeap if it belongs in the top k
    #   - Returns the kth largest element so far

    def __init__(self, k: int, nums: list[int]):

        # Heap Size Limit
        self.k = k

        # MinHeap of the k largest elements seen so far
        self.minHeap = []

        # Stream Pointer
        index = 0

        # Mimic 'streaming' input data into minHeap
        # tc: O(n log k) - n calls to add, each O(log k)
        while index < len(nums):

            # Add num to heap
            self.add(nums[index])

            # Iterate stream
            index += 1

        # overall: tc O(n log k)
        # overall: sc O(k)

    def add(self, val: int) -> int:

        # Heap still has space, push new value
        # k = 3, minHeap = [4, 5], add(8) -> [4, 5, 8]
        # tc: O(log k)
        if len(self.minHeap) < self.k:
            heapq.heappush(self.minHeap, val)

        # Heap is full, only keep val if it beats the current kth largest
        else:

            # Current kth largest element
            kthLargest = self.minHeap[0]

            # Replace root if val is larger:
            # k = 3, minHeap = [4, 5, 8]
            #   add(3)  -> 3 < 4, ignored, heap stays [4, 5, 8]
            #   add(10) -> 10 > 4, replace root -> [5, 8, 10]
            # tc: O(log k)
            if val > kthLargest:
                heapq.heapreplace(self.minHeap, val)

        # Return kth largest element (root), tc: O(1)

        # overall: tc O(log k)
        # overall: sc O(1) extra, O(k) for the heap itself
        return self.minHeap[0]

2357. Make Array Zero by Subtracting Equal Amounts ::3:: - Easy

Topics: Array, Hash Table, Greedy, Sorting, Heap (Priority Queue), Simulation

Intro

You are given a non-negative integer array nums. In one operation, you must: Choose a positive integer x such that x is less than or equal to the smallest non-zero element in nums. Subtract x from every positive element in nums. Return the minimum number of operations to make every element in nums equal to 0.

Example InputOutput
nums = [1,5,0,3,5]3
nums = [0]0

Constraints:

1 ≤ nums.length ≤ 100

0 ≤ nums[i] ≤ 100

Abstraction

Given an array of non-negative integers, use a heap to of size n, to track the current min number in the array, to calculate the number of operations needed to decrease all numbers to 0.

(MinHeap)

(keep only the n largest elements in a minHeap, so the root is the smallest element) (keep popping the root and track the running total of all the numbers popped, until the heap is empty) (This is the min number of operations needed to decrease all numbers to 0)

Pseudocode

  oh! pseudocode hasn't been written yet, try another card! :)

Solution 1: [Sorting] Lazy Subtraction Simulation Over Sorted Values - Heap/Heap

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

        # Lazy Subtraction:
        # Sort so values are processed in increasing order, then simulate
        # each operation's effect without touching every array slot on
        # every operation. Instead, track a running "alreadySubtracted"
        # total; each element's TRUE current value is (original value -
        # alreadySubtracted), which is exactly what repeatedly
        # subtracting x from every positive element produces in aggregate.

        # nums_sorted = [0, 1, 3, 5, 5]
        # alreadySubtracted starts at 0, grows by each new tier's value
        # as we sweep left to right

        # sc: O(n), space used by sorted() to produce nums_sorted
        numsSorted = sorted(nums)

        n = len(numsSorted)

        # Running count of total amount subtracted by completed operations
        alreadySubtracted = 0

        operationsCount = 0

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

            # Note:
            # TRUE current value after all prior operations have been
            # applied -- the real result of every earlier subtraction,
            # computed lazily instead of mutating the whole array.
            currentValue = numsSorted[i] - alreadySubtracted

            # skip: already at zero (started at 0, or zeroed by a
            # previous operation)
            if currentValue <= 0:
                continue

            # New operation:
            # new distinct positive value not yet zeroed. Perform one
            # operation: x = currentValue (guaranteed smallest
            # remaining nonzero value, since array is sorted).
            # tc: O(1) per operation
            alreadySubtracted += currentValue
            operationsCount += 1

        # overall: tc O(n log n)
        # overall: sc O(n)
        return operationsCount

Solution 2: [Heap] Pop Smallest Remaining Value And Add To Running Subtraction Count - Heap/Heap

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

        # MinHeap:
        # A min-heap always pops the current smallest remaining value in
        # O(log n), without needing a full sort upfront. Same lazy
        # subtraction idea as the sorting solution -- a running
        # "alreadySubtracted" total is applied only when a value is
        # popped, rather than mutating every array slot on every
        # operation. This simulates the same tier-by-tier subtraction
        # process, just driven by heap pops instead of a sorted pass.

        # heap = [0, 1, 3, 5, 5] (heap-ordered, not necessarily sorted)
        # each pop returns the current global minimum remaining value

        # heap holds all elements initially
        # sc: O(n)
        heap = list(nums)

        # heapify in linear time
        # tc: O(n)
        heapq.heapify(heap)

        # Running count of total amount subtracted by completed operations
        alreadySubtracted = 0

        operationsCount = 0

        # tc: O(n) pops each O(log n) for total O(n log n)
        while heap:

            # Pop the current smallest remaining value
            # tc: O(log n) per pop
            smallest = heapq.heappop(heap)

            # Note:
            # TRUE current value after all prior operations have been
            # applied -- same lazy-subtraction idea as the sorting
            # solution, just computed at pop time instead of during a
            # linear pass over a sorted array.
            currentValue = smallest - alreadySubtracted

            # skip: already at zero (started at 0, or zeroed by a
            # previous operation)
            if currentValue <= 0:
                continue

            # New operation:
            # new distinct positive value not yet zeroed. Perform one
            # operation: x = currentValue (guaranteed smallest
            # remaining nonzero value, since heap always pops the min).
            alreadySubtracted += currentValue
            operationsCount += 1

        # overall: tc O(n log n)
        # overall: sc O(n)
        return operationsCount

Solution 3: [Greedy] Distinct Positive Values Determine Operation Count - Heap/Heap

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

        # Distinct Count:
        # Every operation subtracts the same x from ALL positive elements
        # at once. This means elements sharing the same value always hit
        # zero together, in the same single operation -- duplicates of a
        # value never require extra operations. Also, x is capped at the
        # current smallest nonzero value, so an operation can never skip
        # past a distinct value to clear two tiers at once. The answer
        # reduces to: how many DISTINCT positive values exist in nums.

        # nums = [1, 5, 0, 3, 5]
        # distinct positive values -> {1, 3, 5}
        # each distinct value needs exactly 1 dedicated operation,
        # duplicates (the two 5's) ride along for free

        # sc: O(n) worst case, one entry per distinct positive value
        distinct_positive = set()

        # tc: O(n), one pass over nums
        for num in nums:

            # skip zeros: already at the target, no operation needed
            if num > 0:
                distinct_positive.add(num)

        operationsCount = len(distinct_positive)

        # overall: tc O(n)
        # overall: sc O(n)
        return operationsCount

347. Top K Elements in List ::3:: - Medium

Topics: Array, Hash Table, Divide and Conquer, Sorting, Heap (Priority Queue), Bucket Sort, Counting, Quickselect

Intro

Given an integer array nums and an integer k, return the k most frequent element within the array. Test cases are generated such that the answer is always unique. You may return the output in any order

InputkOutput
[1,2,2,3,3,3,3]2[2,3]
[7,7]1[7]

Constraints:

1 ≤ k ≤ number of distinct elements in nums

-1000 ≤ nums[i] ≤ 1000

Abstraction

Given an array, count how often each element occurs, then return the k elements with the highest counts.

(count first with a frequency map, so the problem becomes "pick the k largest counts" among the unique elements) (rank by count, not by value: the heap, the partition, and the buckets all compare frequencies)

(MinHeap: keep only the k most frequent in a minHeap, so the root is the least frequent of the k and the first one to evict)

(Quick Select: partition unique elements by frequency with high on the left, once a pivot lands at index k - 1 the first k elements are the top k, so the rest never needs sorting)

(Bucket Sort: a frequency is at most n, so index buckets by count and walk from the highest bucket down, no comparisons needed)

(Count what the heap holds) len(minHeap) = "how many top elements have I kept so far? (at most k, pop the root when it hits k + 1)"

(Read the pivot position) resultIndex = "where did the pivot land? if it equals k - 1, then unique[:k] holds the top k"

(Read a bucket) freqBuckets[count] = "which elements occur exactly count times?"

Pseudocode

  oh! pseudocode hasn't been written yet, try another card! :)

Solution 1: [MinHeap] Min Heap Of Size K Tracks The K Most Frequent Elements - HashMap/Algorithm

    def topKFrequent(self, nums: list[int], k: int) -> list[int]:

        # MinHeap (Fixed Size K)

        # Heap Representation:
        #   - Maintain a minHeap of (frequency, num) pairs for the k most frequent elements so far
        #   - Root (minHeap[0]) is the least frequent of those k elements,
        #     which is the first one to evict
        #   - Heap is valid when it holds at most k pairs

        # Idea:
        #   - Count the frequency of each element
        #   - Push each unique (frequency, num) pair into the minHeap
        #   - If the heap grows to size k + 1, pop the root, since the least frequent
        #     of k + 1 elements can never be in the top k
        #   - A heap only guarantees the root is the smallest, not the order of the rest,
        #     which is fine because the answer can be returned in any order
        #   - After every unique element has been seen, the heap holds the k most frequent

        # Frequency of each element
        frequency = defaultdict(int)

        # Array Pointer
        index = 0

        # Count each num
        # tc: O(n)
        while index < len(nums):

            # Add num to count
            frequency[nums[index]] += 1

            # Iterate array
            index += 1

        # MinHeap of (frequency, num) pairs for the k most frequent elements
        minHeap = []

        # Unique (num, count) pairs
        uniqueItems = list(frequency.items())

        # Unique Pointer
        uniqueIndex = 0

        # tc: O(m log k), m unique elements, each push and pop is O(log k)
        while uniqueIndex < len(uniqueItems):

            # Current pair
            num, count = uniqueItems[uniqueIndex]

            # Push pair
            # k = 2, minHeap = [(1, 5), (2, 7)], pair = (3, 9) -> [(1, 5), (2, 7), (3, 9)]
            # tc: O(log k)
            heapq.heappush(minHeap, (count, num))

            # Heap exceeds k, pop the least frequent root
            # [(1, 5), (2, 7), (3, 9)] -> pop (1, 5) -> [(2, 7), (3, 9)]
            # tc: O(log k)
            if len(minHeap) > k:
                heapq.heappop(minHeap)

            # Iterate unique pairs
            uniqueIndex += 1

        # Top k elements
        result = []

        # Heap Pointer
        heapIndex = 0

        # Collect nums from the heap
        # tc: O(k)
        while heapIndex < len(minHeap):

            # Add num (second item of the pair)
            result.append(minHeap[heapIndex][1])

            # Iterate heap
            heapIndex += 1

        # overall: tc O(n + m log k), worst case m = n so O(n log k)
        # overall: sc O(n) for the frequency map, O(k) for the heap
        return result

Solution 2: [Quick Select] Partition Unique Elements By Frequency Until Index K Minus 1 Is Placed - HashMap/Algorithm

    def topKFrequent(self, nums: list[int], k: int) -> list[int]:

        # Quick Select (Partition By Frequency, High To Low)

        # Search Representation:
        #   - unique is the list of unique elements, partitioned in place
        #   - [left, right] is the current search section of unique
        #   - targetIndex = k - 1 is the final position we need to place correctly
        #   - Search section is valid while it still contains targetIndex

        # Idea:
        #   - Count the frequency of each element, then work only on the unique elements
        #   - Pick a random pivot and partition the section so more frequent elements
        #     sit on the left and less frequent elements sit on the right:
        #     [more frequent ... pivot ... less frequent]
        #   - If the pivot lands at targetIndex, everything to its left is more frequent,
        #     so unique[:k] is the top k
        #   - If targetIndex is left of the pivot, search the left section
        #   - If targetIndex is right of the pivot, search the right section
        #   - Only one side is ever searched, so we never sort the whole array
        #   - A random pivot avoids the O(n^2) worst case of a fixed pivot

        # Frequency of each element
        frequency = defaultdict(int)

        # Array Pointer
        index = 0

        # Count each num
        # tc: O(n)
        while index < len(nums):

            # Add num to count
            frequency[nums[index]] += 1

            # Iterate array
            index += 1

        # Unique elements to partition by frequency
        unique = list(frequency.keys())

        # Helper that partitions [left, right] around the pivot, returns the pivot's final index
        def partitionSection(left: int, right: int, pivotIndex: int) -> int:

            # Frequency of pivot element
            pivotFrequency = frequency[unique[pivotIndex]]

            # Move pivot to the end
            unique[pivotIndex], unique[right] = unique[right], unique[pivotIndex]

            # Next slot for a more frequent element
            partitionIndex = left

            # Scan Pointer
            scanIndex = left

            # Partition section around pivot
            # tc: O(m) for a section of m elements
            while scanIndex < right:

                # Move more frequent elements to the left:
                # unique = [4, 1, 2] with frequency 4 -> 1, 1 -> 3, 2 -> 2, pivot = 2 (at right)
                #   scan 4 -> frequency 1 < 2, stays
                #   scan 1 -> frequency 3 > 2, swap with partitionIndex 0 -> [1, 4, 2], partitionIndex = 1
                if frequency[unique[scanIndex]] > pivotFrequency:
                    unique[scanIndex], unique[partitionIndex] = unique[partitionIndex], unique[scanIndex]
                    partitionIndex += 1

                # Iterate section
                scanIndex += 1

            # Place pivot at its final index:
            # [1, 4, 2] -> swap partitionIndex 1 with right -> [1, 2, 4], pivot lands at 1
            unique[partitionIndex], unique[right] = unique[right], unique[partitionIndex]
            return partitionIndex

        # Helper that narrows [left, right] until targetIndex holds its final element
        def quickSelect(left: int, right: int, targetIndex: int) -> None:

            # One element left, it is already in place
            if left == right:
                return

            # Random pivot
            pivotIndex = random.randint(left, right)

            # Partition section
            resultIndex = partitionSection(left, right, pivotIndex)

            # Pivot landed on target, unique[:k] is the top k
            if targetIndex == resultIndex:
                return

            # Target is left of the pivot, search the left section
            # k = 2 -> targetIndex = 1, resultIndex = 3 -> search [left, 2]
            elif targetIndex < resultIndex:
                quickSelect(left, resultIndex - 1, targetIndex)

            # Target is right of the pivot, search the right section
            # k = 4 -> targetIndex = 3, resultIndex = 1 -> search [2, right]
            else:
                quickSelect(resultIndex + 1, right, targetIndex)

        # Final index of the kth most frequent element
        # k = 2 -> top 2 are indexes [0, 1], so targetIndex = 1
        targetIndex = k - 1

        # Place targetIndex, everything left of it is at least as frequent
        # tc: O(m) average, O(m^2) worst case with bad pivots
        quickSelect(0, len(unique) - 1, targetIndex)

        # overall: tc O(n) average, n for counting + m for quick select, O(n + m^2) worst case
        # overall: sc O(n) for the frequency map and unique list, plus O(log m) expected recursion depth
        return unique[:k]

Solution 3: [Bucket Sort] Bucket Elements By Frequency Then Walk From The Highest Bucket Down - HashMap/Algorithm

    def topKFrequent(self, nums: list[int], k: int) -> list[int]:

        # Bucket Sort (Index = Frequency)

        # Bucket Representation:
        #   - freqBuckets[count] holds every element that occurs exactly count times
        #   - Buckets run from index 0 to n, since an element can occur at most n times
        #   - Higher index = more frequent, so walking from the end visits the top elements first

        # Idea:
        #   - Count the frequency of each element
        #   - Drop each unique element into the bucket for its frequency
        #   - Walk the buckets from highest to lowest, collecting elements
        #   - Stop as soon as k elements are collected
        #   - No comparison sort is needed, since the frequency itself is the bucket index

        # Frequency of each element
        frequency = defaultdict(int)

        # Array Pointer
        index = 0

        # Count each num
        # tc: O(n)
        while index < len(nums):

            # Add num to count
            frequency[nums[index]] += 1

            # Iterate array
            index += 1

        # Number of buckets, +1 so index n exists (one element repeated n times)
        # nums length 5 -> buckets [0, 1, 2, 3, 4, 5], index 0 stays empty
        numBuckets = len(nums) + 1

        # Empty bucket for every possible frequency
        freqBuckets = []

        # Bucket Pointer
        bucketIndex = 0

        # Create buckets
        # tc: O(n)
        while bucketIndex < numBuckets:

            # Add empty bucket
            freqBuckets.append([])

            # Iterate buckets
            bucketIndex += 1

        # Unique (num, count) pairs
        uniqueItems = list(frequency.items())

        # Unique Pointer
        uniqueIndex = 0

        # Place each unique num into its frequency bucket
        # nums = [1, 1, 1, 2, 2, 3] -> freqBuckets = [[], [3], [2], [1], [], [], []]
        # tc: O(m)
        while uniqueIndex < len(uniqueItems):

            # Current pair
            num, count = uniqueItems[uniqueIndex]

            # Add num to its frequency bucket
            freqBuckets[count].append(num)

            # Iterate unique pairs
            uniqueIndex += 1

        # Top k elements
        result = []

        # Bucket Pointer (highest frequency first, index 0 is always empty)
        bucketIndex = len(freqBuckets) - 1

        # Walk buckets from high to low until k elements are collected
        # k = 2 -> bucket 3 adds [1], bucket 2 adds [2], result = [1, 2], loop stops
        # tc: O(n) buckets, O(m) elements across all buckets
        while bucketIndex > 0 and len(result) < k:

            # Bucket Element Pointer
            bucketItemIndex = 0

            # Collect elements from the current bucket
            while bucketItemIndex < len(freqBuckets[bucketIndex]) and len(result) < k:

                # Add num to result
                result.append(freqBuckets[bucketIndex][bucketItemIndex])

                # Iterate bucket
                bucketItemIndex += 1

            # Iterate buckets (toward lower frequency)
            bucketIndex -= 1

        # overall: tc O(n)
        # overall: sc O(n) for the frequency map and buckets
        return result

973. K Closest Points to Origin ::2:: - Medium

Topics: Array, Math, Divide and Conquer, Geometry, Sorting, Heap (Priority Queue), Quickselect

Intro

Given an array of points where points[i] = [x_i, y_i] represents a point on the X-Y plane and an integer k, return the k closest points to the origin (0, 0). The distance between two points on the X-Y plane is the Euclidean distance: sqrt((x_1, x-2)^2 + (y_1 + y_2)^2) You may return the answer in any order. The answer is guaranteed to be unique (except for the order that it is in).

Example InputOutput
points = [[1,3],[-2,2]], k = 1[[-2, 2]]
points = [[3,3],[5,-1],[-2,4]], k = 2[[3,3],[-2,4]]

Constraints:

1 ≤ k ≤ points.length ≤ 104

-104 ≤ x_1, y_i ≤ 104

Abstraction

Given an array of points, return the k points closest to the origin (any order).

(squared distance: sqrt never changes the order, so compare x * x + y * y and skip the sqrt, and the problem becomes "pick the k smallest values" among the points)

(MaxHeap: keep only the k closest in a maxHeap, so the root is the farthest of the k and the first one to evict, push while the heap has space, once full, replace the root only if the new point is closer)

(Monotonic: the root distance only ever decreases or stays the same once the heap is full, since the heap only keeps points closer than the current farthest, so farther points can be ignored for good)

(Quick Select: partition points by squared distance with closer points on the left, once a pivot lands at index k - 1 the first k points are the k closest, so the rest never needs sorting)

(Count what the heap holds) len(maxHeap) = "how many of the closest points have I kept so far? (at most k)"

(Read the farthest kept point) -maxHeap[0][0] = "what is the squared distance of the farthest of my k closest points?"

(Read the pivot position) resultIndex = "where did the pivot land? if it equals k - 1, then points[:k] holds the k closest"

Pseudocode

  oh! pseudocode hasn't been written yet, try another card! :)

Solution 1: [MaxHeap] Max Heap Of Size K Tracks The K Closest Points - Heap/Heap

    def kClosest(self, points: list[list[int]], k: int) -> list[list[int]]:

        # MaxHeap (Fixed Size K)

        # Heap Representation:
        #   - Maintain a maxHeap of (-squaredDistance, point) for the k closest points seen so far
        #   - heapq is a minHeap, so distances are negated to make the largest distance the root
        #   - Root (maxHeap[0]) is the farthest of those k points,
        #     which is the first one to evict
        #   - Heap is valid when it holds at most k points

        # Idea:
        #   - sqrt is monotonic, so comparing x * x + y * y orders points the same
        #     as comparing true distances, and avoids floats
        #   - The k closest points are the k smallest distances, so keep only those k
        #     in a maxHeap, where the root is the farthest of the k
        #   - If the heap has fewer than k points, push the new point
        #   - If the heap is full, the new point only matters if it is closer than the root,
        #     since anything farther can never be in the top k
        #   - Replace the root with the new point to evict the old farthest
        #   - After every point has been seen, the heap holds the k closest, in any order

        # MaxHeap of (-squaredDistance, point) for the k closest points
        maxHeap = []

        # Array Pointer
        index = 0

        # tc: O(n log k)
        while index < len(points):

            # Current point and its squared distance
            x, y = points[index]
            squaredDistance = x * x + y * y

            # Heap still has space, push point
            # k = 2, maxHeap = [(-18, [3, 3])], point = [5, -1] -> [(-26, [5, -1]), (-18, [3, 3])]
            # tc: O(log k)
            if len(maxHeap) < k:
                heapq.heappush(maxHeap, (-squaredDistance, [x, y]))

            # Heap is full, only keep point if it is closer than the current farthest
            else:

                # Replace root if point is closer:
                # k = 2, maxHeap = [(-26, [5, -1]), (-18, [3, 3])]
                #   point = [-2, 4] -> 20 < 26, replace root -> [(-20, [-2, 4]), (-18, [3, 3])]
                #   a point with distance 30 -> 30 > 20, ignored, heap stays the same
                # tc: O(log k)
                if squaredDistance < -maxHeap[0][0]:
                    heapq.heapreplace(maxHeap, (-squaredDistance, [x, y]))

            # Iterate array
            index += 1

        # K closest points
        result = []

        # Heap Pointer
        heapIndex = 0

        # Collect points from the heap
        # tc: O(k)
        while heapIndex < len(maxHeap):

            # Add point (second item of the pair)
            result.append(maxHeap[heapIndex][1])

            # Iterate heap
            heapIndex += 1

        # overall: tc O(n log k)
        # overall: sc O(k) for the heap
        return result

Solution 2: [Quick Select] Partition Points By Squared Distance Until Index K Minus 1 Is Placed - Heap/Heap

    def kClosest(self, points: list[list[int]], k: int) -> list[list[int]]:

        # Quick Select (Partition By Squared Distance, Closest To Farthest)

        # Search Representation:
        #   - points is partitioned in place
        #   - [left, right] is the current search section of points
        #   - targetIndex = k - 1 is the final position we need to place correctly
        #   - Search section is valid while it still contains targetIndex

        # Idea:
        #   - sqrt is monotonic, so partition by x * x + y * y instead of true distance
        #   - Pick a random pivot and partition the section so closer points
        #     sit on the left and farther points sit on the right:
        #     [closer ... pivot ... farther]
        #   - If the pivot lands at targetIndex, everything to its left is closer,
        #     so points[:k] is the k closest
        #   - If targetIndex is left of the pivot, search the left section
        #   - If targetIndex is right of the pivot, search the right section
        #   - Only one side is ever searched, so we never sort the whole array
        #   - A random pivot avoids the O(n^2) worst case of a fixed pivot

        # Helper that returns the squared distance of the point at index
        def squaredDistance(index: int) -> int:
            return points[index][0] ** 2 + points[index][1] ** 2

        # Helper that partitions [left, right] around the pivot, returns the pivot's final index
        def partitionSection(left: int, right: int, pivotIndex: int) -> int:

            # Squared distance of pivot
            pivotDistance = squaredDistance(pivotIndex)

            # Move pivot to the end
            points[pivotIndex], points[right] = points[right], points[pivotIndex]

            # Next slot for a closer point
            partitionIndex = left

            # Scan Pointer
            scanIndex = left

            # Partition section around pivot
            # tc: O(m) for a section of m points
            while scanIndex < right:

                # Move closer points to the left:
                # points = [[3, 3], [5, -1], [-2, 4]] with distances 18, 26, 20, pivot = [-2, 4] (at right)
                #   scan [3, 3]  -> 18 < 20, swap with partitionIndex 0 (itself), partitionIndex = 1
                #   scan [5, -1] -> 26 > 20, stays
                if squaredDistance(scanIndex) < pivotDistance:
                    points[scanIndex], points[partitionIndex] = points[partitionIndex], points[scanIndex]
                    partitionIndex += 1

                # Iterate section
                scanIndex += 1

            # Place pivot at its final index:
            # [[3, 3], [5, -1], [-2, 4]] -> swap partitionIndex 1 with right -> [[3, 3], [-2, 4], [5, -1]], pivot lands at 1
            points[partitionIndex], points[right] = points[right], points[partitionIndex]
            return partitionIndex

        # Helper that narrows [left, right] until targetIndex holds its final point
        def quickSelect(left: int, right: int, targetIndex: int) -> None:

            # One point left, it is already in place
            if left >= right:
                return

            # Random pivot
            pivotIndex = random.randint(left, right)

            # Partition section
            resultIndex = partitionSection(left, right, pivotIndex)

            # Pivot landed on target, points[:k] is the k closest
            if targetIndex == resultIndex:
                return

            # Target is left of the pivot, search the left section
            # k = 1 -> targetIndex = 0, resultIndex = 2 -> search [left, 1]
            elif targetIndex < resultIndex:
                quickSelect(left, resultIndex - 1, targetIndex)

            # Target is right of the pivot, search the right section
            # k = 3 -> targetIndex = 2, resultIndex = 1 -> search [2, right]
            else:
                quickSelect(resultIndex + 1, right, targetIndex)

        # Final index of the kth closest point
        # k = 2 -> k closest are indexes [0, 1], so targetIndex = 1
        targetIndex = k - 1

        # Place targetIndex, everything left of it is at least as close
        # tc: O(n) average, O(n^2) worst case with bad pivots
        quickSelect(0, len(points) - 1, targetIndex)

        # K closest points
        # tc: O(k)
        closest = points[:k]

        # overall: tc O(n) average, O(n^2) worst case with bad pivots
        # overall: sc O(k) for the returned list, plus O(log n) expected recursion depth
        return closest

1985. Find the Kth Largest Integer in the Array ::1:: - Medium

Topics: Array, String, Divide and Conquer, Sorting, Heap (Priority Queue), Quickselect

Intro

You are given an array of strings nums and an integer k. Each string in nums represents an integer without leading zeros. Return the string that represents the kth largest integer in nums. Note: Duplicate numbers should be counted distinctly. For example, if nums is ["1","2","2"], "2" is the first largest integer, "2" is the second-largest integer, and "1" is the third-largest integer.

Example InputOutput
nums = ["3","6","7","10"], k = 4"3"
nums = ["2","21","12","1"], k = 3"2"
nums = ["0","0"], k = 2"0"

Constraints:

1 ≤ k ≤ nums.length ≤ 10^4

1 ≤ nums[i].length ≤ 100

nums[i] consists of only digits.

nums[i] will not have any leading zeros.

Abstraction

Given an array of strings representing integers (too large to compare as plain numbers), return the kth largest element in sorted numeric order.

(compare by (length, string): a longer digit string is always the bigger number, and equal length strings compare the same lexicographically as numerically, so raw string order like "10" < "9" is avoided)

(keep only the k largest in a minHeap, so the root is the kth largest) (push while the heap has space, once full, replace the root only if the new key beats it)

(Monotonic: the root only ever increases or stays the same, since the heap only keeps keys that beat the current kth largest, so smaller keys can be ignored for good)

(Partition: after splitting around a pivot, the kth largest lives in exactly one of bigger, equal, or smaller, so recurse into that one side and discard the other two)

(Compare by key) numKey(numStr) = "what (length, string) pair makes this string sort in true numeric order?"

(Read the answer from the root) minHeap[0] = "what is the smallest of my k largest keys, which is exactly the kth largest?"

(Count what bigger holds, for quick select) len(bigger) = "how many elements rank above the pivot? if k lte this, the answer is in bigger"

(Count what equal holds, for quick select) len(bigger) + len(equal) = "how many elements rank at or above the pivot? if k lte this, the answer is the pivot"

Pseudocode

  oh! pseudocode hasn't been written yet, try another card! :)

Solution 1: [Heap] MinHeap Of Size K With Numeric Key Tracks The Kth Largest Element - Heap/Heap

    def kthLargestNumber(self, nums: list[str], k: int) -> str:

        # MinHeap (Fixed Size K)

        # Heap Representation:
        #   - Maintain a minHeap holding the keys of the k largest numbers seen so far
        #   - Each key is (length, string), which sorts in true numeric order
        #   - Root (minHeap[0]) is the smallest of those k keys,
        #     which is exactly the kth largest number overall
        #   - Heap is valid when it holds at most k keys

        # Idea:
        #   - nums are strings of huge integers, so raw string comparison is wrong ("10" < "9")
        #   - A longer string is always the bigger number (no leading zeros),
        #     and equal length strings compare correctly lexicographically,
        #     so the key (length, string) orders them numerically
        #   - The kth largest is the smallest of the top k, so keep only the top k keys in a minHeap
        #   - If the heap has fewer than k keys, push the new key
        #   - If the heap is full, the new key only matters if it beats the root,
        #     since anything smaller can never be in the top k
        #   - Replace the root with the new key to evict the old kth largest
        #   - After every string has been seen, the root's string is the answer

        # Helper that builds a numerically ordered key
        def numKey(numStr: str) -> tuple[int, str]:
            return (len(numStr), numStr)

        # MinHeap of the keys of the k largest numbers seen so far
        minHeap = []

        # Array Pointer
        index = 0

        # tc: O(n log k * d), n calls with an O(log k) heap op, each comparing keys up to d digits
        while index < len(nums):

            # Current key
            key = numKey(nums[index])

            # Heap still has space, push key
            # k = 2, minHeap = [(1, "3")], key = (2, "10") -> [(1, "3"), (2, "10")]
            # tc: O(log k * d)
            if len(minHeap) < k:
                heapq.heappush(minHeap, key)

            # Heap is full, only keep key if it beats the current kth largest
            else:

                # Replace root if key is larger:
                # k = 2, minHeap = [(1, "3"), (2, "10")]
                #   "2" -> (1, "2") < (1, "3"), ignored, heap stays the same
                #   "6" -> (1, "6") > (1, "3"), replace root -> [(1, "6"), (2, "10")]
                # tc: O(log k * d)
                if key > minHeap[0]:
                    heapq.heapreplace(minHeap, key)

            # Iterate array
            index += 1

        # Kth largest number (string stored in the root key)
        kthLargest = minHeap[0][1]

        # overall: tc O(n log k * d), d = max digits in a string
        # overall: sc O(k * d), for the heap keys
        return kthLargest

Solution 2: [Quick Select] Three Way Partition With Numeric Key That Ignores Duplicates (Recurse Into One Side) - Heap/Heap

    def kthLargestNumber(self, nums: list[str], k: int) -> str:

        # Quick Select (Three Way Partition)

        # Search Representation:
        #   - nums is the current search space
        #   - k is the rank (1 = largest) of the target inside the current nums
        #   - Search space is valid while it still contains the kth largest

        # Idea:
        #   - nums are strings of huge integers, so compare by key (length, string)
        #     instead of raw string order, which avoids int() conversion on 100 digit strings
        #   - Pick a random pivot and split nums into bigger, equal, and smaller by key
        #   - If k <= len(bigger), the kth largest is inside bigger, recurse there
        #   - If k <= len(bigger) + len(equal), the kth largest is the pivot itself,
        #     so every duplicate of the pivot is resolved in one step
        #   - Otherwise the kth largest is inside smaller, recurse there with
        #     k reduced by the number of elements ranked above it
        #   - Only one side is ever searched, so the work shrinks every call

        # Why Three Way (Duplicates):
        #   - A two way partition (<= pivot vs > pivot) leaves every copy of the pivot
        #     on one side, so with many duplicates the pivot barely shrinks the search space
        #   - Random pivots don't help, since every pivot has the same value
        #   - Each call then removes almost nothing, so time degrades to O(n^2) (TLE)
        #     and recursion depth can hit the limit
        #   - The equal bucket is never recursed into: if k lands inside it, the answer
        #     is the pivot, so all copies of that value cost one pass and then disappear
        #   - With no duplicates it costs nothing extra, still O(n) per pass

        # Helper that builds a numerically ordered key
        def numKey(numStr: str) -> tuple[int, str]:
            return (len(numStr), numStr)

        # Helper that searches one partition for the kth largest
        def quickSelect(nums: list[str], k: int) -> str:

            # Random pivot
            pivot = random.choice(nums)
            pivotKey = numKey(pivot)

            # Partitions
            bigger = []
            equal = []
            smaller = []

            # Array Pointer
            index = 0

            # Partition nums around pivot
            # tc: O(n * d)
            while index < len(nums):

                # Current num and key
                num = nums[index]
                key = numKey(num)

                # Sort num into its partition
                if key > pivotKey:
                    bigger.append(num)
                elif key == pivotKey:
                    equal.append(num)
                else:
                    smaller.append(num)

                # Iterate array
                index += 1

            # Kth largest is inside bigger, recurse
            # nums = ["3", "6", "7", "10"], k = 2, pivot = "6"
            # bigger = ["7", "10"], k <= 2 -> search ["7", "10"] for 2nd largest
            if k <= len(bigger):
                return quickSelect(bigger, k)

            # Kth largest is the pivot (duplicates resolved in one step):
            # nums = ["3", "6", "7", "10"], k = 3, pivot = "6"
            # bigger = ["7", "10"], equal = ["6"]
            # k <= 2 + 1 -> the 3rd largest is "6"
            if k <= len(bigger) + len(equal):
                return pivot

            # Kth largest is inside smaller, recurse with k adjusted:
            # nums = ["3", "6", "7", "10"], k = 4, pivot = "6"
            # bigger = ["7", "10"], equal = ["6"], smaller = ["3"]
            # 4 > 2 + 1 -> search ["3"] for k = 4 - 2 - 1 = 1st largest
            newK = k - len(bigger) - len(equal)
            return quickSelect(smaller, newK)

        # Kth largest number
        kthLargest = quickSelect(nums, k)

        # overall: tc O(n * d) average (n + n/2 + n/4 + ...), O(n^2 * d) worst case with bad pivots
        # overall: sc O(n) for the partition lists, plus O(log n) expected recursion depth
        return kthLargest

23. Merge K Sorted Lists ::2:: - Hard

Topics: Linked List, Divide and Conquer, Heap (Priority Queue), Merge Sort

Intro

You are given an array of k linked-lists lists, each linked-list is sorted in ascending order. Merge all the linked-lists into one sorted linked-list and return it.

Example InputOutput
lists = [[1,4,5],[1,3,4],[2,6]][1,1,2,3,4,4,5,6]
lists = [][]
lists = [[]][]

Constraints:

k == lists.length

0 ≤ k ≤ 104

0 ≤ lists[i].length ≤ 500

-104 ≤ lists[i][j] ≤ 104

lists[i] is sorted in ascending order.

The sum of lists[i].length will not exceed 104

Abstraction

Given a list of k sorted linked lists, merge them into one sorted linked list.

(sorted lists: each list's smallest unmerged node is its head, so the next node of the merged list is always the smallest of the k heads, and the problem becomes "repeatedly pick the smallest head")

(MinHeap: keep one candidate per list in a minHeap, so the root is the smallest node not yet taken, pop it onto the merged list, then push the next node from the same list)

(Monotonic: a list's 2nd node can never matter before its 1st is taken, so one candidate per list is enough, and the heap never holds more than k nodes instead of all N)

(Divide And Conquer: merging two sorted lists is a simple two pointer walk, so merge the lists in pairs, each round halves the number of lists, so there are O(log k) rounds and each round touches every node once)

(Count what the heap holds) len(minHeap) = "how many lists still have a candidate in play? (at most k, one per list)"

(Read the next node from the root) minHeap[0] = "what is the smallest node among all the list heads not yet taken?"

(Count what the lists hold) len(lists) = "how many lists are left to merge? stop when it reaches 1"

(Read the end of the merged list) tail = "where does the next node attach to the merged list?"

Pseudocode

  oh! pseudocode hasn't been written yet, try another card! :)

Solution 1: [MinHeap] K Way Merge Of List Heads With A List Index Tiebreaker - Linked List/Simple Traversal

    def mergeKLists(self, lists: list[Optional[ListNode]]) -> Optional[ListNode]:

        # MinHeap (K Way Merge)

        # Heap Representation:
        #   - Maintain a minHeap of (value, listIndex, node) candidates, one per non empty list
        #   - Each list is sorted, so a list's candidate is its smallest node not yet taken
        #   - Root (minHeap[0]) is the smallest value among all candidates not yet taken
        #   - Heap is valid when it holds at most one candidate per list

        # Idea:
        #   - Every list is sorted, so the next node of the merged list is always
        #     the smallest of the k current heads
        #   - Seed the heap with the head of every non empty list
        #   - Pop the root, attach it to the merged list, then push the next node
        #     in the same list, so each list always has exactly one candidate in the heap
        #   - A list's 2nd node can't matter before its 1st is popped,
        #     so the heap holds at most k nodes instead of all N
        #   - listIndex is the tiebreaker: when two values are equal, heapq would compare
        #     the ListNode objects next, which raises a TypeError, so the unique index
        #     decides before it ever gets that far
        #   - Stop when the heap is empty, every list has been fully merged

        # MinHeap of (value, listIndex, node) candidates, one per non empty list
        minHeap = []

        # List Pointer
        seedIndex = 0

        # Seed each non empty list with its head
        # tc: O(k)
        while seedIndex < len(lists):

            # Add head of the list, skipping empty lists
            if lists[seedIndex]:
                minHeap.append((lists[seedIndex].val, seedIndex, lists[seedIndex]))

            # Iterate lists
            seedIndex += 1

        # Order the seeds into a heap
        # tc: O(k)
        heapq.heapify(minHeap)

        # Dummy head so the first attach needs no special case
        dummyHead = ListNode(-1)

        # End of the merged list
        tail = dummyHead

        # tc: O(N log k), N pops, each heap op is O(log k)
        while minHeap:

            # Smallest candidate
            value, listIndex, node = heapq.heappop(minHeap)

            # Attach node to merged list
            tail.next = node
            tail = tail.next

            # List has a next node, push it:
            # lists = [[1, 4, 5], [1, 3, 4], [2, 6]]
            #   seeds -> [(1, 0, 1), (1, 1, 1), (2, 2, 2)]
            #   pop (1, 0, 1) -> merged [1], push (4, 0, 4)
            #   pop (1, 1, 1) -> merged [1, 1], push (3, 1, 3)
            #   pop (2, 2, 2) -> merged [1, 1, 2], push (6, 2, 6)
            #   ... until the heap is empty -> [1, 1, 2, 3, 4, 4, 5, 6]
            # tc: O(log k)
            if node.next:
                heapq.heappush(minHeap, (node.next.val, listIndex, node.next))

        # Merged list (after the dummy)
        mergedHead = dummyHead.next

        # overall: tc O(N log k), N = total nodes, k = number of lists
        # overall: sc O(k) for the heap, nodes are relinked in place
        return mergedHead

Solution 2: [Divide And Conquer] Merge Lists In Pairs Each Round Until One List Remains - Linked List/Simple Traversal

    def mergeKLists(self, lists: list[Optional[ListNode]]) -> Optional[ListNode]:

        # Divide And Conquer (Pairwise Merge)

        # Merge Representation:
        #   - lists holds the sorted lists still to be merged
        #   - Each round merges lists[0] with lists[1], lists[2] with lists[3], and so on
        #   - Merge is done when lists holds exactly one list

        # Idea:
        #   - Merging two sorted lists is a simple two pointer walk, O(length of both)
        #   - Merge the k lists in pairs, so each round halves the number of lists,
        #     which takes O(log k) rounds
        #   - Every round touches each node once across all of its merges, O(N) per round
        #   - An odd list out is merged with None, which just carries it to the next round
        #   - Merging one list at a time into a growing result is O(N * k),
        #     because the early nodes get re walked every merge, pairing keeps the lists balanced

        # Empty check, k can be 0
        if not lists:
            return None

        # Helper that merges two sorted lists
        def mergeTwo(first: Optional[ListNode], second: Optional[ListNode]) -> Optional[ListNode]:

            # Dummy head so the first attach needs no special case
            dummyHead = ListNode(-1)

            # End of the merged list
            tail = dummyHead

            # Merge while both lists have nodes
            # first = [1, 4], second = [2, 3]
            #   1 < 2 -> attach 1, then 2 <= 4 -> attach 2, then 3 < 4 -> attach 3
            #   second is empty, attach the rest of first -> [1, 2, 3, 4]
            # tc: O(length of both lists)
            while first and second:

                # Attach the smaller node
                if first.val < second.val:
                    tail.next = first
                    first = first.next
                else:
                    tail.next = second
                    second = second.next

                # Iterate merged list
                tail = tail.next

            # Attach the remaining list, at most one is non empty
            if first:
                tail.next = first
            else:
                tail.next = second

            return dummyHead.next

        # tc: O(log k) rounds, each round is O(N)
        while len(lists) > 1:

            # Lists produced by this round
            merged = []

            # Pair Pointer
            pairIndex = 0

            # Merge lists in pairs
            # lists = [A, B, C] -> [merge(A, B), merge(C, None)] -> [merge(merge(A, B), C)]
            # tc: O(N)
            while pairIndex < len(lists):

                # Pair of lists, second is None for an odd list out
                first = lists[pairIndex]
                second = lists[pairIndex + 1] if pairIndex + 1 < len(lists) else None

                # Add merged pair
                merged.append(mergeTwo(first, second))

                # Iterate pairs
                pairIndex += 2

            # Next round works on the merged lists
            lists = merged

        # overall: tc O(N log k), N = total nodes, k = number of lists
        # overall: sc O(k) for the merged list of heads each round, nodes are relinked in place
        return lists[0]

373. Find K Pairs with Smallest Sums ::1:: - Medium

Topics: Array, Heap (Priority Queue)

Intro

You are given two integer arrays nums1 and nums2 sorted in non-decreasing order and an integer k. Define a pair (u, v) which consists of one element from the first array and one element from the second array. Return the k pairs (u1, v1), (u2, v2), ..., (uk, vk) with the smallest sums.

Example InputOutput
nums1 = [1,7,11], nums2 = [2,4,6], k = 3[[1,2],[1,4],[1,6]]
nums1 = [1,1,2], nums2 = [1,2,3], k = 2[[1,1],[1,1]]

Constraints:

1 ≤ nums1.length, nums2.length ≤ 10^5

-10^9 ≤ nums1[i], nums2[i] ≤ 10^9

nums1 and nums2 both are sorted in non-decreasing order.

1 ≤ k ≤ 10^4

k ≤ nums1.length * nums2.length

Abstraction

Given two sorted arrays, return the k pairs (one element from each) with the smallest sums.

(sorted arrays: fix a num in nums1 and the sums with nums2[0], nums2[1], ... are non-decreasing, so every nums1 index owns its own sorted list of candidate sums, and the problem becomes "merge k sorted lists")

(MinHeap: keep one candidate per row in a minHeap, so the root is the smallest sum not yet taken, pop it, then push that row's next candidate)

(Monotonic: row i's best pair (i, 0) is never smaller than row i - 1's best pair, so row i can only matter after row i - 1 has been used, which means only the first k rows can ever contribute)

(Count what the heap holds) len(minHeap) = "how many rows still have a candidate in play? (at most min(n1, k) seeded, one per row)"

(Read the next pair from the root) minHeap[0] = "what is the smallest sum among all candidates not yet taken?"

(Count what the result holds) len(result) = "how many of the k smallest pairs have I collected so far?"

Pseudocode

  oh! pseudocode hasn't been written yet, try another card! :)

Solution 1: [Heap] MinHeap K Way Merge Of Sorted Rows With Bounded Seeding - Heap/Heap

    def kSmallestPairs(self, nums1: list[int], nums2: list[int], k: int) -> list[list[int]]:

        # MinHeap (K Way Merge)

        # Heap Representation:
        #   - Maintain a minHeap of (sum, i, j) candidates, one per row i of nums1
        #   - Row i is the sorted list nums1[i] + nums2[0], nums1[i] + nums2[1], ...
        #   - Root (minHeap[0]) is the smallest sum among all candidates not yet taken
        #   - Heap is valid when it holds at most one candidate per row

        # Idea:
        #   - nums2 is sorted, so for a fixed i the sums grow as j grows,
        #     which makes every row its own sorted list
        #   - Finding the k smallest across these lists is the same k way merge as
        #     Merge K Sorted Lists and Kth Smallest In A Sorted Matrix
        #   - Pop the root, add its pair to the result, then push the next pair in the same row
        #     (i, j + 1), so each row always has exactly one candidate in the heap
        #   - Only seed min(n1, k) rows: pair (i, 0) is never smaller than pairs (0..i-1, 0),
        #     so a row at index k or beyond can't reach the first k results
        #   - Seeding fewer rows keeps the heap size, and each heap op, bounded by min(n1, k)
        #   - Stop once k pairs are collected

        # Lengths
        n1 = len(nums1)
        n2 = len(nums2)

        # Rows to seed
        seedCount = min(n1, k)

        # MinHeap of (sum, i, j) candidates, one per seeded row
        minHeap = []

        # Row Pointer
        row = 0

        # Seed each row with nums2[0], the smallest partner for that row
        # tc: O(min(n1, k))
        while row < seedCount:

            # Add first candidate of the row
            minHeap.append((nums1[row] + nums2[0], row, 0))

            # Iterate rows
            row += 1

        # Order the seeds into a heap
        # tc: O(min(n1, k))
        heapq.heapify(minHeap)

        # K smallest pairs
        result = []

        # tc: O(k log min(n1, k)), k pops, each heap op is O(log min(n1, k))
        while minHeap and len(result) < k:

            # Smallest candidate
            total, i, j = heapq.heappop(minHeap)

            # Add pair to result
            result.append([nums1[i], nums2[j]])

            # Row has a next candidate, push it:
            # nums1 = [1, 7, 11], nums2 = [2, 4, 6], k = 3
            #   seeds -> [(3, 0, 0), (9, 1, 0), (13, 2, 0)]
            #   pop (3, 0, 0) -> [1, 2], push (5, 0, 1)
            #   pop (5, 0, 1) -> [1, 4], push (7, 0, 2)
            #   pop (7, 0, 2) -> [1, 6], j + 1 = 3 is out of range, nothing to push
            # tc: O(log min(n1, k))
            if j + 1 < n2:
                heapq.heappush(minHeap, (nums1[i] + nums2[j + 1], i, j + 1))

        # overall: tc O(k log min(n1, k))
        # overall: sc O(min(n1, k)) for the heap, O(k) for the result
        return result

378. Kth Smallest Element in a Sorted Matrix ::1:: - Medium

Topics: String, Greedy, Sorting, Heap (Priority Queue), Counting

Intro

Given an n x n matrix where each of the rows and columns is sorted in ascending order, return the kth smallest element in the matrix. Note that it is the kth smallest element in the sorted order, not the kth distinct element. You must find a solution with a memory complexity better than O(n^2). Follow up: Could you solve the problem with a constant memory (i.e., O(1) memory complexity)? Could you solve the problem in O(n) time complexity? The solution may be too advanced for an interview but you may find reading this paper fun.

Example InputOutput
matrix = [[1,5,9],[10,11,13],[12,13,15]], k = 813
matrix = [[-5]], k = 1-5

Constraints:

n == matrix.length == matrix[i].length

1 ≤ n ≤ 300

-10^9 ≤ matrix[i][j] ≤ 10^9

All the rows and columns of matrix are guaranteed to be sorted in non-decreasing order.

1 ≤ k ≤ n^2

Abstraction

Given an n x n matrix with sorted rows and columns, return the kth smallest element (duplicates count).

(sorted rows: every row is already a sorted list, so the matrix is really n sorted lists, and the problem becomes "merge sorted lists until the kth element")

(MinHeap: keep one candidate per row in a minHeap, so the root is the smallest element not yet taken, pop it, then push that row's next element, the kth pop is the answer)

(Monotonic: a row's 2nd element can never matter before its 1st is popped, so one candidate per row is enough, and the heap never holds more than n entries instead of n^2)

(Binary Search On Value: the answer is a value inside the matrix, so search the value range [matrix[0][0], matrix[n - 1][n - 1]], for a candidate x count how many elements are lte x, if the k lte count the answer is lte x, otherwise it is > x)

(Staircase Count: start at the bottom left, if the element is lte x the whole column above it is lte x so count it and move right, otherwise the whole row to its right is > x so move up, each step moves right or up, so a count is O(n))

(Count what the heap holds) len(minHeap) = "how many rows still have a candidate in play? (at most n, one per row)"

(Count the discarded pops) popCount = "how many of the smaller elements have I thrown away? stop at k - 1"

(Read the answer from the root) minHeap[0][0] = "after k - 1 pops, what is the smallest element left, which is exactly the kth smallest?"

(Count elements at or below a candidate) countLessEqual(x) = "how many elements in the matrix are lte x? if k lte this count, the answer is at most x"

Pseudocode

  oh! pseudocode hasn't been written yet, try another card! :)

Solution 1: [MinHeap] K Way Merge Of Sorted Rows With One Candidate Per Row - Heap/Heap

    def kthSmallest(self, matrix: list[list[int]], k: int) -> int:

        # MinHeap (K Way Merge)

        # Heap Representation:
        #   - Maintain a minHeap of (value, row, col) candidates, one per row
        #   - Each row is already a sorted list, so a row's candidate is its smallest element not yet taken
        #   - Root (minHeap[0]) is the smallest value among all candidates not yet taken
        #   - Heap is valid when it holds at most one candidate per row

        # Idea:
        #   - Every row is sorted, so the matrix is n separate sorted lists
        #   - Finding the kth smallest across sorted lists is the same k way merge as
        #     Merge K Sorted Lists
        #   - Seed every row with its first (smallest) element
        #   - Pop the root k - 1 times to discard the k - 1 smallest values,
        #     and after each pop push the next element in the same row (row, col + 1),
        #     so each row always has exactly one candidate in the heap
        #   - A row's 2nd element can't matter before its 1st is popped,
        #     so the heap holds at most n entries instead of n^2
        #   - After the k - 1 pops, the root is the kth smallest

        # Number of rows
        n = len(matrix)

        # MinHeap of (value, row, col) candidates, one per row
        minHeap = []

        # Row Pointer
        row = 0

        # Seed each row with its first element
        # tc: O(n)
        while row < n:

            # Add first candidate of the row
            minHeap.append((matrix[row][0], row, 0))

            # Iterate rows
            row += 1

        # Order the seeds into a heap
        # tc: O(n)
        heapq.heapify(minHeap)

        # Discarded Pops
        popCount = 0

        # Discard the k - 1 smallest values
        # tc: O(k log n), k - 1 pops and pushes, each heap op is O(log n)
        while popCount < k - 1:

            # Smallest candidate
            value, row, col = heapq.heappop(minHeap)

            # Row has a next candidate, push it:
            # matrix = [[1, 5, 9], [10, 11, 13], [12, 13, 15]], k = 8
            #   seeds -> [(1, 0, 0), (10, 1, 0), (12, 2, 0)]
            #   pop (1, 0, 0), col + 1 = 1 < 3 -> push (5, 0, 1)
            #   pop (5, 0, 1), col + 1 = 2 < 3 -> push (9, 0, 2)
            #   pop (9, 0, 2), col + 1 = 3 is out of range, nothing to push
            #   ... after 7 pops the root is the second 13, the 8th smallest
            # tc: O(log n)
            if col + 1 < n:
                heapq.heappush(minHeap, (matrix[row][col + 1], row, col + 1))

            # Iterate pops
            popCount += 1

        # Kth smallest value (root)
        kthSmallest = minHeap[0][0]

        # overall: tc O(n + k log n), seeding with heapify is O(n), k - 1 pops and pushes are O(k log n)
        # overall: sc O(n) for the heap
        return kthSmallest
    def kthSmallest(self, matrix: list[list[int]], k: int) -> int:

        # Binary Search (On Value)

        # Search Representation:
        #   - [low, high] is the range of possible values for the answer
        #   - Starts as [matrix[0][0], matrix[n - 1][n - 1]], the smallest and largest values
        #   - Search is done when low == high, one candidate value remains

        # Idea:
        #   - We don't binary search positions, we binary search over VALUES
        #   - For a candidate value x, count how many elements are <= x
        #   - If the count is >= k, the kth smallest is at most x, so high = mid
        #   - Otherwise the kth smallest is greater than x, so low = mid + 1
        #   - The range collapses onto the smallest value whose count is >= k,
        #     and that value is guaranteed to be inside the matrix

        # Staircase Count (Saddleback Search):
        #   - Start at the bottom left corner
        #   - If the element is <= x, every element above it in that column is also <= x,
        #     so count the whole column at once and move right
        #   - If the element is > x, every element to its right in that row is also > x,
        #     so move up
        #   - Each step moves right or up, so at most 2n steps, O(n) per count

        # Number of rows and columns
        n = len(matrix)

        # Helper that counts elements <= x
        def countLessEqual(x: int) -> int:

            # Elements <= x
            count = 0

            # Staircase Pointers (bottom left)
            row = n - 1
            col = 0

            # tc: O(n), at most n moves right + n moves up
            while row >= 0 and col < n:

                # Element <= x, count the whole column above it and move right:
                # matrix = [[1, 5, 9], [10, 11, 13], [12, 13, 15]], x = 13
                #   (2, 0) = 12 <= 13 -> count += 3, move right
                #   (2, 1) = 13 <= 13 -> count += 3, move right
                #   (2, 2) = 15 > 13  -> move up
                #   (1, 2) = 13 <= 13 -> count += 2, move right, out of range
                #   count = 8
                if matrix[row][col] <= x:
                    count += row + 1
                    col += 1

                # Element > x, the rest of the row is also > x, move up
                else:
                    row -= 1

            return count

        # Binary Search Boundaries (possible answer values)
        low = matrix[0][0]
        high = matrix[n - 1][n - 1]

        # tc: O(log(high - low)), each iteration does an O(n) count
        while low < high:

            # Middle value
            mid = low + (high - low) // 2

            # Enough elements <= mid, the answer is at most mid, keep mid as a candidate:
            # k = 8, matrix above
            #   low = 1, high = 15, mid = 8 -> countLessEqual(8) = 2 < 8 -> low = 9
            #   low = 9, high = 15, mid = 12 -> countLessEqual(12) = 6 < 8 -> low = 13
            #   low = 13, high = 15, mid = 14 -> countLessEqual(14) = 8 >= 8 -> high = 14
            #   low = 13, high = 14, mid = 13 -> countLessEqual(13) = 8 >= 8 -> high = 13
            #   low == high == 13
            if countLessEqual(mid) >= k:
                high = mid

            # Too few elements <= mid, the answer is greater than mid
            else:
                low = mid + 1

        # low == high, the smallest value whose count is >= k
        kthSmallest = low

        # overall: tc O(n log(max - min)), max and min are the largest and smallest values in the matrix
        # overall: sc O(1) for the pointers and counters
        return kthSmallest

Solution 3: [Binary Search] Sampled Median Pivot With A Binary Search Fallback (Educational, Not True Frederickson Johnson) - Heap/Algorithm

    def kthSmallest(self, matrix: list[list[int]], k: int) -> int:

        # Binary Search (Sampled Median Pivot)

        # NOTE: This is a simplified, educational sketch inspired by Frederickson Johnson selection.
        # It is NOT a verified implementation of the paper, and it does NOT achieve true O(n).

        # Search Representation:
        #   - [low, high] is the range of possible values for the answer
        #   - Search is done when low == high, one candidate value remains

        # Idea:
        #   - Same value search as Solution 2, but the pivot is chosen from the matrix
        #     instead of always taking the middle of the value range
        #   - Pivot: take the middle element of every row (that row's median) and
        #     use the median of those medians, analogous to median of medians in 1D selection
        #   - Count elements <= pivot with the staircase count from Solution 2
        #   - If the count is >= k, high = pivot, otherwise low = pivot + 1
        #   - The pivot must land inside [low, high - 1] to shrink the range,
        #     otherwise fall back to the middle value, as in Solution 2

        # Number of rows and columns
        n = len(matrix)

        # Helper that counts elements <= x (same staircase count as Solution 2)
        def countLessEqual(x: int) -> int:

            # Elements <= x
            count = 0

            # Staircase Pointers (bottom left)
            row = n - 1
            col = 0

            # tc: O(n)
            while row >= 0 and col < n:

                # Element <= x, count the whole column above it and move right
                if matrix[row][col] <= x:
                    count += row + 1
                    col += 1

                # Element > x, move up
                else:
                    row -= 1

            return count

        # Helper that returns the median of the row medians
        def medianOfRowMedians() -> int:

            # Middle element of every row
            rowMedians = []

            # Row Pointer
            row = 0

            # tc: O(n)
            while row < n:

                # Add row median
                rowMedians.append(matrix[row][n // 2])

                # Iterate rows
                row += 1

            # Median of the row medians
            # tc: O(n log n)
            rowMedians.sort()
            return rowMedians[n // 2]

        # Binary Search Boundaries (possible answer values)
        low = matrix[0][0]
        high = matrix[n - 1][n - 1]

        # tc: O(n log(max - min)) worst case, each iteration does an O(n) count
        while low < high:

            # Sampled pivot
            pivot = medianOfRowMedians()

            # Pivot can't shrink the range, fall back to the middle value:
            # matrix = [[1, 2], [3, 4]], k = 1
            #   low = 1, high = 4, pivot = 4 -> pivot >= high, so mid = 2
            #   without this check, count(4) = 4 >= 1 sets high = 4 forever (infinite loop)
            if pivot < low or pivot >= high:
                pivot = low + (high - low) // 2

            # Enough elements <= pivot, the answer is at most pivot
            if countLessEqual(pivot) >= k:
                high = pivot

            # Too few elements <= pivot, the answer is greater than pivot
            else:
                low = pivot + 1

        # low == high, the smallest value whose count is >= k
        kthSmallest = low

        # overall: tc O(n log(max - min)) worst case, the sampled pivot only helps on the first round
        #          because it is computed from the whole matrix, not the remaining range
        # overall: sc O(n) for the rowMedians list
        return kthSmallest

786. Kth Smallest Prime Fraction ::1:: - Medium

Topics: Array, Heap (Priority Queue)

Intro

You are given a sorted integer array arr containing 1 and prime numbers, where all the integers of arr are unique. You are also given an integer k. For every i and j where 0 lte i lt j lt arr.length, we consider the fraction arr[i] / arr[j]. Return the kth smallest fraction considered. Return your answer as an array of integers of size 2, where answer[0] == arr[i] and answer[1] == arr[j].

Example InputOutput
arr = [1,2,3,5], k = 3[2,5]
arr = [1,7], k = 1[1,7]

Constraints:

2 ≤ arr.length ≤ 1000

1 ≤ arr1[i] ≤ 3 * 10^4

arr[0] == 1

arr[i] is a prime number for i > 0

All the numbers of arr are unique and sorted in strictly increasing order.

1 ≤ k ≤ arr.length * (arr.length - 1) / 2

Abstraction

Given a sorted array of 1 and primes, return the kth smallest fraction arr[i] / arr[j] where i < j.

(sorted array: fix a numerator index i and shrink the denominator index j from the end toward i + 1, arr[j] gets smaller while arr[i] stays fixed, so the fractions strictly increase, which means every numerator index owns its own sorted row, and the problem becomes "merge sorted rows")

(MinHeap: keep one candidate per row in a minHeap, so the root is the smallest fraction not yet taken, pop it, then push that row's next candidate by moving j one step left)

(Monotonic: a row's first candidate, with j = n - 1, is its smallest fraction, so seeding every row there guarantees the root is always the true global minimum, and a row's next fraction can never beat its current one, so it only needs to enter the heap after the current one is popped)

(Count what the heap holds) len(minHeap) = "how many rows still have a candidate in play? (n - 1 seeded, one per numerator index)"

(Count the discarded pops) popCount = "how many of the smaller fractions have I thrown away? stop at k - 1"

(Read the answer from the root) minHeap[0] = "after k - 1 pops, what is the smallest fraction left, which is exactly the kth smallest?"

Pseudocode

  oh! pseudocode hasn't been written yet, try another card! :)

Solution 1: [Heap] MinHeap K Way Merge Of Sorted Rows Over Shrinking Denominators - Heap/Heap

    def kthSmallestPrimeFraction(self, arr: list[int], k: int) -> list[int]:

        # MinHeap (K Way Merge)

        # Heap Representation:
        #   - Maintain a minHeap of (fraction, i, j) candidates, one per numerator index i
        #   - Row i is the sorted list arr[i] / arr[n - 1], arr[i] / arr[n - 2], ..., arr[i] / arr[i + 1]
        #   - Root (minHeap[0]) is the smallest fraction among all candidates not yet taken
        #   - Heap is valid when it holds at most one candidate per row

        # Idea:
        #   - For a fixed i, arr[i] stays the same and arr[j] shrinks as j moves left,
        #     so the fraction strictly increases, which makes every row its own sorted list
        #   - Finding the kth smallest across these lists is the same k way merge as
        #     Kth Smallest In A Sorted Matrix, just walking denominators backward
        #   - Seed every row at j = n - 1, since the largest denominator gives
        #     the smallest fraction for that row
        #   - Pop the root k - 1 times to discard the k - 1 smallest fractions,
        #     and after each pop push the next candidate in the same row (i, j - 1),
        #     so each row always has exactly one candidate in the heap
        #   - After the k - 1 pops, the root is the kth smallest
        #   - A row is exhausted once j - 1 reaches i, since j must stay greater than i

        # Number of elements
        n = len(arr)

        # Index of the largest denominator
        lastIndex = n - 1

        # MinHeap of (fraction, i, j) candidates, one per row
        minHeap = []

        # Row Pointer
        row = 0

        # Seed each row with its smallest fraction, arr[row] / arr[lastIndex]
        # tc: O(n)
        while row < lastIndex:

            # Add first candidate of the row
            minHeap.append((arr[row] / arr[lastIndex], row, lastIndex))

            # Iterate rows
            row += 1

        # Order the seeds into a heap
        # tc: O(n)
        heapq.heapify(minHeap)

        # Discarded Pops
        popCount = 0

        # Discard the k - 1 smallest fractions
        # tc: O(k log n), k - 1 pops and pushes, each heap op is O(log n)
        while popCount < k - 1:

            # Smallest candidate
            fraction, i, j = heapq.heappop(minHeap)

            # Row has a next candidate, push it:
            # arr = [1, 2, 3, 5], k = 3
            #   seeds -> [(0.2, 0, 3), (0.4, 1, 3), (0.6, 2, 3)]
            #   pop (0.2, 0, 3), j - 1 = 2 > 0 -> push (1/3, 0, 2)
            #   pop (1/3, 0, 2), j - 1 = 1 > 0 -> push (1/2, 0, 1)
            #   k - 1 = 2 pops done, root is (0.4, 1, 3) -> [2, 5]
            # tc: O(log n)
            if j - 1 > i:
                heapq.heappush(minHeap, (arr[i] / arr[j - 1], i, j - 1))

            # Iterate pops
            popCount += 1

        # Kth smallest fraction (root)
        _, i, j = minHeap[0]

        # overall: tc O(n + k log n), seeding with heapify is O(n), k - 1 pops and pushes are O(k log n)
        # overall: sc O(n) for the heap
        return [arr[i], arr[j]]

1046. Last Stone Weight ::1:: - Easy

Topics: Array, Heap (Priority Queue)

Intro

You are given an array of integers stones where stones[i] is the weight of the ith stone. We are playing a game with the stones. On each turn, we choose the heaviest two stones and smash them together. Suppose the heaviest two stones have weights x and y with x ≤ y. The result of this smash is: If x == y, both stones are destroyed, and If x != y, the stone of weight x is destroyed, and the stone of weight y has new weight y - x. At the end of the game, there is at most one stone left. Return the weight of the last remaining stone. If there are no stones left, return 0.

Example InputOutput
stones = [2,7,4,1,8,1]1
stones = [1]1

Constraints:

1 ≤ stones.length ≤ 30

1 ≤ stones[i] ≤ 1000

Abstraction

Given an array of stone weights, repeatedly smash the two heaviest stones together until at most one remains, and return its weight (0 if none).

(smash rule: equal stones both vanish, unequal stones leave one stone of weight y - x, so each round removes two stones and adds back at most one)

(MaxHeap: the only stones that matter each round are the two heaviest, so keep every stone in a maxHeap, where the root is the heaviest, pop two, smash them, then push the remainder if there is one)

(Monotonic: a smash never creates a stone heavier than the heaviest one used, since y - x lte y, so the heaviest stone never grows, and the heap shrinks by at least one stone every round, so the game always ends)

(Python heapq is a minHeap, so store negated weights to make the heaviest stone the root)

(Count what the heap holds) len(maxHeap) = "how many stones are left? stop when this is 1 or less"

(Read the heaviest stone) -maxHeap[0] = "what is the weight of the heaviest stone still in play?"

(Read the remainder) heaviest - secondHeaviest = "what weight is left after smashing the two heaviest? if 0, both are destroyed"

Pseudocode

  oh! pseudocode hasn't been written yet, try another card! :)

Solution 1: [MaxHeap] Negated MinHeap Always Yields The Two Heaviest Stones - Heap/Heap

    def lastStoneWeight(self, stones: list[int]) -> int:

        # MaxHeap (Negated MinHeap)

        # Heap Representation:
        #   - Maintain a maxHeap of every stone still in play, stored as negated weights
        #   - heapq is a minHeap, so negating weights makes the heaviest stone the root
        #   - Root (maxHeap[0]) is the negated weight of the heaviest stone
        #   - Heap is valid when it holds exactly the stones still in play

        # Idea:
        #   - Every round only the two heaviest stones matter, so a maxHeap gives them in O(log n)
        #   - Pop the two heaviest, smash them, and if they differ push the remainder back
        #   - Equal stones both vanish, so nothing is pushed
        #   - Each round removes two stones and adds back at most one,
        #     so the heap shrinks every round and the game ends
        #   - Stop when at most one stone remains, the root is the answer, or 0 if the heap is empty
        #   - heapify builds the heap from the negated list in O(n), cheaper than n pushes

        # MaxHeap of negated stone weights
        maxHeap = []

        # Stone Pointer
        index = 0

        # Negate each stone
        # tc: O(n)
        while index < len(stones):

            # Add negated weight
            maxHeap.append(-stones[index])

            # Iterate stones
            index += 1

        # Order the negated weights into a heap
        # tc: O(n)
        heapq.heapify(maxHeap)

        # tc: O(n log n), at most n - 1 rounds, each round is a constant number of O(log n) heap ops
        while len(maxHeap) > 1:

            # Two heaviest stones (undo the negation)
            heaviest = -heapq.heappop(maxHeap)
            secondHeaviest = -heapq.heappop(maxHeap)

            # Stones differ, push the remainder back:
            # stones = [2, 7, 4, 1, 8, 1]
            #   smash 8 and 7 -> 1, push -> [4, 2, 1, 1, 1]
            #   smash 4 and 2 -> 2, push -> [2, 1, 1, 1]
            #   smash 2 and 1 -> 1, push -> [1, 1, 1]
            #   smash 1 and 1 -> equal, both destroyed -> [1]
            #   one stone left -> 1
            # tc: O(log n)
            if heaviest != secondHeaviest:
                remainder = heaviest - secondHeaviest
                heapq.heappush(maxHeap, -remainder)

        # Last stone weight, 0 if every stone was destroyed
        lastStone = -maxHeap[0] if maxHeap else 0

        # overall: tc O(n log n)
        # overall: sc O(n) for the heap
        return lastStone

1834. Single Threaded CPU ::1:: - Medium

Topics: Array, Heap (Priority Queue)

Intro

You are given n tasks labeled from 0 to n - 1 represented by a 2D integer array tasks, where tasks[i] = [enqueueTimei, processingTimei] means that the ith task will be available to process at enqueueTimei and will take processingTimei to finish processing. You have a single-threaded CPU that can process at most one task at a time and will act in the following way: If the CPU is idle and there are no available tasks to process, the CPU remains idle. If the CPU is idle and there are available tasks, the CPU will choose the one with the shortest processing time. If multiple tasks have the same shortest processing time, it will choose the task with the smallest index. Once a task is started, the CPU will process the entire task without stopping. The CPU can finish a task then start a new one instantly. Return the order in which the CPU will process the tasks.

Example InputOutput
tasks = [[1,2],[2,4],[3,2],[4,1]][0,2,3,1]
tasks = [[7,10],[7,12],[7,5],[7,4],[7,2]][4,3,2,0,1]

Constraints:

1 ≤ tasks.length ≤ 10^5

tasks[i] = [enqueueTimei, processingTimei]

1 ≤ enqueueTimei, processingTimei ≤ 10^9

Abstraction

Given tasks with an enqueue time and a processing time, simulate a single threaded CPU that always runs the shortest available task (ties go to the smaller index), and return the order the tasks run in.

(Shortest Job First: at any moment the CPU only chooses among tasks whose enqueue time has already passed, so the problem becomes "which tasks are available right now, and which of them is shortest?")

(Sort first: sort task indexes by enqueue time, so tasks can be released into the heap left to right with a single pointer, and the original indexes are kept because the answer reports them)

(MinHeap: keep every available task in a minHeap keyed by (processingTime, index), so the root is the shortest task, with the smaller index winning ties for free, pop it, run it, and advance the clock by its processing time)

(Monotonic: the clock only moves forward, so a released task stays available until it runs, and every task is pushed once and popped once)

(Clock Jump: if the heap is empty, nothing happens until the next task arrives, so jump the clock straight to that task's enqueue time instead of idling one unit at a time)

(Count what the heap holds) len(minHeap) = "how many tasks are available and waiting right now?"

(Read the next task from the root) minHeap[0] = "which available task is shortest, with the smallest index on a tie?"

(Read the clock) clock = "what time is it? every task with enqueueTime lte clock has been released"

(Read the release pointer) taskPointer = "how many tasks have been released into the heap so far?"

Pseudocode

  oh! pseudocode hasn't been written yet, try another card! :)

Solution 1: [Heap] Sort By Enqueue Time Then MinHeap Of Available Tasks With Clock Jumping - Heap/Heap

    def getOrder(self, tasks: list[list[int]]) -> list[int]:

        # MinHeap (Shortest Job First Simulation)

        # Heap Representation:
        #   - Maintain a minHeap of (processingTime, index) for every available task
        #   - A task is available once its enqueueTime <= clock
        #   - Root (minHeap[0]) is the shortest available task, and the smaller index
        #     wins ties because index is the second item of the tuple
        #   - Heap is valid when it holds exactly the released tasks that have not run yet

        # Idea:
        #   - The CPU wants the shortest available task, which is exactly what a minHeap
        #     keyed on (processingTime, index) gives in O(log n)
        #   - The tricky part is knowing which tasks are available at the current time,
        #     so sort task indexes by enqueueTime once, then sweep left to right with a pointer
        #   - Sort the indexes, not the tasks, since the answer reports original indexes
        #   - Each round, release every task with enqueueTime <= clock into the heap,
        #     then pop the shortest, record it, and advance the clock by its processingTime
        #   - If the heap is empty, nothing happens until the next task arrives,
        #     so jump the clock to that task's enqueueTime instead of idling one unit at a time
        #   - Each task is pushed once and popped once, so the heap work is O(n log n)

        # Number of tasks
        n = len(tasks)

        # Task indexes in order of enqueueTime
        # tc: O(n log n), the sort
        sortedIds = sorted(range(n), key=lambda taskId: tasks[taskId][0])

        # MinHeap of (processingTime, index) for available tasks
        minHeap = []

        # Order tasks run in
        result = []

        # Current time of the CPU
        clock = 0

        # Release Pointer (next task not yet released)
        taskPointer = 0

        # tc: O(n log n), each task is pushed once and popped once, each heap op is O(log n)
        while len(result) < n:

            # Release every task that has arrived by the current time:
            # tasks = [[1, 2], [2, 4], [3, 2], [4, 1]], clock = 3, taskPointer = 1
            #   task 1 (enqueue 2) -> push (4, 1)
            #   task 2 (enqueue 3) -> push (2, 2)
            #   task 3 (enqueue 4) -> 4 > 3, not yet released
            # tc: O(log n) per push
            while taskPointer < n and tasks[sortedIds[taskPointer]][0] <= clock:

                # Add available task
                taskId = sortedIds[taskPointer]
                heapq.heappush(minHeap, (tasks[taskId][1], taskId))

                # Iterate release pointer
                taskPointer += 1

            # Task available, run the shortest one
            if minHeap:

                # Shortest available task
                processingTime, taskId = heapq.heappop(minHeap)

                # Record task and advance the clock:
                # tasks = [[1, 2], [2, 4], [3, 2], [4, 1]]
                #   clock = 0, heap empty -> jump to 1
                #   run task 0 -> clock = 3
                #   run task 2 (shortest of (4, 1), (2, 2)) -> clock = 5
                #   run task 3 (shortest of (4, 1), (1, 3)) -> clock = 6
                #   run task 1 -> clock = 10, result = [0, 2, 3, 1]
                # tc: O(log n)
                result.append(taskId)
                clock += processingTime

            # Nothing available, jump the clock to the next arrival
            else:
                clock = tasks[sortedIds[taskPointer]][0]

        # overall: tc O(n log n), dominated by the sort and the heap operations
        # overall: sc O(n) for the sorted indexes, the heap, and the result
        return result

621. Task Scheduler ::3:: - Medium

Topics: Array, Hash Table, Greedy, Sorting, Heap (Priority Queue), Counting, Computer Architecture Scheduling

Intro

You are given an array of CPU tasks, each labeled with a letter from A to Z, and a number n. Each CPU interval can be idle or allow the completion of one task. Tasks can be completed in any order, but there's a constraint: there has to be a gap of at least n intervals between two tasks with the same label. Return the minimum number of CPU intervals required to complete all tasks.

Example InputOutput
tasks = ["A","A","A","B","B","B"], n = 28
["A","C","A","B","D","B"], n = 16
tasks = ["A","A","A", "B","B","B"], n = 310

Constraints:

1 ≤ tasks.length ≤ 105

tasks[i] is an uppercase English letter

0 ≤ n ≤ 100

Abstraction

Given a list of tasks labeled by letter and a cooldown n between identical tasks, return the minimum number of CPU intervals needed to finish every task (idle intervals count).

(most frequent first: the task with the most copies sets the schedule, since its copies need n other intervals between them, so lay it out as maxFrequency - 1 full groups of length n + 1, plus a last group with no cooldown after it)

(Formula: (maxFrequency - 1) * (n + 1) + maxFrequencyCount, where the last group only holds the tasks tied for the max frequency, the other tasks fill the idle slots, and if there are more tasks than slots no idle is needed, so take the max with len(tasks))

(MaxHeap: simulate the CPU one interval at a time, run the task type with the most copies left, then hold it in a cooldown queue until it is allowed to run again)

(Monotonic: one task runs per interval, so ready times in the cooldown queue are strictly increasing, which means the front of the queue is always the next task to become ready)

(Clock Jump: if the heap is empty but the cooldown queue is not, nothing can run until the front becomes ready, so jump the clock straight to its ready time instead of idling one interval at a time)

(Count what the heap holds) len(maxHeap) = "how many task types are ready to run right now?"

(Read when the next task is ready) cooldown[0][0] = "at what interval does the next waiting task become ready again?"

(Read the clock) clock = "how many intervals have been used so far, idle included?"

(Count the intervals the most frequent tasks force) (maxFrequency - 1) * (n + 1) + maxFrequencyCount = "how many intervals do the most frequent tasks need, idle slots included?"

Pseudocode

  oh! pseudocode hasn't been written yet, try another card! :)

Solution 1: [Greedy] Math Formula From The Most Frequent Tasks And Idle Gaps - Heap/Heap

    def leastInterval(self, tasks: list[str], n: int) -> int:

        # Greedy (Math Formula)

        # Schedule Representation:
        #   - Lay the most frequent task out in maxFrequency groups
        #   - Each group is n + 1 intervals long: the task, then n intervals before it can repeat
        #   - The last group only holds the tasks tied for the max frequency,
        #     since nothing has to cool down after it
        #   - Other tasks fill the idle slots inside the groups

        # Idea:
        #   - The most frequent task decides the minimum schedule length, since its copies
        #     must be spaced n intervals apart
        #   - maxFrequency - 1 full groups of length n + 1, then one last group
        #     of maxFrequencyCount tasks
        #   - Other tasks slot into the idle gaps, so they cost no extra intervals
        #   - If there are more tasks than slots, the gaps overflow and no idle is needed,
        #     so the answer is just len(tasks), the lower bound
        #   - Answer is the max of the two

        # Frequency of each task
        frequency = defaultdict(int)

        # Task Pointer
        index = 0

        # Count each task
        # tc: O(N), N = len(tasks)
        while index < len(tasks):

            # Add task to count
            frequency[tasks[index]] += 1

            # Iterate tasks
            index += 1

        # Count of each task type
        counts = list(frequency.values())

        # Most copies of any one task
        maxFrequency = max(counts)

        # Number of task types tied for the most copies
        maxFrequencyCount = 0

        # Count Pointer
        countIndex = 0

        # tc: O(k), k = distinct tasks (at most 26)
        while countIndex < len(counts):

            # Task type is tied for the most copies
            if counts[countIndex] == maxFrequency:
                maxFrequencyCount += 1

            # Iterate counts
            countIndex += 1

        # Intervals forced by the most frequent tasks:
        # A, B share the max frequency 3, n = 2
        #   [A B idle] [A B idle] [A B] -> (3 - 1) * (2 + 1) + 2 = 8
        forcedIntervals = (maxFrequency - 1) * (n + 1) + maxFrequencyCount

        # More tasks than slots means no idle is needed, len(tasks) is the lower bound:
        # tasks = [A, C, A, B, D, B], n = 1
        #   forcedIntervals = (2 - 1) * (1 + 1) + 2 = 4, but there are 6 tasks -> answer 6
        minIntervals = max(forcedIntervals, len(tasks))

        # overall: tc O(N), N = len(tasks)
        # overall: sc O(k) for the frequency map, k <= 26 so effectively O(1)
        return minIntervals

Solution 2: [MaxHeap] Tick By Tick Simulation With A Cooldown Queue - Heap/Heap

    def leastInterval(self, tasks: list[str], n: int) -> int:

        # MaxHeap (Greedy Simulation With Cooldown)

        # Heap Representation:
        #   - Maintain a maxHeap of negated remaining counts, one per task type ready to run
        #   - heapq is a minHeap, so counts are negated to make the largest count the root
        #   - Root (maxHeap[0]) is the task type with the most copies left
        #   - Labels aren't stored, since only the counts decide the schedule
        #   - A task that just ran waits in a cooldown queue of (readyTime, remaining)

        # Idea:
        #   - Each loop is one CPU interval, so clock counts total intervals including idle
        #   - Run the task type with the most copies left, since it is the most at risk
        #     of forcing idle time later
        #   - If it has copies left, put it in the cooldown queue with readyTime = clock + n
        #   - A task ran at interval t can run again at t + n + 1, so it goes back into
        #     the heap at the end of interval t + n
        #   - One task runs per interval, so ready times are strictly increasing,
        #     and a FIFO queue is enough: at most one task becomes ready per interval
        #   - If the heap is empty but the queue is not, the CPU idles this interval
        #   - Stop when both the heap and the queue are empty

        # Frequency of each task
        frequency = defaultdict(int)

        # Task Pointer
        index = 0

        # Count each task
        # tc: O(N), N = len(tasks)
        while index < len(tasks):

            # Add task to count
            frequency[tasks[index]] += 1

            # Iterate tasks
            index += 1

        # MaxHeap of negated counts, one per task type
        maxHeap = []

        # Count of each task type
        counts = list(frequency.values())

        # Count Pointer
        countIndex = 0

        # Seed each task type with its negated count
        # tc: O(k), k = distinct tasks (at most 26)
        while countIndex < len(counts):

            # Add negated count
            maxHeap.append(-counts[countIndex])

            # Iterate counts
            countIndex += 1

        # Order the seeds into a heap
        # tc: O(k)
        heapq.heapify(maxHeap)

        # Tasks waiting out their cooldown as (readyTime, remaining)
        cooldown = deque()

        # Intervals used so far
        clock = 0

        # tc: O(T log k), T = intervals used (the answer), each interval does at most 2 heap ops
        while maxHeap or cooldown:

            # One CPU interval
            clock += 1

            # A task is ready, run the one with the most copies left
            if maxHeap:

                # Remove most frequent task, use one copy (counts are negated, so + 1)
                remaining = heapq.heappop(maxHeap) + 1

                # Copies left, wait out the cooldown
                if remaining != 0:
                    cooldown.append((clock + n, remaining))

            # Front of the queue finished its cooldown, push it back:
            # tasks = [A, A, A, B, B, B], n = 2
            #   clock 1: run A -> cooldown [(3, -2)]
            #   clock 2: run B -> cooldown [(3, -2), (4, -2)]
            #   clock 3: heap empty, idle, A ready -> push -2
            #   clock 4: run A -> cooldown [(4, -2), (6, -1)], B ready -> push -2
            #   clock 5: run B -> cooldown [(6, -1), (7, -1)]
            #   clock 6: idle, A ready -> push -1
            #   clock 7: run A, B ready -> push -1
            #   clock 8: run B, both empty -> 8
            # n = 0: readyTime = clock, so it is pushed back the same interval
            # tc: O(log k)
            if cooldown and cooldown[0][0] == clock:

                # Pop FIFO
                _, remaining = cooldown.popleft()

                # Add task type back to the heap
                heapq.heappush(maxHeap, remaining)

        # overall: tc O(T log k), T = answer, k <= 26 so effectively O(T)
        # overall: sc O(k) for the heap and cooldown queue
        return clock

Solution 3: [MaxHeap] Cooldown Queue Simulation That Jumps The Clock Over Idle Intervals - Heap/Heap

    def leastInterval(self, tasks: list[str], n: int) -> int:

        # MaxHeap (Greedy Simulation With Clock Jump)

        # Heap Representation:
        #   - Same as Solution 2: a maxHeap of negated remaining counts for task types
        #     ready to run, and a cooldown queue of (readyTime, remaining)
        #   - Root (maxHeap[0]) is the task type with the most copies left

        # Idea:
        #   - Same simulation as Solution 2, but idle intervals are skipped
        #   - If the heap is empty, nothing can run until the front of the queue is ready,
        #     so jump the clock straight to its readyTime instead of idling one interval at a time
        #   - The queue is never empty when the heap is, since the loop only runs
        #     while one of them has something
        #   - Ready times only increase, so the jump never moves the clock backward
        #   - Every loop now either runs a task or jumps to a ready task,
        #     so loops are at most 2N instead of T, where T includes all idle intervals

        # Frequency of each task
        frequency = defaultdict(int)

        # Task Pointer
        index = 0

        # Count each task
        # tc: O(N), N = len(tasks)
        while index < len(tasks):

            # Add task to count
            frequency[tasks[index]] += 1

            # Iterate tasks
            index += 1

        # MaxHeap of negated counts, one per task type
        maxHeap = []

        # Count of each task type
        counts = list(frequency.values())

        # Count Pointer
        countIndex = 0

        # Seed each task type with its negated count
        # tc: O(k), k = distinct tasks (at most 26)
        while countIndex < len(counts):

            # Add negated count
            maxHeap.append(-counts[countIndex])

            # Iterate counts
            countIndex += 1

        # Order the seeds into a heap
        # tc: O(k)
        heapq.heapify(maxHeap)

        # Tasks waiting out their cooldown as (readyTime, remaining)
        cooldown = deque()

        # Intervals used so far
        clock = 0

        # tc: O(N log k), each loop runs a task or jumps to a ready task, so at most 2N loops
        while maxHeap or cooldown:

            # A task is ready, run the one with the most copies left
            if maxHeap:

                # One CPU interval
                clock += 1

                # Remove most frequent task, use one copy (counts are negated, so + 1)
                remaining = heapq.heappop(maxHeap) + 1

                # Copies left, wait out the cooldown
                if remaining != 0:
                    cooldown.append((clock + n, remaining))

            # Nothing ready, jump the clock to the next ready task:
            # tasks = [A, A], n = 5
            #   clock 1: run A -> cooldown [(6, -1)]
            #   heap empty -> jump clock to 6, skipping the idle intervals 2 to 5
            #   A ready -> push -1
            #   clock 7: run A -> 7
            else:
                clock = cooldown[0][0]

            # Front of the queue finished its cooldown, push it back
            # tc: O(log k)
            if cooldown and cooldown[0][0] == clock:

                # Pop FIFO
                _, remaining = cooldown.popleft()

                # Add task type back to the heap
                heapq.heappush(maxHeap, remaining)

        # overall: tc O(N log k), k <= 26 so effectively O(N)
        # overall: sc O(k) for the heap and cooldown queue
        return clock

767. Reorganize String ::1:: - Medium

Topics: String, Greedy, Sorting, Heap (Priority Queue), Counting

Intro

Given a string s, rearrange the characters of s so that any two adjacent characters are not the same. Return any possible rearrangement of s or return "" if not possible.

Example InputOutput
s = "aab""aba"
s = "aaab"""

Constraints:

1 ≤ s.length ≤ 500

s consists of lowercase English letters.

Abstraction

Given a string, rearrange its characters so no two adjacent characters match, and return any valid rearrangement ("" if impossible).

(most frequent first: the character with the most copies is the most at risk of landing next to itself, so always place whichever character has the most copies left)

(Impossible: a character with count > ceil(n / 2) can't be spaced out, since there aren't enough other characters to fill the gaps between its copies, so return "" up front)

(MaxHeap: keep every character in a maxHeap keyed by remaining count, so the root is the character with the most copies left, pop it, place it, and it can't be placed again right away)

(Cooldown: after placing a character it only has to wait one round, so hold just that one character aside, and push it back into the heap after the next, different character is placed)

(Count what the heap holds) len(maxHeap) = "how many characters still have copies waiting to be placed? (at most 26)"

(Read the next character from the root) maxHeap[0] = "which character has the most copies left, and is it safe to place?"

(Read the cooldown slot) prevChar = "which character was just placed, and has to sit out this round?"

Pseudocode

  oh! pseudocode hasn't been written yet, try another card! :)

Solution 1: [Heap] Greedy MaxHeap By Count With A One Round Cooldown - Heap/Heap

    def reorganizeString(self, s: str) -> str:

        # MaxHeap (Greedy With Cooldown)

        # Heap Representation:
        #   - Maintain a maxHeap of (-count, char) for every character with copies left
        #   - heapq is a minHeap, so counts are negated to make the largest count the root
        #   - Root (maxHeap[0]) is the character with the most copies left
        #   - The character just placed is held outside the heap for one round (cooldown)

        # Idea:
        #   - The character with the most copies is the most at risk of ending up
        #     next to itself, so always place the one with the most copies left
        #   - If any count > (n + 1) // 2, there aren't enough other characters
        #     to separate every copy, so return "" immediately
        #   - After placing a character, it can't go next to itself, but it can go
        #     after the next different character, so a one round cooldown is enough:
        #     hold only the last placed character aside, and push it back
        #     after the next one is placed
        #   - Only one character is ever on cooldown, so no queue of history is needed
        #   - Heap holds at most 26 characters, so each heap op is O(log 26), effectively O(1)
        #   - With the impossibility check passed, the greedy always finishes placing
        #     all n characters

        # Number of characters
        n = len(s)

        # Frequency of each character
        counts = defaultdict(int)

        # String Pointer
        index = 0

        # Count each character
        # tc: O(n)
        while index < n:

            # Add character to count
            counts[s[index]] += 1

            # Iterate string
            index += 1

        # Most copies of any one character
        maxCount = max(counts.values())

        # Impossible, the most frequent character can't be spaced out:
        # s = "aaab" -> n = 4, 'a' appears 3 times, 3 > (4 + 1) // 2 = 2 -> return ""
        # s = "aab"  -> n = 3, 'a' appears 2 times, 2 <= (3 + 1) // 2 = 2 -> possible
        if maxCount > (n + 1) // 2:
            return ""

        # MaxHeap of (-count, char) for every character
        maxHeap = []

        # Unique (char, count) pairs
        uniqueItems = list(counts.items())

        # Unique Pointer
        uniqueIndex = 0

        # Seed each character with its negated count
        # tc: O(k), k = distinct characters (at most 26)
        while uniqueIndex < len(uniqueItems):

            # Current pair
            char, count = uniqueItems[uniqueIndex]

            # Add negated count
            maxHeap.append((-count, char))

            # Iterate unique pairs
            uniqueIndex += 1

        # Order the seeds into a heap
        # tc: O(k)
        heapq.heapify(maxHeap)

        # Placed characters
        result = []

        # Cooldown slot (negated count, char), empty at the start
        prevCount = 0
        prevChar = ""

        # tc: O(n log k), n pops, each pop and push is O(log k)
        while maxHeap:

            # Character with the most copies left
            count, char = heapq.heappop(maxHeap)

            # Place character
            result.append(char)

            # Previous character finished its cooldown and still has copies, push it back
            # s = "aab", maxHeap = [(-2, 'a'), (-1, 'b')]
            #   pop (-2, 'a') -> "a", cooldown ('a', -1), nothing to push back
            #   pop (-1, 'b') -> "ab", push back (-1, 'a'), cooldown ('b', 0)
            #   pop (-1, 'a') -> "aba", 'b' has no copies left so it is not pushed
            #   heap empty -> "aba"
            # tc: O(log k)
            if prevCount < 0:
                heapq.heappush(maxHeap, (prevCount, prevChar))

            # Use one copy of this character, then put it on cooldown for one round
            prevCount = count + 1
            prevChar = char

        # Rearranged string
        reorganized = "".join(result)

        # overall: tc O(n log k), k <= 26 so effectively O(n)
        # overall: sc O(k) for the heap, O(n) for the result
        return reorganized

295. Find Median from Data Stream ::2:: - Hard

Topics: Two Pointers, Design, Sorting, Heap (Priority Queue), Data Stream

Intro

The median is the middle value in an ordered integer list. If the size of the list is even, there is no middle value, and the median is the mean of the two middle values. For example, for arr = [2,3,4], the median is 3. For example, for arr = [2,3], the median is (2 + 3) / 2 = 2.5. Implement the MedianFinder class: MedianFinder() initializes the MedianFinder object. void addNum(int num) adds the integer num from the data stream to the data structure. double findMedian() returns the median of all elements so far. Answers within 10-5 of the actual answer will be accepted.

Example InputOutput
["MedianFinder", "addNum", "addNum", "findMedian", "addNum", "findMedian"] [[], [1], [2], [], [3], []][null, null, null, 1.5, null, 2.0]

Constraints:

-10^5 ≤ num ≤ 10^5

There will be at least one element in the data structure before calling findMedian.

At most 5 * 10^4 calls will be made to addNum and findMedian

Abstraction

Given a stream of numbers, support adding a number and returning the median of everything added so far.

(the median only depends on the middle one or two values, so split the numbers into a lower half and an upper half, and the problem becomes "keep track of the largest of the lower half and the smallest of the upper half")

(Two Heaps: keep the lower half in a maxHeap and the upper half in a minHeap, so the roots are the two middle values, every number in the lower half is lte every number in the upper half)

(Balance: the lower half holds either the same number of elements as the upper half or exactly one more, so for an odd count the median is the lower root, and for an even count it is the average of both roots)

(Monotonic: the lower root can only increase or stay the same as numbers arrive, since a number that would raise it is pushed up to the upper half the moment it passes the upper root, so a number never has to be re-sorted against the whole stream, only against the two roots)

(Sorted Container: if the numbers are stored in sorted order, the median is just the middle index, so the cost moves from reading the median to inserting a number)

(Count the size difference) len(maxHeap) - len(minHeap) = "is the lower half balanced? it must be 0 or 1"

(Read the lower middle) -maxHeap[0] = "what is the largest value of the lower half, which is the median when the count is odd?"

(Read the upper middle) minHeap[0] = "what is the smallest value of the upper half, which pairs with the lower root when the count is even?"

Pseudocode

  oh! pseudocode hasn't been written yet, try another card! :)

Solution 1: [Two Heaps] MaxHeap Of The Lower Half And MinHeap Of The Upper Half Track The Median - Heap/Heap

class MedianFinder:

    # Two Heaps (Lower Half MaxHeap, Upper Half MinHeap)

    # Heap Representation:
    #   - maxHeap holds the lower half of the numbers, stored as negated values
    #   - minHeap holds the upper half of the numbers
    #   - heapq is a minHeap, so negating values makes the largest of the lower half the root
    #   - Roots (-maxHeap[0] and minHeap[0]) are the two middle values
    #   - Heaps are valid when every lower number is <= every upper number,
    #     and len(maxHeap) is len(minHeap) or len(minHeap) + 1

    # Idea:
    #   - The median only depends on the middle one or two values
    #   - Split the numbers into a lower half and an upper half,
    #     so the middle values are the two roots
    #   - Push every new number into the lower half first,
    #     then fix the two ways the heaps can go wrong
    #   - Out of order: if the lower root is bigger than the upper root, move it up
    #   - Out of balance: if the lower half is smaller than the upper half, or bigger by more than 1,
    #     move one root across
    #   - Odd count: the lower half has the extra element, so the median is its root
    #   - Even count: the halves are equal, so the median is the average of both roots
    #   - Reading the median is O(1), the cost is paid when adding

    # MedianFinder():
    #   - Initializes the two empty heaps

    # addNum():
    #   - Adds a number to the correct half
    #   - Rebalances so the lower half is equal or one larger

    # findMedian():
    #   - Returns the middle value, or the average of the two middle values

    def __init__(self):

        # MaxHeap of the lower half (negated values)
        self.maxHeap = []

        # MinHeap of the upper half
        self.minHeap = []

        # overall: tc O(1)
        # overall: sc O(1)

    def addNum(self, num: int) -> None:

        # Add num to the lower half
        # tc: O(log n)
        heapq.heappush(self.maxHeap, -num)

        # Lower root is bigger than upper root, out of order, move it up:
        # adding 1, 2, 3
        #   add 3 -> maxHeap [3, 1], minHeap [2], 3 > 2 -> move 3 up
        #   maxHeap [1], minHeap [2, 3]
        # tc: O(log n)
        if self.minHeap and -self.maxHeap[0] > self.minHeap[0]:

            # Largest of the lower half
            largestLower = -heapq.heappop(self.maxHeap)

            # Move it to the upper half
            heapq.heappush(self.minHeap, largestLower)

        # Upper half is bigger, move its root down:
        # maxHeap [1], minHeap [2, 3] -> move 2 down -> maxHeap [2, 1], minHeap [3]
        # tc: O(log n)
        if len(self.maxHeap) < len(self.minHeap):

            # Smallest of the upper half
            smallestUpper = heapq.heappop(self.minHeap)

            # Move it to the lower half
            heapq.heappush(self.maxHeap, -smallestUpper)

        # Lower half is bigger by more than 1, move its root up:
        # adding 1, 2 -> add 2 -> maxHeap [2, 1], minHeap [] -> move 2 up
        # maxHeap [1], minHeap [2]
        # tc: O(log n)
        elif len(self.maxHeap) > len(self.minHeap) + 1:

            # Largest of the lower half
            largestLower = -heapq.heappop(self.maxHeap)

            # Move it to the upper half
            heapq.heappush(self.minHeap, largestLower)

        # overall: tc O(log n), at most 3 heap operations
        # overall: sc O(1) extra, the heaps themselves hold the n numbers

    def findMedian(self) -> float:

        # Odd count, the lower half has the extra element:
        # maxHeap [2, 1], minHeap [3] -> median 2
        if len(self.maxHeap) > len(self.minHeap):
            median = -self.maxHeap[0]

        # Even count, average of both roots:
        # maxHeap [1], minHeap [2] -> (1 + 2) / 2 = 1.5
        else:
            median = (-self.maxHeap[0] + self.minHeap[0]) / 2

        # overall: tc O(1)
        # overall: sc O(1)
        return median

Solution 2: [Sort] Sorted Container Reads The Middle Elements By Index - Heap/Heap

class MedianFinder:

    # SortedList (Sorted Container)

    # Container Representation:
    #   - sortedNums holds every number seen so far, always in sorted order
    #   - The median sits at the middle index, or the two middle indexes

    # Idea:
    #   - If the numbers are always sorted, the median is just the middle element
    #   - SortedList (from sortedcontainers) inserts in sorted position and supports indexing,
    #     so no heaps or balancing are needed
    #   - Odd count: the median is sortedNums[n // 2]
    #   - Even count: the median is the average of sortedNums[n // 2 - 1] and sortedNums[n // 2]
    #   - The cost moves to inserting, and the container uses more memory than two heaps

    # MedianFinder():
    #   - Initializes an empty SortedList

    # addNum():
    #   - Inserts the number in sorted position

    # findMedian():
    #   - Returns the middle value, or the average of the two middle values

    def __init__(self):

        # Numbers seen so far, in sorted order
        self.sortedNums = SortedList()

        # overall: tc O(1)
        # overall: sc O(1)

    def addNum(self, num: int) -> None:

        # Insert num in sorted position
        # tc: O(log n)
        self.sortedNums.add(num)

        # overall: tc O(log n)
        # overall: sc O(1) extra, the container itself holds the n numbers

    def findMedian(self) -> float:

        # Number of elements
        n = len(self.sortedNums)

        # Odd count, middle element:
        # sortedNums = [1, 2, 3] -> n = 3, index 1 -> 2
        # tc: O(log n)
        if n % 2 == 1:
            median = self.sortedNums[n // 2]

        # Even count, average of the two middle elements:
        # sortedNums = [1, 2] -> n = 2, indexes 0 and 1 -> (1 + 2) / 2 = 1.5
        # tc: O(log n)
        else:
            median = (self.sortedNums[n // 2 - 1] + self.sortedNums[n // 2]) / 2

        # overall: tc O(log n)
        # overall: sc O(1)
        return median

480. Sliding Window Median ::2:: - Hard

Topics: Array, Hash Table, Sliding Window, Heap (Priority Queue), Treap

Intro

The median is the middle value in an ordered integer list. If the size of the list is even, there is no middle value. So the median is the mean of the two middle values. For examples, if arr = [2,3,4], the median is 3. For examples, if arr = [1,2,3,4], the median is (2 + 3) / 2 = 2.5. You are given an integer array nums and an integer k. There is a sliding window of size k which is moving from the very left of the array to the very right. You can only see the k numbers in the window. Each time the sliding window moves right by one position. Return the median array for each window in the original array. Answers within 10-5 of the actual value will be accepted.

Example InputOutput
nums = [1,3,-1,-3,5,3,6,7], k = 3[1.00000,-1.00000,-1.00000,3.00000,5.00000,6.00000]

Constraints:

1 ≤ k ≤ nums.length &leq 10^5

-2^31 ≤ nums[i] ≤ 2^31 - 1

Abstraction

Given an array and a window size k, return the median of every window as it slides from left to right.

(the median only depends on the middle one or two values of the window, so this is 295 with one new twist: numbers leave the window as well as enter it)

(Two Heaps: keep the lower half of the window in a maxHeap and the upper half in a minHeap, so the roots are the two middle values, slide by adding the new number, removing the old number, then rebalancing)

(Lazy Deletion: a heap can only remove its root efficiently, so don't dig out the old number, just record it in a delayed count, and discard it for real only when it surfaces at a root)

(Size Tracking: stale numbers still sit inside the heaps, so len(heap) lies, track the real size of each half in lowerSize and upperSize instead)

(Monotonic: a stale number is never read, since the roots are pruned right after every removal or move, so the roots are always real window values and the median reads are always correct)

(Sorted Container: if the window is stored in sorted order, the median is just the middle index, and removing the old number is a direct remove instead of a lazy one)

(Count the real sizes) lowerSize - upperSize = "is the lower half balanced? it must be 0 or 1"

(Read the lower middle) -lowerHeap[0] = "what is the largest real value of the lower half, which is the median when k is odd?"

(Count the stale numbers) delayed[num] = "how many copies of num have left the window but still sit inside a heap?"

Pseudocode

  oh! pseudocode hasn't been written yet, try another card! :)

Solution 1: [Two Heaps] MaxHeap And MinHeap Of The Window Halves With Lazy Deletion And Tracked Sizes - Heap/Heap

    def medianSlidingWindow(self, nums: list[int], k: int) -> list[float]:

        # Two Heaps (Lower Half MaxHeap, Upper Half MinHeap, Lazy Deletion)

        # Heap Representation:
        #   - lowerHeap holds the lower half of the window, stored as negated values
        #   - upperHeap holds the upper half of the window
        #   - heapq is a minHeap, so negating values makes the largest of the lower half the root
        #   - delayed[num] counts copies of num that left the window but are still inside a heap
        #   - lowerSize and upperSize are the real sizes of each half, stale numbers not counted
        #   - Heaps are valid when every lower number is <= every upper number,
        #     lowerSize is upperSize or upperSize + 1, and both roots are real (not stale)

        # Idea:
        #   - Same split as 295, but numbers also leave the window
        #   - A heap can only remove its root cheaply, so removing an arbitrary number is lazy:
        #     count it in delayed, and discard it when it reaches a root
        #   - Which half a number belongs to is decided by comparing with the lower root,
        #     since every lower number is <= the lower root and every upper number is >= it
        #   - Stale numbers still sit inside the heaps, so len(heap) can't be used,
        #     the real sizes are tracked by hand and drive the rebalancing
        #   - After every pop or removal, prune the root: while it is stale, pop it for real
        #     so both roots are always real window values
        #   - Slide: add the new number, remove the old number, then record the median
        #   - Odd k: the median is the lower root, even k: the average of both roots

        # Number of elements
        n = len(nums)

        # MaxHeap of the lower half (negated values)
        lowerHeap = []

        # MinHeap of the upper half
        upperHeap = []

        # Copies that left the window but are still inside a heap
        delayed = defaultdict(int)

        # Real size of each half
        lowerSize = 0
        upperSize = 0

        # Helper that pops stale numbers off the root of a heap
        # tc: O(log n) per stale number, each number is discarded at most once
        def prune(heap: list[int], isLower: bool) -> None:

            # Root is stale, pop it for real
            while heap:

                # Real value of the root
                value = -heap[0] if isLower else heap[0]

                # Root is a real window value, done
                if delayed[value] == 0:
                    break

                # Discard stale copy
                delayed[value] -= 1
                heapq.heappop(heap)

        # Helper that moves one root across so the halves are balanced
        # tc: O(log n)
        def rebalance() -> None:
            nonlocal lowerSize, upperSize

            # Lower half too big, move its root up
            if lowerSize > upperSize + 1:
                heapq.heappush(upperHeap, -heapq.heappop(lowerHeap))
                lowerSize -= 1
                upperSize += 1
                prune(lowerHeap, True)

            # Upper half too big, move its root down
            elif lowerSize < upperSize:
                heapq.heappush(lowerHeap, -heapq.heappop(upperHeap))
                lowerSize += 1
                upperSize -= 1
                prune(upperHeap, False)

        # Helper that adds a number to the correct half
        # tc: O(log n)
        def addNum(num: int) -> None:
            nonlocal lowerSize, upperSize

            # Number belongs to the lower half
            if not lowerHeap or num <= -lowerHeap[0]:
                heapq.heappush(lowerHeap, -num)
                lowerSize += 1

            # Number belongs to the upper half
            else:
                heapq.heappush(upperHeap, num)
                upperSize += 1

            rebalance()

        # Helper that lazily removes a number that left the window
        # tc: O(log n)
        def removeNum(num: int) -> None:
            nonlocal lowerSize, upperSize

            # Mark the copy as stale
            delayed[num] += 1

            # Number is in the lower half
            if num <= -lowerHeap[0]:
                lowerSize -= 1

                # Stale copy is the root, discard it now
                if num == -lowerHeap[0]:
                    prune(lowerHeap, True)

            # Number is in the upper half
            else:
                upperSize -= 1

                # Stale copy is the root, discard it now
                if num == upperHeap[0]:
                    prune(upperHeap, False)

            rebalance()

        # Medians of each window
        result = []

        # Fill the first window
        # tc: O(k log k)
        index = 0
        while index < k:

            # Add num to the window
            addNum(nums[index])

            # Iterate array
            index += 1

        # Median of the first window
        if k % 2 == 1:
            result.append(float(-lowerHeap[0]))
        else:
            result.append((-lowerHeap[0] + upperHeap[0]) / 2)

        # Slide the window one step at a time
        # tc: O(n log k), each slide is a constant number of O(log k) heap operations
        while index < n:

            # Add the entering number, remove the leaving number:
            # nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3
            #   first window [1, 3, -1] -> lower [1, -1], upper [3], median 1
            #   add -3 -> lower [1, -1, -3] too big, move 1 up -> lower [-1, -3], upper [1, 3]
            #   remove 1 -> 1 > -1 so it is in the upper half, it is the root, prune it -> upper [3]
            #   window [3, -1, -3] -> median -1
            addNum(nums[index])
            removeNum(nums[index - k])

            # Record the median
            if k % 2 == 1:
                result.append(float(-lowerHeap[0]))
            else:
                result.append((-lowerHeap[0] + upperHeap[0]) / 2)

            # Iterate array
            index += 1

        # overall: tc O(n log k), each number is added, removed, and discarded at most once
        # overall: sc O(n) worst case, stale numbers can wait inside the heaps before they surface
        return result

Solution 2: [SortedList] Sorted Window Reads The Middle Elements By Index - Heap/Heap

    def medianSlidingWindow(self, nums: list[int], k: int) -> list[float]:

        # SortedList (Sorted Container)

        # Container Representation:
        #   - window holds exactly the current k numbers, always in sorted order
        #   - The median sits at the middle index, or the two middle indexes
        #   - Both middle indexes are fixed, since k never changes

        # Idea:
        #   - If the window is always sorted, the median is just the middle element
        #   - SortedList (from sortedcontainers) inserts and removes in sorted position
        #     and supports indexing, so no heaps, balancing, or lazy deletion are needed
        #   - Slide: remove the leaving number, add the entering number
        #   - Odd k: the median is window[k // 2]
        #   - Even k: the median is the average of window[k // 2 - 1] and window[k // 2]
        #   - Simpler than two heaps, but it needs the third party sortedcontainers package

        # Number of elements
        n = len(nums)

        # Numbers of the current window, in sorted order
        window = SortedList()

        # Fill the first window
        # tc: O(k log k)
        index = 0
        while index < k:

            # Add num to the window
            window.add(nums[index])

            # Iterate array
            index += 1

        # Medians of each window
        result = []

        # Window Start Pointer
        windowStart = 0

        # tc: O(n log k), each slide is one remove and one add
        while windowStart + k <= n:

            # Record the median:
            # window = [-1, 1, 3], k = 3 -> window[1] = 1
            # window = [1, 2],     k = 2 -> (window[0] + window[1]) / 2 = 1.5
            # tc: O(log k)
            if k % 2 == 1:
                result.append(float(window[k // 2]))
            else:
                result.append((window[k // 2 - 1] + window[k // 2]) / 2)

            # Slide the window, unless this was the last one
            if windowStart + k < n:

                # Remove the leaving number, add the entering number
                # tc: O(log k)
                window.remove(nums[windowStart])
                window.add(nums[windowStart + k])

            # Iterate window
            windowStart += 1

        # overall: tc O(n log k)
        # overall: sc O(k) for the window, O(n - k + 1) for the returned list
        return result

Solution 3: [Treap] Randomized BST With Subtree Sizes Gives Insert, Delete, And Kth Smallest In Log K - Heap/Heap

    def medianSlidingWindow(self, nums: list[int], k: int) -> list[float]:

        # Treap (Randomized BST With Subtree Sizes)

        # Tree Representation:
        #   - Each node holds val, a random priority, the size of its subtree, and left and right children
        #   - BST order on val: everything in the left subtree is < val, everything in the right is >= val
        #   - Heap order on priority: a parent's priority is higher than its children's
        #   - Random priorities keep the expected height O(log k), so no rotations are needed
        #   - The tree holds exactly the k numbers of the current window

        # Idea:
        #   - The median only depends on the middle one or two values, so we need
        #     insert, delete, and "kth smallest" on the window
        #   - split(node, key) cuts a treap into values < key and values >= key
        #   - merge(a, b) joins two treaps where every value in a is <= every value in b
        #   - Insert: split at num, then merge left + new node + right
        #   - Delete one copy: split at num, split the right part at num + 1 to isolate the copies of num,
        #     drop the root of that middle part, then merge everything back
        #   - Kth smallest: walk down, comparing the index with the left subtree size
        #   - Deleting removes the real node, so there are no stale numbers and no size tracking by hand
        #   - Odd k: the median is the kth at index k // 2
        #   - Even k: the average of the kth at k // 2 - 1 and k // 2

        # Treap node
        class TreapNode:
            def __init__(self, val: int):
                self.val = val
                self.priority = random.random()
                self.size = 1
                self.left = None
                self.right = None

        # Helper that returns the size of a subtree
        def sizeOf(node) -> int:
            return node.size if node else 0

        # Helper that recomputes a node's size from its children
        def update(node) -> None:
            node.size = 1 + sizeOf(node.left) + sizeOf(node.right)

        # Helper that splits a treap into values < key and values >= key
        # tc: O(log k) expected
        def split(node, key: int):

            # Empty treap
            if not node:
                return None, None

            # Node belongs to the left part, split its right child
            if node.val < key:
                smaller, larger = split(node.right, key)
                node.right = smaller
                update(node)
                return node, larger

            # Node belongs to the right part, split its left child
            smaller, larger = split(node.left, key)
            node.left = larger
            update(node)
            return smaller, node

        # Helper that merges two treaps, every value in first is <= every value in second
        # tc: O(log k) expected
        def merge(first, second):

            # One side is empty
            if not first:
                return second
            if not second:
                return first

            # Higher priority becomes the root
            if first.priority > second.priority:
                first.right = merge(first.right, second)
                update(first)
                return first

            second.left = merge(first, second.left)
            update(second)
            return second

        # Helper that inserts one copy of num
        # tc: O(log k) expected
        def insert(root, num: int):

            # Cut at num, then slot the new node between the two parts
            smaller, larger = split(root, num)
            return merge(merge(smaller, TreapNode(num)), larger)

        # Helper that deletes one copy of num
        # tc: O(log k) expected
        def delete(root, num: int):

            # Values < num, and values >= num
            smaller, rest = split(root, num)

            # Values == num, and values > num
            equal, larger = split(rest, num + 1)

            # Drop the root of the equal part, which removes exactly one copy
            equal = merge(equal.left, equal.right)

            return merge(merge(smaller, equal), larger)

        # Helper that returns the kth smallest value, 0 indexed
        # tc: O(log k) expected
        def kthSmallest(root, index: int) -> int:

            # Current node
            node = root

            while node:

                # Size of the left subtree
                leftSize = sizeOf(node.left)

                # Target is in the left subtree
                if index < leftSize:
                    node = node.left

                # Target is this node
                elif index == leftSize:
                    return node.val

                # Target is in the right subtree, skip the left subtree and this node
                else:
                    index -= leftSize + 1
                    node = node.right

        # Number of elements
        n = len(nums)

        # Root of the treap holding the current window
        root = None

        # Fill the first window
        # tc: O(k log k)
        index = 0
        while index < k:

            # Add num to the window
            root = insert(root, nums[index])

            # Iterate array
            index += 1

        # Medians of each window
        result = []

        # Window Start Pointer
        windowStart = 0

        # tc: O(n log k), each slide is one delete, one insert, and one or two kth lookups
        while windowStart + k <= n:

            # Record the median:
            # window = [-1, 1, 3], k = 3 -> kth at index 1 -> 1
            # window = [1, 2],     k = 2 -> (kth at 0 + kth at 1) / 2 = 1.5
            # tc: O(log k)
            if k % 2 == 1:
                result.append(float(kthSmallest(root, k // 2)))
            else:
                result.append((kthSmallest(root, k // 2 - 1) + kthSmallest(root, k // 2)) / 2)

            # Slide the window, unless this was the last one
            if windowStart + k < n:

                # Remove the leaving number, add the entering number:
                # nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3
                #   window [1, 3, -1] -> remove 1, add -3 -> window [3, -1, -3]
                # tc: O(log k)
                root = delete(root, nums[windowStart])
                root = insert(root, nums[windowStart + k])

            # Iterate window
            windowStart += 1

        # overall: tc O(n log k) expected, random priorities keep the height O(log k)
        # overall: sc O(k) for the treap, O(n - k + 1) for the returned list
        return result

502. IPO ::2:: - Hard

Topics: Array, Greedy, Sorting, Heap (Priority Queue)

Intro

Suppose LeetCode will start its IPO soon. In order to sell a good price of its shares to Venture Capital, LeetCode would like to work on some projects to increase its capital before the IPO. Since it has limited resources, it can only finish at most k distinct projects before the IPO. Help LeetCode design the best way to maximize its total capital after finishing at most k distinct projects. You are given n projects where the ith project has a pure profit profits[i] and a minimum capital of capital[i] is needed to start it. Initially, you have w capital. When you finish a project, you will obtain its pure profit and the profit will be added to your total capital. Pick a list of at most k distinct projects from given projects to maximize your final capital, and return the final maximized capital. The answer is guaranteed to fit in a 32-bit signed integer.

Example InputOutput
k = 2, w = 0, profits = [1,2,3], capital = [0,1,1]4

Constraints:

1 ≤ k ≤ 10^5

0 ≤ w ≤ 10^9

n == profits.length

n == capital.length

1 ≤ n ≤ 10^5

0 ≤ profits[i] ≤ 10^4

0 ≤ capital[i] ≤ 10^9

Abstraction

Given starting capital w and up to k projects, each with a capital requirement and a profit, pick at most k projects (each once) to maximize final capital.

(greedy: finishing a project only adds profit and never removes capital, so among the projects you can afford right now, always take the one with the biggest profit, since it grows your capital the most and unlocks the most future projects)

(Two Heaps: split the projects by whether you can afford them, a minHeap of the projects you can't afford yet, keyed by capital, so the root is the next one to unlock, and a maxHeap of the projects you can afford, keyed by profit, so the root is the best one to take)

(Monotonic: w never decreases, so a project that is affordable stays affordable until it is taken, which means every project moves from the waiting heap to the ready heap at most once, and never moves back)

(Sort Variant: sort the projects by capital once, then a pointer replaces the waiting heap, since the next project to unlock is always the one at the pointer)

(Count the rounds) rounds = "how many projects have I taken so far? stop at k, or when nothing is affordable"

(Read the best affordable project) -maxHeap[0] = "what is the biggest profit among the projects I can afford right now?"

(Read the next project to unlock) minHeap[0][0] = "what is the smallest capital I still can't afford? once w reaches it, that project moves to the maxHeap"

Pseudocode

  oh! pseudocode hasn't been written yet, try another card! :)

Solution 1: [Two Heaps] MinHeap Of Unaffordable Projects By Capital And MaxHeap Of Affordable Projects By Profit - Heap/Heap

    def findMaximizedCapital(self, k: int, w: int, profits: list[int], capital: list[int]) -> int:

        # Two Heaps (Waiting MinHeap, Ready MaxHeap)

        # Heap Representation:
        #   - minHeap holds (capital, profit) for projects we can't afford yet
        #   - Root (minHeap[0]) is the cheapest project we still can't afford
        #   - maxHeap holds negated profits for projects we can afford right now
        #   - heapq is a minHeap, so negating profits makes the biggest profit the root
        #   - Heaps are valid when every project in minHeap needs more capital than w,
        #     and every project in maxHeap needs at most w

        # Idea:
        #   - Taking a project only adds capital, so among affordable projects
        #     the best choice is always the one with the biggest profit
        #   - Each round, move every project we can now afford (capital <= w)
        #     from minHeap to maxHeap, then take the root of maxHeap
        #   - Taking a project raises w, which may unlock more projects next round
        #   - w never decreases, so a project that is affordable stays affordable,
        #     and each project moves from minHeap to maxHeap at most once
        #   - If maxHeap is empty, nothing is affordable and w can't grow, so stop early
        #   - Stop after k projects

        # Number of projects
        n = len(profits)

        # MinHeap of (capital, profit) for projects not yet affordable
        minHeap = []

        # Project Pointer
        index = 0

        # Add every project to the waiting heap
        # tc: O(n)
        while index < n:

            # Add project
            minHeap.append((capital[index], profits[index]))

            # Iterate projects
            index += 1

        # Order the projects into a heap
        # tc: O(n)
        heapq.heapify(minHeap)

        # MaxHeap of negated profits for affordable projects
        maxHeap = []

        # Projects Taken
        rounds = 0

        # tc: O((n + k) log n), each project moves heaps at most once, and there are at most k takes
        while rounds < k:

            # Move every newly affordable project to the ready heap:
            # k = 2, w = 0, profits = [1, 2, 3], capital = [0, 1, 1]
            #   round 1: w = 0 -> (0, 1) affordable -> maxHeap [-1], take 1 -> w = 1
            #   round 2: w = 1 -> (1, 2) and (1, 3) affordable -> maxHeap [-3, -2], take 3 -> w = 4
            # tc: O(log n) per move
            while minHeap and minHeap[0][0] <= w:

                # Cheapest waiting project
                projectCapital, projectProfit = heapq.heappop(minHeap)

                # Add negated profit to the ready heap
                heapq.heappush(maxHeap, -projectProfit)

            # Nothing affordable, w can't grow, stop early
            if not maxHeap:
                break

            # Take the most profitable affordable project
            # tc: O(log n)
            w += -heapq.heappop(maxHeap)

            # Iterate rounds
            rounds += 1

        # overall: tc O((n + k) log n), heapify is O(n), each project moves at most once, at most k takes
        # overall: sc O(n) for the two heaps combined
        return w

Solution 2: [Sort] [Heap] MaxHeap Sort Projects By Capital Then Pointer Unlocks Projects Into A MaxHeap Of Profits - Heap/Heap

    def findMaximizedCapital(self, k: int, w: int, profits: list[int], capital: list[int]) -> int:

        # Sort + MaxHeap (Pointer Replaces The Waiting Heap)

        # Heap Representation:
        #   - projects is sorted by capital, so the next project to unlock is always at projectPointer
        #   - maxHeap holds negated profits for projects we can afford right now
        #   - Root (maxHeap[0]) is the negated biggest affordable profit
        #   - Everything before projectPointer has been unlocked, everything after needs more than w

        # Idea:
        #   - Same greedy as Solution 1: take the biggest profit among affordable projects
        #   - The waiting heap only exists to hand out projects in order of capital,
        #     so sorting by capital once does the same job
        #   - Sweep a pointer left to right, pushing each project with capital <= w into maxHeap
        #   - w never decreases, so the pointer never moves backward
        #   - Sorting costs the same O(n log n) as building the waiting heap,
        #     but the sweep is a plain pointer instead of heap pops, so it is simpler and has a smaller constant
        #   - If maxHeap is empty, nothing is affordable, so stop early

        # Number of projects
        n = len(profits)

        # Projects as (capital, profit) pairs in order of capital
        projects = []

        # Project Pointer
        index = 0

        # Pair up each project
        # tc: O(n)
        while index < n:

            # Add project
            projects.append((capital[index], profits[index]))

            # Iterate projects
            index += 1

        # Sort by capital
        # tc: O(n log n)
        projects.sort()

        # MaxHeap of negated profits for affordable projects
        maxHeap = []

        # Release Pointer (next project not yet unlocked)
        projectPointer = 0

        # Projects Taken
        rounds = 0

        # tc: O(n log n + k log n), n pushes total, at most k pops
        while rounds < k:

            # Unlock every project we can now afford:
            # projects = [(0, 1), (1, 2), (1, 3)], w = 1, projectPointer = 1
            #   (1, 2) -> push -2
            #   (1, 3) -> push -3, pointer reaches n
            # tc: O(log n) per push
            while projectPointer < n and projects[projectPointer][0] <= w:

                # Add negated profit to the ready heap
                heapq.heappush(maxHeap, -projects[projectPointer][1])

                # Iterate release pointer
                projectPointer += 1

            # Nothing affordable, w can't grow, stop early
            if not maxHeap:
                break

            # Take the most profitable affordable project
            # tc: O(log n)
            w += -heapq.heappop(maxHeap)

            # Iterate rounds
            rounds += 1

        # overall: tc O(n log n + k log n), dominated by the sort and the heap operations
        # overall: sc O(n) for the sorted projects and the heap
        return w