Encode and Decode Strings: Why Length Prefixes Beat Delimiters
7 min readBytePatterns
Turn a list of strings into one string and back: why any delimiter breaks, how length-prefix framing survives every character, escaping, and binary framing.
"Design an algorithm to encode a list of strings into a single string, and decode it back." The first idea is always a separator, and the interviewer's first reply is always: what if a string contains your separator? The real answer is a technique from network protocols, length-prefix framing, and it fits in two short functions.
The problem it solves
You have a list such as ["hi", "a#b", ""] and a channel that carries exactly one string. Write encode and decode so that decode(encode(parts)) == parts for every list, including:
- strings that contain any character at all, digits and your separator included;
- empty strings;
- the empty list.
The decoder has no side channel. Everything it needs to find the boundaries must be inside the one string.
The intuition
A delimiter reserves a character. ",".join(["hi", "a,b", ""]) gives hi,a,b,, and splitting that returns four parts instead of three. Whatever character you reserve is one the data can no longer contain, and a general-purpose encoder cannot promise that.
There are two ways out:
- Escaping. Keep the delimiter, but write a backslash before every delimiter or backslash inside the data. It works, but the decoder has to inspect every character, and the rules are easy to get subtly wrong.
- Length prefix. Write each part's length first, then a marker, then the part itself:
2#hi3#a#b0#. The decoder reads digits up to the first#, which tells it exactly how many characters follow, and copies them without looking at them. A#inside the payload is just data, because the decoder is never searching for one there.
The marker only has to end the number, and a number is made of digits, so any non-digit works.
Watch it run
The animation encodes two parts, one of which contains a #. It writes the header 2#, copies the payload unexamined, then 3# and the second payload. Decoding reverses it: read digits up to the first #, take exactly that many characters, jump the cursor past them. Nothing is scanned twice, and an empty part would survive as 0#.
Encode and Decode Strings
Step 1 of 10
Two parts to send — and one of them contains a #. Any separator you reserve is a character the data may not hold.
The same interactive animation as the lesson — step through it with the controls.
The code
The failure first, then the length-prefix codec:
parts = ["hi", "a,b", ""]
joined = ",".join(parts)
print(repr(joined), joined.split(",")) # 'hi,a,b,' ['hi', 'a', 'b', '']
def encode(parts):
return "".join(f"{len(p)}#{p}" for p in parts)
def decode(s):
out, i = [], 0
while i < len(s):
j = s.index("#", i) # digits end at the first # after i
n = int(s[i:j])
out.append(s[j + 1:j + 1 + n]) # copy n characters, unexamined
i = j + 1 + n # land on the next header
return out
wire = encode(["hi", "a#b", "", "12#"])
print(wire) # 2#hi3#a#b0#3#12#
print(decode(wire)) # ['hi', 'a#b', '', '12#']
print(decode(encode([])), decode(encode([""]))) # [] ['']
Escaping, for comparison. Note that it cannot tell the empty list from a list holding one empty string, since both encode to nothing:
def encode_escaped(parts):
return ",".join(p.replace("\\", "\\\\").replace(",", "\\,") for p in parts)
def decode_escaped(s):
out, cur, i = [], [], 0
while i < len(s):
if s[i] == "\\":
cur.append(s[i + 1]) # whatever follows a backslash is data
i += 2
elif s[i] == ",":
out.append("".join(cur))
cur = []
i += 1
else:
cur.append(s[i])
i += 1
out.append("".join(cur))
return out
print(encode_escaped(["a,b", "c\\"]), decode_escaped(encode_escaped(["a,b", "c\\"])))
# a\,b,c\\ ['a,b', 'c\\']
On the wire, lengths are counted in bytes, not characters. A fixed four-byte header avoids the marker altogether:
import struct
def encode_bytes(parts):
out = bytearray()
for p in parts:
data = p.encode("utf-8")
out += struct.pack(">I", len(data)) + data # 4-byte big-endian length
return bytes(out)
def decode_bytes(b):
out, i = [], 0
while i < len(b):
(n,) = struct.unpack_from(">I", b, i)
out.append(b[i + 4:i + 4 + n].decode("utf-8"))
i += 4 + n
return out
print(len("é€"), len("é€".encode("utf-8")), decode_bytes(encode_bytes(["é€", ""]))) # 2 5 ['é€', '']
All three round-trips on 3,000 random lists drawn from an alphabet full of trouble: #, commas, backslashes, digits, spaces and multi-byte characters:
import random
random.seed(15)
alphabet = "ab#,\\0123é€ "
ok = True
for _ in range(3000):
parts = ["".join(random.choice(alphabet) for _ in range(random.randint(0, 6)))
for _ in range(random.randint(0, 5))]
ok &= decode(encode(parts)) == parts
ok &= decode_bytes(encode_bytes(parts)) == parts
if parts: # escaping cannot express the empty list
ok &= decode_escaped(encode_escaped(parts)) == parts
print(ok) # True
The complexity
- Encoding writes each character once plus a header of
O(log L)digits for a part of lengthL:O(total length)time and output size. - Decoding reads each header once and slices each payload once, jumping over it:
O(total length). Thes.index("#", i)call only ever scans the digits of the current header. - Overhead is a few characters per part, compared with escaping, which can double a string made entirely of special characters.
- Joining with
"".joinmatters in Python; building the result with+=in a loop can degrade to quadratic time.
Where it goes wrong
- Searching for the marker instead of jumping. Calling
split("#")or scanning ahead for the next#reintroduces the delimiter problem the prefix was meant to remove. - Counting characters, sending bytes.
"é€"is 2 characters but 5 UTF-8 bytes. A header must use the same unit the reader counts in. - Dropping empty strings. A codec that writes nothing for
""cannot restore it;0#keeps it. - Trusting the length on real input. A network reader should reject a header larger than the remaining data, or the message size it is willing to allocate.
How to say it in an interview
"Any delimiter can appear inside the data, so I prefix each string with its length and a #. To decode, I read digits up to the next #, parse the length n, take exactly the next n characters without inspecting them, and jump past them. That handles any character and empty strings, and both directions are linear in the total length. On a real wire I'd count bytes, or use a fixed-size binary length header, which is how HTTP's Content-Length lets a parser find the end of a body."
The same "state the size, then the content" idea appears when serialising a binary tree, covered in depth in serialize and deserialize a binary tree.