Foundations/Growth Rates and Loop Analysis

Lesson 1.42,682 words

Growth Rates and Loop Analysis

With the asymptotic notations in hand, we rank the functions that actually arise in running times — from constant to factorial — proving the orderings between rungs, then read the running time of a loop nest straight off the page. Sequential blocks add, nested loops multiply, index scaling gives logarithms; a worked trace and a tour of cache-aware and galactic algorithms close the lesson.

╌╌╌╌

This builds on Asymptotic Analysis, which defined the , , , , and notations and the limit test for ranking two functions. Here we put that machinery to work: we lay out the growth classes algorithms meet in practice, prove the orderings between them, and then reduce most everyday analysis to counting how many times a loop body runs.

The growth hierarchy

The functions algorithm designers meet most often, in strictly increasing order of growth, are:

ClassNameA typical algorithm
constantarray index, hash lookup (expected)
logarithmicbinary search
linearscan / find max
linearithmicmerge sort, heapsort
quadraticinsertion sort (worst case)
cubicnaive matrix multiply
exponentialsubset enumeration
factorialbrute-force permutations (TSP)

Each row's growth dwarfs every row above it for large . The practical dividing line Skiena draws is between polynomial time ( for a constant ), generally considered tractable, and exponential time, which becomes hopeless very fast.1 A factorial-time algorithm that handles in a second needs years at .

Proving the orderings

The table is really a chain of little-o claims: each class is of the one below it. Every link yields to the two moves from the ratio test, L'Hôpital or a substitution, so we prove three representative ones and leave the rest as finger exercises.

This is the sharpest form of logs are cheap: grows more slowly than , than , than any polynomial sliver at all. It is the theorem behind every claim of the form binary search's beats a linear scan.

Chaining such results is painless because little-o is transitive: if and , then (the ratios multiply, and both factors vanish). That single property turns the eight-row table into a totally ordered ladder.

Each class also has a characteristic shape — a picture of how its work fills space, from a single cell up to a tree that explodes:

The growth hierarchy as shapes, in increasing order of growth: a single cell (constant), a small block (logarithmic), a log n by log n square (log-squared), a row (linear), an n by log n rectangle (linearithmic), an n by n grid (quadratic), a cube (cubic), a doubling binary tree (exponential), and a tree whose branching shrinks each level (factorial).

Those shapes come from how the work is spent — the step size and the work done at each step. The same hierarchy, read as work regimes:

The same hierarchy as work regimes — the step size and the work at each step that produce each rate. Doubling the index takes only log n steps; stepping by one takes n; and when the work itself doubles or multiplies each step, the total explodes.

A sketch of the curves makes the separation clear. Even with a generous constant on the slower-growing function, the faster one wins past some crossover :

Growth-rate curves for , , , and .
growth_hierarchy.pypython
import math
from enum import IntEnum
from typing import Callable

class GrowthClass(IntEnum):
  """
    One rung of the growth hierarchy, ordered slowest to fastest.\n
    The integer value is the rank: a larger rank dominates a smaller one\n
    for all sufficiently large `n`, so the enum's natural ordering is the\n
    asymptotic ordering.\n
  """
  CONSTANT = 0
  LOGARITHMIC = 1
  LINEAR = 2
  LINEARITHMIC = 3
  QUADRATIC = 4
  CUBIC = 5
  EXPONENTIAL = 6
  FACTORIAL = 7

  @property
  def label(self) -> str:
    """
      The textbook name of this growth rate (e.g. "linearithmic").\n
    """
    return self.name.lower()

  def cost(self, size: int) -> float:
    """
      The representative cost of this class at input size `size`.\n
      Uses the canonical formula for the rung — 1, log2 n, n, n log2 n,\n
      n^2, n^3, 2^n, n! — so two classes can be compared numerically at\n
      any concrete size as well as asymptotically.\n
    """
    if size < 0:
      raise ValueError("size must be non-negative")
    formula: Callable[[int], float] = _COST_FORMULAS[self]
    return formula(size)

def _log2_or_zero(size: int) -> float:
  """
    log2(size) for size >= 1, and 0 at size 0 (an empty problem has no\n
    levels to descend), avoiding a domain error.\n
  """
  return math.log2(size) if size >= 1 else 0.0

