Data Structures & Algorithms (DSA) Module 1 – Foundations: problems, correctness and complexity
Counting steps: input size and cost models
Count how often each line of a loop nest runs as a function of n, tell the worst and average cases apart, and find the work hidden in one-line calls.
What you will learn
- Count the basic operations of loops and loop nests as a function of n
- Distinguish best, worst and average cases for one algorithm
- Identify the linear-time work hidden inside one-line statements
- Choose the input size and the basic operation to count for a problem
Before you start
On this page
A timing tells you how long a program took on one computer, once. A count of its steps tells you something that holds on every computer: how the work grows when the input grows. This lesson counts steps exactly, the way later lessons and written exams need, before the next lesson simplifies the counts into Big-O notation.
What to count
Two choices come first.
- The input size, n. It is whatever grows. For a list it is the number of items; for a graph, the number of vertices and edges (two sizes, V and E); for two lists, both lengths. For a single number it is the number of its digits or bits, not the number itself: a whole number in Python can have any number of digits, and adding two numbers of a million digits each means handling every one of those digits.
- The basic operation. It is the step that repeats most, and whose count decides the total: comparisons when searching or sorting, additions when summing, the innermost line of a loop nest.
The cost model says what one step costs. The usual one charges one unit for each basic operation on numbers of a fixed size, and ignores the difference between, say, an addition and a comparison. That is coarse, and deliberately so: it makes counts comparable, and the constant factors it ignores are what timing measures. The CS2023 curriculum lists input size and primitive operations as part of its framework for complexity analysis.
Loops: add, multiply, sum
Four rules cover most loop nests.
-
One after the other, add. A loop of n steps followed by another of n steps makes 2n.
-
One inside the other, multiply. If the inner loop runs m times for each of the n outer passes, the inner line runs n × m times.
-
An inner loop that depends on the outer one, sum. If the inner loop runs i times on pass i, add up 0 + 1 + … + (n − 1):
-
A loop that halves, count the halvings.
while n > 0: n //= 2runs once for each binary digit of n, which is ⌊log₂ n⌋ + 1 times for n ≥ 1. Python’sint.bit_length()gives exactly that number.
This program runs each kind of loop with a counter inside it and checks every count against its formula:
Python · count_loops.py
def one_loop(n):
count = 0
for i in range(n):
count += 1
return count
def two_nested(n):
count = 0
for i in range(n):
for j in range(n):
count += 1
return count
def triangle(n):
count = 0
for i in range(n):
for j in range(i): # the inner loop runs 0, 1, 2, ..., n - 1 times
count += 1
return count
def halving(n):
count = 0
while n > 0:
n //= 2
count += 1
return count
print(f"{'n':>6} {'one loop':>9} {'two nested':>11} {'triangle':>9} {'halving':>8}")
for n in (1, 2, 10, 100, 1000):
counts = one_loop(n), two_nested(n), triangle(n), halving(n)
assert counts == (n, n * n, n * (n - 1) // 2, n.bit_length())
print(f"{n:>6,} {counts[0]:>9,} {counts[1]:>11,} {counts[2]:>9,} {counts[3]:>8,}")
print("Every count equals n, n squared, n(n - 1)/2 and the bit length of n.") Output
n one loop two nested triangle halving
1 1 1 0 1
2 2 4 1 2
10 10 100 45 4
100 100 10,000 4,950 7
1,000 1,000 1,000,000 499,500 10
Every count equals n, n squared, n(n - 1)/2 and the bit length of n.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 count_loops.py
JavaScript · count_loops.mjs
function oneLoop(n) {
let count = 0;
for (let i = 0; i < n; i++) count++;
return count;
}
function twoNested(n) {
let count = 0;
for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) count++;
return count;
}
function triangle(n) {
let count = 0;
for (let i = 0; i < n; i++) for (let j = 0; j < i; j++) count++; // the inner loop runs 0, 1, ..., n - 1 times
return count;
}
function halving(n) {
let count = 0;
while (n > 0) {
n = Math.floor(n / 2);
count++;
}
return count;
}
const bitLength = (n) => 32 - Math.clz32(n); // for 0 <= n < 2 ** 32
const fmt = (x, width) => String(x).replace(/\B(?=(\d{3})+$)/g, ',').padStart(width); // 1000000 -> 1,000,000
console.log(`${'n'.padStart(6)} ${'one loop'.padStart(9)} ${'two nested'.padStart(11)} ${'triangle'.padStart(9)} ${'halving'.padStart(8)}`);
for (const n of [1, 2, 10, 100, 1000]) {
const counts = [oneLoop(n), twoNested(n), triangle(n), halving(n)];
const formulas = [n, n * n, (n * (n - 1)) / 2, bitLength(n)];
if (counts.some((c, k) => c !== formulas[k])) throw new Error(`a count differs from its formula for n = ${n}`);
console.log(`${fmt(n, 6)} ${fmt(counts[0], 9)} ${fmt(counts[1], 11)} ${fmt(counts[2], 9)} ${fmt(counts[3], 8)}`);
}
console.log('Every count equals n, n squared, n(n - 1)/2 and the bit length of n.'); Output
n one loop two nested triangle halving
1 1 1 0 1
2 2 4 1 2
10 10 100 45 4
100 100 10,000 4,950 7
1,000 1,000 1,000,000 499,500 10
Every count equals n, n squared, n(n - 1)/2 and the bit length of n.
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node count_loops.mjs
Runs on this device, in your browser. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Read down a column to see the growth. Going from n = 100 to n = 1,000 multiplies one loop’s count by 10, the nested loops’ count by 100 and the triangle’s by about 100, while the halving loop needs only three more steps.
Best, worst and average cases
Some algorithms take a different number of steps on different inputs of the same size. Linear search compares the key with each item in turn until it finds it:
from fractions import Fraction
def linear_search(items, key):
"""Position of key in items (or -1), and how many comparisons it took to find out."""
comparisons = 0
for i, item in enumerate(items):
comparisons += 1
if item == key:
return i, comparisons
return -1, comparisons
for n in (1, 5, 10, 1000):
items = list(range(n))
found = [linear_search(items, key)[1] for key in items] # every position in turn
absent = linear_search(items, -1)[1]
average = Fraction(sum(found), n)
assert average == Fraction(n + 1, 2)
print(f"n = {n:>4}: best {min(found)}, worst {max(found)}, missing key {absent}, average {average}") Output
n = 1: best 1, worst 1, missing key 1, average 1 n = 5: best 1, worst 5, missing key 5, average 3 n = 10: best 1, worst 10, missing key 10, average 11/2 n = 1000: best 1, worst 1000, missing key 1000, average 1001/2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 linear_search_cases.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
- The best case is the input that needs the fewest steps: the key is the first item, 1 comparison.
- The worst case is the input that needs the most: the key is the last item, or not there at all, n comparisons.
- The average case is the mean over a stated set of inputs. If the key is present and every position is equally
likely, the average is (1 + 2 + … + n) / n = (n + 1) / 2 comparisons, which the program computes exactly with
fractions.Fraction.
The average depends on its assumption. If the key is missing half the time, the average is higher; if users search mostly for the first few items, it is lower. Say which inputs you average over, or the number means nothing. The worst case needs no assumption, which is why guarantees are usually stated for it.
Work hidden in one line
A single line of Python can hide a loop. To make such loops visible, this program uses Probe, a number that counts
every time Python compares it with another:
import random
class Probe:
"""A number that counts how often Python compares it with another."""
comparisons = 0
def __init__(self, value):
self.value = value
def __eq__(self, other):
Probe.comparisons += 1
return self.value == other.value
def __lt__(self, other):
Probe.comparisons += 1
return self.value < other.value
def count(label, action):
Probe.comparisons = 0
action()
print(f"{label:<30} {Probe.comparisons:>8,} comparisons")
items = [Probe(v) for v in range(10_000)]
shuffled = items[:]
random.Random(5).shuffle(shuffled)
count("Probe(9_999) in items", lambda: Probe(9_999) in items)
count("Probe(-1) in items", lambda: Probe(-1) in items)
count("items.index(Probe(5_000))", lambda: items.index(Probe(5_000)))
count("items.count(Probe(7))", lambda: items.count(Probe(7)))
count("max(items)", lambda: max(items))
count("sorted(items), already sorted", lambda: sorted(items))
count("sorted(shuffled)", lambda: sorted(shuffled)) Output
Probe(9_999) in items 10,000 comparisons Probe(-1) in items 10,000 comparisons items.index(Probe(5_000)) 5,001 comparisons items.count(Probe(7)) 10,000 comparisons max(items) 9,999 comparisons sorted(items), already sorted 9,999 comparisons sorted(shuffled) 120,222 comparisons
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hidden_loops.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
x in items compares x with the items one by one until it finds it, so a missing item costs a comparison with every
item. items.index(x) stops at the first match; items.count(x) never stops early. max() compares each item with
the best so far. sorted() notices that sorted input is already in order and makes only n − 1 comparisons, but on
shuffled input it made about 120,000, a little under n log₂ n. Python’s documentation lists the costs of the common
operations on its built-in types, and these are the ones that most often hide inside a loop:
| Operation | Time | Extra space | Note |
|---|---|---|---|
| x in items | O(n) | O(1) | stops when it finds x |
| max(items), min(items) | O(n) | O(1) | looks at every item |
| items[i:j] | O(j − i) | O(j − i) | copies the slice |
| items.pop(0) | O(n) | O(1) | moves every later item |
| items.insert(0, x) | O(n) | O(1) | moves every item |
| sorted(items) | O(n log n) | O(n) | O(n) comparisons on sorted input |
The danger is a hidden loop inside a visible one. for x in a: if x in b: … looks like one pass, but each in on a
list b scans b, so the line runs up to len(a) × len(b) comparisons. Turning b into a set first makes each lookup take
about the same time however large b is.
The cost of pop(0) is easy to see in a measurement. Emptying a list from the front moves the remaining items once per
removal, about n²/2 moves in all, while a deque removes from its front without moving anything:
import time
from collections import deque
def empty_list(n):
items = list(range(n))
while items:
items.pop(0) # removes the first item: every other item moves one place
def empty_deque(n):
items = deque(range(n))
while items:
items.popleft()
def fastest_ms(function, n, repeats=5):
"""The least CPU time of several runs: time.process_time() counts only this program's own work."""
best = float("inf")
for _ in range(repeats):
start = time.process_time()
function(n)
best = min(best, time.process_time() - start)
return best * 1000
print(f"{'n':>7} {'list.pop(0)':>12} {'deque.popleft()':>16} {'ratio':>7}")
for n in (10_000, 20_000, 40_000):
slow, quick = fastest_ms(empty_list, n), fastest_ms(empty_deque, n)
print(f"{n:>7,} {slow:>9.1f} ms {quick:>13.2f} ms {slow / quick:>6.0f}x") Output
n list.pop(0) deque.popleft() ratio 10,000 6.9 ms 0.24 ms 28x 20,000 33.9 ms 0.49 ms 69x 40,000 143.4 ms 1.01 ms 141x
This output changes from run to run: The times change from run to run and from computer to computer.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pop_front_timing.py
Each doubling of n multiplies the time of pop(0) by four or more, and that of popleft() by about two, so the gap
keeps growing. The program uses time.process_time(), which counts only the processor time of this program, so other
programs running at the same moment disturb the measurement less than they disturb a wall clock.
Strings have the same trap. Each s + t builds a new string, so building a long string by adding pieces in a loop
can cost time proportional to the square of its final length: Python’s documentation calls the cost of repeated
concatenation quadratic. CPython can often extend the string in place instead, so a quick test may look fast, but
PEP 8 warns that this optimisation is fragile even in CPython, and implementations without reference counting do not
have it. The documentation’s advice holds everywhere: collect the pieces in a list and join them once with
"".join(pieces).
Exact counts in GATE
GATE papers mix multiple-choice (MCQ), multiple-select (MSQ) and Numerical Answer Type (NAT) questions. According to the GATE 2027 question paper pattern, a wrong MCQ answer loses a third of the question’s marks (1/3 of a mark for a 1-mark question, 2/3 for a 2-mark one), while wrong MSQ and NAT answers lose nothing. When a question shows a loop and asks how many times a line runs, it wants the exact count, not just the growth rate, and a numerical answer has no choices to rule out: work the sum through as in this lesson.
Key takeaways
- Choose the input size (what grows) and the basic operation (what repeats most) before counting anything.
- Loops in sequence add, nested loops multiply, a dependent inner loop sums (0 + 1 + … + (n − 1) = n(n − 1)/2) and a halving loop runs ⌊log₂ n⌋ + 1 times.
- Best, worst and average cases are different functions of n; an average needs a stated assumption about the inputs.
inon a list,index,count,max, slicing,pop(0),insert(0, x)and string concatenation all take time that grows with n, even though each fits on one line.
Exercise
Exercise · Medium · Python, JavaScript
Count loop steps with a formula
Each function below must return, without running the loop, how many times the line count += 1 would run for a given n ≥ 0. Work each formula out on paper first, then write it as a single expression.
count_a(n) (JavaScript: countA(n)) for this loop nest:
for i in range(n):
for j in range(i, n):
count += 1
count_b(n) (JavaScript: countB(n)), a loop that doubles:
i = 1
while i < n:
i *= 2
count += 1
count_c(n) (JavaScript: countC(n)), an inner loop with a step of 3:
for i in range(n):
for j in range(0, n, 3):
count += 1
count_d(n) (JavaScript: countD(n)), a loop that stops at the square root:
i = 1
while i * i <= n:
count += 1
i += 1
The sample tests run the real loops for every n from 0 to 120 and compare the counts with your formulas. Then they ask for n = 1,000,000, where the first and third nests would run hundreds of billions of times: only a formula answers in time.
Python · Starter code · formulas.py
import math
def count_a(n):
"""How many times count += 1 runs in: for i in range(n): for j in range(i, n)."""
# Replace this line with a formula in n.
return 0
def count_b(n):
"""How many times count += 1 runs in: i = 1; while i < n: i *= 2."""
# Replace this line with a formula in n.
return 0
def count_c(n):
"""How many times count += 1 runs in: for i in range(n): for j in range(0, n, 3)."""
# Replace this line with a formula in n.
return 0
def count_d(n):
"""How many times count += 1 runs in: i = 1; while i * i <= n: i += 1."""
# Replace this line with a formula in n.
return 0 The sample tests · test_formulas.py
from formulas import count_a, count_b, count_c, count_d
def loops_a(n):
count = 0
for i in range(n):
for j in range(i, n):
count += 1
return count
def loops_b(n):
count = 0
i = 1
while i < n:
i *= 2
count += 1
return count
def loops_c(n):
count = 0
for i in range(n):
for j in range(0, n, 3):
count += 1
return count
def loops_d(n):
count = 0
i = 1
while i * i <= n:
count += 1
i += 1
return count
def test_count_a():
"""count_a matches the real loops for n = 0 to 120"""
assert [count_a(n) for n in range(121)] == [loops_a(n) for n in range(121)]
def test_count_b():
"""count_b matches the real loop for n = 0 to 120"""
assert [count_b(n) for n in range(121)] == [loops_b(n) for n in range(121)]
def test_count_c():
"""count_c matches the real loops for n = 0 to 120"""
assert [count_c(n) for n in range(121)] == [loops_c(n) for n in range(121)]
def test_count_d():
"""count_d matches the real loop for n = 0 to 120"""
assert [count_d(n) for n in range(121)] == [loops_d(n) for n in range(121)]
def test_a_million():
"""the formulas answer at once for n = 1,000,000"""
n = 1_000_000
assert [count_a(n), count_b(n), count_c(n), count_d(n)] == [500_000_500_000, 20, 333_334_000_000, 1000] JavaScript · Starter code · formulas.mjs
/** How many times count++ runs in: for (i = 0; i < n; i++) for (j = i; j < n; j++). */
export function countA(n) {
// Replace this line with a formula in n.
return 0;
}
/** How many times count++ runs in: i = 1; while (i < n) i *= 2. */
export function countB(n) {
// Replace this line with a formula in n.
return 0;
}
/** How many times count++ runs in: for (i = 0; i < n; i++) for (j = 0; j < n; j += 3). */
export function countC(n) {
// Replace this line with a formula in n.
return 0;
}
/** How many times count++ runs in: i = 1; while (i * i <= n) i++. */
export function countD(n) {
// Replace this line with a formula in n.
return 0;
} The sample tests · formulas.test.mjs
import { test, assert } from 'toolverse:test';
import { countA, countB, countC, countD } from './formulas.mjs';
function loopsA(n) {
let count = 0;
for (let i = 0; i < n; i++) for (let j = i; j < n; j++) count++;
return count;
}
function loopsB(n) {
let count = 0;
for (let i = 1; i < n; i *= 2) count++;
return count;
}
function loopsC(n) {
let count = 0;
for (let i = 0; i < n; i++) for (let j = 0; j < n; j += 3) count++;
return count;
}
function loopsD(n) {
let count = 0;
for (let i = 1; i * i <= n; i++) count++;
return count;
}
const upTo120 = Array.from({ length: 121 }, (_, n) => n);
test('countA matches the real loops for n = 0 to 120', () => assert.deepEqual(upTo120.map(countA), upTo120.map(loopsA)));
test('countB matches the real loop for n = 0 to 120', () => assert.deepEqual(upTo120.map(countB), upTo120.map(loopsB)));
test('countC matches the real loops for n = 0 to 120', () => assert.deepEqual(upTo120.map(countC), upTo120.map(loopsC)));
test('countD matches the real loop for n = 0 to 120', () => assert.deepEqual(upTo120.map(countD), upTo120.map(loopsD)));
test('the formulas answer at once for n = 1,000,000', () => {
const n = 1000000;
assert.deepEqual([countA(n), countB(n), countC(n), countD(n)], [500000500000, 20, 333334000000, 1000]);
}); A hint
In count_a, the inner loop runs n − i times, so add n + (n − 1) + … + 1. In count_b, i takes the values 1, 2, 4, 8 … and the loop runs once for each of them that is below n: think about how many bits n − 1 needs (int.bit_length() in Python). In count_c, range(0, n, 3) has ⌈n / 3⌉ items, which is (n + 2) // 3 in whole numbers. In count_d, the loop runs once for each i whose square is at most n (math.isqrt in Python).
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
9 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- Computer Science Curricula 2023: Algorithmic Foundations (AL) (ACM, IEEE Computer Society and AAAI)
- Time complexity of operations on built-in types (Python Software Foundation)
- Built-in types: common sequence operations (Python Software Foundation)
- Built-in types: int.bit_length() (Python Software Foundation)
- collections: deque objects (Python Software Foundation)
- time.process_time() (Python Software Foundation)
- best case (Dictionary of Algorithms and Data Structures) (National Institute of Standards and Technology (NIST))
- worst case (Dictionary of Algorithms and Data Structures) (National Institute of Standards and Technology (NIST))
- average case (Dictionary of Algorithms and Data Structures) (National Institute of Standards and Technology (NIST))
- PEP 8: Style Guide for Python Code (programming recommendations) (Python Software Foundation)
- GATE 2027 question paper pattern (IIT Madras (GATE 2027 organising institute))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress