Files
codetutor/backend/data/questions/target-sum.yaml

247 lines
10 KiB
YAML

title: Target Sum
slug: target-sum
difficulty: medium
leetcode_id: 494
leetcode_url: https://leetcode.com/problems/target-sum/
categories:
- arrays
- dynamic-programming
patterns:
- slug: dynamic-programming
is_optimal: true
- slug: backtracking
is_optimal: false
function_signature: "def find_target_sum_ways(nums: list[int], target: int) -> int:"
test_cases:
visible:
- input: { nums: [1, 1, 1, 1, 1], target: 3 }
expected: 5
- input: { nums: [1], target: 1 }
expected: 1
- input: { nums: [1, 2, 1], target: 0 }
expected: 2
hidden:
- input: { nums: [1], target: 2 }
expected: 0
- input: { nums: [0, 0, 0, 0, 0], target: 0 }
expected: 32
- input: { nums: [2, 3, 5], target: 0 }
expected: 0
- input: { nums: [1, 0], target: 1 }
expected: 2
- input: { nums: [1, 2, 3, 4, 5], target: 3 }
expected: 3
- input: { nums: [100], target: -200 }
expected: 0
- input: { nums: [7, 9, 3, 8, 0, 2, 4, 8, 3, 9], target: 0 }
expected: 0
description: |
You are given an integer array `nums` and an integer `target`.
You want to build an **expression** out of nums by adding one of the symbols `'+'` and `'-'` before each integer in nums and then concatenate all the integers.
For example, if `nums = [2, 1]`, you can add a `'+'` before `2` and a `'-'` before `1` and concatenate them to build the expression `"+2-1"`.
Return *the number of different expressions that you can build, which evaluates to* `target`.
constraints: |
- `1 <= nums.length <= 20`
- `0 <= nums[i] <= 1000`
- `0 <= sum(nums[i]) <= 1000`
- `-1000 <= target <= 1000`
examples:
- input: "nums = [1,1,1,1,1], target = 3"
output: "5"
explanation: "There are 5 ways to assign symbols to make the sum of nums be target 3: -1+1+1+1+1, +1-1+1+1+1, +1+1-1+1+1, +1+1+1-1+1, +1+1+1+1-1."
- input: "nums = [1], target = 1"
output: "1"
explanation: "There is only one way: +1 = 1."
explanation:
intuition: |
Imagine you have a set of coins, and for each coin you must decide whether to put it in a "positive pile" or a "negative pile". The question becomes: how many ways can you split the coins so that the positive pile minus the negative pile equals the target?
This reframing reveals a powerful insight. Let `P` be the sum of numbers assigned `+` and `N` be the sum assigned `-`. We know:
- `P + N = total` (all numbers are used)
- `P - N = target` (the goal)
Adding these equations: `2P = total + target`, so `P = (total + target) / 2`.
**The problem transforms into a subset sum problem**: find how many subsets of `nums` sum to exactly `P`. This is a classic dynamic programming pattern!
Think of it like this: instead of tracking all possible sums from `-total` to `+total` (which could be huge), we only need to count subsets that reach one specific target sum. This dramatically reduces the problem space.
approach: |
We solve this using **Dynamic Programming (Subset Sum Count)**:
**Step 1: Transform the problem**
- Calculate `total = sum(nums)`
- Calculate `subset_sum = (total + target) / 2`
- If `(total + target)` is odd, return `0` — no valid split exists
- If `total + target < 0`, return `0` — target is unreachable
&nbsp;
**Step 2: Initialise the DP array**
- Create `dp` array of size `subset_sum + 1`
- `dp[s]` represents the number of ways to form sum `s`
- Set `dp[0] = 1` — there's exactly one way to form sum `0` (use no elements)
&nbsp;
**Step 3: Fill the DP table**
- For each number `num` in `nums`:
- Iterate `s` from `subset_sum` down to `num` (reverse order is crucial!)
- Update: `dp[s] += dp[s - num]`
- This adds the count of ways to reach `s - num` (if we include `num`)
&nbsp;
**Step 4: Return the result**
- Return `dp[subset_sum]` — the number of subsets that sum to our target
&nbsp;
The reverse iteration in Step 3 ensures each number is used at most once per subset. If we iterated forward, we'd count the same number multiple times.
common_pitfalls:
- title: Brute Force Exponential Blowup
description: |
The naive approach tries all `2^n` combinations of `+` and `-` signs using recursion or backtracking.
With `n = 20`, this means up to `2^20 = 1,048,576` combinations. While this might pass for small inputs, it's inefficient and the DP approach is much faster.
More importantly, the brute force approach doesn't reveal the elegant mathematical structure of the problem.
wrong_approach: "Recursively try all 2^n sign combinations"
correct_approach: "Transform to subset sum and use DP"
- title: Forward Iteration in DP
description: |
When filling the DP array, you might be tempted to iterate `s` from `0` to `subset_sum`. This is wrong!
Forward iteration allows the same element to be counted multiple times. For example, with `num = 2`, updating `dp[2]` first would affect `dp[4]` in the same iteration, treating `2` as if it could be used twice.
Always iterate in reverse (from `subset_sum` down to `num`) to ensure each element is considered only once.
wrong_approach: "for s in range(num, subset_sum + 1)"
correct_approach: "for s in range(subset_sum, num - 1, -1)"
- title: Forgetting Edge Cases
description: |
Several edge cases can trip you up:
- If `(total + target)` is odd, no valid partition exists — you can't split integers into two groups with a non-integer difference
- If `target > total` or `target < -total`, the target is unreachable
- If `total + target < 0`, the subset sum would be negative, which is impossible
Handle these before starting the DP computation.
- title: Zeros in the Array
description: |
Zeros double the count of ways! A zero can be assigned either `+` or `-` without changing the sum.
With `k` zeros in the array, each valid subset has `2^k` variations. The DP approach handles this automatically since `dp[s] += dp[s - 0]` effectively doubles the count.
key_takeaways:
- "**Problem transformation**: Recognising that `+/-` assignment is equivalent to subset partitioning unlocks an efficient solution"
- "**Subset sum pattern**: Counting subsets that sum to a target is a foundational DP pattern — memorise the `dp[s] += dp[s - num]` recurrence"
- "**Reverse iteration trick**: When each element can only be used once, iterate the DP array in reverse to avoid double-counting"
- "**Mathematical insight**: Always look for ways to simplify the state space — transforming from tracking sums in `[-total, total]` to just `[0, subset_sum]` is a huge optimisation"
time_complexity: "O(n * subset_sum). We process each of the `n` numbers once, and for each number we update up to `subset_sum` entries in the DP array."
space_complexity: "O(subset_sum). We use a 1D DP array of size `subset_sum + 1`. This can be at most `O(total)` where `total = sum(nums)`."
solutions:
- approach_name: Dynamic Programming (Subset Sum)
is_optimal: true
code: |
def find_target_sum_ways(nums: list[int], target: int) -> int:
total = sum(nums)
# Edge cases: impossible to reach target
if (total + target) % 2 != 0: # Can't split into equal parts
return 0
if total + target < 0: # Target too negative
return 0
# Transform: find subsets that sum to this value
subset_sum = (total + target) // 2
# dp[s] = number of ways to form sum s
dp = [0] * (subset_sum + 1)
dp[0] = 1 # One way to form sum 0: use nothing
for num in nums:
# Iterate in reverse to avoid using same element twice
for s in range(subset_sum, num - 1, -1):
dp[s] += dp[s - num]
return dp[subset_sum]
explanation: |
**Time Complexity:** O(n * subset_sum) — For each number, we update the DP array.
**Space Complexity:** O(subset_sum) — 1D DP array.
We transform the problem into counting subsets that sum to `(total + target) / 2`. The 1D DP array counts ways to form each possible sum, updated in reverse order to ensure each number is used once.
- approach_name: Recursion with Memoisation
is_optimal: false
code: |
def find_target_sum_ways(nums: list[int], target: int) -> int:
from functools import lru_cache
@lru_cache(maxsize=None)
def count_ways(index: int, current_sum: int) -> int:
# Base case: processed all numbers
if index == len(nums):
return 1 if current_sum == target else 0
# Try adding current number with + or -
add = count_ways(index + 1, current_sum + nums[index])
subtract = count_ways(index + 1, current_sum - nums[index])
return add + subtract
return count_ways(0, 0)
explanation: |
**Time Complexity:** O(n * total_sum) — Each unique (index, sum) state is computed once.
**Space Complexity:** O(n * total_sum) — Memoisation cache for all states.
This approach directly models the problem: at each index, we can add or subtract the current number. Memoisation prevents recomputing the same (index, current_sum) pairs. While intuitive, it uses more space than the subset sum DP approach.
- approach_name: Brute Force (Backtracking)
is_optimal: false
code: |
def find_target_sum_ways(nums: list[int], target: int) -> int:
count = 0
def backtrack(index: int, current_sum: int) -> None:
nonlocal count
# Base case: used all numbers
if index == len(nums):
if current_sum == target:
count += 1
return
# Try + and - for current number
backtrack(index + 1, current_sum + nums[index])
backtrack(index + 1, current_sum - nums[index])
backtrack(0, 0)
return count
explanation: |
**Time Complexity:** O(2^n) — Every number has two choices, leading to exponential combinations.
**Space Complexity:** O(n) — Recursion stack depth.
This brute force approach explicitly tries all `2^n` combinations of `+` and `-` signs. While correct and easy to understand, it's inefficient for larger inputs. Included to show the problem's natural recursive structure before optimisation.