Files
codetutor/backend/data/questions/perfect-squares.yaml

249 lines
9.7 KiB
YAML

title: Perfect Squares
slug: perfect-squares
difficulty: medium
leetcode_id: 279
leetcode_url: https://leetcode.com/problems/perfect-squares/
categories:
- dynamic-programming
- math
patterns:
- dynamic-programming
- bfs
function_signature: "def num_squares(n: int) -> int:"
test_cases:
visible:
- input: { n: 12 }
expected: 3
- input: { n: 13 }
expected: 2
- input: { n: 1 }
expected: 1
hidden:
- input: { n: 4 }
expected: 1
- input: { n: 2 }
expected: 2
- input: { n: 3 }
expected: 3
- input: { n: 7 }
expected: 4
- input: { n: 100 }
expected: 1
- input: { n: 99 }
expected: 3
- input: { n: 48 }
expected: 3
description: |
Given an integer `n`, return *the least number of perfect square numbers that sum to* `n`.
A **perfect square** is an integer that is the square of an integer; in other words, it is the product of some integer with itself. For example, `1`, `4`, `9`, and `16` are perfect squares while `3` and `11` are not.
constraints: |
- `1 <= n <= 10^4`
examples:
- input: "n = 12"
output: "3"
explanation: "12 = 4 + 4 + 4."
- input: "n = 13"
output: "2"
explanation: "13 = 4 + 9."
explanation:
intuition: |
Imagine you have a collection of coins, but instead of standard denominations, your coins have values `1`, `4`, `9`, `16`, `25`, ... (all perfect squares). Your goal is to make change for amount `n` using the **fewest coins possible**.
This reframing reveals the problem's structure: it's essentially the classic **Coin Change** problem where the coin denominations are perfect squares up to `n`.
Think of it like this: for any number `n`, you can always represent it as a sum of `1`s (since `1` is a perfect square), so a solution always exists. The challenge is finding the **minimum** number of squares needed. For example, while `12 = 1 + 1 + 1 + ... + 1` works (12 ones), the optimal solution is `12 = 4 + 4 + 4` (just 3 squares).
The key insight is that the answer for `n` depends on answers for smaller numbers. If we use a perfect square `k^2` as part of our solution, then we need `1 + numSquares(n - k^2)` total squares. By trying all valid perfect squares and taking the minimum, we build up the optimal solution.
approach: |
We solve this using **Dynamic Programming (Bottom-Up)**:
**Step 1: Precompute perfect squares**
- Generate all perfect squares up to `n`: `[1, 4, 9, 16, ...]` where each value `<= n`
- These are our "coins" for making change
&nbsp;
**Step 2: Initialise the DP array**
- Create array `dp` of size `n + 1`
- `dp[i]`: the minimum number of perfect squares that sum to `i`
- `dp[0] = 0`: zero squares needed to sum to zero
- Initialise all other values to infinity (or `n + 1`) as a placeholder for "not yet computed"
&nbsp;
**Step 3: Fill the DP array**
- For each value `i` from `1` to `n`:
- Try subtracting each perfect square `sq` where `sq <= i`
- `dp[i] = min(dp[i], dp[i - sq] + 1)`
- The `+ 1` accounts for using one square of value `sq`
&nbsp;
**Step 4: Return the result**
- Return `dp[n]`, which contains the minimum squares needed for our target
&nbsp;
This bottom-up approach ensures we solve smaller subproblems first, building up to the final answer. Each state depends only on previously computed states.
common_pitfalls:
- title: Greedy Doesn't Work
description: |
A tempting approach is to greedily pick the largest perfect square that fits, then recurse on the remainder.
For example, with `n = 12`:
- Greedy picks `9` (largest square <= 12), leaving `3`
- Then picks `1` three times for the remaining `3`
- Result: `9 + 1 + 1 + 1 = 12` using **4 squares**
But the optimal is `4 + 4 + 4 = 12` using only **3 squares**.
Greedy fails because locally optimal choices don't guarantee global optimum in this problem.
wrong_approach: "Always pick the largest perfect square"
correct_approach: "Use DP to explore all combinations"
- title: Forgetting Base Case
description: |
The base case `dp[0] = 0` is essential. Without it, the recurrence relation breaks.
When we compute `dp[4]` and try square `4`: we need `dp[4 - 4] = dp[0]`. If `dp[0]` isn't properly initialised to `0`, we get incorrect results.
wrong_approach: "Initialise dp[0] to 1 or leave undefined"
correct_approach: "Set dp[0] = 0 explicitly"
- title: Inefficient Square Generation
description: |
Regenerating perfect squares inside the inner loop wastes time.
Instead, precompute all perfect squares up to `n` once before the main loop. This reduces redundant computation and improves cache efficiency.
wrong_approach: "Generate squares inside nested loops"
correct_approach: "Precompute squares array once"
key_takeaways:
- "**Unbounded knapsack pattern**: This problem is structurally identical to Coin Change — perfect squares are the denominations, and we minimise the count"
- "**Greedy fails**: Problems asking for minimum counts over combinations often require DP because greedy local choices don't guarantee global optimum"
- "**Bottom-up DP**: Building solutions from smaller subproblems (`dp[0]` to `dp[n]`) avoids recursion overhead and stack limits"
- "**BFS alternative**: This can also be solved with BFS, treating each number as a node and perfect squares as edges — the first path to reach `n` is shortest"
time_complexity: "O(n * sqrt(n)). For each value from `1` to `n`, we try up to `sqrt(n)` perfect squares."
space_complexity: "O(n). We use a DP array of size `n + 1` to store intermediate results."
solutions:
- approach_name: Dynamic Programming (Bottom-Up)
is_optimal: true
code: |
def num_squares(n: int) -> int:
# Precompute all perfect squares up to n
squares = []
i = 1
while i * i <= n:
squares.append(i * i)
i += 1
# dp[i] = minimum squares needed to sum to i
dp = [float('inf')] * (n + 1)
dp[0] = 0 # Base case: 0 squares needed for sum of 0
# Build up solutions from 1 to n
for target in range(1, n + 1):
# Try each perfect square as the last square used
for sq in squares:
if sq > target:
break
# Use one square of value sq, plus optimal for remainder
dp[target] = min(dp[target], dp[target - sq] + 1)
return dp[n]
explanation: |
**Time Complexity:** O(n * sqrt(n)) — For each of `n` values, we iterate through up to `sqrt(n)` perfect squares.
**Space Complexity:** O(n) — The DP array stores results for all values from `0` to `n`.
This bottom-up approach fills in the DP table iteratively. For each target sum, we consider all valid perfect squares and take the minimum. The precomputed squares list ensures we don't regenerate squares repeatedly.
- approach_name: BFS (Shortest Path)
is_optimal: true
code: |
from collections import deque
def num_squares(n: int) -> int:
# Precompute perfect squares up to n
squares = []
i = 1
while i * i <= n:
squares.append(i * i)
i += 1
# BFS: each level represents adding one more square
queue = deque([n])
visited = {n}
level = 0
while queue:
level += 1
# Process all nodes at current level
for _ in range(len(queue)):
curr = queue.popleft()
# Try subtracting each perfect square
for sq in squares:
remainder = curr - sq
if remainder == 0:
return level # Found shortest path
if remainder > 0 and remainder not in visited:
visited.add(remainder)
queue.append(remainder)
return level # Should never reach here for valid input
explanation: |
**Time Complexity:** O(n * sqrt(n)) — In the worst case, we visit each number from `1` to `n` and try `sqrt(n)` squares.
**Space Complexity:** O(n) — The visited set and queue can hold up to `n` elements.
BFS treats this as a shortest path problem. Start from `n`, and at each step subtract a perfect square. The first time we reach `0`, the current level (depth) is the minimum number of squares. BFS guarantees the shortest path in an unweighted graph.
- approach_name: Top-Down DP with Memoisation
is_optimal: false
code: |
from functools import lru_cache
def num_squares(n: int) -> int:
# Precompute perfect squares up to n
squares = []
i = 1
while i * i <= n:
squares.append(i * i)
i += 1
@lru_cache(maxsize=None)
def dp(target: int) -> int:
if target == 0:
return 0
min_count = float('inf')
for sq in squares:
if sq > target:
break
min_count = min(min_count, dp(target - sq) + 1)
return min_count
return dp(n)
explanation: |
**Time Complexity:** O(n * sqrt(n)) — Same as bottom-up, but with recursion overhead.
**Space Complexity:** O(n) — Memoisation cache plus recursion stack depth.
This recursive approach with memoisation is conceptually simpler but has function call overhead. The `@lru_cache` decorator handles memoisation automatically. While correct, bottom-up DP is typically faster in practice for this problem.