Strings
Strings are arrays of characters you cannot change in place. Learn why += in a loop gets slow, how to build strings fast, and how to count letters.
On this page
Your script writes a sales report one line at a time: report += line, ten thousand times. On a small test it finishes instantly. On a full year of data it takes noticeably longer than it should, and the extra time grows much faster than the number of lines.
The code is not wrong. It is paying for one fact that is easy to forget: you cannot change a string. Every += may build a brand new one.
A string is an array of characters
Underneath, a string is an array of characters stored side by side. Everything you learned about arrays carries over. s[3] is O(1). s.length in JavaScript and len(s) in Python are O(1), because the length is stored. Looping over every character is O(n).
Each character is stored as a number, its character code. The lowercase letters a to z have the codes 97 to 122, in order. So subtracting the code of a turns any lowercase letter into a position from 0 to 25: c is 99 − 97 = 2. You will use that trick in a moment.
You cannot change a string in place
Strings in JavaScript and Python are immutable. Once made, they never change. s[0] = "J" does nothing in JavaScript (or throws, in strict mode) and raises an error in Python. Methods like toUpperCase(), replace() and slicing do not edit the string. They return a new one.
That is why report += line can be slow. The old report cannot grow, so the language may allocate a new string and copy the old report plus the new line into it. The first line is copied once, the second line twice, the third three times, and so on.
For n pieces that is 1 + 2 + … + n, about n²/2 copies: O(n²). Ten thousand lines means around 50 million line copies where ten thousand would do.
The fix is to collect the pieces in an array, which grows cheaply at the end, and glue them together once at the very end. join measures the total length first and copies each piece exactly once.
function buildReport(sales) {
const lines = [];
for (const sale of sales) {
lines.push(`${sale.item}: ${sale.amount}`); // cheap append to an array
}
return lines.join("\n"); // one copy of everything, at the end
}
buildReport([{ item: "tea", amount: 4 }, { item: "cake", amount: 7 }]);
// "tea: 4\ncake: 7"def build_report(sales):
lines = []
for sale in sales:
lines.append(f"{sale['item']}: {sale['amount']}") # cheap append to a list
return "\n".join(lines) # one copy of everything, at the end
build_report([{"item": "tea", "amount": 4}, {"item": "cake", "amount": 7}])
# "tea: 4\ncake: 7"To be fair to +=: modern JavaScript engines, and CPython in simple cases, often avoid the full copy behind the scenes. You cannot count on it, and the rule costs nothing to follow. In a loop, build a list and join it.
Counting letters with 26 boxes
Many string problems come down to "which characters appear, and how often?" Take this one: are two words anagrams, made of exactly the same letters? tea and eat are. tea and tee are not.
You could sort both words and compare, but there is a simpler way when the words use only lowercase letters. Keep 26 counters, one per letter. Walk the first word and add 1 for each letter. Walk the second word and subtract 1. If they are anagrams, every counter ends at 0. In the tracer only the counters for a, e and t are shown; the other 23 stay at 0 throughout.
if lengths differ: return falsecounts = 26 zerosfor ch in first: counts[ch - 'a'] += 1for ch in second: counts[ch - 'a'] -= 1return every count is 0- first0t1e2acounts
a e t 0 0 0 1Both words have 3 letters, so they might be anagrams. All counters start at 0.
- first0tch1e2acounts
a e t 0 0 1 2
tis letter 116 − 97 = 19. Add 1 to its counter. - first0t1ech2acounts
a e t 0 1 1 3
e: add 1. - first0t1e2achcounts
a e t 1 1 1 4
a: add 1. The counters now describe the first word: one a, one e, one t. - second0ech1a2tcounts
a e t 1 0 1 5Now the second word, subtracting.
e: its counter drops to 0. - second0e1ach2tcounts
a e t 0 0 1 6
a: subtract 1. - second0e1a2tchcounts
a e t 0 0 0 7
t: subtract 1. - second0e1a2tcounts
a e t 0 0 0 - result
- true
8Every counter is 0, so each letter appeared equally often in both words. They are anagrams. Cost: 6 letter visits plus 26 counter checks.
With tea and tee, the a counter would end at 1 and the e counter at −1. Any counter that is not 0 means some letter appears more often in one word than the other.
// Assumes both words use only lowercase a to z.
function isAnagram(first, second) {
if (first.length !== second.length) return false;
const counts = new Array(26).fill(0);
const A = "a".charCodeAt(0); // 97
for (const ch of first) counts[ch.charCodeAt(0) - A]++;
for (const ch of second) counts[ch.charCodeAt(0) - A]--;
return counts.every((c) => c === 0);
}
isAnagram("tea", "eat"); // true
isAnagram("tea", "tee"); // false# Assumes both words use only lowercase a to z.
def is_anagram(first, second):
if len(first) != len(second):
return False
counts = [0] * 26
for ch in first:
counts[ord(ch) - ord("a")] += 1
for ch in second:
counts[ord(ch) - ord("a")] -= 1
return all(c == 0 for c in counts)
is_anagram("tea", "eat") # True
is_anagram("tea", "tee") # False| Approach | Time | Space | Why |
|---|---|---|---|
| Read s[i] or the length | O(1) | O(1) | Same as an array. |
| Compare, copy or slice | O(n) | O(n) for a copy | Touches every character. |
| += in a loop of n pieces | O(n²) | O(n) | Can copy everything built so far on each step. |
| Collect parts, join once | O(n) | O(n) | Each character is copied once. |
| Anagram check with 26 counters | O(n) | O(1) | 26 counters, however long the words are. |
The 26 counters are O(1) space because their number never changes with the input. Sorting both words would also work, but costs O(n log n).
Common mistakes
Check yourself
A loop runs n times and does result += piece each time, where every piece is short. If each += copies the whole string, what is the total cost?
You run the counter check on "cab" and "dab". Which counters are not 0 at the end?
Why is the 26-counter anagram check O(1) in space, even for very long words?
What to practise next
The 26 counters only work because the alphabet is small and known in advance. To count words, user IDs or anything else, you need a structure that turns any key into a box number. That is a hash table.