Question 1 · medium
The running time of for i = 1 to n: for j = 1 to i: (constant work) is:
Answer: option C. The body runs 1 + 2 + … + n = n(n + 1)/2 times.
Seconds depend on your laptop; step counts don't. Meet T(n) and the dominant term.
How do we compare algorithms without comparing computers?
n = 5
count = 0
for i in range(n):
for j in range(i):
count += 1
print(count)At n = 5, T = 10. The counter counts the chosen action, not every machine instruction.
| Structure | Exact count of body actions | Growth |
|---|---|---|
| Two separate n-turn loops | n + n = 2n | linear |
| An n-turn loop inside another | n × n = n² | quadratic |
| The triangular loop above | (n² − n)/2 | quadratic |
| Repeatedly halve a positive integer | ⌊log₂ n⌋ | logarithmic |
Read bounds carefully. An inner loop of fixed length 10 gives 10n actions, not n².
| n | 3n² | 5n + 2 |
|---|---|---|
| 1 | 3 | 7 |
| 10 | 300 | 52 |
| 100 | 30,000 | 502 |
Below 100, n² is smaller; above 100, 100n is smaller. Growth classes describe the long run and do not settle every small-input comparison.
Our simple model treats a fixed-size number comparison, array access or arithmetic operation as one step. This makes the count independent of processor speed.
| Safe classroom assumption | When it needs refinement |
|---|---|
| Compare small integers in one step | Very large integers need work proportional to their length. |
| Read one list position in one step | Searching for a value may read many positions. |
| One call is one line of code | The function may contain a loop or a sort. |
State the input size, the counted operation and the assumptions alongside T(n).
10 test questions · 3 written questions · 13 total
Each question is followed by its answer and explanation. Hard questions also include a starting hint and smaller reasoning steps.
Choose one option. Cost questions state their model and whether the bound is tight, expected or worst-case. Here lg means log₂, and heap positions start at 1.
Question 1 · medium
The running time of for i = 1 to n: for j = 1 to i: (constant work) is:
Answer: option C. The body runs 1 + 2 + … + n = n(n + 1)/2 times.
Question 2 · medium · course question
A loop performs one counted operation for each of n items. What is its exact operation count?
Answer: option C. There is one counted operation per item, so the total is n. This excludes any setup or condition checks not included in the chosen count.
Question 3 · medium · course question
Two consecutive loops each perform one counted operation per item over n items. What is the total?
Answer: option A. The loops run one after the other, so add n + n. Multiplication would describe one full loop nested inside each iteration of another.
Question 4 · medium · course question
An outer loop runs n times. Each time, an inner loop performs exactly 3 counted operations. What is the total?
Answer: option D. The inner count is the fixed number 3, independent of n. Repeating it n times gives 3n.
Question 5 · medium · course question
For T(n) = 4n² + 7n + 2, which term eventually dominates?
Answer: option B. The quadratic term eventually grows faster than the linear term and the constant. Its coefficient affects size but not that ordering.
Question 6 · medium · course question
A maximum scan remembers the first item, then compares every remaining item once. How many comparisons are made for 6 items?
Answer: option C. The first item initialises the remembered maximum. Only the remaining 6 − 1 = 5 items require comparisons.
Question 7 · medium · course question
A pair-checking loop visits each unordered pair of 5 distinct positions exactly once. How many pairs does it check?
Answer: option A. Each of five positions has four partners, but that counts each unordered pair twice. The count is 5 × 4 / 2 = 10.
Question 8 · medium · course question
A search finds its target in the first position. What does that single trace establish?
Answer: option D. The trace describes this input. Other placements, including a missing target, can require more work.
Question 9 · medium · course question
Which comparison makes operation counts meaningful across two implementations?
Answer: option B. A cost comparison needs a consistent unit of work and input-size definition. Different counting conventions can produce incomparable numbers.
Question 10 · medium · course question
A loop performs n additions and then one final output action. If these are the only actions being counted, what is T(n)?
Answer: option C. Add the n additions to the single output action. The exact count is n + 1; its eventual growth is linear.
Read each question together with its explanation, trace or proof. Numbering continues from the test questions.
Question 11 · easy
Name the three parts of a proof by induction.
the base case (it holds for the smallest size), the assumption or induction hypothesis (it holds for sizes up to n − 1), and the general case (using the assumption, it holds for size n).
Question 12 · medium
Prove by induction that 1 + 2 + 4 + … + 2ⁿ = 2ⁿ⁺¹ − 1.
base case n = 0: 1 = 2¹ − 1. Assume it holds for n − 1, so 1 + … + 2ⁿ⁻¹ = 2ⁿ − 1. Add 2ⁿ to both sides: the left is the sum up to 2ⁿ and the right is 2ⁿ − 1 + 2ⁿ = 2ⁿ⁺¹ − 1.
Question 13 · hard
Prove that any comparison-based algorithm that finds the maximum of n distinct numbers must make at least n − 1 comparisons.
In simpler words: Count how many values must be ruled out as the maximum.
Starting hint: Every value except the winner must lose at least once.
the algorithm can only declare x the maximum if every other element has lost a comparison to something; otherwise an unlosing element could be the true maximum and the algorithm could not tell. Each comparison produces exactly one loser, and n − 1 elements must each lose at least once, so n − 1 comparisons are needed. The obvious scan achieves it.
Predict on paper, run the code, then explain any difference. Use the animations below to inspect individual steps.
Draw the triangular-loop cells for n = 4. Count them.
Rows have 0, 1, 2, 3 cells: 6 total.
Find the exact count for n = 100, then name its dominant term.
100 × 99 / 2 = 4,950. The dominant term is n²/2.
Replace range(i) with range(10). How does the count change?
The inner body now runs 10n times: linear instead of quadratic.
Three snippets. Count before reading the answer.
total = 0
for i in range(n):
total = total + i
for j in range(n):
total = total + jcount = 0
for i in range(n):
for j in range(n):
count = count + 1count = 0
for i in range(n):
for j in range(10):
count = count + 1A: 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 for counter initialization and updates → keep n. This is the trap. A nested loop is only quadratic when both loops grow with the input.
"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.
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?
Using the same counting convention should give an exact match. Including only setup adds a constant; including loop-variable assignments or more operations per iteration may add input-dependent terms. Reconcile those choices before simplifying the count.
Reduce each to its dominant term, no constants:
n; n²; n³; a constant (does not grow); n log n — because n log n grows faster than n.
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.
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.
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?
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.
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?
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.
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?
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.
T(n) = 4n² + 1000n + 50 000. Which term decides the behaviour for large n?
Two loops written one after the other, each over n items, give:
Method A takes 100n steps, method B takes n². For which inputs is B actually faster?
A loop variable starts at n and is halved each pass until it reaches 1. The number of passes grows like:
You count comparisons instead of assignments and get a different constant factor. The dominant term:
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.
Step counting establishes a result under a cost model; timing tests how that model relates to an implementation. A disagreement calls for checking the model, input scope and measurement conditions. Every serious report in this course carries both.
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 — as an explicit teaching cost model. Check that omitted work is bounded per iteration before using this count to describe overall growth.
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:
def sum_formula(n):
return n * (n + 1) // 2 # a fixed handful of operations
If we count each arithmetic operation as one step, this expression uses a fixed number of arithmetic operations. Python integers can grow in bit length, so actual arithmetic cost is not constant for arbitrarily large integers. 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.
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:
| n | 5n² | 200n | 3000 | share of total from 5n² |
|---|---|---|---|---|
| 10 | 500 | 2 000 | 3 000 | 9% |
| 100 | 50 000 | 20 000 | 3 000 | 68% |
| 1 000 | 5 000 000 | 200 000 | 3 000 | 96% |
| 100 000 | 50 000 000 000 | 20 000 000 | 3 000 | 99.96% |
For small inputs the constant 3 000 can dominate, and such inputs may matter in practice. As n grows, the n² term dominates this polynomial: the relative shares of the linear and constant terms approach zero.
So we throw away the small stuff and keep the shape:
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.
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.
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:
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
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.
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), these representative counts have the same class. This requires the chosen operation to track total work within constant factors; counting only a rare operation can miss the dominant work. State your counting convention.
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.
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:
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)}")
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.
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)).
Nested loops are not always a clean n². Here the inner loop's length depends on the outer counter:
def triangular(n):
count = 0
for i in range(n):
for j in range(i): # runs i times, not n
count += 1
return countThe 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.
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:
+, −, ×, a division — is one step each;= is one step;a < b or x == y is one step;data[i], is one step — the "random access" part, meaning any slot is reachable in the same single unit, whether it is the first or the millionth;
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.
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.
From the beginner notes · Lectures 1, 2, 3
To find the maximum of n distinct readings using comparisons, every reading except the winner must lose at least one comparison. A comparison can create only one new loser. Therefore at least n − 1 comparisons are necessary in the worst case.
A single scan achieves n − 1 comparisons, so its comparison count meets the lower bound. This is stronger than saying “my code looks short”: it explains why a different method that finds the answer by comparing values cannot remove that work.
State your model. The argument counts comparisons on distinct values; it does not count every instruction or memory access. To prove the answer and the count, state your assumptions, check the starting case, and explain why each next step keeps the claim true.
Engineering use. Use a maximum-temperature scan to separate a proof of the returned value from a claim about controller timing.
00:00–01:00: launch, predict–run–explain cycle, questions · 01:00–01:10: break · 01:10–02:00: worked variation, peer instruction, questions · 02:00–02:10: break · 02:10–03:00: core mechatronics practice, exam bridge, and exit ticket.
Ask at any point. Weekly self-checks stay private; optional extensions are not collected.
Need a slower explanation? Open the English + Türkçe reference guide.
AA_Week07.ipynb and complete Tasks 1–6.| Term | Meaning in plain words |
|---|---|
| T(n) | The number of steps a piece of code takes for input size n. |
| dominant term | The fastest-growing part of a formula; the only part that matters for large n. |
| constant factor | The multiplier in front (the 5 in 5n²) — machine, language and style, not method. |
| basic operation | The step you choose to count (comparison, assignment…); it sets the constant, not the shape. |
| arithmetic growth | A variable that adds a constant each pass — reaches n in about n steps (linear). |
| geometric growth | A variable that multiplies each pass — reaches n in about log n steps. |
| crossover point | The input size where a method with better growth overtakes one with a smaller constant. |
| machine independence | The reason we count steps: the answer stays true when the hardware changes. |
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.
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:
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
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:
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
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.
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:
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
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:
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}")
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
toward 8. These exact-count deviations are lower-order terms, not measurement noise.
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³).
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:
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")(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:
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 = tEach 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:
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^2Answer: (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.
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:
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
(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:
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:
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))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.
You can boil a step count down to its dominant term. Week 8 gives that shape its standard name and shorthand: Big-O.