← All Posts
DSA Series · Arrays · Sorting Techniques · Linear Time Sorts

Linear Time Sorts

Comparison-based sorting has a proven lower bound of $\Omega(n \log n)$. But what if we do not compare elements at all? By exploiting the structure of the data (integer keys, bounded range, digit decomposition), we can sort in linear time. This post covers the three main non-comparison sorts: counting sort, radix sort, and bucket sort.

The catch: these algorithms have constraints. They work only on specific types of input (usually non-negative integers within a known range). But when those constraints are met, they are dramatically faster than any comparison-based sort.

Counting Sort

Counting sort works by counting how many times each value appears, then using those counts to place elements directly into their correct positions. It does not compare any two elements.

How It Works

Build the Count Array

Create an array count[] of size $k+1$, where $k$ is the maximum value in the input. Scan the input and increment count[arr[i]] for each element.

Compute Prefix Sums

Replace each count[i] with the sum of all counts up to index $i$: count[i] += count[i-1]. Now count[v] tells us the position (one past the last index) where value $v$ should go in the output.

Build the Output Array

Scan the input from right to left (to preserve stability). For each element arr[i], place it at position count[arr[i]] - 1 in the output, then decrement count[arr[i]].

Step-Through Animation

Counting Sort: Step-Through

Input: [4, 2, 2, 8, 3, 3, 1]. Watch the count array build up, prefix sums accumulate, and output array fill in.

C++ Implementation

void countingSort(vector<int>& arr) {
    if (arr.empty()) return;
    int maxVal = *max_element(arr.begin(), arr.end());
    vector<int> count(maxVal + 1, 0);

    // Step 1: count occurrences
    for (int x : arr) count[x]++;

    // Step 2: prefix sums
    for (int i = 1; i <= maxVal; i++)
        count[i] += count[i - 1];

    // Step 3: build output (right to left for stability)
    vector<int> output(arr.size());
    for (int i = arr.size() - 1; i >= 0; i--) {
        output[count[arr[i]] - 1] = arr[i];
        count[arr[i]]--;
    }
    arr = output;
}

Analysis

When to use counting sort: When the range $k$ is not significantly larger than $n$. If $k = O(n)$, counting sort runs in $O(n)$. If $k$ is much larger (say, $k = n^2$), the count array wastes memory and the algorithm is no better than comparison sorts.

Example: sorting exam scores (0 to 100) for a class of 500 students. Here $k = 100$ and $n = 500$, so counting sort runs in $O(600)$. Merge sort would take $O(500 \cdot 9) \approx 4500$ comparisons.

Radix Sort

Radix sort handles the problem of large ranges by sorting one digit at a time. Instead of counting all possible values, it sorts by the least significant digit, then the next, and so on. Each digit sort uses counting sort as a subroutine.

How It Works

  1. Find the maximum number of digits $d$ in the input.
  2. For each digit position from least significant to most significant:
  3. Use a stable sort (counting sort with $k = 9$) on that digit.

Sorting from least significant to most significant works because each stable sort preserves the relative order from previous digit sorts. After processing the most significant digit, the entire array is sorted.

Radix Sort Visualization

Radix Sort: [170, 45, 75, 90, 802, 24, 2, 66] Original: 170 045 075 090 802 024 002 066 Pass 1 (ones): 170 090 802 002 024 045 075 066 Sorted by ones digit: 0, 0, 2, 2, 4, 5, 5, 6 Pass 2 (tens): 802 002 024 045 066 170 075 090 Sorted by tens digit: 0, 0, 2, 4, 6, 7, 7, 9 Pass 3 (hundreds): 002 024 045 066 075 090 170 802 Sorted by hundreds digit: 0, 0, 0, 0, 0, 0, 1, 8 Result: 2 24 45 66 75 90 170 802 3 passes (d=3 digits) x O(n+10) each = O(d * (n+k)) total
Radix sort processes digits right to left. Each pass uses counting sort with k = 10 (base 10 digits).

C++ Implementation

void countingSortByDigit(vector<int>& arr, int exp) {
    int n = arr.size();
    vector<int> output(n);
    int count[10] = {0};

    for (int i = 0; i < n; i++)
        count[(arr[i] / exp) % 10]++;

    for (int i = 1; i < 10; i++)
        count[i] += count[i - 1];

    for (int i = n - 1; i >= 0; i--) {
        int digit = (arr[i] / exp) % 10;
        output[count[digit] - 1] = arr[i];
        count[digit]--;
    }
    arr = output;
}

void radixSort(vector<int>& arr) {
    if (arr.empty()) return;
    int maxVal = *max_element(arr.begin(), arr.end());
    for (int exp = 1; maxVal / exp > 0; exp *= 10)
        countingSortByDigit(arr, exp);
}

