10. Tracing and counting steps

Two skills separate participants who get full marks from those who get half: being able to trace an algorithm by hand to make sure the answer is correct, and being able to count its steps to make sure the program is fast enough. Both are done before pressing the compile button.

Tracing with a table

Tracing means running the algorithm on paper, one line at a time, while recording the contents of every variable. Make one column per variable and one row per step.

The algorithm being traced:

READ n              // n = 4
jumlah <- 0
i <- 1
WHILE i <= n
    jumlah <- jumlah + i * i
    i <- i + 1
PRINT jumlah

The table:

Step i i * i jumlah i <= n?
start 1 — 0 yes
1 2 1 1 yes
2 3 4 5 yes
3 4 9 14 yes
4 5 16 30 no
print — — 30 —

Note: the value of i in the first column is the value after the line i <- i + 1 has run. Fix your own convention and then stick with it, so the table does not confuse you.

Four rules so that a tracing table actually catches mistakes:

  1. One row for each time the loop body runs, not one row per line of code.
  2. One column for each variable that changes, plus one column for the result of the condition check.
  3. Write the values as they are, even if they look wrong. The biggest temptation while tracing is to write the value that should appear, not the one the step actually produces.
  4. Do not stop at the last iteration. Most off-by-one errors show up precisely at the final condition check, when the loop stops.

Open exercises/10/01-telusur.cpp. The program prints the contents of every variable on each iteration. First make your tracing table on paper for n = 4, then run the program and compare row by row.

Trace with a small n — 3 or 4 is enough. The purpose of tracing is not to get the answer, but to make sure the steps are correct. If it is correct for n = 4, it is almost always correct for n = 100,000.

Test cases and edge cases

A program that is correct for the sample in the problem is not necessarily correct. Also test edge cases, meaning inputs at the ends of the constraints:

Kind of edge case Example
smallest data n = 1, or an array with one element
all values equal 5 5 5 5
already sorted / reversed 1 2 3 4 and 4 3 2 1
negative values and zero -3 0 7
largest value allowed every element 10⁹
no answer a target that never appears

Most points lost in OSN are not due to a wrong algorithm, but due to one edge case that was never tested.

For each kind of problem, the edge cases can be guessed before the code is finished:

Problem shape Edge cases you must try
going through an array n = 1; all elements equal
finding the largest/smallest all elements negative
searching for something the target is absent; it is at the first and last position
division the divisor is zero
summing largest n and largest values at the same time
while loop input that makes the loop not run at all
multiple test cases two cases in a row; the second very different from the first

A quick way to generate edge cases: take the constraints in the problem, then try values exactly at the bound and one inside the bound. For 1 ≤ n ≤ 200,000, that means n = 1, n = 2, and n = 200,000.

Open exercises/10/02-kasus-tepi.cpp. The program finds the largest difference between two elements and runs correctly on ordinary samples, but is wrong for n = 1 and for data that is entirely negative. Run both of those cases, then fix it.

Counting steps

Count how many times the innermost part of a program runs, as a function of n.

for (int i = 0; i < n; i++)              // n times
    cout << a[i];

for (int i = 0; i < n; i++)              // n * n times
    for (int j = 0; j < n; j++)
        ...

for (int i = 0; i < n; i++)              // n * (n-1) / 2 times
    for (int j = i + 1; j < n; j++)
        ...

for (int i = 1; i <= n; i *= 2)          // log2(n) times
    ...

What is usually written is only the growth shape, ignoring constant factors and smaller terms:

Actual number of steps Written Name
5 O(1) constant
log₂ n O(log n) logarithmic
3n + 7 O(n) linear
n log₂ n O(n log n) like sort
n(n − 1)/2 O(n²) quadratic
2ⁿ O(2ⁿ) exponential

The reason constant factors are ignored: as n grows, the growth shape is what decides. An O(n²) program with a small constant still loses badly to an O(n log n) program once n reaches the hundreds of thousands.

How badly can be seen in the following table. The numbers are step counts, not seconds:

