Week 07 · Phase 3 · The language of growth

Counting Steps Instead of Seconds

Seconds depend on your laptop; step counts don't. Meet T(n) and the dominant term.

Big question: How do we compare algorithms without comparing computers?T(n)dominant term≈2.5 hours
By the end of this week you can
  • explain why seconds are a poor way to compare two algorithms;
  • write down a step-count formula T(n) for short pieces of code;
  • find the dominant term of a formula and justify dropping the rest;
  • show that constant factors do not change the growth pattern;
  • decide which operation to count, and see why the choice never changes the dominant term;
  • tell arithmetic growth (adding a step) from geometric growth (multiplying), and read log n out of the second;
  • connect a step-count formula to the ratio column you measured last week.

7.1The problem with seconds

Last week you measured seconds, and seconds were useful. But they carry passengers. A time of 0.04 s describes:

Publish "0.04 seconds" and it is obsolete when you upgrade your laptop. Publish "one step per item in the list" and it is true forever, on every machine, in every language. That is why the field settled on counting steps as the primary description and uses timing as supporting evidence.

Both, not either

Step counting predicts; timing verifies. A theory that disagrees with the stopwatch is wrong, and a stopwatch reading you cannot explain is a mystery worth chasing. Every serious report in this course carries both.

7.2T(n): the step-count formula

T(n) is simply "how many steps this code takes when the input size is n". We count assignments — the cost of storing one value — because they are easy to see and roughly proportional to everything else.

count the assignments
def sum_to(n):
    total = 0                          # 1 assignment
    for number in range(1, n + 1):     # n assignments to 'number'
        total = total + number         # n assignments to 'total'
    return total

So T(n) = 1 + 2n. For n = 10 that is 21 steps; for n = 1 000, 2 001; for n = 1 000 000, 2 000 001.

Now the formula version of the same job:

no loop at all
def sum_formula(n):
    return n * (n + 1) // 2            # a fixed handful of operations

Here T(n) = 3 or so, whatever n is. That single fact — the formula's step count does not contain n — is exactly what you saw in week 5, when its timings refused to grow.

7.3The dominant term

Real formulas are messier. Suppose you carefully count a piece of code and get:

T(n) = 5n² + 200n + 3000

Which part matters? Put numbers in and watch:

n5n²200n3000share of total from 5n²
105002 0003 0009%
10050 00020 0003 00068%
1 0005 000 000200 0003 00096%
100 00050 000 000 00020 000 0003 00099.96%

For small inputs the constant 3 000 rules. But we are never interested in small inputs — small inputs are fast under any method. As n grows, the n² term takes over completely and everything else becomes rounding error.

So we throw away the small stuff and keep the shape:

Two rules of simplification
  1. Keep only the fastest-growing term. 5n² + 200n + 3000 → 5n².
  2. Drop the constant multiplier. 5n² → n². The 5 is a property of your machine and your coding style, not of the method.

7.4Why dropping the 5 is honest

It feels like cheating. It is not, and the reason is worth a moment. Compare two methods:

At n = 50, A costs 5 000 and B costs 2 500: B wins. At n = 100 they tie at 10 000. At n = 1 000, A costs 100 000 and B costs 1 000 000 — A wins by ten times. At n = 100 000, A wins by a factor of a thousand.

A constant factor buys you a fixed head start; a better growth pattern eventually wins by any margin you like. Buying a computer twice as fast halves your constant — and moves the crossover a little. Choosing a better algorithm changes the shape of the curve, and that is the only thing that survives more data.

When constants do matter

In real work, if n is genuinely always small and always will be, a "worse" algorithm with a tiny constant can be the right choice. Analysis tells you which method wins eventually; engineering judgment tells you whether you live in "eventually". Say so explicitly in your reports rather than pretending the constant does not exist.

7.5Which operation do you count?

A fair question hangs over §7.2: we chose to count assignments, but why those? A loop also does comparisons, additions, and list look-ups. Would counting a different one change the answer?

Take a linear search and count the thing that actually does the work — the comparison item == target:

