Skip to main content
PProfileMatch

Python String Problems: Anagrams, Palindromes, Parsing and Unicode

Data Structures & AlgorithmsLesson 4 of 46 min read

What you'll learn

  • Group anagrams by building a canonical key for each word
  • Check palindromes with two pointers while ignoring case and punctuation
  • Parse semi-structured text with split, partition and a regular expression, reporting bad lines instead of crashing
  • Explain string immutability, casefold, normalization and why len is not the byte count

On this page

  1. Strings are immutable sequences
  2. Grouping anagrams with a canonical key
  3. Palindromes without building a second string
  4. Parsing messy lines without crashing
  5. Unicode and immutability gotchas, run once
  6. Common mistakes
  7. How this shows up in real projects and interviews

Take two spellings of one name: "Zo\u00eb" and "Zoe\u0308". On screen both render as Zoë, yet Python says they are not equal, and one has length 3 while the other has length 4. Nothing is broken. The first uses a single accented character and the second a plain e followed by a combining mark, and a user can produce either by typing or pasting. Text handling is full of this kind of silent difference, and this lesson mixes the classic string problems with the traps that make them harder in real data than in textbook form.

Strings are immutable sequences

A str supports indexing, slicing and iteration like a list, but you can never change one in place. Methods such as upper() and replace() return a new string, and s += "x" creates a new object and rebinds the name. CPython can sometimes optimise repeated concatenation, but that is an implementation detail; the portable way to build a long string is to collect pieces in a list and call "".join(parts) once. Each slice also copies its characters, so s[1:] inside a loop is a hidden O(n) per iteration, the same kind of cost you saw with list.pop(0) in Data Structures.

Grouping anagrams with a canonical key

A word-game club wants to group the words on a rack that are anagrams of each other. Two words are anagrams exactly when their letters sorted are the same, so the sorted letters make a canonical key, and a defaultdict(list) collects words under it. Using casefold() first makes Silent and listen match.

rack_anagrams.py
from collections import defaultdict


def group_anagrams(words):
    groups = defaultdict(list)
    for word in words:
        key = "".join(sorted(word.casefold()))      # same letters -> same key
        groups[key].append(word)
    return list(groups.values())


rack_words = ["listen", "Silent", "enlist", "google", "gogole", "cat", "act", "Tac", "dog"]
for group in group_anagrams(rack_words):
    print(group)

# The key is the whole trick: sorted() returns a list of characters, so join it back
print(sorted("listen"), "".join(sorted("listen")))

Output

['listen', 'Silent', 'enlist']
['google', 'gogole']
['cat', 'act', 'Tac']
['dog']
['e', 'i', 'l', 'n', 's', 't'] eilnst

Sorting each word costs O(k log k) for a word of length k. For a fixed small alphabet, a tuple of 26 letter counts as the key brings that to O(k). Either way, the dict makes the grouping O(total letters) instead of comparing every pair of words.

Palindromes without building a second string

The shortcut s == s[::-1] works for clean input but allocates a reversed copy and fails when spaces and punctuation are present. Two pointers handle both: move inwards from each end, skip anything that is not alphanumeric, and compare the rest ignoring case. The same skeleton also answers the common follow-up, "may delete at most one character".

palindromes.py
def is_palindrome_phrase(text):
    """True if text reads the same both ways, ignoring case, spaces and punctuation."""
    left, right = 0, len(text) - 1
    while left < right:
        if not text[left].isalnum():
            left += 1
        elif not text[right].isalnum():
            right -= 1
        else:
            if text[left].casefold() != text[right].casefold():
                return False
            left += 1
            right -= 1
    return True


def almost_palindrome(s):
    """True if deleting at most one character makes s a palindrome."""

    def is_pal(i, j):
        while i < j:
            if s[i] != s[j]:
                return False
            i += 1
            j -= 1
        return True

    i, j = 0, len(s) - 1
    while i < j:
        if s[i] != s[j]:
            return is_pal(i + 1, j) or is_pal(i, j - 1)
        i += 1
        j -= 1
    return True


