Free Handbook · Every example compiled & verified

Problem Solving

A repeatable way to crack coding problems in Rust: read it, break it down, test small cases, then apply one of eight patterns that cover most interviews.

0 / 140 lessons🔥 0 day streak
ShareXLinkedIn

Module 14 · what you'll be able to do

  • Turn a problem statement into inputs, outputs, constraints and edge cases before writing any code
  • Test a solution with a tiny table of small cases instead of guessing
  • Estimate the Big-O a problem needs from its input limits, in plain English
  • Recognise and apply eight patterns: two pointers, sliding window, hash map, stack, backtracking, sorting, BFS/DFS and DP-lite
  • Write those solutions idiomatically: slices in, Option out, iterators where they read better than index loops
01

Read the problem before touching the keyboard

Most failed coding rounds fail in the first two minutes: the candidate starts typing a solution to a problem they have not fully read. Slow down and write these five things down (in a comment, on the whiteboard, or out loud) before any code. In Rust the second step matters more than in most languages, because the types you pick decide what the compiler will let you do.

  1. 1
    Restate it

    One sentence, in your own words. "Given a list of prices, return the biggest profit from one buy followed by one sell."

  2. 2
    Inputs and outputs, as Rust types

    Take a slice, &[u32], not a Vec: the function only reads it, and a slice accepts arrays and vectors alike. Can there be no answer? Then return Option<T>, not a magic -1.

  3. 3
    Constraints

    How big is n? Can values be negative (i32) or not (u32, usize)? Can a sum overflow i32? Duplicates? Sorted? These decide the algorithm and the integer type.

  4. 4
    Two examples by hand

    One normal, one tricky. Work them out on paper. If you cannot do it by hand, you cannot code it.

  5. 5
    Edge cases

    Empty input, one element, all equal, already sorted, and anything that makes a usize subtraction go below zero.

rustmain.rs
// Restated: best profit from one buy then one later sell; 0 if prices only fall.
// Input: a slice of prices (may be empty). Output: u32, never negative.
fn max_profit(prices: &[u32]) -> u32 {
    let mut lowest = u32::MAX;
    let mut best = 0;
    for &price in prices {
        lowest = lowest.min(price);             // cheapest buy so far
        best = best.max(price - lowest);        // sell today? (price >= lowest, so no underflow)
    }
    best
}

fn main() {
    println!("{}", max_profit(&[7, 1, 5, 3, 6, 4])); // normal: buy 1, sell 6
    println!("{}", max_profit(&[7, 6, 4, 3, 1]));    // only falls
    println!("{}", max_profit(&[]));                 // empty
    println!("{}", max_profit(&[5]));                // one day
}
Outputcompiled & run with real Rust
5
0
0
0

The comments at the top are the reading step. The four calls in main are the examples and edge cases from that step, not an afterthought.

Your turn

Change the function to return Option<(usize, usize)>: the buy day and sell day, or None when no profit is possible. Which of the four calls now print None?

02

Break it down and test with small cases

Break the problem into pieces you can test separately: one step that cleans the input, one that checks it. Then test with the smallest inputs that could break it. An array of (input, expected) tuples and a loop is the fastest harness there is, and it shows an interviewer you verify your own code. The same table moves unchanged into a #[test] function in a real crate.

rustmain.rs
fn is_palindrome(s: &str) -> bool {
    let cleaned: Vec<char> = s
        .chars()
        .filter(|c| c.is_alphanumeric())
        .map(|c| c.to_ascii_lowercase())
        .collect();
    cleaned.iter().eq(cleaned.iter().rev())
}

