LOADING 0%
// nav_menu.exe
Home Resume Blog Contact Order
English فارسی
~/blog / algorithms / sorting-one-million-numbers

How a Computer Sorts One Million Numbers

Imagine handing the computer a file containing 1,000,000 random numbers — 83942, 17, 492001, 73, 12884... — and asking it to hand them back in order: 17, 73, 12884... 492001. At first glance the task looks trivial: "compare the numbers and arrange them from smallest to largest." But with a million numbers, that one sentence can mean the difference between a few milliseconds and several hours. The computer doesn't sort a million numbers with a magic trick — what determines the speed is the algorithm we choose.

# How Big Is the Problem, Really?

One million numbers means N = 1,000,000. If we compare every number with every other number, the number of comparisons grows roughly like this:
problem_size.cpp
// Pairwise comparisons:
N × (N − 1) / 2
 
1,000,000 × 999,999 / 2
499,999,500,000
// ≈ 500 billion comparisons
And here the first important insight appears: the real problem is not "sorting" — the real problem is reducing the number of operations. If our algorithm is forced to perform ~500 billion comparisons, even a fast CPU cannot turn that into a small task. But if the operation count drops to the order of N·log₂(N):
better.cpp
1,000,000 × log₂(1,000,000)
1,000,000 × 19.93
19,930,000
// ≈ 20 million — not 500 billion
This difference is the whole story. Going from ~500 billion operations to ~20 million is not a small optimization — it changes the nature of the problem. For large data, the choice of algorithm can matter more than the power of the CPU.

# One Million Numbers; Two Completely Different Worlds

To see the scale of the problem side by side, here is what each family of algorithms costs for N = 1,000,000:
Algorithm Time Complexity Operations at N = 1,000,000
Bubble Sort O(N²) ≈ 500 billion comparisons
Selection Sort O(N²) ≈ 500 billion comparisons
Insertion Sort O(N²) worst case ≈ 500 billion worst case
Merge Sort O(N log N) ≈ 20 million
Heap Sort O(N log N) ≈ 20 million
Quick Sort O(N log N) average ≈ 20 million average
Counting Sort O(N + K) Depends on the value range K
Radix Sort O(d·N) Depends on the number of digits d
std::sort O(N log N) ≈ 20 million order of comparisons

# The First Algorithm: The Simplest Possible Idea

Let's start with the approach almost everyone thinks of first when they meet sorting: Bubble Sort. Take the array [5, 2, 8, 1, 3]. Compare two adjacent numbers: 5 > 2, so swap them:
bubble_walkthrough.txt
[5, 2, 8, 1, 3]  // 5 > 2 → swap
[2, 5, 8, 1, 3]  // 5 < 8 → keep
[2, 5, 1, 8, 3]  // 8 > 1 → swap
[2, 5, 1, 3, 8]  // 8 > 3 → swap, 8 is in place
...                 // repeat until done
[1, 2, 3, 5, 8]
bubble_sort.cpp — C++
void bubbleSort(std::vector<int>& a)
{
    int n = a.size();
 
    for (int i = 0; i < n; ++i)
        for (int j = 0; j < n - i - 1; ++j)
            if (a[j] > a[j + 1])
                std::swap(a[j], a[j + 1]);
}
Educationally, it's excellent. Practically, for a million numbers? A disaster: two nested loops over a million elements each → roughly 500,000,000,000 comparisons.
Pros
  • Extremely simple to understand
  • Great for teaching the concept
  • Needs almost no extra memory
  • Easy to implement
Cons
  • O(N²) — catastrophically slow
  • Unusable for large data
  • No good reason to use it on 1M numbers

# Selection Sort & Insertion Sort: Two Old Friends

