|
| 1 | +# 2.14 Count Ways To Pick K Coins Divisible By M |
| 2 | + |
| 3 | +> **Source:** [`src/main/kotlin/google/CountNumberOfWaysToPickKCoinsSumDivisibleByM.kt`](https://github.com/arpanpathak/AdvancedAlgorithmPatterns/blob/main/src/main/kotlin/google/CountNumberOfWaysToPickKCoinsSumDivisibleByM.kt) |
| 4 | +> **Pattern:** memoized (index, count, remainder) · **Core page** |
| 5 | +
|
| 6 | +## The Problem |
| 7 | + |
| 8 | +Coins are numbered `0..n-1`. Count how many ways to pick **exactly k coins** such that their sum is **divisible by m** (result mod $10^9+7$). |
| 9 | + |
| 10 | +- Constraints: $1 \le k \le n$; `m` fits in `Int`. |
| 11 | + |
| 12 | +## Examples |
| 13 | + |
| 14 | +``` |
| 15 | +Input: n = 4, k = 2, m = 3 -> Output: 2 ({0,3} and {1,2} both sum to a multiple of 3) |
| 16 | +Input: n = 5, k = 3, m = 3 -> Output: 2 |
| 17 | +``` |
| 18 | + |
| 19 | +## Intuition — the state is (index, picks left, remainder); the remainder *is* the carry |
| 20 | + |
| 21 | +The count-with-a-condition DP needs three axes: |
| 22 | + |
| 23 | +- `idx` — which coin we're deciding next; |
| 24 | +- `k` — how many picks remain; |
| 25 | +- `rem` — the running sum **modulo m** (the only part of the sum that matters for divisibility). |
| 26 | + |
| 27 | +The recurrence is the classic pick/skip: |
| 28 | + |
| 29 | +``` |
| 30 | +solve(idx, k, rem): |
| 31 | + k == 0 -> 1 iff rem == 0 |
| 32 | + (n - idx) < k -> 0 (not enough coins left — pruning) |
| 33 | + else -> solve(idx+1, k, rem) # skip coin idx |
| 34 | + + solve(idx+1, k-1, (rem + idx) % m) # pick coin idx |
| 35 | +``` |
| 36 | + |
| 37 | +**Why does `rem` carry modulo instead of the raw sum?** Only `sum % m` decides divisibility, and `(a + b) % m` is computable from `a % m` — so the remainder is a complete summary of the sum, bounded by `m` instead of by `n·m`. That's what keeps the state space at $O(n \cdot k \cdot m)$ rather than exponential. |
| 38 | + |
| 39 | +**Why the `(n - idx) < k` pruning?** If fewer coins remain than picks needed, no completion exists — the branch dies without recursion. The same "remaining resources vs remaining needs" cut as [11.2](../ch11-greedy/jump-game-ii.md)'s reachability frontier, in DP clothing. |
| 40 | + |
| 41 | +**The coins are 0-indexed** (the repo's comment: `coins = [0, 1, 2, 3]`), so picking coin `idx` adds `idx` to the sum — `(rem + idx) % m`. Careful: not `idx + 1`. |
| 42 | + |
| 43 | +## Approach 1 — Enumerate all C(n, k) combinations (exponential) |
| 44 | + |
| 45 | +Generate every k-subset and check the sum: correct, dies at n = 20. |
| 46 | + |
| 47 | +## Approach 2 — Memoized (idx, k, rem) (the repo's version, optimal) |
| 48 | + |
| 49 | +```kotlin |
| 50 | +fun countWays(n: Int, k: Int, m: Int): Int { |
| 51 | + val mod = 1_000_000_007 |
| 52 | + data class State(val idx: Int, val k: Int, val rem: Int) |
| 53 | + |
| 54 | + val _cache = mutableMapOf<State, Int>() |
| 55 | + |
| 56 | + fun solve(idx: Int, k: Int, rem: Int): Int = |
| 57 | + _cache.getOrPut(State(idx, k, rem)) { |
| 58 | + when { |
| 59 | + k == 0 -> if (rem == 0) 1 else 0 |
| 60 | + // Pruning: if coins remaining (n - idx) < coins needed (k), stop |
| 61 | + (n - idx) < k || idx == n -> 0 |
| 62 | + else -> { |
| 63 | + val skip = solve(idx + 1, k, rem) |
| 64 | + val pick = solve(idx + 1, k - 1, (rem + (idx % m)) % m) |
| 65 | + (skip + pick) % mod |
| 66 | + } |
| 67 | + } |
| 68 | + } |
| 69 | + |
| 70 | + return solve(0, k, 0) |
| 71 | +} |
| 72 | +``` |
| 73 | + |
| 74 | +```java |
| 75 | +import java.util.*; |
| 76 | + |
| 77 | +public class CountWaysToPickKCoinsDivisibleByM { |
| 78 | + private static final int MOD = 1_000_000_007; |
| 79 | + |
| 80 | + /** |
| 81 | + * @param n number of coins (0..n-1) |
| 82 | + * @param k coins to pick |
| 83 | + * @param m divisor |
| 84 | + * @return ways to pick k coins with sum divisible by m |
| 85 | + */ |
| 86 | + public int countWays(int n, int k, int m) { |
| 87 | + Map<String, Integer> memo = new HashMap<>(); |
| 88 | + return solve(0, k, 0, n, m, memo); |
| 89 | + } |
| 90 | + |
| 91 | + private int solve(int idx, int k, int rem, int n, int m, Map<String, Integer> memo) { |
| 92 | + if (k == 0) return rem == 0 ? 1 : 0; |
| 93 | + if (n - idx < k || idx == n) return 0; |
| 94 | + |
| 95 | + String key = idx + "," + k + "," + rem; |
| 96 | + if (memo.containsKey(key)) return memo.get(key); |
| 97 | + |
| 98 | + int skip = solve(idx + 1, k, rem, n, m, memo); |
| 99 | + int pick = solve(idx + 1, k - 1, (rem + idx) % m, n, m, memo); |
| 100 | + int result = (skip + pick) % MOD; |
| 101 | + memo.put(key, result); |
| 102 | + return result; |
| 103 | + } |
| 104 | +} |
| 105 | +``` |
| 106 | + |
| 107 | +```cpp |
| 108 | +#include <cstring> |
| 109 | + |
| 110 | +class CountWaysToPickKCoinsDivisibleByM { |
| 111 | + long long memo[31][31][31]; |
| 112 | + int n, m, k; |
| 113 | + const long long MOD = 1'000'000'007LL; |
| 114 | + |
| 115 | + long long solve(int idx, int left, int rem) { |
| 116 | + if (left == 0) return rem == 0 ? 1 : 0; |
| 117 | + if (n - idx < left || idx == n) return 0; |
| 118 | + if (memo[idx][left][rem] != -1) return memo[idx][left][rem]; |
| 119 | + |
| 120 | + long long skip = solve(idx + 1, left, rem); |
| 121 | + long long pick = solve(idx + 1, left - 1, (rem + idx) % m); |
| 122 | + return memo[idx][left][rem] = (skip + pick) % MOD; |
| 123 | + } |
| 124 | + |
| 125 | +public: |
| 126 | + /** |
| 127 | + * @param n number of coins (0..n-1) |
| 128 | + * @param k coins to pick |
| 129 | + * @param m divisor |
| 130 | + * @return ways to pick k coins with sum divisible by m |
| 131 | + */ |
| 132 | + int countWays(int n, int k, int m) { |
| 133 | + this->n = n; this->k = k; this->m = m; |
| 134 | + std::memset(memo, -1, sizeof memo); |
| 135 | + return (int)solve(0, k, 0); |
| 136 | + } |
| 137 | +}; |
| 138 | +``` |
| 139 | +
|
| 140 | +```python |
| 141 | +from functools import lru_cache |
| 142 | +
|
| 143 | +MOD = 1_000_000_007 |
| 144 | +
|
| 145 | +def count_ways(n: int, k: int, m: int) -> int: |
| 146 | + """ |
| 147 | + @param n: number of coins (0..n-1) |
| 148 | + @param k: coins to pick |
| 149 | + @param m: divisor |
| 150 | + @return: ways to pick k coins with sum divisible by m |
| 151 | + """ |
| 152 | + @lru_cache(None) |
| 153 | + def solve(idx: int, left: int, rem: int) -> int: |
| 154 | + if left == 0: |
| 155 | + return 1 if rem == 0 else 0 |
| 156 | + if n - idx < left or idx == n: |
| 157 | + return 0 # not enough coins left — pruning |
| 158 | +
|
| 159 | + skip = solve(idx + 1, left, rem) |
| 160 | + pick = solve(idx + 1, left - 1, (rem + idx) % m) |
| 161 | + return (skip + pick) % MOD |
| 162 | +
|
| 163 | + return solve(0, k, 0) |
| 164 | +``` |
| 165 | + |
| 166 | +```rust |
| 167 | +use std::collections::HashMap; |
| 168 | + |
| 169 | +impl Solution { |
| 170 | + /// @param n number of coins (0..n-1) |
| 171 | + /// @param k coins to pick |
| 172 | + /// @param m divisor |
| 173 | + /// @return ways to pick k coins with sum divisible by m |
| 174 | + pub fn count_ways(n: i32, k: i32, m: i32) -> i32 { |
| 175 | + const MOD: i64 = 1_000_000_007; |
| 176 | + let mut memo: HashMap<(i32, i32, i32), i64> = HashMap::new(); |
| 177 | + |
| 178 | + fn solve(idx: i32, left: i32, rem: i32, n: i32, m: i32, |
| 179 | + memo: &mut HashMap<(i32, i32, i32), i64>) -> i64 { |
| 180 | + if left == 0 { return if rem == 0 { 1 } else { 0 }; } |
| 181 | + if n - idx < left || idx == n { return 0; } // pruning |
| 182 | + |
| 183 | + if let Some(&v) = memo.get(&(idx, left, rem)) { return v; } |
| 184 | + let skip = solve(idx + 1, left, rem, n, m, memo); |
| 185 | + let pick = solve(idx + 1, left - 1, (rem + idx) % m, n, m, memo); |
| 186 | + let v = (skip + pick) % MOD; |
| 187 | + memo.insert((idx, left, rem), v); |
| 188 | + v |
| 189 | + } |
| 190 | + |
| 191 | + solve(0, k, 0, n, m, &mut memo) as i32 |
| 192 | + } |
| 193 | +} |
| 194 | +``` |
| 195 | + |
| 196 | +## Dry run |
| 197 | + |
| 198 | +**Input:** `n = 4, k = 2, m = 3` — coins {0,1,2,3}, pick 2 with `sum % 3 == 0`. |
| 199 | + |
| 200 | +``` |
| 201 | +enumerate (idx strictly increasing): pairs {0,3} -> 0+3=3 ✓, {1,2} -> 3 ✓. |
| 202 | +All other pairs: 0+1=1, 0+2=2, 1+3=4≡1, 2+3=5≡2 -> fail. Answer: 2. |
| 203 | +
|
| 204 | +DP path (abridged): solve(0,2,0) |
| 205 | + skip -> solve(1,2,0): eventually counts pairs among {1,2,3}: {1,2} ✓ -> 1 |
| 206 | + pick -> solve(1,1,0%3=0): counts pairs starting with coin 0: |
| 207 | + pick coin 1 -> solve(2,0,1): rem 1 != 0 -> 0 |
| 208 | + pick coin 2 -> solve(3,0,2): 0 |
| 209 | + pick coin 3 -> solve(4,0,3%3=0): 1 -> the {0,3} pair ✓ |
| 210 | + total = 1 + 1 = 2 ✓ |
| 211 | +``` |
| 212 | + |
| 213 | +The remainder carry in action: picking coin 3 adds `3 % 3 = 0`, so the state `(4, 0, 0)` closes the `{0,3}` choice — the raw sum never appears, only its residue. The pruning `(n - idx) < left` kills branches like "pick 2 coins from only 1 remaining" instantly. |
| 214 | + |
| 215 | +## Complexity |
| 216 | + |
| 217 | +**Time.** States `n × k × m`, O(1) per state: |
| 218 | + |
| 219 | +$$ |
| 220 | +T(n, k, m) = O(n \cdot k \cdot m) |
| 221 | +$$ |
| 222 | + |
| 223 | +**Space.** The memo: |
| 224 | + |
| 225 | +$$ |
| 226 | +S(n, k, m) = O(n \cdot k \cdot m) |
| 227 | +$$ |
| 228 | + |
| 229 | +## Variants & follow-ups |
| 230 | + |
| 231 | +- **Target Sum** (`array/dp/TargetSum.kt`) — the same (index, remainder-carry) counting, with a signed target instead of a modulo. |
| 232 | +- **Partition Equal Subset Sum** ([2.6](partition-equal-subset-sum.md)) — divisibility reachability without the pick-count axis. |
| 233 | +- **Interview follow-up:** "Why does `rem` make the state small?" Only `sum % m` determines divisibility, and it composes under addition — so the remainder is a lossless summary of the sum, bounded by `m` (≤ 30 here). Replace the remainder with the raw sum and the state space explodes to $n \cdot k \cdot (n \cdot m)$. |
0 commit comments