Part 4 · Algorithms · Module 34

Strings, Bits & Number Theory

Three toolkits that show up everywhere from search engines to cryptography: linear-time string matching, bit manipulation that packs whole sets into one integer, and the number theory behind hashing and public-key encryption. Python's arbitrary-precision integers make all three unusually pleasant.

⏱ ~2.5 hoursPrerequisites: M33 Greedy & Backtracking, M23 Hash Tables

String matching: the naive algorithm

The string matching problem: find every position where a pattern p (length m) occurs in a text t (length n). The naive algorithm tries every alignment and compares character by character.

Worst case O(n·m): on aaaa…ab each alignment matches m − 1 characters before failing, and all that knowledge is thrown away when the window slides by one. In practice, str.find, in, and str.count use a highly tuned C implementation (a two-way / Boyer-Moore-Horspool hybrid), so always reach for them first. Learn the algorithms below for when you need what the built-ins do not offer: matching on lists or token streams, many patterns at once, or fingerprints for similarity search.

Knuth-Morris-Pratt: never re-read the text

KMP's insight: when a mismatch happens after matching p[:j], we already know the last j text characters. The longest proper prefix of p[:j] that is also a suffix of it tells us how much of the pattern is still matched after shifting. Precompute that for every j in the failure table (also called the prefix function), fail[j] = length of the longest proper prefix of p[:j+1] that is also its suffix.

Why it is linear. The text index i only moves forward. Each successful comparison increases k by one, and each fallback decreases it by at least one, so the total number of fallbacks is at most the total number of increases, which is at most n. Searching is O(n), building the table is O(m) by the same argument: O(n + m) total, O(m) extra space.

Rabin-Karp: matching by rolling hash

Rabin-Karp compares hashes of windows instead of characters. Treat a window as a number in base B modulo a large prime M: h = (c₀·B^(m-1) + c₁·B^(m-2) + … + c_(m-1)) mod M. Sliding the window one step is O(1): remove the leading character's contribution, multiply by B, add the new one.

h = ((h - ord(out_char) * B**(m-1)) * B + ord(in_char)) % M

Equal hashes might be a collision, so confirm with a direct comparison. With a random-ish large prime, collisions are rare and the expected time is O(n + m); the worst case (every window collides) is O(n·m). Its real strength is matching many patterns of the same length at once: put their hashes in a set.

Palindromes: expand around the center

A palindrome reads the same backwards. Checking one string is s == s[::-1]. Finding the longest palindromic substring naively checks O(n²) substrings at O(n) each. Better: every palindrome has a center, either a character (odd length) or a gap between two characters (even length). There are 2n − 1 centers; expand outwards from each while the ends match. O(n²) time, O(1) space, and very fast in practice.

Bit manipulation