Selection Sort works differently: find the smallest element in [7, 4, 9, 1, 6] — that's 1 — and move it to the front: [1, 4, 9, 7, 6]. Then find the smallest of the remainder, and so on until the array is sorted.
selection_sort.cpp — C++
void selectionSort(std::vector<int>& a)
{
    int n = a.size();
 
    for (int i = 0; i < n - 1; ++i)
    {
        int minIndex = i;
 
        for (int j = i + 1; j < n; ++j)
            if (a[j] < a[minIndex])
                minIndex = j;
 
        std::swap(a[i], a[minIndex]);
    }
}
Complexity: O(N²). For N = 1,000,000 we're back at ~500 billion comparisons. Selection Sort can be interesting in terms of the number of swaps, but its main problem remains the enormous number of comparisons.
Insertion Sort is a bit different. Imagine holding playing cards: every new card you receive, you insert it in its correct place among the previous cards. Given [2, 5, 8, 10] and a new card 6, you place it between 5 and 8: [2, 5, 6, 8, 10].
insertion_sort.cpp — C++
void insertionSort(std::vector<int>& a)
{
    for (int i = 1; i < (int)a.size(); ++i)
    {
        int key = a[i];
        int j = i - 1;
 
        while (j >= 0 && a[j] > key)
        {
            a[j + 1] = a[j];
            --j;
        }
 
        a[j + 1] = key;
    }
}
Don't dismiss Insertion Sort. Its best case is O(N) — if the data is already nearly sorted, it performs beautifully. That's why professional sorting implementations sometimes use the idea of Insertion Sort for very small sub-arrays: exactly because it's fast exactly there.

# Now the Important Part Begins: Merge Sort

Everything so far shared one flaw: as N grows, the work grows like . Let's look at the problem from a different angle. Instead of comparing everything with everything, we split the array: 1,000,000 → 500,000 + 500,000 → 250,000 + 250,000 + ... and keep going until the pieces are tiny.
merge_walkthrough.txt
// Split:
[8, 3, 7, 4, 9, 2]
[8, 3, 7] | [4, 9, 2]
[8] [3, 7] | [4, 9] [2]
[8] [3] [7] [4] [9] [2]
 
// Merge back (each merge is linear):
[3, 7]  +  [8]      → [3, 7, 8]
[4, 9]  +  [2]      → [2, 4, 9]
[3, 7, 8] + [2, 4, 9] → [2, 3, 4, 7, 8, 9]
merge.cpp — C++
void merge(std::vector<int>& a, int left, int mid, int right)
{
    std::vector<int> temp;
    int i = left, j = mid + 1;
 
    while (i <= mid && j <= right)
    {
        if (a[i] <= a[j]) temp.push_back(a[i++]);
        else               temp.push_back(a[j++]);
    }
 
    while (i <= mid)  temp.push_back(a[i++]);
    while (j <= right) temp.push_back(a[j++]);
 
    for (int k = 0; k < (int)temp.size(); ++k)
        a[left + k] = temp[k];
}
merge_sort.cpp — C++
void mergeSort(std::vector<int>& a, int left, int right)
{
    if (left >= right)
        return;
 
    int mid = left + (right - left) / 2;
 
    mergeSort(a, left, mid);
    mergeSort(a, mid + 1, right);
    merge(a, left, mid, right);
}
 
// Usage:
mergeSort(a, 0, (int)a.size() - 1);
Complexity: O(N log N). For one million numbers that's about 1,000,000 × ~20 ≈ 20,000,000 operations — instead of ≈ 500,000,000,000. The divide-and-conquer idea is what breaks the N² barrier.

# The Growth of the Algorithms, Visualized

At small scales you may not notice much difference. But as N grows, the gap between the two curves explodes:
growth_chart.txt
101001K10K100K1M10M110210410610810101012N = 1MN (elements)operations1012~2×107106O(N²)O(N log N)O(N)
N N² / 2 N·log₂N Ratio
1,000 500,000 ~10,000 50×
100,000 5,000,000,000 ~1,700,000 ~2,900×
1,000,000 ~500,000,000,000 ~19,931,569 ~25,000×
1,000,000,000 ~10¹⁸ ~30,000,000,000 ~33,000,000×
This is not a small optimization. It is practically a change in the nature of the problem.

# Quick Sort: The Algorithm That "Guesses"

Merge Sort splits the array in half blindly, ignoring the content. Quick Sort says: I'll pick one element as the Pivot and partition everything else around it. Take [8, 3, 7, 4, 9, 2, 6] and pick 7 as the pivot:
partition.txt
[8, 3, 7, 4, 9, 2, 6]  // pivot = 7
 
  less than 7    pivot    greater than 7
 ┌──────────┐   ┌──┐    ┌──────────┐
 │ 3 4 2 6 │   │ 7 │    │ 8 9
 └──────────┘   └──┘    └──────────┘
 