fn main() {
    // (input, expected): the small cases, written down before the code
    let cases = [
        ("", true),                              // empty
        ("a", true),                             // one char
        ("ab", false),                           // smallest false case
        ("Aba", true),                           // case
        ("A man, a plan, a canal: Panama", true),
        ("race a car", false),
        (".,", true),                            // only punctuation
    ];
    let mut failed = 0;
    for (input, expected) in cases {
        let got = is_palindrome(input);
        if got != expected {
            failed += 1;
            println!("FAIL {:?}: expected {}, got {}", input, expected, got);
        }
    }
    println!("{} passed, {} failed", cases.len() - failed, failed);
}
Outputcompiled & run with real Rust
7 passed, 0 failed

cleaned.iter().eq(cleaned.iter().rev()) compares the sequence with its own reverse, element by element, with no index arithmetic to get wrong.

Your turn

Break the function on purpose (drop the to_ascii_lowercase step) and watch exactly which case reports FAIL. That is how a good small-case table pinpoints a bug.

rustsrc/lib.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn small_cases() {
        for (input, expected) in [("", true), ("ab", false), ("Aba", true)] {
            assert_eq!(is_palindrome(input), expected, "input: {:?}", input);
        }
    }
}

The same table as a unit test. cargo test runs it, and the message after assert_eq! names the failing input.

The order to test in
Empty, one element, two elements, the example from the problem, then one case that targets each branch of your code. Small inputs make a wrong answer obvious by eye.
03

Big-O in plain English

Big-O answers one question: if the input gets ten times bigger, how much slower does this get? O(n) gets ten times slower. O(n²) gets a hundred times slower. O(log n) barely notices. An optimised Rust program does very roughly 108 to 109 simple operations per second, which turns the constraints in a problem statement into a direct hint about which algorithm is expected. Rust is fast, but it does not rescue the wrong complexity: O(n²) at n = 106 is still hours.

Rules of thumb, not laws. They are good enough to rule out the wrong approach before you write it.
If n is up to…You can affordTypical approach
10 to 20O(2n) or O(n!)Backtracking: try every subset or ordering
~500O(n³)Three nested loops, small DP tables
~5,000O(n²)Two nested loops, 2-D DP
~106O(n log n)Sort first, heap, binary search inside a loop
~108O(n)One pass: two pointers, sliding window, hash map
Anything largerO(log n) or O(1)Binary search on the answer, a formula
rustmain.rs
use std::collections::HashSet;

fn has_duplicate_nested(a: &[u32], ops: &mut u64) -> bool {   // O(n^2)
    for i in 0..a.len() {
        for j in i + 1..a.len() {
            *ops += 1;
            if a[i] == a[j] {
                return true;
            }
        }
    }
    false
}

fn has_duplicate_set(a: &[u32], ops: &mut u64) -> bool {      // O(n)
    let mut seen = HashSet::new();
    for &x in a {
        *ops += 1;
        if !seen.insert(x) {       // insert returns false if x was already there
            return true;
        }
    }
    false
}

fn main() {
    for n in [1_000u32, 10_000] {
        let a: Vec<u32> = (0..n).collect();          // no duplicates: worst case
        let (mut nested, mut set) = (0, 0);
        has_duplicate_nested(&a, &mut nested);
        has_duplicate_set(&a, &mut set);
        println!("n={}  nested={}  set={}", n, nested, set);
    }
}
Outputcompiled & run with real Rust
n=1000  nested=499500  set=1000
n=10000  nested=49995000  set=10000

Ten times the input: the nested version does a hundred times the work, the set version ten times. At n = 106 the nested version would need half a trillion comparisons.

Your turn

Add a third way: sort a copy with sort_unstable, then check v.windows(2).any(|w| w[0] == w[1]). What is its Big-O, and why might it beat the HashSet in practice?

Space is part of the answer
The HashSet version buys speed with O(n) extra memory; the nested version uses none. Interviewers expect both numbers: "O(n) time, O(n) space". Also count hidden work: v.contains(&x) inside a loop is an O(n) scan, and .clone() of a Vec is O(n) too.
04

Pattern 1: two pointers

Signal: a sorted slice (or a string) and a question about pairs, or "in place". Put one index at each end and move them towards each other based on a comparison. Each step discards a whole row of candidate pairs, so an O(n²) "try every pair" becomes O(n). The answer may not exist, so the return type is Option<(usize, usize)>.