An int is a sequence of bits. Python integers have unlimited width; negative numbers behave as if they had infinitely many leading 1s (two's complement). The operators: & (and), | (or), ^ (xor), ~ (not, ~x == -x - 1), << and >> (shifts). Explore them, and the IEEE-754 float layout, in the visualizer.

GoalExpressionExample with x = 0b101100 (44)
Test bit i(x >> i) & 1bit 2 → 1
Set bit ix | (1 << i)set bit 0 → 45
Clear bit ix & ~(1 << i)clear bit 2 → 40
Toggle bit ix ^ (1 << i)toggle bit 5 → 12
Drop lowest set bitx & (x - 1)44 → 40
Isolate lowest set bitx & -x→ 4
Power of two?x > 0 and x & (x - 1) == 0False
Count set bitsx.bit_count()3
Number of bitsx.bit_length()6

XOR tricks

XOR is its own inverse: a ^ a == 0 and a ^ 0 == a, and it is commutative and associative. So XOR-ing a list where every value appears twice except one leaves exactly the odd one out, in O(n) time and O(1) space. The same property powers RAID parity, simple checksums, and the one-time pad.

Subsets as bitmasks

A set drawn from n items can be stored as an n-bit integer: bit i is 1 when item i is in the set. Union is |, intersection &, difference a & ~b, membership a bit test, and the size is bit_count(). Iterating mask from 0 to 2n − 1 visits every subset, an iterative alternative to the backtracking in M33.

Bitmasks are also the state in bitmask DP (M32 + this section): the travelling salesman problem's Held-Karp algorithm uses dp[mask][last] = shortest path visiting exactly the cities in mask and ending at last, O(2n·n²) instead of O(n!).

Number theory: gcd, lcm, and Euclid

The greatest common divisor of a and b is the largest integer dividing both. Euclid's algorithm (about 300 BCE) rests on one fact: any common divisor of a and b also divides a % b, so gcd(a, b) = gcd(b, a % b), and gcd(a, 0) = a. Step through it:

Each two steps at least halve the larger argument, so Euclid runs in O(log min(a, b)) steps. The least common multiple follows: lcm(a, b) = a // gcd(a, b) * b (divide first to keep numbers small). In real code use math.gcd and math.lcm, which accept any number of arguments.

The extended Euclidean algorithm also finds integers x, y with a·x + b·y = gcd(a, b) (Bézout's identity), which is how modular inverses are computed.

Primes and the sieve of Eratosthenes

Trial division tests one number n in O(√n): if n has a factor larger than √n, it also has one smaller. To find all primes up to N, the sieve of Eratosthenes is far better: cross off multiples of each prime, starting at p² (smaller multiples were already crossed off by smaller primes). It runs in O(N log log N), which is almost linear.

Modular arithmetic and fast exponentiation

Arithmetic modulo m wraps around like a clock. Addition, subtraction, and multiplication commute with taking remainders: (a * b) % m == ((a % m) * (b % m)) % m. That lets you compute huge results (the number of ways mod 109 + 7, a hash value) without ever building huge intermediate numbers.

Fast modular exponentiation (square and multiply) computes ab mod m in O(log b) multiplications by reading b's bits: square the base at every step, and multiply it into the result when the current bit is 1. Python's three-argument pow(a, b, m) does exactly this in C.

Modular inverse. x is the inverse of a mod m when a·x % m == 1. It exists exactly when gcd(a, m) == 1. Since Python 3.8, pow(a, -1, m) computes it (raising ValueError when no inverse exists). For a prime m, Fermat's little theorem gives another route: pow(a, m - 2, m).

Hashing and a crypto teaser

Polynomial rolling hashes (Rabin-Karp) are modular arithmetic. So is the most famous public-key system, RSA. A toy version with tiny primes shows the whole idea in six lines; real RSA uses primes with hundreds of digits.

  1. Pick primes p and q; let n = p·q and φ = (p − 1)(q − 1).
  2. Pick e with gcd(e, φ) = 1 (65537 is standard); compute d = e−1 mod φ.
  3. Encrypt: c = me mod n. Decrypt: m = cd mod n. This works by Euler's theorem.
  4. Security rests on factoring: knowing n but not p and q, nobody can compute φ and therefore d.

Python 3.15 preview: math.integer

pixi exec --spec "python=3.15" python integer_demo.py

On Python 3.14, which this course targets, keep using math.isqrt, math.comb, math.perm, math.factorial, math.gcd, and math.lcm.

Practice

Write find_all(text, pattern) returning the start index of every (possibly overlapping) occurrence of a non-empty pattern, using the KMP failure table so the search is O(n + m). Do not use str.find, in, or re.

Hint

Build fail first. Then scan the text with a matched length k, falling back with k = fail[k - 1] on mismatches and after each full match.

Show solution
def find_all(text: str, pattern: str) -> list[int]:
    fail = [0] * len(pattern)
    k = 0
    for i in range(1, len(pattern)):
        while k and pattern[i] != pattern[k]:
            k = fail[k - 1]
        if pattern[i] == pattern[k]:
            k += 1
        fail[i] = k
    hits: list[int] = []
    k = 0
    for i, ch in enumerate(text):
        while k and ch != pattern[k]:
            k = fail[k - 1]
        if ch == pattern[k]:
            k += 1
        if k == len(pattern):
            hits.append(i - k + 1)
            k = fail[k - 1]
    return hits

Write count_palindromes(s) returning the number of palindromic substrings of s, counting each position separately ("aaa" has 6: three a, two aa, one aaa). Use expand-around-center.

Hint

For each of the 2n − 1 centers, every successful expansion step is one more palindrome.

Show solution
def count_palindromes(s: str) -> int:
    total = 0
    for center in range(2 * len(s) - 1):
        lo = center // 2
        hi = lo + center % 2
        while lo >= 0 and hi < len(s) and s[lo] == s[hi]:
            total += 1
            lo -= 1
            hi += 1
    return total

Write three functions using bit operations (no loops over string representations):

  • is_power_of_two(n): True for 1, 2, 4, 8, …; False for 0 and negatives.
  • single_number(nums): every value appears twice except one; return it in O(1) extra space.
  • hamming(a, b): the number of bit positions where non-negative a and b differ.
Hint

n & (n - 1) drops the lowest set bit; XOR cancels pairs; (a ^ b).bit_count() counts differences.

Show solution
from functools import reduce
from operator import xor

def is_power_of_two(n: int) -> bool:
    return n > 0 and (n & (n - 1)) == 0

def single_number(nums: list[int]) -> int:
    return reduce(xor, nums, 0)

def hamming(a: int, b: int) -> int:
    return (a ^ b).bit_count()

Write smallest_factors(n) returning a list spf of length n + 1 where spf[k] is the smallest prime factor of k for k ≥ 2 (and 0 for k < 2), built with a sieve. Then write factorize(k, spf) returning the prime factors of k in non-decreasing order.

Hint

Start with spf = list(range(n + 1)). For each p up to √n with spf[p] == p, walk multiples from p² and set spf[m] = p if it is still m. To factorize, repeatedly divide by spf[k].

Show solution
import math

def smallest_factors(n: int) -> list[int]:
    spf = list(range(n + 1))
    for k in range(min(2, n + 1)):
        spf[k] = 0
    for p in range(2, math.isqrt(n) + 1):
        if spf[p] == p:
            for m in range(p * p, n + 1, p):
                if spf[m] == m:
                    spf[m] = p
    return spf

def factorize(k: int, spf: list[int]) -> list[int]:
    out: list[int] = []
    while k > 1:
        out.append(spf[k])
        k //= spf[k]
    return out

Write mod_inverse(a, m) using the extended Euclidean algorithm (not pow). Return the inverse in the range 0..m-1, or raise ValueError if gcd(a, m) != 1.

Hint

Iterative extended Euclid keeps (old_r, r) and (old_s, s); at the end old_r is the gcd and old_s is the coefficient of a. Reduce it with % m.

Show solution
def mod_inverse(a: int, m: int) -> int:
    old_r, r = a % m, m
    old_s, s = 1, 0
    while r:
        q = old_r // r
        old_r, r = r, old_r - q * r
        old_s, s = s, old_s - q * s
    if old_r != 1:
        raise ValueError(f"{a} has no inverse modulo {m}")
    return old_s % m

Mini-project

Mini-projectA plagiarism-snippet finder

Build snippets.py, which finds passages shared between two documents, the core idea behind tools like MOSS. Normalize each document (lowercase, collapse whitespace, optionally strip punctuation), split it into words, and compute a Rabin-Karp rolling hash over every window of k consecutive words (a k-gram). Shared hashes point to candidate shared passages; verify them by comparing the actual words, then merge adjacent matching windows into maximal snippets.

pixi init snippets && cd snippets
pixi add python pytest
pixi run python snippets.py essay_a.txt essay_b.txt --k 8
pixi run pytest -q
  1. kgram_hashes(words, k) -> list[int] with an O(1) rolling update per window (hash each word to an int first, for example with zlib.crc32 so results are stable across runs).
  2. shared_windows(a_words, b_words, k) -> list[tuple[int, int]]: index pairs whose k-grams match, verified to rule out collisions.
  3. merge_snippets(pairs, k): join runs where both indexes advance by one into (start_a, start_b, length) snippets.
  4. Print each snippet with its location and a similarity score (shared k-grams ÷ total k-grams).
  • Identical documents produce one snippet covering everything and a score of 1.0
  • Unrelated documents produce no snippets
  • A paragraph copied into the middle of another essay is found with the correct word offsets
  • A test forces a hash collision (tiny modulus) and shows verification rejects it
  • At least six pytest tests run with pixi run pytest

Stretch goals: implement winnowing (keep only the minimum hash in each window of w k-grams) to shrink fingerprints while guaranteeing detection of long matches; compare a whole folder of files and report the most similar pairs.

Quiz

Recap

Going further