// now recurse on both sides
[3,4,2,6] → sorted   [8,9] → sorted
quick_sort.cpp — C++
int partition(std::vector<int>& a, int low, int high)
{
    int pivot = a[high];
    int i = low - 1;
 
    for (int j = low; j < high; ++j)
        if (a[j] < pivot)
            std::swap(a[++i], a[j]);
 
    std::swap(a[i + 1], a[high]);
    return i + 1;
}
 
void quickSort(std::vector<int>& a, int low, int high)
{
    if (low >= high)
        return;
 
    int p = partition(a, low, high);
    quickSort(a, low, p - 1);
    quickSort(a, p + 1, high);
}
On average: O(N log N). But there's a dangerous catch: the worst case is O(N²). For example, if the array is already sorted and we always pick the worst pivot, the partitions degenerate into [1] [2,3,4,...][2] [3,4,5,...] → ... and we're effectively back at N² behavior.
So why is Quick Sort still important? Because on real data, a good pivot strategy works remarkably well. Techniques like Random Pivot, Median-of-three, and introspective strategies reduce or eliminate the probability of the worst case. This is where the real world gets more interesting than the textbook: production sorting algorithms are usually not one pure algorithm, but a hybrid.

# Heap Sort: Sorting with a Heap

In Heap Sort, we first turn the data into a heap structure. In a Max Heap, the largest element always sits at the top:
max_heap.txt
99708040206050
1
Extract max — 99 comes off the top
2
Place at end — the largest goes to its final slot
3
Re-heapify — restore the heap property
Repeat — next largest element, every time
Complexity: O(N log N) in the best, average, AND worst case. Its big advantage is a predictable worst case and very little extra memory. In practice, though, it's usually not as attractive as a very good Quick Sort or std::sort implementation — but when you need guaranteed bounds, it shines.

# Counting Sort: When You Don't Need to Compare at All

Every algorithm so far shared one common action: a < b ? But what if we know our numbers only range from 0 to 100? Take [4, 1, 3, 1, 2, 4, 4] — we don't need to compare anything. We just count:
counting.txt
[4, 1, 3, 1, 2, 4, 4]
 
// count occurrences:
00   12   21   31   43
 
// output:
1 1 2 3 4 4 4
counting_sort.cpp — C++
void countingSort(std::vector<int>& a)
{
    if (a.empty())
        return;
 
    int maxValue = *std::max_element(a.begin(), a.end());
    int minValue = *std::min_element(a.begin(), a.end());
    int range = maxValue - minValue + 1;
 
    std::vector<int> count(range, 0);
    for (int x : a)
        ++count[x - minValue];
 
    int index = 0;
    for (int i = 0; i < range; ++i)
        while (count[i]--)
            a[index++] = i + minValue;
}
Complexity: O(N + K), where K is the size of the value range. With N = 1,000,000 and K = 100, this is phenomenal. But with K = 4,000,000,000, the story changes completely.
Why isn't Counting Sort always the answer? Suppose your data is 12, 8, 92, 4,000,000,000, 17. Building a count array for the entire range 0...4,000,000,000 would consume an unreasonable amount of memory. Counting Sort is fantastic only when the value range is small relative to the number of elements.

# Radix Sort: Go Digit by Digit

There's another way. Instead of comparing whole numbers, sort them digit by digit: first by the ones place, then the tens place, then the hundreds — and so on:
radix.txt
// input:
170  045  075  090  802  024  002  066
 
// after sorting by ones digit:
170  090  802  002  024  045  075  066
 
// after tens, then hundreds → fully sorted
002  024  045  066  075  090  170  802
The cost depends on the number of digits: O(d·N), where d is the digit count and N the element count. For a million 32-bit integers, d is just a few passes — which makes Radix Sort a very powerful option for fixed-width integer keys.

# What We Actually Use in C++: std::sort

In most C++ projects, nobody writes Bubble Sort from scratch. The standard library gives us std::sort():
main.cpp — C++
#include <algorithm>
#include <vector>
 