rustmain.rs
// Sorted input: positions of two numbers that add up to target.
fn pair_sum(a: &[i32], target: i32) -> Option<(usize, usize)> {
    if a.is_empty() {
        return None;
    }
    let (mut i, mut j) = (0, a.len() - 1);
    while i < j {
        let sum = a[i] + a[j];
        if sum == target {
            return Some((i, j));
        } else if sum < target {
            i += 1;               // need bigger: move the left pointer up
        } else {
            j -= 1;               // need smaller: move the right pointer down
        }
    }
    None
}

fn main() {
    let a = [1, 3, 4, 6, 8, 11];
    println!("{:?}", pair_sum(&a, 10));
    println!("{:?}", pair_sum(&a, 2));
    println!("{:?}", pair_sum(&[], 5));
}
Outputcompiled & run with real Rust
Some((2, 3))
None
None
Your turn

Write reverse_in_place(v: &mut [i32]) with two pointers and v.swap(i, j), then compare it with the built-in v.reverse().

Visualizepair_sum(&[1, 3, 4, 6, 8, 11], 10)Step 1 / 11
let (mut i, mut j) = (0, a.len() - 1);
while i < j {
let sum = a[i] + a[j];
if sum == target {
return Some((i, j));
} else if sum < target {
i += 1;
} else {
j -= 1;
}
}
Line 1

Pointers at both ends.

Variables now
i0
j5
All 11 steps as a table
StepLineWhat happenedVariables now
11Pointers at both ends.i = 0 j = 5
231 + 11 = 12, too big.sum = 12
39Every pair using 11 is too big, so drop 11.j = 4
431 + 8 = 9, too small.sum = 9
57Every pair using 1 is too small, so drop 1.i = 1
633 + 8 = 11, too big.sum = 11
79Drop 8.j = 3
833 + 6 = 9, too small.sum = 9
97Drop 3.i = 2
1034 + 6 = 10.sum = 10
115Found it after five steps, not fifteen pairs.
Error you will hit

Runtime panic: attempt to subtract with overflow

rust
fn pair_sum(a: &[i32], target: i32) -> Option<(usize, usize)> {
    let (mut i, mut j) = (0, a.len() - 1);
    while i < j {
        let sum = a[i] + a[j];
        if sum == target {
            return Some((i, j));
        } else if sum < target {
            i += 1;
        } else {
            j -= 1;
        }
    }
    None
}

fn main() {
    println!("{:?}", pair_sum(&[], 5));
}
thread 'main' (13492595) panicked at main.rs:2:30:
attempt to subtract with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Why the compiler said that

a.len() is a usize, which cannot be negative. For an empty slice, 0 - 1 has no answer, so a debug build panics (a release build would silently wrap to usize::MAX and then index out of bounds). This is the classic Rust two-pointer bug: code ported from C or Java, where the index was a signed int.

The fix

Handle the empty case before the subtraction, as the working version above does with if a.is_empty() { return None; }. Alternatives: a.len().checked_sub(1)?, or keep j as one-past-the-end and read a[j - 1].

rust
fn pair_sum(a: &[i32], target: i32) -> Option<(usize, usize)> {
    let mut i = 0;
    let mut j = a.len().checked_sub(1)?; // None for an empty slice
    while i < j {
        let sum = a[i] + a[j];
        if sum == target {
            return Some((i, j));
        } else if sum < target {
            i += 1;
        } else {
            j -= 1;
        }
    }
    None
}

fn main() {
    println!("{:?}", pair_sum(&[], 5));
}
05

Pattern 2: sliding window

Signal: "contiguous", "substring", "subarray", "of length k", "longest/shortest run such that…". Keep a window [left, right] and a running summary of it (a sum, a count, the last position of each byte). Move right forward every step; move left forward only when the window breaks the rule. Both pointers only go forward, so the whole thing is O(n).