n O(log n) O(n) O(n log n) O(n²) O(2ⁿ)
10 3 10 33 100 1,024
1,000 10 1,000 9,966 10⁶ infinite for our purposes
100,000 17 10⁵ 1.7 × 10⁶ 10¹⁰ —
1,000,000 20 10⁶ 2.0 × 10⁷ 10¹² —

Look at the O(n²) column: from n = 1,000 to n = 100,000, the data grows 100 times but the work grows 10,000 times. This is why a solution that is "fast enough" on the sample can fail to finish at all on the real data.

A quick way to recognize the growth shape without counting: see what happens when n is doubled.

If n is doubled, the time ... Shape
barely changes O(1) or O(log n)
doubles O(n)
becomes a bit more than double O(n log n)
becomes four times O(n²)
becomes eight times O(n³)
doubles for every extra 1 in n O(2ⁿ)

The step counts of some operations you have already used:

Operation Number of steps
a[i] — accessing one array element O(1)
v.push_back(x) O(1) on average
going through the whole array O(n)
sort(...) O(n log n)
finding one value with a plain scan O(n)
checking all pairs O(n²)
sieve of Eratosthenes up to n O(n log log n)
pangkat_mod(a, b, m) (Chapter 14) O(log b)

From constraints to choice of algorithm

The judge machine does roughly 10⁸ simple operations per second. The time limit of a problem is usually 1 or 2 seconds. From there, the constraint on n in the problem immediately tells you what kind of algorithm is expected:

Constraint on n Safe number of steps Suitable algorithm
n ≤ 10 O(n!) or O(2ⁿ) try all arrangements (Chapter 11)
n ≤ 25 O(2ⁿ) try all subsets
n ≤ 500 O(n³) three nested loops
n ≤ 5,000 O(n²) two nested loops, table DP
n ≤ 200,000 O(n log n) sort, greedy, binary search
n ≤ 10,000,000 O(n) a single pass
n ≤ 10¹⁸ O(log n) or O(1) formula, fast exponentiation (Chapter 14)

The figure of 10⁸ per second is a deliberately safe estimate. A very simple loop can run much faster than that, while steps that access memory in a scattered way can be much slower. For choosing an algorithm, the safe estimate is what is used.

This table is a working tool, not decoration. Before writing code, read the constraints, find the row, and ask: does my plan fit? Writing an O(n²) solution for a problem with n = 200,000 means zero points even if the logic is perfectly correct.

An example of reading it: a problem with n ≤ 200,000 almost certainly does not want nested loops. If your plan has the shape "check all pairs", there is an observation you have not found yet — for example "sort first", as in the example in Chapter 08.

Measuring real time

The estimates above still need to be checked. The easiest way on your own computer:

#include <ctime>

clock_t t0 = clock();
// ... the part being measured ...
double detik = (double)(clock() - t0) / CLOCKS_PER_SEC;
cerr << detik << " detik\n";

Note cerr, not cout: cerr output is not graded by the online judge, so it is safe to use for helper messages. Even so, make a habit of removing it before submitting.

When the constant factor matters too

The growth shape determines the choice of algorithm, but there is one thing that often makes a correct O(n) solution still run out of time: input reading speed. By default cin is set up so it can be used together with scanf, and that synchronization costs time. Two lines at the start of main turn it off:

ios::sync_with_stdio(false);
cin.tie(nullptr);

In a measurement of reading one million integers, the time dropped from 0.21 seconds to 0.04 seconds — about five times faster, without changing a single line of the algorithm. For problems with hundreds of thousands of input numbers, make a habit of writing both lines.

After cin.tie(nullptr), do not mix cin/cout with scanf/printf in one program: the output order can get swapped. Pick one and stick with it.

Open exercises/10/03-hitung-langkah.cpp. The program counts how many times the innermost part runs for three loop shapes. Run it for n = 1000 and n = 2000, then observe: the O(n) one doubles, the O(n²) one quadruples.

Testing against a reference