count comparisons
def contains(data, target):
    comparisons = 0
    for item in data:
        comparisons += 1
        if item == target:
            return True, comparisons
    return False, comparisons

haystack = list(range(1000))
print(contains(haystack, 999))   # found at the very end
print(contains(haystack, -1))    # missing: full pass
(True, 1000) (False, 1000)

In the worst case (target missing, or last) the loop makes n comparisons — so counting comparisons gives T(n) = n. Counting assignments to item would also give n. Counting the loop's additions: also n. Every reasonable choice lands on "some constant times n", so the dominant term — the part we keep — is n whichever operation you picked.

The choice sets the constant, not the shape

Counting comparisons might give T(n) = n; counting every basic operation in the loop body might give T(n) = 4n. Different constant, identical growth. Since we throw the constant away anyway (§7.3), the choice of operation cannot change the class. Pick the operation that is easiest to see, and say which one you counted.

This is also why the constant is machine-dependent and the shape is not. On a fast CPU a comparison might take 2 nanoseconds; on a slow one, 20. In C it is faster than in Python. Every one of those facts scales the constant up or down — none of them turns an n into an n². The growth pattern is a property of the algorithm; the constant is a property of everything else.

7.6Arithmetic growth vs geometric growth

There is one more distinction that decides an algorithm's class, and it hides in how a loop variable changes. Watch two loops that both stop at n:

adding vs multiplying
def arithmetic_steps(n):     # i goes 0, 1, 2, 3, ... up to n
    steps = 0
    i = 0
    while i < n:
        i = i + 1            # ADD a constant each pass
        steps += 1
    return steps

def geometric_steps(n):      # i goes n, n/2, n/4, ... down to 1
    steps = 0
    i = n
    while i > 1:
        i = i // 2           # MULTIPLY (by 1/2) each pass
        steps += 1
    return steps

for n in [8, 1024, 1000000]:
    print(f"n={n:>8}   adding: {arithmetic_steps(n):>8}   halving: {geometric_steps(n)}")
n= 8 adding: 8 halving: 3 n= 1024 adding: 1024 halving: 10 n= 1000000 adding: 1000000 halving: 19

The gap is enormous, and it comes entirely from add versus multiply. When a loop variable adds a fixed amount, it needs about n passes to reach n — that is arithmetic growth, and it gives O(n). When a loop variable multiplies (here, halves) each pass, it reaches its target in only about log₂ n passes — geometric growth, and it gives O(log n). Doubling n adds exactly one pass to the halving loop, which is the "small fixed addition, not a multiple" row you spotted in last week's ratio table.

The one-line test

Look at what happens to the loop variable each pass. Adding a constant → linear in that variable. Multiplying by a constant → logarithmic. This single question separates a scan (O(n)) from a halving search like the one in week 8's binary search (O(log n)).

A second dominant-term example: the triangular loop

Nested loops are not always a clean n². Here the inner loop's length depends on the outer counter:

inner loop grows with i
def triangular(n):
    count = 0
    for i in range(n):
        for j in range(i):       # runs i times, not n
            count += 1
    return count

The inner loop runs 0 times, then 1, then 2, … up to n−1. Add those up:

T(n) = 0 + 1 + 2 + … + (n−1) = n(n−1)/2 = ½n² − ½n  →  keep n²

Only half the full grid of n² is visited, yet the dominant term is still n²: the ½ is a constant multiplier, and we drop it. So this loop is O(n²) and its ratio column will still read ≈ 4 when you double n — exactly the behaviour you will meet again in week 9's "checking off" anagram solution. Half of a quadratic is still a quadratic.

7.7Counting practice

Three snippets. Count before reading the answer.

snippet A
total = 0
for i in range(n):
    total = total + i
for j in range(n):
    total = total + j
snippet B
count = 0
for i in range(n):
    for j in range(n):
        count = count + 1
snippet C
count = 0
for i in range(n):
    for j in range(10):
        count = count + 1
Answers

