Lessons
1 · Foundations (Think Like a Programmer)
2 · Data Structures and Recursion
- 05Arrays and Array-Based Problem Solving
- 06Strings: Characters, Immutability, and Efficient Manipulation
- 07Linked Lists: Nodes, Pointers, and Pointer Reasoning
- 08Stacks and Queues
- 09Hashing: HashMap, HashSet, and Frequency Patterns
- 10Recursion and Backtracking
- 11Trees and Binary Search Trees
- 12Heaps and Priority Queues
3 · Core Algorithms and Reusable Patterns
4 · Advanced Interview Topics
Lesson 6 · Data Structures and Recursion
Strings: Characters, Immutability, and Efficient Manipulation
How Java strings really work: characters and codes, comparison, immutability and StringBuilder, then the classic problems (palindromes, anagrams, run-length encoding) plus honest notes on Unicode.
For
Beginners and early-career developers who want to solve string problems correctly and efficiently in Java.
Level
Before you start
Articles 3 to 5: Java basics, Big O, and arrays. Frequency counting with a small array is used here again.
On this page
Key takeaways
- A String is an immutable sequence of UTF-16 characters. Every change creates a new String, so building a string in a loop with += is O(n^2).
- Use StringBuilder to build or edit text, char[] when you need to change characters in place, and equals (never ==) to compare.
- Most string problems reduce to a few tools: two pointers (palindromes), frequency counts (anagrams) and one pass with a builder.
- Subtracting 'a' from a lowercase letter gives a slot from 0 to 25. This only works if the input really is lowercase a-z.
- Java's length() counts UTF-16 code units, not what a reader sees as characters. Emoji and many Indian-language characters expose the difference.
Words are the data humans care about most: names, messages, searches, log lines. So it is no surprise that string problems are everywhere in interviews.
The good news is that a string is, to a first approximation, an array of characters. Almost everything you learned in the arrays article carries over. The bad news is that Java's String has some special rules, and ignoring them leads to slow code and strange bugs. This article covers both.
What you will learn
#- How characters map to numbers, and the arithmetic tricks that follow.
- How to compare strings correctly.
- What immutability means, and when
StringBuilderhelps. - How to solve palindrome, anagram and compression problems.
- What Java's
length()really counts, and where Unicode surprises you.
Characters are numbers
#In Java, a char is a 16-bit number that represents a character. The letters, digits and symbols you type on an English keyboard have codes between 0 and 127 (the ASCII range). The ones you need to remember are the runs:
Characters | Codes | Meaning |
|---|---|---|
| 48 to 57 | Digits |
| 65 to 90 | Upper case |
| 97 to 122 | Lower case |
Each run is consecutive, so subtracting the first character of a run gives a position within it. 'e' - 'a' is 4, and '7' - '0' is 7. Adding goes the other way: (char) ('a' + 4) is 'e'.
The Character class has methods so you do not need to remember ranges:
// File: Main.javapublic class Main { public static void main(String[] args) { System.out.println((int) 'A' + " " + (int) 'a' + " " + (int) '0'); System.out.println(Character.isLetter('x') + " " + Character.isDigit('7') + " " + Character.isLetterOrDigit('_')); System.out.println(Character.toUpperCase('q') + " " + Character.getNumericValue('8')); String a = "apple"; String b = "Apple"; System.out.println(a.equals(b) + " " + a.equalsIgnoreCase(b)); System.out.println(a.compareTo("banana") + " " + a.compareTo("apple") + " " + "b".compareTo("a")); System.out.println(b.compareTo(a)); }}65 97 48true true falseQ 8false true-1 0 1-32About the comparisons:
equalschecks that two strings have the same characters.equalsIgnoreCaseignores case. Neither uses==.compareToreturns a negative number, zero or a positive number. It compares character by character, and at the first difference returns the difference of the two character codes. This is lexicographic (dictionary-like) order based on character codes. That is why"Apple"comes before"apple": upper-case codes are smaller than lower-case codes.- Do not rely on the exact value of a non-zero result, only on its sign.
Immutability
#A String in Java is immutable: once created, its characters can never change. Methods such as toUpperCase, substring and replace do not modify the string they are called on. They return a new string.
That has two consequences:
- Safety. You can share a string freely without worrying that someone else will change it.
- Cost. Any "change" builds a whole new string, which means copying characters.
The cost is invisible in one operation and enormous in a loop. Consider building a string of n characters by adding one character at a time with +=. Adding the 1st character copies 0 old characters, adding the 2nd copies 1, adding the 3rd copies 2, and so on. In total that is 0 + 1 + 2 + ... + (n - 1), which is n × (n - 1) / 2 copies, so O(n^2).
A StringBuilder is a mutable buffer. It keeps a growable array of characters inside and appends to it directly, with the same amortised O(1) cost per character that ArrayList has.
// File: Main.javapublic class Main { public static void main(String[] args) { int n = 1000; String result = ""; long copiedByConcat = 0; for (int i = 0; i < n; i++) { copiedByConcat += result.length(); // the old characters are copied into a new string result += 'x'; } StringBuilder builder = new StringBuilder(); for (int i = 0; i < n; i++) { builder.append('x'); } System.out.println(result.equals(builder.toString())); System.out.println("characters copied by += (model): " + copiedByConcat); System.out.println("characters appended by StringBuilder: " + builder.length()); }}truecharacters copied by += (model): 499500characters appended by StringBuilder: 1000The counter is a model of the work, not a measurement. (Java compilers can optimise one concatenation expression, but a loop that repeatedly extends a string still creates a new string on each pass.) For a string of 100,000 characters the model says 5 billion copies versus 100,000 appends. That difference is the gap between "times out" and "instant".
The StringBuilder toolkit
#Method | What it does | Cost |
|---|---|---|
| Adds text, a character or a number at the end | amortised O(1) per character |
| Inserts at position | O(n), shifts the tail |
| Reverses in place | O(n) |
| Removes one character | O(n), shifts the tail |
| Replaces one character | O(1) |
| Read | O(1) |
| Makes the final | O(n) |
Also remember String.join(", ", list) for joining pieces, and String.valueOf(charArray) or new String(charArray) for turning a char[] back into a string.
Problem: palindromes
#A palindrome reads the same forwards and backwards: "racecar", "level". In an interview you usually have to ignore case and punctuation, so "A man, a plan, a canal: Panama" counts.
Brute force. Clean the text: build a new string with only letters and digits, lower-cased. Then reverse it and compare. This is O(n) time and O(n) extra space.
Better. Put one index at each end and move inwards. Skip characters that are not letters or digits. Compare the others. This is O(n) time and O(1) extra space, because we never build a cleaned copy. It is the first appearance of the two pointers technique, studied in detail in Two Pointers.
// File: Main.javapublic class Main { static boolean isPalindrome(String s) { int left = 0; int 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; } public static void main(String[] args) { String[] tests = {"racecar", "abca", "", "A man, a plan, a canal: Panama", ".,", "0P"}; for (String test : tests) { System.out.println("[" + test + "] " + isPalindrome(test)); } }}[racecar] true[abca] false[] true[A man, a plan, a canal: Panama] true[.,] true[0P] falseWhy does comparing the outermost pair first work? A string is a palindrome exactly when its first and last characters match and the part between them is a palindrome. So after each matching pair, we shrink the problem by two characters. The invariant is: everything outside [left, right] has already been verified to match.
Dry run on "A man, a plan" truncated to "A m,a" (so we can see the skipping):
|
|
|
| Action |
|---|---|---|---|---|
0 | 4 |
|
| letters, lower-case equal, move both in |
1 | 3 | space |
|
|
2 | 2 |
|
| the same character, trivially equal. Both pointers move past each other and the loop ends |
Result: true. (The characters A, m, a do read the same backwards.) Because we only compare each character once, the time is O(n).
An empty string and a string with no letters or digits (such as ".,") are treated as palindromes. That is a decision you should state: the usual convention is yes.
Problem: anagrams
#Two strings are anagrams if they contain the same characters with the same counts, in any order: "listen" and "silent".
Idea 1: sort both and compare. If they are anagrams, sorting gives identical results. This takes O(n log n) time.
Idea 2: count characters. Use the frequency array from the arrays article. Add one for each character of the first string, subtract one for each character of the second. If every counter returns to zero, they are anagrams. This is O(n) time and O(1) space (26 counters).
// File: Main.javaimport java.util.Arrays;public class Main { // Assumes both strings contain only lowercase letters a to z. static boolean isAnagram(String a, String b) { if (a.length() != b.length()) { return false; } int[] counts = new int[26]; for (int i = 0; i < a.length(); i++) { counts[a.charAt(i) - 'a']++; counts[b.charAt(i) - 'a']--; } for (int count : counts) { if (count != 0) { return false; } } return true; } static boolean isAnagramSorted(String a, String b) { char[] x = a.toCharArray(); char[] y = b.toCharArray(); Arrays.sort(x); Arrays.sort(y); return Arrays.equals(x, y); } public static void main(String[] args) { String[][] tests = {{"listen", "silent"}, {"rat", "car"}, {"aabb", "abab"}, {"", ""}, {"abc", "ab"}}; for (String[] pair : tests) { System.out.println(pair[0] + " / " + pair[1] + ": " + isAnagram(pair[0], pair[1]) + " " + isAnagramSorted(pair[0], pair[1])); } }}listen / silent: true truerat / car: false falseaabb / abab: true true / : true trueabc / ab: false falseThe length check at the start is not only an optimisation. Without it, strings of different lengths could slip through if the counters balance in an odd way, and for the counting approach it would also cause an index error when one string is shorter.
The big assumption is "only lowercase a to z". If the input could contain upper case, digits or any other character, c - 'a' could be negative or out of range and the program would crash. Always ask what characters are possible. For arbitrary characters use a HashMap<Character, Integer>, or a larger array (for example 128 or 256 slots for ASCII or Latin-1), or sort the characters. We study the map version in Hashing.
Problem: run-length compression
#Problem. Compress a string by replacing each run of the same character with the character followed by the length of the run, but only write the length when it is greater than 1. "aaabcc" becomes "a3bc2".
The loop uses two indexes: i marks the start of a run, and j scans to the end of the run. Then we write the character and (if needed) the count, and continue from j.
// File: Main.javapublic class Main { static String compress(String s) { StringBuilder out = new StringBuilder(); int i = 0; while (i < s.length()) { char c = s.charAt(i); int j = i; while (j < s.length() && s.charAt(j) == c) { j++; } out.append(c); if (j - i > 1) { out.append(j - i); } i = j; } return out.toString(); } public static void main(String[] args) { String[] tests = {"aaabcc", "abc", "", "zzzzzzzzzzzz", "aabbaa"}; for (String test : tests) { System.out.println("[" + test + "] -> [" + compress(test) + "]"); } }}[aaabcc] -> [a3bc2][abc] -> [abc][] -> [][zzzzzzzzzzzz] -> [z12][aabbaa] -> [a2b2a2]Dry run for "aaabcc":
|
|
| Run length | Output so far |
|---|---|---|---|---|
0 |
| 3 | 3 |
|
3 |
| 4 | 1 |
|
4 |
| 6 | 2 |
|
i moves to 6, which equals the length, and the loop ends. Even though there is a loop inside a loop, j never moves backwards and the next i starts from where j stopped. Every character is looked at once, so the time is O(n). The extra space is the output, which is at most about n characters.
Note that a run of 12 characters produced z12: the count is a number, written as append(int), so its digits are appended as text. This detail matters if you later try to decode the output. "a12" could mean twelve a's, and it is ambiguous when the original text contains digits. Real compression formats solve that with an escape rule. For the exercise here we assume the input has no digits.
Complexity of common string operations
#Operation | Time | Notes |
|---|---|---|
| O(1) | |
| O(length of the shorter, in the worst case) | It stops at the first difference |
| O(j - i) | Copies the characters (in modern Java) |
| O(n × m) worst case for a text of length | Typically much faster in practice, but do not claim |
| O(length of both) | New string |
| O(n^2) in total | Use |
| O(n) | Copies |
| amortised O(1) per character |
What length() really counts: a note on Unicode
#So far we treated each char as one character. That is true for English text, and it is not true in general. Java stores text as UTF-16: each char is a 16-bit unit. Characters outside the first 65,536 (many emoji, some rare scripts) take two chars, called a surrogate pair. And what a reader sees as one letter can be several code points, such as a base letter plus a combining accent, or an Indian-language consonant plus vowel marks.
// File: Main.javapublic class Main { public static void main(String[] args) { String smile = "😀"; // an emoji outside the basic range String composed = "é"; // e with an acute accent, as one code point String decomposed = "é"; // e followed by a combining accent String hindi = "नमस्ते"; // the Hindi word namaste System.out.println(smile.length() + " " + smile.codePointCount(0, smile.length())); System.out.println(composed.length() + " " + decomposed.length() + " " + composed.equals(decomposed)); String normalized = java.text.Normalizer.normalize(decomposed, java.text.Normalizer.Form.NFC); System.out.println(normalized.equals(composed)); System.out.println(hindi.length() + " " + hindi.codePointCount(0, hindi.length())); System.out.println("TITLE".toLowerCase(java.util.Locale.ROOT)); }}2 11 2 falsetrue6 6titleWhat this tells you:
- The emoji has
length()2 but is one code point. Reversing such a string withcharAtwould split the pair and produce garbage.StringBuilder.reverse()is aware of surrogate pairs; a hand-written loop overchars is not. - The two spellings of "é" look identical on screen, but
equalssays they differ.Normalizerconverts both to the same form so you can compare them. - The Hindi word has 6 code points, but a reader would not count six letters, because vowel signs and the virama attach to the consonants around them. If a problem says "count the characters" for such text, you need to know what it means:
length()code units, code points, or what readers perceive (grapheme clusters). toLowerCase()without a locale uses the computer's default locale, and in a Turkish locale"I"lowercases to a dotless character, not to"i". UsetoLowerCase(Locale.ROOT)when you want predictable results.
For interview problems, the usual assumption is ASCII or lowercase English. It is a good habit to state that assumption aloud, and to know that a real application handling names in many languages needs more care. Most Indian-language text, for example, is outside the simple a to z model, and so are the names your users will type.
Edge cases and common mistakes
#- Empty string.
s.charAt(0)throwsStringIndexOutOfBoundsException. Checks.isEmpty()or the length first. Also decide whethernullis possible. - Comparing with
==. Alwaysequals. - Discarding the result.
s.trim();alone does nothing. Writes = s.trim();. - Building strings with
+=in a loop. UseStringBuilder. - Case. Upper and lower case letters are different characters unless you normalise them. Decide whether
"A"equals"a"for your problem. - Assuming only
atoz. If the input might contain anything else,c - 'a'can go negative or out of bounds. - Off-by-one with
substring.s.substring(i, j)includesiand excludesj. split(" ")with repeated spaces. It creates empty strings between the spaces. Uses.trim().split("\\s+")for "any amount of whitespace".- Treating
length()as the number of visible characters. It is the number of UTF-16 units.
Exercises
#Exercise 1
Trace
#What does the following print? Write the content of the builder after each call.
StringBuilder sb = new StringBuilder("abc");sb.append("de").insert(0, "x").reverse().deleteCharAt(1);System.out.println(sb + " " + sb.length());Exercise 2: First unique character
#Given a string of lowercase letters, return the index of the first character that appears exactly once, or -1 if there is none. For "loveleetcode" the answer is 2. Aim for O(n) time.
Exercise 3: Rotation check
#Decide whether string b is a rotation of string a. For example, "erbottlewat" is a rotation of "waterbottle". State the length check and the trick that avoids trying every rotation.
Exercise 4: Debug
#This method should reverse the order of words in a sentence: "the sky is blue" becomes "blue is sky the". It has several problems with extra spaces and speed. Find them and fix them.
static String reverseWordsBuggy(String s) { String[] parts = s.split(" "); String result = ""; for (int i = parts.length - 1; i >= 0; i--) { result += parts[i] + " "; } return result;}Exercise 5: Interview-style
#Given a string, decide whether it can become a palindrome after deleting at most one character. "abca" can (delete c or b), "abc" cannot. Aim for O(n) time and O(1) extra space.
Show solutionHide solution
Call | Builder |
|---|---|
|
|
|
|
|
|
|
|
|
|
// File: Main.javapublic class Main { public static void main(String[] args) { StringBuilder sb = new StringBuilder("abc"); sb.append("de").insert(0, "x").reverse().deleteCharAt(1); System.out.println(sb + " " + sb.length()); }}ecbax 5The methods can be chained because each returns the builder itself. The deleteCharAt(1) removes the character at index 1 of the reversed string, which is d.
Exercise 2
Show solutionHide solution
Two passes. First, count every letter. Second, scan from the left and return the first index whose count is 1. Both passes are O(n), and the counter array has 26 slots, so extra space is O(1). We cannot answer in one pass, because we do not know whether a letter will appear again later.
// File: Main.javapublic class Main { static int firstUniqueIndex(String s) { int[] counts = new int[26]; for (int i = 0; i < s.length(); i++) { counts[s.charAt(i) - 'a']++; } for (int i = 0; i < s.length(); i++) { if (counts[s.charAt(i) - 'a'] == 1) { return i; } } return -1; } public static void main(String[] args) { System.out.println(firstUniqueIndex("leetcode")); System.out.println(firstUniqueIndex("loveleetcode")); System.out.println(firstUniqueIndex("aabb")); System.out.println(firstUniqueIndex("")); }}02-1-1In "loveleetcode", the counts are l=2, o=2, v=1, e=4, t=1, c=1, d=1. Scanning from the left, l and o have counts above 1, and v at index 2 is the first with count 1.
Exercise 3
Show solutionHide solution
If b is a rotation of a, then b appears inside a + a. For a = "waterbottle", a + a = "waterbottlewaterbottle", which contains "erbottlewat". Every rotation of a is a window of length n inside a + a.
We must first check the lengths. Without that, "a" would be reported as a rotation of "aa" (which is inside "aa").
// File: Main.javapublic class Main { static boolean isRotation(String a, String b) { return a.length() == b.length() && (a + a).contains(b); } public static void main(String[] args) { System.out.println(isRotation("waterbottle", "erbottlewat")); System.out.println(isRotation("abc", "acb")); System.out.println(isRotation("", "")); System.out.println(isRotation("a", "ab")); System.out.println(isRotation("abab", "baba")); }}truefalsetruefalsetrueBuilding a + a costs O(n) time and space. The search with contains is O(n) for typical inputs, but a straightforward search can take O(n × m) in a worst case. In an interview, you can say "O(n) with an efficient substring search such as KMP, and Java's contains is fine in practice".
Exercise 4
Show solutionHide solution
Problems:
- Empty strings from repeated spaces.
" hello world ".split(" ")gives empty strings for each extra space, and these end up as extra spaces in the output. - A trailing space. Every word is followed by a space, including the last one.
- Leading space handling. Leading and trailing whitespace in the input should usually vanish.
O(n^2)building. The+=loop copies the result every time.
Fix: trim, split on one or more whitespace characters, then join with a StringBuilder or String.join.
// File: Main.javapublic class Main { static String reverseWordsBuggy(String s) { String[] parts = s.split(" "); String result = ""; for (int i = parts.length - 1; i >= 0; i--) { result += parts[i] + " "; } return result; } static String reverseWords(String s) { String trimmed = s.trim(); if (trimmed.isEmpty()) { return ""; } String[] words = trimmed.split("\\s+"); StringBuilder out = new StringBuilder(); for (int i = words.length - 1; i >= 0; i--) { out.append(words[i]); if (i > 0) { out.append(' '); } } return out.toString(); } public static void main(String[] args) { String input = " the sky is blue "; System.out.println("[" + reverseWordsBuggy(input) + "]"); System.out.println("[" + reverseWords(input) + "]"); System.out.println("[" + reverseWords(" ") + "]"); System.out.println("[" + reverseWords("one") + "]"); }}[blue is sky the ][blue is sky the][][one]The fixed version is O(n) time and O(n) space (the words array and the builder). The brackets in the output make the stray spaces visible: whenever you debug string output, wrap it in markers like this.
Exercise 5
Show solutionHide solution
Use two pointers from both ends, as in the palindrome check. At the first mismatch s[left] != s[right], one of those two characters must be the one we delete. Try both options: skip the left character (check that s[left + 1 .. right] is a palindrome) or skip the right character (check s[left .. right - 1]). If either works, the answer is true. If there is no mismatch at all, it is already a palindrome.
Why only two options? The deletion must fix this mismatch. If neither of the two mismatching characters is deleted, they would still face each other (or the same mismatch would survive), so the string could not be a palindrome. Each of the two checks takes at most n steps, and we only ever do them once, so the total is O(n). Extra space is O(1).
// File: Main.javapublic class Main { static boolean isPalindromeRange(String s, int left, int right) { while (left < right) { if (s.charAt(left) != s.charAt(right)) { return false; } left++; right--; } return true; } static boolean validAfterOneDeletion(String s) { int left = 0; int right = s.length() - 1; while (left < right) { if (s.charAt(left) != s.charAt(right)) { return isPalindromeRange(s, left + 1, right) || isPalindromeRange(s, left, right - 1); } left++; right--; } return true; } public static void main(String[] args) { String[] tests = {"aba", "abca", "abc", "deeee", "", "a", "abcdba"}; for (String test : tests) { System.out.println("[" + test + "] " + validAfterOneDeletion(test)); } }}[aba] true[abca] true[abc] false[deeee] true[] true[a] true[abcdba] trueFor "deeee", the first mismatch is d versus e. Skipping the left d leaves "eeee", a palindrome, so the answer is true. For "abcdba", the outer a and b pairs match, and the first mismatch is c versus d in the middle. Skipping the c leaves "abdba", which is a palindrome, so the answer is true. For "abc", the first mismatch is a versus c, and neither "bc" nor "ab" is a palindrome, so the answer is false.
Where to go next
#- Next: Linked Lists: Nodes, Pointers, and Pointer Reasoning introduces a structure that is not stored side by side.
- Soon: Hashing gives you map-based counting that works for any characters, and Sliding Window solves many substring problems in one pass.
- Practice habit: before you submit a string solution, test with an empty string, one character, all-same characters, and mixed case.
Connected lessons
Linked from 3 guides
Other pages in the library that point here.
Keep reading
More in Technology →Guide · Technology
How to Learn Data Structures and Algorithms Without Burning Out
Guide · Career
The OA Landscape: Platforms, Formats, Proctoring and Rules
Guide · Career
A LinkedIn Profile That Gets Recruiter Messages