If a fast solution feels doubtful, test it against a slow solution that is clearly correct:

  1. Write a slow version that is definitely correct (for example, checking all pairs).
  2. Generate small random data.
  3. Run both and compare their outputs.
  4. Repeat hundreds of times. As soon as something differs, you have a small example case that can be traced by hand.

The key is step 2: the data must be small. A difference found on an array of four numbers can be traced by hand in a minute; a difference on an array of a hundred thousand numbers tells you nothing.

for (int uji = 0; uji < 1000; uji++) {
    int n = rand() % 6 + 1;                  // 1 to 6 elements
    for (int i = 0; i < n; i++) a[i] = rand() % 21 - 10;   // -10 to 10
    if (cepat(n) != lambat(n)) {
        cerr << "beda pada n=" << n << ":";
        for (int i = 0; i < n; i++) cerr << " " << a[i];
        cerr << "\n  cepat=" << cepat(n) << " lambat=" << lambat(n) << "\n";
        break;
    }
}

This technique finds bugs that are not visible when reading the code, and it is also the only practical way to test greedy conjectures in Chapter 12. An example is in exercises/10/04-uji-banding.cpp.

Homework

Problems 1 and 2 are done on paper.

Problem 1 — Tracing table

Trace the following algorithm for n = 5 and make its table. Write the value of every variable on each iteration, then write the output.

READ n
a <- 0
b <- 1
FOR i FROM 1 TO n
    c <- a + b
    a <- b
    b <- c
PRINT a

Problem 2 — Choose an algorithm

For each of the following constraints, state the largest complexity that is still safe, and one example of a suitable algorithm:

a. n ≤ 18 b. n ≤ 2,000 c. n ≤ 300,000 d. n ≤ 10¹²

Problem 3 — Fix the edge cases

Open exercises/10/homework/03-kasus-tepi.cpp. The program is supposed to print the average of the positive numbers in an array, but it fails for three edge cases: no positive numbers at all, n = 1, and data whose sum exceeds the int limit. Find all three, write them as comments, then fix them.

Problem 4 — Count the steps

Open exercises/10/homework/04-hitung-langkah.cpp. There are four loop fragments in the comments. For each, write its step count in O notation, then complete the program so it counts the actual steps for n = 100 and n = 200 — and check whether the ratio matches your guess.

Challenge — Reference tester

Open exercises/10/homework/05-uji-banding.cpp. The problem: print the largest difference between two array elements. The file already contains two functions — a slow version that is correct, and a fast version that contains one bug. Write the tester loop: generate small random arrays (n between 1 and 6, values −10 to 10), compare the two functions, and print the first example case on which they differ. Then fix the bug.

OSN tips

Read constraints as instructions, not as remarks. Before deciding on an approach, match the largest constraint in the problem against this table. The numbers use the benchmark of 10⁸ simple operations per second.

Constraint N Number of steps that fits Shape of the solution
≤ 20 2^N try all possibilities
≤ 500 N³ three nested loops
≤ 5,000 N² two nested loops
≤ 200,000 N log N sort, or one loop + binary search
≤ 1,000,000 N a single loop only
≤ 10¹⁸ log N formula, or a loop over the digits

The constraint N ≤ 200,000 that appears again and again in OSN problems is the clearest signal: N² = 4 × 10¹⁰ is impossible, so what is wanted is a single-pass or single-sort solution.

State the cost before typing. Write one line on scratch paper: "N ≤ 2 × 10⁵, my approach is N², so 4 × 10¹⁰ — does not fit". A sentence that short prevents half an hour of typing an answer that is certain to get Time Limit Exceeded.

A slow answer is still worth submitting. If the problem is split into subtasks, an N² answer usually solves the subtasks with small constraints. Submit it first, then improve it. What you must not do is run out of time without submitting anything.

A slow answer is also a testing tool. How to compare automatically is covered in this chapter: run the slow version and the fast version on the same random input, and compare the outputs with diff. If they differ, you have a small failing example — far more useful than an unexplained Wrong Answer verdict.