A: two loops one after another, T(n) = 1 + 2n + 2n = 4n + 1 → keep n. Sequential loops add.

B: nested loops, T(n) = 1 + n² (counting the inner assignment) → keep n². Nested loops multiply.

C: nested, but the inner loop runs a fixed 10 times regardless of n: T(n) = 1 + 10n → keep n. This is the trap. A nested loop is only quadratic when both loops grow with the input.

The rule people get wrong

"Nested loop means n²" is false. What matters is how many times each loop runs as a function of the input. Two loops side by side add; loops inside each other multiply; a loop with a fixed count is just a constant factor.

7.8Try it yourself

Task 1 — count, then verify

For each of snippets A, B and C above: write down T(n), then add a step counter and run at n = 10, 100 and 1 000. Does the counter match your formula exactly? Where does it differ, and why?

What to look for

The counter should match to within the one or two setup assignments you may have counted differently. That "off by one or two" is precisely the constant term the simplification rules tell you to discard.

Task 2 — simplify

Reduce each to its dominant term, no constants:

  1. T(n) = 3n + 17
  2. T(n) = n² + n + 1
  3. T(n) = 2n³ + 500n² + 10 000n
  4. T(n) = 42
  5. T(n) = 6n + 3n log n
Answers

n; n²; n³; a constant (does not grow); n log n — because n log n grows faster than n.

Task 3 — the crossover, measured

Write one function that does 100 steps per item (a loop of 100 inside a loop of n) and one that does a full nested pass (n inside n). Time both from n = 10 upward and find the n where the second becomes slower. Compare with the predicted crossover at n = 100.

What you should see

A crossover near n = 100, though the exact point wobbles with machine noise and Python overheads. The lesson is not the exact number: it is that the crossover exists, is predictable from the formulas, and that beyond it the gap only widens.

Task 4 — count a different operation

Instrument the worst-case contains from §7.5 twice: once counting comparisons, once counting every assignment in the loop body. Run both at n = 100, 200, 400 on a missing target. Do the two counts differ? Do they grow in the same proportion? What does that tell you about which operation to count?

Answer

The raw counts differ by a constant factor (say n vs 3n), but both double when n doubles — the ratio column is identical. The choice of operation set the constant, not the shape, so either is fine as long as you state which you counted.

Task 5 — the triangular loop

Predict T(n) for the triangular function in §7.6, then add a counter and run it at n = 500, 1 000, 2 000. Does count match n(n−1)/2? What is the ratio when n doubles, and why is it ≈ 4 even though only half the grid is visited?

Expected

count equals n(n−1)/2 exactly: 124 750, 499 500, 1 999 000. The ratio is ≈ 4 because the dominant term is ½n² — doubling n multiplies ½n² by 4. The ½ is a constant we drop; half a quadratic is still a quadratic.

Task 6 — adding vs multiplying

Run arithmetic_steps and geometric_steps from §7.6 at n = 1 000, 1 000 000 and 1 000 000 000. For each, say how the step count changes when you multiply n by 1 000. Which one barely moves, and which log connects it to week 6's "adds a fixed amount per doubling" row?

Expected

The adding loop's count multiplies by 1 000 each time (linear). The halving loop's count rises by only about 10 each time (log₂ 1000 ≈ 10) — that is O(log n). Doubling n adds exactly one step to the halving loop, which is precisely the logarithmic row of last week's ratio table.

7.9Self-check

T(n) = 4n² + 1000n + 50 000. Which term decides the behaviour for large n?

Growth beats size. At n = 1 000 the n² term is already 25 times everything else combined, and the gap keeps widening.

Two loops written one after the other, each over n items, give:

Sequential loops add. Only nesting multiplies.

Method A takes 100n steps, method B takes n². For which inputs is B actually faster?

They cross at n = 100. Below it the constant factor dominates and B wins; above it the growth pattern takes over and A wins by ever more.

A loop variable starts at n and is halved each pass until it reaches 1. The number of passes grows like:

Multiplying (here, halving) the variable each pass is geometric growth: it reaches the target in about log₂ n steps. Adding a constant instead would be linear.

