From deb2f64ea7eff0a80e33d8f05d253cabfb027124 Mon Sep 17 00:00:00 2001 From: Kai Chappell Date: Thu, 7 Aug 2025 00:41:51 +0100 Subject: [PATCH] feat(patterns): tutorial system --- .../004_add_pattern_tutorial_fields.py | 84 +++++++ backend/data/patterns/binary-search.yaml | 202 ++++++++++++++++ .../data/patterns/dynamic-programming.yaml | 224 ++++++++++++++++++ backend/data/patterns/sliding-window.yaml | 194 +++++++++++++++ backend/data/patterns/two-pointers.yaml | 166 +++++++++++++ backend/scripts/load_data.py | 95 ++++++-- frontend/src/app/patterns/[slug]/page.tsx | 142 +++++++++-- .../patterns/common-mistakes-list.tsx | 37 +++ frontend/src/components/patterns/index.ts | 5 + .../patterns/learning-progression.tsx | 111 +++++++++ .../patterns/pattern-variations.tsx | 38 +++ .../patterns/recognition-signals.tsx | 32 +++ .../components/patterns/related-patterns.tsx | 48 ++++ frontend/src/lib/api.ts | 5 + frontend/src/types/index.ts | 48 ++++ 15 files changed, 1386 insertions(+), 45 deletions(-) create mode 100644 backend/alembic/versions/004_add_pattern_tutorial_fields.py create mode 100644 backend/data/patterns/binary-search.yaml create mode 100644 backend/data/patterns/dynamic-programming.yaml create mode 100644 backend/data/patterns/sliding-window.yaml create mode 100644 backend/data/patterns/two-pointers.yaml create mode 100644 frontend/src/components/patterns/common-mistakes-list.tsx create mode 100644 frontend/src/components/patterns/index.ts create mode 100644 frontend/src/components/patterns/learning-progression.tsx create mode 100644 frontend/src/components/patterns/pattern-variations.tsx create mode 100644 frontend/src/components/patterns/recognition-signals.tsx create mode 100644 frontend/src/components/patterns/related-patterns.tsx diff --git a/backend/alembic/versions/004_add_pattern_tutorial_fields.py b/backend/alembic/versions/004_add_pattern_tutorial_fields.py new file mode 100644 index 0000000..9e32204 --- /dev/null +++ b/backend/alembic/versions/004_add_pattern_tutorial_fields.py @@ -0,0 +1,84 @@ +"""add pattern tutorial fields + +Revision ID: 004 +Revises: 003 +Create Date: 2025-05-10 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "004" +down_revision: str | None = "003" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Tutorial content fields + op.add_column("patterns", sa.Column("metaphor", sa.Text(), nullable=True)) + op.add_column("patterns", sa.Column("core_concept", sa.Text(), nullable=True)) + op.add_column("patterns", sa.Column("visualization", sa.Text(), nullable=True)) + op.add_column("patterns", sa.Column("code_template", sa.Text(), nullable=True)) + + # Structured data fields (JSONB) + op.add_column( + "patterns", + sa.Column( + "recognition_signals", + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + ), + ) + op.add_column( + "patterns", + sa.Column( + "common_mistakes", + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + ), + ) + op.add_column( + "patterns", + sa.Column( + "variations", + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + ), + ) + op.add_column( + "patterns", + sa.Column( + "related_patterns", + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + ), + ) + op.add_column( + "patterns", + sa.Column( + "prerequisite_patterns", + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + ), + ) + + # Difficulty level (1-5) + op.add_column("patterns", sa.Column("difficulty_level", sa.Integer(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("patterns", "difficulty_level") + op.drop_column("patterns", "prerequisite_patterns") + op.drop_column("patterns", "related_patterns") + op.drop_column("patterns", "variations") + op.drop_column("patterns", "common_mistakes") + op.drop_column("patterns", "recognition_signals") + op.drop_column("patterns", "code_template") + op.drop_column("patterns", "visualization") + op.drop_column("patterns", "core_concept") + op.drop_column("patterns", "metaphor") diff --git a/backend/data/patterns/binary-search.yaml b/backend/data/patterns/binary-search.yaml new file mode 100644 index 0000000..908cf06 --- /dev/null +++ b/backend/data/patterns/binary-search.yaml @@ -0,0 +1,202 @@ +name: Binary Search +slug: binary-search +difficulty_level: 2 + +description: > + Efficiently search sorted data by repeatedly dividing the search space in half. + This transforms O(n) linear search into O(log n) by eliminating half the + remaining possibilities with each comparison. + +when_to_use: | + - Sorted arrays or search spaces + - Finding boundaries (first/last occurrence) + - Searching in rotated sorted arrays + - Finding peak elements + - Minimizing/maximizing with monotonic constraints + +metaphor: | + Imagine playing a number guessing game where someone says "higher" or "lower" + after each guess. The optimal strategy is always guessing the middle—you + eliminate half the possibilities each time regardless of the answer. + + Another analogy: looking up a word in a physical dictionary. You don't read + page by page from the start. You open roughly to the middle, see if you're + before or after your word, then repeat in the appropriate half. + +core_concept: | + Binary search works because the data has **monotonic ordering**—if you find + something too small, everything before it is also too small. If something is + too big, everything after is also too big. + + The key insight extends beyond simple arrays: + + 1. **Value search**: Find a specific target in sorted array + 2. **Boundary search**: Find the first/last element satisfying a condition + 3. **Search space**: Binary search over answers (e.g., "minimum capacity") + + At each step, you make one comparison and eliminate half the space. After k + comparisons, you've narrowed n elements down to n/2^k. Solving n/2^k = 1 + gives k = log₂(n). + +visualization: | + **Example: Find target = 7 in sorted array** + + ``` + Array: [1, 3, 5, 7, 9, 11, 13] + L M R + + Step 1: mid = 7, target = 7 → Found! + ``` + + **Example: Find target = 9** + + ``` + [1, 3, 5, 7, 9, 11, 13] + L M R + + Step 1: mid = 7 < 9 → Search right half + L M R + + Step 2: mid = 11 > 9 → Search left half + L + M + R + + Step 3: mid = 9 → Found at index 4! + ``` + + **Binary search on answer: Minimum capacity to ship packages in D days** + + ``` + Search space: [max(weights), sum(weights)] + + mid = some capacity + Can ship in D days with capacity mid? + Yes → try smaller capacity (go left) + No → need more capacity (go right) + ``` + +code_template: | + def binary_search(arr: list, target: int) -> int: + """Classic binary search for exact match.""" + left, right = 0, len(arr) - 1 + + while left <= right: + mid = left + (right - left) // 2 # Avoid overflow + + if arr[mid] == target: + return mid + elif arr[mid] < target: + left = mid + 1 + else: + right = mid - 1 + + return -1 # Not found + + + def lower_bound(arr: list, target: int) -> int: + """Find first position where arr[i] >= target.""" + left, right = 0, len(arr) + + while left < right: + mid = left + (right - left) // 2 + + if arr[mid] < target: + left = mid + 1 + else: + right = mid + + return left # First valid position + + + def binary_search_answer(low: int, high: int, is_valid) -> int: + """Binary search on answer space.""" + while low < high: + mid = low + (high - low) // 2 + + if is_valid(mid): + high = mid # Try smaller + else: + low = mid + 1 # Need bigger + + return low + +recognition_signals: + - "sorted array" + - "O(log n)" + - "find minimum/maximum" + - "find first/last occurrence" + - "rotated sorted array" + - "peak element" + - "minimum capacity" + - "search space" + - "lower bound" + - "upper bound" + +common_mistakes: + - title: Integer overflow in mid calculation + description: | + Using `(left + right) / 2` can overflow if left and right are both + large positive integers (in languages with fixed-size integers). + fix: | + Use `left + (right - left) // 2` instead. This is mathematically + equivalent but avoids overflow. + + - title: Infinite loop with wrong boundary update + description: | + Using `right = mid` with `left <= right` condition, or `left = mid` + when mid could equal left, causes infinite loops. + fix: | + For `left <= right`, always use `left = mid + 1` and `right = mid - 1`. + For `left < right`, use `left = mid + 1` and `right = mid`. + + - title: Off-by-one with boundary search + description: | + Returning `left` when you should return `left - 1` (or vice versa) + gives the wrong boundary element. + fix: | + Think carefully about loop invariants. What does `left` represent when + the loop ends? Test with edge cases. + + - title: Not recognizing binary search applicability + description: | + Missing that a problem can use binary search because the "array" is + implicit (search space of possible answers). + fix: | + If you need to minimize/maximize something and can write a function + `is_valid(x)` that's monotonic, binary search applies. + +variations: + - name: Classic search + description: | + Find exact target in sorted array. Returns index or -1. + example: "Binary Search, Search Insert Position" + + - name: Lower/Upper bound + description: | + Find first or last position satisfying a condition. Used for ranges + and counting occurrences. + example: "First Bad Version, Find First and Last Position" + + - name: Rotated array + description: | + Sorted array rotated at some pivot. One half is always sorted—determine + which half and search appropriately. + example: "Search in Rotated Sorted Array, Find Minimum" + + - name: Binary search on answer + description: | + Search the space of possible answers. Need a monotonic predicate function + to determine feasibility. + example: "Capacity To Ship Packages, Koko Eating Bananas, Split Array Largest Sum" + + - name: Peak finding + description: | + Find local maximum in bitonic array. Compare mid with neighbors to + determine which side has the peak. + example: "Find Peak Element, Find in Mountain Array" + +related_patterns: + - two-pointers + +prerequisite_patterns: [] diff --git a/backend/data/patterns/dynamic-programming.yaml b/backend/data/patterns/dynamic-programming.yaml new file mode 100644 index 0000000..094a747 --- /dev/null +++ b/backend/data/patterns/dynamic-programming.yaml @@ -0,0 +1,224 @@ +name: Dynamic Programming +slug: dynamic-programming +difficulty_level: 4 + +description: > + Break problems into overlapping subproblems, storing results to avoid + recomputation. This transforms exponential time complexity into polynomial + by trading space for time. + +when_to_use: | + - Optimization problems (min/max) + - Counting problems + - Problems with optimal substructure + - Sequence alignment + - Knapsack-type problems + +metaphor: | + Imagine building with LEGO bricks. Instead of reconstructing the same base + structure every time you try a new top, you save your work. Each completed + substructure becomes a building block for larger structures. + + Another analogy: calculating Fibonacci numbers. To find fib(5), you need + fib(4) and fib(3). But fib(4) also needs fib(3). Rather than recalculating + fib(3) twice, save it the first time and reuse it. + +core_concept: | + Dynamic programming requires two properties: + + 1. **Optimal substructure**: The optimal solution contains optimal solutions + to its subproblems. + + 2. **Overlapping subproblems**: The same subproblems are solved multiple + times in a naive recursive approach. + + The key insight is identifying the **state**—what information do you need + to solve a subproblem? And the **transition**—how do you combine smaller + subproblems into larger ones? + + Two implementation approaches: + - **Top-down (memoization)**: Recursive with caching + - **Bottom-up (tabulation)**: Iterative, filling a table from base cases + +visualization: | + **Example: Fibonacci with memoization** + + ``` + Without memoization (exponential calls): + fib(5) + / \ + fib(4) fib(3) + / \ / \ + fib(3) fib(2) fib(2) fib(1) + / \ + fib(2) fib(1) + ... + + With memoization: + fib(5) → fib(4) → fib(3) → fib(2) → fib(1) + ↓ ↓ + use cached use cached + fib(3) fib(2) + ``` + + **Example: Coin Change (minimum coins for amount 11)** + + ``` + Coins: [1, 5, 6] Amount: 11 + + dp[0] = 0 (base case: 0 coins for amount 0) + + dp[1] = dp[0] + 1 = 1 (use coin 1) + dp[5] = min(dp[4]+1, dp[0]+1) = 1 (use coin 5) + dp[6] = min(dp[5]+1, dp[0]+1) = 1 (use coin 6) + + dp[11] = min(dp[10]+1, dp[6]+1, dp[5]+1) + = min(?, 2, 3) + = 2 (6 + 5) + ``` + +code_template: | + # Top-down (memoization) + from functools import lru_cache + + def solve_top_down(n: int) -> int: + @lru_cache(maxsize=None) + def dp(state): + # Base case + if base_condition(state): + return base_value + + # Recursive case with memoization + result = initial_value + for choice in choices(state): + subproblem = dp(next_state(state, choice)) + result = combine(result, subproblem) + + return result + + return dp(initial_state(n)) + + + # Bottom-up (tabulation) + def solve_bottom_up(n: int) -> int: + # Initialize DP table + dp = [initial_value] * (n + 1) + + # Base case + dp[0] = base_value + + # Fill table iteratively + for i in range(1, n + 1): + for choice in choices(i): + if valid(i, choice): + dp[i] = combine(dp[i], dp[prev_state(i, choice)]) + + return dp[n] + + + # 2D DP example (Longest Common Subsequence) + def lcs(text1: str, text2: str) -> int: + m, n = len(text1), len(text2) + dp = [[0] * (n + 1) for _ in range(m + 1)] + + for i in range(1, m + 1): + for j in range(1, n + 1): + if text1[i-1] == text2[j-1]: + dp[i][j] = dp[i-1][j-1] + 1 + else: + dp[i][j] = max(dp[i-1][j], dp[i][j-1]) + + return dp[m][n] + +recognition_signals: + - "minimum/maximum" + - "count ways" + - "can you reach" + - "optimal" + - "longest/shortest" + - "number of ways" + - "subset sum" + - "partition" + - "knapsack" + - "sequence" + - "subsequence" + +common_mistakes: + - title: Incorrect state definition + description: | + Choosing a state that doesn't capture all necessary information leads + to incorrect transitions or missing cases. + fix: | + Ask: "What do I need to know to solve this subproblem?" The answer + defines your state. Test with small examples to verify. + + - title: Wrong base case + description: | + Incorrect initialization causes wrong answers to propagate through + the entire DP table. + fix: | + Think about the smallest/simplest subproblem. What's the answer when + there's nothing left to consider? Start from there. + + - title: Off-by-one in 2D DP + description: | + Confusion about whether dp[i] represents the first i elements or the + element at index i causes index errors. + fix: | + Be consistent. Common convention: dp[i] = answer for first i elements, + so dp[0] = empty case. Indices in strings/arrays are 0-based. + + - title: Forgetting to handle impossible cases + description: | + Not returning infinity for minimum problems or 0 for counting when + a state is unreachable gives wrong aggregations. + fix: | + Initialize dp with appropriate "impossible" values (infinity for min, + -infinity for max, 0 for counting). Return -1 if final answer is + still impossible. + + - title: Space complexity not optimized + description: | + Using O(n*m) space when only the previous row/column is needed + wastes memory on large inputs. + fix: | + If dp[i] only depends on dp[i-1], use two arrays (current and previous) + or even a single array updated carefully. + +variations: + - name: 1D DP + description: | + Single dimension state, typically indexed by position or remaining + capacity. Common for linear sequences. + example: "Climbing Stairs, House Robber, Coin Change" + + - name: 2D DP + description: | + Two-dimensional state, often for comparing two sequences or tracking + two variables (position and capacity). + example: "Longest Common Subsequence, Edit Distance, 0/1 Knapsack" + + - name: Interval DP + description: | + State represents a range [i, j]. Solve for all subranges and combine. + Often O(n^3) time. + example: "Burst Balloons, Matrix Chain Multiplication" + + - name: Bitmask DP + description: | + State includes a bitmask representing a subset. Used when order matters + among a small set of items. + example: "Traveling Salesman, Shortest Superstring" + + - name: DP on Trees + description: | + State associated with tree nodes. Transition from children to parent + (or vice versa). + example: "House Robber III, Binary Tree Maximum Path Sum" + +related_patterns: + - greedy + - backtracking + +prerequisite_patterns: + - backtracking diff --git a/backend/data/patterns/sliding-window.yaml b/backend/data/patterns/sliding-window.yaml new file mode 100644 index 0000000..a4c69e7 --- /dev/null +++ b/backend/data/patterns/sliding-window.yaml @@ -0,0 +1,194 @@ +name: Sliding Window +slug: sliding-window +difficulty_level: 2 + +description: > + Maintain a window of elements that slides through the data, tracking a + constraint or computing aggregates. This transforms O(n*k) brute force into + O(n) by incrementally updating the window instead of recalculating from scratch. + +when_to_use: | + - Finding subarrays/substrings with specific properties + - Maximum/minimum sum of fixed-size windows + - Longest substring with at most K distinct characters + - Problems mentioning "contiguous" elements + +metaphor: | + Imagine looking at a landscape through a train window. As the train moves + forward, the scenery at the back of your view disappears while new scenery + appears at the front. You don't need to memorize the entire journey—just + keep track of what's currently visible through your window. + + Another analogy: a cashier's sliding tray at a bank. As new items are added + to one end, old items fall off the other. You only count what's on the tray + at any moment. + +core_concept: | + The **sliding window** technique avoids redundant computation by maintaining + state as the window moves. Instead of recalculating the entire window each + time, you *add* what enters and *remove* what leaves. + + There are two main types: + + 1. **Fixed-size window**: Window size is constant (e.g., "find max sum of k elements") + 2. **Variable-size window**: Window expands and contracts based on constraints + (e.g., "longest substring with at most 2 distinct characters") + + The key insight is that consecutive windows share most of their elements. + Only the edges change, so only update those. + +visualization: | + **Example: Maximum sum of 3 consecutive elements** + + ``` + Array: [2, 1, 5, 1, 3, 2] Window size: 3 + + Window 1: [2, 1, 5] = 8 (calculate full sum) + └─────┘ + + Window 2: [1, 5, 1] = 8-2+1 = 7 (remove 2, add 1) + └─────┘ + + Window 3: [5, 1, 3] = 7-1+3 = 9 (remove 1, add 3) ← Maximum! + └─────┘ + + Window 4: [1, 3, 2] = 9-5+2 = 6 (remove 5, add 2) + └─────┘ + ``` + + **Variable window: Longest substring with at most 2 distinct chars** + + ``` + String: "eceba" + + "e" → 1 distinct, expand → length 1 + "ec" → 2 distinct, expand → length 2 + "ece" → 2 distinct, expand → length 3 ← Answer! + "eceb" → 3 distinct, shrink from left + "ceb" → 3 distinct, shrink from left + "eb" → 2 distinct, expand → length 2 + "eba" → 3 distinct, shrink... + ``` + +code_template: | + def fixed_window(arr: list, k: int) -> int: + """Fixed-size sliding window.""" + n = len(arr) + if n < k: + return 0 + + # Calculate initial window + window_sum = sum(arr[:k]) + max_sum = window_sum + + # Slide the window + for i in range(k, n): + window_sum += arr[i] - arr[i - k] # Add new, remove old + max_sum = max(max_sum, window_sum) + + return max_sum + + + def variable_window(s: str, k: int) -> int: + """Variable-size sliding window.""" + char_count = {} + left = 0 + max_length = 0 + + for right in range(len(s)): + # Expand: add character at right + char_count[s[right]] = char_count.get(s[right], 0) + 1 + + # Contract: shrink from left if constraint violated + while len(char_count) > k: + char_count[s[left]] -= 1 + if char_count[s[left]] == 0: + del char_count[s[left]] + left += 1 + + # Update answer + max_length = max(max_length, right - left + 1) + + return max_length + +recognition_signals: + - "contiguous subarray" + - "substring" + - "maximum sum of k elements" + - "window" + - "consecutive" + - "at most k distinct" + - "minimum window" + - "longest substring" + - "sliding" + +common_mistakes: + - title: Forgetting to handle window smaller than required + description: | + When array length is less than window size k, trying to create a window + causes index errors or incorrect results. + fix: | + Add an early check: + ```python + if len(arr) < k: + return 0 # or appropriate default + ``` + + - title: Off-by-one in variable window + description: | + When calculating window length, using `right - left` instead of + `right - left + 1` gives length off by one. + fix: | + Window length is always `right - left + 1` (inclusive on both ends). + + - title: Not cleaning up empty entries in hash map + description: | + When shrinking a variable window, decrementing a counter to 0 but not + removing the key causes the distinct count to be wrong. + fix: | + Always delete keys when count reaches 0: + ```python + if char_count[s[left]] == 0: + del char_count[s[left]] + ``` + + - title: Updating answer at wrong time + description: | + For "minimum" problems, updating the answer inside the while loop + captures invalid states. For "maximum" problems, updating only inside + the while loop misses valid states. + fix: | + For maximum problems, update after expanding. For minimum problems, + update when the constraint is first satisfied (inside the while loop). + +variations: + - name: Fixed-size window + description: | + Window size stays constant throughout. Simple slide operation: add one + element, remove one element. + example: "Maximum Sum Subarray of Size K, Find All Anagrams" + + - name: Variable-size (shrinkable) + description: | + Window expands freely but contracts when constraints are violated. + Uses a while loop to shrink until valid. + example: "Longest Substring Without Repeating, Minimum Window Substring" + + - name: Two-pointer variant + description: | + Some problems use two pointers that feel like sliding window but track + different metrics. The mechanics are similar. + example: "Container With Most Water, Trapping Rain Water" + + - name: Caterpillar method + description: | + Another name for the variable sliding window, emphasizing how the window + stretches and contracts like a caterpillar moving. + example: "Common in competitive programming contexts" + +related_patterns: + - two-pointers + - prefix-sum + +prerequisite_patterns: + - two-pointers diff --git a/backend/data/patterns/two-pointers.yaml b/backend/data/patterns/two-pointers.yaml new file mode 100644 index 0000000..61504a5 --- /dev/null +++ b/backend/data/patterns/two-pointers.yaml @@ -0,0 +1,166 @@ +name: Two Pointers +slug: two-pointers +difficulty_level: 2 + +description: > + Use two pointers to traverse data from different positions, often moving + toward or away from each other. This technique transforms O(n²) brute force + into O(n) by eliminating redundant comparisons. + +when_to_use: | + - Sorted arrays where you need to find pairs + - Linked list cycle detection + - Removing duplicates in-place + - Partitioning arrays + - Palindrome checking + +metaphor: | + Imagine two people reading a book from opposite ends, each moving toward the + middle. The person at the back skips ahead when they find what they're looking + for, while the person at the front moves forward when they don't match. They + meet somewhere in the middle, having searched the entire book without either + person reading the same page twice. + + Another way to think about it: squeezing toothpaste from both ends of the + tube. You apply pressure from each side, working toward the center until + you've gotten everything out. + +core_concept: | + The **two pointers** technique eliminates the need for nested loops by + maintaining two positions that move through the data based on conditions. + + The key insight is that when data has *structure* (like being sorted), you + can make intelligent decisions about which pointer to move. If the current + pair is too small, moving the left pointer right increases the sum. If it's + too large, moving the right pointer left decreases it. + + This reduces O(n²) brute force (checking all pairs) to O(n) because each + element is visited at most twice—once by each pointer. + +visualization: | + **Example: Find pair with sum = 10 in sorted array** + + ``` + Array: [1, 2, 4, 6, 8, 10] Target: 10 + L R + + Step 1: 1 + 10 = 11 > 10 → Sum too large, move R left + L R + + Step 2: 1 + 8 = 9 < 10 → Sum too small, move L right + L R + + Step 3: 2 + 8 = 10 ✓ → Found! Return [1, 4] + ``` + + **Key insight**: Because the array is sorted, we know exactly which pointer + to move. Too big? Decrease the larger value. Too small? Increase the smaller. + +code_template: | + def two_pointers(arr: list, target: int) -> list: + """Two pointers converging from opposite ends.""" + left, right = 0, len(arr) - 1 + + while left < right: + current = arr[left] + arr[right] + + if current == target: + return [left, right] # Found! + elif current < target: + left += 1 # Need larger sum + else: + right -= 1 # Need smaller sum + + return [] # No solution found + + + def two_pointers_same_direction(arr: list) -> int: + """Two pointers moving in same direction (slow/fast).""" + slow = 0 + + for fast in range(len(arr)): + if some_condition(arr[fast]): + arr[slow] = arr[fast] + slow += 1 + + return slow # New length + +recognition_signals: + - "sorted array" + - "find pair with sum" + - "two sum" + - "in-place modification" + - "remove duplicates" + - "partition array" + - "palindrome" + - "container with most water" + - "trapping rain water" + - "move zeros" + +common_mistakes: + - title: Off-by-one with boundaries + description: | + Using `<=` instead of `<` when pointers should not overlap causes + infinite loops or double-counting elements. + fix: | + For converging pointers, use `while left < right`. Only use `<=` when + the same element can be part of the answer twice. + + - title: Not handling duplicates + description: | + When the problem asks for unique pairs, forgetting to skip duplicate + values leads to repeated answers. + fix: | + After finding a match, skip over duplicates: + ```python + while left < right and arr[left] == arr[left + 1]: + left += 1 + ``` + + - title: Moving both pointers at once + description: | + Moving both pointers simultaneously after finding a match can skip + valid solutions. + fix: | + Move one pointer at a time and let the next iteration decide the other. + After a match, move both only when you've recorded the result. + + - title: Forgetting the sorted requirement + description: | + Two pointers only works predictably on sorted data. Applying it to + unsorted arrays gives wrong results. + fix: | + Sort first if needed (adds O(n log n)), or use a hash map approach + instead if sorting changes the problem semantics. + +variations: + - name: Opposite-direction (converging) + description: | + Pointers start at opposite ends and move toward each other. Used for + pair problems in sorted arrays. + example: "Two Sum II, Container With Most Water, Valid Palindrome" + + - name: Same-direction (fast-slow) + description: | + Both pointers start at the same end but move at different speeds or + based on different conditions. Used for in-place modifications. + example: "Remove Duplicates, Move Zeros, Remove Element" + + - name: Sliding window variant + description: | + Two pointers defining a window that expands and contracts. Technically + a separate pattern but uses similar mechanics. + example: "Minimum Window Substring, Longest Substring Without Repeating" + + - name: Three pointers + description: | + Extension with three pointers for problems involving triplets or + partitioning into three sections. + example: "3Sum, Sort Colors (Dutch National Flag)" + +related_patterns: + - sliding-window + - fast-slow-pointers + - binary-search + +prerequisite_patterns: [] diff --git a/backend/scripts/load_data.py b/backend/scripts/load_data.py index dd0c41e..c513489 100644 --- a/backend/scripts/load_data.py +++ b/backend/scripts/load_data.py @@ -52,33 +52,45 @@ async def load_categories(session: AsyncSession, data_dir: Path) -> dict[str, Ca async def load_patterns(session: AsyncSession, data_dir: Path) -> dict[str, Pattern]: - """Load patterns from YAML file.""" - patterns_file = data_dir / "patterns" / "patterns.yaml" - if not patterns_file.exists(): - print(f"Warning: {patterns_file} not found") - return {} - - with open(patterns_file) as f: - data = yaml.safe_load(f) + """Load patterns from YAML files. + Supports both: + - Legacy single file: patterns/patterns.yaml + - Individual files: patterns/.yaml (preferred for tutorials) + """ + patterns_dir = data_dir / "patterns" patterns: dict[str, Pattern] = {} - for item in data.get("patterns", []): - result = await session.execute(select(Pattern).where(Pattern.slug == item["slug"])) - existing = result.scalar_one_or_none() - if existing: - existing.name = item["name"] - existing.description = item.get("description") - existing.when_to_use = item.get("when_to_use") - patterns[item["slug"]] = existing - else: - pattern = Pattern( - name=item["name"], - slug=item["slug"], - description=item.get("description"), - when_to_use=item.get("when_to_use"), - ) - session.add(pattern) + # First, try loading individual pattern files (preferred for tutorials) + individual_files = list(patterns_dir.glob("*.yaml")) + # Filter out the legacy patterns.yaml file + individual_files = [f for f in individual_files if f.name != "patterns.yaml"] + + if individual_files: + for pattern_file in individual_files: + with open(pattern_file) as f: + item = yaml.safe_load(f) + + if not item or "slug" not in item: + print(f" Warning: Skipping {pattern_file.name} - missing slug") + continue + + pattern = await _upsert_pattern(session, item) + patterns[item["slug"]] = pattern + print(f" Loaded: {item['name']}") + + # Fall back to legacy patterns.yaml if no individual files found + legacy_file = patterns_dir / "patterns.yaml" + if legacy_file.exists(): + with open(legacy_file) as f: + data = yaml.safe_load(f) + + for item in data.get("patterns", []): + # Skip if already loaded from individual file + if item["slug"] in patterns: + continue + + pattern = await _upsert_pattern(session, item) patterns[item["slug"]] = pattern await session.flush() @@ -86,6 +98,41 @@ async def load_patterns(session: AsyncSession, data_dir: Path) -> dict[str, Patt return patterns +async def _upsert_pattern(session: AsyncSession, item: dict[str, Any]) -> Pattern: + """Insert or update a single pattern from YAML data.""" + result = await session.execute(select(Pattern).where(Pattern.slug == item["slug"])) + existing = result.scalar_one_or_none() + + if existing: + pattern = existing + else: + pattern = Pattern(slug=item["slug"]) + session.add(pattern) + + # Core fields + pattern.name = item["name"] + pattern.description = item.get("description") + pattern.when_to_use = item.get("when_to_use") + + # Tutorial content fields + pattern.metaphor = item.get("metaphor") + pattern.core_concept = item.get("core_concept") + pattern.visualization = item.get("visualization") + pattern.code_template = item.get("code_template") + + # Structured data fields (JSONB) + pattern.recognition_signals = item.get("recognition_signals") + pattern.common_mistakes = item.get("common_mistakes") + pattern.variations = item.get("variations") + pattern.related_patterns = item.get("related_patterns") + pattern.prerequisite_patterns = item.get("prerequisite_patterns") + + # Difficulty level + pattern.difficulty_level = item.get("difficulty_level") + + return pattern + + async def load_question( session: AsyncSession, question_file: Path, diff --git a/frontend/src/app/patterns/[slug]/page.tsx b/frontend/src/app/patterns/[slug]/page.tsx index 799816c..1454a7b 100644 --- a/frontend/src/app/patterns/[slug]/page.tsx +++ b/frontend/src/app/patterns/[slug]/page.tsx @@ -1,8 +1,18 @@ import type { Metadata } from "next"; -import { getPattern, getQuestions } from "@/lib/api"; -import { QuestionCard } from "@/components/questions/question-card"; -import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { notFound } from "next/navigation"; +import { getPatternTutorial } from "@/lib/api"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { CodeBlock } from "@/components/ui/code-block"; +import { Markdown } from "@/components/ui/markdown"; +import { Callout } from "@/components/ui/callout"; +import { Badge } from "@/components/ui/badge"; +import { + CommonMistakesList, + LearningProgression, + PatternVariations, + RecognitionSignals, + RelatedPatterns, +} from "@/components/patterns"; interface PageProps { params: Promise<{ slug: string }>; @@ -12,7 +22,7 @@ export async function generateMetadata({ params }: PageProps): Promise const { slug } = await params; try { - const pattern = await getPattern(slug); + const pattern = await getPatternTutorial(slug); const description = pattern.description || `Learn the ${pattern.name} pattern with ${pattern.question_count} practice problems.`; @@ -37,52 +47,142 @@ export default async function PatternDetailPage({ params }: PageProps) { const { slug } = await params; let pattern; - let questions; try { - [pattern, questions] = await Promise.all([ - getPattern(slug), - getQuestions({ pattern: slug, limit: 50 }), - ]); + pattern = await getPatternTutorial(slug); } catch { notFound(); } + const difficultyLabels = ["Beginner", "Easy", "Intermediate", "Advanced", "Expert"]; + const difficultyLabel = pattern.difficulty_level + ? difficultyLabels[pattern.difficulty_level - 1] || "Intermediate" + : null; + return (
+ {/* Header */}
-

{pattern.name}

+
+

{pattern.name}

+ {difficultyLabel && ( + {difficultyLabel} + )} +

{pattern.question_count} questions using this pattern

+ {/* Description */} {pattern.description && ( - Description + What is {pattern.name}? - {pattern.description} + + {pattern.description} + )} + {/* Metaphor - The relatable analogy */} + {pattern.metaphor && ( + + {pattern.metaphor} + + )} + + {/* Core Concept - The "aha!" insight */} + {pattern.core_concept && ( + + + Core Concept + + + {pattern.core_concept} + + + )} + + {/* Visualization - ASCII diagram walkthrough */} + {pattern.visualization && ( + + + Visual Walkthrough + + + {pattern.visualization} + + + )} + + {/* Code Template */} + {pattern.code_template && ( + + + Code Template + + +

+ Use this skeleton as a starting point for problems using this pattern: +

+ +
+
+ )} + + {/* Recognition Signals */} + {pattern.recognition_signals && pattern.recognition_signals.length > 0 && ( + + )} + + {/* When to Use */} {pattern.when_to_use && ( When to Use - - {pattern.when_to_use} + + {pattern.when_to_use} )} -
-

Questions

-
- {questions.items.map((question) => ( - - ))} -
+ {/* Common Mistakes */} + {pattern.common_mistakes && pattern.common_mistakes.length > 0 && ( + + )} + + {/* Pattern Variations */} + {pattern.variations && pattern.variations.length > 0 && ( + + )} + + {/* Learning Progression */} + {pattern.learning_progression && ( + + )} + + {/* Related Patterns */} +
+ {pattern.prerequisite_patterns && pattern.prerequisite_patterns.length > 0 && ( + + )} + {pattern.related_patterns && pattern.related_patterns.length > 0 && ( + + )}
); diff --git a/frontend/src/components/patterns/common-mistakes-list.tsx b/frontend/src/components/patterns/common-mistakes-list.tsx new file mode 100644 index 0000000..a0c4eb3 --- /dev/null +++ b/frontend/src/components/patterns/common-mistakes-list.tsx @@ -0,0 +1,37 @@ +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Callout } from "@/components/ui/callout"; +import { Markdown } from "@/components/ui/markdown"; +import type { CommonMistake } from "@/types"; + +interface CommonMistakesListProps { + mistakes: CommonMistake[]; +} + +export function CommonMistakesList({ mistakes }: CommonMistakesListProps) { + if (!mistakes.length) return null; + + return ( + + + Common Mistakes + + +
+ {mistakes.map((mistake) => ( + +
+ {mistake.description} + {mistake.fix && ( +
+

Fix:

+ {mistake.fix} +
+ )} +
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/patterns/index.ts b/frontend/src/components/patterns/index.ts new file mode 100644 index 0000000..5a88531 --- /dev/null +++ b/frontend/src/components/patterns/index.ts @@ -0,0 +1,5 @@ +export { CommonMistakesList } from "./common-mistakes-list"; +export { LearningProgression } from "./learning-progression"; +export { PatternVariations } from "./pattern-variations"; +export { RecognitionSignals } from "./recognition-signals"; +export { RelatedPatterns } from "./related-patterns"; diff --git a/frontend/src/components/patterns/learning-progression.tsx b/frontend/src/components/patterns/learning-progression.tsx new file mode 100644 index 0000000..1b79a69 --- /dev/null +++ b/frontend/src/components/patterns/learning-progression.tsx @@ -0,0 +1,111 @@ +import Link from "next/link"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { getDifficultyVariant, capitalize } from "@/lib/utils"; +import type { LearningProgression as LearningProgressionType } from "@/types"; + +interface LearningProgressionProps { + progression: LearningProgressionType; +} + +export function LearningProgression({ progression }: LearningProgressionProps) { + const hasQuestions = + progression.warmup.length > 0 || + progression.core.length > 0 || + progression.challenge.length > 0; + + if (!hasQuestions) return null; + + return ( + + + Learning Path + + +
+ {progression.warmup.length > 0 && ( +
+

+ + 1 + + Warmup +

+

+ Start here to build foundational understanding. +

+
+ {progression.warmup.map((q) => ( + + ))} +
+
+ )} + + {progression.core.length > 0 && ( +
+

+ + 2 + + Core Practice +

+

+ Master the pattern with these representative problems. +

+
+ {progression.core.map((q) => ( + + ))} +
+
+ )} + + {progression.challenge.length > 0 && ( +
+

+ + 3 + + Challenge +

+

+ Test your mastery with complex variations. +

+
+ {progression.challenge.map((q) => ( + + ))} +
+
+ )} +
+
+
+ ); +} + +interface QuestionLinkProps { + question: LearningProgressionType["warmup"][number]; +} + +function QuestionLink({ question }: QuestionLinkProps) { + return ( + + {question.title} +
+ {question.leetcode_id && ( + + #{question.leetcode_id} + + )} + + {capitalize(question.difficulty)} + +
+ + ); +} diff --git a/frontend/src/components/patterns/pattern-variations.tsx b/frontend/src/components/patterns/pattern-variations.tsx new file mode 100644 index 0000000..5a99b18 --- /dev/null +++ b/frontend/src/components/patterns/pattern-variations.tsx @@ -0,0 +1,38 @@ +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Markdown } from "@/components/ui/markdown"; +import type { PatternVariation } from "@/types"; + +interface PatternVariationsProps { + variations: PatternVariation[]; +} + +export function PatternVariations({ variations }: PatternVariationsProps) { + if (!variations.length) return null; + + return ( + + + Pattern Variations + + +
+ {variations.map((variation) => ( +
+

{variation.name}

+ {variation.description} + {variation.example && ( +

+ Examples:{" "} + {variation.example} +

+ )} +
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/patterns/recognition-signals.tsx b/frontend/src/components/patterns/recognition-signals.tsx new file mode 100644 index 0000000..693ef72 --- /dev/null +++ b/frontend/src/components/patterns/recognition-signals.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; + +interface RecognitionSignalsProps { + signals: string[]; +} + +export function RecognitionSignals({ signals }: RecognitionSignalsProps) { + if (!signals.length) return null; + + return ( + + + Recognition Signals + + +

+ Look for these keywords and patterns in problem descriptions: +

+
+ {signals.map((signal) => ( + + {signal} + + ))} +
+
+
+ ); +} diff --git a/frontend/src/components/patterns/related-patterns.tsx b/frontend/src/components/patterns/related-patterns.tsx new file mode 100644 index 0000000..74fd5a7 --- /dev/null +++ b/frontend/src/components/patterns/related-patterns.tsx @@ -0,0 +1,48 @@ +import Link from "next/link"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import type { RelatedPattern } from "@/types"; + +interface RelatedPatternsProps { + title: string; + description?: string; + patterns: RelatedPattern[]; +} + +export function RelatedPatterns({ + title, + description, + patterns, +}: RelatedPatternsProps) { + if (!patterns.length) return null; + + return ( + + + {title} + + + {description && ( +

+ {description} +

+ )} +
+ {patterns.map((pattern) => ( + +

{pattern.name}

+ {pattern.description && ( +

+ {pattern.description} +

+ )} + + ))} +
+
+
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a1497e7..ecb39b6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -2,6 +2,7 @@ import type { CategoryListResponse, Pattern, PatternListResponse, + PatternTutorial, QuestionDetail, QuestionListResponse, Stats, @@ -98,6 +99,10 @@ export async function getPattern(slug: string): Promise { return fetchApi(`/api/patterns/${slug}`); } +export async function getPatternTutorial(slug: string): Promise { + return fetchApi(`/api/patterns/${slug}/tutorial`); +} + export async function getStats(): Promise { return fetchApi("/api/stats"); } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 322d94f..afe3402 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -23,6 +23,54 @@ export interface Pattern extends PatternBrief { question_count: number; } +// Pattern Tutorial Types + +export interface CommonMistake { + title: string; + description: string; + fix: string | null; +} + +export interface PatternVariation { + name: string; + description: string; + example: string | null; +} + +export interface RelatedPattern { + slug: string; + name: string; + description: string | null; +} + +export interface LearningQuestion { + id: string; + title: string; + slug: string; + difficulty: Difficulty; + leetcode_id: number | null; +} + +export interface LearningProgression { + warmup: LearningQuestion[]; + core: LearningQuestion[]; + challenge: LearningQuestion[]; +} + +export interface PatternTutorial extends Pattern { + metaphor: string | null; + core_concept: string | null; + visualization: string | null; + code_template: string | null; + recognition_signals: string[] | null; + common_mistakes: CommonMistake[] | null; + variations: PatternVariation[] | null; + related_patterns: RelatedPattern[] | null; + prerequisite_patterns: RelatedPattern[] | null; + difficulty_level: number | null; + learning_progression: LearningProgression | null; +} + export interface QuestionListItem { id: string; title: string;