Convolutions and Their Applications
Two strange dice
A board-game shop sells a pair of six-sided dice with odd labels. The first die shows 1, 2, 2, 3, 3, 4 and the second shows 1, 3, 4, 5, 6, 8. The box claims that they can replace two ordinary dice in any game that only looks at the total. Is that true? For every total from 2 to 12, the pair must give as many of the 36 equally likely rolls as ordinary dice do: one way to roll 2, two ways to roll 3, and so on up to six ways to roll 7.
The table answers the question for two dice. It does not scale: the same question for two lists of numbers between 0 and (how many pairs, one number from each list, give each possible total?) has pairs. This module shows how to answer it with one convolution, and then uses the same step for two more problems that don't look like sums at all: comparing a short pattern with a long text at every alignment, and finding three numbers that add up to zero.
It builds on module 25, which computes a convolution fast with the fast Fourier transform. Here that computation is a tool with a known price, and the question is what it can count.
std = [1, 2, 3, 4, 5, 6]
odd1, odd2 = [1, 2, 2, 3, 3, 4], [1, 3, 4, 5, 6, 8]
def totals_by_pairs(xs, ys):
"""Count pairs by total, one pair at a time."""
count = {}
for x in xs:
for y in ys:
count[x + y] = count.get(x + y, 0) + 1
return count
assert totals_by_pairs(odd1, odd2) == totals_by_pairs(std, std)
assert totals_by_pairs(odd1, odd2)[7] == 6 and len(totals_by_pairs(std, std)) == 11
The double loop, and where it wastes work
The double loop visits every pair, so it costs additions and dictionary updates. When both lists are short, that is the right method. When they are long and their values lie in a limited range, it does far more work than the answer contains. Two lists of numbers in have pairs, but only possible totals. The loop reaches each total thousands of times, one pair at a time.
The fix is to stop working per pair and start working per value. Describe list by its count vector: is the number of times the value appears in . Describe by in the same way. The first odd die has count vector , and the answer is then a single formula in these two vectors.
Convolution counts sums
The convolution of two sequences and is the sequence with
It is the rule for multiplying polynomials: if and , then is the coefficient of in , because .
Counting lemma
If and are the count vectors of lists and , then is the number of pairs with from , from and .
Proof. Group the pairs by their values. The pairs whose first number has value and whose second has value number exactly . Such a pair has total exactly when , so summing over all counts each pair with total once and no other pair.
So the answer to the dice question is one convolution. Draw the products in a grid, and is the sum along one anti-diagonal, the cells with :
The grid has 32 cells instead of the table's 36, which is no saving yet. The saving comes from how
a convolution is computed. Before that, one Python block checks the lemma on the dice with a
stand-in: schoolbook computes the convolution by the definition, one product per cell.
Module 25's transform gives the same numbers.
def count_vector(xs):
"""a[v] = how many times v appears; values are 0..max(xs)."""
a = [0] * (max(xs) + 1)
for x in xs:
a[x] += 1
return a
def schoolbook(a, b):
"""Stand-in for a fast convolution: c[k] = sum of a[i] * b[j] over i + j = k."""
c = [0] * (len(a) + len(b) - 1)
for i, x in enumerate(a):
for j, y in enumerate(b):
c[i + j] += x * y
return c
c_odd = schoolbook(count_vector(odd1), count_vector(odd2))
c_std = schoolbook(count_vector(std), count_vector(std))
assert c_odd == c_std == [0, 0, 1, 2, 3, 4, 5, 6, 5, 4, 3, 2, 1]
assert count_vector(odd1)[1:] == [1, 2, 2, 1]
Predict: why do the odd dice give the same totals as ordinary dice?
Because the two products are the same polynomial, split differently. An ordinary die is
Two ordinary dice multiply that by itself. The odd dice deal out the same eight factors differently. The first die gets , which is , its count vector, and the second gets , which multiplies out to . The block below checks both.
def poly_mul(*ps):
out = [1]
for p in ps:
out = schoolbook(out, p)
return out
X, ONE_X, ONE_X_X2, ONE_MX_X2 = [0, 1], [1, 1], [1, 1, 1], [1, -1, 1]
assert poly_mul(X, ONE_X, ONE_X_X2, ONE_MX_X2) == count_vector(std)
assert poly_mul(X, ONE_X, ONE_X_X2) == count_vector(odd1)
assert poly_mul(X, ONE_X, ONE_X_X2, ONE_MX_X2, ONE_MX_X2) == count_vector(odd2)
The model: a convolution as one step with a known price
Module 25 computes the convolution of two sequences with lengths adding to at most , for a power of two, by three transforms of length , one pointwise product and one scaling. It works with exact integers modulo the prime (a number-theoretic transform), and it costs
multiplications modulo , in the worst case, with no randomness. The model is the word RAM: numbers below fit in a machine word, and each addition or multiplication modulo is one step. Two facts about that price matter here.
It depends on the lengths of the vectors, not on how many items they count. A count vector for values in has length whether the list has 3 numbers or .
It is exact only when every true is below . The transform returns each modulo , so an answer of or more comes back wrong without warning. A count is safe when it is provably small. For two lists without repeated values, , because each in has at most one partner in . With repeats, can reach , and past you need a second prime and the Chinese remainder theorem (module 25). A floating-point FFT with complex numbers is faster in practice but rounds, and module 25 showed it rounding wrong on large inputs. Every count in this module stays below .
For the big instance, two lists of distinct numbers in , the totals run from 0 to , so . That is about multiplications against pair visits, and each count is at most , far below .
def ntt_mults(N):
"""Multiplications of one exact convolution of padded length N (module 25's count)."""
return 3 * (N // 2) * (N.bit_length() - 1) + 2 * N
def padded_length(size):
N = 1
while N < size:
N *= 2
return N
P = 998244353
N_big = padded_length(2 * 10**6 + 1)
assert N_big == 2**21 and ntt_mults(N_big) == 70_254_592
assert 10**5 * 10**5 == 10**10 and 10**5 < P
Any range: shift the values
Values need not start at 0. Temperatures, offsets and differences are often negative. Shift every value of by its smallest value, , so that index holds the count of , and do the same for . A pair then lands at index
so entry of the convolution counts the pairs with total . This also settles what the transform pays for: the span of each list, its largest value minus its smallest, and not the size of the numbers. The values to need a vector of length 101, not .
Sliding a pattern: every alignment at once
Reverse one of the two sequences and the convolution computes something that does not look like a sum of pairs. Take a long sequence and a short one , and place against starting at position . Their correlation at is the dot product of the pattern with the window it covers:
Now let be reversed, , and convolve. In entry of , the index pairs are , one for each , and they sum to as they should. Their products are . So
and one convolution gives the dot product at every one of the alignments.
Here is a use for it. A sequencing machine reads a short strand of DNA, and a lab wants to know
where on a reference it fits best: at every alignment, how many letters disagree? The
reference is CCTAGGATTAGCTAGA and the read is TAGC.
Letters are not numbers, but a letter's positions are a 0/1 sequence. For each letter , let when the reference has at position , and when the read has at position . The correlation of these two indicator sequences at counts the positions where both have an . Summed over the letters, it counts every agreement at alignment , so the number of disagreements is
A letter that never occurs in the read contributes nothing, so a read with distinct letters needs correlations, each one convolution.
The block checks the table against a direct count, one alignment at a time (a stand-in, again: it is the slow method this section replaces).
ref, read = "CCTAGGATTAGCTAGA", "TAGC"
n, m = len(ref), len(read)
def agreements(letter, s):
"""Direct count: positions j where both ref[s+j] and read[j] are the letter."""
return sum(ref[s + j] == letter == read[j] for j in range(m))
d = [m - sum(agreements(x, s) for x in set(read)) for s in range(n - m + 1)]
direct = [sum(ref[s + j] != read[j] for j in range(m)) for s in range(n - m + 1)]
assert d == direct == [4, 4, 1, 3, 4, 3, 4, 3, 0, 4, 4, 4, 1]
assert [agreements("T", s) for s in range(n - m + 1)] == [0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 1]
assert [agreements("A", s) for s in range(n - m + 1)] == [0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1]
Predict: the reference also contains letters the read never uses. Do they need a correlation of their own?
No. A position where the reference has a letter the read lacks can never agree with the read, so it only ever adds to the disagreements, and already counts it: it is one of the positions that no correlation matched. Only the letters of the read need correlations.
Three numbers that sum to zero
The last application answers a yes-or-no question. Given three lists , , of integers in , are there in , in and in with ? This is 3SUM. Sorting and and walking two pointers inward once for each answers it with comparisons and additions for lists of numbers.
Convolution answers it by counting. By the counting lemma, the convolution of the count vectors of and gives, for every total , the number of pairs with . A triple exists exactly when some in finds a nonzero count at . Summing the counts over the in gives the number of triples. The cost is to build the vectors and read the answers, plus one convolution of length about : operations in the word RAM, in the worst case. That is below when the values lie in a range small compared with .
This does not contradict what is believed about 3SUM. In one common form, the 3SUM conjecture says that for integers in , no algorithm on the word RAM runs in time for a fixed . It is unproved, and algorithms faster by logarithmic factors are known. When is around , is far above , so the convolution route helps only for dense lists.
A3, B3, C3 = [-8, -3, 5, 9], [-6, 2, 4, 11], [-7, -1, 3, 10]
lo = min(A3) + min(B3)
sums = schoolbook(count_vector([x - min(A3) for x in A3]), count_vector([y - min(B3) for y in B3]))
def pairs_with_total(k):
return sums[k - lo] if 0 <= k - lo < len(sums) else 0
hits = {z: pairs_with_total(-z) for z in C3}
assert hits == {-7: 1, -1: 1, 3: 0, 10: 0} and sum(hits.values()) == 2
brute = [(x, y, z) for x in A3 for y in B3 for z in C3 if x + y + z == 0]
assert brute == [(-3, 4, -1), (5, 2, -7)]
The two triples are and . For the pairs would have to total , and none does.
Why the answers are right, and what they cost
All three applications rest on the counting lemma, used in three ways: count vectors for sums, indicator sequences with one of them reversed for alignments, and a lookup at for 3SUM. The remaining step is exactness. With the transform working modulo , each answer is right when the true value is below . For totals of lists without repeats that is at most, and an agreement count is at most .
The costs, in the word RAM with arithmetic modulo as unit steps, worst case. For totals, the lists hold and numbers with spans at most ; for alignments, the text has letters, the read , with distinct letters in the read; for 3SUM, the lists hold numbers in . The convolution column leaves out the linear time to build the vectors and read the answers.
| Problem | Direct | Convolution |
|---|---|---|
| Totals | ||
| Alignments | ||
| 3SUM |
Neither column wins everywhere. A convolution pays for the range, the double loop for the pairs. Ten numbers spread over make 100 pairs and a convolution of length . For alignments, a read with many distinct letters needs many convolutions: with near , the direct count wins. The honest summary is the table, not "convolution is faster".
Measure the claim
Take two lists of numbers drawn uniformly from (seeds ), and compare
the pairs the double loop visits with the multiplications of the convolution of their shifted
count vectors ("mults" in the table, with padded length ), computed with ntt_mults
from the spans.
import random
rows = []
for size in (100, 1000, 10000):
rng = random.Random(451 + size)
xs = [rng.randrange(2**14) for _ in range(size)]
ys = [rng.randrange(2**14) for _ in range(size)]
N = padded_length((max(xs) - min(xs)) + (max(ys) - min(ys)) + 1)
rows.append((size, size * size, N, ntt_mults(N)))
assert rows == [(100, 10_000, 32768, 802_816), (1000, 1_000_000, 32768, 802_816),
(10000, 100_000_000, 32768, 802_816)]
assert 896**2 == 802_816
| pairs | mults | ||
|---|---|---|---|
| 100 | 10,000 | 32,768 | 802,816 |
| 1,000 | 1,000,000 | 32,768 | 802,816 |
| 10,000 | 100,000,000 | 32,768 | 802,816 |
The convolution's cost does not move with , because the spans barely change: even 100 random numbers spread over most of . The double loop grows a hundredfold per row. On these instances the two meet at , where is exactly 802,816. These are counts, not timings; a Python double loop and a Python transform have different constants per operation.
A problem that looks different
A cashier has one coin each of 1, 2, 5, 10, 20 and 50 cents, plus one each of a few odd commemorative coins. In how many ways can she pay exactly cents, for every up to the total value of her coins? Think about it before the next module.
Practise
The lab makes each idea yours. You draw a pattern sliding along a signal one shift at a time, then compute every shift with one convolution. You predict and then watch the totals of two lists with negative values. You compute disagreements at every alignment of a strand, with a meter on how much of it you read. You measure where the double loop and the convolution cross over, on lists of your own. And you solve a problem from an observatory that doesn't say what it is.
Recap
You can now: state and prove the counting lemma; count the totals of two lists with one convolution of shifted count vectors; turn a correlation into a convolution by reversing one sequence; count disagreements at every alignment with one correlation per letter; decide 3SUM on a bounded range by counting; and say when each beats the double loop.
Invariant: entry of the convolution of two count vectors is exactly the number of pairs whose shifted values add up to ; with one sequence reversed, entry is the dot product at alignment .
Complexity achieved: totals of two lists in word-RAM operations, worst case, where bounds the spans, exact modulo when every count is below ; disagreements at all alignments with convolutions of length about ; 3SUM on in . The double loop costs , and .
Failure mode: forgetting the shift or the reversal (the answer lands at the wrong index), or trusting a count at or past (it comes back reduced modulo ).
In real software: NumPy's numpy.convolve computes the sum directly, and does it by handing
the reversed second array to its correlation routine, the same reversal as above; its
documentation points to scipy.signal.fftconvolve for large inputs. SciPy's
scipy.signal.convolve with method="auto" estimates the operations of both methods and picks
the cheaper, and it falls back to the direct sum for integer inputs once a simple bound on the
results reaches , near the up to which a double holds every integer exactly.
Retrieval (module 05): Karp–Rabin fingerprints find every exact occurrence of a pattern in expected time. Why can't a rolling fingerprint tell you how many letters disagree at each alignment?
Check yourself
- Why is entry of the convolution of two count vectors exactly the number of pairs with total , and when does the transform modulo return that number unchanged?
- Convolve a text of length with a reversed pattern of length . Entry is the dot product at alignment . What do the entries before and after hold?
- For disagreements at every alignment, compare the convolution method with the direct count. For which reads (length, number of distinct letters) does the direct count win?
Topic list modeled on the public schedule of CMU 15-451/651 Algorithm Design and Analysis (Fall 2025). All lecture text, proofs, examples, code, and exercises are original to SciMigo; no CMU course materials are reproduced. This course is independent and not affiliated with or endorsed by Carnegie Mellon University.