_COST_FORMULAS: dict[GrowthClass, Callable[[int], float]] = {
  GrowthClass.CONSTANT: lambda size: 1.0,
  GrowthClass.LOGARITHMIC: _log2_or_zero,
  GrowthClass.LINEAR: lambda size: float(size),
  GrowthClass.LINEARITHMIC: lambda size: size * _log2_or_zero(size),
  GrowthClass.QUADRATIC: lambda size: float(size) ** 2,
  GrowthClass.CUBIC: lambda size: float(size) ** 3,
  GrowthClass.EXPONENTIAL: lambda size: float(2 ** size),
  GrowthClass.FACTORIAL: lambda size: float(math.factorial(size)),
}

def dominates(faster: GrowthClass, slower: GrowthClass) -> bool:
  """
    Whether `faster` grows strictly faster than `slower` — that is,\n
    `slower(n) = o(faster(n))`. True exactly when `faster` outranks\n
    `slower` on the hierarchy.\n
  """
  return faster > slower

def hierarchy() -> list[GrowthClass]:
  """
    The full hierarchy in increasing order of growth.\n
  """
  return sorted(GrowthClass)

def crossover(slow: GrowthClass, fast: GrowthClass, max_size: int = 100_000) -> int:
  """
    The smallest size `n >= 1` past which the faster class is at least as\n
    costly as the slower one and stays so — the threshold `n0` where the\n
    dominating curve overtakes for good.\n
    `fast` must outrank `slow`; raises if no crossover is found by\n
    `max_size`. (A scaled-up slow class can lead for a while; we want the\n
    first point beyond which the order never reverses.)\n
  """
  if not dominates(fast, slow):
    raise ValueError("fast must outrank slow on the hierarchy")

  # scan up from 1 for the first size where the faster curve catches up.
  for size in range(1, max_size + 1):
    if fast.cost(size) >= slow.cost(size):
      return size

  raise ValueError("no crossover found within max_size")

Reading the cost off loops

Most analysis reduces to counting how many times each line runs. A few rules cover the common cases.

Sequential blocks add; we keep the max. If block costs and is followed by block costing , the total is : the larger term absorbs the smaller.

A simple loop multiplies the body by the iteration count. This nested fragment runs the constant-time body times:

Algorithm:Counting work in nested loops
  1. 1
    for i1i \gets 1 to nn do
  2. 2
    for j1j \gets 1 to nn do
  3. 3
    cc+A[i]B[j]c \gets c + A[i] \cdot B[j]
    Θ(1)\Theta(1) body, n2n^2 times

so its cost is .

When the inner bound depends on the outer index, sum a series. If the inner loop runs , the body executes times — still quadratic, because triangular work is half of square work, and the constant vanishes. This is the shape of insertion sort's worst case.

When the loop variable is scaled, take a logarithm. A loop that does until runs about times, since doubles each pass. This is the source of every logarithm in algorithm analysis: repeatedly halving (or doubling) gives steps. Binary search and balanced-tree depth are the canonical examples.

Every loop nest has a shape, and its cost is the size of that shape — usually an area, sometimes just a count of rungs. Lined up in order of growth, the common shapes make the hierarchy concrete: a constant loop touches one cell; a doubling index visits only the powers of two; a flat pass is linear; and an outer loop of passes wrapped around a doubling inner loop fills an grid.

Reading loop cost as a shape, in order of growth — a constant loop touches one cell (); a doubling index visits rungs (); a flat pass is linear (); an outer loop around a doubling inner loop fills an grid ().

The two costliest shapes in everyday code are the quadratic ones. Two nested loops over the same range fill a square grid — work. Bounding the inner loop by the outer index instead fills only the triangle below the diagonal: half as many iterations, , but the same once the constant is dropped. (A subtler linear case hides nearby: a loop that halves its live problem each pass does total work, so it stays despite touching every prefix.)

The two quadratic loop shapes. A full square grid of iterations is work; an inner bound fills only the triangle below the diagonal, — half as much, but still .

A worked trace: three loops, one bound

Rules are easier to trust once you have watched them settle a real fragment. Take a routine that, given an array of numbers, prints the sum of every contiguous block the slow way — recomputing each block's sum from scratch:

Algorithm:Naive all-subarray sums
  1. 1
    for i1i \gets 1 to nn do
  2. 2
    for jij \gets i to nn do
  3. 3
    s0s \gets 0
  4. 4
    for kik \gets i to jj do
  5. 5
    ss+A[k]s \gets s + A[k]
    innermost body, Θ(1)\Theta(1)
  6. 6
    print ss

Count the innermost body. For a fixed pair it runs times, so the total is