You count comparisons instead of assignments and get a different constant factor. The dominant term:

Different operations differ by a constant factor, and we drop constants. The growth pattern belongs to the algorithm; the constant belongs to your choice of operation and your machine.

7.10Homework

Due before week 8 — about 3 hours
  1. Create AA_Week07.ipynb and complete Tasks 1–6.
  2. Take any three functions you wrote in weeks 3–6. For each: write T(n), simplify it, then verify with a step counter at three sizes.
  3. For one of them, put the predicted step counts and the measured seconds side by side in one table. Do they rise in the same proportions?
  4. Using Task 6, write two sentences contrasting arithmetic and geometric growth in your own words, and name one algorithm from an earlier week that is each kind.
  5. Write five sentences on this question: your colleague says "just buy a faster computer". Using your own crossover measurement from Task 3, explain when they are right and when they are badly wrong.

7.11Words from this week

TermMeaning in plain words
T(n)The number of steps a piece of code takes for input size n.
dominant termThe fastest-growing part of a formula; the only part that matters for large n.
constant factorThe multiplier in front (the 5 in 5n²) — machine, language and style, not method.
basic operationThe step you choose to count (comparison, assignment…); it sets the constant, not the shape.
arithmetic growthA variable that adds a constant each pass — reaches n in about n steps (linear).
geometric growthA variable that multiplies each pass — reaches n in about log n steps.
crossover pointThe input size where a method with better growth overtakes one with a smaller constant.
machine independenceThe reason we count steps: the answer stays true when the hardware changes.

7.12The RAM model — counting the way computer scientists do

You have been counting steps all week without asking a slightly awkward question: what exactly is a step? Adding two numbers, comparing two letters, reading data[i] out of a list — are those all one step, or does a big multiplication cost more than a small one? The computer scientist Steven Skiena answers this with a deliberately simple picture called the Random Access Machine, or RAM model, and it is the quiet foundation under everything in this course.

The idea is a bargain. We agree to pretend that every simple operation costs exactly one time unit, and that this one unit is the same no matter what the values are. On this imaginary machine:

Once we accept that, the running time of a program is just the total number of those steps it performs. That is the whole model. It is exactly the number your step counters have been printing: a program that runs a one-step body n times costs n, and one that runs it n² times costs n². Counting assignments in §7.2 was a first, honest instance of RAM-model counting — we simply picked one operation and tallied it.

Is this true? Not literally. On a real chip a multiply can take longer than an addition, a value already in cache is fetched far faster than one out in main memory, and a number too big to fit a machine word costs more still. The RAM model wilfully ignores all of that. It is a simplification, and a proud one — the same kind of move a physicist makes when they drop air resistance to see the shape of a falling body. What we buy with the simplification is enormous: we can compare two algorithms with pencil and paper, on no particular machine, in no particular language, and get an answer that stays true when the hardware is replaced. That is the machine independence you met in §7.1, now given a name and a rule.

Why the small lie is worth it

Because we throw the constant factor away anyway (§7.3), the exact per-operation cost never reaches the final answer. Whether a multiply "really" costs one unit or three, the dominant term of a loop that runs n² times is still n². The RAM model lets us stop arguing about nanoseconds and start comparing shapes — which is the only comparison that survives the next generation of laptops.

7.13Chapter problem set — Skiena 2.10

Here are four classic exercises from Skiena's The Algorithm Design Manual, worked at our level. Three of them are little loop puzzles written in pseudocode; for each we will first rewrite the loops as readable Python — so you can drop them into a notebook and run them — then trace what they count and read off the growth. The fourth is about counting the real work inside a familiar formula. Reach for the same tools you have used all week: the RAM-model step count, the dominant term, and a Python counter to check the arithmetic.

Problem 2-1 · the mystery function

Skiena gives this triple-nested loop and asks: what value does it return as a function of n, and what is its worst-case running time in Big-O? Written out in Python:

