Data Structures & Algorithms (DSA) Module 14 – Dynamic programming
0/1 knapsack, subset sum and partition
Solve 0/1 knapsack with an items-by-capacity table, shrink it to one row looped downwards, and reuse it for subset sum and equal partition.
What you will learn
- Solve 0/1 knapsack with an O(nW) table and walk back to the chosen items
- Optimise the table to one dimension with a capacity loop that runs downwards
- Decide subset sum and equal partition with a table of reachable totals
- Explain what pseudo-polynomial time means for the knapsack table
Before you start
What you’ll learn · Before you start
What you will learn
- Solve 0/1 knapsack with an O(nW) table and walk back to the chosen items
- Optimise the table to one dimension with a capacity loop that runs downwards
- Decide subset sum and equal partition with a table of reachable totals
- Explain what pseudo-polynomial time means for the knapsack table
Before you start
On this page
The 0/1 knapsack problem gives you items, each with a weight and a value, and a capacity W, and asks for the most valuable set of items that fits, every item taken once or not at all. Dynamic programming solves it with a table: best[i][w] is the most value the first i items give within capacity w, and item i is either left out, keeping best[i − 1][w], or packed, adding its value to best[i − 1][w − weight]. That is O(nW) time, and one row is enough if the capacity loop runs downwards. Subset sum and equal partition are the same table with True and False in place of values. This lesson packs a sprint of ten working days, shrinks the table to one row, and splits parcels between two riders.
A sprint with ten working days
A team has 10 working days in its next sprint and six features it could build, each with an estimate in days and a value to users: an onboarding tour (1 day, 6), dark mode (2, 15), CSV export (3, 24), two-factor login (5, 50), search filters (5, 50) and an audit log (6, 66). Which features should it build to deliver the most value?
Six features allow 2⁶ = 64 plans; 100 would allow about 1.3 × 10³⁰. Sorting by value per day fails too: it starts with the audit log, 11 a day, and ends with 96, while the best plan is worth 100. That is the 0/1 knapsack: each feature is a whole item, never half of one.
The table: items by capacity
The state is best[i][w], the most value from the first i features within w days. Feature i is left out or packed:
How one entry of the knapsack table is filled
Text description of the diagram
The diagram shows how one entry of the 0/1 knapsack table is computed, from top to bottom.
- Leaving item i out keeps best[i − 1][w], the entry in the same column of the row above.
- Packing item i reads the row above weight(i) columns to the left, best[i − 1][w − weight(i)], and adds value(i).
- best[i][w] is the larger of the two.
- In the lesson's sprint, adding search filters (5 days, value 50) at w = 10 compares 89 for leaving it out with 50 + 50 = 100 for packing it, so the entry is 100.
- With a single row instead of the table, w must run from high to low, so that dp[w − weight(i)] still holds the value from the row above when dp[w] reads it.
Row 0 is 0 everywhere, a feature that does not fit is left out, and the answer is best[n][W]. The sprint’s table, row by row:
"""Choosing features for a sprint of 10 working days: a 0/1 knapsack, filled as a table and walked back."""
from itertools import combinations
CAPACITY = 10 # working days in the sprint
features = [ # (name, days of work, value to users)
("onboarding tour", 1, 6),
("dark mode", 2, 15),
("CSV export", 3, 24),
("two-factor login", 5, 50),
("search filters", 5, 50),
("audit log", 6, 66),
]
def table(items, capacity):
"""best[i][w]: the most value from the first i items within w days."""
best = [[0] * (capacity + 1)]
for _, days, value in items:
above = best[-1]
row = list(above) # leaving the item out keeps the row above
for w in range(days, capacity + 1):
row[w] = max(above[w], above[w - days] + value)
best.append(row)
return best
def walk_back(items, best, capacity):
chosen, w = [], capacity
for i in range(len(items), 0, -1):
if best[i][w] != best[i - 1][w]: # the item made the difference, so it was packed
name, days, _ = items[i - 1]
chosen.append(name)
w -= days
return chosen[::-1]
best = table(features, CAPACITY)
print("days: " + " ".join(f"{w:>3}" for w in range(CAPACITY + 1)))
print("no features: " + " ".join(f"{x:>3}" for x in best[0]))
for (name, days, value), row in zip(features, best[1:]):
print(f"+ {name:<16} " + " ".join(f"{x:>3}" for x in row))
print(f"best value {best[-1][-1]}: {walk_back(features, best, CAPACITY)}")
ratio = sorted(features, key=lambda f: f[2] / f[1], reverse=True)
left, value, picked = CAPACITY, 0, []
for name, days, v in ratio:
if days <= left:
left, value, picked = left - days, value + v, picked + [name]
print(f"best value per day first: {value}: {picked}")
top = max(
(sum(v for _, _, v in c), [n for n, _, _ in c])
for r in range(len(features) + 1)
for c in combinations(features, r)
if sum(d for _, d, _ in c) <= CAPACITY
)
print(f"every one of the {2 ** len(features)} subsets tried: {top[0]}: {top[1]}") Output
days: 0 1 2 3 4 5 6 7 8 9 10 no features: 0 0 0 0 0 0 0 0 0 0 0 + onboarding tour 0 6 6 6 6 6 6 6 6 6 6 + dark mode 0 6 15 21 21 21 21 21 21 21 21 + CSV export 0 6 15 24 30 39 45 45 45 45 45 + two-factor login 0 6 15 24 30 50 56 65 74 80 89 + search filters 0 6 15 24 30 50 56 65 74 80 100 + audit log 0 6 15 24 30 50 66 72 81 90 100 best value 100: ['two-factor login', 'search filters'] best value per day first: 96: ['audit log', 'CSV export', 'onboarding tour'] every one of the 64 subsets tried: 100: ['two-factor login', 'search filters']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sprint.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The plan is two-factor login and search filters, worth 100 in exactly 10 days. To read it back, start at best[6][10] and move up a row at a time: wherever an entry differs from the one above it, that feature was packed, so record it and subtract its days. Search filters lifts 89 to 100, and two-factor login takes the walk from 5 days to 0. Trying all 64 subsets gives the same plan.
One row, looped downwards
Row i reads only row i − 1, so a single row dp can be updated in place: dp[w] = max(dp[w], dp[w − days] + value).
The order of w decides what that means. Downwards, dp[w − days] has not been touched by this feature yet, so it
still means “without this feature”. Upwards, it may already include the feature, which then gets packed again:
"""The sprint table in one row: capacities looped downwards, then upwards by mistake."""
import random
CAPACITY = 10
features = [("onboarding tour", 1, 6), ("dark mode", 2, 15), ("CSV export", 3, 24),
("two-factor login", 5, 50), ("search filters", 5, 50), ("audit log", 6, 66)]
def one_row(items, capacity, downwards=True):
dp = [0] * (capacity + 1) # dp[w]: the most value within w days, for the items seen so far
for _, days, value in items:
ws = range(capacity, days - 1, -1) if downwards else range(days, capacity + 1)
for w in ws:
dp[w] = max(dp[w], dp[w - days] + value)
return dp
print(f"after the tour, downwards: {one_row(features[:1], CAPACITY)}")
print(f"after the tour, upwards: {one_row(features[:1], CAPACITY, downwards=False)}")
print(f"all six, downwards: {one_row(features, CAPACITY)}")
print(f"all six, upwards: {one_row(features, CAPACITY, downwards=False)}")
rng = random.Random(14)
differ = 0
for _ in range(1_000):
items = [("", rng.randint(1, 8), rng.randint(1, 60)) for _ in range(rng.randint(1, 8))]
differ += one_row(items, 20)[-1] != one_row(items, 20, downwards=False)[-1]
print(f"1,000 random lists, capacity 20: the upward loop changed the answer {differ} times") Output
after the tour, downwards: [0, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6] after the tour, upwards: [0, 6, 12, 18, 24, 30, 36, 42, 48, 54, 60] all six, downwards: [0, 6, 15, 24, 30, 50, 66, 72, 81, 90, 100] all six, upwards: [0, 6, 15, 24, 30, 50, 66, 72, 81, 90, 100] 1,000 random lists, capacity 20: the upward loop changed the answer 1000 times
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 one_row.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
With only the onboarding tour, the upward loop packs it ten times for 60. On the full sprint both directions happen to end with 100, so this example alone would miss the bug, while on 1,000 random lists the upward loop changed every answer. It solves another problem, the unbounded knapsack, where every item can be taken any number of times, which the next lesson uses for coin change.
Subset sum and equal partition
Subset sum asks whether some of the numbers add up to exactly a target t. It is the same table with booleans: can[0] is true, for the empty subset, and each number x makes t reachable when t − x already was, t again running downwards. Equal partition asks whether a list splits into two groups with equal totals, which is subset sum with the target total ÷ 2 when the total is even:
"""Subset sum and equal partition: the same table with True and False instead of values."""
def reachable(numbers, limit):
"""can[t] is True when some subset of the numbers adds up to exactly t (t from 0 to limit)."""
can = [True] + [False] * limit # the empty subset makes 0
for x in numbers:
for t in range(limit, x - 1, -1): # downwards, so each number is used at most once
if can[t - x]:
can[t] = True
return can
def equal_split(numbers):
"""One group with half the total, or None; the other group is everything else."""
total = sum(numbers)
if total % 2:
return None
half = total // 2
# rows[i][t]: some subset of the first i numbers adds up to t
rows = [[True] + [False] * half]
for x in numbers:
above = rows[-1]
rows.append([above[t] or (t >= x and above[t - x]) for t in range(half + 1)])
if not rows[-1][half]:
return None
group, t = [], half
for i in range(len(numbers), 0, -1):
if not rows[i - 1][t]: # t was not reachable without number i, so number i is in the group
group.append(numbers[i - 1])
t -= numbers[i - 1]
return group[::-1]
payments = [7, 11, 3, 19, 5]
can = reachable(payments, 45)
print(f"totals that some of {payments} make: {[t for t in range(46) if can[t]]}")
print(f"26 reachable: {can[26]}; 2 reachable: {can[2]}")
parcels = [8, 6, 5, 7, 3, 9, 4]
group = equal_split(parcels)
print(f"parcels {parcels}, total {sum(parcels)}: one rider takes {group}, the other the rest, {sum(group)} kg each")
print(f"parcels [8, 6, 5, 3]: {equal_split([8, 6, 5, 3])}") Output
totals that some of [7, 11, 3, 19, 5] make: [0, 3, 5, 7, 8, 10, 11, 12, 14, 15, 16, 18, 19, 21, 22, 23, 24, 26, 27, 29, 30, 31, 33, 34, 35, 37, 38, 40, 42, 45] 26 reachable: True; 2 reachable: False parcels [8, 6, 5, 7, 3, 9, 4], total 42: one rider takes [8, 6, 7], the other the rest, 21 kg each parcels [8, 6, 5, 3]: [6, 5]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 subset.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
| Criterion | 0/1 knapsack | Subset sum | Equal partition |
|---|---|---|---|
| Question | The most value within capacity W | Do some numbers add up to exactly t? | Do two groups have equal totals? |
| An entry holds | A best value | True or False | True or False |
| Combining the two choices | The larger of the two | Either is true | Either is true |
| Start | 0 for every capacity | Only total 0 is true | Only total 0 is true |
| Answer | best[n][W] | can[t] | can[total ÷ 2], for an even total |
The seven parcels weigh 42 kg in all, and one rider can take 8, 6 and 7, exactly 21 kg. A Python integer used as a bitset adds a number to every total with one shift and one OR. When the capacity is huge but the values are small, a table indexed by value replaces the table indexed by weight.
Why O(nW) is called pseudo-polynomial
The table has (n + 1) × (W + 1) entries, polynomial in the number W. But W takes only about log₂ W bits to write, so one more decimal digit in W multiplies the work by 10 while the input grows by one digit: in the size of the input, the time is exponential. A capacity of 10⁶ with 100 items is 10⁸ entries; a capacity of 10¹² is out of reach. Richard Karp’s 1972 list of NP-complete problems includes knapsack, in the form “can chosen numbers add up to exactly a target”, which is subset sum, and partition, so no algorithm polynomial in the input’s length is known for them.
Exercise
Exercise · Medium · Python
Split exam marks into two groups with equal totals
A teacher wants to split the answer sheets of a test between two markers so that both mark the same total number of marks. marks[i] is the mark of sheet i, a whole number of at least 0.
Write split_marks(marks). It returns the indices of the sheets for one marker, in increasing order, so that their marks add up to exactly half of the total; the other marker takes every other sheet. When no such split exists, including when the total is odd, it returns None. An empty list splits into two empty groups, so it returns [].
The starter code tries every subset, which works for 20 sheets and is hopeless for 100. Replace it with a table of reachable totals, as in the lesson, and rebuild one group from it. The sample tests check small lists, odd and impossible totals, zeros, 300 random lists against the slow search, and a class of 100 sheets.
Starter code · marks.py
from itertools import combinations
def split_marks(marks):
"""Indices of one group of sheets whose marks add up to half the total, or None if there is no such group."""
# Tries every subset: fine for a few sheets, hopeless for a class. Replace it with a table of reachable totals.
if len(marks) > 20:
raise ValueError("too many sheets to try every subset")
total = sum(marks)
if total % 2:
return None
for r in range(len(marks) + 1):
for group in combinations(range(len(marks)), r):
if sum(marks[i] for i in group) * 2 == total:
return list(group)
return None The sample tests · test_marks.py
import random
from itertools import combinations
from marks import split_marks
def is_half(marks, group):
"""group is a list of distinct indices, in increasing order, whose marks add up to half the total."""
return (
group == sorted(set(group))
and all(0 <= i < len(marks) for i in group)
and 2 * sum(marks[i] for i in group) == sum(marks)
)
def can_split(marks):
total = sum(marks)
return total % 2 == 0 and any(
2 * sum(c) == total for r in range(len(marks) + 1) for c in combinations(marks, r)
)
def test_small_class():
"""12 marks in all: one marker gets 6"""
marks = [3, 1, 4, 2, 2]
group = split_marks(marks)
assert group is not None and is_half(marks, group)
def test_odd_total():
"""an odd total can never be halved"""
assert split_marks([5, 4, 4]) is None
def test_even_but_impossible():
"""14 in all, but 7 cannot be made from even marks"""
assert split_marks([2, 2, 2, 8]) is None
assert split_marks([10, 2, 2]) is None
def test_empty_and_zeros():
"""no sheets, and sheets of 0 marks"""
assert split_marks([]) == []
group = split_marks([0, 0, 0])
assert group is not None and is_half([0, 0, 0], group)
def test_against_every_subset():
"""300 random classes of up to 12 sheets with marks from 0 to 20"""
rng = random.Random(23)
for _ in range(300):
marks = [rng.randint(0, 20) for _ in range(rng.randint(0, 12))]
group = split_marks(marks)
if can_split(marks):
assert group is not None and is_half(marks, group), marks
else:
assert group is None, marks
def test_a_class_of_one_hundred():
"""100 sheets of up to 100 marks: far too many subsets to try"""
rng = random.Random(24)
marks = [rng.randint(0, 100) for _ in range(100)]
if sum(marks) % 2:
marks[0] += 1
group = split_marks(marks)
assert group is not None and is_half(marks, group) A hint
Let half = sum(marks) // 2 and build rows of booleans: rows[i][t] says whether some of the first i marks add up to t, with rows[0] true only at t = 0. To rebuild a group, walk back from rows[n][half]: if t was already reachable without sheet i, leave the sheet out; otherwise take it and subtract its mark.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Why the table and the downward row are right, with four slips reproduced Pro: opens with a Pro, Premium or Ultimate pass
The recurrence has a two-case proof, and the one-row version needs one more argument about the order of the loop. This part gives both, then runs four plausible slips against a search of every subset, each with the smallest list that breaks it.
In this part:
- The proof: a best plan packs item i or leaves it out
- Four slips, each with the smallest list that breaks it
1 runnable example (Python) · about 2 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Subset sum at speed: a Python integer as a bitset, timed against a list Pro: opens with a Pro, Premium or Ultimate pass
In Python the boolean row of subset sum costs one interpreted step per total and number, and an integer used as a bitset does the same work in machine words. This part times both on 1,000 numbers and totals up to 100,000, and shows how to rebuild a group without giving the speed back.
In this part:
- One shift and one OR per number
- Measured: more than a thousand times faster
- Rebuilding a group from the bitsets
- Counting subsets instead
- When the bitset is the wrong tool
1 runnable example (Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Huge capacities, small values, and the code in five languages Pro: opens with a Pro, Premium or Ultimate pass
When the capacity runs into billions the table by weight cannot be built, but a table by value often can. This part builds it, checks it against every subset, and gives the one-row knapsack with its plan in five languages.
In this part:
- A table indexed by value
- The one-row knapsack with its plan in five languages
- Practice set
- Key takeaways of these parts
3 runnable examples (Build and run all three, C++, Go, Java, JavaScript, Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
Why does the one-row 0/1 knapsack loop the capacity downwards, and what does the same loop solve when it runs upwards?
The one-row version updates dp[w] = max(dp[w], dp[w − weight] + value) in place, and packing an item must read dp[w − weight] as it was before that item was considered. Running w from high to low guarantees it: w − weight is smaller than w, so its entry has not been updated in this item's pass yet and still means "without this item". Running upwards, dp[w − weight] may already include the item, so it gets packed a second time, a third, and so on: with one item of weight 1 and value 6, the upward loop fills 10 units of capacity with ten copies for 60. That is exactly the unbounded knapsack, where every item may be used any number of times, so the upward loop is right for coin change and rod cutting and wrong for 0/1 choices.
-
Mid to senior
A truck holds 25,000,000 grams and there are 80 parcels, each worth 1 to 100 points. Your knapsack table by weight runs out of memory. What do you change, how big is the new table, and what would you do if the values were large too?
-
Fresher to mid
You must split 500 answer sheets, each worth up to 1,000 marks, between two markers with equal totals, and return one marker's sheets. Your table of booleans is correct but uses gigabytes and takes minutes in Python. Estimate why, then make it fit and run fast.
-
Fresher to mid
A teammate says that packing the items with the best value per kilogram first is optimal, because it is how you would fill a bag with rice and lentils. When is the claim right, when is it wrong, and how do you show it?
GATE CS
Knapsack tables with a capacity of about 10 are small enough to fill by hand, a good way to practise dynamic programming, which the GATE 2027 Computer Science syllabus lists under Algorithms.
Key takeaways
- 0/1 knapsack: best[i][w] = max(best[i − 1][w], best[i − 1][w − weight] + value), in O(nW) time.
- Walk back from best[n][W]: an entry that differs from the one above means the item was packed.
- One row works if the capacity runs downwards; upwards, items get packed more than once.
- Subset sum and partition are the same table with booleans; partition targets half the total.
References
- Reducibility among combinatorial problems (Complexity of Computer Computations, 1972) (Springer (Plenum Press))
- The theory of dynamic programming (Bulletin of the American Mathematical Society 60(6)) (American Mathematical Society)
- NIST Dictionary of Algorithms and Data Structures: knapsack problem (National Institute of Standards and Technology (NIST))
- NIST Dictionary of Algorithms and Data Structures: unbounded knapsack problem (National Institute of Standards and Technology (NIST))
- Built-in types: bitwise operations on integer types, int.bit_count() (Python Software Foundation)
- time.perf_counter() (Python Software Foundation)
- GATE 2027 syllabus for Computer Science and Information Technology (CS) (Indian Institute of Technology Madras (GATE 2027))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress