1. The Four Canonical Knapsack Variants
Given $N$ item types with weights $w_i$, values $v_i$, and knapsack capacity $W$:
- 0/1 Knapsack ($c_i \in \{0, 1\}$): Each item taken at most once. 1D recurrence iterates $w$ backward from $W$ down to $w_i$: $$dp[w] = \max(dp[w],\; dp[w - w_i] + v_i)$$
- Unbounded / Complete Knapsack ($c_i \in \{0, 1, 2, \dots\}$): Unlimited copies of each item. 1D recurrence iterates $w$ forward from $w_i$ up to $W$ so $dp[w - w_i]$ already includes item $i$: $$dp[w] = \max(dp[w],\; dp[w - w_i] + v_i)$$
- Subset Sum Bitset Acceleration ($v_i = 0/1$ feasibility): Can a subset sum to $w$? Pack the boolean array into 64-bit machine words (
std::bitset). Adding item $w_i$ becomes a single word-parallel shift-OR: $$\text{bits} \mathrel{|=} (\text{bits} \ll w_i) \quad \implies \quad O\!\left(\frac{N \cdot W}{64}\right)$$ - Bounded Knapsack ($c_i \in \{0, 1, \dots, k_i\}$): At most $k_i$ copies of item $i$. Naive transition checks all $c \in [0..k_i]$ in $O(N W \cdot k_i)$. How do we remove the $k_i$ factor?
Core Invariant — Binary Splitting ($O(N W \log K)$) & Monotonic Queue ($O(N W)$) for Bounded Knapsack
Method 1 (Binary Splitting): Any integer $k_i$ can be decomposed into $\lfloor \log_2 k_i \rfloor + 1$ bundles with sizes $1, 2, 4, \dots, 2^{p-1}$ plus the remainder $R = k_i - (2^p - 1)$. Every integer in $[0, k_i]$ is uniquely expressible as a subset sum of these $\approx \log_2 k_i$ bundles! Running standard 0/1 Knapsack on these bundles reduces time to $O(W \sum \log k_i)$.
Method 2 (Monotonic Deque per Residue Class $\bmod w_i$): For a fixed item $(w_i, v_i, k_i)$, transition $dp_{new}[w]$ only depends on capacities congruent to $w \pmod{w_i}$. Writing $w = q \cdot w_i + r$, we have:
This is a sliding-window maximum of width $k_i + 1$ over $q = 0, 1, \dots, \lfloor (W-r)/w_i \rfloor$, solvable in exact $O(W)$ time per item ($O(N W)$ total) using a monotonic deque!
Observe why 0/1 Knapsack iterates capacity w downwards (preventing item reuse), how Patience Sorting finds LIS in O(N log N), and how Myers Diff slides along diagonal snakes k = x - y.
Invariant & State
2. The $\sqrt{S}$ Frequency Compression Trick for Subset Sum ($\sum w_i = S$)
When the sum of all item weights is $\sum_{i=1}^N w_i = S$, at most $O(\sqrt{S})$ distinct weight values can exist! Why? Because the minimum sum of $d$ distinct positive integers is $1 + 2 + \dots + d = d(d+1)/2 \le S \implies d \le \sqrt{2S}$. By grouping identical weights into (weight, count) pairs and applying binary splitting (or packing pairs of equal weights into a next-level weight $2w$ until at most 2 copies of each weight remain), Subset Sum on $\sum w_i = S$ runs in $O\!\left(\frac{S \sqrt{S}}{64}\right)$ time!
Bundles of size 1, 2, 4, and remainder 6
(1 + 2 + 4 + 6 = 13)"] Split --> ZeroOne["Every count c in [0..13] can be formed
as a 0/1 subset of {1, 2, 4, 6}!"]
Hardware Reality — AVX2 / AVX-512 Word-Level Bitset Parallelism
In C++, std::bitset<100001> dp; dp |= (dp << w); compiles under -O3 -march=native to a tight loop of 256-bit AVX2 (VPXOR/VPOR/VPSLLQ) or 512-bit AVX-512 instructions processing 256 to 512 capacity states per CPU clock cycle. For $N = 2000, W = 100{,}000$, scalar DP executes $2 \times 10^8$ loop iterations (~150 ms), whereas std::bitset executes $2000 \times (100000 / 256) \approx 7.8 \times 10^5$ SIMD instructions in under 1.2 milliseconds.
3. Complexity Matrix
| Knapsack Problem Variant | 1D Loop Direction | Time Complexity | Space Complexity | Key Technique |
|---|---|---|---|---|
| 0/1 Knapsack ($c_i \in \{0, 1\}$) | Backward ($W \to w_i$) | O(N * W) | O(W) | In-place 1D rolling array |
| Unbounded Knapsack ($c_i \ge 0$) | Forward ($w_i \to W$) | O(N * W) | O(W) | Reuse updated $dp[w - w_i]$ |
| Bounded Knapsack (Binary Split) | Backward per bundle | O(W * sum log k_i) | O(W) | Powers-of-2 + remainder bundles |
| Bounded Knapsack (Monotonic Deque) | Residue classes $\bmod w_i$ | O(N * W) | O(W) | Sliding window max on $dp[j w_i + r] - j v_i$ |
| Subset Sum Bitset ($sum w_i = S$) | Word-parallel |= |
O(S sqrt(S) / 64) | O(S / 64) | Frequency grouping + std::bitset |
4. Reference C++20 Implementation
#include <algorithm>
#include <bitset>
#include <cstdint>
#include <deque>
#include <vector>
struct BoundedItem {
int weight;
long long value;
int count;
};
// 1. Bounded Knapsack in O(W * sum(log k_i)) via Binary Splitting
long long bounded_knapsack_binary_split(const std::vector<BoundedItem>& items, int capacity) {
std::vector<long long> dp(capacity + 1, 0);
auto apply_01_item = [&](int w, long long v) {
for (int cap = capacity; cap >= w; --cap) {
dp[cap] = std::max(dp[cap], dp[cap - w] + v);
}
};
for (const auto& item : items) {
int rem = item.count;
for (int b = 1; b <= rem; b <<= 1) {
int bundle_w = b * item.weight;
if (bundle_w <= capacity) {
apply_01_item(bundle_w, b * item.value);
}
rem -= b;
}
if (rem > 0 && rem * item.weight <= capacity) {
apply_01_item(rem * item.weight, rem * item.value);
}
}
return dp[capacity];
}
// 2. Bounded Knapsack in Strict O(N * W) Time via Monotonic Deque over Residue Classes
long long bounded_knapsack_monotonic_queue(const std::vector<BoundedItem>& items, int capacity) {
std::vector<long long> dp(capacity + 1, 0);
std::deque<std::pair<int, long long>> dq; // (quotient q, shifted_val = dp[q*w + r] - q*v)
for (const auto& item : items) {
const int w = item.weight;
const long long v = item.value;
const int k = item.count;
if (w > capacity || k == 0) continue;
for (int r = 0; r < w; ++r) {
dq.clear();
int max_q = (capacity - r) / w;
for (int q = 0; q <= max_q; ++q) {
long long shifted = dp[q * w + r] - q * v;
while (!dq.empty() && dq.back().second <= shifted) {
dq.pop_back();
}
dq.emplace_back(q, shifted);
// Evict window elements older than q - k
if (dq.front().first < q - k) {
dq.pop_front();
}
dp[q * w + r] = dq.front().second + q * v;
}
}
}
return dp[capacity];
}
// 3. O(N * W / 64) Word-Parallel Subset Sum using std::bitset
template <std::size_t MAXW = 100001>
bool subset_sum_bitset(const std::vector<int>& weights, int target) {
if (target < 0 || static_cast<std::size_t>(target) >= MAXW) return false;
std::bitset<MAXW> reachable;
reachable.set(0);
for (int w : weights) {
reachable |= (reachable << w);
}
return reachable.test(target);
}
Production Pitfall — Splitting $k_i$ into Powers of 2 Up to $\lceil \log_2 k_i \rceil$ Instead of Remainder $R$
When binary-splitting $k_i = 13$, if you emit powers of two $1, 2, 4, 8$, their sum is $15 > 13$—allowing the solver to illegally take 14 or 15 copies of the item! You must subtract $1, 2, 4, \dots$ while $2^b \le \text{rem}$ ($13 - 1 - 2 - 4 = 6$) and emit the leftover remainder $R = 6$ as the final bundle so the bundle sum equals $1 + 2 + 4 + 6 = 13$ exactly. Also note: when weights $W \le 10^9$ are huge but values $\sum v_i \le 10^5$ are small, invert the DP state and objective: let $dp[v]$ be the minimum weight required to achieve exact value $v$ in $O(N \sum v_i)$ time!