print(is_palindrome_phrase("Was it a car or a cat I saw?"))
print(is_palindrome_phrase("Ticket 4-4 tekciT"))
print(is_palindrome_phrase(""))
print(almost_palindrome("abca"), almost_palindrome("abcda"))

Output

True
True
True
True False

An empty string counts as a palindrome, which is the usual convention; decide your edge cases before you code, not after the tests fail. almost_palindrome tries both deletions at the first mismatch and needs no more, because after one deletion the rest must match exactly.

Parsing messy lines without crashing

Text arrives in whatever shape people typed. A small invoice reader shows the usual toolkit: skip blanks and comment lines, match each line against a pattern, convert money with Decimal rather than float, and collect errors with line numbers so one bad line does not hide the others.

invoice_parser.py
import re
from decimal import Decimal

LINE = re.compile(r"^(?P<qty>\d+)\s*x\s+(?P<item>.+?)\s*@\s*(?P<price>\d+(?:\.\d{1,2})?)$")


def parse_invoice(lines):
    items, errors = [], []
    for number, raw in enumerate(lines, start=1):
        line = raw.strip()
        if not line or line.startswith("#"):
            continue                              # blank lines and comments
        m = LINE.match(line)
        if not m:
            errors.append(f"line {number}: cannot read {line!r}")
            continue
        qty, price = int(m["qty"]), Decimal(m["price"])
        items.append((m["item"], qty, price, qty * price))
    return items, errors


text = """
# order 5521
2 x Cable tie (pack of 100) @ 3.50
1 x Label printer @ 129
three x Tape @ 2.00
10 x Zip bag, small @ 0.40
"""
items, errors = parse_invoice(text.splitlines())
for name, qty, price, subtotal in items:
    print(f"{name:28} {qty:3} x {price:>7} = {subtotal:>7}")
print("total:", sum(i[3] for i in items))
print(errors)

# partition: split once, keep the rest intact
key, _, value = "ship_to = Pune, MH = 411001".partition("=")
print(repr(key.strip()), repr(value.strip()))

Output

Cable tie (pack of 100)        2 x    3.50 =    7.00
Label printer                  1 x     129 =     129
Zip bag, small                10 x    0.40 =    4.00
total: 140.00
["line 5: cannot read 'three x Tape @ 2.00'"]
'ship_to' 'Pune, MH = 411001'

Line numbers start at 1 for the blank first line produced by the triple-quoted string, so the bad line is reported as line 5. Notice partition("="): unlike split("="), it splits only at the first separator and always returns three parts, so a value that itself contains = survives. Reach for a regular expression when the format has structure, for split or partition when it is delimiter-based, and for a real parser (csv, json, datetime.strptime) whenever one exists.

Unicode and immutability gotchas, run once

unicode_gotchas.py
import unicodedata

composed = "Zo\u00eb"          # Zoe with e-diaeresis as ONE code point
decomposed = "Zoe\u0308"       # plain e followed by a combining diaeresis
print(ascii(composed), ascii(decomposed), composed == decomposed)
print(len(composed), len(decomposed))
print(unicodedata.normalize("NFC", decomposed) == composed)

smile = "\U0001F600"
print(len(smile), len(smile.encode("utf-8")), len("caf\u00e9".encode("utf-8")))

print("STRASSE".lower() == "stra\u00dfe".lower(), "STRASSE".casefold() == "stra\u00dfe".casefold())

s = "parcel"
try:
    s[0] = "P"
except TypeError as err:
    print("TypeError:", err)
t = s
s += "!"
print(s, t, s is t)

parts = [str(n) for n in range(5)]
print("-".join(parts))

Output