rustmain.rs
// Fixed window: biggest sum of k consecutive values.
fn max_window_sum(a: &[i64], k: usize) -> Option<i64> {
    if k == 0 || k > a.len() {
        return None;
    }
    let mut sum: i64 = a[..k].iter().sum();
    let mut best = sum;
    for i in k..a.len() {
        sum += a[i] - a[i - k];          // slide: add the new right, drop the old left
        best = best.max(sum);
    }
    Some(best)
}

// Variable window: length of the longest run of bytes with no repeat.
fn longest_unique(s: &str) -> usize {
    let bytes = s.as_bytes();
    let mut last_seen = [None::<usize>; 256];  // byte -> last index it appeared at
    let (mut left, mut best) = (0, 0);
    for (right, &b) in bytes.iter().enumerate() {
        if let Some(prev) = last_seen[b as usize] {
            if prev >= left {
                left = prev + 1;                // shrink past the earlier copy
            }
        }
        last_seen[b as usize] = Some(right);
        best = best.max(right - left + 1);
    }
    best
}

fn main() {
    println!("{:?}", max_window_sum(&[2, 1, 5, 1, 3, 2], 3));
    println!("{:?}", max_window_sum(&[4, 2], 3));
    for s in ["abcabcbb", "bbbbb", "pwwkew", ""] {
        println!("{:?} -> {}", s, longest_unique(s));
    }
}
Outputcompiled & run with real Rust
Some(9)
None
"abcabcbb" -> 3
"bbbbb" -> 1
"pwwkew" -> 3
"" -> 0

a.windows(k).map(|w| w.iter().sum::<i64>()).max() gives the same fixed-window answer in one line, but re-adds k values per window: O(n·k). The running sum is O(n). For the byte table, a 256-slot array replaces a HashMap: the key space is tiny and fixed.

Your turn

Write longest_with_two_distinct(s: &str) -> usize: the longest substring with at most two different bytes. Keep a count per byte and shrink from the left while more than two counts are non-zero. "eceba" should give 3.

06

Pattern 3: hash map lookups

Signal: "have I seen this before?", "count", "group by", "find the complement". Trade O(n) memory for O(1) lookups and a nested loop disappears. In Rust, get returns an Option, so "found it" and "not there" are both handled by if let, and the entry API inserts-or-updates with one lookup. When the output must be in a stable order, use a BTreeMap: a HashMap iterates in an order that changes from run to run.

rustmain.rs
use std::collections::{BTreeMap, HashMap};

// Unsorted input: positions of two numbers that add up to target.
fn two_sum(nums: &[i32], target: i32) -> Option<(usize, usize)> {
    let mut seen: HashMap<i32, usize> = HashMap::new();   // value -> index
    for (i, &x) in nums.iter().enumerate() {
        if let Some(&j) = seen.get(&(target - x)) {
            return Some((j, i));
        }
        seen.insert(x, i);
    }
    None
}

// Group words that are anagrams of each other.
fn group_anagrams<'a>(words: &[&'a str]) -> Vec<Vec<&'a str>> {
    let mut groups: BTreeMap<Vec<char>, Vec<&'a str>> = BTreeMap::new();
    for &w in words {
        let mut key: Vec<char> = w.chars().collect();
        key.sort_unstable();                                  // "eat" and "tea" -> ['a','e','t']
        groups.entry(key).or_default().push(w);
    }
    groups.into_values().collect()
}

fn main() {
    println!("{:?}", two_sum(&[2, 7, 11, 15], 9));
    println!("{:?}", two_sum(&[3, 2, 4], 6));
    println!("{:?}", two_sum(&[1, 2], 7));
    println!("{:?}", group_anagrams(&["eat", "tea", "tan", "ate", "nat", "bat"]));
}
Outputcompiled & run with real Rust
Some((0, 1))
Some((1, 2))
None
[["bat"], ["eat", "tea", "ate"], ["tan", "nat"]]