Analysis

When radix sort beats comparison sorts: If the maximum value is bounded by a polynomial in $n$ (say, max $\leq n^c$ for some constant $c$), then $d = O(c)$ and radix sort runs in $O(n)$. For 32-bit integers, $d = 10$ (base 10) or $d = 4$ (base 256), both constants.

Choosing the Base

The base does not have to be 10. Using base 256, each 32-bit integer has only 4 "digits" (bytes). This means 4 passes of counting sort over a count array of size 256. In practice, base-256 radix sort is extremely fast for sorting arrays of 32-bit integers.

// Base-256 radix sort for 32-bit unsigned integers
void radixSort256(vector<uint32_t>& arr) {
    int n = arr.size();
    vector<uint32_t> buf(n);
    for (int shift = 0; shift < 32; shift += 8) {
        int count[256] = {0};
        for (int i = 0; i < n; i++)
            count[(arr[i] >> shift) & 0xFF]++;
        for (int i = 1; i < 256; i++)
            count[i] += count[i - 1];
        for (int i = n - 1; i >= 0; i--)
            buf[--count[(arr[i] >> shift) & 0xFF]] = arr[i];
        swap(arr, buf);
    }
}

Bucket Sort

Bucket sort distributes elements into a fixed number of buckets, sorts each bucket individually (typically with insertion sort), and then concatenates all buckets. It works best when the input is uniformly distributed over a known range.

How It Works

  1. Create $k$ empty buckets.
  2. Distribute each element into a bucket based on its value: bucket index $= \lfloor k \cdot \text{arr}[i] / (\text{max} + 1) \rfloor$.
  3. Sort each bucket (using insertion sort or any sort).
  4. Concatenate all buckets in order.

Bucket Sort Visualization

Input: [0.78, 0.17, 0.39, 0.26, 0.72, 0.94, 0.21, 0.12, 0.23, 0.68] 10 buckets (range [0, 1)): [0] empty [1] 0.17, 0.12 [2] 0.26, 0.21, 0.23 [3] 0.39 [4]..[5] empty [6] 0.68 [7] 0.78, 0.72 [8] empty [9] 0.94 Sort each bucket: [1] 0.12, 0.17 [2] 0.21, 0.23, 0.26 [3] 0.39 [6] 0.68 [7] 0.72, 0.78 [9] 0.94 Concatenate: 0.12 0.17 0.21 0.23 0.26 0.39 0.68 0.72 0.78 0.94
Bucket sort for floating-point numbers in [0, 1). Each bucket covers a range of 0.1.

C++ Implementation

void bucketSort(vector<float>& arr) {
    int n = arr.size();
    if (n <= 1) return;
    vector<vector<float>> buckets(n);

    for (float x : arr) {
        int idx = (int)(n * x);  // assumes x in [0, 1)
        buckets[idx].push_back(x);
    }

    for (auto& b : buckets)
        sort(b.begin(), b.end());  // insertion sort for small buckets

    int pos = 0;
    for (auto& b : buckets)
        for (float x : b)
            arr[pos++] = x;
}

Analysis

When to use bucket sort: When the input is uniformly distributed over a known range, such as random floating-point numbers in $[0, 1)$, hash values, or any data that distributes evenly across bins.

When to Use Which

AlgorithmInput ConstraintTimeSpaceBest For
Counting Sort Non-negative integers, range $k$ known $O(n + k)$ $O(n + k)$ Small range (k close to n). Scores, grades, ages.
Radix Sort Fixed-length integers or strings $O(d \cdot (n + k))$ $O(n + k)$ Large range but bounded digits. 32-bit integers, fixed-length strings.
Bucket Sort Uniformly distributed over known range $O(n)$ avg $O(n + k)$ Floating-point values in [0,1). Uniformly distributed data.

Decision Guide

Handling Negative Numbers

Counting sort and radix sort assume non-negative keys. To handle negatives:

// Approach 1: Shift all values to non-negative
void countingSortWithNegatives(vector<int>& arr) {
    int minVal = *min_element(arr.begin(), arr.end());
    for (int& x : arr) x -= minVal;  // shift to [0, max-min]
    countingSort(arr);
    for (int& x : arr) x += minVal;  // shift back
}

// Approach 2: Separate negatives and positives
void radixSortSigned(vector<int>& arr) {
    vector<int> neg, pos;
    for (int x : arr) {
        if (x < 0) neg.push_back(-x);
        else pos.push_back(x);
    }
    radixSort(neg);
    radixSort(pos);
    reverse(neg.begin(), neg.end());
    int i = 0;
    for (int x : neg) arr[i++] = -x;
    for (int x : pos) arr[i++] = x;
}

Practice Problems

Summary