Jc-alt logo
jc

LeetCode: Heaps Priority Queue

LeetCode: Heaps Priority Queue
75 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

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 list of numbers, use a heap to track the kth highest score while streaming and return the value.

Pseudocode

  text will go here

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

class KthLargest:

    # MinHeap:
    # We need to track 2 things
    #   - The root min item currently in the stack, which represents kth highest
    #   - Number of items in the stack

    # For our functions, they may need to update the above 2

    # KthLargest():
    #   - Initializes the minHeap
    #   - Starts adding more numbers

    # Add():
    #   - Adds a number to the minHeap
    #   - Returns the kth largest element up to that point

    def __init__(self, k: int, nums: list[int]):
        
        # MinHeap of size k:
        # will contain the k largest elements seen so far,
        # lowest top score at root which represents the kth highest number

        # limiting size of minHeap to be size k    
        self.k = k

        # minHeap
        self.minHeap = []
        
        # mimic 'streaming' input data into minHeap
        for num in nums:
            self.add(num)
    
    # tc:
    # sc: 
    def add(self, val: int) -> int:

        # Attempt to add element to stack
        #   - if minHeap has less elements than k, simply add new element
        #   - if minHeap is out of space, push only if new score is higher than 
        #     current lowest score at the root

        # Heap still has space, add element
        if len(self.minHeap) < self.k:
            heapq.heappush(self.minHeap, val)

        # Heap is out of space, check against min element
        else:
            # Grab min element
            smallestTopScore = self.minHeap[0]

            # Add score if its higher than current lowest element
            if val > smallestTopScore:
                # Replace root element
                heapq.heapreplace(self.minHeap, val)
        
        # return top k score
        return self.minHeap[0]

    # overall: tc
    # overall: sc 

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 unsorted array, find the kth largest element in sorted order.

Pseudocode

  text will go here

Solution 1: Min Heap of Size k - Heap/Heap

    def findKthLargest(self, nums: list[int], k: int) -> int:
        # Note:
        # 1. MinHeap of size k
        # Result: root of minHeap contains kth largest element
        
        # heap
        minHeap = []
        
        # for each element
        for num in nums:

            # if heap has space, simply push
            if len(minHeap) < k:
                heapq.heappush(minHeap, num)

            # if heap has no space, check if push is necessary
            else:
                # push if new num is larger than smallest in minHeap,
                # this will force a new kth largest element
                if num > minHeap[0]:
                    heapq.heapreplace(minHeap, num)
        
        # kth largest is at the root of the minHeap
        kth = minHeap[0]

        # overall: time complexity
        # overall: space complexity
        return kth

Solution 2: Modified Ignore Duplicates Quick Select - Heap/Heap

    def findKthLargest(self, nums: List[int], k: int) -> int:
        
        # Note:
        # Optimized Quick Select which will remove 1 subList of duplicates per iteration 
        # 1. RandPivot
        # 2. Partition nums by creating larger, equal, or smaller than pivot subLists
        # 3. Recurse into the correct subList
        # Result: kth largest found while accounting for potential stream of duplicates

        # Empty check
        if not nums: 
            return
        
        # random pivot
        pivot = random.choice(nums)
        
        # Partition nums into three parts:
        # bigger: elements greater than pivot
        bigger = [num for num in nums if num > pivot]
        
        # if kth is within bigger, recurse
        if k <= len(bigger):
            return self.findKthLargest(bigger, k)
        
        # Duplicate Optimization 
        # equal: elements equal to pivot
        equal = [num for num in nums if num == pivot]
        
        # If kth largest is within biggest + equal count,
        # kth will be within partition duplicate list
        if k <= len(bigger) + len(equal):
            return equal[0]
        
        # kth must be within smaller list, recurse
        # update k to ignore length of bigger and equal list
        smaller = [num for num in nums if num < pivot]
        return self.findKthLargest(smaller, k-len(bigger)-len(equal))

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 a array of strings representing integers, find the kth largest element in sorted order.

Pseudocode

  text will go here

Solution 1: Min Heap of Size k With Numeric Comparator - Heap/Heap

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

        # Note:
        # 1. MinHeap of size k, same shape as 215's heap solution
        # 2. The trap: these are STRINGS representing potentially huge
        #    integers (up to 100 digits), so raw string comparison
        #    ("10" < "9") is WRONG -- we must compare by (length, value)
        #    since equal-length numeric strings DO compare correctly
        #    lexicographically, and longer strings are always larger
        #    (no leading zeros guaranteed by constraints)
        # Result: root of minHeap contains kth largest element

        # Comparator key: (length, string) sorts numerically correct --
        # longer digit strings are bigger numbers, and same-length
        # digit strings compare the same lexicographically as numerically
        def numKey(numStr: str):
            return (len(numStr), numStr)

        # heap holds (comparable key, original string) pairs so heapq
        # compares using our numeric key instead of raw string order
        minHeap = []

        # tc: O(n log k), each of n elements does at most one O(log k) op
        for numStr in nums:
            key = numKey(numStr)

            # if heap has space, simply push
            if len(minHeap) < k:
                heapq.heappush(minHeap, (key, numStr))

            # if heap has no space, check if push is necessary
            else:
                # push if new num is larger than smallest in minHeap,
                # this will force a new kth largest element
                if key > minHeap[0][0]:
                    heapq.heapreplace(minHeap, (key, numStr))

        # kth largest is at the root of the minHeap
        kth = minHeap[0][1]

        # overall: tc O(n log k), n = len(nums)
        # overall: sc O(k), for the heap
        return kth

Solution 2: Modified Ignore Duplicates Quick Select With Numeric Comparator - Heap/Heap

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

        # Note:
        # Same duplicate-aware Quick Select shape as 215's Solution 2,
        # but comparisons use (length, value) instead of raw numeric
        # comparison, since these are strings representing potentially
        # huge integers that Python ints could also handle directly --
        # comparing by (len, str) avoids the overhead of int() conversion
        # on up to 10^4 strings of up to 100 digits each.
        # 1. RandPivot
        # 2. Partition nums by creating larger, equal, or smaller than pivot subLists
        # 3. Recurse into the correct subList
        # Result: kth largest found while accounting for potential stream of duplicates

        def numKey(numStr: str):
            return (len(numStr), numStr)

        def quickSelect(numsList: List[str], k: int) -> str:

            # Empty check
            if not numsList:
                return ""

            # random pivot
            pivot = random.choice(numsList)
            pivotKey = numKey(pivot)

            # Partition numsList into three parts:
            # bigger: elements greater than pivot
            bigger = [num for num in numsList if numKey(num) > pivotKey]

            # if kth is within bigger, recurse
            if k <= len(bigger):
                return quickSelect(bigger, k)

            # Duplicate Optimization
            # equal: elements equal to pivot
            equal = [num for num in numsList if numKey(num) == pivotKey]

            # If kth largest is within biggest + equal count,
            # kth will be within partition duplicate list
            if k <= len(bigger) + len(equal):
                return equal[0]

            # kth must be within smaller list, recurse
            # update k to ignore length of bigger and equal list
            smaller = [num for num in numsList if numKey(num) < pivotKey]
            return quickSelect(smaller, k - len(bigger) - len(equal))

        # overall: tc O(n) average case, O(n^2) worst case (rare with
        #          random pivot); n = len(nums)
        # overall: sc O(n) average, for the recursive partition lists
        return quickSelect(nums, k)

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

To find the k most frequent elements, we must first create an occurrence counter for each element in the list. Now that we have the count, we just grab the top k highest occurring elements.

Pseudocode

  text will go here

Solution 1: MinHeap Track K Elements - HashMap/Algorithm

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

        # MinHeap: 
        # Heap only guarantees that the root is the min or max, and
        # provides no guarantee about the relative order of nodes. 
        # The minHeap root will hold the smallest element,
        # as we iterate we will add elements, 
        # and we only remove the root/smallest element when heap exceeds size k.
        # This ensures the heap will hold the k most frequent
        # elements we have seen so far

        # sc: freq count for list, m unique elements in worst case O(n)
        freq = defaultdict(int)

        # Calculate freq count for each element
        # tc: iterate over list O(n)
        for num in nums: 
            freq[num] += 1

        # sc: minHeap holds smallest k elements O(k)
        minHeap = []

        # tc: iterate over freq count, m unique elements in worst case O(n)
        for (num, freq) in freq.items(): 

            # tc: push operation O(log n)
            heapq.heappush(minHeap, (freq, num)) 
            
            # if heap grows to size k + 1, pop least freq root so we keep the k most freq elements
            if len(minHeap) > k:
