Rust String Manipulation for Coding Interviews: The Reference Guide

June 19, 202610 min read
dsaalgorithmsinterview-prepdata-structures
Rust String Manipulation for Coding Interviews: The Reference Guide
TL;DR
  • String owns and mutates; &str borrows — function parameters should always take &str to accept both
  • s[0] is a compile error in Rust; use .as_bytes()[i] for ASCII or .chars().nth(i) for Unicode
  • .len() returns byte count, not character count — use .chars().count() when you need character length
  • Collect into Vec<char> before two-pointer work so you can index freely at O(1)
  • .split_whitespace() handles consecutive spaces where .split(' ') produces empty string tokens
  • Every transformation returns a new String — there is no in-place .reverse(), collect from iterators instead
  • .parse::<T>().unwrap() is fine in interviews where the problem guarantees clean numeric input

Rust strings are the thing that makes first-timers rage-quit. Two types. No indexing. UTF-8 enforced at compile time. Every other language lets you grab s[0] and go home for dinner. Rust looks you in the eye and says: "What is a character, really?"

The model is actually consistent once it clicks. The API is clean and precise. This is the reference you want before the 45-minute clock starts.

If you want the broader Rust interview picture first (data structures, idioms, common gotchas), start with the Rust for Coding Interviews guide.

Two Types. One Rule.

Rust has two string types and you will use both constantly.

String is owned, heap-allocated, and mutable. You create it, you own it, you can push into it. &str is a borrowed string slice, a reference into string data that lives somewhere else (the heap, the binary, a function argument). You cannot modify &str.

let owned: String = String::from("hello"); let literal: &str = "world"; // points into binary let slice: &str = &owned[0..3]; // points into owned

The rule for interviews: use String when you're building or modifying. Use &str for function parameters, since it accepts both String references and string literals without forcing the caller to convert anything.

fn first_char(s: &str) -> Option<char> { s.chars().next() } first_char("hello"); // works first_char(&String::from("hello")); // also works

If your function takes &String, it only accepts &String. If it takes &str, it accepts everything. Always prefer &str for read-only parameters. This is not a style preference. It is the difference between a function that works and one that makes every caller add .as_str() in five different places.

You Cannot Index a String. The Compiler Is Technically Right About This.

Python: s[0]. JavaScript: s[0]. Java: s.charAt(0). Rust:

let s = String::from("hello"); let ch = s[0]; // error[E0277]: the type `str` cannot be indexed by `{integer}`

Rust refuses because strings are UTF-8, and a byte offset might land in the middle of a multi-byte character. The compiler cannot verify that index 0 is safe without knowing the content. This is technically correct. It is also extremely annoying the first fifteen times you hit it.

Three alternatives, depending on what you actually need:

let s = "hello"; // Character at position (O(n), allocates nothing) let ch: Option<char> = s.chars().nth(0); // Some('h') // Byte at position (only correct for ASCII) let byte: u8 = s.as_bytes()[0]; // 104 // Byte-range slice (panics if boundaries split a multi-byte char) let piece: &str = &s[1..4]; // "ell"

In LeetCode-style problems, input is almost always ASCII. .as_bytes()[i] is the fast path. For correctness with arbitrary Unicode, use .chars().nth(i).

The full catalog of Rust footguns (including several that compile fine and fail silently) is in Rust Coding Interview Gotchas. String indexing is just the most visible one.

Collecting chars into a Vec<char> is often the cleanest approach for two-pointer problems, since you can index that freely:

let chars: Vec<char> = s.chars().collect(); let first = chars[0]; // fine, no lectures

It costs O(n) space, but you get O(1) indexing and clean two-pointer logic. In a 45-minute interview, clean logic beats micro-optimization every time.

A meme about the frustration of reversing a string in Rust compared to other languages Every other language: s[::-1]. Rust: "have you considered iterator adapters and the nature of Unicode scalar values?"

Building Strings Without Going Insane

Three patterns cover everything you will encounter:

