Ruby String Manipulation for Coding Interviews: The Reference Guide

- Ruby strings are mutable by default — use
<<to append in place;+=creates a new object every iteration, making loops O(n²) s[i]returns a one-character string, not an integer — unlike Python; out-of-bounds returnsnil, not an error- Bang methods return
nilon no-op —upcase!returnsnilif already uppercase; never chain or test the return value count("aeiou")operates on a character set —"hello".count("lo")returns 3; it counts every char matching the set, not the substringsplitwith no argument strips surrounding whitespace;split(/\s+/)doesn't — leading whitespace produces an empty string with the regex formtris Ruby-exclusive — replaces characters by position in a set; faster thangsubfor single-character substitutionsHash.new(0)is the idiomatic frequency counter — eliminates nil checks; build one witheach_charfor any character counting problem
Ruby ships with roughly forty string methods. Your interview has one problem. These two facts are only loosely related.
The issue isn't finding a method that does the thing. It's knowing which one to reach for, what it actually returns, and which silent behaviors will detonate your solution at the worst possible moment. Ruby is polite about this. It won't tell you what it's doing wrong. It'll just return nil and wait.
Strings in Ruby Are Mutable. That Changes Everything.
Ruby strings are mutable by default. This is the first thing that separates Ruby from Python, Java, and Kotlin, and it'll surprise you if you've been doing most of your prep in another language.
s = "hello" s << " world" # mutates s, returns "hello world" s[0] = "H" # mutates s to "Hello world"
There are two interview consequences here. The good one: you can use << to build strings without allocating a new object on every append. The bad one:
a = "hello" b = a # b and a point to the SAME object b << "!" # mutates a too puts a # "hello!"
b = a does not make a copy. It makes a second name tag on the same object. You now have one string and two variables pointing at it, both blissfully unaware that you only wanted to change one of them.
If you want an independent copy, use .dup or .clone. This aliasing trap is covered in detail at Ruby Coding Interview Gotchas, but the short version is: when in doubt, dup.
Indexing: Not What Python Taught You
In Python, s[0] gives you an integer. In Ruby, it gives you a one-character string. That's a different type, and it matters when you try to compare things.
s = "hello" s[0] # "h" (string, not integer) s[-1] # "o" (last character) s[1..3] # "ell" (range, inclusive) s[1, 3] # "ell" (start index, length) s[1...3] # "el" (exclusive range)
The two-argument form s[start, length] is useful for sliding window problems where you know the position and size. s[-3..] gives you the last three characters without having to know the total length.
s[n] on an out-of-bounds index returns nil, not an error. Ruby's fine with it. You might not be. Check for that before treating the result as a string.
The Methods You'll Actually Use
Length and Checking
s.length # character count s.size # alias for length s.empty? # true if "" s.include?("ll") # substring check, O(n) s.start_with?("he") s.end_with?("lo") s.count("l") # count occurrences of any chars in the set
count does something that catches people off guard. "hello".count("lo") returns 3, not 1, because it counts every character that appears in the argument string, not the substring. The argument is treated as a character set. count("lo") means "count every l or every o." You get 2 l's and 1 o.
If you want substring occurrences, use scan("lo").length instead.
Case and Cleaning
s.upcase # returns new string s.downcase s.swapcase s.capitalize # only first char upcased s.strip # removes leading and trailing whitespace s.lstrip / s.rstrip s.chomp # removes trailing newline (\n, \r\n, \r) s.chop # removes last character unconditionally
Every one of these has a bang version (upcase!, strip!, etc.) that mutates in place. The catch: bang methods return nil if no change was made. So result = s.upcase! can be nil even when s is non-empty, if it was already uppercase.
s = "hello" result = s.upcase! # "HELLO", s is also mutated s = "HELLO" result = s.upcase! # nil, already uppercase, Ruby shrugs
Don't use bang methods when you need to chain or check the result. You'll spend five minutes staring at a nil wondering what you did wrong, which is exactly five minutes you don't have in an interview.
Splitting and Joining
"hello world".split # ["hello", "world"], splits on whitespace "hello world".split(" ") # same "a,b,c".split(",") # ["a", "b", "c"] "hello".split("") # ["h", "e", "l", "l", "o"] "hello".chars # ["h", "e", "l", "l", "o"]
split with no argument is not the same as split(/\s+/), despite looking identical in behavior on clean inputs. The bare form strips leading and trailing whitespace from the result. split(/\s+/) produces an empty string at the front if the input starts with whitespace. Your test cases probably won't catch the difference. The edge case will.
For interview problems, chars is cleaner than split("") when you just want an array of characters.
["a", "b", "c"].join # "abc" ["a", "b", "c"].join(", ") # "a, b, c"
Searching and Replacing
s.index("l") # first occurrence index, nil if not found s.rindex("l") # last occurrence index s.gsub("l", "r") # replace all occurrences s.sub("l", "r") # replace first occurrence only s.gsub(/[aeiou]/, "*") # regex replacement s.scan("l") # returns array of all matches: ["l", "l"] s.scan(/\d+/) # array of all digit sequences
gsub with a block lets you transform each match individually:
"hello".gsub(/./) { |c| c == "l" ? "r" : c } # "herro"
Reversing and Comparing
"hello".reverse # "olleh" "abc" <=> "abd" # -1 (lexicographic) "hello".casecmp("HELLO") # 0 (case-insensitive, returns int) "hello".casecmp?("HELLO") # true (Ruby 2.4+)
Character-Level Operations
"a".ord # 97 (ASCII code) 97.chr # "a" "hello".bytes # [104, 101, 108, 108, 111] "hello".each_char { |c| puts c }
For frequency counting, each_char with a Hash.new(0) is the idiomatic Ruby pattern:
freq = Hash.new(0) "hello".each_char { |c| freq[c] += 1 } # {"h"=>1, "e"=>1, "l"=>2, "o"=>1}
Hash.new(0) sets the default value for missing keys to 0. No fetch, no nil check, no conditional. You increment and move on.
<< Beats + Every Time
Building a string character by character comes up constantly. Ruby has three ways, and one of them is an O(n²) trap disguised as normal code.
# Option 1: += (creates a new string each time, O(n²) total) result = "" chars.each { |c| result += c } # Option 2: << (mutates in place, O(n) total, use this) result = "" chars.each { |c| result << c } # Option 3: collect and join (idiomatic, one allocation for join) result = chars.join
<< is the right choice when building a string in a loop. The + operator always allocates a new string. Do that n times and you've done O(1 + 2 + 3 + ... + n) = O(n²) work. It'll pass on short inputs. It'll time out on long ones, right when it matters most.
If you're assembling from an existing array of parts, join is cleaner and does one allocation. See What Is a StringBuilder? for the full explanation of why this matters at scale.
tr: Ruby's Secret Character Weapon
tr is Ruby-specific and worth having in your back pocket. It substitutes characters by position in a character set, the same way Unix tr works.
"hello".tr("aeiou", "*") # "h*ll*" "hello".tr("el", "ip") # "hippo" "hello".tr("a-y", "b-z") # "ifmmp" (ROT1) "hello".delete("aeiou") # "hll" (removes characters) "aaabbbccc".squeeze # "abc" (collapses runs) "aaabbbccc".squeeze("a") # "abbbccc" (only collapses a)
delete removes all characters in the set. squeeze collapses consecutive duplicates. Both accept character set notation. These come up less often than gsub, but when the problem calls for character substitution they're far more readable.
Patterns Worth Knowing Cold
Palindrome Check
def palindrome?(s) s == s.reverse end
One line. If the interviewer wants O(1) space, you implement two pointers and explain the trade-off. But .reverse is idiomatic and fine unless space is explicitly constrained. Use the readable version first and upgrade if asked.
Anagram Detection
def anagram?(a, b) a.chars.sort == b.chars.sort end # Or with frequency hash (better for large strings): def anagram?(a, b) return false if a.length != b.length freq = Hash.new(0) a.each_char { |c| freq[c] += 1 } b.each_char { |c| freq[c] -= 1 } freq.values.all?(&:zero?) end
The sort version is O(n log n). The hash version is O(n). Know which one you're using and say so.
First Non-Repeating Character
def first_unique(s) freq = Hash.new(0) s.each_char { |c| freq[c] += 1 } s.each_char { |c| return c if freq[c] == 1 } nil end
Two passes. First builds the frequency map, second finds the answer. Ruby's Hash.new(0) makes the counting half clean.
Sliding Window Substring
def longest_no_repeat(s) seen = {} left = 0 max = 0 s.chars.each_with_index do |c, right| left = [left, seen[c] + 1].max if seen[c] seen[c] = right max = [max, right - left + 1].max end max end
Working with indices into the original string is cleaner than extracting substrings in each iteration. Extract only when you actually need the substring text.
Ruby String Manipulation: Quick Reference
| Goal | Method | Returns | Notes |
|---|---|---|---|
| Length | .length / .size | Integer | |
| Character at index | s[i] | String or nil | Not an integer |
| Substring | s[i..j] or s[i, len] | String or nil | |
| Last char | s[-1] | String | |
| Char array | .chars | Array | |
| Byte values | .bytes | Array | |
| Reverse | .reverse | New string | |
| Downcase | .downcase | New string | .downcase! mutates |
| Strip whitespace | .strip | New string | |
| Contains? | .include? | Boolean | O(n) |
| Find index | .index(sub) | Integer or nil | |
| Replace all | .gsub(pat, rep) | New string | |
| Replace first | .sub(pat, rep) | New string | |
| All matches | .scan(pat) | Array | |
| Split | .split(delim) | Array | |
| Join | arr.join(sep) | String | |
| Append (fast) | s << other | s (mutated) | O(1) amortized |
| ASCII value | c.ord | Integer | |
| Char from ASCII | n.chr | String | |
| Count char set | .count("abc") | Integer | Character set, not substring |
When Your String Is Frozen
Ruby 3 and most production codebases use # frozen_string_literal: true at the top of files. This makes all string literals immutable. If you see a FrozenError in an interview environment, this is almost certainly why.
# frozen_string_literal: true s = "hello" s << " world" # raises FrozenError s = s + " world" # fine, creates new string
Most competitive-style interview platforms don't enable this by default. If you're doing a take-home against a real Rails codebase, it matters. Check the top of the file before you start mutating things.
What to Remember
- Ruby strings are mutable. Use
<<to build strings in a loop, not+=. s[i]returns a string, never an integer. Compare with==, not against a number.- Bang methods (
upcase!,strip!) mutate in place and returnnilon no-op. Don't chain them. splitwith no args strips surrounding whitespace;split(/\s+/)does not.count("aeiou")counts characters from a set, not occurrences of a substring. It will lie to you confidently.gsubreplaces all matches.subreplaces the first. Getting these backwards is the kind of bug that passes on your test cases and fails on theirs.Hash.new(0)is the idiomatic frequency counter. Nofetch, no nil checks, no ceremony.
The methods themselves are easy to look up. What gets scored is whether you can reach for the right one under pressure, explain why you chose it, and catch the edge case before the interviewer has to point it out. If you want to drill that under actual interview conditions, SpaceComplexity runs voice-based mock sessions with rubric scoring on exactly this kind of pattern recognition.
Further Reading
- Ruby Documentation: String: the authoritative reference for every method and edge case
- Ruby Gotchas: the behaviors that compile fine and still break your solution
- Ruby Built-In Time Complexity: complexity of every common method
- String Coding Interview Bugs: the bugs that pass test cases and fail interviews
- What Is a StringBuilder?: why concatenation in a loop is O(n²) and how to fix it