0-1 BFS — Junior Level¶
One-line summary: 0-1 BFS finds shortest paths in a graph whose every edge weighs either 0 or 1, in
O(V + E)time, by using a double-ended queue (deque) instead of a priority queue: relax a 0-weight edge withpush_front, relax a 1-weight edge withpush_back. It is the fast middle ground between plain BFS (unweighted only) and Dijkstra (general non-negative weights).
Table of Contents¶
- Introduction
- Prerequisites
- Glossary
- Core Concepts
- Big-O Summary
- Real-World Analogies
- Pros & Cons
- Step-by-Step Walkthrough
- Code Examples
- Coding Patterns
- Error Handling
- Performance Tips
- Best Practices
- Edge Cases & Pitfalls
- Common Mistakes
- Cheat Sheet
- Visual Animation
- Summary
- Further Reading
Introduction¶
You already know two shortest-path tools:
- Plain BFS (sibling topic 02-bfs) finds the fewest-edges path in an unweighted graph in
O(V + E). It works because a FIFO queue naturally processes vertices in non-decreasing distance order — every edge costs exactly1. - Dijkstra (sibling topic 04-dijkstra) finds shortest paths with arbitrary non-negative weights, but it needs a priority queue and pays
O(E log V).
0-1 BFS lives in between. It solves the special — and surprisingly common — case where every edge weight is 0 or 1. In that case you do not need a logarithmic-factor priority queue. A plain double-ended queue (a deque) is enough, and you get the full O(V + E) speed of BFS while still respecting weights.
The whole idea fits in one sentence:
When you relax an edge of weight 0, push the neighbour to the front of the deque; when you relax an edge of weight 1, push it to the back.
Why does this work? Because at any moment the deque holds vertices with at most two distinct distance values — some at distance d and some at distance d + 1, in that order from front to back. A 0-edge keeps you at the same distance d, so the neighbour belongs with the d-group at the front. A 1-edge moves you to d + 1, so the neighbour belongs with the d + 1-group at the back. The deque stays sorted by distance without ever sorting anything — exactly the property a priority queue would buy you, but for free.
This makes 0-1 BFS the go-to algorithm for a huge class of grid puzzles: "some moves are free, others cost 1." Examples you will meet over and over: minimum number of obstacles to remove to cross a grid, minimum cost to make a path by rotating arrows in cells, teleporters that are free vs steps that cost one. Whenever the cost alphabet is just {0, 1}, reach for 0-1 BFS.
Prerequisites¶
Before reading this file you should be comfortable with:
- Plain BFS — the FIFO-queue layer-by-layer traversal (sibling 02-bfs). 0-1 BFS is a small mutation of it.
- Graphs and adjacency lists — vertices, edges, neighbours, weighted edges
(to, weight). - The deque (double-ended queue) — a structure supporting
push_front,push_back,pop_frontinO(1). In Go a slice orcontainer/list; in JavaArrayDeque; in Pythoncollections.deque. - The Dijkstra idea (sibling 04-dijkstra) — "settle the closest vertex, relax its edges." 0-1 BFS is Dijkstra specialised to a 2-value weight set.
- Arrays / 2D grids — most applications are grid problems; a cell
(r, c)is a vertex. - Big-O notation —
O(V + E),O(E log V).
Optional but helpful:
- The notion of distance relaxation:
if dist[u] + w < dist[v] then dist[v] = dist[u] + w.
Glossary¶
| Term | Meaning |
|---|---|
| 0-1 BFS | Shortest-path algorithm for graphs whose edges all weigh 0 or 1, running in O(V + E) using a deque. |
| Deque (double-ended queue) | A queue you can push to and pop from both ends in O(1). The heart of 0-1 BFS. |
push_front | Insert at the front (head) of the deque. Used when relaxing a 0-weight edge. |
push_back | Insert at the back (tail) of the deque. Used when relaxing a 1-weight edge. |
| Relaxation | Trying to improve dist[v] via an edge u → v: if dist[u] + w < dist[v]: dist[v] = dist[u] + w. |
| dist[] | Array of best-known distances from the source. Initialised to +∞ except dist[src] = 0. |
| Stale entry | A deque entry whose stored distance is larger than the current best dist[v]; it must be skipped on pop. |
| Settled / finalised | A vertex whose dist is provably optimal and will not change again. |
| Monotone deque invariant | The front-to-back order in the deque is non-decreasing by distance, with at most two distinct values d and d+1. |
| Dial's algorithm | The generalisation of 0-1 BFS to weights 0..k using a bucket queue; runs in O(V + E + maxDist). |
Core Concepts¶
1. The graph model: weights are only 0 or 1¶
A 0-1 BFS graph has an adjacency list where each edge carries a weight in {0, 1}:
In grid problems the vertex is a cell (r, c) and the four moves are edges. A move into a "free" cell may cost 0; a move that breaks an obstacle may cost 1. The exact 0/1 mapping is what each problem defines.
2. The deque replaces the priority queue¶
Plain BFS uses a FIFO queue. Dijkstra uses a priority queue (min-heap). 0-1 BFS uses a deque and decides which end to push to based on the edge weight:
relax edge (u -> v, w):
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
if w == 0: deque.push_front(v) # stays in the same distance bucket
else: deque.push_back(v) # moves to the next distance bucket
pop_front always returns a vertex with the smallest current distance — exactly like a priority queue's extract-min, but in O(1).
3. Why the deque stays "almost sorted" (≤ 2 distance levels)¶
This is the key insight. Suppose you pop a vertex u with dist[u] = d. Every other vertex still in the deque has distance d or d + 1 (this is an invariant we maintain). When you relax u's edges:
- A 0-edge gives a neighbour distance
d→ it joins the front group (distanced), sopush_front. - A 1-edge gives a neighbour distance
d + 1→ it joins the back group (distanced + 1), sopush_back.
Either way the deque never holds three distinct distance values, and the front always has the minimum. It is, in effect, a 2-bucket Dijkstra where the "buckets" are the front half and the back half of one deque.
4. Lazy distance check — skip stale entries¶
Because we may push the same vertex more than once (a later, shorter path can reach it), the deque can contain stale entries. When you pop a vertex, re-check its distance:
Without this check the algorithm still terminates but wastes work re-expanding old states. (Some implementations store only the vertex and compare against a done[] flag instead — both are correct; see Pitfalls.)
5. Where 0-1 BFS sits among its cousins¶
unweighted graph → plain BFS O(V+E) FIFO queue
weights in {0,1} → 0-1 BFS O(V+E) deque
weights in {0..k} → Dial's algorithm O(V+E+maxD) bucket queue
arbitrary non-negative → Dijkstra O(E log V) min-heap
If weights are not strictly 0/1, do not use 0-1 BFS — use Dial's (small integer range) or Dijkstra (general).
Big-O Summary¶
| Quantity | Cost | Notes |
|---|---|---|
| Time | O(V + E) | Each vertex is finalised once; each edge relaxed O(1) times. |
| Space | O(V) | dist[] array plus the deque (holds at most O(V) live + a few stale entries). |
Deque pop_front | O(1) | The reason we beat Dijkstra's O(log V). |
Deque push_front / push_back | O(1) amortised | Plain ring buffer / linked deque. |
| Per-vertex finalisation | O(1) | First (smallest) pop settles it. |
Comparison at a glance:
| Algorithm | Weight set | Time | Queue type |
|---|---|---|---|
| Plain BFS | all 1 (unweighted) | O(V+E) | FIFO queue |
| 0-1 BFS | {0, 1} | O(V+E) | deque |
| Dial's | {0..k} | O(V+E+maxDist) | bucket queue |
| Dijkstra | non-negative | O(E log V) | min-heap |
Real-World Analogies¶
| Concept | Analogy |
|---|---|
0-edge → push_front | Free roads vs toll roads. Driving onto a free road does not cost you anything, so it is "just as cheap" as where you already are — you keep your place at the front of the line of equally cheap destinations. |
1-edge → push_back | Taking a toll road costs exactly one coin, so that destination is one tier more expensive — it goes to the back of the line, behind everyone reachable for free. |
| The deque | A queue of places to visit, sorted by how many coins it took to reach them — but only ever two adjacent coin-counts are in the line at once. |
| Stale entry skip | You wrote down "visit town X, cost 3" earlier, but then found a free shortcut making it cost 2. When the old "cost 3" note comes up, you tear it up because X is already done cheaper. |
| Settling a vertex | The first time a town comes off the front of the line, its cost is final — no cheaper route can possibly exist, because the front is always the cheapest. |
Where the analogy breaks: real toll prices vary; here every toll is exactly one coin and every free road is exactly zero. The moment tolls cost 2 or 3 coins, the simple front/back rule fails and you need Dial's or Dijkstra.
Pros & Cons¶
| Pros | Cons |
|---|---|
O(V + E) — no log V factor, faster than Dijkstra. | Only works when every edge is 0 or 1. |
| Tiny code: plain BFS plus a front/back decision. | A wrong weight (e.g. a 2) silently produces wrong answers. |
No heap, no comparator, no decrease-key. | Deque can hold stale duplicates — needs the lazy skip check. |
| Cache-friendly: a deque is a flat ring buffer. | Easy to mis-implement push_front with an O(n) array insert. |
Generalises cleanly to 0..k via Dial's. | Not for negative weights (use Bellman-Ford, sibling 05). |
When to use: weights in {0,1}; grid puzzles with free moves and unit-cost moves; 0/1 state graphs; layered graphs where transitions are free or unit.
When NOT to use: unweighted graph (plain BFS is simpler); weights > 1 (Dial's or Dijkstra); negative edges (Bellman-Ford).
Step-by-Step Walkthrough¶
Consider this tiny weighted graph. Edges are labelled with their weight (0 or 1). Source is A.
Adjacency (directed):
Initialise dist = {A:0, B:∞, C:∞, D:∞, E:∞}, deque = [A(0)].
Step 1 pop_front A (d=0)
relax A→B w0: dist[B]=0, push_front B deque=[B(0)]
relax A→C w1: dist[C]=1, push_back C deque=[B(0), C(1)]
Step 2 pop_front B (d=0)
relax B→D w1: dist[D]=1, push_back D deque=[C(1), D(1)]
relax B→E w0: dist[E]=0, push_front E deque=[E(0), C(1), D(1)]
Step 3 pop_front E (d=0)
relax E→D w0: dist[D]=0 < 1, update, push_front D
deque=[D(0), C(1), D(1)]
Step 4 pop_front D (d=0) -> settle D at 0
D has no out-edges deque=[C(1), D(1)]
Step 5 pop_front C (d=1) -> settle C at 1
relax C→E w1: dist[E]+? 1+1=2 > 0, no update
deque=[D(1)]
Step 6 pop_front D (d=1) -> STALE: 1 > dist[D]=0, skip
deque=[]
Final distances: A=0, B=0, C=1, D=0, E=0.
Notice the invariant: at every step the deque held at most two distance values, with the smaller at the front. The stale D(1) in step 6 was correctly skipped because D had already been settled at distance 0.
Code Examples¶
Example: 0-1 BFS on an adjacency list¶
Go¶
package main
import "fmt"
type Edge struct {
to int
weight int // must be 0 or 1
}
// zeroOneBFS returns shortest distances from src on a graph with 0/1 edge weights.
func zeroOneBFS(adj [][]Edge, src, n int) []int {
const INF = 1 << 30
dist := make([]int, n)
for i := range dist {
dist[i] = INF
}
dist[src] = 0
// Use a slice as a deque: front = index 0, back = end.
deque := []int{src}
for len(deque) > 0 {
u := deque[0]
deque = deque[1:] // pop_front
for _, e := range adj[u] {
nd := dist[u] + e.weight
if nd < dist[e.to] {
dist[e.to] = nd
if e.weight == 0 {
deque = append([]int{e.to}, deque...) // push_front
} else {
deque = append(deque, e.to) // push_back
}
}
}
}
return dist
}
func main() {
// A=0 B=1 C=2 D=3 E=4
adj := make([][]Edge, 5)
adj[0] = []Edge{{1, 0}, {2, 1}} // A->B(0), A->C(1)
adj[1] = []Edge{{3, 1}, {4, 0}} // B->D(1), B->E(0)
adj[2] = []Edge{{4, 1}} // C->E(1)
adj[4] = []Edge{{3, 0}} // E->D(0)
fmt.Println(zeroOneBFS(adj, 0, 5)) // [0 0 1 0 0]
}
Note:
append([]int{x}, deque...)is anO(n)push-front. It is shown here for clarity. For real use prefer a ring-buffer orcontainer/listdeque — see middle.md.
Java¶
import java.util.*;
public class ZeroOneBFS {
static final int INF = Integer.MAX_VALUE / 2;
// adj[u] = list of {to, weight}, weight in {0,1}
static int[] zeroOneBFS(List<int[]>[] adj, int src, int n) {
int[] dist = new int[n];
Arrays.fill(dist, INF);
dist[src] = 0;
Deque<Integer> dq = new ArrayDeque<>();
dq.addFirst(src);
while (!dq.isEmpty()) {
int u = dq.pollFirst(); // pop_front
for (int[] e : adj[u]) {
int v = e[0], w = e[1], nd = dist[u] + w;
if (nd < dist[v]) {
dist[v] = nd;
if (w == 0) dq.addFirst(v); // push_front
else dq.addLast(v); // push_back
}
}
}
return dist;
}
public static void main(String[] args) {
int n = 5;
List<int[]>[] adj = new List[n];
for (int i = 0; i < n; i++) adj[i] = new ArrayList<>();
adj[0].add(new int[]{1, 0}); adj[0].add(new int[]{2, 1});
adj[1].add(new int[]{3, 1}); adj[1].add(new int[]{4, 0});
adj[2].add(new int[]{4, 1});
adj[4].add(new int[]{3, 0});
System.out.println(Arrays.toString(zeroOneBFS(adj, 0, n))); // [0, 0, 1, 0, 0]
}
}
Python¶
from collections import deque
def zero_one_bfs(adj, src, n):
INF = float("inf")
dist = [INF] * n
dist[src] = 0
dq = deque([src])
while dq:
u = dq.popleft() # pop_front
for v, w in adj[u]: # w in {0, 1}
nd = dist[u] + w
if nd < dist[v]:
dist[v] = nd
if w == 0:
dq.appendleft(v) # push_front
else:
dq.append(v) # push_back
return dist
if __name__ == "__main__":
# A=0 B=1 C=2 D=3 E=4
adj = {
0: [(1, 0), (2, 1)],
1: [(3, 1), (4, 0)],
2: [(4, 1)],
3: [],
4: [(3, 0)],
}
print(zero_one_bfs(adj, 0, 5)) # [0, 0, 1, 0, 0]
What it does: computes shortest distances from the source on a 0/1-weighted graph. Run: go run main.go | javac ZeroOneBFS.java && java ZeroOneBFS | python zero_one_bfs.py
Coding Patterns¶
Pattern 1: Grid 0-1 BFS (free vs unit moves)¶
Intent: A 2D grid where moving into a free cell costs 0 and into a blocked cell costs 1 (e.g. "minimum obstacles to remove"). Flatten (r, c) to a vertex; push 0-cost moves to the front, 1-cost moves to the back.
from collections import deque
def min_obstacles(grid):
R, C = len(grid), len(grid[0])
INF = float("inf")
dist = [[INF] * C for _ in range(R)]
dist[0][0] = 0
dq = deque([(0, 0)])
while dq:
r, c = dq.popleft()
for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
nr, nc = r + dr, c + dc
if 0 <= nr < R and 0 <= nc < C:
w = grid[nr][nc] # 1 = obstacle to remove, 0 = free
if dist[r][c] + w < dist[nr][nc]:
dist[nr][nc] = dist[r][c] + w
if w == 0:
dq.appendleft((nr, nc))
else:
dq.append((nr, nc))
return dist[R - 1][C - 1]
Pattern 2: State-augmented vertex¶
Intent: When the "cost" depends on state (direction faced, key held), make the vertex (cell, state). The 0/1 rule is unchanged — only the vertex set grows.
vertex = (row, col, direction)
turning = weight 1 (push_back)
going straight = weight 0 (push_front)
Pattern 3: Lazy skip on pop (store distance too)¶
dq = deque([(0, src)]) # (distance, vertex)
while dq:
d, u = dq.popleft()
if d > dist[u]: # stale, already settled cheaper
continue
...
Error Handling¶
| Error | Cause | Fix |
|---|---|---|
| Wrong shortest distances | An edge weight is not 0 or 1 (e.g. a 2 slipped in). | Validate weights up front; if any weight > 1, use Dial's or Dijkstra. |
IndexError / panic on pop_front | Popping an empty deque. | Guard the loop with while deque is not empty. |
| Out-of-bounds in grid version | Neighbour (nr, nc) off the grid. | Bounds-check 0 <= nr < R and 0 <= nc < C before relaxing. |
| Infinite loop | Re-pushing a vertex even when distance did not improve. | Only push when dist[u] + w < dist[v] (strict improvement). |
| Quadratic blow-up | push_front implemented as an O(n) array insert. | Use a real deque (ArrayDeque, collections.deque, ring buffer). |
Performance Tips¶
- Use a real deque. Go's slice
append([]int{x}, s...)forpush_frontisO(n)and kills theO(V+E)bound — use a ring buffer or two stacks. Java'sArrayDequeand Python'scollections.dequeare alreadyO(1)both ends. - Flatten grid coordinates to a single integer
r*C + cto avoid tuple/object allocation in hot loops. - Prefer the lazy distance-check over a
visitedset when distances can improve — popping a stale entry andcontinue-ing is cheaper than the bookkeeping for eager updates. - Avoid storing the distance in the deque if you keep a
dist[]array — you can readdist[u]on pop. (But storing it makes the stale-skip trivial; pick one style and stay consistent.) - Short-circuit on target. If you only need
dist[target], you may stop as soon astargetis popped from the front (it is final then).
Best Practices¶
- Write a Dijkstra baseline first and diff against 0-1 BFS on random 0/1 graphs — they must agree on every vertex.
- Document the 0/1 weight convention at the top of the function ("0 = free move, 1 = costs one obstacle").
- Keep
push_front/push_backadjacent to thedistupdate so the weight-based branch is obvious to readers. - For grids, define the neighbour-offset list once (
((1,0),(-1,0),(0,1),(0,-1))) and reuse it. - Assert weights are in
{0,1}in a debug build — a silent2is the classic bug.
Edge Cases & Pitfalls¶
- Source equals target — distance is 0; the answer is
dist[src] = 0before the loop even starts. - All edges weight 0 — every reachable vertex has distance 0; the deque behaves like a stack/queue, still correct.
- All edges weight 1 — degenerates to plain BFS; everything goes to the back, identical to a FIFO queue.
- Disconnected vertices — stay at
INF; that is the correct "unreachable" answer. - Self-loops — a 0-weight self-loop never improves distance (no strict
<), so it is harmless; a 1-weight self-loop is also ignored. - Stale entries — the same vertex may sit in the deque twice with different distances; the lazy check or a settled-flag handles it.
Common Mistakes¶
- Using 0-1 BFS when a weight is
2or more. The front/back rule assumes exactly two adjacent distance levels; a weight of 2 breaks the invariant and gives wrong answers. Use Dial's or Dijkstra. - Pushing 0-edges to the back. Reverses the whole logic — you get FIFO behaviour and lose the "free moves are cheapest" guarantee.
O(n)push_front. Inserting at the front of a plain array isO(n); the algorithm degrades toO(V·E).- Forgetting the stale-skip. Without
if d > dist[u]: continue(or a settled flag) you re-expand finalised vertices and waste time, sometimes a lot. - Marking visited on push instead of on pop+settle. Eagerly marking on push can block a later, shorter 0-edge path. Settle on first front-pop.
- Mutating
distwithout the strict<check. Equal-distance re-pushes cause loops.
Cheat Sheet¶
| Action | Operation | Why |
|---|---|---|
| Relax 0-weight edge | push_front(v) | Same distance bucket; stays cheapest. |
| Relax 1-weight edge | push_back(v) | Next distance bucket; goes behind. |
| Take next vertex | pop_front() | Always the minimum current distance. |
| Skip stale entry | if d > dist[u]: continue | A cheaper path already settled it. |
| Initialise | dist[src]=0, others INF, deque=[src] | Standard single-source setup. |
relax(u -> v, w):
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
if w == 0: deque.push_front(v)
else: deque.push_back(v)
Decision rule: weights in {0,1} → 0-1 BFS; weights in {0..k} → Dial's; arbitrary ≥0 → Dijkstra; all equal → plain BFS.
Visual Animation¶
See
animation.htmlfor an interactive visual animation of 0-1 BFS.The animation demonstrates: - A small grid/graph with 0- and 1-weight edges - The deque shown with its front and back ends -
push_frontfor 0-edges andpush_backfor 1-edges, colour-coded - Distance labels updating as vertices are settled - Step / Run / Reset controls
Summary¶
0-1 BFS is plain BFS with one twist: a deque instead of a queue, with 0-weight edges going to the front and 1-weight edges to the back. That single rule keeps the deque sorted by distance (only ever two adjacent values), so pop_front always yields the closest vertex — giving you Dijkstra's correctness at BFS's O(V + E) speed. Use it whenever every edge weighs 0 or 1; reach for Dial's algorithm for small integer weights and Dijkstra for arbitrary non-negative weights.
Further Reading¶
- cp-algorithms.com — "0-1 BFS" — the canonical write-up with the deque proof.
- Sibling topic 02-bfs — plain BFS and the FIFO-queue distance argument.
- Sibling topic 04-dijkstra — the general non-negative-weight shortest path.
- Sibling topic 05-bellman-ford — what to do when weights can be negative.
- Competitive Programmer's Handbook (Laaksonen) — shortest paths chapter, bucket and deque techniques.
- Dial, R. B. (1969) — "Algorithm 360: Shortest-path forest with topological ordering" — the original bucket queue (Dial's algorithm).