mystery.py
def mystery(n):
    r = 0
    for i in range(1, n):              # i = 1 .. n-1
        for j in range(i + 1, n + 1):  # j = i+1 .. n
            for k in range(1, j + 1):  # k = 1 .. j
                r += 1
    return r
Worked solution

Start from the inside and work out. The innermost loop runs k = 1 .. j, which is exactly j passes, and each pass adds 1 to r. So the inner loop contributes j to the total. That means the whole function is really adding up a pile of j-values:

r = Σi=1n−1 Σj=i+1n j

Do not panic at the double sum — the point is only its shape. There are three loops stacked inside each other, and all three ranges grow with n: the outer runs about n times, the middle about n times, and the inner up to n times. A quantity that is "about n, times about n, times about n" is cubic — proportional to n³. Every extra factor of n from a nested loop that grows with the input multiplies the work again, exactly the "loops inside each other multiply" rule from §7.7, here applied three times.

So the returned value r is a cubic polynomial in n, and the worst-case running time is Θ(n³), i.e. O(n³). (There is no early return or data-dependent branch, so best and worst case are the same — the loops always run to completion.) Confirm both claims with a counter, and watch the tell-tale ×8 when you double n:

check the cubic growth
prev = None
for n in [5, 10, 20]:
    r = mystery(n)
    ratio = "-" if prev is None else f"{r / prev:.2f}x"
    print(f"n={n:>3}   r={r:>6}   ratio {ratio}")
    prev = r
n= 5 r= 40 ratio - n= 10 r= 330 ratio 8.25x n= 20 r= 2660 ratio 8.06x

The counter matches the value the function returns, and each doubling of n multiplies r by roughly 8. A ×8 response to a ×2 input is the fingerprint of cubic growth: 2³ = 8, just as a ×4 response meant an exponent of 2 in §7.6.

Counting by j instead of by the loops makes it exact: each value j (from 2 to n) is reached by j−1 choices of i, so r = Σj=2n j(j−1) = (n3 − n)/3. Check: n = 5 gives (125−5)/3 = 40, exactly what the function returns.

Answer: mystery(n) returns r = (n3 − n)/3, and its worst-case running time is Θ(n3). Doubling n multiplies the value by about 8, confirming the cube.

Problem 2-2 · the pesky function

A second triple-nested loop, this time with the inner range depending on both counters. What does it return, and what is its running time? In Python:

pesky.py
def pesky(n):
    r = 0
    for i in range(1, n + 1):          # i = 1 .. n
        for j in range(1, i + 1):      # j = 1 .. i
            for k in range(j, i + j + 1):  # k = j .. i+j
                r += 1
    return r
Worked solution

Again begin at the innermost loop. It runs k = j .. i+j. The number of integers from j to i+j inclusive is

(i + j) − j + 1 = i + 1.

Notice what happened: the j cancelled clean out. However far up the range starts, its length is always i + 1 — it does not depend on j at all. That is the whole trick of this problem. So the inner loop contributes i + 1 every time, and the two outer loops just repeat that:

r = Σi=1n Σj=1i (i + 1) = Σi=1n i·(i + 1)

The middle line simplifies because the summand (i + 1) does not mention j, so adding it up i times (as j runs from 1 to i) is just multiplying it by i — giving i(i+1). Now i(i+1) = i² + i, and summing a term that grows like i² across n values of i lands us at something proportional to n³. (The tidy closed form is n(n+1)(n+2)/3, plainly a cubic — but you do not need it to see the shape.)

Confirm the value and the class with a counter, comparing against that closed form:

match the formula, watch the growth
for n in [5, 10, 20]:
    r = pesky(n)
    formula = n * (n + 1) * (n + 2) // 3
    print(f"n={n:>3}   r={r:>5}   n(n+1)(n+2)/3={formula:>5}")
n= 5 r= 70 n(n+1)(n+2)/3= 70 n= 10 r= 440 n(n+1)(n+2)/3= 440 n= 20 r= 3080 n(n+1)(n+2)/3= 3080