after substituting and using . Let run from down to as climbs, and the outer sum becomes by the hockey-stick identity — that is, .

Pin it down with real numbers at . The block lengths over all pairs are:

1234
11234
2123
312
41

Summing the entries gives innermost additions, and the closed form checks: . The cubic is real, not an over-count — the third nested loop, whose length grows with the gap , is what lifts the cost from the of the pair-enumeration alone to .

The lesson the trace teaches is where the cost hides. A reader who stops at two visible loops, so is wrong by a full factor of ; the inner for $k$ loop supplies that factor. It is also removable: a running prefix-sum table lets each block sum be read in , collapsing the whole routine to . Counting first tells you which loop to attack.

The recurrences that arise when a loop is replaced by recursion, a function calling smaller copies of itself, need their own machinery, which is the subject of the next lesson.

loop_cost.pypython
from growth_hierarchy import GrowthClass

def linear_scan_cost(size: int) -> int:
  """
    Iterations of a single flat pass `for i in 1..n` — exactly `size`,\n
    the linear shape Theta(n).\n
  """
  _require_non_negative(size)
  return size

def square_loop_cost(size: int) -> int:
  """
    Iterations of two nested same-range loops `for i in 1..n / for j in\n
    1..n` — the full square grid, n^2, the quadratic shape Theta(n^2).\n
  """
  _require_non_negative(size)
  return size * size

def triangular_loop_cost(size: int) -> int:
  """
    Iterations when the inner bound follows the outer index\n
    (`for j in 1..i`): sum_{i=1}^{n} i = n(n+1)/2 — the triangle below the\n
    diagonal, half the square but still Theta(n^2). This is insertion\n
    sort's worst-case shape.\n
  """
  _require_non_negative(size)
  return size * (size + 1) // 2

def doubling_loop_cost(size: int) -> int:
  """
    Passes of a loop that does `i <- i * 2` until `i > n`, starting at 1.\n
    Doubling reaches n in floor(log2 n) + 1 passes for n >= 1 (one pass\n
    for n = 0, which never enters) — the logarithmic shape Theta(log n).\n
  """
  _require_non_negative(size)

  # double the index from 1 until it passes n, counting each pass.
  passes: int = 0
  index: int = 1
  while index <= size:
    passes += 1
    index *= 2

  return passes

def halving_total_work(size: int) -> int:
  """
    Total work of a loop that halves its live problem each pass:\n
    n + floor(n/2) + floor(n/4) + ... down to 0. The geometric series sums\n
    to under 2n, so despite touching every prefix the loop stays\n
    Theta(n) — the subtle linear case the lesson flags.\n
  """
  _require_non_negative(size)

  # add each live size while halving the problem down to 0.
  total: int = 0
  remaining: int = size
  while remaining > 0:
    total += remaining
    remaining //= 2

  return total

def growth_class_of_nesting(outer_exponent: int, inner_log_factor: bool = False) -> GrowthClass:
  """
    The growth class of a loop nest with `outer_exponent` plain nested\n
    passes (1 -> linear, 2 -> quadratic, 3 -> cubic), optionally wrapped\n
    around a doubling inner loop (`inner_log_factor=True`) that multiplies\n
    the cost by a log factor. Sequential blocks would instead keep the max\n
    of their classes — see `dominant_class`.\n
  """
  # a log inner factor bumps the plain nesting up one notch.
  if outer_exponent == 0 and not inner_log_factor:
    return GrowthClass.CONSTANT
  if outer_exponent == 0 and inner_log_factor:
    return GrowthClass.LOGARITHMIC
  if outer_exponent == 1 and inner_log_factor:
    return GrowthClass.LINEARITHMIC

  # plain nesting maps the exponent straight onto its polynomial class.
  table: dict[int, GrowthClass] = {
    1: GrowthClass.LINEAR,
    2: GrowthClass.QUADRATIC,
    3: GrowthClass.CUBIC,
  }
  if outer_exponent in table and not inner_log_factor:
    return table[outer_exponent]

  raise ValueError("unsupported loop nesting shape")

def dominant_class(blocks: list[GrowthClass]) -> GrowthClass:
  """
    The cost of sequential blocks: they add, and the largest term absorbs\n
    the smaller, so the total is the maximum growth class. An empty\n
    sequence does no work and is constant.\n
  """
  if not blocks:
    return GrowthClass.CONSTANT
  return max(blocks)

def _require_non_negative(size: int) -> None:
  """
    Guard: an input size cannot be negative.\n
  """
  if size < 0:
    raise ValueError("size must be non-negative")