Large input needs fast reading. For N in the hundreds of thousands and above, these two lines should become a habit at the start of main:

ios::sync_with_stdio(false);
cin.tie(nullptr);

After writing them, do not mix cin with scanf in one program.

Measure, do not guess. clock() from <ctime> is enough to measure the part suspected of being slow. Guessing which part is slow almost always misses, and optimizing a part that is not the cause changes nothing.

A loop that calls cout on every iteration for 200,000 lines can become the slowest part of the whole program if it uses endl. Replace it with "\n" before suspecting the algorithm.

Summary

  • Tracing with a table checks correctness; counting steps checks speed. Both are done before compiling.
  • Tracing table: one row per iteration, one column per variable, write the values as they are, and do not stop before the final check.
  • Mandatory edge cases: n = 1, all values equal, all negative, no answer, and the largest value allowed by the constraints.
  • O notation hides constant factors and keeps the growth shape. A quick way to recognize it: see what happens when n is doubled.
  • Safe estimate: 10⁸ simple operations per second. Use the constraints table to choose an algorithm before writing code.
  • ios::sync_with_stdio(false); cin.tie(nullptr); speeds up reading large input without changing the algorithm.
  • A reference tester with small random data finds bugs that are not visible when reading the code.

OSN-style exercises

Each of the following three problems has a direct answer that is easy to write and certainly too slow. Before typing, first count the steps of that direct answer, then look for a shape that fits.

The skeletons for all three are in exercises/10/osn/.

Problem 1 — Maximum Subarray

Given a sequence of N integers. Choose a contiguous, non-empty segment so that the sum of its contents is as large as possible. Determine that sum.

Input format. The first line contains the integer N. The second line contains N integers A₁ A₂ … A_N.

Output format. One line containing the largest sum that can be achieved.

Constraints.

  • 1 ≤ N ≤ 200,000
  • −10⁹ ≤ Aᵢ ≤ 10⁹

Sample input

8
-2 1 -3 4 -1 2 1 -5

Sample output

6

Explanation. The segment 4 -1 2 1 sums to 6, and no other segment is larger. Note that the segment may not be empty: if all numbers are negative, the answer is the largest negative number, not 0.

Skeleton: exercises/10/osn/01-subarray-maksimum.cpp

Problem 2 — Pairs Summing to K

Given N integers and a number K. Count how many position pairs (i, j) with i < j satisfy Aᵢ + A_j = K.

Input format. The first line contains two integers N and K. The second line contains N integers.

Output format. One line containing the number of pairs.

Constraints.

  • 2 ≤ N ≤ 200,000
  • 1 ≤ Aᵢ ≤ 10⁶
  • 2 ≤ K ≤ 2 × 10⁶

Sample input

5 7
1 6 3 4 2

Sample output

2

Explanation. The pairs are (1, 6) and (3, 4). The number 2 has no partner because 5 is not in the list. What is counted is pairs of positions: if the input is 2 2 2 2 with K = 4, the answer is 6, because there are six ways to choose two different positions.

Skeleton: exercises/10/osn/02-pasangan-berjumlah.cpp

Problem 3 — Range Sum

Given a sequence of N integers and Q queries. Each query contains two position numbers L and R; the answer is the sum of A_L through A_R, including both ends.

Input format. The first line contains two integers N and Q. The second line contains N integers. Each of the next Q lines contains two integers L and R.

Output format. Q lines, one answer per query.

Constraints.

  • 1 ≤ N, Q ≤ 200,000
  • −10⁹ ≤ Aᵢ ≤ 10⁹
  • 1 ≤ L ≤ R ≤ N

Sample input

5 3
1 2 3 4 5
1 5
2 3
4 4

Sample output

15
5
4

Explanation. The first query sums the whole sequence, the second sums 2 + 3, and the third is just one number. Re-summing for every query takes N × Q = 4 × 10¹⁰ additions — work it out yourself before deciding.

Skeleton: exercises/10/osn/03-jumlah-rentang.cpp

Stuck or already done? Open the homework solutions page.