The counter sits exactly on n(n+1)(n+2)/3. The doubling ratios here (70 → 440 → 3080, about 6.3× then 7.0×) are still climbing towards 8 rather than sitting on it, because at these small sizes the lower-order +n² and +n parts of the cubic have not yet faded — push n higher and the ratio settles on 8, exactly the "small inputs are noisy, the trend is cubic" story of §7.3.

Answer: The inner loop always runs (i+j)−j+1 = i+1 times, so pesky(n) returns Σi=1n i(i+1) = n(n+1)(n+2)/3, a cubic polynomial, and its running time is Θ(n³) = O(n³).

Problem 2-36 · nested summations

This one asks you to describe the work rather than compute a return value. The pseudocode prints "foobar" from inside three nested loops (take n even). (a) Write its running time T(n) as three nested summations; (b) simplify to a Big-O class. In Python:

foobar.py
def foobar(n):
    for i in range(1, n // 2 + 1):     # i = 1 .. n/2
        for j in range(i, n - i + 1):  # j = i .. n-i
            for k in range(1, j + 1):  # k = 1 .. j
                print("foobar")
Worked solution

(a) The nested-sum form. Each loop becomes one summation sign, and the printed line — the thing we are counting — is the "1" being summed at the very centre. Reading the ranges straight off the loops:

T(n) = Σi=1n/2 Σj=in−i Σk=1j 1

The innermost sum, Σk=1j 1, is just "add 1 to yourself j times", which equals j. Substituting that collapses the triple sum to a double one:

T(n) = Σi=1n/2 Σj=in−i j

The inner sum now adds up the whole numbers from j = i up to j = n − i. That is a run of consecutive integers, and a run of consecutive integers up to about n adds up to something on the order of n² (the triangular-number idea from §7.6: 1 + 2 + … + m ≈ m²/2). So the inner sum alone is already quadratic in n for the early values of i.

(b) Simplify. We then repeat that quadratic amount of work for each i from 1 up to n/2 — that is, about n more times. "About n²" of work, done "about n" times, is cubic. So T(n) = Θ(n³) = O(n³). The n/2 and the shrinking i .. n−i window only change constant factors — half a cube is still a cube, exactly as half a square was still a square in §7.6 — they do not touch the class.

Confirm the cube with a counter that tallies the prints instead of doing them:

count the foobars
def foobar_count(n):
    total = 0
    for i in range(1, n // 2 + 1):
        for j in range(i, n - i + 1):
            for k in range(1, j + 1):
                total += 1             # one 'print' would fire here
    return total

prev = None
for n in [10, 20, 40]:
    t = foobar_count(n)
    ratio = "-" if prev is None else f"{t / prev:.1f}x"
    print(f"n={n:>3}   prints={t:>5}   ratio {ratio}")
    prev = t
n= 10 prints= 125 ratio - n= 20 prints= 1000 ratio 8.0x n= 40 prints= 8000 ratio 8.0x

Each doubling multiplies the print count by exactly 8 — a clean ×8 on a ×2, the signature of Θ(n³).

Show the work explicitly. The inner sum of j from i to n−i is [(n−i)(n−i+1) − (i−1)i]/2; writing n = 2m:

simplifying the sum
T(n) = Σ [ (2m-i)(2m-i+1) - (i-1)i ] / 2   (i = 1..m, n = 2m)
     = Σ m(2m - 2i + 1)
       # numerator simplifies to 2m(2m - 2i + 1)
     = m [ (2m-1) + (2m-3) + ... + 1 ]
       # the odd numbers 1 .. 2m-1
     = m · m2 = m3 = (n/2)3 = n3/8
       # the first m odd numbers sum to m^2

Answer: (a) T(n) = Σi=1n/2 Σj=in−i Σk=1j 1, which simplifies to Σi=1n/2 Σj=in−i j once the inner sum of 1's is replaced by j. (b) Carrying the sums through exactly (write n = 2m; the inner sum of j from i to n−i summed over i = 1..m) collapses to the clean closed form T(n) = m3 = (n/2)3 = n3/8. Check: n = 4 gives 8 and n = 10 gives 125, both matching a direct count. So T(n) = n3/8 = Θ(n3) — confirmed by the ×8-per-doubling counter.

Problem 2-5 · evaluating a polynomial

The straightforward way to evaluate a polynomial p(x) = a₀ + a₁x + a₂x² + … + aₙxⁿ is to build each power of x as you go. Skiena asks: (a) how many multiplications and additions does it do in the worst case; (b) how many multiplications on average; (c) can you do better? The method, as Python:

evaluate_naive.py
def evaluate_naive(a, x):
    # a is the list of coefficients: a[0], a[1], ..., a[n]
    n = len(a) - 1
    p = a[0]
    xpower = 1
    for i in range(1, n + 1):
        xpower = x * xpower            # one multiplication
        p = p + a[i] * xpower          # one multiplication, one addition
    return p
Worked solution

(a) Worst case. Look inside the loop and just count the arithmetic. The line xpower = x * xpower is one multiplication. The line p = p + a[i] * xpower is one multiplication (a[i] * xpower) and one addition (p + …). So every pass of the loop costs two multiplications and one addition, and the loop runs n times (for i = 1 .. n). Total: 2n multiplications and n additions.

(b) Average case. Here is the quiet insight: there is no if, no break, nothing in this loop that depends on the values of the coefficients or of x. The loop always runs the same n times and always does the same arithmetic each pass. When behaviour never branches on the data, the average case cannot differ from the worst case — so the average is also 2n multiplications. (Contrast the linear search of §7.5, whose count did depend on where the target sat.)

(c) Can we do better? Yes — Horner's rule. The waste in the naive method is that first multiplication, the one that keeps rebuilding xpower. Horner's rule rewrites the polynomial by factoring x out repeatedly — a₀ + x(a₁ + x(a₂ + … )) — so each step folds in one coefficient with a single multiply-and-add:

evaluate_horner.py
def evaluate_horner(a, x):
    n = len(a) - 1
    p = a[n]                           # start from the top coefficient
    for i in range(n - 1, -1, -1):     # i = n-1, n-2, ..., 0
        p = p * x + a[i]               # one multiplication, one addition
    return p

Now the loop body is a single p * x + a[i]: one multiplication and one addition per pass, over n passes. That is n multiplications and n additions — the additions are unchanged, but the multiplications have been halved, from 2n to n. The reason it needs only n multiplications is that it never builds the powers of x separately; each existing running total is multiplied by x exactly once as the next coefficient is folded in.

Check that both agree on the answer while the counts differ:

same value, fewer multiplications
def naive_counts(a, x):
    n = len(a) - 1; m = adds = 0; p = a[0]; xpower = 1
    for i in range(1, n + 1):
        xpower = x * xpower;      m += 1
        p = p + a[i] * xpower;    m += 1; adds += 1
    return p, m, adds

def horner_counts(a, x):
    n = len(a) - 1; m = adds = 0; p = a[n]
    for i in range(n - 1, -1, -1):
        p = p * x + a[i];         m += 1; adds += 1
    return p, m, adds

a = [2, -3, 1, 4]                # 2 - 3x + x^2 + 4x^3, so n = 3
print("naive :", naive_counts(a, 2))    # (value, mults, adds)
print("horner:", horner_counts(a, 2))
naive : (32, 6, 3) horner: (32, 3, 3)

Both return 32, but the naive method spent 6 multiplications (2n = 2×3) where Horner's rule spent only 3 (n = 3). Same additions, half the multiplications — and both are still O(n), the best class possible since you must at least touch every coefficient once.

Answer: (a) 2n multiplications and n additions in the worst case. (b) 2n multiplications on average too, because the loop has no data-dependent branch, so average equals worst. (c) Yes — Horner's rule, p = p*x + a[i] evaluated from the top coefficient down, uses only n multiplications and n additions, halving the multiplications.

Where this leads

You can boil a step count down to its dominant term. Week 8 gives that shape its standard name and shorthand: Big-O.