Two-sum checks for the complement before inserting the current value, so a number is never paired with itself. The anagram groups come out sorted by key because the map is a BTreeMap.

Your turn

Write first_unique(s: &str) -> Option<usize>: the index of the first character that appears exactly once. Count with *counts.entry(c).or_insert(0) += 1 in one pass, then find the index in a second pass.

07

Pattern 4: stack

Signal: nesting (brackets, tags, undo), "the most recent unmatched…", or "the next greater element". A Vec is the stack (Module 13 built one); pop returns Option, which makes "closer with nothing open" fall out of a single comparison. A monotonic stack keeps its items in increasing or decreasing order and answers "next warmer / taller / bigger" questions in O(n): each index is pushed once and popped once.

rustmain.rs
fn balanced(s: &str) -> bool {
    let mut stack = Vec::new();
    for c in s.chars() {
        match c {
            '(' => stack.push(')'),          // push the closer we now expect
            '[' => stack.push(']'),
            '{' => stack.push('}'),
            ')' | ']' | '}' => {
                if stack.pop() != Some(c) {
                    return false;            // wrong closer, or nothing open
                }
            }
            _ => {}                          // ignore everything else
        }
    }
    stack.is_empty()                         // anything left open?
}

// For each day, how many days until a warmer one (0 if never).
fn days_until_warmer(temps: &[i32]) -> Vec<usize> {
    let mut answer = vec![0; temps.len()];
    let mut waiting: Vec<usize> = Vec::new();         // indices, temps decreasing
    for (i, &t) in temps.iter().enumerate() {
        while let Some(&j) = waiting.last() {
            if temps[j] >= t {
                break;
            }
            waiting.pop();
            answer[j] = i - j;                          // day i is j's warmer day
        }
        waiting.push(i);
    }
    answer
}

