diff --git a/exercises/practice/two-bucket/.approaches/bfs/content.md b/exercises/practice/two-bucket/.approaches/bfs/content.md new file mode 100644 index 0000000000..327e534e67 --- /dev/null +++ b/exercises/practice/two-bucket/.approaches/bfs/content.md @@ -0,0 +1,271 @@ +# Breadth First Search (BFS) + +```v +module main + +enum BucketId { + one + two +} + +struct Solution { + moves int + goal_bucket BucketId + other_bucket int +} + +struct State { + contents_one int + contents_two int +} + +struct Search { + capacity_one int + capacity_two int +mut: + seen []bool + queue []State +} + +fn (search Search) id_for_state(contents_one int, contents_two int) int { + capacity_one := search.capacity_one + capacity_two := search.capacity_two + combined_capacity := capacity_one + capacity_two + + // After each move, at least one bucket is either empty or full. + // We enumerate the valid states as follows: + // States 0..capacity_two: bucket one empty, contents_two increasing. + // States capacity_two..combined_capacity: contents_one increasing, bucket two full. + // States combined_capacity..(combined_capacity + capacity_two): bucket one full, contents_two decreasing. + // States (combined_capacity + capacity_two)..(2 * combined_capacity): contents_one decreasing, bucket two empty. + + if contents_one == 0 { + return contents_two + } + if contents_two == capacity_two { + return capacity_two + contents_one + } + if contents_one == capacity_one { + return capacity_one + 2 * capacity_two - contents_two + } + if contents_two == 0 { + return 2 * combined_capacity - contents_one + } + panic('invalid state') +} + +fn (mut search Search) exclude(contents_one int, contents_two int) { + id := search.id_for_state(contents_one, contents_two) + search.seen[id] = true +} + +fn (mut search Search) enqueue(contents_one int, contents_two int) { + id := search.id_for_state(contents_one, contents_two) + if search.seen[id] { + return + } + search.queue << State{ + contents_one: contents_one + contents_two: contents_two + } + search.seen[id] = true +} + +fn new_search(capacity_one int, capacity_two int) Search { + mut result := Search{ + capacity_one: capacity_one + capacity_two: capacity_two + } + result.seen = []bool{len: 2 * (capacity_one + capacity_two), init: false} + result.queue = []State{cap: 2 * (capacity_one + capacity_two)} + return result +} + +pub fn measure(capacity_one int, capacity_two int, goal int, start_bucket BucketId) !Solution { + assert goal != 0 + + mut search := new_search(capacity_one, capacity_two) + if start_bucket == .one { + search.enqueue(capacity_one, 0) + search.exclude(0, capacity_two) + } else { + search.enqueue(0, capacity_two) + search.exclude(capacity_one, 0) + } + + mut moves := 1 + // The total number of states added before the next move. + mut old_back := search.queue.len + + mut front := 0 + for front < search.queue.len { + contents_one := search.queue[front].contents_one + contents_two := search.queue[front].contents_two + combined_contents := contents_one + contents_two + + if contents_one == goal { + return Solution{ + moves: moves + goal_bucket: .one + other_bucket: contents_two + } + } + if contents_two == goal { + return Solution{ + moves: moves + goal_bucket: .two + other_bucket: contents_one + } + } + + // Pouring one bucket into the other bucket until source is empty or target is full. + // Pour 2->1: bucket one gets min(combined, capacity_one), bucket two gets the rest. + if combined_contents <= capacity_one { + search.enqueue(combined_contents, 0) + } else { + search.enqueue(capacity_one, combined_contents - capacity_one) + } + // Pour 1->2: bucket two gets min(combined, capacity_two), bucket one gets the rest. + if combined_contents <= capacity_two { + search.enqueue(0, combined_contents) + } else { + search.enqueue(combined_contents - capacity_two, capacity_two) + } + + // Emptying a bucket and doing nothing to the other. + search.enqueue(0, contents_two) + search.enqueue(contents_one, 0) + + // Filling a bucket and doing nothing to the other. + search.enqueue(capacity_one, contents_two) + search.enqueue(contents_one, capacity_two) + + front += 1 + if front == old_back { + moves += 1 + old_back = search.queue.len + } + } + + // No more reachable states. + return error('impossible') +} +``` + +This approach models the two-bucket problem as a **state space search** using **Breadth-First Search (BFS)**. +Each state represents the amount of water in both buckets. +BFS explores states level by level, guaranteeing the minimum number of moves to reach the goal. + +## Data Structures + +### `State` + +Represents the number of liters in each bucket. +See [structs][structs]. + +### `Search` + +Holds the search state: +- `capacity_one`, `capacity_two` - bucket sizes +- `seen` - boolean [array][arrays] tracking visited states (indexed by `id_for_state`) +- `queue` - slice of `State` for BFS frontier + +### `Solution` + +Return value: `moves`, `goal_bucket` (which bucket has the goal), `other_bucket` (liters in the other bucket). +The function returns `!Solution` - see [error handling][error-handling]. + +## Key Functions + +### `id_for_state` + +Maps each valid state to a unique integer ID for O(1) visited checks. + +**Insight**: After every move, at least one bucket is empty or full. +Valid states form a bounded set of size `2 * (capacity_one + capacity_two)`. + +The mapping: + +| Condition | ID Range | Meaning | +|-----------|----------|---------| +| `contents_one == 0` | `0 .. capacity_two` | Bucket 1 empty, bucket 2 varies | +| `contents_two == capacity_two` | `capacity_two .. combined_capacity` | Bucket 2 full, bucket 1 varies | +| `contents_one == capacity_one` | `combined_capacity .. combined_capacity + capacity_two` | Bucket 1 full, bucket 2 decreases | +| `contents_two == 0` | `combined_capacity + capacity_two .. 2*combined_capacity` | Bucket 2 empty, bucket 1 decreases | + +The `panic('invalid state')` is unreachable during the search, because every state the search creates has at least one bucket empty or full. +Note that `(0,0)` is a valid state the search can visit (both buckets empty). + +### `enqueue` + +Adds a state to the BFS queue if not already visited. +Uses `id_for_state` for deduplication. + +### `exclude` + +Marks a state as visited without enqueueing. +Used to **exclude the invalid starting state** where the non-starting bucket is full while the starting bucket is empty (per problem rules). + +### `new_search` + +Initializes `seen` array (size `2*(c1+c2)`) and `queue` with capacity for all possible states. + +## Algorithm: `measure` + +1. **Assert**: `goal != 0` - see [assert][assert] (assertion, not an error return). +2. **Initialize search** with capacities. +3. **Set starting state**: + - If `start_bucket == one`: enqueue `(capacity_one, 0)`, exclude `(0, capacity_two)` + - If `start_bucket == two`: enqueue `(0, capacity_two)`, exclude `(capacity_one, 0)` + - Move count starts at 1 (initial fill counts as a move). +4. **BFS loop** (level-by-level): + - Dequeue state, check if either bucket == goal -> return `Solution` + - Generate all 6 possible next states: + - **Pour 2->1** (until source empty or target full): bucket one gets `min(combined, capacity_one)`, bucket two gets the rest. + - **Pour 1->2** (until source empty or target full): bucket two gets `min(combined, capacity_two)`, bucket one gets the rest. + - **Empty bucket 1**: `(0, contents_two)` + - **Empty bucket 2**: `(contents_one, 0)` + - **Fill bucket 1**: `(capacity_one, contents_two)` + - **Fill bucket 2**: `(contents_one, capacity_two)` + - Enqueue unvisited states. +5. **Level counting**: `front` index tracks current state; `old_back` marks queue length at start of level. + + Example with buckets 3 and 5, start bucket one, (0,5) excluded: + ``` + Initial: queue=[(3,0)], front=0, old_back=1, moves=1 + Process (3,0): adds (0,3), (0,0), (3,5) -- queue length is 4 + front=1 == old_back=1 -> moves=2, old_back=4 + Process the 3 states at moves=2, then front=4 == old_back=4 -> moves=3 + ``` + The goal check happens when a state is taken off the queue, so a state found at level N is reported with moves = N. + When `front` reaches `old_back`, all states at the current distance are done; increment `moves` and set `old_back` to the new queue length. +6. **Exhaustion**: If queue empties, return `error('impossible')`. + +## Complexity + +- **Time**: O(capacity_one + capacity_two) - there are at most `2 * (capacity_one + capacity_two)` valid states and each generates up to 6 moves. +- **Space**: O(capacity_one + capacity_two) - `seen` array + queue. + +## Example Walkthrough + +Buckets: 3 and 5, Goal: 1, Start: bucket one + +| Move | Bucket 1 | Bucket 2 | Action | +|------|----------|----------|--------| +| 1 | 3 | 0 | Fill bucket 1 | +| 2 | 0 | 3 | Pour 1->2 | +| 3 | 3 | 3 | Fill bucket 1 | +| 4 | 1 | 5 | Pour 1->2 (goal reached!) | + +Result: 4 moves, goal in bucket 1, 5 liters in bucket 2 + +## Why BFS? + +- **Guarantees optimal** (minimum moves) - explores all states at distance N before N+1 +- **Simple to understand and implement** - no advanced math required +- **Works within problem constraints** - the number of states grows with bucket sizes, so it's fine for exercise inputs + +[arrays]: https://github.com/vlang/v/blob/master/doc/docs.md#arrays +[structs]: https://github.com/vlang/v/blob/master/doc/docs.md#structs +[error-handling]: https://github.com/vlang/v/blob/master/doc/docs.md#error-handling +[assert]: https://github.com/vlang/v/blob/master/doc/docs.md#assert \ No newline at end of file diff --git a/exercises/practice/two-bucket/.approaches/bfs/snippet.txt b/exercises/practice/two-bucket/.approaches/bfs/snippet.txt new file mode 100644 index 0000000000..e843b06698 --- /dev/null +++ b/exercises/practice/two-bucket/.approaches/bfs/snippet.txt @@ -0,0 +1,8 @@ +fn (mut s Search) enqueue(c1, c2 int) { + if s.seen[s.id_for_state(c1, c2)] { return } + s.queue << State{c1, c2}; s.seen[s.id_for_state(c1, c2)] = true +} +for front < s.queue.len { + if s.queue[front].contents_one == goal { return Solution{moves, .one, ...} } + s.enqueue(...); front++ +} \ No newline at end of file diff --git a/exercises/practice/two-bucket/.approaches/config.json b/exercises/practice/two-bucket/.approaches/config.json new file mode 100644 index 0000000000..695ac652b4 --- /dev/null +++ b/exercises/practice/two-bucket/.approaches/config.json @@ -0,0 +1,27 @@ +{ + "introduction": { + "authors": [ + "haaeppyy" + ] + }, + "approaches": [ + { + "uuid": "e1f0286f-aa0a-402d-bfaf-98b0aa908025", + "slug": "bfs", + "title": "Breadth-First Search", + "blurb": "Explore all possible states level by level until the goal is reached.", + "authors": [ + "haaeppyy" + ] + }, + { + "uuid": "b8c3170f-626a-4e12-8a96-6a361f231437", + "slug": "egcd", + "title": "Extended GCD", + "blurb": "Use the Extended Euclidean Algorithm to find the solution mathematically.", + "authors": [ + "haaeppyy" + ] + } + ] +} diff --git a/exercises/practice/two-bucket/.approaches/egcd/content.md b/exercises/practice/two-bucket/.approaches/egcd/content.md new file mode 100644 index 0000000000..a09d0662da --- /dev/null +++ b/exercises/practice/two-bucket/.approaches/egcd/content.md @@ -0,0 +1,173 @@ +# Extended GCD (Mathematical Approach) + +```v +module main + +import math + +enum BucketId { + one + two +} + +struct Solution { + moves int + goal_bucket BucketId + other_bucket int +} + +struct Plan { + moves int + holder_is_source bool + other int +} + +fn bucket_of(index int) BucketId { + return if index == 0 { BucketId.one } else { BucketId.two } +} + +// Fill the source (capacity s), pour into the destination (capacity d), +// and empty the destination whenever it is full. +// With f source fills and k destination empties/fills: s*f - d*k = goal. +fn plan(s int, d int, goal int) ?Plan { + if goal == s { + return Plan{1, true, 0} + } + g64, x64, _ := math.egcd(s, d) + g := int(g64) + x0 := int(x64) + if goal % g != 0 { + return none + } + step := d / g + // Use i64 for the multiplication to avoid overflow with large capacities + f0 := i64(x64) * i64(goal / g) + mut f := int((f0 % i64(step) + i64(step)) % i64(step)) + for f < 1 || s * f < goal { + f += step + } + k := (s * f - goal) / d + if k >= 1 && goal < s { + return Plan{2 * (f + k - 1), true, d} + } + if goal <= d { + return Plan{2 * (f + k), false, 0} + } + return none +} + +pub fn measure(capacity_one int, capacity_two int, goal int, start_bucket BucketId) !Solution { + assert goal != 0 + c := [capacity_one, capacity_two] + if goal > math.max(capacity_one, capacity_two) + || goal % int(math.gcd(capacity_one, capacity_two)) != 0 { + return error('impossible') + } + s := if start_bucket == .one { 0 } else { 1 } + o := 1 - s + if goal == c[s] { + return Solution{1, bucket_of(s), 0} + } + if goal == c[o] { + other := if goal < c[s] { c[s] - goal } else { c[s] } + return Solution{2, if other == goal { BucketId.one } else { bucket_of(o) }, other} + } + p := plan(c[s], c[o], goal) or { return error('impossible') } + return Solution{p.moves, if p.holder_is_source { bucket_of(s) } else { bucket_of(o) }, p.other} +} +``` + +## The Equation: `s*f - d*k = goal` + +The core insight: the water pouring process can be modeled as a linear Diophantine equation. + +- `s` = source bucket capacity (the one we start with) +- `d` = destination bucket capacity +- `f` = number of times we **fill** the source bucket +- `k` = number of times we **empty/fill** the destination bucket +- Each fill of source adds `s` liters; each empty of destination removes `d` liters +- `s*f - d*k` is the total water poured in minus the total emptied out, which is what remains in the bucket holding the goal. + +For a solution to exist, `goal` must be a multiple of `gcd(s, d)`. +This is Bézout's identity — the equation `s*f - d*k = goal` has integer solutions iff `gcd(s, d)` divides `goal`. + +The `measure` function checks this early: +```v +if goal > math.max(capacity_one, capacity_two) + || goal % int(math.gcd(capacity_one, capacity_two)) != 0 { + return error('impossible') +} +``` + +## From `math.egcd` to the Smallest `f` + +[`math.egcd(s, d)`][egcd] uses the [extended Euclidean algorithm][extended-euclid] and returns `(g, x0, y0)` where `s*x0 + d*y0 = g = gcd(s, d)`. + +Scaling by `goal/g` gives a particular solution: +``` +s * (x0 * goal/g) + d * (y0 * goal/g) = goal +``` + +So `f₀ = x0 * (goal/g)` is one solution for `f`. +But we need the **smallest positive `f`** such that: +1. `f ≥ 1` (at least one fill) +2. `s*f ≥ goal` (source has enough water to reach goal) + +All solutions for `f` are: `f = f₀ + t*(d/g)` for integer `t`. + +The code computes this efficiently: +```v +step := d / g +f0 := i64(x64) * i64(goal / g) +mut f := int((f0 % i64(step) + i64(step)) % i64(step)) // f₀ mod step, normalized to [0, step) +for f < 1 || s * f < goal { + f += step +} +``` + +The modulo arithmetic gives the smallest non-negative `f ≡ f₀ (mod step)`. +The loop then increases by `step` until both conditions hold. + +## Move Count + +Each fill of the source is followed by a pour into the destination. +Whenever the destination becomes full, we empty it (1 move) and pour again (1 move), so `k` destination fills means `k` pours + `k` empties when it ends in the destination, or `k - 1` empties when it ends in the source. + +- **Goal ends in destination** (`goal <= d`): We fill the source `f` times, pour `f` times, empty the destination `k` times. Total: `f + f + k = 2*(f + k)` moves. The source is empty at the end. +- **Goal ends in source** (`k >= 1` and `goal < s`): We fill the source `f` times, pour `f` times, empty the destination `k - 1` times (the last fill leaves the destination full with the goal in source). Total: `f + f + (k - 1) = 2*(f + k - 1)` moves. The destination is full at the end. + +The source row is checked first in the code, matching the table above. + +## Special cases + +Two cases are handled in `measure` before calling `plan`: + +- `goal == s`: 1 move, because filling the starting bucket is enough. +- `goal == d`: 2 moves. Fill the starting bucket, then pour into the other bucket if `goal < s`, or fill the other bucket otherwise. + If both buckets then hold the goal, the answer reports bucket one. + +Only the starting bucket is used as the source. +The rule that the starting bucket may not be empty while the other is full rules out the reverse strategy, apart from the special cases above. + +## Example + +Buckets 3 and 5, goal 1, start bucket one, so `s = 3` and `d = 5`: + +- `gcd(3, 5) = 1`, so the goal is possible. +- The smallest `f` with `3*f >= 1` and `3*f ≡ 1 (mod 5)` is `f = 2`. +- `k = (3*2 - 1) / 5 = 1`. +- `k >= 1` and `goal < s`, so the goal is in the source: `2 * (2 + 1 - 1) = 4` moves, with 5 liters in the other bucket. + +## Verification + +This approach was checked against a breadth-first search for every pair of bucket sizes from 1 to 15 (goals 1 to 16, both start buckets), and for 20,000 random cases with sizes up to 60. +That is strong evidence but not a proof. + +## Complexity + +There is no search. +The work is dominated by the `egcd` call, plus a few steps of the loop that adds `step` to `f`. +Space use is constant. + +[egcd]: https://modules.vlang.io/math.html#egcd +[extended-euclid]: https://en.wikipedia.org/wiki/Extended_Euclidean_algorithm \ No newline at end of file diff --git a/exercises/practice/two-bucket/.approaches/egcd/snippet.txt b/exercises/practice/two-bucket/.approaches/egcd/snippet.txt new file mode 100644 index 0000000000..8be71bb440 --- /dev/null +++ b/exercises/practice/two-bucket/.approaches/egcd/snippet.txt @@ -0,0 +1,7 @@ +g64, x64, _ := math.egcd(s, d) +g := int(g64) +step := d / g +f0 := i64(x64) * i64(goal / g) +mut f := int((f0 % i64(step) + i64(step)) % i64(step)) +for f < 1 || s * f < goal { f += step } +k := (s * f - goal) / d \ No newline at end of file diff --git a/exercises/practice/two-bucket/.approaches/introduction.md b/exercises/practice/two-bucket/.approaches/introduction.md new file mode 100644 index 0000000000..34955ecfa5 --- /dev/null +++ b/exercises/practice/two-bucket/.approaches/introduction.md @@ -0,0 +1,72 @@ +# Introduction + +The Two Bucket problem asks you to measure an exact amount of water using two buckets of different sizes. +You can fill, empty, and pour between buckets. +The challenge is to find the minimum number of moves. + +There are two main approaches to solve this: + +## Breadth-First Search (BFS) + +Model the problem as a state space search. +Each state represents the amount of water in each bucket. +BFS explores all possible states level by level, guaranteeing the shortest path (minimum moves) to the goal. + +It's intuitive and works well for the problem constraints. + +Key idea: After every move, at least one bucket is empty or full. +This bounds the state space to `2 * (capacity_one + capacity_two)` valid states, each mapped to a unique ID for O(1) visited checks. +The `capacity_one`, `capacity_two`, and `combined_capacity` values come from the `Search` struct. + +```v +fn (search Search) id_for_state(contents_one int, contents_two int) int { + if contents_one == 0 { return contents_two } + if contents_two == capacity_two { return capacity_two + contents_one } + if contents_one == capacity_one { return capacity_one + 2 * capacity_two - contents_two } + if contents_two == 0 { return 2 * combined_capacity - contents_one } + panic('invalid state') +} +``` + +For more information, check the [BFS approach][approach-bfs]. + +## Extended GCD (Mathematical Approach) + +Use number theory: a solution exists if and only if the goal is a multiple of the GCD of the two bucket sizes. +The Extended Euclidean Algorithm finds the coefficients to express the GCD as a linear combination of the bucket sizes, which directly gives the solution without searching. + +Core equation: `s*f - d*k = goal` where `s` = source bucket capacity, `d` = destination bucket capacity, `f` = source fills, `k` = destination empties/fills. + +```v +g64, x64, _ := math.egcd(s, d) +g := int(g64) +step := d / g +// Use i64 for the multiplication to avoid overflow with large buckets +f0 := i64(x64) * i64(goal / g) +mut f := int((f0 % i64(step) + i64(step)) % i64(step)) +for f < 1 || s * f < goal { + f += step +} +k := (s * f - goal) / d +``` + +**Note**: The multiplications can overflow V's 32-bit `int` once capacity₁ × capacity₂ exceeds about 2 billion. +For typical exercise inputs this is not an issue. + +For more information, check the [Extended GCD approach][approach-egcd]. + +## Which Approach to Use? + +| Aspect | BFS | Extended GCD | +|--------|-----|--------------| +| **Simplicity** | Easier to understand & implement | Requires number theory background | +| **Correctness** | Always correct (exhaustive search) | Verified up to bucket size 60 (see [Extended GCD approach][approach-egcd]) | +| **Performance** | O(capacity₁ + capacity₂) | O(log min(capacity₁, capacity₂)) | +| **Scalability** | Slows down with large buckets | Fast for large buckets | +| **Insight** | Shows the pouring sequence | Reveals *when* solutions exist | + +- **BFS**: Simpler to understand and implement. Good for learning state space search. +- **Extended GCD**: More elegant mathematically. Better performance for very large buckets. Recommended if you're comfortable with number theory. + +[approach-bfs]: https://exercism.org/tracks/vlang/exercises/two-bucket/approaches/bfs +[approach-egcd]: https://exercism.org/tracks/vlang/exercises/two-bucket/approaches/egcd \ No newline at end of file diff --git a/exercises/practice/two-bucket/.docs/instructions.md b/exercises/practice/two-bucket/.docs/instructions.md index 30d779aa92..9e9aeefb4a 100644 --- a/exercises/practice/two-bucket/.docs/instructions.md +++ b/exercises/practice/two-bucket/.docs/instructions.md @@ -39,7 +39,7 @@ Bucket one can hold 3 liters, and bucket two can hold up to 5 liters. You are told you must start with bucket one. So your first action is to fill bucket one. You choose to empty bucket one for your second action. -For your third action, you may not fill bucket two, because this violates the third rule -- you may not end up in a state after any action where the starting bucket is empty and the other bucket is full. +For your third action, you may not fill bucket two, because this violates the third rule - you may not end up in a state after any action where the starting bucket is empty and the other bucket is full. Written with <3 at [Fullstack Academy][fullstack] by Lindsay Levine.