int main()
{
    std::vector<int> numbers = { 8, 3, 7, 1, 9, 2 };
 
    std::sort(numbers.begin(), numbers.end());
}
That's it. But behind this one simple command lies an entire algorithmic world. The C++ standard requires std::sort to run in O(N log N) and it does not preserve the order of equal elements; common implementations use ideas from the Introsort family — starting with Quick Sort, switching to Heap Sort when recursion gets too deep, and using Insertion Sort for tiny partitions — to keep Quick Sort's speed while capping the worst case at O(N log N).
benchmark.cpp — C++
#include <algorithm>
#include <chrono>
#include <iostream>
#include <random>
#include <vector>
 
int main()
{
    constexpr int N = 1'000'000;
    std::vector<int> numbers(N);
 
    std::mt19937 rng(42);
    std::uniform_int_distribution<int> dist(0, 1'000'000'000);
    for (int& x : numbers) x = dist(rng);
 
    auto start = std::chrono::steady_clock::now();
    std::sort(numbers.begin(), numbers.end());
    auto end = std::chrono::steady_clock::now();
 
    double ms = std::chrono::duration<double, std::milli>(end - start).count();
    std::cout << "Sorting time: " << ms << " ms\n";
    std::cout << "First: " << numbers.front()
             << " | Last: " << numbers.back() << '\n';
}
A common benchmarking mistake: wrapping sort between two clock calls is not automatically a scientific benchmark. For better measurements: warm up first, run several independent repetitions, use identical input data, avoid I/O effects, report the median — and above all, remember Debug ≠ Release. Compile with g++ -O2 -std=c++20 main.cpp or -O3; a Debug build can be drastically slower than the optimized one. Real-world numbers depend entirely on CPU, compiler, standard library, frequency, memory, and system conditions.

# Memory Is Part of the Problem Too — and So Is Stability

A std::vector of one million elements is about 1,000,000 × 4 bytes ≈ 3.8 MiB — not huge. But algorithms like Merge Sort need auxiliary memory of the same order, while others work almost entirely in place. So the question is never just "which is faster?" — it's "which is faster and at what memory cost?"
There's another property: stability. Suppose we sort employees by age: Ali(30), Sara(25), Reza(30), Mina(25). A stable sort guarantees that equal keys keep their original relative order — Ali stays before Reza, Sara stays before Mina. In C++, that's what std::stable_sort is for: it preserves the order of equal elements and runs in O(N log N) comparisons when enough auxiliary memory is available, degrading to O(N log² N) when it isn't.
stable.cpp — C++
std::stable_sort(people.begin(), people.end(),
    [](const Person& a, const Person& b)
    {
        return a.age < b.age;
    });
Algorithm Best Average Worst Extra Memory Stable
Bubble O(N) O(N²) O(N²) O(1)
Selection O(N²) O(N²) O(N²) O(1)
Insertion O(N) O(N²) O(N²) O(1)
Merge O(N log N) O(N log N) O(N log N) O(N)
Quick O(N log N) O(N log N) O(N²) O(log N) typical
Heap O(N log N) O(N log N) O(N log N) O(1)
Counting O(N+K) O(N+K) O(N+K) O(K) can be ✓
Radix O(dN) O(dN) O(dN) depends
std::sort O(N log N) O(N log N) O(N log N) implementation-dependent
std::stable_sort O(N log N)* O(N log N)* O(N log N)* more
* With sufficient auxiliary memory; otherwise O(N log² N).

# A Simple Guide for Choosing an Algorithm

1
Look at the data — small value range? → Counting / Radix
2
General data — does stability matter? → yes: stable_sort
3
Stability not needed — std::sort
Special needs — partial_sort / nth_element
Data Situation Suitable Choice
General-purpose data std::sort
Stability required std::stable_sort
Small numeric range Counting Sort
Limited digit count Radix Sort
Nearly sorted data Insertion / std::sort
Only K elements matter nth_element
Guaranteed worst case needed Heap / introspective

# Maybe You Shouldn't Sort a Million Numbers at All

This part is crucial. Suppose you have 1,000,000 numbers, but the project's only question is: "Which are the 10 largest?" Why sort the entire array? Selection or Heap-based methods can find exactly the part you need. Or if the question is "What is the median?" — again, a full sort is unnecessary:
partial.cpp — C++
// place the median in its correct position
// without fully sorting the array:
std::nth_element(
    numbers.begin(),
    numbers.begin() + numbers.size() / 2,
    numbers.end());
An important principle of software engineering: the most optimal sort is sometimes not sorting. std::partial_sort for partial ordering, std::nth_element for just the top-K or the median — you don't always need all one million elements in order.

# What the Computer Actually Sorts — and Where Cache Enters

A common misconception is that the CPU sees numbers the way humans do: 17, 23, 42, 100. At the hardware level, they exist as binary: 00010001, 00010111, 00101010, 01100100. When we write if (a < b), behind the scenes the CPU deals with registers, memory, cache, branches, and machine instructions: Load, Compare, Branch, Move, Swap, Store.
CPU Registers
Fastest — a handful of slots
L1 Cache
Tiny, extremely fast
L2 / L3 Cache
Larger, slower — shared layers
RAM
Huge capacity, high latency
The further down you go: capacity ↑, latency ↑. So two algorithms with the same theoretical complexity can perform very differently on real hardware, because how they access memory matters enormously. That's why in the real world, Big O is essential — but it isn't everything.
Hardware can't save a bad algorithm. A fast CPU running an O(N²) algorithm loses to a mid-range CPU running an O(N log N) one, once N is large. At N = 10 you might not notice the difference; at N = 1,000,000 the story completely changes. Raw power cannot easily rescue a poor algorithm.

# One Million Numbers Is Not Always the Same Problem

Picture three arrays, all with N = 1,000,000: array A is 1 2 3 4 5 ..., array B is 8 7 6 5 4 ..., array C is 92 14 827 3 71 991 .... Same size — completely different data shapes:
Sorted / Nearly Sorted
  • Insertion Sort shines here — near O(N)
  • Naive Quick Sort can hit its worst case
Random
  • The classic benchmark case
  • std::sort / Quick Sort thrive
Many Duplicates
  • Small effective value range
  • Counting Sort becomes attractive
Small Range (e.g. 0–255)
  • Only 256 possible values
  • Just count — comparison sorting isn't optimal
If the data is 1...1,000,000 but only 100 values are out of place, Insertion Sort behaves dramatically differently from its worst case. So never decide based on N alone — always ask: what does the data look like? The best algorithm isn't the fastest one on paper; it's the one that fits the properties of the data.

# One Million Isn't Really "a Lot" — and a Billion Is a Different Universe

Here's perhaps the strangest part: for a human, 1,000,000 is a big number. For a modern computer, a million ints is a few megabytes — nothing extraordinary. The real question is not the element count; it's how many operations do we perform per element? If it's N — fine. If it's N log N — still very good. If it's N² — the ground suddenly vanishes beneath the algorithm as N grows.
scale.txt
// N = 1,000,000
N log₂N ≈ 20,000,000
 
// N = 1,000,000,000
N log₂N ≈ 30,000,000,000
N²       = 1,000,000,000,000,000,000,000
 
// N² at a billion = 10^18 operations.
// That's where "complexity" shows its teeth.
And this story repeats far beyond sorting — in search, graphs, databases, files, rendering, simulation, AI, cryptography, image processing. They all share one question: as the data grows, how does the number of operations grow? An algorithm that looks perfect today at N = 1,000 can become the system's main bottleneck tomorrow at N = 1,000,000.

# So How Does a Computer Sort One Million Numbers?

The final answer is much simpler than it first appeared. The computer does not brute-force a million numbers with raw CPU power. It does this:
1
Data — understand the structure of the problem
2
Choose the right algorithm — reduce the work
3
Memory & cache — use them wisely
4
Optimized execution — on the CPU, in a Release build
Sorted array — in milliseconds, not hours
For general data: std::sort. When stability matters: std::stable_sort. For a small value range: Counting Sort. For limited-digit integers: Radix Sort. And when only part of the order matters: nth_element. In most ordinary C++ programs, std::sort is the first logical choice — not because it's "always the best algorithm in the world", but because it's a standard, highly-optimized implementation for the general problem, with O(N log N) guaranteed by the C++ standard.
takeaway.txt
Sorting a million numbers in a blink isn't magic —
it's the art of doing less work.