fn main() {
    for s in ["([]{})", "([)]", "((", "fn f() { v[0] }"] {
        println!("{:<16} {}", s, balanced(s));
    }
    println!("{:?}", days_until_warmer(&[73, 74, 75, 71, 69, 72, 76, 73]));
}
Outputcompiled & run with real Rust
([]{})           true
([)]             false
((               false
fn f() { v[0] }  true
[1, 1, 4, 2, 1, 1, 0, 0]

Pushing the expected closer instead of the opener means the check is just stack.pop() != Some(c): an empty stack gives None, which never equals Some(c).

Your turn

Write eval_rpn(tokens: &[&str]) -> Option<i64> for reverse Polish notation: push numbers, and on an operator pop two, apply it, push the result. ["2", "1", "+", "3", "*"] is 9. Use ? on each pop().

08

Pattern 5: recursion and backtracking

Signal: "all combinations", "all permutations", "every way to…", and a small n (up to about 20). Build a candidate one choice at a time: choose (push), explore (recurse), un-choose (pop). One &mut Vec is shared by every call, so there is no copying except when a full answer is recorded. Prune early: if the candidates are sorted and one is already too big, every later one is too.

rustmain.rs
// Every combination of candidates (each used at most once) that sums to target.
fn combination_sum(candidates: &[u32], target: u32) -> Vec<Vec<u32>> {
    fn go(c: &[u32], start: usize, remaining: u32, path: &mut Vec<u32>, out: &mut Vec<Vec<u32>>) {
        if remaining == 0 {
            out.push(path.clone());                  // found one: record a copy
            return;
        }
        for i in start..c.len() {
            if c[i] > remaining {
                break;                               // sorted, so nothing later fits either
            }
            path.push(c[i]);                         // choose
            go(c, i + 1, remaining - c[i], path, out); // explore
            path.pop();                              // un-choose
        }
    }
    let mut sorted = candidates.to_vec();
    sorted.sort_unstable();
    let mut out = Vec::new();
    go(&sorted, 0, target, &mut Vec::new(), &mut out);
    out
}

fn main() {
    println!("{:?}", combination_sum(&[5, 2, 3, 7, 1], 8));
    println!("{:?}", combination_sum(&[4, 6], 5));
}
Outputcompiled & run with real Rust
[[1, 2, 5], [1, 7], [3, 5]]
[]

The helper go is a nested fn: it cannot capture variables like a closure, so everything it needs is passed in. That is also what the borrow checker wants — one &mut path handed down the recursion.

Your turn

Write permutations(v: &[i32]) -> Vec<Vec<i32>> with the same choose/explore/un-choose shape and a used: Vec<bool>. [1, 2, 3] has 6 permutations.

09

Pattern 6: sort first

Signal: intervals, meetings, "closest", "merge", "group equal things", or a problem that would be easy if the input were in order. An O(n log n) sort often turns a messy O(n²) problem into one linear pass. sort_unstable_by_key is the usual choice: faster than sort and no extra memory, and stability rarely matters once you sort by the field you care about.

rustmain.rs
// Merge overlapping [start, end] intervals.
fn merge(mut intervals: Vec<(u32, u32)>) -> Vec<(u32, u32)> {
    intervals.sort_unstable_by_key(|&(start, _)| start);
    let mut merged: Vec<(u32, u32)> = Vec::new();
    for (start, end) in intervals {
        match merged.last_mut() {
            Some(last) if start <= last.1 => last.1 = last.1.max(end), // overlaps: extend
            _ => merged.push((start, end)),                            // gap: new interval
        }
    }
    merged
}

fn main() {
    println!("{:?}", merge(vec![(8, 10), (1, 3), (2, 6), (15, 18)]));
    println!("{:?}", merge(vec![(1, 4), (4, 5)]));
    println!("{:?}", merge(vec![(1, 10), (2, 3), (4, 5)]));
    println!("{:?}", merge(vec![]));
}
Outputcompiled & run with real Rust
[(1, 6), (8, 10), (15, 18)]
[(1, 5)]
[(1, 10)]
[]

last_mut() hands back an Option<&mut (u32, u32)>, and the match guard if start <= last.1 decides between extending the last interval and starting a new one. Taking Vec by value lets the function sort it without cloning.

Your turn

Write can_attend_all(meetings: &mut [(u32, u32)]) -> bool: sort by start, then check meetings.windows(2).all(|w| w[0].1 <= w[1].0).

10

Pattern 7: BFS and DFS on a grid

Signal: a grid, a maze, "connected regions", "islands", "fewest steps". A grid is a graph where each cell links to its four neighbours. DFS (a stack) is enough for "how many regions"; BFS (a VecDeque) is required for "fewest steps", because it visits cells in order of distance. Module 13 did both on an adjacency list; here the new problem is the grid edge, where usize cannot go to -1.

rustmain.rs
use std::collections::VecDeque;

const DIRS: [(isize, isize); 4] = [(-1, 0), (1, 0), (0, -1), (0, 1)];

// The in-bounds neighbours of (r, c).
fn neighbours(grid: &[Vec<u8>], r: usize, c: usize) -> impl Iterator<Item = (usize, usize)> + '_ {
    DIRS.iter().filter_map(move |&(dr, dc)| {
        let nr = r.checked_add_signed(dr)?;          // None if it would go below 0
        let nc = c.checked_add_signed(dc)?;
        (nr < grid.len() && nc < grid[0].len()).then_some((nr, nc))
    })
}

// DFS: count islands of '#' (land connected up/down/left/right).
fn count_islands(grid: &[Vec<u8>]) -> usize {
    let mut seen = vec![vec![false; grid[0].len()]; grid.len()];
    let mut islands = 0;
    for r in 0..grid.len() {
        for c in 0..grid[0].len() {
            if grid[r][c] != b'#' || seen[r][c] {
                continue;
            }
            islands += 1;
            let mut stack = vec![(r, c)];            // explicit stack: no deep recursion
            seen[r][c] = true;
            while let Some((cr, cc)) = stack.pop() {
                for (nr, nc) in neighbours(grid, cr, cc) {
                    if grid[nr][nc] == b'#' && !seen[nr][nc] {
                        seen[nr][nc] = true;
                        stack.push((nr, nc));
                    }
                }
            }
        }
    }
    islands
}

// BFS: fewest steps from top-left to bottom-right through '.' cells.
fn shortest_path(grid: &[Vec<u8>]) -> Option<usize> {
    let (rows, cols) = (grid.len(), grid[0].len());
    let mut dist = vec![vec![None; cols]; rows];
    let mut queue = VecDeque::from([(0, 0)]);
    dist[0][0] = Some(0);
    while let Some((r, c)) = queue.pop_front() {
        let d = dist[r][c]?;
        if (r, c) == (rows - 1, cols - 1) {
            return Some(d);                          // BFS reaches it first by the shortest route
        }
        for (nr, nc) in neighbours(grid, r, c) {
            if grid[nr][nc] == b'.' && dist[nr][nc].is_none() {
                dist[nr][nc] = Some(d + 1);
                queue.push_back((nr, nc));
            }
        }
    }
    None
}

fn parse(rows: &[&str]) -> Vec<Vec<u8>> {
    rows.iter().map(|r| r.bytes().collect()).collect()
}

fn main() {
    let map = parse(&["##..#", "#...#", "..#..", "....#"]);
    println!("islands: {}", count_islands(&map));
    let maze = parse(&["..#.", "#...", "..#.", ".#.."]);
    println!("shortest: {:?}", shortest_path(&maze));
    let blocked = parse(&[".#", "#."]);
    println!("blocked: {:?}", shortest_path(&blocked));
}
Outputcompiled & run with real Rust
islands: 4
shortest: Some(6)
blocked: None

checked_add_signed returns None when a move would leave the grid at the top or left, and ? inside filter_map drops that neighbour. One helper handles every bounds check, so the searches never touch index arithmetic.

Your turn

Change count_islands to return the size of the largest island instead (count cells as you pop them). For the map above the answer is 3.

Why an explicit stack, not recursion
A recursive DFS on a 1000 × 1000 grid of land can go a million calls deep and overflow the main thread's stack, which aborts the program. A Vec used as a stack lives on the heap and has no such limit.
11

Pattern 8: DP-lite

Signal: "maximum/minimum", "how many ways", and each answer depends on the answer for a slightly smaller input. Many interview DP problems only look back one or two steps, so the table from Module 13 shrinks to a couple of variables: O(n) time, O(1) memory. Name each variable by what it means ("best so far if I took the last house") and the recurrence writes itself.

rustmain.rs
// House robber: best total without taking two neighbours.
fn rob(houses: &[u32]) -> u32 {
    let (mut skip, mut take) = (0, 0);         // best so far if the last house was skipped / taken
    for &h in houses {
        (skip, take) = (skip.max(take), skip + h);
    }
    skip.max(take)
}

// Kadane: biggest sum of a non-empty contiguous run.
fn max_subarray(a: &[i32]) -> Option<i32> {
    let (&first, rest) = a.split_first()?;
    let (mut ending_here, mut best) = (first, first);
    for &x in rest {
        ending_here = x.max(ending_here + x);   // extend the run, or start again at x
        best = best.max(ending_here);
    }
    Some(best)
}

fn main() {
    println!("rob {:?} = {}", [2, 7, 9, 3, 1], rob(&[2, 7, 9, 3, 1]));
    println!("rob {:?} = {}", [2, 1, 1, 2], rob(&[2, 1, 1, 2]));
    println!("max_subarray = {:?}", max_subarray(&[-2, 1, -3, 4, -1, 2, 1, -5, 4]));
    println!("max_subarray = {:?}", max_subarray(&[-3, -1, -2]));
    println!("max_subarray = {:?}", max_subarray(&[]));
}
Outputcompiled & run with real Rust
rob [2, 7, 9, 3, 1] = 12
rob [2, 1, 1, 2] = 4
max_subarray = Some(6)
max_subarray = Some(-1)
max_subarray = None

(skip, take) = (skip.max(take), skip + h) updates both at once from the old values; two separate assignments would read a value already overwritten. split_first()? turns "empty slice" into None without an index or a panic.

Your turn

Climbing stairs: you may take 1 or 2 steps; how many ways to reach step n? Keep two variables, (a, b) = (b, a + b). n = 5 gives 8.

12

Choosing the pattern

The problem says…TryTypical cost
Sorted slice, pairs, "in place"Two pointersO(n) time, O(1) space
Contiguous subarray or substring, "longest/shortest such that"Sliding windowO(n)
"Seen before", count, group, complementHash map / set (BTreeMap if order matters)O(n) time, O(n) space
Nesting, "most recent", "next greater"Stack (Vec), monotonic stackO(n)
"All combinations / permutations", n ≤ 20BacktrackingO(2n) or O(n!)
Intervals, "merge", "closest", order would helpSort firstO(n log n)
Grid, maze, regions, "fewest steps"DFS for regions, BFS for distanceO(rows × cols)
"Max/min/how many ways", answer builds on smaller answersDP (often two variables)O(n)
Sorted data, "find the smallest x such that…"Binary search (Module 13)O(log n)
"k largest / smallest", a streamBinaryHeap of size kO(n log k)
Talk while you solve
Interviewers grade the process as much as the answer. Say the brute force and its cost first, name the pattern and why the signal fits, then code. When the borrow checker objects mid-interview, say what it caught ("I am holding a reference into the vector while pushing to it") — that sentence scores better than silently adding .clone() until it compiles.
Two pointers
Two indices that move towards each other (or in the same direction) so each step rules out many candidates; turns O(n²) pair checks into O(n).
Sliding window
A contiguous range [left, right] with a running summary; right always advances, left advances only to restore the rule.
Monotonic stack
A stack kept in increasing or decreasing order; each item is pushed and popped once, answering "next greater" questions in O(n).
Backtracking
Build a candidate by choose, explore, un-choose, abandoning a branch as soon as it cannot lead to an answer (pruning).
BFS
Breadth-first search with a queue; visits nodes in order of distance, so the first time it reaches a node is by a shortest path.
DFS
Depth-first search with a stack or recursion; follows one path as far as it goes before backing up. Good for regions and reachability.
Dynamic programming
Solve each smaller subproblem once and build bigger answers from them; "DP-lite" keeps only the last one or two results.
usize underflow
Subtracting below zero on an unsigned index; panics in debug builds and wraps in release. Guard empty inputs or use checked_sub.
Quick check

Constraints say n ≤ 100,000. Which approach is most likely expected?

Quick check

Your two-pointer function starts with let mut j = a.len() - 1; and is called with an empty slice in a debug build. What happens?

Frequently asked questions

Is Rust a good language for coding interviews?
Yes, once you know the handful of APIs that problems need: slices, Vec, HashMap and its entry API, BTreeMap, VecDeque, BinaryHeap, sort_unstable_by_key and iterator adapters. The main traps are usize underflow at index 0 and fighting the borrow checker over a reference held while mutating; both disappear with indices instead of references and early guards for empty input.
How do I get better at solving coding problems in Rust?
Practise by pattern, not at random: three to five problems per pattern (two pointers, sliding window, hash map, stack, backtracking, sorting, BFS/DFS, DP) until you recognise the signal in the problem statement. Write the brute force first, test with a small table of cases, and state the Big-O out loud.
How do I know which Big-O a problem needs?
Read the input limits. Up to about 20 allows exponential backtracking, a few thousand allows O(n²), around a million needs O(n log n), and larger needs O(n) or binary search. Rust runs roughly 10^8 to 10^9 simple operations per second, which is fast but does not rescue the wrong complexity.

Finish the Rust handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.