Swift String Manipulation for Coding Interviews: The Reference Guide

- Swift strings have no integer indexing — convert with
Array(s)to get a[Character]you can access in O(1) with plain integer indices. String.Indexjumps are O(n) —s.index(startIndex, offsetBy: n)walks the UTF-8 buffer; use array conversion for two-pointer and sliding-window problems.Substringshares the underlying buffer — slicing is O(1), but wrap withString()before storing in a dictionary, returning from a function, or passing to a helper.s.countis O(n) in Swift — cache it aslet n = s.countor you'll accidentally turn an O(n) loop into O(n²).s.append()overs += "x"—appendis amortized O(1) via dynamic-array doubling; the+=form reallocates the entire string on every call.[key, default: 0]is the idiomatic frequency subscript — avoids nil-unwrapping and is safe for character counting across all interview patterns.splitdrops empty subsequences by default — passomittingEmptySubsequences: falsewhen the problem requires preserving empty fields between delimiters.
Swift strings have a reputation for being painful in interviews. That reputation is earned. The language makes Unicode correctness non-negotiable, which means you cannot index into a string with an integer. That decision is philosophically correct and operationally annoying at 9am on a live coding call. Here's what you need without reaching for the docs mid-problem.
The Core Problem: No Integer Indexing
In Python you write s[0]. In Java you write s.charAt(0). In Swift you write this:
let s = "hello" let first = s[s.startIndex] // 'h'
This is not a quirk. Swift String uses a String.Index type because characters are not all the same width. An emoji takes more storage than an ASCII letter. If you could index with plain integers, index arithmetic would silently produce garbage on anything non-ASCII. Swift refuses to let you shoot yourself in the foot. You have to get creative about shooting yourself in other places instead.
The practical fix for most interview problems: convert to [Character] immediately if you need random access.
let s = "hello" let chars = Array(s) // [Character] let c = chars[2] // 'l', O(1)
Now you have O(1) index access and you can solve the problem with plain integer arithmetic. The conversion is O(n) time and O(n) space, but you were going to iterate the string anyway.
String.Index When You Can't Avoid It
For slicing a large string into overlapping windows, you want String.Index because slices share the underlying buffer (no copy). The API:
let s = "interview" let start = s.startIndex let third = s.index(start, offsetBy: 2) // 'i' let sub = s[start...third] // "int", a Substring
The methods you need:
| Method | What it does |
|---|---|
s.startIndex | Index of the first character |
s.endIndex | One past the last character |
s.index(after: i) | Next index |
s.index(before: i) | Previous index |
s.index(startIndex, offsetBy: n) | Jump n positions, O(n) |
s.index(startIndex, offsetBy: n, limitedBy: s.endIndex) | Safe version, returns Optional |
endIndex is a trap: s[s.endIndex] crashes at runtime. Use open ranges (i..<s.endIndex) not closed ranges, and use s.index(before: s.endIndex) to reach the last character. The compiler will not warn you. The crash will be instant and confusing.

