Coding Interview Patterns Module 28 – String patterns
Strings in five languages
What length and indexing count in Python, JavaScript, Java, C++ and Go, how to build strings in linear time, and how to reverse text with emoji safely.
What you will learn
- Predict what length and indexing count in each language, whether code points, UTF-16 code units or bytes
- Build a string in linear time with join, StringBuilder, strings.Builder or an appending std::string
- Handle accents, emoji and flags when you reverse or compare text, without splitting a character
- Check which alphabet the input uses before choosing a unit, and state the unit your code counts
- Rebuild a line from its words and gaps in linear time
Before you start
What you’ll learn · Before you start
What you will learn
- Predict what length and indexing count in each language, whether code points, UTF-16 code units or bytes
- Build a string in linear time with join, StringBuilder, strings.Builder or an appending std::string
- Handle accents, emoji and flags when you reverse or compare text, without splitting a character
- Check which alphabet the input uses before choosing a unit, and state the unit your code counts
- Rebuild a line from its words and gaps in linear time
Before you start
On this page
Python, JavaScript, Java, C++ and Go all store text, but each counts it in its own unit. A Python str is a
sequence of Unicode code points. A JavaScript or Java string is a sequence of UTF-16 code units, where most characters
take one unit and most emoji take two. A Go string and a C++ std::string are sequences of bytes, which usually
hold UTF-8. So len, length and size can return four different numbers for the same name, and s[i] hands back
a whole character in one language, half an emoji in another and a single byte in a third. Four of the five also make
strings immutable, which turns s = s + piece inside a loop into a copy of everything built so far. This lesson runs
the same strings through all five languages: it measures them, times slow and fast ways to build them, and reverses
six names, most of which come out wrong until the last fix.
One text, four lengths
Unicode gives every character a number, its code point, written U+0041 for A and U+1F600 for 😀. A string stores its code points in an encoding. UTF-8 spends one to four bytes on each: one on an ASCII character, two on é (U+00E9), three on a Devanagari letter and four on 😀, following the ranges that RFC 3629 sets out. UTF-16 spends one 16-bit code unit on every code point up to U+FFFF and two, called a surrogate pair, on every code point above it, which is where most emoji are.
A third level sits on top of the code points. What a reader takes for one character can be several of them: a letter followed by a combining accent, a Devanagari consonant with its vowel sign, an emoji with a skin-tone modifier, or a flag spelled with two regional indicator letters. Unicode Standard Annex #29 calls these user-perceived characters grapheme clusters and gives the rules that find their boundaries. The diagram measures one short text all four ways.
One text, four lengths
Text description of the diagram
The diagram counts one short text four ways, in four rows from top to bottom. The text is the letter é followed by a smiling-face emoji, and the é is written as two code points: the letter e and a combining acute accent.
- A reader sees 2 characters, which Unicode calls grapheme clusters: é and the emoji.
- Underneath are 3 code points: U+0065 (the letter e), U+0301 (the combining accent) and U+1F600 (the emoji). Python's len counts these.
- In UTF-16 the text takes 4 code units: 0065, 0301, D83D and DE00. The emoji is above U+FFFF, so it needs a surrogate pair, D83D followed by DE00. Java's and JavaScript's length count these.
- In UTF-8 the text takes 7 bytes: 65 for the e, CC 81 for the accent and F0 9F 98 80 for the emoji. Go's len and C++'s size() count these.
The same text is 2, 3, 4 or 7 long depending on which unit a language counts.
Each language’s length counts one of these units, and its index operator returns one of them:
| Language | Length counts | Index gives |
|---|---|---|
| Python | code points | code point |
| JavaScript | UTF-16 units | code unit |
| Java | UTF-16 units | code unit |
| C++ | bytes | byte |
| Go | bytes | byte |
To walk the code points instead, write for ch in s in Python, for (const ch of s) in JavaScript,
s.codePoints() in Java and for i, r := range s in Go. The C++ standard library has nothing for it, so you decode
UTF-8 yourself, as the function by_code_point in the C++ reversal further down does.
Here are six strings measured in every language. Press Run on the Python or JavaScript tab to measure them yourself; the Java, C++ and Go programs come with the terminal session that built and ran them.
Python · py/lengths.py
"""What len() counts in Python (code points), next to the size of the same text in UTF-16 and in UTF-8."""
import unicodedata
SAMPLES = [
("Asha", "plain ASCII"),
("Jos\u00e9", "é as one code point, U+00E9"),
("Jose\u0301", "e followed by U+0301, a combining acute accent"),
("\u0928\u0947\u0939\u093e", "Neha in Devanagari: two letters, each with a vowel sign"),
("\U0001F600", "one emoji, U+1F600"),
("\U0001F1EE\U0001F1F3", "a flag: the regional indicator letters I and N"),
]
print("len UTF-16 UTF-8 text")
for text, note in SAMPLES:
utf16_units = len(text.encode("utf-16-le")) // 2 # two bytes per UTF-16 code unit
utf8_bytes = len(text.encode("utf-8"))
print(f"{len(text):3} {utf16_units:6} {utf8_bytes:5} {text} ({note})")
composed, decomposed = SAMPLES[1][0], SAMPLES[2][0]
print("an index gives one code point:", ascii(composed[3]), ascii(SAMPLES[4][0][0]))
print("the two Josés are equal:", composed == decomposed)
nfc = unicodedata.normalize
print("equal after NFC normalization:", nfc("NFC", composed) == nfc("NFC", decomposed)) Output
len UTF-16 UTF-8 text 4 4 4 Asha (plain ASCII) 4 4 5 José (é as one code point, U+00E9) 5 5 6 José (e followed by U+0301, a combining acute accent) 4 4 12 नेहा (Neha in Devanagari: two letters, each with a vowel sign) 1 2 4 😀 (one emoji, U+1F600) 2 4 8 🇮🇳 (a flag: the regional indicator letters I and N) an index gives one code point: '\xe9' '\U0001f600' the two Josés are equal: False equal after NFC normalization: True
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lengths.py
JavaScript · js/lengths.mjs
// What .length counts in JavaScript (UTF-16 code units), next to the code points that the string iterator walks over.
const samples = [
['Asha', 'plain ASCII'],
['Jos\u00e9', 'é as one code point, U+00E9'],
['Jose\u0301', 'e followed by U+0301, a combining acute accent'],
['\u0928\u0947\u0939\u093e', 'Neha in Devanagari: two letters, each with a vowel sign'],
['\u{1F600}', 'one emoji, U+1F600'],
['\u{1F1EE}\u{1F1F3}', 'a flag: the regional indicator letters I and N'],
];
console.log('length code points text');
for (const [text, note] of samples) {
console.log(`${String(text.length).padStart(6)} ${String([...text].length).padStart(11)} ${text} (${note})`);
}
const smile = samples[4][0];
console.log('an index gives one code unit:', JSON.stringify(smile[0]), 'codePointAt(0):', smile.codePointAt(0).toString(16));
const composed = samples[1][0];
const decomposed = samples[2][0];
console.log('the two Josés are equal:', composed === decomposed);
console.log('equal after NFC normalization:', composed.normalize('NFC') === decomposed.normalize('NFC')); Output
length code points text
4 4 Asha (plain ASCII)
4 4 José (é as one code point, U+00E9)
5 5 José (e followed by U+0301, a combining acute accent)
4 4 नेहा (Neha in Devanagari: two letters, each with a vowel sign)
2 1 😀 (one emoji, U+1F600)
4 2 🇮🇳 (a flag: the regional indicator letters I and N)
an index gives one code unit: "\ud83d" codePointAt(0): 1f600
the two Josés are equal: false
equal after NFC normalization: true
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node lengths.mjs
Runs on this device, in your browser. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Java · compiled/Lengths.java
// What length() counts in Java (UTF-16 code units), next to codePointCount and the size of the text in UTF-8.
// A Unicode escape in Java names one UTF-16 code unit, so the emoji and the flag are written as surrogate pairs.
import java.nio.charset.StandardCharsets;
import java.text.Normalizer;
public class Lengths {
public static void main(String[] args) {
String[][] samples = {
{"Asha", "plain ASCII"},
{"Jos\u00e9", "é as one code point, U+00E9"},
{"Jose\u0301", "e followed by U+0301, a combining acute accent"},
{"\u0928\u0947\u0939\u093e", "Neha in Devanagari: two letters, each with a vowel sign"},
{"\uD83D\uDE00", "one emoji, U+1F600"},
{"\uD83C\uDDEE\uD83C\uDDF3", "a flag: the regional indicator letters I and N"},
};
System.out.println("length code points UTF-8 text");
for (String[] sample : samples) {
String text = sample[0];
System.out.printf("%6d %11d %5d %s (%s)%n", text.length(), text.codePointCount(0, text.length()),
text.getBytes(StandardCharsets.UTF_8).length, text, sample[1]);
}
String smile = samples[4][0];
System.out.println("charAt(0) gives one code unit: " + Integer.toHexString(smile.charAt(0))
+ ", codePointAt(0): " + Integer.toHexString(smile.codePointAt(0)));
String composed = samples[1][0];
String decomposed = samples[2][0];
System.out.println("the two Josés are equal: " + composed.equals(decomposed));
System.out.println("equal after NFC normalization: " + Normalizer.normalize(composed, Normalizer.Form.NFC)
.equals(Normalizer.normalize(decomposed, Normalizer.Form.NFC)));
}
} C++ · compiled/lengths.cpp
// What size() counts in C++: the bytes of the string. clang stores ordinary string literals as UTF-8, so a code
// point takes 1 to 4 bytes; they are counted by hand, since every byte except a continuation byte (10xxxxxx) starts one.
#include <cstddef>
#include <cstdio>
#include <string>
#include <string_view>
std::size_t code_points(std::string_view s) {
std::size_t count = 0;
for (unsigned char byte : s) count += (byte & 0xC0) != 0x80;
return count;
}
int main() {
struct Sample {
std::string text;
const char* note;
};
const Sample samples[] = {
{"Asha", "plain ASCII"},
{"Jos\u00e9", "é as one code point, U+00E9"},
{"Jose\u0301", "e followed by U+0301, a combining acute accent"},
{"\u0928\u0947\u0939\u093e", "Neha in Devanagari: two letters, each with a vowel sign"},
{"\U0001F600", "one emoji, U+1F600"},
{"\U0001F1EE\U0001F1F3", "a flag: the regional indicator letters I and N"},
};
std::printf("size code points text\n");
for (const auto& [text, note] : samples) {
std::printf("%4zu %11zu %s (%s)\n", text.size(), code_points(text), text.c_str(), note);
}
const std::string& jose = samples[1].text;
std::printf("an index gives one byte: %d as char, %d as unsigned char\n", jose[3], static_cast<unsigned char>(jose[3]));
} Go · compiled/lengths.go
// What len counts in Go: the bytes of the string, which Go keeps as UTF-8. unicode/utf8 counts the code points
// (runes), and unicode/utf16 encodes them, to compare with Java and JavaScript.
package main
import (
"fmt"
"unicode/utf16"
"unicode/utf8"
)
func main() {
samples := []struct{ text, note string }{
{"Asha", "plain ASCII"},
{"Jos\u00e9", "é as one code point, U+00E9"},
{"Jose\u0301", "e followed by U+0301, a combining acute accent"},
{"\u0928\u0947\u0939\u093e", "Neha in Devanagari: two letters, each with a vowel sign"},
{"\U0001F600", "one emoji, U+1F600"},
{"\U0001F1EE\U0001F1F3", "a flag: the regional indicator letters I and N"},
}
fmt.Println("len runes UTF-16 text")
for _, s := range samples {
units := len(utf16.Encode([]rune(s.text)))
fmt.Printf("%3d %5d %6d %s (%s)\n", len(s.text), utf8.RuneCountInString(s.text), units, s.text, s.note)
}
word := "Jos\u00e9\U0001F600"
fmt.Println("an index gives one byte:", word[3])
fmt.Print("range gives each rune with the byte offset where it starts:")
for offset, r := range word {
fmt.Printf(" %d:%c", offset, r)
}
fmt.Println()
} Build and run all three · compiled/lengths_session.py
from session import run, versions
# Lengths.java runs as a single source file, lengths.cpp is built with the sanitizers on, and go run builds lengths.go.
versions("java -version", "clang++ --version", "go version")
run("""
java Lengths.java
clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined lengths.cpp -o lengths && ./lengths
go run lengths.go
""") Output
# openjdk version "25.0.4.1" LTS
# Apple clang version 21.0.0 (clang-2100.3.34.2)
# go version go1.27.1 darwin/arm64
$ java Lengths.java
length code points UTF-8 text
4 4 4 Asha (plain ASCII)
4 4 5 José (é as one code point, U+00E9)
5 5 6 José (e followed by U+0301, a combining acute accent)
4 4 12 नेहा (Neha in Devanagari: two letters, each with a vowel sign)
2 1 4 😀 (one emoji, U+1F600)
4 2 8 🇮🇳 (a flag: the regional indicator letters I and N)
charAt(0) gives one code unit: d83d, codePointAt(0): 1f600
the two Josés are equal: false
equal after NFC normalization: true
$ clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined lengths.cpp -o lengths && ./lengths
size code points text
4 4 Asha (plain ASCII)
5 4 José (é as one code point, U+00E9)
6 5 José (e followed by U+0301, a combining acute accent)
12 4 नेहा (Neha in Devanagari: two letters, each with a vowel sign)
4 1 😀 (one emoji, U+1F600)
8 2 🇮🇳 (a flag: the regional indicator letters I and N)
an index gives one byte: -61 as char, 195 as unsigned char
$ go run lengths.go
len runes UTF-16 text
4 4 4 Asha (plain ASCII)
5 4 4 José (é as one code point, U+00E9)
6 5 5 José (e followed by U+0301, a combining acute accent)
12 4 4 नेहा (Neha in Devanagari: two letters, each with a vowel sign)
4 1 2 😀 (one emoji, U+1F600)
8 2 4 🇮🇳 (a flag: the regional indicator letters I and N)
an index gives one byte: 195
range gives each rune with the byte offset where it starts: 0:J 1:o 2:s 3:é 5:😀
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lengths_session.py
Read the output one string at a time:
- Asha is 4 in every column. For ASCII text, bytes, code units, code points and characters are the same thing, which is why problems whose input is limited to English letters or printable ASCII never meet the rest of this lesson.
- The two Josés look the same and are different strings. The first stores é as one code point, U+00E9; the
second as the letter e followed by U+0301, a combining acute accent. Every length of the second is one larger, and
==,===andequalsall report false. Unicode Standard Annex #15 calls the two spellings canonically equivalent, and normalising both to NFC, the composed form, makes them equal. Python’sunicodedata.normalize, JavaScript’snormalize()and Java’sjava.text.Normalizerdo it. Go keeps normalisation outside its standard library, in the packagegolang.org/x/text/unicode/norm, and C++ needs a library such as ICU. - नेहा (Neha) is two characters to a reader and four code points, because each of its two letters carries a vowel sign. Every Devanagari code point takes three bytes in UTF-8, so the name is 12 bytes long.
- 😀 is one code point above U+FFFF. Python counts 1, JavaScript and Java count the 2 code units of its surrogate pair, and Go and C++ count its 4 UTF-8 bytes.
- 🇮🇳 is one flag and two code points, the regional indicator letters I and N, so it is 4 code units long in UTF-16 and 8 bytes long in UTF-8.
The last lines show what an index hands back. Python returns a whole code point as a string of length 1.
JavaScript’s smile[0] and Java’s charAt(0) both return d83d, the first half of the surrogate pair, which is no
character on its own; codePointAt(0) reads the whole pair. Go’s word[3] is the byte 195, the first of the two
bytes of é, while ranging over the string decodes UTF-8 and reports the byte where each rune starts, so é starts at 3
and the emoji at 5. C++ printed that same byte as −61: the standard leaves it to the compiler whether a plain char
is signed, so compare bytes as unsigned char. It also leaves the encoding of ordinary string literals to the
compiler, and clang stores them as UTF-8, which is why é takes two bytes in the C++ output.
Building a string without copying it on every pass
In Python, JavaScript, Java and Go a string never changes once it exists, and every operation that seems to change one
builds a new string. A loop that runs s = s + piece therefore copies the whole of s on every pass. Adding one
character n times copies 1 + 2 + … + n characters, about n²/2, so the work grows with the square of the final length.
The Python documentation states this quadratic cost for repeated concatenation of any immutable sequence. C++ is the
exception: a std::string can grow in place. The standard requires push_back to take amortised constant time, and
s += piece appends to s itself, while s = s + piece first copies s, as the standard’s definition of + spells
out.
Each language has a way to build a long string in linear time:
- Python: collect the pieces in a list and call
"".join(pieces)once, or write them to anio.StringIO. - JavaScript: push the pieces into an array and call
join(""), or use+=, which both engines measured below handle well. - Java: append to a
StringBuilderand calltoString()once. An append copies only the new piece, apart from the occasional enlargement of the builder’s buffer when it fills up. The Java toolkit lesson uses one to print a large answer in one go. - C++: use
s += pieceors.push_back(c), ands.reserve(n)first when you know the final size. - Go: write into a
strings.Builder, whose documentation says it minimises memory copying, and callString()once.
The next programs build a string of 100,000 characters several ways and time each way as the best of three runs. The times differ from one run and one computer to the next, so each program ends with comparisons that do not.
"""Building strings in Python, each way timed as the best of three runs.
Part 1 builds 100,000 characters four ways. Part 2 appends 20,000 lines of 50 characters with +=, once inside a
function and once at the top level of this script. The times change from run to run and from computer to computer;
the comparisons do not.
"""
import time
N = 100_000
LINE = "x" * 49 + "\n" # one line of 50 characters
def append(n):
"""s += piece: CPython can often grow s where it is."""
s = ""
for _ in range(n):
s += "x"
return s
def prepend(n):
"""piece + s: a new string, and a copy of s, on every pass."""
s = ""
for _ in range(n):
s = "x" + s
return s
def append_while_shared(n):
"""s += piece while a second name refers to the same string."""
s = ""
for _ in range(n):
previous = s # a second reference to the same string
s += "x"
return s
def join_at_end(n):
"""Collect the pieces in a list and join them once."""
pieces = []
for _ in range(n):
pieces.append("x")
return "".join(pieces)
def append_lines(count):
report = ""
for _ in range(count):
report += LINE
return report
def best_of_three(build, size, length):
times = []
for _ in range(3):
start = time.perf_counter()
text = build(size)
times.append(time.perf_counter() - start)
assert len(text) == length
return min(times)
print("Part 1: 100,000 characters, one at a time")
best = {}
for build in (append, prepend, append_while_shared, join_at_end):
best[build.__name__] = best_of_three(build, N, N)
print(f" {build.__name__:20} {best[build.__name__] * 1000:8.1f} ms")
join_time = best["join_at_end"]
print(" prepend took over 10 times as long as join_at_end:", best["prepend"] > 10 * join_time)
print(" append_while_shared took over 10 times as long:", best["append_while_shared"] > 10 * join_time)
print(" append stayed within 10 times of join_at_end:", best["append"] < 10 * join_time)
print("Part 2: 20,000 lines of 50 characters with +=")
LINES = 20_000
in_function = best_of_three(append_lines, LINES, LINES * len(LINE))
top_level = float("inf")
for _ in range(3):
start = time.perf_counter()
report = "" # a global variable: this loop is not inside a function
for _ in range(LINES):
report += LINE
top_level = min(top_level, time.perf_counter() - start)
assert len(report) == LINES * len(LINE)
print(f" inside a function {in_function * 1000:8.1f} ms")
print(f" at the top level {top_level * 1000:8.1f} ms")
print(" at the top level += took over 10 times as long:", top_level > 10 * in_function) Output
Part 1: 100,000 characters, one at a time append 5.5 ms prepend 190.9 ms append_while_shared 332.0 ms join_at_end 2.5 ms prepend took over 10 times as long as join_at_end: True append_while_shared took over 10 times as long: True append stayed within 10 times of join_at_end: True Part 2: 20,000 lines of 50 characters with += inside a function 1.2 ms at the top level 291.8 ms at the top level += took over 10 times as long: True
This output changes from run to run: the times depend on the computer and on what else it is running
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 build.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
// Three ways to build a string of 100,000 characters in JavaScript, each timed as the best of three runs. The times
// change from run to run and from computer to computer; the comparison at the end does not.
const N = 100_000;
function append(n) {
let s = '';
for (let i = 0; i < n; i++) s += 'x';
return s;
}
function prepend(n) {
let s = '';
for (let i = 0; i < n; i++) s = 'x' + s;
return s;
}
function joinAtEnd(n) {
const pieces = [];
for (let i = 0; i < n; i++) pieces.push('x');
return pieces.join('');
}
function bestOfThree(build) {
let best = Infinity;
for (let run = 0; run < 3; run++) {
const start = Date.now();
const text = build(N);
best = Math.min(best, Date.now() - start);
if (text.length !== N) throw new Error(`${build.name} built ${text.length} characters`);
}
return best;
}
const times = { append: bestOfThree(append), prepend: bestOfThree(prepend), joinAtEnd: bestOfThree(joinAtEnd) };
for (const [name, ms] of Object.entries(times)) console.log(`${name.padEnd(9)} ${String(ms).padStart(5)} ms`);
console.log('all three finished in under a second:', Object.values(times).every((ms) => ms < 1000)); Output
append 0 ms prepend 1 ms joinAtEnd 1 ms all three finished in under a second: true
This output changes from run to run: the times depend on the computer and on what else it is running
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node build.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Java · compiled/Build.java
// Building a string of 100,000 characters in Java. += makes a new String, a copy of everything so far, on every
// pass; a StringBuilder appends to one buffer. The times change from run to run; the comparison at the end does not.
public class Build {
static final int N = 100_000;
public static void main(String[] args) {
long start = System.nanoTime();
String s = "";
for (int i = 0; i < N; i++) {
s += "x";
}
long plus = System.nanoTime() - start;
start = System.nanoTime();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < N; i++) {
sb.append('x');
}
String built = sb.toString();
long builder = System.nanoTime() - start;
System.out.println("+= in a loop: " + plus / 1_000_000 + " ms");
System.out.println("StringBuilder: " + builder / 1_000_000 + " ms");
System.out.println("same text: " + s.equals(built));
System.out.println("+= took over 10 times as long: " + (plus > 10 * builder));
}
} C++ · compiled/build.cpp
// Building a string of 100,000 characters in C++. A std::string can change in place, so s += 'x' appends to it;
// s = s + 'x' builds a new string, a copy of everything so far, on every pass. The times change from run to run;
// the comparison at the end does not.
#include <chrono>
#include <cstdio>
#include <string>
int main() {
constexpr int n = 100'000;
using clock = std::chrono::steady_clock;
auto start = clock::now();
std::string copied;
for (int i = 0; i < n; ++i) copied = copied + 'x';
const auto copy_time = clock::now() - start;
start = clock::now();
std::string appended;
for (int i = 0; i < n; ++i) appended += 'x';
const auto append_time = clock::now() - start;
auto ms = [](auto d) { return static_cast<long long>(std::chrono::duration_cast<std::chrono::milliseconds>(d).count()); };
std::printf("s = s + 'x': %lld ms\n", ms(copy_time));
std::printf("s += 'x': %lld ms\n", ms(append_time));
std::printf("same text: %s\n", copied == appended ? "true" : "false");
std::printf("s = s + 'x' took over 10 times as long: %s\n", copy_time > 10 * append_time ? "true" : "false");
} Go · compiled/build.go
// Building a string of 100,000 bytes in Go. += makes a new string, a copy of everything so far, on every pass; a
// strings.Builder appends to one buffer. The times change from run to run; the comparison at the end does not.
package main
import (
"fmt"
"strings"
"time"
)
const n = 100_000
func main() {
start := time.Now()
s := ""
for range n {
s += "x"
}
plus := time.Since(start)
start = time.Now()
var b strings.Builder
for range n {
b.WriteByte('x')
}
built := b.String()
builder := time.Since(start)
fmt.Println("+= in a loop: ", plus.Milliseconds(), "ms")
fmt.Println("strings.Builder:", builder.Milliseconds(), "ms")
fmt.Println("same text:", s == built)
fmt.Println("+= took over 10 times as long:", plus > 10*builder)
} Build and run all three · compiled/build_session.py
from session import run, versions
# Build.java runs as a single source file, build.cpp is built with the sanitizers on, and go run builds build.go.
versions("java -version", "clang++ --version", "go version")
run("""
java Build.java
clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined build.cpp -o build && ./build
go run build.go
""") Output
# openjdk version "25.0.4.1" LTS # Apple clang version 21.0.0 (clang-2100.3.34.2) # go version go1.27.1 darwin/arm64 $ java Build.java += in a loop: 1595 ms StringBuilder: 5 ms same text: true += took over 10 times as long: true $ clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined build.cpp -o build && ./build s = s + 'x': 2531 ms s += 'x': 0 ms same text: true s = s + 'x' took over 10 times as long: true $ go run build.go += in a loop: 868 ms strings.Builder: 0 ms same text: true += took over 10 times as long: true
This output changes from run to run: the times depend on the computer and on what else it is running
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 build_session.py
Java’s +=, Go’s += and C++’s s = s + 'x' each took far longer than the builder, much more than the tenfold
the programs check for, because each pass copied the whole string again. Python is the surprising one. Prepending
took over ten times as long as joining, as the documentation predicts, yet plain appending with += kept pace with
join. CPython 3.14 turns s += piece into an append in place, without a new string, but only when two conditions
hold: s is a local variable of a function, and nothing else refers to the string. Its source code says the purpose
is to spare programs that forget str.join() from quadratic time. Part 1 breaks the second condition with one extra
name, previous = s. Part 2 breaks the first by running the same loop at the top level of the script, where report
is a global variable: 20,000 lines took far longer there than inside a function. Both times += went back to copying
the whole string on every pass. PEP 8 asks code not to rely on the optimisation for this reason: it is fragile even
in CPython, and Python implementations without reference counting do not have it.
In JavaScript, both Node.js and QuickJS, the engine behind the Run button, handled += quickly in either direction,
and all three ways finished in well under a second. The language specification describes what + returns, not how
long it takes, so that speed belongs to the engines, while join is linear by construction in every language.
Reversing names with accents and emoji
“Reverse a string” is a classic warm-up, and a natural follow-up asks what happens with emoji. These programs reverse six names in each language’s usual ways and compare every result with what a reader expects: the characters they see, in reverse order.
"""Reversing names in Python. Slicing with [::-1] reverses the code points. A reader expects the characters they see
(grapheme clusters) to come back in reverse order, which is the second string of each pair."""
NAMES = [
("Asha", "ahsA"),
("Jos\u00e9", "\u00e9soJ"),
("Jose\u0301", "e\u0301soJ"),
("\u0928\u0947\u0939\u093e", "\u0939\u093e\u0928\u0947"),
("Kabir \U0001F44B\U0001F3FD", "\U0001F44B\U0001F3FD ribaK"),
("Priya \U0001F1EE\U0001F1F3", "\U0001F1EE\U0001F1F3 ayirP"),
]
right = 0
for name, expected in NAMES:
result = name[::-1]
right += result == expected
print(f"{name} -> {result} {'right' if result == expected else 'wrong'}")
print(f"[::-1] reversed {right} of {len(NAMES)} names as a reader expects") Output
Asha -> ahsA right José -> ésoJ right José -> ́esoJ wrong नेहा -> ाहेन wrong Kabir 👋🏽 -> 🏽👋 ribaK wrong Priya 🇮🇳 -> 🇳🇮 ayirP wrong [::-1] reversed 2 of 6 names as a reader expects
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 reverse.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Slicing with [::-1] reverses code points, and it got two names right. The other four broke in four different ways:
- The combining accent of the second José came first, with no letter to sit on, and the e lost its accent.
- The vowel signs of नेहा landed in front of their consonants, so the result starts with a sign that has nothing to attach to.
- Kabir’s skin-tone modifier moved in front of the hand, so the hand lost its skin tone and the modifier shows as a separate swatch.
- The flag of India is the letters I and N. Reversed, they read N and I, and Unicode’s emoji data lists that pair as the flag of Nicaragua.
Python 3.14’s standard library has no function for grapheme clusters. JavaScript has one built in, and the second program compares it with the two usual ways:
// Reversing names in JavaScript three ways: by UTF-16 code unit, by code point and by grapheme cluster. The second
// string of each pair is what a reader expects: the characters they see, in reverse order.
const names = [
['Asha', 'ahsA'],
['Jos\u00e9', '\u00e9soJ'],
['Jose\u0301', 'e\u0301soJ'],
['\u0928\u0947\u0939\u093e', '\u0939\u093e\u0928\u0947'],
['Kabir \u{1F44B}\u{1F3FD}', '\u{1F44B}\u{1F3FD} ribaK'],
['Priya \u{1F1EE}\u{1F1F3}', '\u{1F1EE}\u{1F1F3} ayirP'],
];
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const ways = [
["split('')", (s) => s.split('').reverse().join('')],
['[...s]', (s) => [...s].reverse().join('')],
['Intl.Segmenter', (s) => Array.from(segmenter.segment(s), (part) => part.segment).reverse().join('')],
];
for (const [label, reverse] of ways) {
let right = 0;
for (const [name, expected] of names) {
const result = reverse(name);
if (result === expected) right++;
console.log(`${label}: ${name} -> ${result} ${result === expected ? 'right' : 'wrong'}`);
}
console.log(`${label} reversed ${right} of ${names.length} names as a reader expects`);
} Output
split(''): Asha -> ahsA right
split(''): José -> ésoJ right
split(''): José -> ́esoJ wrong
split(''): नेहा -> ाहेन wrong
split(''): Kabir 👋🏽 -> �🁋� ribaK wrong
split(''): Priya 🇮🇳 -> �🇮� ayirP wrong
split('') reversed 2 of 6 names as a reader expects
[...s]: Asha -> ahsA right
[...s]: José -> ésoJ right
[...s]: José -> ́esoJ wrong
[...s]: नेहा -> ाहेन wrong
[...s]: Kabir 👋🏽 -> 🏽👋 ribaK wrong
[...s]: Priya 🇮🇳 -> 🇳🇮 ayirP wrong
[...s] reversed 2 of 6 names as a reader expects
Intl.Segmenter: Asha -> ahsA right
Intl.Segmenter: José -> ésoJ right
Intl.Segmenter: José -> ésoJ right
Intl.Segmenter: नेहा -> हाने right
Intl.Segmenter: Kabir 👋🏽 -> 👋🏽 ribaK right
Intl.Segmenter: Priya 🇮🇳 -> 🇮🇳 ayirP right
Intl.Segmenter reversed 6 of 6 names as a reader expects
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node reverse.mjs
split('')splits into UTF-16 code units, so the surrogate pairs came apart, and a half left on its own prints as �. In Kabir’s name two halves from different emoji met in the middle and formed a valid pair, which is the domino tile 🁋 (U+1F04B), a character nobody typed.[...s]iterates code points, so it does exactly as well as Python’s slice, and no better.Intl.Segmenterwithgranularity: 'grapheme'splits the text into grapheme clusters, and reversing those got all six names right.
This example shows recorded output from Node.js only. The Run button runs JavaScript in QuickJS, a small engine that
has no Intl object; your browser’s own engine has Intl.Segmenter, and the JavaScript compiler linked below runs
code there.
The same three levels exist in Java, while C++ and Go stop at code points:
Java · compiled/Reverse.java
// Reversing names in Java three ways: by UTF-16 code unit (swapping the chars of an array), by code point
// (StringBuilder.reverse keeps each surrogate pair together) and by grapheme cluster (the regular expression \X).
import java.util.List;
import java.util.function.UnaryOperator;
import java.util.regex.MatchResult;
import java.util.regex.Pattern;
public class Reverse {
static final String[][] NAMES = {
{"Asha", "ahsA"},
{"Jos\u00e9", "\u00e9soJ"},
{"Jose\u0301", "e\u0301soJ"},
{"\u0928\u0947\u0939\u093e", "\u0939\u093e\u0928\u0947"},
{"Kabir \uD83D\uDC4B\uD83C\uDFFD", "\uD83D\uDC4B\uD83C\uDFFD ribaK"},
{"Priya \uD83C\uDDEE\uD83C\uDDF3", "\uD83C\uDDEE\uD83C\uDDF3 ayirP"},
};
static String byCodeUnit(String s) {
char[] a = s.toCharArray();
for (int i = 0, j = a.length - 1; i < j; i++, j--) {
char t = a[i];
a[i] = a[j];
a[j] = t;
}
return new String(a);
}
static String byCodePoint(String s) {
return new StringBuilder(s).reverse().toString();
}
static final Pattern GRAPHEME = Pattern.compile("\\X");
static String byGrapheme(String s) {
List<String> clusters = GRAPHEME.matcher(s).results().map(MatchResult::group).toList();
return String.join("", clusters.reversed());
}
static void report(String label, UnaryOperator<String> reverse) {
int right = 0;
for (String[] pair : NAMES) {
String result = reverse.apply(pair[0]);
boolean ok = result.equals(pair[1]);
if (ok) right++;
System.out.println(label + ": " + pair[0] + " -> " + result + " " + (ok ? "right" : "wrong"));
}
System.out.println(label + " reversed " + right + " of " + NAMES.length + " names as a reader expects");
}
public static void main(String[] args) {
report("char[] swap", Reverse::byCodeUnit);
report("StringBuilder.reverse", Reverse::byCodePoint);
report("regex \\X", Reverse::byGrapheme);
}
} C++ · compiled/reverse.cpp
// Reversing names in C++ two ways: std::ranges::reverse on the bytes, and by code point, keeping each UTF-8
// sequence (a lead byte and its continuation bytes, 10xxxxxx) together. The standard library has no grapheme clusters.
#include <algorithm>
#include <cstdio>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
std::string by_byte(std::string_view s) {
std::string out(s);
std::ranges::reverse(out);
return out;
}
std::string by_code_point(std::string_view s) {
std::vector<std::string_view> points;
for (std::size_t i = 0; i < s.size();) {
std::size_t j = i + 1;
while (j < s.size() && (static_cast<unsigned char>(s[j]) & 0xC0) == 0x80) ++j;
points.push_back(s.substr(i, j - i));
i = j;
}
std::string out;
out.reserve(s.size());
for (auto it = points.rbegin(); it != points.rend(); ++it) out += *it;
return out;
}
const std::pair<std::string, std::string> names[] = {
{"Asha", "ahsA"},
{"Jos\u00e9", "\u00e9soJ"},
{"Jose\u0301", "e\u0301soJ"},
{"\u0928\u0947\u0939\u093e", "\u0939\u093e\u0928\u0947"},
{"Kabir \U0001F44B\U0001F3FD", "\U0001F44B\U0001F3FD ribaK"},
{"Priya \U0001F1EE\U0001F1F3", "\U0001F1EE\U0001F1F3 ayirP"},
};
void report(const char* label, std::string (*reverse)(std::string_view)) {
int right = 0;
for (const auto& [name, expected] : names) {
const std::string result = reverse(name);
const bool ok = result == expected;
right += ok;
std::printf("%s: %s -> %s %s\n", label, name.c_str(), result.c_str(), ok ? "right" : "wrong");
}
std::printf("%s reversed %d of %zu names as a reader expects\n", label, right, std::size(names));
}
int main() {
report("std::ranges::reverse", by_byte);
report("by code point", by_code_point);
} Go · compiled/reverse.go
// Reversing names in Go two ways: the bytes of the string, and its runes (code points). The standard library has
// no grapheme clusters.
package main
import (
"fmt"
"slices"
)
func byByte(s string) string {
b := []byte(s)
slices.Reverse(b)
return string(b)
}
func byRune(s string) string {
r := []rune(s)
slices.Reverse(r)
return string(r)
}
func main() {
names := [][2]string{
{"Asha", "ahsA"},
{"Jos\u00e9", "\u00e9soJ"},
{"Jose\u0301", "e\u0301soJ"},
{"\u0928\u0947\u0939\u093e", "\u0939\u093e\u0928\u0947"},
{"Kabir \U0001F44B\U0001F3FD", "\U0001F44B\U0001F3FD ribaK"},
{"Priya \U0001F1EE\U0001F1F3", "\U0001F1EE\U0001F1F3 ayirP"},
}
ways := []struct {
label string
reverse func(string) string
}{{"[]byte", byByte}, {"[]rune", byRune}}
for _, way := range ways {
right := 0
for _, pair := range names {
result := way.reverse(pair[0])
verdict := "wrong"
if result == pair[1] {
verdict = "right"
right++
}
fmt.Printf("%s: %s -> %s %s\n", way.label, pair[0], result, verdict)
}
fmt.Printf("%s reversed %d of %d names as a reader expects\n", way.label, right, len(names))
}
} Build and run all three · compiled/reverse_session.py
from session import run, versions
# Reverse.java runs as a single source file, reverse.cpp is built with the sanitizers on, and go run builds reverse.go.
versions("java -version", "clang++ --version", "go version")
run("""
java Reverse.java
clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined reverse.cpp -o reverse && ./reverse
go run reverse.go
""") Output
# openjdk version "25.0.4.1" LTS # Apple clang version 21.0.0 (clang-2100.3.34.2) # go version go1.27.1 darwin/arm64 $ java Reverse.java char[] swap: Asha -> ahsA right char[] swap: José -> ésoJ right char[] swap: José -> ́esoJ wrong char[] swap: नेहा -> ाहेन wrong char[] swap: Kabir 👋🏽 -> ?🁋? ribaK wrong char[] swap: Priya 🇮🇳 -> ?🇮? ayirP wrong char[] swap reversed 2 of 6 names as a reader expects StringBuilder.reverse: Asha -> ahsA right StringBuilder.reverse: José -> ésoJ right StringBuilder.reverse: José -> ́esoJ wrong StringBuilder.reverse: नेहा -> ाहेन wrong StringBuilder.reverse: Kabir 👋🏽 -> 🏽👋 ribaK wrong StringBuilder.reverse: Priya 🇮🇳 -> 🇳🇮 ayirP wrong StringBuilder.reverse reversed 2 of 6 names as a reader expects regex \X: Asha -> ahsA right regex \X: José -> ésoJ right regex \X: José -> ésoJ right regex \X: नेहा -> हाने right regex \X: Kabir 👋🏽 -> 👋🏽 ribaK right regex \X: Priya 🇮🇳 -> 🇮🇳 ayirP right regex \X reversed 6 of 6 names as a reader expects $ clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined reverse.cpp -o reverse && ./reverse std::ranges::reverse: Asha -> ahsA right std::ranges::reverse: José -> ��soJ wrong std::ranges::reverse: José -> ��esoJ wrong std::ranges::reverse: नेहा -> �����ਤ� wrong std::ranges::reverse: Kabir 👋🏽 -> �������� ribaK wrong std::ranges::reverse: Priya 🇮🇳 -> ���𮇟� ayirP wrong std::ranges::reverse reversed 1 of 6 names as a reader expects by code point: Asha -> ahsA right by code point: José -> ésoJ right by code point: José -> ́esoJ wrong by code point: नेहा -> ाहेन wrong by code point: Kabir 👋🏽 -> 🏽👋 ribaK wrong by code point: Priya 🇮🇳 -> 🇳🇮 ayirP wrong by code point reversed 2 of 6 names as a reader expects $ go run reverse.go []byte: Asha -> ahsA right []byte: José -> ��soJ wrong []byte: José -> ��esoJ wrong []byte: नेहा -> �����ਤ� wrong []byte: Kabir 👋🏽 -> �������� ribaK wrong []byte: Priya 🇮🇳 -> ���𮇟� ayirP wrong []byte reversed 1 of 6 names as a reader expects []rune: Asha -> ahsA right []rune: José -> ésoJ right []rune: José -> ́esoJ wrong []rune: नेहा -> ाहेन wrong []rune: Kabir 👋🏽 -> 🏽👋 ribaK wrong []rune: Priya 🇮🇳 -> 🇳🇮 ayirP wrong []rune reversed 2 of 6 names as a reader expects
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 reverse_session.py
- Java. Swapping the
chars of an array reverses code units, with the same damage assplit(''); Java’s UTF-8 encoder printed each lone surrogate as?.StringBuilder.reverse()treats a surrogate pair as one character, as its documentation promises, so it matches Python. The regular expression\Xmatches one extended grapheme cluster, and reversing the list of its matches got all six right.BreakIterator.getCharacterInstance()is the other built-in way: its documentation says it follows the same extended grapheme cluster rules. - C++ and Go. Reversing the bytes breaks every character that is not ASCII, because a UTF-8 sequence read
backwards starts with a continuation byte, which can never start a character. Only Asha survived; the � marks show
bytes that are no longer valid UTF-8, and a few reversed byte runs happened to form valid but unrelated characters.
Reversing Go’s
[]rune, or the decoded code points in C++, matches Python. Neither standard library knows grapheme clusters: in C++ the usual tool is ICU’sBreakIterator, and Go needs a third-party package.
New in Python 3.15
Python 3.15 adds unicodedata.iter_graphemes(), which splits a string into grapheme clusters by the extended rules
of Unicode Standard Annex #29; converting each item it yields with str() gives the cluster’s text. The examples on
this page run on Python 3.14.8, which does not have it, and on an online judge with an older Python a call to it
fails with an AttributeError.
Over the six names, reversing bytes got 1 right, reversing UTF-16 code units 2, reversing code points 2, and
reversing grapheme clusters all 6. The tools that reverse what a reader sees are Intl.Segmenter in JavaScript, the
regular expression \X or BreakIterator in Java, iter_graphemes in unicodedata from Python 3.15, a library
such as ICU in C++, and a third-party package in Go.
Settle the alphabet before you code
Most coding problems pin the alphabet down in their constraints, with lines such as “s consists of lowercase English letters” or “printable ASCII characters”. Under such a constraint bytes, code units, code points and characters are all the same, and every language’s indexing, slicing and reversing is correct. When the statement leaves it open, ask. If the answer is “any text a user can type”, say which unit your solution counts, and why:
- Code points are enough when the task only moves text around without splitting characters, such as counting the frequency of each code point, or comparing two strings after normalising them.
- Grapheme clusters are needed when the task is about what a person sees: reversing for display, cutting a name to 20 characters, moving a cursor or counting characters for a limit shown to the user.
- Bytes are the unit of storage and transfer: a file, a network message, a database column or an API whose limit is stated in bytes.
The alphabet is one of the questions on the checklist of edge cases that is worth settling before any code is written.
Splitting at an ASCII separator is safe in every language, whatever the text holds. In UTF-8 the bytes of ASCII
characters never appear inside the encoding of any other character, as RFC 3629 points out, and in UTF-16 the two
halves of a surrogate pair lie in the range D800 to DFFF, so a unit with the value of a space is always a space. A
search for " " or "," therefore never cuts an emoji or a Devanagari letter in half, and that is what the exercise
below relies on.
Practise: reverse the words, keep every space
The exercise asks for the words of a line in reverse order, with every run of spaces left exactly where it was. It uses two ideas from this lesson: cutting at an ASCII space keeps every word whole, whatever it contains, and the result is collected in pieces and joined once, so it takes linear time on a line of 100,000 words.
Exercise · Easy · Python, JavaScript
Reverse the words, keep every space
A caption editor has a button that reverses the order of the words in a line and leaves its spacing alone: every run of spaces stays where it was, just as wide. Write reverse_words_keep_spaces(s) (reverseWordsKeepSpaces(s) in JavaScript), which returns the line with its words in reverse order.
- A word is a longest run of characters other than the space
" ", and a gap is a longest run of spaces. Only" "counts as a space: a tab belongs to the word it touches. - The gaps stay in place. A line that starts or ends with spaces still does, and the third gap from the left is still the third gap from the left.
- Words come out unchanged, whatever they hold: accents, emoji, Devanagari.
- The result takes time linear in the length of
s, also for a line of 100,000 words.
reverse_words_keep_spaces("one two three") # "three two one"
reverse_words_keep_spaces(" hi there ") # " there hi "
reverse_words_keep_spaces(" ") # " "
Run the sample tests when you are done.
Python · Starter code · reverse_words.py
def reverse_words_keep_spaces(s):
"""The words of s in reverse order, with every run of spaces left where it was and as wide as it was.
A word is a longest run of characters other than " "; a gap is a longest run of " ". Words come out unchanged.
Take time linear in len(s).
"""
# Replace this line with your code.
return "" The sample tests · test_reverse_words.py
import random
import re
from reverse_words import reverse_words_keep_spaces
def test_the_examples():
"""the words swap places and the gaps stay"""
assert reverse_words_keep_spaces("one two three") == "three two one"
assert reverse_words_keep_spaces(" hi there ") == " there hi "
def test_one_word_or_none():
"""one word, an empty line and a line of spaces come back as they are"""
assert reverse_words_keep_spaces("word") == "word"
assert reverse_words_keep_spaces(" solo ") == " solo "
assert reverse_words_keep_spaces("") == ""
assert reverse_words_keep_spaces(" ") == " "
def test_words_with_accents_emoji_and_devanagari():
"""words come out unchanged, whatever they hold"""
neha, wave, flag = "\u0928\u0947\u0939\u093e", "\U0001F44B\U0001F3FD", "\U0001F1EE\U0001F1F3"
assert reverse_words_keep_spaces(f"{neha} {wave} Kabir") == f"Kabir {wave} {neha}"
assert reverse_words_keep_spaces(f"Jose\u0301 {flag}") == f"{flag} Jose\u0301"
def test_only_the_space_separates_words():
"""a tab or a line break belongs to the word it touches"""
assert reverse_words_keep_spaces("a\tb c") == "c a\tb"
assert reverse_words_keep_spaces("x\ny z") == "z x\ny"
def test_random_lines_keep_their_gaps():
"""500 random lines: the same gaps in the same order, the words reversed"""
rng = random.Random(28)
pieces = ["a", "bc", "\u00e9", "e\u0301", "\U0001F600", " ", " "]
for _ in range(500):
line = "".join(rng.choice(pieces) for _ in range(rng.randint(0, 12)))
out = reverse_words_keep_spaces(line)
assert re.findall(r" +", out) == re.findall(r" +", line), line
assert re.findall(r"[^ ]+", out) == re.findall(r"[^ ]+", line)[::-1], line
assert out.startswith(" ") == line.startswith(" "), line
assert len(out) == len(line), line
def test_a_long_line():
"""100,000 words, in time linear in the length of the line"""
words = [f"w{i}" for i in range(100_000)]
assert reverse_words_keep_spaces(" ".join(words)) == " ".join(reversed(words)) JavaScript · Starter code · reverse_words.mjs
/**
* The words of s in reverse order, with every run of spaces left where it was and as wide as it was. A word is a
* longest run of characters other than " "; a gap is a longest run of " ". Words come out unchanged. Take time
* linear in s.length.
*/
export function reverseWordsKeepSpaces(s) {
// Replace this line with your code.
return '';
} The sample tests · reverse_words.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { reverseWordsKeepSpaces } from './reverse_words.mjs';
let seed = 28;
const random = () => (seed = (seed * 48271) % 2147483647) / 2147483647;
const randInt = (lo, hi) => lo + Math.floor(random() * (hi - lo + 1));
const runs = (text, pattern) => text.match(pattern) ?? [];
test('the words swap places and the gaps stay', () => {
assert.equal(reverseWordsKeepSpaces('one two three'), 'three two one');
assert.equal(reverseWordsKeepSpaces(' hi there '), ' there hi ');
});
test('one word, an empty line and a line of spaces come back as they are', () => {
assert.equal(reverseWordsKeepSpaces('word'), 'word');
assert.equal(reverseWordsKeepSpaces(' solo '), ' solo ');
assert.equal(reverseWordsKeepSpaces(''), '');
assert.equal(reverseWordsKeepSpaces(' '), ' ');
});
test('words come out unchanged, whatever they hold', () => {
const neha = '\u0928\u0947\u0939\u093e';
const wave = '\u{1F44B}\u{1F3FD}';
const flag = '\u{1F1EE}\u{1F1F3}';
assert.equal(reverseWordsKeepSpaces(`${neha} ${wave} Kabir`), `Kabir ${wave} ${neha}`);
assert.equal(reverseWordsKeepSpaces(`Jose\u0301 ${flag}`), `${flag} Jose\u0301`);
});
test('a tab or a line break belongs to the word it touches', () => {
assert.equal(reverseWordsKeepSpaces('a\tb c'), 'c a\tb');
assert.equal(reverseWordsKeepSpaces('x\ny z'), 'z x\ny');
});
test('500 random lines: the same gaps in the same order, the words reversed', () => {
const pieces = ['a', 'bc', '\u00e9', 'e\u0301', '\u{1F600}', ' ', ' '];
for (let trial = 0; trial < 500; trial++) {
const line = Array.from({ length: randInt(0, 12) }, () => pieces[randInt(0, pieces.length - 1)]).join('');
const out = reverseWordsKeepSpaces(line);
assert.deepEqual(runs(out, / +/g), runs(line, / +/g), JSON.stringify(line));
assert.deepEqual(runs(out, /[^ ]+/g), runs(line, /[^ ]+/g).reverse(), JSON.stringify(line));
assert.equal(out.startsWith(' '), line.startsWith(' '), JSON.stringify(line));
assert.equal(out.length, line.length, JSON.stringify(line));
}
});
test('100,000 words, in time linear in the length of the line', () => {
const words = Array.from({ length: 100_000 }, (_, i) => `w${i}`);
assert.equal(reverseWordsKeepSpaces(words.join(' ')), [...words].reverse().join(' '));
}); A hint
Cut the line into runs that alternate between words and gaps, in order. In Python, re.findall(r" +|[^ ]+", s) returns exactly those runs; in JavaScript, s.match(/ +|[^ ]+/g) does the same (it returns null for an empty string). Collect the words in a list. Then walk the runs again: keep every gap as it is, and put in a word from the end of the word list each time you meet a word. Collect the pieces and join them once at the end. Why does cutting at spaces never split an emoji or a Devanagari letter?
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Warm-up (fresher to mid-level): Why does "😀".length give 2 in JavaScript and Java, while len("😀") gives 1 in
Python and 4 in Go?
Each language counts a different unit. 😀 is one code point, U+1F600, which lies above U+FFFF. Python strings are
sequences of code points, so len counts 1. JavaScript and Java strings are sequences of UTF-16 code units, and
UTF-16 stores a code point above U+FFFF as a surrogate pair of two units, so the length is 2 and an index returns one
half of the pair. Go strings are bytes, and UTF-8 needs four bytes for this code point, so len is 4, as size() is
for a C++ std::string. None of the four counts what a reader sees: 🇮🇳 is one flag but two code points. Count code
points with [...s].length in JavaScript, codePointCount in Java and utf8.RuneCountInString in Go, and count
user-perceived characters with Intl.Segmenter or Java’s \X.
Warm-up (fresher to mid-level): Is building a string with += in a loop quadratic in Python?
By the language’s rules, yes: str is immutable, and the Python documentation says that building a sequence by
repeated concatenation costs time quadratic in its total length. CPython 3.14 often avoids that cost: when s is a
local variable of a function and nothing else refers to the string, s += piece appends in place. This lesson’s run
shows how easily that breaks. A second name for the string, or the same loop at the top level of a script, where the
variable is global, made += copy the whole string on every pass and take over ten times as long. PEP 8 asks code
not to rely on the optimisation, which is fragile even in CPython and absent from implementations without reference
counting. Collect the pieces in a list and call "".join(pieces) once, or write them to an io.StringIO.
Key takeaways
- Python counts code points, JavaScript and Java count UTF-16 code units, and Go and C++ count bytes; a reader counts grapheme clusters. For ASCII text all four agree.
- An index returns that same unit: a whole code point in Python, possibly half an emoji in JavaScript and Java, and one byte in Go and C++.
- Strings are immutable in Python, JavaScript, Java and Go, so
s = s + piecein a loop is quadratic. Usejoin,StringBuilderorstrings.Builder; in C++, append with+=. - CPython’s fast
+=is an optimisation, not a promise: prepending, a second reference or a global variable makes it quadratic again. - Reversing bytes breaks every non-ASCII character, reversing code units breaks emoji, and reversing code points breaks
accents, vowel signs, skin tones and flags. Only grapheme clusters reverse correctly:
Intl.Segmenter, Java’s\Xor, from Python 3.15,unicodedata.iter_graphemes. - Normalise to NFC before you compare text that may come from different sources.
- Read the constraints for the alphabet, ask when it is missing, and say which unit your code counts.
References
- Unicode Standard Annex #29: Unicode Text Segmentation (grapheme cluster boundaries) (Unicode Consortium)
- Unicode Standard Annex #15: Unicode Normalization Forms (Unicode Consortium)
- Unicode emoji data: emoji-sequences.txt (flag sequences) (Unicode Consortium)
- RFC 3629: UTF-8, a transformation format of ISO 10646 (IETF (RFC Editor))
- Built-in Types: Text Sequence Type, str (Python Software Foundation)
- Built-in Types: Common Sequence Operations (note 6, repeated concatenation) (Python Software Foundation)
- unicodedata.normalize (Python Software Foundation)
- Python 3.15: unicodedata.iter_graphemes (Python Software Foundation)
- PEP 8: Programming Recommendations (in-place string concatenation) (Python Software Foundation)
- CPython 3.14 source: Python/bytecodes.c (BINARY_OP_INPLACE_ADD_UNICODE) (Python Software Foundation)
- ECMAScript 2026 Language Specification: The String Type (Ecma International)
- String: UTF-16 characters, Unicode code points, and grapheme clusters (MDN Web Docs (Mozilla))
- Intl.Segmenter() constructor (MDN Web Docs (Mozilla))
- String.prototype.normalize() (MDN Web Docs (Mozilla))
- Java SE 25 API: String (Oracle)
- Java SE 25 API: StringBuilder (Oracle)
- Java SE 25 API: Pattern (the grapheme cluster matcher) (Oracle)
- Java SE 25 API: BreakIterator (Oracle)
- Java SE 25 API: Normalizer (Oracle)
- JEP 431: Sequenced Collections (OpenJDK)
- C++ working draft N4950: Character sets [lex.charset] (literal encodings) (ISO/IEC JTC1/SC22/WG21 (HTML rendering of the working draft))
- C++ working draft N4950: Fundamental types [basic.fundamental] (ISO/IEC JTC1/SC22/WG21 (HTML rendering of the working draft))
- C++ working draft N4950: operator+ for basic_string [string.op.plus] (ISO/IEC JTC1/SC22/WG21 (HTML rendering of the working draft))
- C++ working draft N4950: Sequence containers [sequence.reqmts] (amortised constant push_back) (ISO/IEC JTC1/SC22/WG21 (HTML rendering of the working draft))
- The Go Programming Language Specification: String types (The Go Authors)
- The Go Programming Language Specification: For statements with range clause (The Go Authors)
- The Go Programming Language Specification: Conversions to and from a string type (The Go Authors)
- Package strings: Builder (The Go Authors)
- Package unicode/utf8 (The Go Authors)
- Package slices: Reverse (The Go Authors)
- Package golang.org/x/text/unicode/norm (The Go Authors)
- ICU User Guide: Boundary Analysis (Unicode Consortium (ICU))
- ICU User Guide: Normalization (Unicode Consortium (ICU))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress