242 lines
9.5 KiB
YAML
242 lines
9.5 KiB
YAML
title: Integer Break
|
|
slug: integer-break
|
|
difficulty: medium
|
|
leetcode_id: 343
|
|
leetcode_url: https://leetcode.com/problems/integer-break/
|
|
categories:
|
|
- dynamic-programming
|
|
- math
|
|
patterns:
|
|
- dynamic-programming
|
|
- greedy
|
|
|
|
function_signature: "def integer_break(n: int) -> int:"
|
|
|
|
test_cases:
|
|
visible:
|
|
- input: { n: 2 }
|
|
expected: 1
|
|
- input: { n: 10 }
|
|
expected: 36
|
|
hidden:
|
|
- input: { n: 3 }
|
|
expected: 2
|
|
- input: { n: 4 }
|
|
expected: 4
|
|
- input: { n: 5 }
|
|
expected: 6
|
|
- input: { n: 6 }
|
|
expected: 9
|
|
- input: { n: 7 }
|
|
expected: 12
|
|
- input: { n: 8 }
|
|
expected: 18
|
|
- input: { n: 11 }
|
|
expected: 54
|
|
- input: { n: 58 }
|
|
expected: 1549681956
|
|
|
|
description: |
|
|
Given an integer `n`, break it into the sum of `k` **positive integers**, where `k >= 2`, and maximize the product of those integers.
|
|
|
|
Return *the maximum product you can get*.
|
|
|
|
constraints: |
|
|
- `2 <= n <= 58`
|
|
|
|
examples:
|
|
- input: "n = 2"
|
|
output: "1"
|
|
explanation: "2 = 1 + 1, 1 x 1 = 1."
|
|
- input: "n = 10"
|
|
output: "36"
|
|
explanation: "10 = 3 + 3 + 4, 3 x 3 x 4 = 36."
|
|
|
|
explanation:
|
|
intuition: |
|
|
Imagine you have a rope of length `n` and you must cut it into at least two pieces. You want the **product of the piece lengths** to be as large as possible.
|
|
|
|
The key mathematical insight is: **3s are magical**. When you break a number into parts, using 3s (with some 2s for adjustment) produces the maximum product.
|
|
|
|
Why? Consider that for any number greater than 4, breaking off a 3 gives a better product than keeping it whole. For example, `6` as a single piece contributes 6 to the product, but `3 + 3` contributes `3 x 3 = 9`.
|
|
|
|
Think of it like this: you're trying to pack as many 3s as possible because they're the most "efficient" multiplier. The exceptions are:
|
|
- If the remainder is 1, you should take one 3 back and make two 2s instead (because `2 x 2 = 4 > 3 x 1 = 3`)
|
|
- If the remainder is 2, just keep it as a 2
|
|
|
|
This greedy insight can also be approached with dynamic programming, where we build up optimal products for smaller numbers.
|
|
|
|
approach: |
|
|
We can solve this using either a **Mathematical (Greedy)** approach or **Dynamic Programming**. The math approach is O(1), but DP helps understand the structure.
|
|
|
|
**Mathematical Approach:**
|
|
|
|
**Step 1: Handle base cases**
|
|
|
|
- If `n == 2`: Return `1` (must split into `1 + 1`)
|
|
- If `n == 3`: Return `2` (must split into `1 + 2`, giving `1 x 2 = 2`)
|
|
|
|
|
|
|
|
**Step 2: Divide n by 3 to determine the split**
|
|
|
|
- If `n % 3 == 0`: Use all 3s. The answer is `3^(n/3)`
|
|
- If `n % 3 == 1`: Use one fewer 3 and add two 2s. The answer is `3^(n/3 - 1) x 4`
|
|
- If `n % 3 == 2`: Use all 3s plus one 2. The answer is `3^(n/3) x 2`
|
|
|
|
|
|
|
|
**Dynamic Programming Approach:**
|
|
|
|
**Step 1: Initialise the DP array**
|
|
|
|
- Create array `dp` of size `n + 1` where `dp[i]` represents the maximum product for integer `i`
|
|
- Set `dp[1] = 1` as the base case
|
|
|
|
|
|
|
|
**Step 2: Fill the DP table**
|
|
|
|
- For each `i` from `2` to `n`:
|
|
- Try every possible first cut `j` from `1` to `i - 1`
|
|
- The product is either `j x (i - j)` (if we don't break further) or `j x dp[i - j]` (if we continue breaking)
|
|
- Take the maximum across all cuts
|
|
|
|
|
|
|
|
**Step 3: Return the result**
|
|
|
|
- Return `dp[n]`
|
|
|
|
common_pitfalls:
|
|
- title: Forgetting Base Cases
|
|
description: |
|
|
For `n = 2` and `n = 3`, the optimal "unforced" choice would be to not break at all, but the problem requires at least 2 pieces.
|
|
|
|
For `n = 2`: Must return `1` (from `1 + 1`)
|
|
For `n = 3`: Must return `2` (from `1 + 2`), not `3`
|
|
|
|
When using DP, remember that `dp[2]` and `dp[3]` used as subproblems can return their full value (2 and 3), since the "must break" constraint only applies to the original number.
|
|
wrong_approach: "Returning 2 for n=2 or 3 for n=3"
|
|
correct_approach: "Handle n=2 and n=3 as special cases with forced splits"
|
|
|
|
- title: Not Considering Both Options in DP
|
|
description: |
|
|
When computing `dp[i]`, for each cut position `j`, you must consider two options:
|
|
- `j x (i - j)`: Don't break the remaining part further
|
|
- `j x dp[i - j]`: Break the remaining part optimally
|
|
|
|
Missing the first option means you miss cases where not breaking further is optimal. For example, when `i = 4` and `j = 2`, the answer is `2 x 2 = 4`, not `2 x dp[2] = 2 x 1 = 2`.
|
|
wrong_approach: "Only considering j x dp[i - j]"
|
|
correct_approach: "max(j x (i - j), j x dp[i - j]) for each j"
|
|
|
|
- title: Using 1s in the Split
|
|
description: |
|
|
Including 1 in your split is almost always suboptimal. For any factor of 1, you could add that 1 to another factor to increase the product.
|
|
|
|
For example, `3 + 3 + 1` gives `3 x 3 x 1 = 9`, but `3 + 4` gives `3 x 4 = 12`.
|
|
|
|
The only time 1 appears is in forced base cases (`n = 2` and `n = 3`).
|
|
wrong_approach: "Splitting into parts that include 1"
|
|
correct_approach: "Only use 2s and 3s (except for base cases)"
|
|
|
|
key_takeaways:
|
|
- "**The power of 3**: For maximising products, 3 is the optimal factor (provable via calculus or discrete analysis)"
|
|
- "**Greedy meets math**: Sometimes a mathematical insight replaces the need for DP entirely, reducing O(n^2) to O(1)"
|
|
- "**DP transition structure**: The pattern of choosing whether to break further (`j x (i-j)` vs `j x dp[i-j]`) appears in many partition problems"
|
|
- "**Related problems**: This connects to *Cutting a Rod*, *Partition Equal Subset Sum*, and other optimisation over partitions"
|
|
|
|
time_complexity: "O(1) for the mathematical approach. O(n^2) for dynamic programming, as we compute each `dp[i]` by iterating through all possible cuts."
|
|
space_complexity: "O(1) for the mathematical approach. O(n) for dynamic programming to store the `dp` array."
|
|
|
|
solutions:
|
|
- approach_name: Mathematical (Greedy)
|
|
is_optimal: true
|
|
code: |
|
|
def integer_break(n: int) -> int:
|
|
# Base cases: must split, but would prefer not to
|
|
if n == 2:
|
|
return 1 # 1 + 1 = 2, product = 1
|
|
if n == 3:
|
|
return 2 # 1 + 2 = 3, product = 2
|
|
|
|
# For n >= 4, use as many 3s as possible
|
|
if n % 3 == 0:
|
|
# n is divisible by 3, use all 3s
|
|
return 3 ** (n // 3)
|
|
elif n % 3 == 1:
|
|
# Remainder 1: take one 3 back, use 2 + 2 instead
|
|
# (because 2 x 2 = 4 > 3 x 1 = 3)
|
|
return 3 ** (n // 3 - 1) * 4
|
|
else:
|
|
# Remainder 2: use all 3s plus one 2
|
|
return 3 ** (n // 3) * 2
|
|
explanation: |
|
|
**Time Complexity:** O(1) — Just arithmetic operations (exponentiation is O(log n) but with small exponents here).
|
|
|
|
**Space Complexity:** O(1) — Only a few variables used.
|
|
|
|
The mathematical insight is that 3 is the optimal factor. For any `n >= 5`, breaking off a 3 and multiplying gives a larger product than keeping the number whole. We handle remainders: if `n % 3 == 1`, we use `2 + 2` instead of `3 + 1` since `4 > 3`.
|
|
|
|
- approach_name: Dynamic Programming
|
|
is_optimal: false
|
|
code: |
|
|
def integer_break(n: int) -> int:
|
|
# dp[i] = maximum product for integer i
|
|
dp = [0] * (n + 1)
|
|
dp[1] = 1 # Base case
|
|
|
|
for i in range(2, n + 1):
|
|
for j in range(1, i):
|
|
# Option 1: don't break (i - j) further
|
|
# Option 2: break (i - j) optimally using dp
|
|
product = max(j * (i - j), j * dp[i - j])
|
|
dp[i] = max(dp[i], product)
|
|
|
|
return dp[n]
|
|
explanation: |
|
|
**Time Complexity:** O(n^2) — For each `i` from 2 to n, we try all cuts from 1 to i-1.
|
|
|
|
**Space Complexity:** O(n) — We store the dp array of size n+1.
|
|
|
|
For each number `i`, we try every possible first cut `j`. The remaining part `i - j` can either stay whole (giving `j * (i - j)`) or be broken further (giving `j * dp[i - j]`). We take the maximum across all possibilities.
|
|
|
|
- approach_name: Recursion with Memoization
|
|
is_optimal: false
|
|
code: |
|
|
def integer_break(n: int) -> int:
|
|
memo = {}
|
|
|
|
def helper(num: int, must_break: bool) -> int:
|
|
# If we've computed this before, return cached result
|
|
if (num, must_break) in memo:
|
|
return memo[(num, must_break)]
|
|
|
|
# Base case
|
|
if num <= 1:
|
|
return num
|
|
|
|
# If we don't have to break, we can return num itself
|
|
if not must_break:
|
|
result = num
|
|
else:
|
|
result = 0
|
|
|
|
# Try all possible first cuts
|
|
for i in range(1, num):
|
|
# First piece is i, remaining is num - i (which can stay whole)
|
|
product = i * helper(num - i, False)
|
|
result = max(result, product)
|
|
|
|
memo[(num, must_break)] = result
|
|
return result
|
|
|
|
# Start with must_break=True since we need at least 2 pieces
|
|
return helper(n, True)
|
|
explanation: |
|
|
**Time Complexity:** O(n^2) — Each subproblem is solved once, and each takes O(n) to compute.
|
|
|
|
**Space Complexity:** O(n) — Memoization cache plus recursion stack.
|
|
|
|
This top-down approach explicitly tracks whether we're forced to break. The original call has `must_break=True`, but recursive calls for remaining parts use `must_break=False` since they can stay whole if that's optimal.
|