t6
                # tc: pop smallest element O(log k)
                heapq.heappop(minHeap) 
        

        # alternative expanded loop:
        # tc: grab top k occurring elements from minHeap, worst case grab n elements O(n)
        result = []
        for (_, num) in minHeap:
            result.append(num) 

        # alternative shorter notation
        # result = [num for freq, num in minHeap]

        # overall: tc O(n log n)
        # overall: sc O(n)
        return result
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
Iterate ListO(n)O(n)Iterate over list O(n)Allocation for n elements O(n)
Iterate Freq Counts + MinHeapO(n log k)O(k)Iterate over counts * Push/BubbleUp, Pop O(log k) for heap of size kAllocation for heap of max k elements O(k)
MinHeap Grab kO(k)O(k)Iterate over minHeap of k size O(k)Result list of size k O(k)
OverallO(n log k)O(n)Iterating over freq counts * heap operations dominate, O(n log k)Allocation for freq count of n elements dominates, O(n)

Solution 2: QuickSelect BinarySearch High to Low Sort [TC Opt] - Hashmap/Algorithm

    def topKFrequent(self, nums: List[int], k: int) -> List[int]:
    
        # QuickSelect Modified QuickSort:
        # QuickSort sorts based on a partitioned element, whereas when QuickSort
        # complete, everything to the left of the partitioned element is less than it
        # and everything to the right of the partitioned element is greater than it.
        # QuickSelect uses a finalPartitionMarker marks the final expected correct position, 
        # where at which point it stops and elements to the left and right of finalPartitionMarker
        # are guaranteed to be smaller/larger.
        # This avoids sorting the entire array by only sorting as many elements until the 
        # finalPartitionMarker is in the correct place which
        # isolates the minimum elements needed to complete the task

        # Helper: 
        # In place partition of subarray [left,right] relative to random pivot index value
        def partitionSection(left, right, randPivotElemIndex):
            
            # tc: subarray m elements, n in worst case O(n)
            
            # Frequency count of random pivot element
            pivotElemFreq = frequency[unique[randPivotElemIndex]]

            # Move random pivot element to end
            # Tuple unpacking in python allows swapping two variables
            unique[randPivotElemIndex], unique[right] = unique[right], unique[randPivotElemIndex]
            
            # Left index to partition/move larger elements to the left boundary of subarray
            partitionIndex = left

            # tc: iterate over subarray, n in worst case O(n)
            for i in range(left, right):

                # Partition elements with frequencies above the pivotElem Freq (pivotElemFreq < freq),
                # move them to left (move larger elements greater than the pivot to the left of the pivot)
                if pivotElemFreq < frequency[unique[i]]:  
                    
                    # Slicing:
                    # Placing larger freq element on left [high... low] 
                    # allows [:k] to grab the first top k freq elements
                    unique[i], unique[partitionIndex] = unique[partitionIndex], unique[i]
                    partitionIndex += 1

            # set pivot to correct partitioned index,
            # elements left and right of pivot follow [greater... pivot ... lesser]
            unique[partitionIndex], unique[right] = unique[right], unique[partitionIndex]
            return partitionIndex

        # Helper: 
        # Wrapper function that continues to pick random partition element until we have 
        # placed some element at the "finalPartitionMarker" index
        # tc: average recursion depth O(log m)
        # sc: recursion stack for in place partitioning on average O(log m)
        def quickSelectBinarySearchHelper(left, right, finalPartitionMarker):
            
            # Base Case:
            if left == right:
                return

            # Random pivot index:
            # Differs from QuickSort "median-of-three" approach.
            # Random pivot focuses on finding the kth smallest or largest,
            # rather than fully sorting the array, and using random pivot avoids 
            # degrading to worst case O(n^2)
            randPivotElemIndex = random.randint(left, right)

            # Partition subarray
            resultPivotElemIndex = partitionSection(left, right, randPivotElemIndex)
            
            # Base Case: Random partition marker ended up in correct place
            if finalPartitionMarker == resultPivotElemIndex:
                # -> allows us to grab k top freq count by [:k]
                return 
            
            # Binary Search Modification:
            # Selects next subarray and partition pivot
            # resultPivotElemIndex must recurse towards side where finalPartitionMark is on

            # Result was to the right of final expected, 
            # search to the left of the result: [left, resultPivot-1]
            elif finalPartitionMarker < resultPivotElemIndex:
                quickSelectBinarySearchHelper(left, resultPivotElemIndex - 1, finalPartitionMarker)
            
            # Result was to the left of final expected,
            # search to the right of the result: [resultPivot+1, right]
            else:
                quickSelectBinarySearchHelper(resultPivotElemIndex + 1, right, finalPartitionMarker)

        # Calculate freq count for each element
        # tc: iterate over list O(n)
        # sc: allocate for list, n unique elements worst case O(n)
        frequency = defaultdict(int)
        for num in nums:
            frequency[num] += 1

        # Top Kth Elements Indexing: Top 2 elements are indexes [0, 1]
        # [9 7 4 3 1]
        #  0 1 2 3 4
        finalPartition = k - 1

        # Unique count to sort by during partitioning
        unique = list(frequency.keys())
        n = len(unique)
        l, r = 0, n - 1

        # For High to Low (descending) indexing:
        # with list of 6 elements, grab largest 2 elements
        # 2 - 1 = 1 (our 2nd largest)
        # splice [:2] = [0, 1]
        quickSelectBinarySearchHelper(l, r, finalPartition)

        # Pivot is now in the correct partitioned index.
        # Elements left and right of pivot follow [greater... pivot ... lesser]
        # Slice the first k to grab the top k elements

        # overall: tc O(n)
        # overall: sc O(n)
        return unique[:k]
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
Iterate ListO(n)O(n)Iterate over list O(n)Allocation for n elements O(n)
QuickSort PartitionAverage: O(n) Worst: O(n2)O(1)Good pivot splits array in half on average O(n), Bad pivot each iteration, smallest or largest, causes O(n2)Partition of freq count array occurs in place O(1)
QuickSort RecursionO(k)O(k)Iterate over buckets for k steps O(k)Result list of size k O(k)
Result SpliceO(k)O(k)Splicing array for first k elementsResult list of k elements
OverallAverage: O(n) Worst: O(n2)O(n)Bad pivot partition dominates O(n2)Allocation for freq count of n elements dominates, O(n)

Solution 3: BucketSort by Count [TC Opt] - Hashmap/Algorithm

    def topKFrequent(self, nums: List[int], k:int) -> List[int]:
        
        # BucketSort:
        # Grouping values by freq count into buckets.
        # Allows us to iterate over most freq buckets and grab top k elements

        # Calculate freq count for each element
        # tc: iterate over list of n integers O(n)
        # sc: frequency count for unique integers O(m) 
        count = defaultdict(int)
        for key in nums:
            count[key] += 1

        # numBuckets index = items with that frequency count
        # numBuckets must account for max freq count case, where list is full of only 1 element
        # numBuckets must account for empty list, (but still needs a bucket index 0), so we need
        # len(num)+1,
        # empty: 1 bucket: [0] representing empty group
        # else: list of len 5, 6 buckets: [0, 1, 2, 3, 4, 5] each representing length group,
        # with max freq count of 5
        numBuckets = len(nums) + 1

        # list of empty lists, numBucket amount of times 
        # tc: iterate over list length O(n)
        # sc: create len(list)+1 buckets O(n)
        freqBuckets = [[] for i in range(numBuckets)]

        # tc: iterate over frequency list for m unique integer tuples (int, occurrences) O(m) 
        for int, occurrences in count.items():
            freqBuckets[occurrences].append(int)

        # sc: grabbing top k integers, n worst case O(k)
        res = []

        # tc: iterate over len(list)+1 buckets O(n)
        for i in range(len(freqBuckets) - 1, 0, -1):
            
            # tc: iterate over all elements in curr bucket O(m)
            for num in freqBuckets[i]:
                
                # tc: insert operation O(1)
                res.append(num)
                
                # tc: continue while less than k elements grabbed O(k)
                if len(res) == k:
                    return res

        # overall: tc O(n)
        # overall: sc O(n)
        return res
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
Iterate listO(n)O(n)Iteration over list O(n)Allocation for n elements O(n)
Bucket CreationO(n)O(n)Iterate len(list)+1 steps O(n)Allocate len(list)+1 buckets O(n)
Bucket PopulationO(n)O(n)Inserting n integers into buckets O(n)Buckets store n integers O(m)
Bucket Grab kO(k)O(k)Iterate over buckets for k steps O(k)Result list of size k O(k)
OverallO(n)O(n)Iterating over list dominates, O(n)Allocating n buckets dominates, O(n)