// Accumulate in a loop let mut result = String::new(); result.push_str("hello"); result.push(' '); // push takes a char, not a &str result.push_str("world"); // Format macro (readable, allocates once) let name = "Alice"; let greeting = format!("Hello, {}!", name); // Pre-allocate when you know the approximate size let mut buf = String::with_capacity(n * 2); for ch in s.chars() { buf.push(ch); buf.push('-'); }

format! is the right default for most interview problems. It is readable, does not consume its arguments, and creates zero ownership headaches. Use push_str in tight loops where you are appending many times and want to avoid repeated reallocations.

Collecting from an iterator is the other common pattern:

let chars = vec!['h', 'e', 'l', 'l', 'o']; let s: String = chars.into_iter().collect(); // Reversing (no, there is no s.reverse()) let rev: String = "hello".chars().rev().collect(); // Joining a list of strings let words = vec!["one", "two", "three"]; let joined = words.join(", "); // "one, two, three"

Search Is Easy. Split Has a Catch.

let s = "the quick brown fox"; s.contains("quick"); // true s.starts_with("the"); // true s.ends_with("fox"); // true // Returns byte index, not char index s.find("quick"); // Some(4) s.find("cat"); // None // Split on a delimiter let parts: Vec<&str> = s.split(' ').collect(); // Split on whitespace (handles multiple spaces, tabs, newlines) let tokens: Vec<&str> = s.split_whitespace().collect(); // Lines let text = "one\ntwo\nthree"; let lines: Vec<&str> = text.lines().collect(); // splitn limits the number of pieces let limited: Vec<&str> = "a:b:c:d".splitn(3, ':').collect(); // ["a", "b", "c:d"]

Use .split_whitespace() for any token-parsing problem. Plain .split(' ') gives you empty strings when there are consecutive spaces. This will bite you on test case 7 of 10 and you will spend ten minutes confused about why.

The parts returned by .split() are &str slices, not String. If you need owned strings, call .to_string() on each piece.

Every Transformation Returns a New String

There is no in-place .reverse(). No in-place .to_uppercase(). Rust strings are not byte arrays and they do not pretend to be. Every transformation produces a new String. This sounds annoying and then you realize it is actually fine because mutation was the source of half your bugs in other languages.

let padded = " hello world "; padded.trim(); // "hello world" padded.trim_start(); // "hello world " padded.trim_end(); // " hello world" "hello".to_uppercase(); // "HELLO" "HELLO".to_lowercase(); // "hello" "aabba".replace("a", "x"); // "xxbbx" "aabba".replacen("a", "x", 1); // "xabba" (first match only) "ab".repeat(3); // "ababab"

The iterator pattern handles every transformation:

let reversed: String = s.chars().rev().collect(); let uppercased: String = s.chars().map(|c| c.to_ascii_uppercase()).collect(); let filtered: String = s.chars().filter(|c| c.is_alphabetic()).collect();

Chain them. Collect at the end. Done.

.chars() vs .bytes(): Default to Chars

let s = "café"; s.len(); // 5 (byte count) s.chars().count(); // 4 (character count)

é is two bytes in UTF-8. So len() is 5 but there are 4 characters. This is the kind of mismatch that hides quietly until someone tests with non-ASCII input and everything breaks.

// Iterate characters (correct for Unicode) for ch in s.chars() { /* c, a, f, é */ } // Iterate with byte offset for (byte_idx, ch) in s.char_indices() { // byte_idx: 0, 1, 2, 3 (é starts at byte 3, ends at byte 5) } // Iterate raw bytes (fast, ASCII-only) for b in s.bytes() { /* 99, 97, 102, 195, 169 */ }

For interviews: use .chars() by default. It is always correct. Use .bytes() only when the problem guarantees ASCII and you need every microsecond, which is almost never during an interview.

Meme showing the confusion between Rust's two string types Two string types, both valid, both watching you reach for the wrong one.

Type Conversions Are One Line

// &str to String let s: String = "hello".to_string(); let s: String = String::from("hello"); // String to &str let owned = String::from("hello"); let slice: &str = &owned; let slice: &str = owned.as_str(); // String to number let n: i32 = "42".parse().unwrap(); let n: i32 = "42".parse::<i32>().unwrap(); // Number to String let s = 42.to_string(); let s = format!("{}", 42);

