String Manipulation Patterns — Loop Concatenation OOM
Autocomplete crashed OOM: 2GB/min temporary char[] from loop concatenation.
20+ years shipping performance-critical code where algorithms decide the bill. Written from production experience, not tutorials.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Sliding window: contiguous substring problems (longest/shortest/min window)
- Two pointers: symmetry problems (palindrome, reverse, compare ends)
- Frequency map: character count problems (anagram, permutation, unique chars)
- String hashing: grouping/dedup problems (group anagrams, Rabin-Karp search)
- Build-then-join: always use StringBuilder, never + in a loop
- Search engines, autocomplete, log dedup, and content fingerprinting all rely on these patterns.
- The missing Math.max guard in sliding window is the #1 silent bug.
- String concatenation with + inside a loop. O(n^2) on 10K-char strings. Use StringBuilder.
This article dissects a classic performance landmine in string manipulation: the O(n²) memory and time cost of naive loop concatenation, where each += or + operation creates a new immutable string, copying the entire accumulated result. You'll learn why this pattern causes out-of-memory (OOM) crashes in production systems handling anything beyond toy inputs, and how to replace it with StringBuilder (Java), list + ''.join() (Python), or strings.Builder (Go).
The article then walks through a concrete worked example—reversing words in a string—to demonstrate the fix. Beyond that single pattern, it surveys four essential string manipulation techniques you'll use daily: the sliding window (scan once with two pointers, answer fast for substrings), frequency maps (character counts via arrays or hash maps, ignoring positions), and two-pointer strategies for palindromes and in-place reversals.
These patterns are language-agnostic but map directly to real interview questions and production code—think log parsing, DNA sequence analysis, or sanitizing user input at scale. If you're still building strings in loops or reaching for regex when a frequency map would do, this article will save you from shipping code that melts under load.
Imagine you're sorting through a long receipt from a grocery store, looking for every time you bought milk. You don't reread the entire receipt from scratch each time — you scan once, maybe use your finger as a pointer, and keep a tally. String manipulation patterns work exactly the same way: they're clever scanning strategies that let your code process text efficiently without doing unnecessary repetitive work. Once you learn the patterns, you stop reinventing the wheel every time a string problem shows up.
Strings are the most common data type in production systems. Search engines scan billions of words per second. Autocomplete predicts the next word from a prefix. Log deduplication identifies duplicate entries by content fingerprinting. Each of these relies on a small set of string manipulation patterns.
The core problem with naive string processing is O(n^2) concatenation and O(n^2) substring enumeration. A 10,000-character string processed naively can require 100 million comparisons. The patterns covered here — sliding window, two pointers, frequency maps, and hashing — reduce most string problems to O(n) or O(n log n).
The common misconception is that 'string problems are just array problems with characters.' While the algorithmic patterns overlap, strings introduce unique concerns: immutable concatenation cost, character encoding (ASCII vs Unicode), case sensitivity, and the choice between int[26] and HashMap for frequency counting. Understanding these distinctions is what separates a correct solution from a production-grade one.
Why Naive String Concatenation in Loops Is a Performance Trap
String manipulation patterns refer to the common approaches for building or transforming strings, with the core mechanic being the choice between immutable concatenation (using + or concat()) and mutable builders (StringBuilder or StringBuffer). In Java, strings are immutable — every concatenation creates a new String object, copying the old content plus the new part. In a loop, this turns an O(n) operation into O(n²) time and memory, because each iteration copies the entire accumulated string. For example, concatenating 10,000 strings of length 10 results in roughly 50 million character copies instead of 100,000.
In practice, the key property is that StringBuilder maintains a mutable char array that grows amortized O(1) per append, avoiding the repeated full-copy overhead. The default capacity is 16, but you can pre-size it if you know the final length — this eliminates array resizing entirely. The difference is stark: building a 100 KB string via loop concatenation can take seconds and allocate megabytes of garbage, while StringBuilder does it in microseconds with minimal allocation.
Use StringBuilder (or StringBuffer for thread safety) whenever you build a string dynamically — in loops, serialization, SQL query construction, or logging frameworks. In real systems, this pattern is the #1 cause of unexpected OutOfMemoryErrors in string-heavy services, especially under load. The rule: if you concatenate more than a handful of strings, or if the concatenation appears inside a loop, use StringBuilder explicitly.
Worked Example — Reverse Words in a String
Input: s = ' the sky is blue '
- Split by whitespace (ignore empty tokens): words = ['the','sky','is','blue'].
- Reverse the list: ['blue','is','sky','the'].
- Join with single space: 'blue is sky the'.
Now trace character-by-character palindrome check on 'racecar': 1. left=0 (r), right=6 (r). Match. 2. left=1 (a), right=5 (a). Match. 3. left=2 (c), right=4 (c). Match. 4. left=3 (e) = right=3 (e). Left >= right, stop. Result: palindrome.
Anagram check 'listen' vs 'silent': sort both → 'eilnst' == 'eilnst'. True. Or use frequency count: count each char in both strings; if all counts match, they are anagrams. O(n) time with hash map vs O(n log n) with sorting.
package io.thecodeforge.algo; import java.util.Arrays; public class StringWorkedExamples { /** * Reverses the order of words in a string. * Handles multiple spaces between words. */ public static String reverseWords(String s) { // Split on whitespace, trim leading/trailing spaces first String[] words = s.trim().split("\\s+"); // Reverse in-place using two pointers int left = 0, right = words.length - 1; while (left < right) { String temp = words[left]; words[left] = words[right]; words[right] = temp; left++; right--; } // Join with single space — never concatenate in a loop return String.join(" ", words); } /** * Checks if a string is a palindrome (alphanumeric only, case-insensitive). */ public static boolean isPalindrome(String s) { int left = 0, right = s.length() - 1; while (left < right) { while (left < right && !Character.isLetterOrDigit(s.charAt(left))) left++; while (left < right && !Character.isLetterOrDigit(s.charAt(right))) right--; if (Character.toLowerCase(s.charAt(left)) != Character.toLowerCase(s.charAt(right))) { return false; } left++; right--; } return true; } /** * Checks if two strings are anagrams using int[26] frequency map. O(n) time, O(1) space. */ public static boolean areAnagrams(String s1, String s2) { if (s1.length() != s2.length()) return false; int[] freqint i = 0; i < s1.length(); = new int[26]; for ( i++) { freq[Character.toLowerCase(s1.charAt(i)) - 'a']++; freq[Character.toLowerCase(s2.charAt(i)) - 'a']--; } for (int count : freq) { if (count != 0) return false; } return true; } public static void main(String[] args) { System.out.println(reverseWords(" the sky is blue ")); // "blue is sky the" System.out.println(isPalindrome("A man, a plan, a canal: Panama")); // true System.out.println(areAnagrams("listen", "silent")); // true System.out.println(areAnagrams("hello", "world")); // false } }
- Compare characters: two pointers moving inward. Palindrome, reverse, symmetry.
- Count characters: frequency map (int[26] or HashMap). Anagram, permutation, unique.
- Track region: sliding window with two boundary pointers. Longest/shortest substring.
- Combine patterns: minimum window substring = sliding window + frequency map.
- Build output: always StringBuilder or String.join, never + in a loop.
Key String Manipulation Patterns — Plain English
String problems cluster into a handful of recurring patterns. Recognising the pattern determines the approach.
Pattern 1 — Frequency map (anagram, permutation check): Build a character count dict. Two strings are anagrams when their counts are equal.
Pattern 2 — Two pointers (palindrome, reverse): left=0, right=n-1. While left<right: compare or swap. O(n), O(1) space.
Pattern 3 — Sliding window (minimum window substring, longest without repeating): Expand right; shrink left when constraint violated.
Pattern 4 — Build output in a list, join once: Appending to a list is O(1); string concatenation with '+' is O(n). Always join at the end.
Step-by-step — is 'listen' an anagram of 'silent'? 1. Both length 6: ok. 2. Count 'listen': {l:1,i:1,s:1,t:1,e:1,n:1}. 3. Count 'silent': {s:1,i:1,l:1,e:1,n:1,t:1}. 4. Maps equal. Answer: True.
Step-by-step — reverse 'hello' in-place: left=0,right=4: swap h↔o → 'oellh'. left=1,right=3: swap e↔l → 'olleh'. Done.
package io.thecodeforge.algo; import java.util.Arrays; import java.util.HashMap; import java.util.Map; public class StringPatterns { /** * Pattern 1: Frequency map anagram check. O(n) time, O(1) space. */ public static boolean areAnagrams(String s1, String s2) { if (s1.length() != s2.length()) return false; int[] freq = new int[26]; for (int i = 0; i < s1.length(); i++) { freq[s1.charAt(i) - 'a']++; freq[s2.charAt(i) - 'a']--; } for (int count : freq) { if (count != 0) return false; } return true; } /** * Pattern 2: Two-pointer reverse. O(n) time, O(n) space (char array). */ public static String reverseString(String s) { char[] chars = s.toCharArray(); int left = 0, right = chars.length - 1; while (left < right) { char temp = chars[left]; chars[left] = chars[right]; chars[right] = temp; left++; right--; } return new String(chars); } /** * Pattern 3: Sliding window — longest substring without repeating chars. */ public static int longestUniqueSubstring(String s) { Map<Character, Integer> lastSeen = new HashMap<>(); int maxLen = 0, windowStart = 0; for (int windowEnd = 0; windowEnd < s.length(); windowEnd++) { char ch = s.charAt(windowEnd); if (lastSeen.containsKey(ch)) { windowStart = Math.max(windowStart, lastSeen.get(ch)); } lastSeen.put(ch, windowEnd + 1); maxLen = Math.max(maxLen, windowEnd - windowStart + 1); } return maxLen; } /** * Pattern 4: Build output with StringBuilder, join at end. */ public static String reverseWords(String s) { String[] words = s.trim().split("\\s+"); StringBuilder sb = new StringBuilder(); for (int i = words.length - 1; i >= 0; i--) { sb.append(words[i]); if (i > 0) sb.append(' '); } return sb.toString(); } public static void main(String[] args) { System.out.println(areAnagrams("listen", "silent")); // true System.out.println(reverseString("hello")); // olleh System.out.println(longestUniqueSubstring("abcabcbb")); // 3 System.out.println(reverseWords(" the sky is blue ")); // "blue is sky the" } }
- Contiguous substring → sliding window (fixed or variable size).
- Symmetry/comparison from ends → two pointers.
- Character counting/frequency → frequency map (int[26] or HashMap).
- Grouping by content → string hashing (sorted key or frequency key).
- Building output → StringBuilder or String.join, never + in a loop.
The Sliding Window Pattern — Scan Once, Answer Fast
package io.thecodeforge.algo; import java.util.HashMap; import java.util.Map; public class LongestUniqueSubstring { /** * Finds the length of the longest substring without repeating characters. * Classic variable-size sliding window problem. * * Time: O(n) — each character is visited at most twice (once by right, once by left) * Space: O(min(n, alphabet)) — the map holds at most one entry per unique character */ public static int findLongestUniqueSubstring(String input) { // Maps each character to the index AFTER its last known position. // Storing index+1 lets us jump the left pointer forward in one step. Map<Character, Integer> lastSeenAt = new HashMap<>(); int maxLength = 0; int windowStart = 0; // left edge of our sliding window for (int windowEnd = 0; windowEnd < input.length(); windowEnd++) { char currentChar = input.charAt(windowEnd); // If this character already exists inside our current window, // move the left edge just past its previous position so the // window no longer contains the duplicate. if (lastSeenAt.containsKey(currentChar)) { // Math.max prevents the window from moving BACKWARD // if the duplicate was outside the current window. windowStart = Math.max(windowStart, lastSeenAt.get(currentChar)); } // Record this character's "next safe position" for the left pointer lastSeenAt.put(currentChar, windowEnd + 1); // Current window length = windowEnd - windowStart + 1 maxLength = Math.max(maxLength, windowEnd - windowStart + 1); } return maxLength; } public static void main(String[] args) { System.out.println(findLongestUniqueSubstring("abcabcbb")); // 3 → "abc" System.out.println(findLongestUniqueSubstring("bbbbb")); // 1 → "b" System.out.println(findLongestUniqueSubstring("pwwkew")); // 3 → "wke" System.out.println(findLongestUniqueSubstring("")); // 0 → empty string System.out.println(findLongestUniqueSubstring("abcdefg")); // 7 → whole string } }
Frequency Maps — When You Care About Character Counts, Not Positions
A frequency map (also called a character count array or histogram) is a data structure that counts how many times each character appears. It transforms a string into a numerical fingerprint. Two strings with identical fingerprints are anagrams of each other. That's powerful.
For strings restricted to lowercase English letters, you can use an int[26] array instead of a HashMap. Array access is O(1) with no hashing overhead, and comparing two int[26] arrays takes exactly 26 comparisons — constant time regardless of string length. This is a significant practical speedup that interviewers love to hear you mention.
Frequency maps unlock the fixed-size sliding window approach for anagram problems. You precompute the frequency map of the pattern, then slide a same-length window across the text. Each slide, you add one character and remove one character from your running frequency map. When the running map matches the pattern map, you've found an anagram. One pass. O(n) time.
package io.thecodeforge.algo; import java.util.ArrayList; import java.util.Arrays; import java.util.List; public class AnagramFinder { /** * Returns all starting indices where an anagram of 'pattern' begins in 'text'. * Uses a fixed-size sliding window with an int[26] frequency map. * * Time: O(n) where n = text.length() * Space: O(1) — the two int[26] arrays are constant size regardless of input */ public static List<Integer> findAnagramStartIndices(String text, String pattern) { List<Integer> resultIndices = new ArrayList<>(); // Edge case: pattern can't fit inside text if (pattern.length() > text.length()) { return resultIndices; } int windowSize = pattern.length(); // Build the frequency fingerprint for the target pattern int[] patternFrequency = new int[26]; for (char ch : pattern.toCharArray()) { patternFrequency[ch - 'a']++; // 'a'=0, 'b'=1, ..., 'z'=25 } // Build the frequency fingerprint for the first window in text int[] windowFrequency = new int[26]; for (int i = 0; i < windowSize; i++) { windowFrequency[text.charAt(i) - 'a']++; } // Check the first window before we start sliding if (Arrays.equals(patternFrequency, windowFrequency)) { resultIndices.add(0); } // Slide the window one character at a time for (int windowEnd = windowSize; windowEnd < text.length(); windowEnd++) { // Add the new character entering the right side of the window windowFrequency[text.charAt(windowEnd) - 'a']++; // Remove the character leaving the left side of the window int windowStart = windowEnd - windowSize; windowFrequency[text.charAt(windowStart) - 'a']--; // Arrays.equals on two int[26] arrays is 26 comparisons — O(1) if (Arrays.equals(patternFrequency, windowFrequency)) { resultIndices.add(windowStart + 1); // +1 because new window starts one ahead } } return resultIndices; } public static void main(String[] args) { System.out.println(findAnagramStartIndices("cbaebabacd", "abc")); // [0, 6] System.out.println(findAnagramStartIndices("abab", "ab")); // [0, 1, 2] System.out.println(findAnagramStartIndices("af", "be")); // [] } }
- int[26]: O(1) access, O(1) comparison (26 comparisons), no autoboxing.
- HashMap: O(1) amortized access, O(k) comparison, autoboxing overhead.
- Use int[26] when input is lowercase English only.
- Use HashMap when input includes uppercase, digits, or Unicode.
- Benchmark: int[26] is 3-5x faster than HashMap for anagram detection on lowercase strings.
Two Pointers on Strings — The Palindrome and Reverse Toolkit
Two pointers on a string means placing one pointer at the start and one at the end, then marching them toward each other. It's perfect for problems involving symmetry (palindromes), reversals, or comparisons from both ends simultaneously.
The reason this pattern exists is that many string properties are inherently symmetric. A palindrome reads the same forwards and backwards. The minimum number of deletions to make a string a palindrome depends on mismatches at mirrored positions. Both of these are naturally expressed as comparisons between a left and right pointer moving inward.
Two pointers also pairs beautifully with other patterns. You can use two pointers on the result of a frequency map to reconstruct strings. You can use them inside a sliding window as the window boundaries themselves. Once you're comfortable with each pattern in isolation, start noticing when problems require you to combine two of them — that's where intermediate-level solutions start to look elegant rather than brute-force.
package io.thecodeforge.algo; public class PalindromeChecker { /** * Checks if a string is a valid palindrome, considering only * alphanumeric characters and ignoring case. * * Time: O(n) * Space: O(1) — no extra data structures, just two integer pointers */ public static boolean isValidPalindrome(String sentence) { int leftPointer = 0; int rightPointer = sentence.length() - 1; while (leftPointer < rightPointer) { // Skip non-alphanumeric characters from the left while (leftPointer < rightPointer && !Character.isLetterOrDigit(sentence.charAt(leftPointer))) { leftPointer++; } // Skip non-alphanumeric characters from the right while (leftPointer < rightPointer && !Character.isLetterOrDigit(sentence.charAt(rightPointer))) { rightPointer--; } // Compare characters at both pointers, case-insensitive char leftChar = Character.toLowerCase(sentence.charAt(leftPointer)); char rightChar = Character.toLowerCase(sentence.charAt(rightPointer)); if (leftChar != rightChar) { return false; // Mismatch — definitely not a palindrome } // Both characters matched — move both pointers inward leftPointer++; rightPointer--; } // All mirrored pairs matched return true; } /** * Finds the length of the longest palindromic substring using * the "expand around center" technique. * * Time: O(n²) — each of the 2n-1 centers expands up to n/2 times * Space: O(1) */ public static String longestPalindromicSubstring(String word) { if (word == null || word.isEmpty()) return ""; int bestStart = 0; int bestLength = 1; for (int centerIndex = 0; centerIndex < word.length(); centerIndex++) { int oddLength = expandFromCenter(word, centerIndex, centerIndex); int evenLength = expandFromCenter(word, centerIndex, centerIndex + 1); int longerExpansion = Math.max(oddLength, evenLength); if (longerExpansion > bestLength) { bestLength = longerExpansion; bestStart = centerIndex - (longerExpansion - 1) / 2; } } return word.substring(bestStart, bestStart + bestLength); } private static int expandFromCenter(String word, int left, int right) { while (left >= 0 && right < word.length() && word.charAt(left) == word.charAt(right)) { left--; right++; } return right - left - 1; } public static void main(String[] args) { System.out.println(isValidPalindrome("A man, a plan, a canal: Panama")); // true System.out.println(isValidPalindrome("race a car")); // false System.out.println(isValidPalindrome(" ")); // true System.out.println(longestPalindromicSubstring("babad")); // "bab" or "aba" System.out.println(longestPalindromicSubstring("cbbd")); // "bb" System.out.println(longestPalindromicSubstring("racecar")); // "racecar" } }
- Expand around center: simplest, O(1) space. Best for interviews.
- DP table: O(n²) space. Useful when you need to answer many palindrome queries on the same string.
- Manacher's: O(n) time. Complex to implement. Mention it exists, don't implement unless asked.
- Two-pointer palindrome check: O(n) time, O(1) space. Different from longest palindromic substring.
- For 'valid palindrome' check: two pointers. For 'longest palindromic substring': expand around center.
String Hashing — Catching Patterns You Can't See by Eye
Hashing a string means converting it into a number so you can compare strings in O(1) instead of O(n). The canonical application is the Rabin-Karp rolling hash algorithm for substring search, but the concept shows up any time you need to detect duplicate substrings, group anagrams, or find repeated patterns at scale.
The key idea is a rolling hash: when your window slides one character to the right, you don't recompute the entire hash from scratch. You mathematically remove the contribution of the outgoing character and add the incoming character. This keeps each slide at O(1), making the full scan O(n) regardless of pattern length.
Grouping anagrams is a softer but very common application. The trick: sort each word's characters to produce a canonical key. All anagrams of 'eat' sort to 'aet'. Store them in a HashMap<String, List<String>> keyed by the sorted form. This is O(n * k log k) where k is the average word length — entirely practical for real word lists and comes up frequently in interview problems involving dictionaries.
package io.thecodeforge.algo; import java.util.*; public class AnagramGrouper { /** * Groups a list of words so that anagrams appear together. * Uses a sorted-character string as a canonical hash key. * * Time: O(n * k log k) where n = number of words, k = average word length * Space: O(n * k) to store all words in the result map */ public static List<List<String>> groupAnagrams(String[] words) { Map<String, List<String>> anagramBuckets = new HashMap<>(); for (String word : words) { char[] wordChars = word.toCharArray(); Arrays.sort(wordChars); String canonicalKey = new String(wordChars); anagramBuckets.computeIfAbsent(canonicalKey, k -> new ArrayList<>()).add(word); } return new ArrayList<>(anagramBuckets.values()); } /** * Alternative fingerprint approach using a frequency-count key. * Avoids sorting entirely — O(n * k) overall. */ public static List<List<String>> groupAnagramsLinear(String[] words) { Map<String, List<String>> anagramBuckets = new HashMap<>(); for (String word : words) { int[] charCounts = new int[26]; for (char ch : word.toCharArray()) { charCounts[ch - 'a']++; } StringBuilder keyBuilder = new StringBuilder(); for (int count : charCounts) { keyBuilder.append('#').append(count); } String frequencyKey = keyBuilder.toString(); anagramBuckets.computeIfAbsent(frequencyKey, k -> new ArrayList<>()).add(word); } return new ArrayList<>(anagramBuckets.values()); } public static void main(String[] args) { String[] wordList = {"eat", "tea", "tan", "ate", "nat", "bat"}; List<List<String>> grouped = groupAnagrams(wordList); System.out.println("Sorted-key approach:"); for (List<String> group : grouped) { System.out.println(" " + group); } System.out.println("\nFrequency-key approach:"); List<List<String>> groupedLinear = groupAnagramsLinear(wordList); for (List<String> group : groupedLinear) { System.out.println(" " + group); } } }
- Sorted key: simple, O(k log k) per word. Best for short words (k < 50).
- Frequency key: O(k) per word. Best for long words (k > 100).
- Frequency key uses int[26] → string conversion: "#2#0#1#..." as the hash key.
- Both produce the same grouping result. The difference is per-word key-building cost.
- Decision rule: if k is unknown, use sorted key for simplicity. If k can be large, use frequency key.
StringBuilder and String Concatenation — The Hidden O(n^2)
String concatenation with + inside a loop is the most common performance anti-pattern in string-heavy code. In Java, Strings are immutable. Each + creates a new String object, copying all previous characters. For a loop of n iterations building a string of final length L, total work is O(L^2) — not O(L).
StringBuilder solves this by maintaining a mutable char[] buffer. Appends are O(1) amortized (occasional resize is O(L) but amortized over n appends). The final toString() call allocates one String of length L. Total work: O(L).
The Java compiler can optimize simple string + into StringBuilder for cases like String s = a + b + c. But it fails for complex patterns like result += match + ", " inside a loop. Never rely on the compiler — use StringBuilder explicitly for any string assembly inside a loop.
package io.thecodeforge.algo; public class StringBuilderBenchmark { /** Demonstrates the O(n^2) cost of string concatenation vs O(n) StringBuilder. */ public static void main(String[] args) { int n = 100_000; // BAD: O(n^2) — each + copies all previous characters long start = System.nanoTime(); String bad = ""; for (int i = 0; i < n; i++) { bad += "a"; // new String object each iteration } long badTime = System.nanoTime() - start; // GOOD: O(n) — StringBuilder appends in-place start = System.nanoTime(); StringBuilder sb = new StringBuilder(n); // pre-sized for (int i = 0; i < n; i++) { sb.append('a'); } String good = sb.toString(); long goodTime = System.nanoTime() - start; System.out.println("String +000) StringBuilder: 1 ms (length=100000) Speedup: 84 loop: " + badTime / 1_000_000 + " ms (length=" + bad.length() + ")"); System.out.println("StringBuilder: " + goodTime / 1_000_000 + " ms (length=" + good.length() + ")"); System.out.println("Speedup: " + (badTime / Math.max(goodTime, 1)) + "x"); } }
Trie Trees — When Prefix Searches Burn O(n²) and You Need O(L)
Most devs reach for a HashSet when they need to check if a substring exists. Fine for exact matches. Trash for prefix searches. When you're validating autocomplete, filtering profanity by prefix, or routing phone numbers, a HashSet forces an O(n) scan per query. Do that a thousand times and you've got a production fire.
A Trie (prefix tree) trades memory for speed. Each node stores one character and a flag for 'end of word'. Searching for a prefix costs O(L) where L is the length of the prefix — not the size of the dictionary. That's the difference between a laggy search bar and instant results.
The trick: don't implement Trie for everything. It's overkill for exact lookups. But for problems like 'Word Break', 'Replace Words', or 'Longest Common Prefix', it's the difference between passing and timing out. String manipulation isn't always about slicing and dicing characters. Sometimes it's about building a search structure that doesn't suck.
// io.thecodeforge — dsa tutorial class TrieNode { TrieNode[] children = new TrieNode[26]; boolean isEnd; } public class TrieAutocomplete { TrieNode root = new TrieNode(); public void insert(String word) { TrieNode node = root; for (char c : word.toCharArray()) { int idx = c - 'a'; if (node.children[idx] == null) { node.children[idx] = new TrieNode(); } node = node.children[idx]; } node.isEnd = true; } public boolean startsWith(String prefix) { TrieNode node = root; for (char c : prefix.toCharArray()) { int idx = c - 'a'; if (node.children[idx] == null) return false; node = node.children[idx]; } return true; } public static void main(String[] args) { TrieAutocomplete t = new TrieAutocomplete(); t.insert("codeforge"); t.insert("codecrash"); System.out.println(t.startsWith("code")); // true System.out.println(t.startsWith("coda")); // false } }
Rabin-Karp — Rolling Hash That Catches Plagiarism and Malware Signatures
The sliding window pattern handles fixed-size substrings by recomputing from scratch each time. That's O(k) per window. Dumb. If your window is 1000 characters and you slide across a 100K document, you're doing 100 million character operations. Your CPU hates you.
Rabin-Karp uses a rolling hash. Instead of recalculating the hash of each window from zero, it subtracts the outgoing character's contribution and adds the incoming one. O(1) per slide. The math uses a base (usually 256 for ASCII) and a large prime to keep collisions manageable.
Real talk: Rabin-Karp isn't for everyday substring search. That's what KMP or Java's indexOf() is for — they're optimized by people smarter than you. But Rabin-Karp shines when you need to search for multiple patterns in one pass. Think plagiarism detection (search 1000 phrases at once), or malware signature scanning. The hash lets you test candidates fast and only verify exact matches when the hash collides.
Implementation gotcha: hash collisions happen. Always verify with a character-by-character comparison when hashes match. Skip that check and you'll push false positives to prod. Ask me how I know.
// io.thecodeforge — dsa tutorial public class RabinKarpSearch { private static final int BASE = 256; private static final int PRIME = 101; public static int search(String text, String pattern) { int n = text.length(); int m = pattern.length(); if (m > n) return -1; long patternHash = 0, windowHash = 0, highestPower = 1; for (int i = 0; i < m - 1; i++) { highestPower = (highestPower * BASE) % PRIME; } for (int i = 0; i < m; i++) { patternHash = (patternHash * BASE + pattern.charAt(i)) % PRIME; windowHash = (windowHash * BASE + text.charAt(i)) % PRIME; } for (int i = 0; i <= n - m; i++) { if (patternHash == windowHash) { boolean match = true; for (int j = 0; j < m; j++) { if (text.charAt(i + j) != pattern.charAt(j)) { match = false; break; } } if (match) return i; } if (i < n - m) { windowHash = (BASE * (windowHash - text.charAt(i) * highestPower) + text.charAt(i + m)) % PRIME; if (windowHash < 0) windowHash += PRIME; } } return -1; } public static void main(String[] args) { String text = "thecodeforgepatternhunt"; String pattern = "pattern"; System.out.println(search(text, pattern)); // 11 } }
Custom Comparators — Sort Strings by Rules, Not Alphabet
Your interviewer tells you to sort an array of strings by length, or by sum of character codes, or by the number of vowels. The naive approach is to write a bubble sort with inline comparisons—O(n²) and embarrassing when you could write a comparator in two lines.
The WHY: Sorting strings by default lexicographic order is almost never the real problem. You want to define your own ranking. Java’s Comparator interface lets you slot in arbitrary comparison logic, and under the hood Arrays.sort() uses TimSort (O(n log n)). The HOW: implement compare(String a, String b) to return negative/zero/positive based on your custom rule. Production gotcha: make sure the comparator is transitive, or you'll get Comparison method violates its general contract! at runtime.
Use this when you're asked to sort by frequency, length, or any derived property. It's the single most direct way to turn a string-sorting problem into a comparison problem without writing a sort from scratch.
// io.thecodeforge — dsa tutorial import java.util.*; public class CustomComparatorExample { public static void main(String[] args) { String[] words = {"apple", "fig", "banana", "kiwi", "grape"}; // Sort by length ascending, then alphabetically for ties Arrays.sort(words, (a, b) -> { if (a.length() != b.length()) { return Integer.compare(a.length(), b.length()); } return a.compareTo(b); }); System.out.println(Arrays.toString(words)); } }
a.length() < b.length() else 1) will crash with IllegalArgumentException on large arrays. Always cover the equality case.compare(), get O(n log n), never write bubble sort by hand again.StringBuilder vs StringBuffer — Thread Safety You Probably Don't Need
You're mutating a string in a loop. You reach for StringBuffer because you once read it's 'thread-safe'. Stop. That's a performance footgun. Here's the cold truth: StringBuffer synchronizes every single method call—append, insert, delete, everything. If you're doing this in a single-threaded context (which is 99% of LeetCode and backend request handlers), you're paying for locks you never use.
The WHY: StringBuilder is the unsynchronized, faster twin. No method-level locks, no memory barrier flushes. In a tight loop building a string of 10,000 characters, StringBuilder is 2x-3x faster than StringBuffer. In production, unless you're sharing the builder across threads (you shouldn't be—that's a design smell), use StringBuilder.
The HOW: StringBuilder sb = new . That's it. The only defense for StringBuilder(); sb.append(...); sb.toString();StringBuffer is legacy Java 1.4 code, or a truly shared buffer passed between threads—and even then, ask yourself why you're not using a thread-local or an immutable design. Senior engineers reach for StringBuilder by default. Make it a reflex.
// io.thecodeforge — dsa tutorial public class StringBuilderVsBuffer { public static void main(String[] args) { int n = 100_000; long start, end; // StringBuilder (fast) start = System.nanoTime(); StringBuilder sb = new StringBuilder(); for (int i = 0; i < n; i++) { sb.append("a"); } String result = sb.toString(); end = System.nanoTime(); System.out.println("StringBuilder: " + (end - start) / 1_000_000 + " ms"); // StringBuffer (slow — pays for locks) start = System.nanoTime(); StringBuffer sbf = new StringBuffer(); for (int i = 0; i < n; i++) { sbf.append("a"); } result = sbf.toString(); end = System.nanoTime(); System.out.println("StringBuffer: " + (end - start) / 1_000_000 + " ms"); } }
Monotonic Stack — The Secret to Next Greater Element on Strings
When you need to find the next greater (or smaller) character in a string traversal, a monotonic stack eliminates nested loops. The stack maintains indices with strictly increasing (or decreasing) character values. On each new character, pop while the condition breaks monotonicity, recording results for popped indices. This collapses O(n²) brute-force into O(n) with O(n) space. Use it for problems like "Remove Duplicate Letters" or "Smallest Subsequence of Distinct Characters". The core insight: you only care about relative ordering and future characters, not arbitrary comparisons.
// io.thecodeforge — dsa tutorial import java.util.ArrayDeque; import java.util.Deque; public class NextGreaterChar { public int[] nextGreater(String s) { int n = s.length(); int[] res = new int[n]; Deque<Integer> stack = new ArrayDeque<>(); for (int i = 0; i < n; i++) { while (!stack.isEmpty() && s.charAt(i) > s.charAt(stack.peek())) { res[stack.pop()] = i; } stack.push(i); } while (!stack.isEmpty()) res[stack.pop()] = -1; return res; } }
In-Place Transformations — Modify the String Without Extra Memory
In-place transformations mutate the input array to achieve O(1) extra space. The classic pattern is two passes: one to compute final lengths or counts, then a reverse scan to overwrite from the end. This avoids creating new strings for every edit. Used in "URLify" (replace spaces with %20) or "Remove Duplicates from Sorted Array". The performance gain is massive when strings are large — memory allocation drops from O(n) to O(1). The trick: work backwards so you never overwrite data you still need.
// io.thecodeforge — dsa tutorial public class URLify { public void replaceSpaces(char[] str, int trueLength) { int spaceCount = 0; for (int i = 0; i < trueLength; i++) if (str[i] == ' ') spaceCount++; int index = trueLength + spaceCount * 2; for (int i = trueLength - 1; i >= 0; i--) { if (str[i] == ' ') { str[--index] = '0'; str[--index] = '2'; str[--index] = '%'; } else str[--index] = str[i]; } } }
Backtracking on Strings — Generate All Permutations Without Repeats
Backtracking systematically explores every valid combination by building partial solutions and reverting choices. For strings, use a boolean visited array or swap characters in place. The recursion tree prunes branches when constraints fail (like duplicate characters). Time complexity is O(n!) worst-case, but pruning reduces real runtime significantly. Use it for "Generate All Palindromic Partitions" or "Word Break II". The key insight: decide at each step which character to pick next, and backtrack when no valid continuation exists.
// io.thecodeforge — dsa tutorial import java.util.*; public class Permutations { public List<String> permute(String s) { List<String> res = new ArrayList<>(); char[] chars = s.toCharArray(); Arrays.sort(chars); backtrack(chars, new boolean[chars.length], new StringBuilder(), res); return res; } private void backtrack(char[] chars, boolean[] used, StringBuilder path, List<String> res) { if (path.length() == chars.length) { res.add(path.toString()); return; } for (int i = 0; i < chars.length; i++) { if (used[i] || (i > 0 && chars[i] == chars[i-1] && !used[i-1])) continue; used[i] = true; path.append(chars[i]); backtrack(chars, used, path, res); path.deleteCharAt(path.length() - 1); used[i] = false; } } }
chars[i] == chars[i-1] fails to catch non-adjacent duplicates, leading to exponential waste.Autocomplete Service OOM: String Concatenation in Loop Created 2GB of Temporary Objects Per Minute
matches.size() * 15) to avoid resize overhead.
5. Added a unit test that builds a 10,000-element string and asserts no GC pressure.- String + in a loop is O(n^2). StringBuilder is O(n). This is the single most impactful one-line fix in string-heavy code.
- Always pre-size StringBuilder when you know the approximate final length. Default capacity is 16, causing log(n) resize operations.
- Add lint rules to catch string concatenation in loops. This bug recurs every time a new developer joins the team.
- Profile GC pressure, not just latency. The OOM was a symptom of 2GB/min temporary allocations, not a memory leak.
- For Java, the compiler optimizes string + into StringBuilder for simple cases. But it fails for complex expressions like result += match + ", ". Never rely on the compiler — use StringBuilder explicitly.
Character.toLowerCase() before indexing, or switch to HashMap<Character, Integer>.s1.length() != s2.length(), they cannot be anagrams. Return false immediately.Add trace: System.out.println("before max: " + windowStart + " lastSeen: " + lastSeenAt.get(ch))Verify windowStart = Math.max(windowStart, lastSeenAt.get(ch) + 1)Profile with -XX:+PrintGCDetails — look for excessive GC from char[] allocationsSearch code for: result += or result = result +StringBuilder.append().Print the character that crashes: System.out.println("char=" + ch + " code=" + (int)ch)If code > 127 or char is uppercase, the int[26] assumption is wrongCount key-building operations: is it O(k) or O(k log k) per word?If sorting: switch to frequency-count key (int[26] → string key)| Pattern | Best For | Time Complexity | Space Complexity | Key Signal in Problem |
|---|---|---|---|---|
| Sliding Window (variable) | Longest/shortest substring with constraint | O(n) | O(alphabet size) | Problem says 'longest/shortest/minimum window' |
| Sliding Window (fixed) | Anagram detection, fixed-length pattern match | O(n) | O(1) with int[26] | Problem gives a fixed pattern length |
| Two Pointers (inward) | Palindrome check, symmetry comparison | O(n) | O(1) | Problem involves mirroring or reading both ends |
| Frequency Map | Character count comparison, anagram grouping | O(n) | O(alphabet size) | Problem asks 'same characters?' or 'rearrangement?' |
| String Hashing / Sorted Key | Grouping anagrams, duplicate substring detection | O(n * k log k) | O(n * k) | Problem asks to group or deduplicate by content |
| StringBuilder | Building output strings from parts | O(n) | O(n) | Problem requires assembling a string from components |
| Expand Around Center | Longest palindromic substring | O(n^2) | O(1) | Problem asks for longest palindrome (not just check) |
| Rabin-Karp Rolling Hash | Substring search, duplicate detection at scale | O(n + m) avg | O(1) | Problem requires finding pattern occurrences in large text |
| File | Command / Code | Purpose |
|---|---|---|
| io | public class StringWorkedExamples { | Worked Example |
| io | public class StringPatterns { | Key String Manipulation Patterns |
| io | public class LongestUniqueSubstring { | The Sliding Window Pattern |
| io | public class AnagramFinder { | Frequency Maps |
| io | public class PalindromeChecker { | Two Pointers on Strings |
| io | public class AnagramGrouper { | String Hashing |
| io | public class StringBuilderBenchmark { | StringBuilder and String Concatenation |
| TrieAutocomplete.java | class TrieNode { | Trie Trees |
| RabinKarpSearch.java | public class RabinKarpSearch { | Rabin-Karp |
| CustomComparatorExample.java | public class CustomComparatorExample { | Custom Comparators |
| StringBuilderVsBuffer.java | public class StringBuilderVsBuffer { | StringBuilder vs StringBuffer |
| MonotonicStackExample.java | public class NextGreaterChar { | Monotonic Stack |
| InPlaceTransform.java | public class URLify { | In-Place Transformations |
| BacktrackPermutations.java | public class Permutations { | Backtracking on Strings |
Key takeaways
Practice These on LeetCode
Interview Questions on This Topic
Frequently Asked Questions
The sliding window technique maintains a contiguous substring defined by two pointers (left and right). Instead of restarting from scratch for each position, you slide the window forward by adding one character on the right and optionally removing one on the left, updating your state incrementally. This reduces most O(n^2) substring problems to O(n).
The fastest O(n) approach for lowercase-only strings is to build an int[26] frequency array for each string — increment for the first string, decrement for the second — then check that all 26 values are zero. If any entry is non-zero, the strings aren't anagrams. Alternatively, sort both strings and compare with equals(), but that's O(n log n).
Use two pointers moving inward from both ends when the problem involves symmetry, palindromes, or comparing characters from opposite sides of the string. Use a sliding window when the problem involves a contiguous segment (substring) and asks you to find the longest, shortest, or all such segments satisfying a constraint.
int[26] frequency comparison is O(n) time and O(1) space for lowercase English strings. Increment for string A, decrement for string B, check all 26 values are zero. Sorting both and comparing is O(n log n). int[26] is strictly faster.
Count character frequencies using a fixed-size array (for lowercase ASCII, size 26) or a HashMap. Increment counts for string A, decrement for string B. If all counts are zero at the end, the strings are anagrams. This avoids sorting and runs in O(n) time, O(1) space for fixed character sets.
Java Strings are immutable. Each + creates a new String object, copying all previous characters. For n iterations, total work is 1 + 2 + 3 + ... + n = n(n+1)/2 = O(n^2). StringBuilder maintains a mutable buffer and appends in-place: O(n) total. Always use StringBuilder for string assembly inside loops.
When a duplicate character is found, lastSeenAt.get(ch) gives the index after its previous occurrence. Without Math.max, this index could be less than the current windowStart (if the duplicate was before the current window), causing the left pointer to jump backward. Math.max(windowStart, lastSeenAt.get(ch)) ensures the left pointer only moves forward, preserving the window invariant.
Both are O(n^2) time. Expand-around-center uses O(1) space by expanding from each possible center. DP uses O(n^2) space for a table storing whether each substring is a palindrome. Expand-around-center is preferred unless you need to answer many palindrome queries on the same string (in which case the DP table amortizes its cost).
20+ years shipping performance-critical code where algorithms decide the bill. Written from production experience, not tutorials.
That's Arrays & Strings. Mark it forged?
8 min read · try the examples if you haven't