658. Find K Closest Elements ::2:: - Medium

Topics: Array, Binary Search, Sliding Window, Prefix Sum

Intro

Given a sorted integer array arr, two integers k and x, return the k closest integers to x in the array. The result should also be sorted in ascending order. An integer a is closer to x than an integer b if:

  • |a - x| lt |b - x|, or
  • |a - x| == |b - x| and a lt b
Example InputOutput
arr = [1,2,3,4,5], k = 4, x = 3[1,2,3,4]
arr = [1,1,2,3,4,5], k = 4, x = -1[1,1,2,3]

Constraints:

1 ≤ k ≤ arr.length

1 ≤ arr.length ≤ 10^4

arr is sorted in ascending order

-10^4 ≤ arr[i], c ≤ 10^4

Abstraction

wow! again

Pseudocode

  text will go here

Solution 1: [Sliding Window] Simple Window Version - Sliding Window/Variable Size Window

    def findClosestElements(self, arr, k, x):
        
        n = len(arr) - 1

        # Sliding Window Boundaries
        # sc: O(1)
        left = 0
        right = n

        currWindowLen = right - left + 1

        # Shrink window from n down to k elements
        # At each step, remove the element farther from x
        while k < currWindowLen:

            # value difference from left and right nums to target x
            leftNum = arr[left]
            leftValue  = abs(leftNum - x)

            rightNum = arr[right]
            rightValue = abs(rightNum - x)

            # Between left and right, remove farther num from target
            # If equal, default to remove right
            # (keep left, problem prefers smaller)
            if leftValue <= rightValue:
                right -= 1
            else:
                left += 1

            # Check new window length
            currWindowLen = right - left + 1

        # left is now at the best location
        res = arr[left:(right + 1)]

        # overall: tc O()
        # overall: sc O()
        return res
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Solution 2: [Sliding Window] Binary Search To Create Sliding Window - Sliding Window/Variable Size Window

    def findClosestElements(self, arr: List[int], k: int, x: int) -> List[int]:
                
        # Binary Search + Sliding Window Approach

        # Idea:
        # 1. The closest k elements must form a contiguous subarray of length k.
        # 2. Use binary search to find the left boundary of that window.
        # 3. Compare distances from x for potential windows, and shrink towards the closest.
        
        n = len(arr)
        
        # Binary search boundaries for left index of window
        left = 0
        right = n - k  # window of size k, so left can go at most n-k
        
        # Binary search over possible left boundaries
        # tc: O(log(n-k))
        while left < right:

            # grab mid
            mid = (left + right) // 2

            # Compare distances of window edges to x
            leftEdgeDist  = x - arr[mid]
            rightEdgeDist = arr[mid + k] - x

            # Left edge is farther from x than the right edge
            # Shift left boundary to get left edge closer
            # tc: O(1)
            if rightEdgeDist < leftEdgeDist:
                left = mid + 1

            # Right edge is farther or equal to from x than the left edge
            # Shift left boundary to get right edge closer
            # tc: O(1)
            else:
                right = mid

        # Left now points to optimal left boundary because:
        #   - Every position to the left was eliminated (left edge was too far)
        #   - Every position to the right was eliminated (right edge was too far)
        #   - left == right means only one candidate remains that was never eliminated
        #   - This is the position where the window [left, left+k] is closest to x
        # Splice window of size k
        # tc: O(k)
        res = arr[left:left + k]

        # overall: tc O(log(n-k) + k)
        # overall: sc O(k)
        return res
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

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

Abstract

Treat this as merging len(nums1) sorted lists, where list i is nums1[i] paired with every element of nums2 in order. Use a min-heap to always expand the pair with the smallest sum next, seeding only the first k rows of nums1 to keep the heap bounded.

Pseudocode

  text will go here

Solution 1: [Heap] Min-Heap k-Way Merge, Bounded Seeding - Heap/Priority Queue K Pairs With Smallest Sums

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

        # Min-Heap as k-way Merge:
        # Fix i and vary j: for a fixed nums1[i], the sums
        # nums1[i] + nums2[0], nums1[i] + nums2[1], ... are non-decreasing
        # since nums2 is sorted. So each i defines its own sorted "list"
        # of candidate sums. Finding the k smallest across all these
        # lists is the same k-way merge pattern as Kth Smallest in a
        # Sorted Matrix and Merge K Sorted Lists.

        # Why we only seed min(len(nums1), k) rows:
        # We only ever need at most k pairs total, so there's no reason
        # to seed more than k starting candidates -- any row beyond the
        # kth smallest nums1[i] value can't contribute to the first k
        # results before at least one smaller-sum pair is exhausted first.
        # This keeps the heap size, and therefore each op's cost, bounded
        # by O(min(n, k)) instead of O(n).

        if not nums1 or not nums2 or k == 0:
            return []

        n1, n2 = len(nums1), len(nums2)
        result = []

        # sc: O(min(n1, k)), one "frontier" entry per seeded row
        # Seed: pair each of the first min(n1, k) elements of nums1
        # with nums2[0], the smallest possible partner for that row
        heap = [(nums1[i] + nums2[0], i, 0) for i in range(min(n1, k))]
        heapify(heap)

        # tc: O(k log(min(n1, k))), k pops, each heap op O(log(min(n1,k)))
        while heap and len(result) < k:
            total, i, j = heappop(heap)
            result.append([nums1[i], nums2[j]])

            # Advance:
            # this row's next element becomes the new candidate for
            # row i, maintaining the "one frontier per row" invariant
            if j + 1 < n2:
                heappush(heap, (nums1[i] + nums2[j + 1], i, j + 1))

        return result

        # overall: tc O(k log(min(n1, k)))
        # overall: sc O(min(n1, k)), for the heap and seeded rows

786. K-th 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

Abstract

For each numerator index i, fixing i and shrinking the denominator index j toward i produces a strictly increasing sequence of fractions (smaller denominator, same numerator = bigger fraction). Treat this as merging those n increasing "rows" with a min-heap, seeded at the smallest fraction in each row: arr[i] / arr[n-1].

Pseudocode

  text will go here

Solution 1: [Heap] Min-Heap k-Way Merge Over Shrinking Denominators - Heap/Priority Queue Kth Smallest Prime Fraction

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

        # Min-Heap as k-way Merge:
        # For a fixed numerator index i, as the denominator index j
        # shrinks from n-1 down toward i+1, arr[j] gets smaller while
        # arr[i] stays fixed -- so arr[i]/arr[j] strictly increases.
        # That means each numerator index i defines its own "row" of
        # increasing fractions, exactly like the sorted-matrix merge
        # pattern from problem 378, just walking denominators backward
        # instead of forward.

        # Why seed at j = n-1:
        # arr[i]/arr[n-1] is the SMALLEST fraction for a given i, since
        # n-1 is the largest possible denominator index. Seeding every
        # row at its smallest element is what lets the heap always
        # offer the true global minimum next.

        n = len(arr)

        # sc: O(n), one entry per row: (fraction, i, j)
        # Seed the heap with the smallest fraction for each numerator
        # index i (paired with the largest denominator, arr[n-1])
        heap = [(arr[i] / arr[n - 1], i, n - 1) for i in range(n - 1)]
        heapify(heap)

        # Pop the smallest k-1 times, discarding each -- the kth pop
        # tc: O(k log n), each pop/push is O(log n) on a heap of size n
        for _ in range(k-1):
            frac, i, j = heappop(heap)

            # Advance:
            # shrink this row's denominator index by one, producing
            # the next-larger fraction for the same numerator index i
            if j - 1 > i:
                heappush(heap, (arr[i] / arr[j - 1], i, j - 1))

        # The kth pop is the answer
        _, i, j = heap[0]
        return [arr[i], arr[j]]

        # overall: tc O((n + k) log n) -- seeding is O(n log n),
        #          k pops/pushes are O(k log n)
        # overall: sc O(n), for the heap

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 a list of point on a X-Y grid, find the k closest points to the origin.

Pseudocode

  text will go here

Solution 1: Max Heap of size k - Heap/Heap

    def kClosest(self, points: list[list[int]], k: int) -> list[list[int]]:
        
        # Note:
        # 1. We want the k points with the smallest Euclidean distances from (0, 0).
        # 2. Instead of computing sqrt(x^2 + y^2), we can use the squared distance x^2 + y^2
        #    because sqrt is monotonic and doesn't change order.
        # 3. Approach:
        #    - Use a max-heap (store negative distances) of size k.
        #    - Iterate over points:
        #         if heap size < k: push (-distance, point)
        #         else if current distance < largest distance in heap: pop and push
        #    - This ensures we always keep k closest points in O(n log k) time.

        # (-distance, [x, y])
        maxHeap = []

        # add all distances to maxHeap
        for x, y in points:

            # square distance
            dist = x * x + y * y 

            # if maxHeap has space, simply push
            if len(maxHeap) < k:
                heapq.heappush(maxHeap, (-dist, [x, y]))

            # if maxHeap is full, check if push is required
            else:

                # if closer than farthest point
                if dist < -maxHeap[0][0]:
                    heapq.heapreplace(maxHeap, (-dist, [x, y]))
        
        # grab list of coords
        res = [xy for (_, xy) in maxHeap]

        # overall: time complexity
        # overall: space complexity
        return res

Solution 2: QuickSelect - Heap/Heap

    def kClosest(self, points: list[list[int]], k: int) -> list[list[int]]:
        
        # Note:
        # Classic Quick Select but with square distance
        # sqrt() is monotonic so we can ignore it
        
        # squared distance to origin
        def dist(i: int) -> int:
            return points[i][0] ** 2 + points[i][1] ** 2
        
        def partition(left, right, randPivotIndex) -> int:

            # prepare partition
            randPivotDist = dist(randPivotIndex)
            points[randPivotIndex], points[right] = points[right], points[randPivotIndex]
            leftPartition = left
            
            # partition all closer points to left
            for i in range(left, right):

                # if point is closer to origin (lower)
                if dist(i) < randPivotDist:
                    points[leftPartition], points[i] = points[i], points[leftPartition]
                    leftPartition += 1
            
            # restore pivot
            points[right], points[leftPartition] = points[leftPartition], points[right]

            # return
            return leftPartition
        
        def quickselect(left, right, finalIndex):
            
            # Base case:
            if left >= right:
                return
            
            # random pivot and result
            randPivotIndex = random.randint(left, right)
            resPivot = partition(left, right, randPivotIndex)
            
            # target check
            if resPivot == finalIndex:
                return
            
            elif resPivot < finalIndex: 
                quickselect(resPivot + 1, right, finalIndex)

            else:
                quickselect(left, resPivot - 1, finalIndex)
            
        
        # boundary
        n = len(points)

        # quickSelect on bounds
        quickselect(0, n - 1, k)

        # grab lowest k points
        res = points[:k]

        # overall: time complexity
        # overall: space complexity
        return res

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

something about max

Pseudocode

  text will go here

Solution 1: [Heap] Min-Heap Merge of Sorted Rows - Heap/Priority Queue Kth Smallest in Sorted Matrix

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

        # Min-Heap as k-way Merge:
        # Since every row is individually sorted, the matrix is really
        # n separate sorted lists. Finding the kth smallest across
        # multiple sorted lists is the classic "merge k sorted lists"
        # pattern -- push each list's current smallest onto a min-heap,
        # pop the overall smallest, then push that same row's next
        # element as its new candidate.

        # Why we only ever need one heap entry per row:
        # We never need to consider a row's 2nd element before its 1st
        # has been popped, since rows are sorted -- so the heap never
        # needs more than n entries at once (one "frontier" per row),
        # giving O(n) space instead of O(n^2).

        n = len(matrix)

        # sc: O(n), one entry per row: (value, row, col)
        # Seed the heap with the first (smallest) element of each row
        heap = [(matrix[row][0], row, 0) for row in range(n)]
        heapify(heap)

        # Pop the smallest k-1 times, discarding each -- the kth pop
        # tc: O(k log n), each pop/push is O(log n) on a heap of size n
        for _ in range(k-1):
            val, row, col = heappop(heap)

            # Advance:
            # this row's next element becomes the new candidate,
            # maintaining the "one frontier per row" invariant
            if col + 1 < n:
                heappush(heap, (matrix[row][col + 1], row, col + 1))

        # The kth pop is the answer
        return heap[0][0]

        # overall: tc O((n + k) log n) -- seeding is O(n log n),
        #          k pops/pushes are O(k log n)
        # overall: sc O(n), for the heap

Solution 2: [Binary Search on Value] Count-Less-Equal via Sorted Structure - Heap/Binary Search Kth Smallest in Sorted Matrix

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

        # Binary Search on the ANSWER, not the index:
        # We don't binary search positions in the matrix -- we binary
        # search over the range of possible VALUES [matrix[0][0], matrix[n-1][n-1]].
        # For any candidate value x, we can count how many elements are
        # <= x using the sorted rows/columns, then narrow the range until
        # it collapses onto the actual kth smallest value.

        # Why counting is fast (Staircase / Saddleback Search):
        # Start at the bottom-left corner. If the current element is
        # <= x, every element above it in that column is also <= x
        # (columns sorted ascending upward), so we count the whole
        # column at once and move right. If it's > x, that whole row
        # to the right is > x too, so we move up. Each step moves
        # either right or up, at most 2n steps total -- O(n) per count.

        n = len(matrix)

        def countLessEqual(x: int) -> int:
            count = 0
            row, col = n - 1, 0  # start bottom-left

            # tc: O(n), at most n moves right + n moves up
            while row >= 0 and col < n:
                if matrix[row][col] <= x:
                    # entire column above (row, col) is also <= x
                    count += row + 1
                    col += 1
                else:
                    # entire row to the right is > x
                    row -= 1

            return count

        lo, hi = matrix[0][0], matrix[n - 1][n - 1]

        # Binary Search:
        # narrow [lo, hi] until it collapses onto the smallest value
        # whose count-less-equal is >= k -- that value is guaranteed
        # to actually be in the matrix
        # tc: O(log(hi - lo)), each iteration does an O(n) count
        while lo < hi:
            mid = lo + (hi - lo) // 2

            if countLessEqual(mid) >= k:
                hi = mid
            else:
                lo = mid + 1

        return lo

        # overall: tc O(n log(hi - lo)) -- effectively O(n log(max-min))
        # overall: sc O(1), only pointers and counters, no extra storage