'Zo\xeb' 'Zoe\u0308' False
3 4
True
1 4 5
False True
TypeError: 'str' object does not support item assignment
parcel! parcel False
0-1-2-3-4
SurpriseWhat happensWhat to do
Same-looking strings are unequalComposed and decomposed accents are different code pointsNormalize both sides with unicodedata.normalize("NFC", s) before comparing or hashing
len(s) is not what the user seesIt counts code points; one visible character can be severalCount grapheme clusters with a library if the UI needs it
len(s) is not the byte sizeEncoded length depends on the encodingUse len(s.encode("utf-8")) for storage limits
lower() misses some matchesGerman sharp s lowercases to itself, not to ssUse casefold() for caseless comparison
s[0] = "P" failsStrings are immutableBuild a new string with slicing, replace or join

Common mistakes

  • Comparing user-entered names or tags without normalization. Symptom: a user cannot log in or a duplicate record appears although two values "look identical". Normalize to NFC and use casefold() for caseless matching.
  • Building a big string with `+=` in a loop. Symptom: runtime grows faster than the input on large data and varies between Python implementations. Collect pieces and join.
  • Splitting on a delimiter that can occur in the value. Symptom: ValueError: too many values to unpack or silently truncated data. Use partition, split(sep, maxsplit) or a real parser.
  • Using `float` for money while parsing. Symptom: totals like 0.30000000000000004. Parse into Decimal or integer cents.
  • Assuming one character is one byte. Symptom: UnicodeEncodeError on write, or truncated text when cutting a byte string in the middle of a character. Decode and encode explicitly with UTF-8.

How this shows up in real projects and interviews

Real projects spend a lot of time cleaning text: normalising names before a join, parsing log lines and CSV exports, validating identifiers, de-duplicating records that differ only in case or accents. The bugs are rarely algorithmic; they come from assuming ASCII. Interviews use the algorithmic cores, anagram grouping, palindrome checks, "valid parentheses" and parsing numbers from text, and a quick note on case folding or Unicode shows depth without costing time. Before coding, ask the interviewer: are inputs ASCII only, is case significant, and what should an empty string return? The answers change your solution. For the dictionary work in the anagram solution, see Data Structures, and when strings need ordering, see Sorting Algorithms.

Try it yourself

Write normalize_tag(s) that returns a canonical form of a user-entered tag: NFC-normalised, casefolded, with runs of whitespace collapsed to one space and the ends stripped. Then write dedupe_tags(tags) that keeps the first spelling of each canonical tag, and test it with ['Caf\u00e9', 'Cafe\u0301', ' CAFE\u00c9 '].

Show a hint

' '.join(s.split()) collapses whitespace. Keep a set of canonical forms already seen and a result list of the original spellings.

Key takeaways

  • Strings are immutable: every change builds a new string, so collect pieces and join.
  • A canonical key such as sorted letters turns grouping problems into a single dict pass.
  • Two pointers check palindromes in O(n) without a reversed copy and handle punctuation by skipping.
  • Parse messy text defensively: match with a pattern, use Decimal for money, and report bad lines with their numbers.
  • Normalize with NFC and compare with casefold(); len counts code points, not bytes or visible characters.

Test yourself

A trick from this lesson's ride: Many pieces? Collect them in a list and join once. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

Why are strings immutable in Python?

Immutability makes strings safe to share, to use as dict keys and set members, and to cache or intern. The cost is that every change creates a new string, so build large text with "".join(parts) rather than repeated +=.

How do I group anagrams in Python?

Build a canonical key for each word, such as "".join(sorted(word.casefold())), and collect words in a defaultdict(list) under that key. Words that are anagrams share the same key, so the dict values are the groups. For long words, a tuple of letter counts is faster than sorting.

What is the difference between lower() and casefold() in Python?

lower() converts to lowercase, while casefold() is more aggressive and meant for caseless matching across languages: the German sharp s becomes ss, so "STRASSE".casefold() == "straße".casefold() is True and the lower() comparison is False. Use casefold() when comparing user text.