From a2ecd6aa857d7f3d95b3ad5e68822a679ef058c5 Mon Sep 17 00:00:00 2001 From: Kai Chappell Date: Mon, 28 Apr 2025 23:19:49 +0100 Subject: [PATCH] hard questions --- .../median-of-two-sorted-arrays.yaml | 122 ++++++++++++++ .../data/questions/merge-k-sorted-lists.yaml | 149 ++++++++++++++++++ .../data/questions/trapping-rain-water.yaml | 129 +++++++++++++++ 3 files changed, 400 insertions(+) create mode 100644 backend/data/questions/median-of-two-sorted-arrays.yaml create mode 100644 backend/data/questions/merge-k-sorted-lists.yaml create mode 100644 backend/data/questions/trapping-rain-water.yaml diff --git a/backend/data/questions/median-of-two-sorted-arrays.yaml b/backend/data/questions/median-of-two-sorted-arrays.yaml new file mode 100644 index 0000000..c3813f2 --- /dev/null +++ b/backend/data/questions/median-of-two-sorted-arrays.yaml @@ -0,0 +1,122 @@ +title: Median of Two Sorted Arrays +slug: median-of-two-sorted-arrays +difficulty: hard +leetcode_id: 4 +leetcode_url: https://leetcode.com/problems/median-of-two-sorted-arrays/ +categories: + - arrays + - binary-search +patterns: + - binary-search + +description: | + Given two sorted arrays `nums1` and `nums2` of size m and n respectively, return the median + of the two sorted arrays. + + The overall run time complexity should be O(log(m+n)). + +constraints: | + - nums1.length == m + - nums2.length == n + - 0 <= m <= 1000 + - 0 <= n <= 1000 + - 1 <= m + n <= 2000 + - -10^6 <= nums1[i], nums2[i] <= 10^6 + +examples: + - input: "nums1 = [1,3], nums2 = [2]" + output: "2.0" + explanation: "Merged array is [1,2,3]. Median is 2." + - input: "nums1 = [1,2], nums2 = [3,4]" + output: "2.5" + explanation: "Merged array is [1,2,3,4]. Median is (2+3)/2 = 2.5." + +explanation: + approach: | + 1. Binary search on the smaller array for partition point + 2. Partition both arrays such that left half has (m+n+1)//2 elements + 3. Check if partition is valid: max(left) <= min(right) + 4. If valid, compute median from boundary elements + 5. Adjust binary search bounds based on comparison + + intuition: | + The median divides the combined array into two halves of equal size. We don't need to + actually merge; we just need to find the correct partition. + + If we choose i elements from nums1 for the left half, we need (m+n+1)//2 - i from nums2. + Binary search on i (0 to m) to find where nums1[i-1] <= nums2[j] and nums2[j-1] <= nums1[i]. + + This is O(log min(m,n)) since we binary search on the smaller array. + + common_pitfalls: + - title: Not handling edge cases at partition + description: | + When partition is at array boundary (i=0 or i=m), use -inf or inf for boundary values. + wrong_approach: "Accessing nums1[i-1] when i=0" + correct_approach: "Use float('-inf') if i == 0" + + - title: Binary searching on the longer array + description: | + Always binary search on the shorter array to ensure valid partition exists + and for better efficiency. + + - title: Odd vs even total length + description: | + For odd total, median is max of left half. + For even, it's average of max(left) and min(right). + + key_takeaways: + - Binary search on partition, not on values + - Partition both arrays to have equal halves + - Handle boundary conditions with infinity + - O(log min(m,n)) is achievable + + time_complexity: "O(log min(m,n))" + space_complexity: "O(1)" + complexity_explanation: | + Time: Binary search on the smaller array. + Space: Only constant extra variables. + +solutions: + - approach_name: Binary Search on Partition (Optimal) + is_optimal: true + code: | + def find_median_sorted_arrays(nums1: list[int], nums2: list[int]) -> float: + # Ensure nums1 is the smaller array + if len(nums1) > len(nums2): + nums1, nums2 = nums2, nums1 + + m, n = len(nums1), len(nums2) + left, right = 0, m + half_len = (m + n + 1) // 2 + + while left <= right: + i = (left + right) // 2 # Partition in nums1 + j = half_len - i # Partition in nums2 + + # Handle edge cases with infinity + nums1_left = float('-inf') if i == 0 else nums1[i - 1] + nums1_right = float('inf') if i == m else nums1[i] + nums2_left = float('-inf') if j == 0 else nums2[j - 1] + nums2_right = float('inf') if j == n else nums2[j] + + if nums1_left <= nums2_right and nums2_left <= nums1_right: + # Found valid partition + if (m + n) % 2 == 1: + return max(nums1_left, nums2_left) + else: + return (max(nums1_left, nums2_left) + + min(nums1_right, nums2_right)) / 2 + + elif nums1_left > nums2_right: + # Too many elements from nums1 in left half + right = i - 1 + else: + # Too few elements from nums1 in left half + left = i + 1 + + return 0.0 # Should never reach here + explanation: | + Binary search to find correct partition point in the smaller array. + Partition is valid when all left elements <= all right elements. + Compute median from the four boundary elements. diff --git a/backend/data/questions/merge-k-sorted-lists.yaml b/backend/data/questions/merge-k-sorted-lists.yaml new file mode 100644 index 0000000..d0ba9a1 --- /dev/null +++ b/backend/data/questions/merge-k-sorted-lists.yaml @@ -0,0 +1,149 @@ +title: Merge k Sorted Lists +slug: merge-k-sorted-lists +difficulty: hard +leetcode_id: 23 +leetcode_url: https://leetcode.com/problems/merge-k-sorted-lists/ +categories: + - linked-lists + - heap +patterns: + - heap + +description: | + 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. + +constraints: | + - k == lists.length + - 0 <= k <= 10^4 + - 0 <= lists[i].length <= 500 + - -10^4 <= lists[i][j] <= 10^4 + - lists[i] is sorted in ascending order + - The sum of lists[i].length will not exceed 10^4 + +examples: + - input: "lists = [[1,4,5],[1,3,4],[2,6]]" + output: "[1,1,2,3,4,4,5,6]" + explanation: "Merge three sorted lists into one." + - input: "lists = []" + output: "[]" + explanation: "No lists to merge." + - input: "lists = [[]]" + output: "[]" + explanation: "Single empty list." + +explanation: + approach: | + 1. Use a min-heap to track the smallest element among all list heads + 2. Add the first node from each non-empty list to the heap + 3. Pop the smallest node, add it to the result + 4. If that node has a next, add it to the heap + 5. Continue until heap is empty + + intuition: | + At each step, we need to find the minimum among k candidates (the heads of each list). + A min-heap gives us this minimum in O(log k) time. + + Since each node is pushed and popped from the heap exactly once, and we have N total nodes, + the overall complexity is O(N log k). + + common_pitfalls: + - title: Not handling empty lists + description: | + Some lists might be empty (null). Filter them out or check before adding to heap. + wrong_approach: "Adding null to heap" + correct_approach: "if node: heappush(...)" + + - title: Heap comparison with ListNode + description: | + Python's heapq can't compare ListNode objects directly. + Either use a tuple (value, index, node) or define __lt__ on ListNode. + + - title: Not advancing the list pointer + description: | + After adding a node to result, push its next node to the heap, not the same node. + + key_takeaways: + - Min-heap efficiently finds minimum among k elements + - This is a k-way merge algorithm + - Total work is O(N log k) where N is total nodes + - Same pattern works for merging k sorted arrays + + time_complexity: "O(N log k)" + space_complexity: "O(k)" + complexity_explanation: | + Time: Each of N nodes is pushed and popped once, each operation is O(log k). + Space: Heap holds at most k nodes at any time. + +solutions: + - approach_name: Min-Heap (Optimal) + is_optimal: true + code: | + import heapq + + class ListNode: + def __init__(self, val=0, next=None): + self.val = val + self.next = next + + def merge_k_lists(lists: list[ListNode | None]) -> ListNode | None: + heap = [] + + # Add first node from each list with index for tie-breaking + for i, node in enumerate(lists): + if node: + heapq.heappush(heap, (node.val, i, node)) + + dummy = ListNode() + current = dummy + + while heap: + val, i, node = heapq.heappop(heap) + current.next = node + current = current.next + + if node.next: + heapq.heappush(heap, (node.next.val, i, node.next)) + + return dummy.next + explanation: | + Use heap to always get the smallest current head. + Push next node when popping to maintain k candidates. + Index in tuple handles equal values (tie-breaking). + + - approach_name: Divide and Conquer + is_optimal: true + code: | + def merge_k_lists(lists: list[ListNode | None]) -> ListNode | None: + if not lists: + return None + + def merge_two(l1: ListNode | None, l2: ListNode | None) -> ListNode | None: + dummy = ListNode() + current = dummy + + while l1 and l2: + if l1.val <= l2.val: + current.next = l1 + l1 = l1.next + else: + current.next = l2 + l2 = l2.next + current = current.next + + current.next = l1 or l2 + return dummy.next + + 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(merge_two(l1, l2)) + lists = merged + + return lists[0] + explanation: | + Pair up lists and merge, reducing k to k/2 each round. + Same complexity as heap approach but iterative merge logic. diff --git a/backend/data/questions/trapping-rain-water.yaml b/backend/data/questions/trapping-rain-water.yaml new file mode 100644 index 0000000..10ab08c --- /dev/null +++ b/backend/data/questions/trapping-rain-water.yaml @@ -0,0 +1,129 @@ +title: Trapping Rain Water +slug: trapping-rain-water +difficulty: hard +leetcode_id: 42 +leetcode_url: https://leetcode.com/problems/trapping-rain-water/ +categories: + - arrays + - two-pointers + - stack +patterns: + - two-pointers + - monotonic-stack + +description: | + Given n non-negative integers representing an elevation map where the width of each bar is 1, + compute how much water it can trap after raining. + +constraints: | + - n == height.length + - 1 <= n <= 2 * 10^4 + - 0 <= height[i] <= 10^5 + +examples: + - input: "height = [0,1,0,2,1,0,1,3,2,1,2,1]" + output: "6" + explanation: "6 units of water are trapped between the bars." + - input: "height = [4,2,0,3,2,5]" + output: "9" + explanation: "9 units of water are trapped." + +explanation: + approach: | + 1. Use two pointers from left and right + 2. Track maximum height seen from each side + 3. Move the pointer with smaller max height + 4. Water at current position = max_height - current_height + 5. Add to total and continue until pointers meet + + intuition: | + Water at any position is determined by the minimum of the maximum heights to its left + and right, minus the current height. + + With two pointers, we track left_max and right_max. If left_max < right_max, water at + the left pointer is limited by left_max (the right side is guaranteed to be at least + as tall). We process and move the pointer with the smaller maximum. + + common_pitfalls: + - title: Only considering one side + description: | + Water level is determined by BOTH sides. You need to track maximum from left AND right. + wrong_approach: "Only tracking left_max" + correct_approach: "Track both left_max and right_max" + + - title: Counting bars instead of water + description: | + Water trapped at position i is max_height - height[i], not max_height. + The bar itself takes up space. + + - title: Not updating max heights + description: | + Update left_max or right_max before calculating water, not after. + + key_takeaways: + - Two pointers eliminate need for O(n) precomputation + - Water level = min(left_max, right_max) - current_height + - Always process the side with smaller max (guaranteed bound) + - This can also be solved with monotonic stack or DP + + time_complexity: "O(n)" + space_complexity: "O(1)" + complexity_explanation: | + Time: Single pass with two pointers. + Space: Only a few variables for pointers and max values. + +solutions: + - approach_name: Two Pointers (Optimal) + is_optimal: true + code: | + def trap(height: list[int]) -> int: + if not height: + return 0 + + left, right = 0, len(height) - 1 + left_max, right_max = 0, 0 + water = 0 + + while left < right: + if height[left] < height[right]: + if height[left] >= left_max: + left_max = height[left] + else: + water += left_max - height[left] + left += 1 + else: + if height[right] >= right_max: + right_max = height[right] + else: + water += right_max - height[right] + right -= 1 + + return water + explanation: | + Process from both ends. Move the pointer with smaller max height. + Add water based on the difference between max height and current height. + + - approach_name: Monotonic Stack + is_optimal: false + code: | + def trap(height: list[int]) -> int: + stack = [] # stores indices + water = 0 + + for i, h in enumerate(height): + while stack and h > height[stack[-1]]: + top = stack.pop() + + if not stack: + break + + width = i - stack[-1] - 1 + bounded_height = min(h, height[stack[-1]]) - height[top] + water += width * bounded_height + + stack.append(i) + + return water + explanation: | + Stack stores indices of bars in decreasing height order. + When a taller bar is found, calculate water trapped in the "valley".