Solution 3: [Advanced] True O(n) via Frederickson Johnson Selection - Heap/Algorithm

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

        # NOTE: This is a best-effort, simplified reconstruction of the
        # Frederickson-Johnson style approach -- it captures the core
        # idea (recursively eliminate a constant fraction of the matrix
        # using a cleverly chosen pivot) but is NOT a verified,
        # line-for-line implementation of the original paper. Treat it
        # as educational rather than production-grade.

        n = len(matrix)

        def countLessEqual(x: int) -> int:
            # Same O(n) staircase count from Solution 2
            count = 0
            row, col = n - 1, 0
            while row >= 0 and col < n:
                if matrix[row][col] <= x:
                    count += row + 1
                    col += 1
                else:
                    row -= 1
            return count

        def medianOfSampledPivots() -> int:
            # Pivot Selection:
            # Sample the middle element of every row (each row is
            # already sorted, so its middle element is that row's
            # median) and take the median of THOSE medians. This gives
            # a pivot that is likely to be reasonably central across
            # the whole matrix, in O(n) time -- analogous to the
            # "median of medians" trick from 1D linear-time selection.
            rowMedians = sorted(matrix[row][n // 2] for row in range(n))
            return rowMedians[n // 2]

        lo, hi = matrix[0][0], matrix[n - 1][n - 1]

        # Recursive Elimination:
        # Instead of halving the VALUE range blindly (binary search),
        # pick pivots that are informed by the matrix's actual median
        # structure, so each round's count-less-equal check tends to
        # eliminate a large, roughly-constant fraction of remaining
        # candidates rather than just halving an abstract numeric range.
        # In the true FJ algorithm this fraction is tightly bounded,
        # which is what yields the O(n) total across all rounds; this
        # simplified version doesn't guarantee that bound as rigorously.
        while lo < hi:
            pivot = medianOfSampledPivots()
            pivot = max(lo, min(hi, pivot))

            countLE = countLessEqual(pivot)

            if countLE >= k:
                hi = pivot
            else:
                lo = pivot + 1

        return lo

        # intended: tc ~O(n) via constant-fraction elimination per round
        # actual (unverified): closer to O(n log(max-min)) in the worst
        #   case, since pivot quality here isn't rigorously bounded the
        #   way the paper's construction guarantees
        # sc: O(n) for medianOfSampledPivots' temporary array

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 sorted linked lists, combine into one sorted linked list.

Pseudocode

  text will go here

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Solution 1: [Linked List] [MinHeap] Single MinHeap To Do Something Priority Queue - Linked List/Simple Traversal

    def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
        
        # Note:
        # 1. minHeap stores current head for each sorted list
        # 2. minHeap sorted by value, list_index breaking ties: 
        #    (value, list_index, node)
        # 3. smallest value across heads added to list
        # 4. push to minHeap to replaced added node
        # Result: merge list in O(n log k)

        # MinHeap():
        #   - O(log n) insertion and removal)

        # MinHeap data representation: (value, index, node)
        #   - Need the value to validate min
        #   - Need index to check if ___
        #   - Need the node to modify connections for new linked list
        minHeap = []

        # For each non empty list,
        # push the head into the heap
        for i, head in enumerate(lists):
            if head:
                heappush(minHeap, (head.val, i, head))  

        # MinHeap() After:
        # Contains the first node of each of the lists,
        # with the min one, at the top of the minHeap

        # DummyHead to keep track of new head
        dummyHead = ListNode(-1)
        prev = dummyHead

        # While the minHeap still has nodes from lists
        #   - pop the smallest from the heap and add to the new list
        #   - grab a new node from the list we just lost a node from
        # tc: O(n log k)
        while minHeap:

            # Current min node
            (minVal, i, node) = heappop(minHeap)

            # Point new list to curr node, and iterate
            prev.next = node
            prev = prev.next

            # If curr min node, has more nodes in their list
            if node.next:
                # Grab next node from list and add to minHeap
                heappush(minHeap, (node.next.val, i, node.next))

        # minHeap Empty:
        # all lists are empty, final list has been merged

        mergedListHead = dummyHead.next

        # overall: tc O(n log k)
        # overall: sc O(k)
        return mergedListHead
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Solution 2: [Linked List] [Divide And Conquer] Iterative Divide And Conquer Merging 2 Lists Per Round So Log() [SC Opt] - Linked List/Simple Traversal

    def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
        
        # Note:
        # 1. Merge lists in pairs with standard merge two sorted lists
        # 2. Each round halves the total number of lists -> O(log k) rounds
        # 3. Each round processes all nodes (across all merges) -> O(n) work per round
        # Result: Merging k lists in place
        
        # Empty check
        if not lists: 
            return None

        # Merge two sorted linked lists
        # time complexity: iterate over lists of n length O(n)
        def mergeTwo(l1, l2):

            # dummy node trick
            dummy = ListNode(-1)
            prev = dummy

            # merge while both lists non empty
            while l1 and l2:
                # grab smaller head
                if l1.val < l2.val:
                    prev.next = l1
                    l1 = l1.next
                else:
                    prev.next = l2
                    l2 = l2.next
                # iterate
                prev = prev.next

            # attach remaining list 
            if not l1:
                prev.next = l2 
            else:
                prev.next = l1

            # return merged new merged list
            return dummy.next

        # iteratively merge lists in pairs until one list remains
        # time complexity: log(len(list)) total merges
        while len(lists) > 1:
            merged = []
            for i in range(0, len(lists), 2):
                l1 = lists[i]
                l2 = lists[i + 1] if i + 1 < len(lists) else None
                merged.append(mergeTwo(l1, l2))
            lists = merged

        # overall: time complexity O(n log k)
        # overall: space complexity O(1)
        return lists[0]
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

912. Sort an Array ::2:: - Medium

Topics: Array, Divide and Conquer, Sorting, Heap (Priority Queue), Merge Sort, Bucket Sort, Radix Sort, Counting Sort

Intro

Given an array of integers nums, sort the array in ascending order and return it. You must solve the problem without using any built-in functions in O(nlog(n)) time complexity and with the smallest space complexity possible.

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

Constraints:

1 ≤ nums.length ≤ 5 * 10^4

-5 * 10^4 ≤ nums[i] ≤ 5 * 10^4

Abstraction

Sort an array. A classic!

  • Merge Sort: (divide and conquer, stable, O(n) extra space)
  • Heap Sort (in-place via a manually built max-heap, O(1) extra space) r
  • Randomized Quicksort (in-place, expected O(n log n))
  • Counting sort (not comparison-based at all, exploits the bounded value range for true O(n + range) time).

Pseudocode

  text will go here

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Solution 1: [Merge Sort] Divide and Conquer, Bottom-Up Merge - Sorting/Merge Sort

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

        # Merge Sort:
        # Classic divide and conquer -- split the array in half
        # recursively until each piece has 1 element (trivially sorted),
        # then merge pairs of sorted halves back together. The merge
        # step is what does the actual sorting work, in linear time
        # per level, across O(log n) levels.

        def mergeSort(arr: List[int]) -> List[int]:
            # Base case: a single element (or empty) is already sorted
            if len(arr) <= 1:
                return arr

            mid = len(arr) // 2

            # Recurse: sort each half independently
            left = mergeSort(arr[:mid])
            right = mergeSort(arr[mid:])

            # Merge the two sorted halves into one sorted array
            return merge(left, right)

        def merge(left: List[int], right: List[int]) -> List[int]:
            merged = []
            i = j = 0

            # Walk both halves simultaneously, always taking the
            # smaller front element -- this is what keeps the result
            # sorted and is also the classic "merge two sorted lists" op
            while i < len(left) and j < len(right):
                if left[i] <= right[j]:
                    merged.append(left[i])
                    i += 1
                else:
                    merged.append(right[j])
                    j += 1

            # Append any leftovers -- at most one of these loops runs
            merged.extend(left[i:])
            merged.extend(right[j:])

            return merged

        return mergeSort(nums)

        # overall: tc O(n log n), log n levels of recursion, O(n)
        #          merge work per level
        # overall: sc O(n), for the merged sub-arrays at each level
        #          (not in-place)

Solution 2: [Heap Sort] Manual Max-Heap, In-Place Extraction - Sorting/Heap Sort

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

        # Heap Sort:
        # Build a max-heap out of the entire array IN-PLACE (no heapq,
        # since the problem disallows built-in sort/heap functions).
        # Then repeatedly swap the max (root) with the last unsorted
        # element and shrink the heap by one, restoring the heap
        # property each time. This sorts the array in-place with O(1)
        # extra space -- the best space complexity of these four solutions.

        n = len(nums)

        def siftDown(heapSize: int, root: int) -> None:
            # Sift Down:
            # push the element at `root` down until it's >= both its
            # children, restoring the max-heap property for the subtree
            largest = root
            left = 2 * root + 1
            right = 2 * root + 2

            if left < heapSize and nums[left] > nums[largest]:
                largest = left
            if right < heapSize and nums[right] > nums[largest]:
                largest = right

            # If a child was bigger, swap it up and keep sifting down
            # into whichever subtree we swapped into
            if largest != root:
                nums[root], nums[largest] = nums[largest], nums[root]
                siftDown(heapSize, largest)

        # Build-Heap:
        # start from the last non-leaf node and sift down each node --
        # this bottom-up build is O(n), not O(n log n), since most
        # nodes are near the bottom and sift down a short distance
        for i in range(n // 2 - 1, -1, -1):
            siftDown(n, i)

        # Extract:
        # the root of a max-heap is always the current largest element.
        # Swap it to the end (its final sorted position), shrink the
        # heap by one, then sift the new root down to restore the heap
        for end in range(n - 1, 0, -1):
            nums[0], nums[end] = nums[end], nums[0]
            siftDown(end, 0)

        return nums

        # overall: tc O(n log n) -- O(n) to build the heap,
        #          O(n log n) for n extractions each costing O(log n)
        # overall: sc O(1), sorts in-place (ignoring recursion stack,
        #          which can be made O(1) with an iterative siftDown)

Solution 3: [Quick Sort] Randomized Pivot, In-Place Partition - Sorting/Quick Sort

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

        # To avoid Quick Sort O(n^2)
        # use pivot = nums[random.randint(left, right)], 
        # instead of pivot = nums[(left + right) // 2]

        # Quick Sort (Randomized):
        # Pick a random pivot, partition the array in-place so
        # everything smaller ends up left of the pivot and everything
        # bigger ends up right, then recurse on each side. Randomizing
        # the pivot choice avoids the classic O(n^2) worst case that
        # a fixed pivot (like "always first element") hits on already
        # sorted or adversarial input.

        def quickSort(lo: int, hi: int) -> None:
            if lo >= hi:
                return

            pivotIndex = partition(lo, hi)

            # Recurse on both sides of the pivot's final position
            quickSort(lo, pivotIndex - 1)
            quickSort(pivotIndex + 1, hi)

        def partition(lo: int, hi: int) -> int:
            # Randomize:
            # swap a random element into the pivot slot (last position)
            # so the algorithm's performance doesn't depend on input order
            randIdx = random.randint(lo, hi)
            nums[randIdx], nums[hi] = nums[hi], nums[randIdx]

            pivot = nums[hi]
            i = lo  # boundary: everything before i is < pivot

            # Lomuto Partition:
            # walk through, and whenever we find something smaller
            # than the pivot, swap it into the "smaller" region and
            # advance the boundary
            for j in range(lo, hi):
                if nums[j] < pivot:
                    nums[i], nums[j] = nums[j], nums[i]
                    i += 1

            # Place the pivot in its correct final sorted position
            nums[i], nums[hi] = nums[hi], nums[i]

            return i

        quickSort(0, len(nums) - 1)
        return nums

        # overall: tc O(n log n) expected (randomization makes O(n^2)
        #          worst case astronomically unlikely in practice)
        # overall: sc O(log n) expected, for the recursion stack
        #          (in-place partitioning otherwise)

Solution 4: [Counting Sort] Exploit Bounded Value Range - Sorting/Counting Sort

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

        # Counting Sort:
        # NOT comparison-based, so the usual O(n log n) lower bound
        # for comparison sorts doesn't apply here. Since constraints
        # guarantee values are bounded (-5*10^4 to 5*10^4, a range of
        # 10^5 + 1 possible values), we can count occurrences of each
        # value directly and reconstruct the sorted array from those
        # counts -- true O(n + range) time.

        # Why this isn't "cheating" the O(n log n) requirement:
        # the problem asks for a solution "in O(n log n) time" as a
        # baseline expectation (ruling out naive O(n^2) approaches),
        # and counting sort satisfies that trivially since
        # O(n + range) is asymptotically better when range is bounded --
        # but it's only viable BECAUSE the constraints guarantee a
        # bounded range; it wouldn't work for arbitrary integers.

        OFFSET = 50000  # shift negative values into a valid index range
        RANGE = 100001  # -50000 to 50000 inclusive

        # sc: O(range), one counter per possible value
        counts = [0] * RANGE

        # Count occurrences of each value
        # tc: O(n)
        for num in nums:
            counts[num + OFFSET] += 1

        result = []

        # Reconstruct:
        # walk counts in ascending order, appending each value as
        # many times as it occurred
        # tc: O(n + range), range dominates only when range >> n
        for i, count in enumerate(counts):
            if count > 0:
                result.extend([i - OFFSET] * count)

        return result

        # overall: tc O(n + range), range = 10^5 here -- effectively
        #          O(n) when n is reasonably large relative to range
        # overall: sc O(n + range), for the counts array and result

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

Stone smashing game. Given some amount of stones, a round consists of smashing the two heaviest stones together. If they are the same weight both stones are destroyed. If one is heavier than the other, the heavier stone loses weight equal to the weight of the less heavy stone.

Pseudocode

  text will go here

Solution 1: Inverted MinHeap Is A MaxHeap Of Size K Tracking The Kth Smallest Element - Heap/Heap

    def lastStoneWeight(self, stones: list[int]) -> int:
        
        # MaxHeap:
        # We use a max heap to always extract the two largest stones efficiently.
        # Since Python only provides a min heap, we simulate a max heap
        # by inserting negative values.

        # Heap Strategy:
        # 1. Build a max heap from the input stones.
        # 2. Repeatedly remove the two largest stones.
        # 3. Smash them together and, if a remainder exists, push it back into the heap.
        # 4. Continue until at most one stone remains.
        # Result: return the final stone weight or 0 if all stones are destroyed.


        # MaxHeap List:
        # Turn positive stone weights into negative weights
        # tc:
        # sc:
        maxHeap = [-stone for stone in stones]

        # MaxHeap:
        # Transform negative list into heap in place in linear time
        # MinHeap with negative numbers is a MaxHeap
        # tc:
        # sc:
        heapq.heapify(maxHeap)

        # remove top two stones until one stone left
        # tc:
        while len(maxHeap) > 1:

            # Grab two heaviest stones
            heaviest = -(heapq.heappop(maxHeap))
            second_heaviest = -(heapq.heappop(maxHeap))

            # If remainder stone
            if heaviest != second_heaviest:

                # Get remainder stone
                remainingStoneWeight = -(heaviest - second_heaviest)

                # Push remaining stone back to MaxHeap
                heapq.heappush(maxHeap, remainingStoneWeight)

        # Check: if any stone remains
        # Grab the heaviest stone
        res = -maxHeap[0] if maxHeap else 0

        # overall: tc
        # overall: sc
        return res

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

Take a string an determine if we can rearrange so that neighbors are not matching

Pseudocode

  text will go here

Solution 1: [Heap] Greedy Max-Heap With One-Round Cool Down - Heap/Priority Queue Reorganize String

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

        # Greedy + Max-Heap:
        # Intuitively, the character with the most copies is the one
        # most at risk of ending up adjacent to itself, so it should
        # always be placed as early and as often as possible. A max-heap
        # (by count) lets us repeatedly grab "whichever character has
        # the most copies left" in O(log k) time, where k = 26 letters.

        # Why a one-round cooldown is enough:
        # After placing a character, we can't place it again immediately
        # -- but we CAN place it again next round. So instead of a full
        # queue of history, we only need to remember the single character
        # we just placed and push it back into the heap after placing
        # the next (different) character.

        # Impossibility check:
        # If any character's count exceeds ceil(n / 2), it's mathematically
        # impossible to space it out enough -- there aren't enough "gaps"
        # from other characters to separate every copy of it.

        n = len(s)

        # tc: O(n), count occurrences of each character
        counts = Counter(s)

        # Impossibility:
        # the most frequent character can't need more than half
        # (rounded up) of all positions
        if max(counts.values()) > (n + 1) // 2:
            return ""

        # Max-Heap:
        # Python's heapq is a min-heap, so negate counts to simulate
        # a max-heap -- the most frequent character surfaces first
        # sc: O(k), k = number of distinct characters (at most 26)
        heap = [(-count, char) for char, count in counts.items()]
        heapify(heap)

        result = []

        # Cooldown slot: holds the previously placed (count, char)
        # until it's safe to push back onto the heap
        prevCount, prevChar = 0, ""

        # tc: O(n log k), n pops/pushes, each O(log k)
        while heap:

            # Take:
            # grab whichever character currently has the most copies left
            count, char = heappop(heap)
            result.append(char)

            # Cooldown:
            # if we have a previously placed character still waiting
            # out its cooldown, it's now safe to make it eligible again
            if prevCount < 0:
                heappush(heap, (prevCount, prevChar))

            # Consume one copy of the character we just placed, then
            # put it on cooldown for exactly one round
            prevCount, prevChar = count + 1, char

        # Validate:
        # if we couldn't place every character, the greedy choice
        # ran into a dead end (shouldn't happen given the check above,
        # but guards against edge cases)
        return "".join(result) if len(result) == n else ""

        # overall: tc O(n log k), k = 26 -> effectively O(n)
        # overall: sc O(k) for the heap, O(n) for the result string

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

Abstract

In Technical terms this problem is called Shortest Job First (SJF) scheduling algorithm in OS

Pseudocode

  text will go here

Solution 1: [Heap] Min-Heap Simulation With Clock-Jumping - Heap/Priority Queue Single-Threaded CPU

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

        # Shortest Job First Simulation:
        # The CPU always wants "the shortest available task, breaking
        # ties by original index" -- exactly what a min-heap keyed on
        # (processingTime, index) gives us in O(log n) per operation.
        # The tricky part isn't the tie-break, it's correctly tracking
        # WHICH tasks are "available" at any given moment, since
        # availability depends on the current simulated time.

        # Why sort by enqueue time first:
        # We need to release tasks into the heap in the order they
        # become available, not in their original index order. Sorting
        # once up front lets us sweep through tasks left-to-right with
        # a pointer, only ever looking at "the next task to arrive."

        n = len(tasks)

        # Preserve original indices before sorting, since the answer
        # needs to report original task indices, not sorted positions
        # sc: O(n)
        # tc: O(n log n), the dominant cost of the whole algorithm
        indexedTasks = sorted(range(n), key=lambda i: tasks[i][0])

        # sc: O(n), holds available tasks as (processingTime, index)
        heap = []

        result = []
        time = 0        # current simulated CPU clock
        ptr = 0         # points to the next not-yet-released task

        # tc: O(n log n) total -- each task pushed/popped from the
        # heap at most once, each op O(log n)
        while len(result) < n:

            # Release:
            # push every task that has become available by "time"
            # (their enqueueTime <= time) into the heap
            while ptr < n and tasks[indexedTasks[ptr]][0] <= time:
                i = indexedTasks[ptr]
                heappush(heap, (tasks[i][1], i))
                ptr += 1

            if heap:
                # Run:
                # process the shortest available task (ties broken by
                # index automatically, since index is the tuple's
                # second element)
                processingTime, i = heappop(heap)
                result.append(i)
                time += processingTime
            else:
                # Idle:
                # nothing is available yet -- instead of idling one
                # unit at a time, jump the clock straight to the next
                # task's enqueue time, since nothing happens in between
                time = tasks[indexedTasks[ptr]][0]

        return result

        # overall: tc O(n log n), dominated by the initial sort and
        #          the heap operations
        # overall: sc O(n), for the sorted index list and the heap

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 marked by uppercase English letters and a minimum distance n between identical tasks, find how many cycles it would take to finish all tasks.

Pseudocode

  text will go here

Solution 1: Highest Occurring Tasks into Greedy Math Formula - Heap/Heap

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

        # Note:
        # 1. Highest occurring tasks + idle time determine the min scheduling
        # 2. Total number of tasks determine the min scheduling
        # Result: min scheduling for list of tasks 

        # Count frequency of each task (A-Z)
        freq = defaultdict(int)
        for task in tasks:
            freq[task] += 1
        
        # highest number of occurrences for single task
        maxFreq = max(freq.values())
        
        # number of tasks with highest number of occurrences
        maxCount = sum(1 for count in freq.values() if count == maxFreq)
        
        # The minimum time is based on arranging the most frequent tasks spaced by n:
        # ex:
        # A, B, C share freq count of 9
        # idle interval n = 5
        # 
        # so:
        # [A -> B -> C -> idle -> idle...] * 8 times
        # + [A -> B -> C] last group (notice no idles needed)

        # (maxFreq-1) -> 8 times = groups
        # (n+1) = length of group (n idle time + 1 for task occurrence

        # + maxCount for last group
        intervals = (maxFreq - 1) * (n + 1) + maxCount
        
        # if totalTasks is higher than intervals, totalTasks is the lower bound
        # ie: A,B,C combo takes 51 
        #     however, rest of alphabet all has 4 count, leading to 119
        # we will take the 119
        # (in this case, we wont have to use idle)
        res = max(intervals, len(tasks))

        # overall: time complexity
        # overall: space complexity
        return res

Solution 2: Max Heap Tick by Tick Simulation For Count Occurrences - Heap/Heap

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

        # Note:
        # Simulates task scheduling process 
        # 1. MaxHeap picks current most frequent remaining task first
        # 2. Queue (FIFO) simulates cool down to store tasks that have been run
        # but must wait 'n' intervals before they can be scheduled again
        # 3. Each iteration represents 1 unit of CPU time
        # 4. Iterate until both MaxHeap and Queue are empty
        # Result: time is total CPU time needed, including idle intervals

        # counts
        freq = Counter(tasks)
        
        # MaxHeap with negative counts
        maxHeap = [-count for count in freq.values()]
        heapq.heapify(maxHeap)
        
        # total CPU time simulated
        time = 0
        
        # Queue to hold cooldown tasks: (ready_time, count)
        cooldown = deque()
        
        # until both are empty
        while maxHeap or cooldown:

            # 1 CPU tick
            time += 1
            
            # grab most frequent task
            if maxHeap:
                
                # remove most freq task from root, and decrement by 1 
                cnt = heapq.heappop(maxHeap) + 1
                if cnt != 0:
                    # put task into cool down,
                    # and calculate time until next available use
                    cooldown.append((time + n, cnt))
            
            # check if any task in cool down is ready to be added back to maxHeap
            if cooldown and cooldown[0][0] == time:

                # pop FIFO
                _, cnt = cooldown.popleft()

                # push task occurrence count back to maxHeap
                heapq.heappush(maxHeap, cnt)
        
        # overall: time complexity
        # overall: space complexity
        return time

Solution 3: Max Heap Time Jump Optimization Simulation for Count Occurrences - Heap/Heap

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

        # Note:
        # Simulates task scheduling with a MaxHeap + cool down queue
        # 1. MaxHeap picks current most frequent remaining task first
        # 2. Queue (FIFO) simulates cool down to store tasks that have been run
        # but must wait 'n' intervals before they can be scheduled again
        # 3. if MaxHeap is empty but cool down queue is not, we 'jump' 
        #    directly to the next ready task instead of simulating idle ticks,
        #.   skipping over unnecessary +1 increments and speeds up simulation
        # Result: time is total CPU time needed, including idle intervals,
        #         with fewer iterations
        
        
        # counts
        freq = Counter(tasks)
        
        # MaxHeap with negative counts
        maxHeap = [-count for count in freq.values()]
        heapq.heapify(maxHeap)

        # total CPU time simulated
        time = 0

        # Queue to hold cooldown tasks: (-cnt, idleTime)
        q = deque()  

        # until both are empty
        while maxHeap or q:

            # CPU tick
            time += 1

            # if maxHeap is empty
            if not maxHeap:
                # time 'jump' to next cool down
                time = q[0][1]

            # maxHeap is non empty
            else:

                # remove most freq task from root, and decrement by 1 
                cnt = 1 + heapq.heappop(maxHeap)
                if cnt:
                    # put task into cool down,
                    # and calculate time until next available use 
                    q.append([cnt, time + n])

            # check if any task in cool down is ready to be added back to maxHeap
            if q and q[0][1] == time:
                
                # pop FIFO
                (cnt, idleTime) = q.popleft()

                heapq.heappush(maxHeap, cnt)

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

355. Design Twitter ::2:: - Medium

Topics: Hash Table, Linked List, Design, Heap (Priority Queue)

Intro

Design a simplified version of Twitter where users can post tweets, follow/unfollow another user, and is able to see the 10 most recent tweets in the user's news feed. Implement the Twitter class: Twitter() Initializes your twitter object. void postTweet(int userId, int tweetId) Composes a new tweet with ID tweetId by the user userId. Each call to this function will be made with a unique tweetId. List[Integer] getNewsFeed(int userId) Retrieves the 10 most recent tweet IDs in the user's news feed. Each item in the news feed must be posted by users who the user followed or by the user themself. Tweets must be ordered from most recent to least recent. void follow(int followerId, int followeeId) The user with ID followerId started following the user with ID followeeId. void unfollow(int followerId, int followeeId) The user with ID followerId started unfollowing the user with ID followeeId.

Example InputOutput
["Twitter", "postTweet", "getNewsFeed", "follow", "postTweet", "getNewsFeed", "unfollow", "getNewsFeed"] [[], [1, 5], [1], [1, 2], [2, 6], [1], [1, 2], [1]][null, null, [5], null, null, [6, 5], null, [5]]

Constraints:

1 ≤ userId, follwerId, followeeId ≤ 500

0 ≤ tweetId ≤ 104

All the tweets have unique IDs.

At most 3 * 104 calls will be made to postTweet, getNewsFeed, follow, and unfollow

A user cannot follow themselves.

Abstraction

Design a miniTwitter that allows for efficient news feed and postTweet. Boils down to desicions on efficiently storing tweets and retrieving the top 10 recent ones. Also boils down to a merge k sorted lists question.

Pseudocode

  text will go here

Solution 1: Store Tweets as Lists + Min Heap Merge - Heap/Heap

class Twitter:

    def __init__(self):

        # IRL Use Case: 
        # Balanced usage scenario users follow moderate number of accounts, tweet counts are not huge.

        # Note:
        # Tweets: Stores tweets as deques (lists) per user.
        # News Feed: Merges k sorted lists using a maxHeap to always pick the most
        #            recent tweet across the user and their followees.

        # Pros: Simple implementation, efficient for moderate tweet counts.
        # Cons: Heap merge has some overhead; memory grows with many tweets per user.

        self.followees = defaultdict(set)
        self.tweets = defaultdict(deque)
        self.time = 0
        self.FEED_SIZE = 10

    # time complexity:
    # space complexity:
    def postTweet(self, userId: int, tweetId: int) -> None:

        # Note:
        #

        self.time += 1
        self.tweets[userId].append((self.time, tweetId))

        if len(self.tweets[userId]) > 100:
            self.tweets[userId].popleft()

    # time complexity:
    # space complexity:
    def follow(self, followerId: int, followeeId: int) -> None:

        # Note:
        #

        if followerId != followeeId:
            self.followees[followerId].add(followeeId)

    # time complexity:
    # space complexity:
    def unfollow(self, followerId: int, followeeId: int) -> None:

        # Note:
        #

        self.followees[followerId].discard(followeeId)

    # time complexity:
    # space complexity:
    def getNewsFeed(self, userId: int) -> list[int]:

        # Note:
        #

        # heap stores (-timestamp, tweetId, userId, index_in_user_tweets)
        heap = []

        # The list of users to fetch tweets from: self + followees
        users = self.followees[userId] | {userId}
        
        # Init pointers for each user tweet list (start from newest)
        for u in users:

            if self.tweets[u]:

                # Index of last tweet in deque (newest)
                idx = len(self.tweets[u]) - 1
                timestamp, tweetId = self.tweets[u][idx]
                heapq.heappush(heap, (-timestamp, tweetId, u, idx))
        
        result = []

        while heap and len(result) < self.FEED_SIZE:

            neg_time, tweetId, u, idx = heapq.heappop(heap)
            result.append(tweetId)

            # Move pointer to next newest tweet if available
            if idx > 0:
                idx -= 1
                timestamp, tweetId = self.tweets[u][idx]
                heapq.heappush(heap, (-timestamp, tweetId, u, idx))
        
        return result

    # overall: time complexity
    # overall: space complexity

Solution 2: Store Tweets as Linked Lists + Merge K Sorted Lists - Heap/Heap

class TweetNode:
   
    # IRL Use Case: 
    # 'Celebrity' users with many tweets; avoids indexing overhead when fetching top 10.    
    

    # Note:
    # Tweets: Linked list node for tweets, storing tweetId, timestamp, and next pointer
    # News Feed: MaxHeap merges the heads of k linked lists, picking the newest tweet each time to produce top 10.

    # Pros: Handles users with very large tweet histories efficiently.
    # Cons: Pointer overhead; more complex than simple list implementation.

    def __init__(self, tweetId, time):
        self.tweetId = tweetId
        self.time = time
        self.next = None

class Twitter:
    def __init__(self):

        # Note:
        # Stores tweets as linked lists.
        # News feed built by max-heap merging list heads.
        # Pros/Cons as above.

        # Map userId to head of linked list of tweets
        self.tweets = {}

        # Map userId to set of followed userIds
        self.followees = defaultdict(set)
        self.time = 0
        self.FEED_SIZE = 10

    def postTweet(self, userId: int, tweetId: int) -> None:
        
        # Note:
        # 

        self.time += 1
        node = TweetNode(tweetId, self.time)
        node.next = self.tweets.get(userId, None)
        self.tweets[userId] = node

    def follow(self, followerId: int, followeeId: int) -> None:

        # Note:
        # 

        if followerId != followeeId:
            self.followees[followerId].add(followeeId)

    def unfollow(self, followerId: int, followeeId: int) -> None:

        # Note:
        #

        self.followees[followerId].discard(followeeId)

    def getNewsFeed(self, userId: int) -> list[int]:

        # Note:
        #

        # Users to consider: self + followees
        users = self.followees[userId] | {userId}
        
        # Build a max heap of (-time, TweetNode) for heads of all tweet lists
        heap = []

        for u in users:

            if self.tweets.get(u):
                heapq.heappush(heap, (-self.tweets[u].time, self.tweets[u]))
        
        result = []

        while heap and len(result) < self.FEED_SIZE:

            neg_time, node = heapq.heappop(heap)
            result.append(node.tweetId)

            if node.next:
                heapq.heappush(heap, (-node.next.time, node.next))
        
        return result

    # overall: time complexity
    # overall: space complexity

Solution 3: Store Tweets as Arrays/Deques + Dynamic Heap Merge - Heap/Heap

class Twitter:

    def __init__(self):

        # IRL Use Case: 
        # Most practical for IRL apps where MOST users have few tweets and follow a manageable number of accounts.
        # Slightly more efficient than Solution 1 due to index-based access for news feed construction.

        # Note:
        # Tweets: Stores tweets as deques per user, uses indices to access latest tweets.
        # News Feed: Max-heap merges the most recent tweets from each user for news feed.
        
        # Pros: Simple, practical, low overhead, works well with typical usage.
        # Cons: Slightly more complex than naive list append-only approach; still has heap overhead.

        # Map userId to list of (timestamp, tweetId)
        self.tweets = defaultdict(deque)
        
        # Map userId to set of followed userIds
        self.followees = defaultdict(set)
        
        # Global timestamp for ordering
        self.time = 0
        self.FEED_SIZE = 10

    def postTweet(self, userId: int, tweetId: int) -> None:
        # Increment timestamp
        self.time += 1
        self.tweets[userId].append((self.time, tweetId))
        # Optional: limit stored tweets to last 100 per user
        if len(self.tweets[userId]) > 100:
            self.tweets[userId].popleft()

    def follow(self, followerId: int, followeeId: int) -> None:
        if followerId != followeeId:
            self.followees[followerId].add(followeeId)

    def unfollow(self, followerId: int, followeeId: int) -> None:
        self.followees[followerId].discard(followeeId)

    def getNewsFeed(self, userId: int) -> list[int]:
        # Users to pull tweets from: self + followees
        users = self.followees[userId] | {userId}
        heap = []

        # Initialize heap with most recent tweet of each user
        for u in users:
            if self.tweets[u]:
                idx = len(self.tweets[u]) - 1
                ts, tid = self.tweets[u][idx]
                heapq.heappush(heap, (-ts, tid, u, idx))

        result = []

        while heap and len(result) < self.FEED_SIZE:
            neg_ts, tid, u, idx = heapq.heappop(heap)
            result.append(tid)
            if idx > 0:
                idx -= 1
                ts, tid = self.tweets[u][idx]
                heapq.heappush(heap, (-ts, tid, u, idx))

        return result

295. Find Median from Data Stream ::1:: - 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:

-105 ≤ num ≤ 105

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

At most 5 * 104 calls will be made to addNum and findMedian

Abstraction

Find median value from a stream of data.

Pseudocode

  text will go here

Solution 1: MaxHeap + MinHeap Median Finder - Heap/Heap

class MedianFinder:

    # Note:
    # Two

    def __init__(self):
        
        # MaxHeap stores smaller half,
        # all numbers less than or equal to the median
        self.maxHeap = []

        # MinHeap stores larger half,
        # all numbers greater than or equal to the median
        self.minHeap = []

    # time complexity:
    # space complexity: 
    def addNum(self, num: int) -> None:

        # Note:
        # Two heaps track lower and upper half
        # 

        # Add new number to maxHeap
        heapq.heappush(self.maxHeap, -num)
        
        # Ensure all elements in maxHeap greater than every element in minHeap
        # move smallest  largest from maxHeap to minHeap if out of order
        if (self.minHeap and (-self.maxHeap[0] > self.minHeap[0])):
            val = -heapq.heappop(self.maxHeap)
            heapq.heappush(self.minHeap, val)
        
        # Balance heaps:
        # MaxHeap should have 1 more element than minHeap

        # if minHeap is larger, move minHeap root to maxHeap
        if len(self.maxHeap) < len(self.minHeap):
            
            #
            val = heapq.heappop(self.minHeap)
            #
            heapq.heappush(self.maxHeap, -val)

        # if maxHeap is larger by more than 1, move maxHeap root to minHeap
        elif len(self.maxHeap) > len(self.minHeap) + 1:
            
            #
            val = -heapq.heappop(self.maxHeap)
            #
            heapq.heappush(self.minHeap, val)


    # time complexity:
    # space complexity:
    def findMedian(self) -> float:

        # Note:
        # Median retrieval logic:
        # 1. if odd -> root of MaxHeap (largest in smaller half)
        # 2. if even -> average of roots from both heaps

        # odd
        if len(self.maxHeap) > len(self.minHeap):
            return -self.maxHeap[0]
       
        # even
        average = (-self.maxHeap[0] + self.minHeap[0]) / 2

        return average

Solution 2: Balanced BST - Heap/Heap

class MedianFinder:
    def __init__(self):

        # Note:
        # balanced binary tree 
        self.sl = SortedList()

    # time complexity:
    # space complexity:
    def addNum(self, num: int) -> None:

        # insert in sorted order
        self.sl.add(num)

    # time complexity:
    # space complexity:
    def findMedian(self) -> float:
        
        # len
        n = len(self.sl)

        # odd
        if n % 2 == 1:
            return self.sl[n // 2]

        # even
        average = (self.sl[n // 2 - 1] + self.sl[n // 2]) / 2

        return average

    
    # overall: time complexity
    # overall: space complexity