Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 25 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot

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):

    ∑i=0n−1i=n(n−1)2\sum_{i=0}^{n-1} i = \frac{n(n-1)}{2}
  • A loop that halves, count the halvings. while n > 0: n //= 2 runs once for each binary digit of n, which is ⌊log₂ n⌋ + 1 times for n ≥ 1. Python’s int.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:

Four loops, counted

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

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:

Linear search, with every key position tried Python · linear_search_cases.py
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

  • 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:

How many comparisons one line makes, on 10,000 items Python · hidden_loops.py
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

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:

Costs of common one-line operations on a list of n items
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:

Emptying a list and a deque from the front Python · pop_front_timing.py
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.
  • in on 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).

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.

  1. Question 1 of 9 A function adds two whole numbers digit by digit, as on paper, and the numbers may have any length. What should n be when you count its steps?

    Choose one answer.

    Show the answer to question 1

    Answer: The number of digits in the longer number

    The work grows with the length of the numbers: each column of digits is added once, with its carry. The value itself is a poor measure, because a number with a million digits is far larger than a million, yet adding it takes only about a million additions of digits. The basic operation to count is that addition of two digits.

  2. Question 2 of 9 How many times does count += 1 run?

    Type a number.

    count = 0
    for i in range(10):
        for j in range(10):
            count += 1
    Show the answer to question 2

    Answer: 100

    The inner loop runs 10 times on each of the 10 outer passes: 10 × 10 = 100.

  3. Question 3 of 9 How many times does count += 1 run?

    Type a number.

    count = 0
    for i in range(10):
        for j in range(i):
            count += 1
    Show the answer to question 3

    Answer: 45

    On pass i the inner loop runs i times, so the total is 0 + 1 + … + 9 = 10 × 9 / 2 = 45.

  4. Question 4 of 9 How many times does count += 1 run?

    Type a number.

    count = 0
    for i in range(10):
        for j in range(i, 10):
            count += 1
    Show the answer to question 4

    Answer: 55

    On pass i the inner loop runs 10 − i times: 10 + 9 + … + 1 = 10 × 11 / 2 = 55. Compare with the previous question, which counted the pairs with j < i: together the two loops cover all 10 × 10 = 100 pairs (i, j), so this one runs 100 − 45 = 55 times.

  5. Question 5 of 9 How many times does the body of this loop run?

    Type a number.

    i = 1000
    while i > 0:
        i //= 2
    Show the answer to question 5

    Answer: 10

    i goes 1000, 500, 250, 125, 62, 31, 15, 7, 3, 1 and then 0, so the body runs 10 times. That is the number of binary digits of 1000, which is (1000).bit_length() and ⌊log₂ 1000⌋ + 1.

  6. Question 6 of 9 How many times does count += 1 run?

    Type a number.

    count = 0
    for i in range(6):
        for j in range(i):
            for k in range(j):
                count += 1
    Show the answer to question 6

    Answer: 20

    The innermost line runs once for every choice of k < j < i from the numbers 0 to 5, which is the number of ways to choose 3 of 6 numbers: 6 × 5 × 4 / (3 × 2 × 1) = 20. Adding up the triangle counts 0, 0, 1, 3, 6 and 10 for i = 0 to 5 gives the same 20.

  7. Question 7 of 9 How many times does j *= 2 run in total?

    Type a number.

    for i in range(1, 17):
        j = 1
        while j < i:
            j *= 2
    Show the answer to question 7

    Answer: 49

    For each i the inner loop doubles j until it reaches i: 0 times for i = 1, once for 2, twice for 3 and 4, three times for 5 to 8 and four times for 9 to 16. The total is 0 + 1 + 2 × 2 + 4 × 3 + 8 × 4 = 49.

  8. Question 8 of 9 A key is in a list of 9 items, and every position is equally likely. How many comparisons does linear search make on average?

    Choose one answer.

    Show the answer to question 8

    Answer: 5

    Finding the item at position k (counting from 1) takes k comparisons, so the average is (1 + 2 + … + 9) / 9 = (9 + 1) / 2 = 5. The best case is 1 and the worst case is 9.

  9. Question 9 of 9 For a Python list items of n numbers, which of these lines take time that grows with n?

    Choose every answer that is right.

    Show the answer to question 9

    Answer:

    • x in items
    • max(items)
    • items.pop(0)

    in compares item by item, pop(0) moves every later item, and max() looks at every item. A list stores its length, so len() does not count; indexing jumps straight to a position; and append adds at the end, which on average over many appends does not grow with n.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.