Searching "Swift get character at index" in 2015 and discovering the String.Index rabbithole.
Substring Is Not String
Slicing a Swift string returns a Substring, not a String. They share the same buffer, so slicing is O(1), but they are different types. Function signatures that expect String reject Substring.
let s = "hello world" let sub = s.prefix(5) // Substring: "hello" let str = String(sub) // String: "hello", copied here
Convert to String explicitly when passing to a helper, storing in a dictionary, or returning from a function. The compiler will tell you when you forget, but the error message wastes time if you don't recognize it immediately. During a live interview, that 30-second pause while you figure out why your types don't match is 30 seconds you could spend solving the actual problem.
Characters, ASCII Values, and the Frequency Array
The classic frequency-array trick (26-element array for lowercase letters) needs character-to-index conversion. Swift does this via asciiValue:
let char: Character = "a" let val = char.asciiValue! // UInt8: 97
asciiValue returns UInt8?. For lowercase-only inputs, the forced unwrap is safe. The idiomatic helper:
func charIndex(_ c: Character) -> Int { Int(c.asciiValue! - Character("a").asciiValue!) }
You can also compare characters directly, which avoids the ASCII arithmetic entirely:
char >= "a" && char <= "z" // true for lowercase letters char.isLetter // true for any Unicode letter char.isNumber // true for digit characters char.isWhitespace // space, tab, newline
isLetter and isNumber are Unicode-aware, which matters for non-ASCII inputs. If the problem constrains input to lowercase ASCII, char >= "a" && char <= "z" is more explicit and avoids any ambiguity about what "letter" means internationally.
See the ASCII vs Unicode explainer for the underlying encoding difference.
Building Strings Without Exploding Complexity
String concatenation with += on a String literal creates a new string on every call. In a loop, that is O(n²). Two clean alternatives:
// Option A: build [Character], convert once at the end var chars: [Character] = [] for c in source { chars.append(c) } let result = String(chars) // one allocation // Option B: use .append() on a var String var s = "" s.append("h") s.append("i") // .append() is amortized O(1), same as dynamic array doubling
The trap is s = s + "x" inside a loop. That reads s, allocates a new string, copies s into it, appends x, and writes back. Every iteration. For a 1000-character string that's a million-character copy total. Use s.append("x") instead and save yourself an uncomfortable conversation about why your solution is O(n²).
For an explanation of why the O(n²) trap appears in every language, see What Is a StringBuilder.
The Methods You'll Actually Use
Checking and Searching
s.hasPrefix("abc") // Bool, O(n) s.hasSuffix("xyz") // Bool, O(n) s.contains("sub") // Bool, O(n) s.count // Int, O(n), cache it s.isEmpty // Bool, O(1), prefer over count == 0
count is O(n) in Swift because the runtime must walk the UTF-8 storage to count grapheme clusters. Cache it as a local constant if you reference it more than once. Calling s.count inside an O(n) loop is a classic interview gotcha that turns a clean solution into O(n²).
Transforming
s.lowercased() // String s.uppercased() // String s.trimmingCharacters(in: .whitespaces) // String s.replacingOccurrences(of: "a", with: "b") // String String(s.reversed()) // String
Splitting and Joining
let parts = s.split(separator: " ") // [Substring] let joined = parts.joined(separator: "-") // String // Closure variant for multiple separators let words = s.split { $0.isWhitespace } // [Substring] // With Foundation (avoid in pure-stdlib interviews) let parts2 = s.components(separatedBy: ", ") // [String]
split from the standard library drops empty subsequences by default. "a,,b".split(separator: ",") gives ["a", "b"], not ["a", "", "b"]. Pass omittingEmptySubsequences: false when empty fields matter.
Inserting and Removing
var s = "hello" let i = s.index(s.startIndex, offsetBy: 2) s.insert("X", at: i) // "heXllo" s.remove(at: i) // removes and returns 'X' s.removeAll { $0 == "l" } // "heo"
Prefix and Suffix Operations
s.prefix(3) // Substring: first 3 chars s.suffix(3) // Substring: last 3 chars s.dropFirst(2) // Substring: skip first 2 s.dropLast(2) // Substring: skip last 2
Swift String Manipulation Patterns for Interviews
Frequency Count
func frequency(_ s: String) -> [Character: Int] { var freq: [Character: Int] = [:] for c in s { freq[c, default: 0] += 1 } return freq }
The [key, default: value] subscript avoids the nil check and is the idiomatic Swift way.
Palindrome Check
func isPalindrome(_ s: String) -> Bool { let chars = Array(s) var left = 0 var right = chars.count - 1 while left < right { if chars[left] != chars[right] { return false } left += 1 right -= 1 } return true }
Convert first, then use integer indices. You could do this with String.Index but the code becomes harder to read and easier to get wrong under pressure.
Anagram Check
func isAnagram(_ s: String, _ t: String) -> Bool { guard s.count == t.count else { return false } var freq = [Character: Int]() for c in s { freq[c, default: 0] += 1 } for c in t { freq[c, default: 0] -= 1 if freq[c]! < 0 { return false } } return true }
Sliding Window on Characters
func maxVowels(_ s: String, _ k: Int) -> Int { let chars = Array(s) let vowels: Set<Character> = ["a", "e", "i", "o", "u"] var count = chars[0..<k].filter { vowels.contains($0) }.count var best = count for i in k..<chars.count { if vowels.contains(chars[i]) { count += 1 } if vowels.contains(chars[i - k]) { count -= 1 } if count > best { best = count } } return best }
Quick Reference Table
| Operation | Swift | Complexity |
|---|---|---|
| Length | s.count | O(n), cache it |
| Character at index | Array(s)[i] | O(n) to convert, O(1) after |
| Slice | s[i..<j] | O(1), returns Substring |
| Iterate | for c in s | O(n) |
| Empty check | s.isEmpty | O(1) |
| Contains | s.contains("x") | O(n) |
| Starts/Ends | s.hasPrefix / hasSuffix | O(n) |
| Lowercase | s.lowercased() | O(n) |
| Reverse | String(s.reversed()) | O(n) |
| Split | s.split(separator: " ") | O(n), returns [Substring] |
| Join | arr.joined(separator: "-") | O(n) |
| Append char | s.append(c) | Amortized O(1) |
| ASCII value | c.asciiValue! | O(1), returns UInt8 |
| Is letter | c.isLetter | O(1), Unicode-aware |
| Is digit | c.isNumber | O(1), Unicode-aware |
| Frequency | freq[c, default: 0] += 1 | O(1) per char |
The Gotchas That Will Bite You
These are the ones that don't cause a compiler error. They just crash, or silently produce wrong output, right when your interviewer is watching.
s.endIndex is not a valid character position. Indexing into it crashes. Use s.index(before: s.endIndex) for the last character, or chars[chars.count - 1] after converting to an array.
count is not free. Calling s.count inside an O(n) loop makes it O(n²). Compute it once: let n = s.count.
split drops empty sequences by default. "a,,b".split(separator: ",") gives two elements, not three. Pass omittingEmptySubsequences: false to preserve them.
Substring causes type errors. Wrap with String(sub) when storing in a dictionary, passing to a helper, or returning from a function.
Character arithmetic doesn't exist. You cannot write char + 1. To get the next character by ASCII value: Character(UnicodeScalar(char.asciiValue! + 1)). In practice, use integer indices into a character array instead.
split returns [Substring], not [String]. Use .map { String($0) } if you need a typed [String].

Knowing every Swift string gotcha but still calling s.count inside a loop.
For a full catalogue of Swift footguns, the Swift coding interview gotchas guide covers the rest.
The Strategic Call: Array vs String.Index
You have two paths for almost every string problem.
Convert to [Character] early. You pay O(n) upfront and get integer indexing everywhere. Works for two-pointer, sliding window, frequency counting, reversals, anagram checks. This is the right default for almost everything an interview will throw at you.
Keep as String, use String.Index. You get O(1) slicing via Substring shared-buffer semantics. Worth it when you're doing many overlapping slices of a large string, like in rolling-hash or string search algorithms.
In an interview, say which one you're picking and why. "I'm converting to a character array because I need integer indexing for this two-pointer approach" takes three seconds and tells the interviewer you understand the tradeoff. Picking one silently and then fumbling a type error is what actually costs you. The code can be wrong and recoverable. The silence is what gets written into the feedback form.
That kind of narration is what separates a clean interview from a stressful one. SpaceComplexity runs voice-based mock interviews where you explain your approach as you code, and the feedback specifically covers whether your reasoning is coming through clearly. A few sessions of that fixes the silence habit faster than any amount of solo LeetCode.