On this page

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.

1 2 2 3 3 4 1 3 4 5 6 8 2 4 5 6 7 9 3 5 6 7 8 10 3 5 6 7 8 10 4 6 7 8 9 11 4 6 7 8 9 11 5 7 8 9 10 12
All 36 rolls of the odd dice (rows: first die, columns: second die), each cell the total. The six green cells are the rolls that total 7, as many as ordinary dice give.

The table answers the question for two dice. It does not scale: the same question for two lists of 105 numbers between 0 and 106 (how many pairs, one number from each list, give each possible total?) has 1010 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 The Fast Fourier Transform, which computes a convolution fast. 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 |A|·|B| 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 105 numbers in [0,106] have 1010 pairs, but only 2·106+1 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 A by its count vector: av is the number of times the value v appears in A. Describe B by bw in the same way. The first odd die has count vector (a1,a2,a3,a4)=(1,2,2,1), and the answer is then a single formula in these two vectors.

Convolution counts sums

The convolution of two sequences a and b is the sequence c=a*b with

ck=∑i+j=kaibj.

It is the rule for multiplying polynomials: if A(x)=∑iaixi and B(x)=∑jbjxj, then ck is the coefficient of xk in A(x)B(x), because xixj=xi+j.

Counting lemma

If a and b are the count vectors of lists A and B, then ck is the number of pairs (x,y) with x from A, y from B and x+y=k.

Proof. Group the pairs by their values. The pairs whose first number has value i and whose second has value j number exactly aibj. Such a pair has total k exactly when i+j=k, so summing aibj over all i+j=k counts each pair with total k once and no other pair. ◻

So the answer to the dice question is one convolution. Draw the products aibj in a grid, and ck is the sum along one anti-diagonal, the cells with i+j=k:

1: 1 2: 2 3: 2 4: 1 1: 1 2: 0 3: 1 4: 1 5: 1 6: 1 7: 0 8: 1 1 0 1 1 1 1 0 1 2 0 2 2 2 2 0 2 2 0 2 2 2 2 0 2 1 0 1 1 1 1 0 1
The products a_i · b_j of the two count vectors (labels 'value: count'). The green anti-diagonal holds the pairs of values with i + j = 7; it sums to 1 + 2 + 2 + 1 = 6.

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. The transform from The Fast Fourier 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

x+x2+…+x6=x(1+x)(1+x+x2)(1−x+x2).

Two ordinary dice multiply that by itself. The odd dice deal out the same eight factors differently. The first die gets x(1+x)(1+x+x2), which is x+2x2+2x3+x4, its count vector, and the second gets x(1+x)(1+x+x2)(1−x+x2)2, which multiplies out to x+x3+x4+x5+x6+x8. 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

The Fast Fourier Transform computes the convolution of two sequences with lengths adding to at most N+1, for N a power of two, by three transforms of length N, one pointwise product and one scaling. It works with exact integers modulo the prime p=998244353 (a number-theoretic transform), and it costs

32Nlog2N+2N

multiplications modulo p, in the worst case, with no randomness. The model is the word RAM: numbers below p fit in a machine word, and each addition or multiplication modulo p 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 [0,U] has length U+1 whether the list has 3 numbers or 105.

It is exact only when every true ck is below p. The transform returns each ck modulo p, so an answer of p or more comes back wrong without warning. A count is safe when it is provably small. For two lists without repeated values, ck≤min(|A|,|B|), because each x in A has at most one partner y=k−x in B. With repeats, ck can reach |A|·|B|, and past p you need a second prime and the Chinese remainder theorem (The Fast Fourier Transform). A floating-point FFT with complex numbers is faster in practice but rounds, and that module showed it rounding wrong on large inputs. Every count in this module stays below p.

For the big instance, two lists of 105 distinct numbers in [0,106], the totals run from 0 to 2·106, so N=221. That is about 7·107 multiplications against 1010 pair visits, and each count is at most 105, far below p.

