The String
An array of characters that cannot be edited. Reading any position costs O(1), but changing one character means copying the whole text. Underneath, a character is a number, and half the traps in this chapter come from the tables that turn numbers into characters.
Intuition: a line carved in stone
First see how close it is to an array, then accept the one rule that makes it different.
The array in the previous chapter is a row of whiteboard cells: to change one, you erase it and write again. A string is a row of characters carved into stone. Each character still has its own slot, numbered from 0, exactly like an array. But once carved, the slot cannot be rewritten. To turn "rain tomorrow" into "sun tomorrow" you carve a new stone and copy over the parts that did not change as well.
This rule is called immutability. It holds for Java String, Python str, and JavaScript strings alike. It is a deliberate design, not a defect, and the story card below explains why. What matters first is the consequence: every operation that appears to modify a string, whether concatenation, replacement, or a case change, actually copies the whole text. That single fact decides half the complexity results in this chapter.
One more thing to settle up front: the slots do not really hold characters. A computer only stores numbers. A character is what you see after a number is looked up in a character encoding: 65 shows as 'A', and 23383 shows as 字. Three rules follow.
The contents are fixed at creation. replace, toUpperCase, and concatenation all return a new string and leave the original untouched. The real cost of an "edit" is one full copy, O(n).
Every character is a number in an encoding table: 'A' = 65, 'a' = 97, 字 = 23383. Comparing, sorting, and changing case are all integer operations.
A string behaves like a character array you may read but not write: O(1) by index, plus iteration and slicing. Careful: one index is not always one character (see §02). To edit in place, convert to a real, mutable character array first.
Why did all three languages choose immutability?
Safety. Strings are the most common hash key (chapter 06). If the contents could change after a key was stored in a hash table, the entry could never be found again. Immutability also lets the hash code be computed once and cached. Sharing. Because the contents cannot change, several variables can safely point at the same copy, which is how the Java string pool and Python string interning save memory. No locking between threads. A value that never changes can be read by any number of threads at once. There is one cost: code that changes a string often becomes expensive, so all three languages offer a mutable stand-in, whether StringBuilder, a Python list, or a JavaScript array. In §04 you build one.
Memory and encoding: from ASCII to UTF-8
What a character really is in memory, told as the history of one table.
In 1963 ASCII gave the numbers 0 to 127 to the English letters, the digits, the punctuation, and a set of control codes. One character, one byte, and everything worked, as long as you only wrote English. Chinese has tens of thousands of characters and does not fit in 128 numbers, so each region built its own table (GB2312, Big5, Shift-JIS, and others). The same bytes opened in another country produced unreadable text. That is where the old problem of garbled characters comes from.
Unicode solves it in one move: put every character in the world into one list and give each a unique number, called a code point, written U+XXXX. 'A' is U+0041, 字 is U+5B57, and 🙂 is U+1F642. More than 150,000 code points have been assigned. But a code point is only a number. How that number is stored as bytes is a separate question, and that is the job of UTF-8, UTF-16, and UTF-32. Here is the single character 字 at every level.
| Level | 字 at this level | What it means |
|---|---|---|
| What you see | 字 | The shape drawn on screen (the glyph) |
| Unicode code point | U+5B57 (23383) | A globally unique number. Still only a number; nothing about storage yet. |
| UTF-8 (3 bytes) | E5 AD 97 | Variable length, 1 to 4 bytes. Most Chinese characters take 3. The standard for files and the network. |
| UTF-16 (2 bytes) | 5B 57 | 2 bytes for code points up to U+FFFF; above that, two units (a surrogate pair). Java and JavaScript index strings this way. |
| UTF-32 (4 bytes) | 00 00 5B 57 | Fixed 4 bytes. Indexing is simplest, space use is worst, so it is rarely written to disk. |
UTF-8 became the standard on the web because it is variable length: common characters get short encodings, rare ones get long encodings, and plain English text stored as UTF-8 is byte for byte identical to ASCII from 1963. The length is chosen from the code point in four bands.
| Code point range | UTF-8 bytes | Typical characters |
|---|---|---|
U+0000 – U+007F | 1 | English letters, digits, common punctuation (that is, ASCII) |
U+0080 – U+07FF | 2 | Latin extensions, Greek, Cyrillic, Arabic |
U+0800 – U+FFFF | 3 | Most Chinese characters, Japanese kana, Korean |
U+10000 – U+10FFFF | 4 | Emoji and rare historic scripts; these need a surrogate pair in UTF-16 |
The three languages index strings differently, and that decides what "length" means. Java and JavaScript index by UTF-16 code unit, so length counts 16-bit units. Python 3 str is a sequence of code points, so len counts code points. Note that a code point is still not always one visible character: an accented letter written as a base letter plus a combining mark is two code points, and a family emoji is several. The difference is invisible in plain English text and shows up the moment an emoji appears. Check it yourself below.
decimal 6541
decimal 23383E5AD97
decimal 128578F09F9982
.length, and why charAt returns only half of it. Java behaves the same way. This is the trap in §05.“Length” has three meanings, and all three have caused real bugs
Take the single emoji "👍". You see one character. In Java and JavaScript length is 2, because UTF-16 needs a surrogate pair. As UTF-8 it is 4 bytes. In Python len is 1, counting code points. Truncating a string by length, sizing a database column in bytes, and walking emoji text by index have each broken production systems. The habit to build: before you use a length, ask which unit it counts.
In practice: why files and networks are almost all UTF-8
HTML, JSON, HTTP, Git, and Linux filenames: the large majority of text on the internet travels as UTF-8. Three reasons. It is byte-compatible with ASCII, so old systems keep working. Text that is mostly English takes about half the space of UTF-16. And the byte stream is self-synchronizing: from any position you can find the next character boundary, because the leading byte and the continuation bytes have different bit patterns. Java and JavaScript chose UTF-16 in the 1990s, when two bytes per character looked like enough. Emoji broke that assumption, and surrogate pairs are the patch. Java 9 and later store many strings as Latin-1 bytes internally, but the API still counts UTF-16 code units, so the behavior you see does not change.
Core operations: every cost comes from copying
Reading is cheap and every edit is expensive. The key case: why += inside a loop is O(n²).
| Operation | Complexity | Why |
|---|---|---|
Read one position s[i] / charAt(i) | O(1) | Same address formula as an array. Java and JavaScript return one UTF-16 code unit; Python returns one code point. In a language that stores strings as UTF-8 bytes (Go, Rust), reaching the i-th character means walking the bytes, which is O(n). |
| Length | O(1) | The length is stored in a field when the string is created, so it is just a read. |
Concatenate s + t | O(n+m) | Immutability means nothing can be appended to s, so a new string of length n+m is allocated and both sides are copied into it. |
Slice / substring s[a..b] | O(k) | k characters are copied into a new string. Java before version 7 shared the original array and was O(1), but a small substring could keep a huge string alive; since Java 7 it copies. |
Compare contents equals / == / === | O(n) | Character by character, up to the last one in the worst case. Different lengths are rejected in O(1). Use equals in Java, == in Python, === in JavaScript. |
| Find a substring (naive) | O(n·m) | n starting positions, each comparing up to m characters. KMP in §04 brings it down to O(n + m). |
The row worth studying is concatenation. A single s + t is O(n+m), which sounds acceptable. The real problem is += inside a loop. Suppose you build a string of length n one character at a time.
The first += copies 1 character. The second copies 2, the old one plus the new one. The third copies 3. At step i the old string already has i−1 characters and all of them are copied again before the new one is added. The total is 1 + 2 + 3 + … + n = n(n+1)/2 ≈ n²/2. At n = 100,000 that is about 5 billion character copies. Each step looks harmless; the sum is not. This is the arithmetic-series trap: += on an immutable string inside a loop is O(n²).
The fix is a mutable stand-in: append into a mutable container, then build the string once at the end. The total returns to O(n). Watch the two strategies race.
The same trap in all three languages
By the rules of the language, the loop is O(n²) in Java, Python, and JavaScript alike, because a string cannot be extended in place. Java: the compiler turns s += x into a new StringBuilder plus toString() on every iteration, so the total stays O(n²). You have to lift the builder out of the loop yourself. Python: CPython has an optimization that extends the buffer in place when the string has exactly one reference, but the language does not guarantee it and it stops applying as soon as a second reference exists. The idiom is to collect the parts in a list and call "".join() once. JavaScript: V8 represents a + b as a small node that points at both halves and only flattens it when the value is read, so a loop of += is often faster than n² in practice, but the flattening and the peak memory are still paid, and no engine promises this. The conclusion is the same everywhere: use a mutable container explicitly rather than relying on an engine optimization.
Write it yourself: a builder, indexOf, and KMP
Two small implementations and one idea. KMP takes half of this section.
First: a string builder. It works exactly like the dynamic array from the previous chapter. Inside is a mutable character array. append writes into a free slot, which is amortized O(1), and when the array is full the capacity doubles and the contents move once. Only at the end does build copy everything into an immutable string. Reading this makes it clear why collecting first and building once is O(n).
"".join(list) already is one: collect the parts, then build the string in a single step.Second: naive substring search (indexOf). Find a pattern of length m inside a text of length n. Try each starting position, and on a mismatch move the start one step right and compare from the beginning again. The idea is direct, and the worst case is O(n·m).
The idea: KMP, where a failure is information. Where does the naive method lose time? Look at one typical failure. Search for ABABC inside the text ABABABC. The first four characters ABAB match and the fifth does not. The naive reaction is to move the start one step right and forget everything, comparing from the beginning again. But we already know that those four characters of the text are ABAB. That knowledge is thrown away.
text A B A B A B C
pattern A B A B C
✓ ✓ ✓ ✓ ✗ mismatch at j=4. Matched so far = "ABAB"
Naive: move the start 1 to the right, reset j to 0, compare from scratch
(the 4 characters just compared are compared all over again)
KMP: inside "ABAB", the longest overlap between its start and its end
is "AB", of length 2
→ slide the pattern until that overlap lines up, continue at j = 2,
and never move the pointer into the text backwards
text A B A B A B C
pattern A B A B C
✓ ✓ ✓ ✓ ✓ matchThe whole secret of KMP (Knuth–Morris–Pratt) is that one slide. When the comparison fails, the end of the part that already matched, ABAB, and the start of the pattern share a longest overlap, AB. Slide the pattern until that overlap lines up. The overlapping characters need no recheck, because they were just compared and are known to be equal, so the comparison resumes at the overlap length. The pointer into the text therefore never moves backwards, and the whole search is O(n + m).
"How long is the overlap between the start and the end of each prefix" can be computed in advance from the pattern alone and stored in an array. That array is the prefix function, written next or lps. Its definition is exact: next[i] is the length of the longest proper prefix of pattern[0..i] that is also a suffix of pattern[0..i]. "Proper" means it cannot be the whole of pattern[0..i], otherwise the answer would always be i+1. Here it is for ABABC.
| Prefix | Proper prefixes | Proper suffixes | Longest match | next |
|---|---|---|---|---|
A | (none) | (none) | — | 0 |
AB | A | B | — | 0 |
ABA | A, AB | A, BA | A | 1 |
ABAB | A, AB, ABA | B, AB, BAB | AB | 2 |
ABABC | A, AB, ABA, ABAB | C, BC, ABC, BABC | — | 0 |
Matching then follows one rule: when the comparison fails at j, set j to next[j−1] and try again; only when j is already 0 does the text pointer move forward. Why does this never skip a valid match? Because next gives the longest overlap, and every shorter alignment is reached by applying the rule again. If the first jump still fails, jump again, following the chain down to 0. The full implementation is below. The code that builds next looks almost the same as the code that matches, because building next is the pattern matching against itself.
Why KMP is O(n + m), even though the loops look nested
Building next is O(m), matching is O(n), so the total is O(n + m) with O(m) extra space. The inner while makes the matching loop look like O(n·m), but count the changes to j instead of the loops. Each turn of the outer loop increases j by at most 1, so over the whole run j increases at most n times. Every turn of the inner loop sets j to next[j−1], which is strictly smaller than j, so it decreases j by at least 1, and j never goes below 0. A value that rises at most n times in total and never falls below 0 can be lowered at most n times in total. So all the inner loops together run at most n times, and matching is O(n). The same argument with k and m covers the preprocessing. Interviews rarely ask you to write KMP from memory, but they do ask three questions: what does the naive method waste (the contents of the part that already matched), what is the next array (for each prefix, the length of its longest proper prefix that is also a suffix), and why does the text pointer never move back (after the slide the overlapping characters are known to be equal).
Three languages: one rule, three habits
The Java string pool, the Python join idiom, and the JavaScript emoji trap. The toolbar switches the code language for the whole site.
In all three languages strings are immutable, indexable, iterable, and rebuilt on every concatenation, so at the level of ideas they agree. What differs is the habits. Java has a string pool and the == trap. Python turns "collect, then join" into muscle memory. JavaScript has the most convenient template literals and the deepest UTF-16 emoji trap.
s[0] returns a str, not a number; use ord() for the code point. And str is a sequence of code points, so len("👍") = 1, the only one of the three languages that counts it as one.| Operation | Java (String) | Python (str) | JavaScript (string) | Complexity |
|---|---|---|---|---|
| Length | s.length() | len(s) | s.length | O(1) |
| Read one position | s.charAt(i) | s[i] | s[i] / charAt(i) | O(1) |
| Substring / slice | s.substring(a, b) | s[a:b] | s.slice(a, b) | O(k) |
| Find a substring | s.indexOf(t) | s.find(t) | s.indexOf(t) | O(n·m)† |
| Split | s.split(",") | s.split(",") | s.split(",") | O(n) |
| Join many pieces | String.join / StringBuilder | "".join(parts) | parts.join("") | O(n) |
| Change case | s.toLowerCase() | s.lower() | s.toLowerCase() | O(n) |
| Trim both ends | s.strip() / s.trim() | s.strip() | s.trim() | O(n) |
| Replace every match | s.replace(a, b) | s.replace(a, b) | s.replaceAll(a, b)* | O(n) |
| To a character array | s.toCharArray() | list(s) | [...s] / s.split("") | O(n) |
* In JavaScript, s.replace("a", "b") with a string argument replaces only the first match. Use replaceAll, or a regular expression with the g flag. This is the easiest mistake to make when coming from another language. † O(n·m) is the worst case of the naive scan, which is what Java indexOf does. CPython str.find uses a smarter algorithm with an O(n + m) worst case, and JavaScript engines vary. Also, every operation that returns a new string uses O(n) space.
In practice: the string pool, interning, and intern()
Letting equal strings share one copy in memory has a name: string interning. Java puts compile-time literals in the pool, and at run time you can ask for it with s.intern(). Python automatically interns short strings that look like identifiers, which is why "abc" is "abc" is sometimes True. That is an implementation detail, not a promise: use == to test equality and keep is for "the same object". JavaScript engines do the same sharing internally, but it is never visible, because === on strings compares the value. So the three languages differ exactly here: Java needs equals, Python needs == rather than is, and JavaScript has no equivalent trap. All of this rests on immutability from §01: nobody would dare share an object whose contents can change.
Three techniques: two pointers, sliding window, counting
★ Interview coreWhere most LeetCode string problems live. Three representative problems, taken apart step by step.
String techniques overlap heavily with array techniques, which is no surprise given how close the two structures are, but three of them belong here. Read the problem and look for the signal. Palindrome or reversal points to two pointers moving toward each other. The best contiguous substring points to a sliding window. Anagrams or character counts point to a counting array. These three cover most of the problem set in this chapter.
Start at both ends and compare one pair per step. The invariant: everything outside the range has already been checked. Standard for palindrome checks and in-place reversal. Expanding from the center is the same idea run backwards. See LC 125, 344, 5.
Use it when the answer is a contiguous substring and the condition inside the window can be updated step by step. Keep a Set or a counter inside the window, take in on the right and give up on the left. See LC 3, 438, 76.
When the alphabet is small and known (26 lowercase letters, or 128 ASCII codes), a fixed-size int array replaces a hash table: faster, smaller, shorter to write. See LC 242, 438, 383. For arbitrary Unicode input, go back to a hash map.
LC 125 · Valid Palindrome
EASYThe problem: considering only letters and digits and ignoring case, decide whether the string reads the same forwards and backwards. Brute force: build a filtered lowercase string, then compare it with its reverse. That is correct but builds two new strings of size O(n). Better: two pointers decide it in place, with no new string at all.
Complexity and follow-up questions
Time O(n), because each character is examined by l or r at most once, and extra space O(1). Compare that with the filter-and-reverse solution, which needs O(n) extra space. That difference is the point of doing it in place. Follow-up one: "what if you may delete at most one character?" (LC 680: on a mismatch, try the two branches l+1 and r−1 and continue each). Follow-up two: "why does the inner while also test l < r?" (a string made entirely of punctuation would run a pointer past the end; watching the boundary is the basic skill of two-pointer code).
LC 3 · Longest Substring Without Repeating Characters
MEDIUMThe problem: find the length of the longest contiguous substring with no repeated character. Brute force: enumerate every start and end and check each substring for a repeat, O(n³), or O(n²) with a Set. Better: the two sliding-window signals are both present, contiguous and incrementally maintainable. The key insight: while the window [l..r] has no repeat, a new duplicate only requires dropping characters from the left, and l never has to move back, because any earlier start still contains the duplicate that was just removed.
Complexity and follow-up questions
The loop invariant: at the end of every step, s[l..r] contains no repeated character. Both l and r move at most n steps forward, and each character enters and leaves the Set once, so the time is O(n) and the space is O(min(n, size of the alphabet)). Follow-up one: "can it be faster?" Store the last position of each character in a hash map and jump l straight past it, which removes the inner loop. Follow-up two: "what if the input contains emoji?" §02 answers it: in JavaScript, split with Array.from(s) first, otherwise a surrogate pair is cut into two halves that are not characters.
LC 5 · Longest Palindromic Substring
MEDIUMThe problem: find the longest contiguous substring that is a palindrome. Note that you have to find it, not just check one. Brute force: enumerate all O(n²) substrings and spend O(n) checking each, giving O(n³). Better: change what you enumerate. Instead of substrings, enumerate the centers. A palindrome has a useful property: remove one character from each end and it is still a palindrome. Read backwards, that says you can grow outward from a center until the two sides disagree, and that is the longest palindrome at this center. There are only 2n−1 centers, n characters plus n−1 gaps.
Complexity and follow-up questions
The invariant inside expand: before each step, s[l+1..r−1] is already a palindrome, so s[l..r] is a palindrome exactly when s[l] equals s[r]. 2n−1 centers, each growing at most O(n) layers, gives time O(n²) and extra space O(1). Follow-up one: "is anything faster?" Yes, Manacher's algorithm is O(n). It inserts separators between characters so odd and even cases become one, and reuses the symmetry of palindromes already found. It is hard to write, so naming it and describing the idea is usually enough. Follow-up two: "what about dynamic programming?" It works: dp[i][j] says whether s[i..j] is a palindrome. Also O(n²) time, but O(n²) space, so expanding around the center wins on memory.
Problem set: 10 string problems
Hot 100 selectionTwo pointers, then counting, then sliding window, then KMP, from easy to hard. Your progress is stored locally. Think for 30 seconds before opening the hint.
Chapter quiz
✎ Chapter quizGet all 7 right to turn on the green dot for this chapter.
What does it mean exactly to say that a string is immutable?
You run s += c n times in a loop, appending one character each time. What is the total time complexity?
In Java, what is the correct way to compare the contents of two strings?
Which of these statements about UTF-8 are correct? (Select all that apply.)
Why does Python have no separate char type?
Which signal should make you think of a sliding window first?
In JavaScript, what is the value of "👍".length?
- A string is an immutable character array: reading is O(1), and every "edit" is a full copy. Concatenation is O(n+m), and += inside a loop is O(n²) in Java, Python, and JavaScript alike. Collect into a StringBuilder, a list, or an array, and build the string once at the end.
- A character is a number. Unicode assigns the number (the code point) and UTF-8, UTF-16, and UTF-32 decide how it is stored. Java and JavaScript index by UTF-16 code unit, so an emoji counts as 2, while Python 3 indexes by code point. Before you use a length, ask which unit it counts.
- Substring search: naive is O(n·m). KMP uses the prefix function, where next[i] is the length of the longest proper prefix of pattern[0..i] that is also a suffix, so the pointer into the text never moves back: O(n + m). Remember the three questions: what does brute force waste, what is next, and why is no backtracking needed.
- Three techniques: two pointers closing in (palindromes and reversal), sliding window (a contiguous substring whose condition updates incrementally), and a counting array (a small, known alphabet, instead of a hash table).
- Language differences worth memorizing: Java == compares references, equals compares contents; Python has no char type (a str of length 1) and concatenates with join, and
isis not==; JavaScript===compares string values, butcharAtsplits a surrogate pair, so handle emoji withfor...ofandcodePointAt.