When the cost model shifts

The RAM model's flat, unit-cost memory is the assumption that ages worst. On real hardware a cache miss can cost hundreds of times what a hit does, so two algorithms with identical RAM counts can differ by an order of magnitude in wall-clock time. The external-memory (I/O) model of Aggarwal and Vitter (1988) charges for block transfers between a fast memory of size and a slow disk moved in blocks of size , and counts I/Os rather than instructions; scanning items costs transfers, and sorting costs .2 Frigo, Leiserson, Prokop, and Ramachandran (1999) pushed this further with cache-oblivious algorithms, which achieve the optimal transfer count for every block size at once, without ever naming or — recursive layouts like the van Emde Boas tree do this automatically.3 The cost model is itself a modeling choice: pick the one whose expensive operation matches your bottleneck.

A second modern wrinkle sharpens constants don't matter. They do not matter asymptotically, but the crossover can sit past every input anyone will run. An algorithm whose asymptotic bound only beats its rivals for astronomically large is called galactic. The canonical case is matrix multiplication: the Coppersmith–Winograd family and its descendants drive the exponent below , yet the hidden constants and structure make them useless in practice, where Strassen's or plain still win for any feasible matrix.4 The lesson mirrors small inputs lie from the previous lesson: an asymptotic verdict is a statement about the limit, and engineering lives before the limit.

Finally, the polynomial-versus-exponential line the hierarchy draws is the same line complexity theory draws between P, the problems solvable in polynomial time, and the harder classes. Cobham (1965) and Edmonds (1965) independently proposed polynomial time as the formal stand-in for efficient, which is why the hierarchy treats as tractable and as hopeless.5 Whether every problem whose solutions can be checked in polynomial time can also be solved in polynomial time — the P versus NP question — remains open, and a great many natural problems (the traveling-salesman tour whose brute force sits at the bottom of our table among them) are NP-complete: a polynomial-time algorithm for any one would give a polynomial-time algorithm for all.6 Growth rates are where practical analysis and the deepest open question in the field meet.

Takeaways

  • Memorize the hierarchy ; each rung is of the next, and little-o's transitivity makes the table a totally ordered ladder.
  • The orderings are proved with the ratio test: for every , , and — indeed for every constant , so the factorial outgrows every exponential.
  • The polynomial/exponential boundary is the line between tractable and hopeless: a factorial-time routine that handles in a second needs years at .
  • Read loop cost by counting: sequential blocks add (keep the max), nested loops multiply the body by the iteration count, an index-dependent inner bound sums a series (a triangle is still ), and a scaled index () gives steps.
  • Count before you optimize: the naive all-subarray sums routine hides a in a third loop whose length grows with the gap; a prefix-sum table removes it, dropping the cost to .
  • The cost model is a choice. The external-memory and cache-oblivious models count block transfers instead of instructions when memory hierarchy dominates; galactic algorithms win asymptotically but never in practice; and the polynomial/exponential line is the same one P versus NP is drawn on.

Footnotes

  1. Skiena, §2 — Algorithm Analysis: the polynomial-vs-exponential dividing line between tractable and hopeless running times.
  2. Aggarwal, A. & Vitter, J. S. (1988). The input/output complexity of sorting and related problems. Communications of the ACM 31(9). Introduces the external-memory model and the sorting I/O bound.
  3. Frigo, M., Leiserson, C. E., Prokop, H. & Ramachandran, S. (1999). Cache-oblivious algorithms. Proc. 40th FOCS. Algorithms optimal across all block/cache sizes without naming them.
  4. Le Gall, F. (2014). Powers of tensors and fast matrix multiplication. Proc. ISSAC — the sub- exponent; the term galactic algorithm is due to R. J. Lipton and K. Regan for bounds that only help at astronomically large inputs. Strassen, V. (1969). Gaussian elimination is not optimal. Numerische Mathematik 13.
  5. Cobham, A. (1965). The intrinsic computational difficulty of functions. Proc. Logic, Methodology and Philosophy of Science. Edmonds, J. (1965). Paths, trees, and flowers. Canadian J. Mathematics 17 — polynomial time as the formal notion of efficient.
  6. Cook, S. A. (1971). The complexity of theorem-proving procedures. Proc. 3rd STOC; Karp, R. M. (1972). Reducibility among combinatorial problems. NP-completeness and the P-vs-NP question.
Practice

╌╌ END ╌╌