def ntt_mults(N):
    """Multiplications of one exact convolution of padded length N (the count in
    The Fast Fourier Transform)."""
    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 A by its smallest value, ℓA, so that index v−ℓA holds the count of v, and do the same for B. A pair (x,y) then lands at index

k=(x−ℓA)+(y−ℓB),

so entry k of the convolution counts the pairs with total k+ℓA+ℓB. 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 106 to 106+100 need a vector of length 101, not 106+101.

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 t0,…,tn−1 and a short one q0,…,qm−1, and place q against t starting at position s. Their correlation at s is the dot product of the pattern with the window it covers:

rs=∑j=0m−1ts+jqj,0≤s≤n−m.

Now let qR be q reversed, qiR=qm−1−i, and convolve. In entry s+m−1 of t*qR, the index pairs are (s+j,m−1−j), one for each j, and they sum to s+m−1 as they should. Their products are ts+jqj. So

rs=(t*qR)s+m−1,

and one convolution gives the dot product at every one of the n−m+1 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 x, let ti(x)=1 when the reference has x at position i, and qj(x)=1 when the read has x at position j. The correlation of these two indicator sequences at s counts the positions where both have an x. Summed over the letters, it counts every agreement at alignment s, so the number of disagreements is

ds=m−∑xrs(x).

A letter that never occurs in the read contributes nothing, so a read with σ distinct letters needs σ correlations, each one convolution.

T A G C d 0 1 2 3 4 5 6 7 8 9 10 11 12 0 0 1 0 0 0 0 1 1 0 0 0 1 0 0 1 0 0 1 0 0 1 0 0 0 1 0 0 1 1 0 0 0 0 1 0 0 0 1 0 0 0 0 0 0 0 0 1 0 0 0 0 4 4 1 3 4 3 4 3 0 4 4 4 1
The read TAGC against CCTAGGATTAGCTAGA. Rows T, A, G, C: agreements on that letter at each alignment s (one correlation each). Row d: disagreements, 4 minus the column sum. Green: the exact fit at s = 8; yellow: one disagreement at s = 2 and s = 12.

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 ds=m−∑xrs(x) already counts it: it is one of the m 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 A, B, C of integers in [−U,U], are there x in A, y in B and z in C with x+y+z=0? This is 3SUM. Sorting A and B and walking two pointers inward once for each z answers it with O(n2) comparisons and additions for lists of n numbers.

Convolution answers it by counting. By the counting lemma, the convolution of the count vectors of A and B gives, for every total k, the number of pairs with x+y=k. A triple exists exactly when some z in C finds a nonzero count at k=−z. Summing the counts over the z in C gives the number of triples. The cost is O(n) to build the vectors and read the answers, plus one convolution of length about 4U: O(n+UlogU) operations in the word RAM, in the worst case. That is below n2 when the values lie in a range small compared with n2.

This does not contradict what is believed about 3SUM. In one common form, the 3SUM conjecture says that for integers in [−n3,n3], no algorithm on the word RAM runs in O(n2−ε) time for a fixed ε>0. It is unproved, and algorithms faster by logarithmic factors are known. When U is around n3, UlogU is far above n2, 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 −3+4−1 and 5+2−7. For z=3 the pairs would have to total −3, 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 −z for 3SUM. The remaining step is exactness. With the transform working modulo p, each answer is right when the true value is below p. For totals of lists without repeats that is min(|A|,|B|) at most, and an agreement count is at most m.

The costs, in the word RAM with arithmetic modulo p as unit steps, worst case. For totals, the lists hold nA and nB numbers with spans at most U; for alignments, the text has n letters, the read m, with σ distinct letters in the read; for 3SUM, the lists hold n numbers in [−U,U]. The convolution column leaves out the linear time to build the vectors and read the answers.

