Complexity in Practice: Reading Constraints and Choosing an Algorithm
Read a problem's limits before you think about code, turn them into a step budget, and pick the algorithm that fits, with operation counts instead of guesses.
This lesson is a free sample of the course.
The constraints line is part of the answer
A problem statement ends with a line like 1 <= n <= 200000. Many people read it last, or skip it, and start coding the first idea that gives correct answers. That idea is often two nested loops over the input. On the sample it prints the right number. On the judge's largest test it runs out of time, and the verdict says nothing about why.
Two nested loops over 200000 elements are about n^2 / 2 = 2*10^10 steps. No time limit you will meet in a contest or an online assessment allows that. The same loops over 2000 elements are 2*10^6 steps and finish before you notice. The code is identical; the constraint decides whether it is a solution.
So the constraints line is a hint from the problem setter about which complexity they intend. Big-O notation is how you read that hint. It is not a grade for elegance: an O(n^2) solution is the right one when n <= 3000 and you can write it in five minutes with no bugs. This lesson shows how to turn constraints into a step budget, how to solve one problem at three different complexities, and how to count steps so that you see the difference instead of taking it on faith.
You need the C++ basics and the standard containers from STL & Algorithms. Everything else is built here.
A step budget from the constraints
The usual starting point is a rough budget: a time limit of one or two seconds buys on the order of 10^8 simple steps, where a step is something like an addition, a comparison or an array access. Antti Laaksonen's "Competitive Programmer's Handbook" puts it as "some hundreds of millions of operations in a second". Treat this as a rule of thumb for ordering your options, not as a fact about any particular judge: the real speed depends on the machine, the compiler, the memory access pattern and what one "step" actually costs.
From that budget comes the table used throughout this course. Each row is the complexity that typically fits when n is up to the given size.
| n up to | Typical target |
|---|---|
| 10 | O(n!) |
| 20 | O(2^n), O(n 2^n) |
| 500 | O(n^3) |
| 5,000 | O(n^2) |
| 200,000 | O(n log n) |
| 10^6 to 10^7 | O(n) |
| beyond | O(log n) or O(1) per query |
The table is easier to trust once you evaluate it. This program computes the step count of each row at the top of its range, and of a few mismatches: the next-slower complexity at the same n.
#include <cmath>
#include <print>
double factorial(int n) { return n <= 1 ? 1 : n * factorial(n - 1); }
void row(const char* complexity, int n, double steps) {
const char* verdict = steps <= 2e8 ? "fits" : "too slow";
std::println("{:<12} n = {:>8} {:>8.1e} steps {}", complexity, n, steps, verdict);
}
int main() {
row("O(n!)", 10, factorial(10));
row("O(n 2^n)", 20, 20 * std::pow(2.0, 20));
row("O(n^3)", 500, std::pow(500.0, 3));
row("O(n^2)", 5000, std::pow(5000.0, 2));
row("O(n log n)", 200000, 200000 * std::log2(200000.0));
row("O(n)", 10000000, 1e7);
std::println("--- one row slower ---");
row("O(n!)", 12, factorial(12));
row("O(n^3)", 5000, std::pow(5000.0, 3));
row("O(n^2)", 200000, std::pow(200000.0, 2));
}Press Run to compile and run this example.
O(n!) n = 10 3.6e+06 steps fits
O(n 2^n) n = 20 2.1e+07 steps fits
O(n^3) n = 500 1.2e+08 steps fits
O(n^2) n = 5000 2.5e+07 steps fits
O(n log n) n = 200000 3.5e+06 steps fits
O(n) n = 10000000 1.0e+07 steps fits
--- one row slower ---
O(n!) n = 12 4.8e+08 steps too slow
O(n^3) n = 5000 1.2e+11 steps too slow
O(n^2) n = 200000 4.0e+10 steps too slowLook at the gaps. Every fitting row lands between 10^6 and about 10^8, and the polynomial mismatches are off by a factor of several hundred or more. The factorial row shows the other way to miss: n! grows so fast that two more elements already overshoot. That is why the rule of thumb works despite being rough: you are not choosing between 10^8 and 2*10^8 steps, you are choosing between 10^7 and 10^10. A constant factor of 3 rarely changes the verdict; a wrong exponent always does.
Two more things to read in the constraints. The value range tells you the integer type: if values reach 10^9 and you add up to 2*10^5 of them, the sum reaches 2*10^14 and needs a 64-bit long long. And the number of test cases multiplies everything: "up to 10^4 test cases, each with n <= 2*10^5" usually comes with a promise that the sum of all n is bounded, and that sum is the n you budget for.
One problem at three complexities
Here is a problem to solve three times.
Best stretch. A community bike-repair stand records its net balance for each of n days: positive when donations beat costs, negative otherwise. The treasurer wants the largest total over a stretch of consecutive days, at least one day long. Constraints: 1 <= n <= 2*10^5, each value between -10^9 and 10^9.
The input is n followed by the n daily values:
8
4 -6 3 -1 5 -9 2 2By hand: the stretch 3 -1 5 totals 7, and nothing beats it. Including the 4 -6 in front lowers the total; extending past 5 into -9 lowers it more; 2 2 makes only 4.
The first idea is to try every stretch [l, r] and add up its values: three nested loops, O(n^3). The second notices that the sum of [l, r + 1] is the sum of [l, r] plus one value, so a running sum removes the innermost loop: O(n^2). The third asks a different question. What is the best stretch that ends on day i? It is either day i alone, or day i appended to the best stretch ending on day i - 1, whichever is larger. One pass computes that for every i, and the answer is the best of them: O(n). This linear method is usually called Kadane's algorithm; Jon Bentley's "Programming Pearls" made the problem well known and credits the linear solution to Jay Kadane.
The constraints already chose for us: n up to 2*10^5 means O(n) or O(n log n). Every program in this course reads its input through a function solve(std::istream&, std::ostream&). The browser playground has no keyboard input, so main feeds solve the sample from a string. On a judge you call solve(std::cin, std::cout) instead and nothing else changes.
#include <algorithm>
#include <iostream>
#include <print>
#include <sstream>
#include <vector>
using ll = long long;
// Best total of a non-empty run of consecutive days.
ll bestStretch(const std::vector<ll>& a) {
ll best = a[0];
ll endingHere = 0; // best total of a run that ends at the current day
for (ll x : a) {
endingHere = std::max(x, endingHere + x); // start fresh, or extend
best = std::max(best, endingHere);
}
return best;
}
void solve(std::istream& in, std::ostream& out) {
int n;
in >> n;
std::vector<ll> a(n);
for (ll& x : a) in >> x;
std::println(out, "{}", bestStretch(a));
}
int main() {
std::istringstream sample("8\n4 -6 3 -1 5 -9 2 2\n");
solve(sample, std::cout); // on a judge: solve(std::cin, std::cout)
}Press Run to compile and run this example.
7The values are stored as ll (long long) even though each one fits in an int, because the totals do not. The pitfall section below shows what happens otherwise.
Counting steps instead of timing them
A claim like "the cubic version is too slow" deserves evidence. Timing a program on a shared server gives noisy numbers that change from run to run. Counting does not: we decide what one step is, add one each time it happens, and print the totals for growing n.
Here a step is one addition of a day's value into a running sum. All three versions run on the same random data and must agree on the answer.
#include <algorithm>
#include <cstdint>
#include <print>
#include <vector>
using ll = long long;
struct Lcg { // tiny deterministic generator: same numbers on every compiler
std::uint64_t s;
std::uint32_t next() {
s = s * 6364136223846793005ULL + 1442695040888963407ULL;
return std::uint32_t(s >> 33);
}
int range(int lo, int hi) { return lo + int(next() % std::uint32_t(hi - lo + 1)); }
};
ll cubic(const std::vector<ll>& a, ll& steps) {
ll best = a[0];
for (std::size_t l = 0; l < a.size(); ++l)
for (std::size_t r = l; r < a.size(); ++r) {
ll sum = 0;
for (std::size_t k = l; k <= r; ++k) { sum += a[k]; ++steps; }
best = std::max(best, sum);
}
return best;
}
ll quadratic(const std::vector<ll>& a, ll& steps) {
ll best = a[0];
for (std::size_t l = 0; l < a.size(); ++l) {
ll sum = 0;
for (std::size_t r = l; r < a.size(); ++r) {
sum += a[r]; // reuse the sum of [l, r - 1]
++steps;
best = std::max(best, sum);
}
}
return best;
}
ll linear(const std::vector<ll>& a, ll& steps) {
ll best = a[0], endingHere = 0;
for (ll x : a) {
endingHere = std::max(x, endingHere + x);
++steps;
best = std::max(best, endingHere);
}
return best;
}
int main() {
Lcg rng{2024};
std::println("{:>5} {:>12} {:>10} {:>6} agree", "n", "O(n^3)", "O(n^2)", "O(n)");
for (int n : {100, 200, 400, 800}) {
std::vector<ll> a(n);
for (ll& x : a) x = rng.range(-1000, 1000);
ll s3 = 0, s2 = 0, s1 = 0;
ll r3 = cubic(a, s3), r2 = quadratic(a, s2), r1 = linear(a, s1);
bool agree = r3 == r2 && r2 == r1;
std::println("{:>5} {:>12} {:>10} {:>6} {}", n, s3, s2, s1, agree);
}
}Press Run to compile and run this example.
n O(n^3) O(n^2) O(n) agree
100 171700 5050 100 true
200 1353400 20100 200 true
400 10746800 80200 400 true
800 85653600 320400 800 trueEach time n doubles, the last column doubles, the middle one multiplies by 4 and the first by almost 8. That is what the exponents mean, made visible. The exact counts are n(n+1)(n+2)/6, n(n+1)/2 and n: the cubic version has a small constant in front of n^3 (one sixth), and it still loses badly. Extrapolate the first column to n = 2*10^5 and you get about 1.3*10^15 steps. The middle column gives 2*10^10. Only the last one fits.
Brute force first, then a stress test
The cubic version is useless at the real limits and very valuable anyway. It is short, it follows the definition of the problem directly, and it is hard to get wrong. That makes it the reference for testing the fast version.
A stress test runs both on thousands of small random inputs and compares the answers. Small inputs matter: when the two disagree, the failing case is short enough to check by hand. The program below tests the correct linear version and a tempting variant that starts best at 0 instead of the first value. That variant is the right answer to a slightly different problem, one where an empty stretch with total 0 is allowed.
#include <algorithm>
#include <cstdint>
#include <print>
#include <vector>
using ll = long long;
struct Lcg {
std::uint64_t s;
std::uint32_t next() {
s = s * 6364136223846793005ULL + 1442695040888963407ULL;
return std::uint32_t(s >> 33);
}
int range(int lo, int hi) { return lo + int(next() % std::uint32_t(hi - lo + 1)); }
};
ll brute(const std::vector<ll>& a) { // straight from the definition
ll best = a[0];
for (std::size_t l = 0; l < a.size(); ++l)
for (std::size_t r = l; r < a.size(); ++r) {
ll sum = 0;
for (std::size_t k = l; k <= r; ++k) sum += a[k];
best = std::max(best, sum);
}
return best;
}
ll linear(const std::vector<ll>& a, ll initialBest) {
ll best = initialBest, endingHere = 0;
for (ll x : a) {
endingHere = std::max(x, endingHere + x);
best = std::max(best, endingHere);
}
return best;
}
void stress(const char* name, bool startAtZero) {
Lcg rng{7};
for (int test = 1; test <= 3000; ++test) {
std::vector<ll> a(rng.range(1, 8));
for (ll& x : a) x = rng.range(-10, 10);
ll want = brute(a);
ll got = linear(a, startAtZero ? 0 : a[0]);
if (want != got) {
std::print("{}: test {} fails on [", name, test);
for (ll x : a) std::print(" {}", x);
std::println(" ]: expected {}, got {}", want, got);
return;
}
}
std::println("{}: 3000 random tests, all agree", name);
}
int main() {
stress("best starts at a[0]", false);
stress("best starts at 0 ", true);
}Press Run to compile and run this example.
best starts at a[0]: 3000 random tests, all agree
best starts at 0 : test 3 fails on [ -7 ]: expected -7, got 0The bug is found on the third random case, and the counterexample is a single day with a loss of 7. Once you see it, the cause is obvious: when every value is negative, no stretch reaches 0, but the buggy version reports 0 anyway. The sample in the statement had positive totals and would never have shown this.
This habit, a brute force written first and kept for a stress test, is one of the most reliable ways to avoid a wrong answer that you cannot explain. The next lesson turns it into a reusable harness.
A second problem: hashing, sorting or both loops
Two parcels, one drone. A courier has n parcels with weights w_1 ... w_n grams. A drone flight must carry exactly two different parcels whose weights add up to exactly C grams. Is there such a pair? Constraints: 2 <= n <= 2*10^5, 1 <= w_i, C <= 10^9.
Three approaches, in order of complexity:
- All pairs. Check every
i < j:n(n-1)/2checks,O(n^2). Too slow at the limit. - Sort, then two pointers. Sort the weights, put one pointer at the lightest and one at the heaviest. If the two add up to less than
C, the lightest one cannot be in any pair (even the heaviest partner is not enough), so move the left pointer right. If they add up to more, the heaviest one is out, so move the right pointer left. Each step discards one parcel, so there are at mostn - 1steps after anO(n log n)sort. - Hash set. Walk through the parcels; for each weight
w, ask whetherC - wwas seen before, then rememberw. That isnlookups andninsertions into astd::unordered_set, each constant time on average:O(n)expected.
The input is n and C, then the weights. This program solves the sample with the sorting method and then counts the work of all three methods on inputs with no valid pair, which is the worst case because nobody stops early.
#include <algorithm>
#include <iostream>
#include <print>
#include <sstream>
#include <unordered_set>
#include <vector>
using ll = long long;
bool allPairs(const std::vector<ll>& w, ll c, ll& work) {
for (std::size_t i = 0; i < w.size(); ++i)
for (std::size_t j = i + 1; j < w.size(); ++j)
if (++work, w[i] + w[j] == c) return true;
return false;
}
bool twoPointers(std::vector<ll> w, ll c, ll& work) { // takes a copy to sort
std::ranges::sort(w);
std::size_t lo = 0, hi = w.size() - 1;
while (lo < hi) {
++work;
ll sum = w[lo] + w[hi];
if (sum == c) return true;
if (sum < c) ++lo; else --hi; // the excluded parcel has no partner
}
return false;
}
bool hashed(const std::vector<ll>& w, ll c, ll& work) {
std::unordered_set<ll> seen;
for (ll x : w) {
++work;
if (seen.contains(c - x)) return true;
seen.insert(x);
}
return false;
}
void solve(std::istream& in, std::ostream& out) {
int n;
ll c;
in >> n >> c;
std::vector<ll> w(n);
for (ll& x : w) in >> x;
ll work = 0;
std::println(out, "{}", twoPointers(w, c, work) ? "YES" : "NO");
}
int main() {
std::istringstream sample("5 1000\n420 250 380 750 300\n");
solve(sample, std::cout); // 250 + 750 = 1000
std::println("{:>6} {:>10} {:>8} {:>6}", "n", "all pairs", "pointers", "hash");
for (int n : {1000, 2000, 4000, 8000}) {
std::vector<ll> w(n);
for (int i = 0; i < n; ++i) w[i] = 2 * (i + 1); // even weights, odd C: no pair
ll a = 0, b = 0, h = 0;
allPairs(w, 1001, a), twoPointers(w, 1001, b), hashed(w, 1001, h);
std::println("{:>6} {:>10} {:>8} {:>6}", n, a, b, h);
}
}Press Run to compile and run this example.
YES
n all pairs pointers hash
1000 499500 999 1000
2000 1999000 1999 2000
4000 7998000 3999 4000
8000 31996000 7999 8000The pointer column does not include the sort. std::sort and std::ranges::sort are required by the standard to make O(n log n) comparisons, so the sorting method costs O(n log n) in total; at n = 2*10^5 that is a few million comparisons. The pointer loop itself is an example of amortized reasoning: a single step can do anything, but since every step moves a pointer inward and the pointers start n - 1 apart, the whole loop takes at most n - 1 steps.
Expected time is not worst-case time
The hash set column looks like the winner, and on random data it is. But the word "expected" is doing work. std::unordered_set lookups are constant on average and linear in the size of the container in the worst case, which happens when many keys land in the same bucket. Which keys do that depends on the hash function, and for integers the standard library's hash is a fixed, public function. Someone who knows it can choose inputs that collide on purpose; Scott Crosby and Dan Wallach showed this as a practical denial-of-service attack on real hash table implementations in 2003. Contest platforms where other participants may supply test inputs against your solution are a version of the same situation.
So the honest summary of the two methods is: sorting with two pointers is O(n log n) in the worst case; hashing is O(n) expected and O(n^2) in the worst case. At n = 2*10^5, both fit in the budget when the hash behaves, and only the first one is guaranteed to. When O(n log n) fits, it is the safer choice. Lesson 10 returns to the problem of hashing that an adversary cannot predict.
Amortized cost: why `push_back` is cheap
std::vector::push_back is specified to take amortized constant time. Sometimes the vector is full, and that one call allocates a larger buffer and moves every element into it, which is O(n). Amortized constant means that over any sequence of n calls the total work is O(n), so the expensive calls are rare enough to average out.
They are rare because the capacity grows by a constant factor rather than a constant amount. The exact factor is the library's choice and differs between implementations. This program simulates both policies, doubling the capacity and adding one slot, and counts how many element copies each one causes for n insertions.
#include <algorithm>
#include <print>
using ll = long long;
// Number of element copies when n elements are appended one at a time
// and a full buffer is replaced by one of capacity grow(old capacity).
template <class Grow>
ll copiesFor(ll n, Grow grow) {
ll size = 0, capacity = 0, copies = 0;
for (ll i = 0; i < n; ++i) {
if (size == capacity) {
copies += size; // move everything into the new buffer
capacity = grow(capacity);
}
++size;
}
return copies;
}
int main() {
auto doubling = [](ll c) { return std::max<ll>(1, 2 * c); };
auto plusOne = [](ll c) { return c + 1; };
std::println("{:>8} {:>10} {:>9} {:>14}", "n", "doubling", "per elem", "plus one");
for (ll n : {1'000, 10'000, 100'000, 1'000'000}) {
ll d = copiesFor(n, doubling), p = copiesFor(n, plusOne);
std::println("{:>8} {:>10} {:>9.2f} {:>14}", n, d, double(d) / n, p);
}
}Press Run to compile and run this example.
n doubling per elem plus one
1000 1023 1.02 499500
10000 16383 1.64 49995000
100000 131071 1.31 4999950000
1000000 1048575 1.05 499999500000With doubling, the copies are 1 + 2 + 4 + ... up to the last capacity, which is less than twice the final size, so there are never more than two copies per element. With "plus one", every insertion copies the whole array and the total is n(n-1)/2. Same code, same answers, and one policy is quadratic.
The same reasoning tells you when a container is the wrong tool. If you know the final size, reserve it and there are no copies at all. If you insert at the front of a std::vector, every insertion shifts all existing elements, and no growth policy helps.
Constants, memory and the machine
Big-O hides constant factors, and sometimes they decide. An O(n log n) algorithm with a heavy inner step can lose to a tight O(n^2) loop when n is a few dozen; for this reason the std::sort implementations in both GCC's libstdc++ and LLVM's libc++ hand short ranges to insertion sort, a quadratic method. At the sizes in the table, though, the exponent dominates, as the counting program showed: one sixth of n^3 is still hopeless at n = 2*10^5.
Memory has a budget too, usually stated next to the time limit. Do the arithmetic before you allocate. A std::vector<int> of 10^7 elements is 40 MB on the usual platforms where int has 4 bytes. A 5000 by 5000 table of long long is 2.5*10^7 entries of 8 bytes, 200 MB, which is more than many problems allow even though O(n^2) time is fine at that size. A table with an entry for each of the 2^20 subsets and each of 20 positions has about 2*10^7 entries: fine for int, worth checking for anything bigger.
The machine itself adds one effect worth knowing. Memory is fetched from RAM in blocks (cache lines), so walking through a contiguous array uses every byte it fetched, while following pointers in a std::list or a tree jumps to unrelated addresses. Both traversals are O(n); the contiguous one is often several times faster in practice. Prefer std::vector and arrays unless you need what a node-based container gives you.
The interview version of the same skill
In an interview the constraints are often not written down. Asking for them is part of solving the problem, not a delay before it. Good questions are short and specific:
- How large can the input get? Thousands, millions?
- What is the range of the values? Can they be negative, or zero? Can the answer overflow 32 bits?
- Can the input be empty? Are there duplicates? Is it sorted?
- Do you want the value, or also where it comes from (indices, the stretch itself)?
Then say a brute force out loud with its complexity, before optimizing: "Trying every pair is O(n^2), which is fine for a thousand parcels but not for a million. Sorting first gets it to O(n log n)." This shows that you can produce a correct answer, gives you a reference to test against, and often reveals the bottleneck that the better solution removes. When you finish, state time and extra memory separately, and say whether the time is worst case or expected.
The most common way to write a quadratic program by accident is to call something that looks like one step but walks over the whole container. v.erase(it) on a std::vector shifts every element after it one place to the left. Doing it in a loop over the vector makes the loop O(n^2). The same goes for v.insert(v.begin(), x), for s = s + c on a std::string (which builds a new string each time, unlike s += c), and for std::count or std::find called inside a loop over the same data.
Here is the problem: remove the invalid readings (stored as 0) from a log where every second reading is invalid. The program counts elements shifted by erase in a loop, using the cost the standard specifies for erase (one move per element after the erased one), and compares it with std::erase from C++20, which moves each kept element at most once.
#include <print>
#include <vector>
using ll = long long;
int main() {
std::println("{:>6} {:>12} {:>14}", "n", "erase loop", "std::erase");
for (int n : {2500, 5000, 10000, 20000}) {
std::vector<int> log(n);
for (int i = 0; i < n; ++i) log[i] = i % 2 ? 0 : i + 1; // every second is 0
std::vector<int> a = log;
ll shifted = 0;
for (auto it = a.begin(); it != a.end();) {
if (*it == 0) {
shifted += a.end() - it - 1; // elements after it move left by one
it = a.erase(it);
} else {
++it;
}
}
std::vector<int> b = log;
ll moved = 0; // std::erase compacts: each kept element moves at most once
for (int x : b) moved += x != 0;
std::erase(b, 0);
std::println("{:>6} {:>12} {:>14} same result: {}", n, shifted, moved, a == b);
}
}Press Run to compile and run this example.
n erase loop std::erase
2500 1561250 1250 same result: true
5000 6247500 2500 same result: true
10000 24995000 5000 same result: true
20000 99990000 10000 same result: trueBoth methods leave the same vector. Doubling n multiplies the left column by about 4 and the right one by 2. At n = 2*10^5 the loop would shift about 10^10 elements.
The second classic is overflow. Every value fits in int, so the sum feels like it should too:
#include <cstdint>
#include <limits>
#include <print>
#include <vector>
int main() {
std::vector<int> a(200'000, 1'000'000'000); // n and values at their limits
long long wide = 0;
for (int x : a) wide += x;
// What a 32-bit accumulator ends up holding (the conversion wraps since C++20).
auto narrow = static_cast<std::int32_t>(wide);
std::println("true sum: {}", wide);
std::println("32-bit result: {}", narrow);
std::println("largest int32_t: {}", std::numeric_limits<std::int32_t>::max());
}Press Run to compile and run this example.
true sum: 200000000000000
32-bit result: 552894464
largest int32_t: 2147483647The wrapped value is positive and looks plausible, which is what makes this bug hard to spot by eye. A real int accumulator is worse than this demonstration suggests: signed overflow is undefined behavior, so the compiler may assume it never happens, and the result is not even guaranteed to be the wrapped value. Compile with -fsanitize=undefined while testing and the program stops at the overflowing addition. Read the value range, multiply by n, and pick the type before you write the loop.
Do the same few things in the same order every time. Each step is cheap, and skipping one is how a correct idea turns into a wrong verdict.
- Read the constraints first. Write down
n, the value range, the number of test cases and the limits. Look up the target complexity in the table. - Estimate the steps of your idea at the largest
n. If it is within about10^8, go; if it is10^10, do not start typing. - Write the brute force anyway when the problem is not trivial. It costs minutes and gives you a stress test, as above.
- Check the types. Largest possible answer, largest intermediate value. If either can exceed about
2*10^9, uselong long. - Look for hidden loops in every library call inside a loop:
erase,insert,find, string concatenation, copying a container by value. - State the complexity of what you wrote, worst case and expected separately. If you cannot, you do not yet know whether it passes.
Practice: read the limits, then solve
Two original problems. Try each before reading the solution program.
Repeated tickets. A raffle printed n tickets, but the printer was faulty and some numbers repeat. Count the pairs of tickets i < j with the same number. Constraints: 1 <= n <= 2*10^5, numbers between 1 and 10^9. The limits rule out checking all pairs, and the answer can reach n(n-1)/2, about 2*10^10, so it needs long long. Sort, then every group of k equal numbers contributes k(k-1)/2 pairs: O(n log n).
Closest readings. Given n temperature readings in hundredths of a degree, find the smallest absolute difference between two different readings. Constraints: 2 <= n <= 2*10^5, readings between -10^9 and 10^9. After sorting, the closest pair is always adjacent, because any non-adjacent pair has an adjacent pair between them that is at least as close. The difference can be 2*10^9, just below the int limit of 2147483647. Slightly wider limits would overflow it, so the program uses long long rather than rely on that margin.
The program solves both samples and stress tests both solutions against an O(n^2) brute force.
#include <algorithm>
#include <cstdint>
#include <iostream>
#include <print>
#include <sstream>
#include <vector>
using ll = long long;
struct Lcg {
std::uint64_t s;
std::uint32_t next() {
s = s * 6364136223846793005ULL + 1442695040888963407ULL;
return std::uint32_t(s >> 33);
}
int range(int lo, int hi) { return lo + int(next() % std::uint32_t(hi - lo + 1)); }
};
ll equalPairs(std::vector<ll> a) {
std::ranges::sort(a);
ll pairs = 0;
for (std::size_t i = 0, j = 0; i < a.size(); i = j) {
while (j < a.size() && a[j] == a[i]) ++j;
ll k = j - i; // size of one group of equal numbers
pairs += k * (k - 1) / 2;
}
return pairs;
}
ll closest(std::vector<ll> a) {
std::ranges::sort(a);
ll best = a[1] - a[0];
for (std::size_t i = 2; i < a.size(); ++i) best = std::min(best, a[i] - a[i - 1]);
return best;
}
std::pair<ll, ll> brute(const std::vector<ll>& a) { // (equal pairs, closest)
ll pairs = 0, best = -1;
for (std::size_t i = 0; i < a.size(); ++i)
for (std::size_t j = i + 1; j < a.size(); ++j) {
pairs += a[i] == a[j];
ll d = a[i] > a[j] ? a[i] - a[j] : a[j] - a[i];
if (best < 0 || d < best) best = d;
}
return {pairs, best};
}
void solve(std::istream& in, std::ostream& out) {
int n;
in >> n;
std::vector<ll> a(n);
for (ll& x : a) in >> x;
std::println(out, "equal pairs: {}, closest: {}", equalPairs(a), closest(a));
}
int main() {
std::istringstream sample("6\n1000000000 -1000000000 7 7 12 7\n");
solve(sample, std::cout);
Lcg rng{99};
int checked = 0;
for (int test = 0; test < 3000; ++test) {
std::vector<ll> a(rng.range(2, 9));
for (ll& x : a) x = rng.range(-6, 6);
if (brute(a) != std::pair{equalPairs(a), closest(a)}) {
std::println("mismatch on test {}", test);
return 1;
}
++checked;
}
std::println("{} random tests agree with the brute force", checked);
}Press Run to compile and run this example.
equal pairs: 3, closest: 0
3000 random tests agree with the brute forceTry it yourself
- In the step-counting program, add
n = 1600to the list. Predict all three counts from the doubling pattern before you run it. Then predict the cubic count atn = 2*10^5from the formulan(n+1)(n+2)/6. - In the stress test, change the random values from
rng.range(-10, 10)torng.range(0, 10). Predict whether the buggy version is still caught, then run it. What does that tell you about choosing the range of random inputs? - In the parcel program, change the test data so that a valid pair exists near the start (for example make
Cequal tow[0] + w[1]). Which columns shrink, and which one does not shrink as much as you might expect? - In the growth simulation, add a policy that grows by a factor of 1.5 (
c + c / 2, but at leastc + 1). Predict whether copies per element stay bounded, and roughly by what. - Write a third practice problem's solution: given
n <= 2*10^5values, count the pairsi < jwitha_i + a_j = C. Reuse the two-pointer idea, take care with groups of equal values, and stress test it against the double loop.
- Antti Laaksonen, "Competitive Programmer's Handbook", 2018. Chapter 2 covers time complexity, the input-size table and the maximum subarray problem at three complexities.
- Jon Bentley, "Programming Pearls", 2nd edition, Addison-Wesley, 2000. Column 8, "Algorithm Design Techniques", develops the maximum subvector problem from cubic to linear time.
- Scott A. Crosby and Dan S. Wallach, "Denial of Service via Algorithmic Complexity Attacks", 12th USENIX Security Symposium, 2003. How inputs chosen to collide turn hash tables into linked lists.
- cppreference:
std::vector::push_backandstd::unordered_map::find, for the complexity guarantees the standard library makes.
- Read the constraints first. They tell you the intended complexity: about
10^8simple steps is the rule-of-thumb budget, and a wrong exponent usually misses it by factors of hundreds, not by a little. - The same problem often has solutions at several complexities. The best stretch problem goes from
O(n^3)toO(n^2)with a running sum and toO(n)with "best ending here". - Show complexity by counting steps for growing
n. Doublingndoubles, quadruples or multiplies by eight the work, and timing on a shared machine cannot show that reliably. - Keep the brute force. A stress test against it on small random inputs finds bugs like a wrong initial value in seconds.
- Distinguish worst case, expected and amortized: two pointers are amortized linear,
push_backis amortized constant, a hash lookup is constant only on average. - Watch for library calls that hide a loop (
erase,insertat the front, string concatenation) and for sums that overflowint.
Quiz — Test Your Knowledge
(50 XP)1. A problem has n <= 200000 and a two-second limit. Your solution has two nested loops over all pairs. What should you expect?
2. In the step-counting program, n doubles from 400 to 800. By about what factor does the cubic version's count grow?
3. A stress test of "best stretch" with best initialized to 0 failed on the input [-7]. What does the failure tell you?
4. Which statement about std::unordered_set lookups is accurate?
5. You remove all zeros from a std::vector of 2*10^5 values with it = v.erase(it) in a loop, and half the values are zero. What is the problem?