.parse() returns a Result. In interview code, .unwrap() is fine. The problem guarantees clean input. Famous last words in production. Completely reasonable in a 45-minute interview where no one is running your code against "NaN".

The Patterns That Actually Come Up

Reverse a string:

fn reverse(s: &str) -> String { s.chars().rev().collect() }

Check palindrome with two pointers:

fn is_palindrome(s: &str) -> bool { let chars: Vec<char> = s.chars().collect(); let (mut l, mut r) = (0, chars.len().saturating_sub(1)); while l < r { if chars[l] != chars[r] { return false; } l += 1; r -= 1; } true }

Count character frequencies:

use std::collections::HashMap; fn char_counts(s: &str) -> HashMap<char, usize> { let mut map = HashMap::new(); for ch in s.chars() { *map.entry(ch).or_insert(0) += 1; } map }

For the full HashMap and BTreeMap behavior in interview contexts, see Rust Built-In Data Structures for Coding Interviews.

Check anagram (ASCII):

fn is_anagram(s: &str, t: &str) -> bool { if s.len() != t.len() { return false; } let mut counts = [0i32; 26]; for (a, b) in s.bytes().zip(t.bytes()) { counts[(a - b'a') as usize] += 1; counts[(b - b'a') as usize] -= 1; } counts.iter().all(|&c| c == 0) }

b'a' is the byte literal syntax. It gives you the u8 value of ASCII 'a'. Cleaner than casting 'a' as u8.

Sliding window over characters:

Sliding window is one of the most common string patterns in interviews. The sliding window algorithm guide has the full breakdown. Here is what it looks like in Rust:

fn longest_unique_substr(s: &str) -> usize { use std::collections::HashMap; let chars: Vec<char> = s.chars().collect(); let mut seen = HashMap::new(); let mut best = 0; let mut left = 0; for right in 0..chars.len() { if let Some(&prev) = seen.get(&chars[right]) { left = left.max(prev + 1); } seen.insert(chars[right], right); best = best.max(right - left + 1); } best }

Converting to Vec<char> first makes the window indexing natural. The performance cost is acceptable in an interview context.

If you want to practice these patterns under timed conditions with spoken feedback, SpaceComplexity runs voice-based mock interviews where you have to explain your indexing strategy out loud. That is exactly the kind of Rust-specific reasoning interviewers probe when they see an unfamiliar language.

Quick Reference

OperationMethodNotes
Byte length.len()UTF-8 bytes, not chars
Char count.chars().count()O(n)
Contains.contains("pat")
Find.find("pat")Option<usize> byte offset
Starts with.starts_with("pre")
Ends with.ends_with("suf")
Split.split("sep")Returns &str slices
Split whitespace.split_whitespace()Handles consecutive spaces
Replace all.replace("a", "b")Returns String
Replace first n.replacen("a", "b", n)
Trim.trim()Returns &str
Uppercase.to_uppercase()Returns String
Lowercase.to_lowercase()
Repeat.repeat(n)
Reverse.chars().rev().collect()Must specify String type
Chars iter.chars()Unicode scalar values
Bytes iter.bytes()u8 values
Char indices.char_indices()(byte_offset, char)
Parse.parse::<T>()Returns Result
Number to string.to_string()
Join vecvec.join("sep")On slices of str

Key Takeaways

  • String to own and mutate, &str to read. Function parameters take &str.
  • s[0] is a compile error. Use .chars().nth(i) for Unicode, .as_bytes()[i] for ASCII.
  • .len() returns bytes, not characters. Use .chars().count() for character count.
  • .chars() for correctness, .bytes() for fast ASCII work.
  • Reversals and most transformations produce new String values. Collect from iterators.
  • .split_whitespace() handles multiple spaces. .split(' ') does not.
  • Collect into Vec<char> before indexing. Two-pointer problems get much cleaner.

Further Reading