Problem Direct Convolution
Totals nAnB O(UlogU)
Alignments (n−m+1)m O(σnlogn)
3SUM O(n2) O(UlogU)

Neither column wins everywhere. A convolution pays for the range, the double loop for the pairs. Ten numbers spread over [0,106] make 100 pairs and a convolution of length 221. For alignments, a read with many distinct letters needs many convolutions: with σ near m, the direct count wins. The honest summary is the table, not "convolution is faster".

Measure the claim

Take two lists of n numbers drawn uniformly from [0,214) (seeds 451+n), 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 N), 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
n pairs N 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 n, because the spans barely change: even 100 random numbers spread over most of [0,214). The double loop grows a hundredfold per row. On these instances the two meet at n=896, where n2 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 k cents, for every k 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 k of the convolution of two count vectors is exactly the number of pairs whose shifted values add up to k; with one sequence reversed, entry s+m−1 is the dot product at alignment s.

Complexity achieved: totals of two lists in O(|A|+|B|+UlogU) word-RAM operations, worst case, where U bounds the spans, exact modulo p=998244353 when every count is below p; disagreements at all alignments with σ convolutions of length about 2n; 3SUM on [−U,U] in O(n+UlogU). The double loop costs |A|·|B|, (n−m+1)m and n2.

Failure mode: forgetting the shift or the reversal (the answer lands at the wrong index), or trusting a count at or past p (it comes back reduced modulo p).

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 252, near the 253 up to which a double holds every integer exactly.

Retrieval (Fingerprinting and String Matching): Karp–Rabin fingerprints find every exact occurrence of a pattern in expected O(n+m) time. Why can't a rolling fingerprint tell you how many letters disagree at each alignment?

Check yourself

  1. Why is entry k of the convolution of two count vectors exactly the number of pairs with total k, and when does the transform modulo p return that number unchanged?
  2. Convolve a text of length n with a reversed pattern of length m. Entry s+m−1 is the dot product at alignment s. What do the entries before m−1 and after n−1 hold?
  3. 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.

Your turn

Now try it yourself

Write the code, press Run, and scrub through the frames your program draws. The checks tell you when it works.

Loading the lab…

