Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 35 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0, quickjs 0.32.0, Java 25, Clang 21.0.0 and Go 1.27.1
  • By MySmartCoPilot
Free core, Pro depth: 13 of 35 minutes free
Free: A sprint with ten working days · The table: items by capacity · One row, looped downwards · 3 more sections. With a pass: 3 in-depth parts and 3 interview answers, about 22 minutes.

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

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:

best[i][w] is the larger of best[i − 1][w] and best[i − 1][w − weight(i)] + value(i); in the sprint example that is 100.Leave item i outbest[i − 1][w]same column, row abovePack item ibest[i − 1][w − weight(i)] + value(i)row above, weight(i) columns to the leftbest[i][w]the larger of the twoSprint, search filters (5 days, 50), w = 10:leave out 89, pack 50 + 50 = 100best = 100In one row: run w from high to low,so dp[w − weight(i)] still holds the row abovefor example

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.

  1. Leaving item i out keeps best[i − 1][w], the entry in the same column of the row above.
  2. Packing item i reads the row above weight(i) columns to the left, best[i − 1][w − weight(i)], and adds value(i).
  3. best[i][w] is the larger of the two.
  4. 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.
  5. 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:

The sprint table, the plan it gives and two checks Python · sprint/sprint.py
"""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

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:

One row, downwards and upwards, on the sprint and on random lists Python · one_row/one_row.py
"""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

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:

Reachable totals, and parcels split between two riders Python · subset/subset.py
"""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

Three problems, one table
Criterion 0/1 knapsackSubset sumEqual partition
Question The most value within capacity WDo some numbers add up to exactly t?Do two groups have equal totals?
An entry holds A best valueTrue or FalseTrue or False
Combining the two choices The larger of the twoEither is trueEither is true
Start 0 for every capacityOnly total 0 is trueOnly 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.

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.

Python Online Compiler Change the sprint's estimates or capacity in sprint.py, and watch which features the walk back picks.

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.

  1. Question 1 of 4 In the sprint table, best[5][6] = 56 and best[5][0] = 0. The audit log takes 6 days and is worth 66. What is best[6][6]?

    Type a number.

    Show the answer to question 1

    Answer: 66

    Leaving the audit log out keeps best[5][6] = 56. Packing it uses all 6 days and adds 66 to best[5][0] = 0. The larger is 66, the audit log alone, which beats the 56 of two-factor login and the onboarding tour.

  2. Question 2 of 4 Why does the one-row 0/1 knapsack run the capacity w from high to low?

    Choose one answer.

    Show the answer to question 2

    Answer: So that dp[w − weight] still holds its value from before this item, and no item is packed twice

    Packing reads dp[w − weight], a smaller capacity. Going downwards, that entry has not been updated for this item yet, so it still means "without this item". Going upwards it may already include the item, which packs it again: with only the onboarding tour, the upward loop packed it ten times for 60.

  3. Question 3 of 4 Why is the O(nW) running time of the knapsack table called pseudo-polynomial?

    Choose one answer.

    Show the answer to question 3

    Answer: It is polynomial in the value of W, but W takes only about log₂ W bits to write, so it is exponential in the size of the input

    Adding one digit to W multiplies the table's width by 10, while the input grows by one digit. The running time is polynomial in the number W itself, not in the length of the input that describes it.

  4. Question 4 of 4 To decide whether a list can be split into two groups with equal totals, which question does the table answer?

    Choose one answer.

    Show the answer to question 4

    Answer: Can some subset add up to exactly half of the total?

    If one group makes half the total, everything else makes the other half. So the question is subset sum with the target total ÷ 2, after checking that the total is even.

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.

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.

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.

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

  1. 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.

  2. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

  4. 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?

    Model answer, Pro. First step and follow-ups

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.