๐ŸŽ“ A1Learner
๐Ÿ”

DSA Perfect Squares

A perfect square is a whole number multiplied by itself, like 1, 4 and 9. The perfect squares problem asks for the fewest perfect squares that sum to a target number.

The problem

A perfect square is a whole number multiplied by itself, like 1, 4, and 9. The Perfect Squares problem asks for the fewest perfect squares that sum to a target number.

Every square may be used more than once. The answer is a count, and the article also recovers one split that reaches it.

The cases used in this article

cases = [
    (12, 3),
    (13, 2),
    (18, 2),
    (23, 4),
    (30, 3),
]
for n, expected in cases:
    print("target", n, "fewest squares:", expected)

Read the first row with care. Target 12 takes three squares, all of them 4. Taking the biggest square first does worse, as the next section shows.

Intuition: take the largest square that fits

The first idea takes the largest square at or below the target. It then repeats on what remains. The method is quick and it always reaches the target, because square 1 always fits.

Greedy fails on target 12

def greedy(n):
    parts = []
    k = 1
    squares = []
    while k * k <= n:
        squares.append(k * k)
        k += 1
    rest = n
    for sq in reversed(squares):
        while rest >= sq:
            parts.append(sq)
            rest -= sq
    return parts

parts = greedy(12)
print("greedy split for 12:", parts)
print("greedy count:", len(parts))
print("better split: [4, 4, 4] count: 3")

Greedy takes 9 first and pays for it. Three ones must fill the rest. The better split skips 9 and uses three squares of 4.

Brute force: try every last square, recounting targets

The brute force picks the last square of the target. Each fitting square leaves a smaller target, and that target is solved from scratch. No answer is stored, so the same small target is solved many times.

The brute force, with calls counted

calls = 0

def fewest(n):
    global calls
    calls += 1
    if n == 0:
        return 0
    best = n
    k = 1
    while k * k <= n:
        best = min(best, 1 + fewest(n - k * k))
        k += 1
    return best

for n in [12, 15, 18]:
    calls = 0
    result = fewest(n)
    print("target", n, "fewest:", result, "calls:", calls)

Even these small targets cost many calls. Most calls repeat a target that another branch already solved. A table stores each answer once.

The optimal solution: one table of fewest counts

The state is dp(n), the fewest perfect squares that sum to n. Each target tries every square that fits as its last square, and dp(n) is one plus the smallest count before it.

The table starts with dp(0) equal to 0. Zero needs no squares. Each later count builds only on smaller counts, which are already final.

The table on every case

def square_table(limit):
    squares = []
    k = 1
    while k * k <= limit:
        squares.append(k * k)
        k += 1
    dp = [0] * (limit + 1)
    for n in range(1, limit + 1):
        dp[n] = 1 + min(dp[n - sq] for sq in squares if sq <= n)
    return squares, dp

def recover(dp, squares, n):
    parts = []
    while n > 0:
        for sq in sorted(squares, reverse=True):
            if sq <= n and dp[n - sq] == dp[n] - 1:
                parts.append(sq)
                n -= sq
                break
    return parts

squares, dp = square_table(30)
cases = [(12, 3), (13, 2), (18, 2), (23, 4), (30, 3)]
for n, expected in cases:
    print("target", n, "->", dp[n], "split:", recover(dp, squares, n), "match:", dp[n] == expected)

All five cases from the start of the article pass. The recovered splits prove each count. A count alone would leave the reader trusting the table.

Walkthrough: building the table up to 13

The walk fills one target at a time. Each row shows the squares that fit and the earlier counts they point to.

The table after each target up to 13

squares = [1, 4, 9]
dp = [0]
print("dp(0) = 0")
for n in range(1, 14):
    fitting = [sq for sq in squares if sq <= n]
    earlier = [dp[n - sq] for sq in fitting]
    dp.append(1 + min(earlier))
    print("target", n, "fitting:", fitting, "earlier counts:", earlier, "dp:", dp[n])
print("answer for 13:", dp[13])

The last row reads the answer off the table. Target 13 points back to counts 3, 1, and 1. The smallest gives dp(13) equal to 2, from 9 plus 4.

Complexity

The table tries each fitting square once per target, so its time is O(n times the square root of n). The table holds one count per target, so its space is O(n).

Counting the work instead of timing it

import math

calls = 0

def fewest(n):
    global calls
    calls += 1
    if n == 0:
        return 0
    best = n
    k = 1
    while k * k <= n:
        best = min(best, 1 + fewest(n - k * k))
        k += 1
    return best

for n in [10, 13, 16]:
    calls = 0
    fewest(n)
    tries = sum(int(math.isqrt(m)) for m in range(1, n + 1))
    print("target", n, "- brute calls:", calls, " table inner tries:", tries)

The brute calls already dwarf the table tries at target 16. The gap grows with the target. Stored answers are the whole saving.

Edge cases and pitfalls

Target zero needs no squares at all. A target that is itself a square answers 1. Small targets below 4 can only use square 1, so their counts equal the target.

Edge cases, run

def fewest_table(limit):
    squares = []
    k = 1
    while k * k <= limit:
        squares.append(k * k)
        k += 1
    dp = [0] * (limit + 1)
    for n in range(1, limit + 1):
        dp[n] = 1 + min(dp[n - sq] for sq in squares if sq <= n)
    return dp

dp = fewest_table(30)
print("zero:", dp[0])
print("one:", dp[1])
print("three, only ones fit:", dp[3])
print("a square itself, 25:", dp[25])
print("greedy trap, 12:", dp[12])
print("one past a square, 26:", dp[26])

The classic pitfall is the greedy split from the intuition section. It always works and it is sometimes one square too many. The table avoids it by trying every fitting last square.

Exercise

A table gives dp(12) = 3. Which split proves that count?