In this lab

  1. Slide a template along a signal, then do every shift at once

    A vibration sensor on a bridge records one reading per second, and an engineer has a short template, the shape a passing truck leaves in the readings. To see where the template fits, she slides it along the signal: at shift s the template covers readings s to s + m − 1, and the score at that shift is the dot product of the template with the readings it covers. A high score means a good fit.

    First, write slide(signal, template), which does exactly that, one shift at a time. For each shift from 0 to n − m, compute the score, call the given show(signal, template, s, score) to draw it, and collect the scores in a list. The starter does shift 0 only.

    Second, write slide_fast(signal, template), which returns the same list using one call to the given convolve(a, b), whose entry k is the sum of a[i] ∗ b[j] over i + j = k. The template must not be multiplied reading by reading: the check wraps every reading so that each ∗ involving one (or anything computed from one) is counted, and allows only a handful beyond the call to convolve. Decide first which of the two lists to reverse, and at which index of the result the score for shift s lands.

    Before you run it: the demo slides [2, 0, 1] along [4, 1, 0, 3, 5, 2, 0, 1, 6, 2]. How many frames will it record, and at which shift is the score highest? Work it out on paper.

  2. Predict the totals of two lists, then watch one convolution count them

    A weather station logs the change in temperature over the morning and over the afternoon, in whole degrees, on several days. A forecaster wants to know, for every possible total change over a day, how many (morning, afternoon) pairs produce it. The values can be negative, and a value can repeat.

    Write count_vector(values), which returns (low, counts): low is the smallest value and counts[i] is how many times the value low + i appears, for i from 0 up to the largest value minus low. Then write sum_counts(A, B), which returns (low, counts) for the totals: counts[k] is the number of pairs (x, y), x from A and y from B, with x + y == low + k. Use one call to the given convolve, and draw three frames with the given show(label, low, counts): the count vector of A, then of B, then of the totals.

    The check wraps every value so that each arithmetic operation on it (and on anything computed from it) is counted, and allows a few per value, so the double loop over all pairs runs out.

    Answer the three questions first, for A = [-2, 0, 1, 1, 3] and B = [-1, 2, 2, 4], then press Run and compare with your predictions.

  3. Disagreements at every alignment, one correlation per letter

    A lab aligns short reads from a sequencer against a reference strand. For every alignment s from 0 to n − m, the read's letter j sits against the reference's letter s + j, and the lab wants the number of disagreements: the positions where the two letters differ. The read with the fewest disagreements shows where it most likely came from, and a count of 1 or 2 usually means a mutation or a reading error.

    Write disagreements(reference, read), returning the list of disagreement counts for every alignment (an empty list if the read is longer than the reference). Letters are any characters: the reference may hold letters the read never uses, such as N for an unreadable position.

    The starter is correct, and compares letter by letter at every alignment. The reference is kept in a metered store: every letter your code reads from reference or read counts (by index, slice, loop, in or string method), and so does every comparison of a letter you read. A check aligns a read of 600 letters against a reference of 6,000 and allows 12 reads per letter of the two together, so the starter runs out. Use the given convolve, and think about how many calls you need. (The meter is feedback, not security: a copy made with str() is charged once and free after that, and ord() or encode() are not charged.)

    The demo draws the counts for the read GCAT against ATGCGTACGATTGCATGCA. Before running your version, find the alignment with 0 disagreements by hand.

  4. Measure where the convolution overtakes the double loop

    The lesson claims that a convolution pays for the range of the values and the double loop pays for the pairs. Measure it on lists of your own.

    The given convolve is the same exact transform as before, now metered: each call adds to COST["mults"] the multiplications it performs, 3 · (N/2) · log₂ N + 2N for padded length N (three transforms, the pointwise products, the scaling). The given sum_counts(A, B) counts the totals with one convolution of shifted count vectors, as in the second exercise.

    Write three functions. First, double_loop(A, B), which counts the totals by visiting every pair and returns (totals, visits): a dict from total to count, and the number of pairs your loop visited, counted by your loop. Second, measure(A, B), which returns (visits, mults): the visits of your double_loop, and the multiplications sum_counts performs on the same lists, read from COST["mults"] before and after. Third, profile(U, sizes, seed), which returns one row (n, visits, mults) per n in sizes, measured on two lists made by rng = random.Random(seed + n), then A = [rng.randrange(U) for _ in range(n)] and then B the same way.

    The demo profiles U = 2000 at n = 10, 100, 1000 and draws both counts for each n on a log scale. Answer the questions while it runs.

  5. Mastery challenge: evenly spaced pulses

    A radio observatory records the arrival times of bright pulses from one patch of sky, in whole milliseconds within an 8-second window: every time is between 0 and 7,999, and no two pulses share a millisecond. A rotating neutron star would leave pulses at regular intervals, so the first thing the survey computes is how many evenly spaced triples the recording holds: three pulses at times t1 < t2 < t3 with t2 − t1 == t3 − t2. A recording far above the count expected by chance goes to an astronomer.

    Write evenly_spaced(times), returning that count for a list of distinct times. A busy recording has 4,000 pulses, and the survey recomputes the count for every recording in a night, so it has to be quick.

    The starter is correct but looks at every pair of pulses. A check runs two recordings of up to 4,000 pulses and counts the steps your code takes: every line of your code that runs counts once each time it runs (a loop body once per round, a comprehension once per item), while the given convolve and built-ins such as sorted are free. It allows 40 steps per pulse, and the starter runs out after a fraction of its pairs. (The count is feedback, not security: work hidden inside built-in functions is not counted.)

    Answer the four questions first. The demo draws a recording of 12 pulses as ticks on a time line, with your count.