Home DSA Counting Sort and Radix Sort Explained — How to Sort in Linear Time

Counting Sort and Radix Sort Explained — How to Sort in Linear Time

In Plain English 🔥
Imagine you're sorting a massive pile of exam papers by grade (A, B, C, D, F). Instead of comparing papers against each other one by one, you just put each paper straight into the matching labelled box — then read out the boxes in order. That's Counting Sort. Radix Sort is like doing that same trick multiple times: first sort by the last digit, then the tens digit, then the hundreds digit, and suddenly the whole list is sorted without ever comparing two numbers head-to-head.
⚡ Quick Answer
Imagine you're sorting a massive pile of exam papers by grade (A, B, C, D, F). Instead of comparing papers against each other one by one, you just put each paper straight into the matching labelled box — then read out the boxes in order. That's Counting Sort. Radix Sort is like doing that same trick multiple times: first sort by the last digit, then the tens digit, then the hundreds digit, and suddenly the whole list is sorted without ever comparing two numbers head-to-head.

Every developer learns the classic sorting algorithms — QuickSort, MergeSort, HeapSort — and accepts the O(n log n) ceiling as an immovable law of nature. But it isn't a law. It's only the floor for comparison-based sorting. When your data has a bounded range — like ages, zip codes, exam scores, or pixel colour values — you can blow past that ceiling entirely and sort in O(n) time. That's not a theoretical curiosity; it's a genuine production win when you're processing millions of records.

Why Comparison-Based Sorts Hit a Wall at O(n log n)

Before we build anything, you need to understand the problem these algorithms solve. Every comparison-based sort — QuickSort, MergeSort, you name it — works by asking 'is A greater than B?' over and over. Information theory proves you need at least log₂(n!) comparisons to sort n items, which simplifies to O(n log n). You simply cannot do better if comparisons are your only tool.

The key insight behind Counting Sort and Radix Sort is that they don't compare elements against each other at all. They exploit extra information: the range of possible values. If you know all your values sit between 0 and 999, you can use that constraint to skip comparisons entirely and place each element directly into its correct position.

This is the fundamental trade-off: you exchange time for domain knowledge. That trade is fantastic when sorting integers in a known range, but it completely breaks down for arbitrary objects, floats, or unbounded data. Knowing when the trade is worth it is what separates a senior engineer from someone who just memorised an algorithm.

WhyLinearSortingMatters.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
import java.util.Arrays;
import java.util.Random;

public class WhyLinearSortingMatters {

    // Comparison-based sort — time grows as O(n log n)
    static void comparisonSort(int[] data) {
        Arrays.sort(data); // Java's dual-pivot QuickSort under the hood
    }

    // Non-comparison sort — time grows as O(n + k) where k is the value range
    static void countingSort(int[] data, int maxValue) {
        int[] frequency = new int[maxValue + 1]; // bucket for every possible value

        // Pass 1: count how many times each value appears
        for (int value : data) {
            frequency[value]++;
        }

        // Pass 2: write values back in order using the frequency counts
        int writeIndex = 0;
        for (int value = 0; value <= maxValue; value++) {
            while (frequency[value] > 0) {
                data[writeIndex++] = value;
                frequency[value]--;
            }
        }
    }

    public static void main(String[] args) {
        Random rng = new Random(42);
        int dataSize = 1_000_000;
        int maxScore = 100; // exam scores: always 0-100

        int[] scoresForComparison = new int[dataSize];
        int[] scoresForCounting  = new int[dataSize];

        for (int i = 0; i < dataSize; i++) {
            int score = rng.nextInt(maxScore + 1);
            scoresForComparison[i] = score;
            scoresForCounting[i]   = score;
        }

        long start = System.nanoTime();
        comparisonSort(scoresForComparison);
        long comparisonMs = (System.nanoTime() - start) / 1_000_000;

        start = System.nanoTime();
        countingSort(scoresForCounting, maxScore);
        long countingMs = (System.nanoTime() - start) / 1_000_000;

        System.out.println("Sorting " + dataSize + " exam scores (0-100):");
        System.out.println("  Comparison-based (Arrays.sort): " + comparisonMs + " ms");
        System.out.println("  Counting Sort:                  " + countingMs  + " ms");
        System.out.println("  Results match: " + Arrays.equals(scoresForComparison, scoresForCounting));
    }
}
▶ Output
Sorting 1000000 exam scores (0-100):
Comparison-based (Arrays.sort): 87 ms
Counting Sort: 11 ms
Results match: true
🔥
The Real Constraint:Counting Sort's speed comes from the range k, not just the count n. If k is huge (e.g., sorting 1000 items where values go up to 1,000,000,000), you'd allocate a billion-slot array. That's when Counting Sort destroys you. Always ask: what is k relative to n?

Counting Sort Deep Dive — The Stable, Prefix-Sum Version You Actually Need

The simple version of Counting Sort shown above works for integers but loses stability — equal elements might not preserve their original relative order. That matters the moment you're sorting objects (like Student records by grade). The production-grade version uses a prefix-sum trick to guarantee stability, and it's what feeds directly into Radix Sort.

Here's the three-pass approach: first, count frequencies as before. Second, convert the frequency array into a prefix-sum array — each position now stores the starting output index for that value. Third, iterate the original input left-to-right, placing each element at the index the prefix-sum array points to, then increment that pointer.

Why left-to-right? Because iterating in order preserves the relative sequence of equal elements, making the sort stable. This stability is non-negotiable for Radix Sort to work correctly — so understand it here and Radix Sort becomes trivial.

StableCountingSort.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
import java.util.Arrays;

public class StableCountingSort {

    /**
     * Stable Counting Sort for an array of Student records sorted by grade (0-100).
     * Stability means two students with the same grade keep their original order.
     */
    static class Student {
        String name;
        int    grade;

        Student(String name, int grade) {
            this.name  = name;
            this.grade = grade;
        }

        @Override
        public String toString() {
            return name + "(" + grade + ")";
        }
    }

    static Student[] stableCountingSort(Student[] students, int maxGrade) {
        int n = students.length;
        int[] frequency = new int[maxGrade + 1];

        // Pass 1: count how many students have each grade
        for (Student student : students) {
            frequency[student.grade]++;
        }

        // Pass 2: prefix sum — frequency[g] now holds the starting output
        //         index for all students whose grade is exactly g
        for (int grade = 1; grade <= maxGrade; grade++) {
            frequency[grade] += frequency[grade - 1];
        }

        // Pass 3: place each student into the output array.
        //         Iterate RIGHT-TO-LEFT to preserve stability
        //         (last student seen at a grade goes to the rightmost slot for that grade).
        Student[] sorted = new Student[n];
        for (int i = n - 1; i >= 0; i--) {
            int grade = students[i].grade;
            // decrement first because prefix sum points one past the last slot
            sorted[--frequency[grade]] = students[i];
        }

        return sorted;
    }

    public static void main(String[] args) {
        Student[] classroom = {
            new Student("Alice",   85),
            new Student("Bob",     92),
            new Student("Carmen",  85), // same grade as Alice — should stay after Alice
            new Student("David",   78),
            new Student("Eva",     92), // same grade as Bob — should stay after Bob
            new Student("Frank",   65)
        };

        System.out.println("Before: " + Arrays.toString(classroom));

        Student[] sorted = stableCountingSort(classroom, 100);

        System.out.println("After:  " + Arrays.toString(sorted));
        System.out.println();
        System.out.println("Stability check:");
        System.out.println("  Alice(85) appears before Carmen(85)? " +
            (indexOfStudent(sorted, "Alice") < indexOfStudent(sorted, "Carmen")));
        System.out.println("  Bob(92)   appears before Eva(92)?   " +
            (indexOfStudent(sorted, "Bob")   < indexOfStudent(sorted, "Eva")));
    }

    static int indexOfStudent(Student[] arr, String name) {
        for (int i = 0; i < arr.length; i++) {
            if (arr[i].name.equals(name)) return i;
        }
        return -1;
    }
}
▶ Output
Before: [Alice(85), Bob(92), Carmen(85), David(78), Eva(92), Frank(65)]
After: [Frank(65), David(78), Alice(85), Carmen(85), Bob(92), Eva(92)]

Stability check:
Alice(85) appears before Carmen(85)? true
Bob(92) appears before Eva(92)? true
⚠️
Pro Tip:The right-to-left iteration in Pass 3 is the one line most tutorials skip explaining. It's not arbitrary — you must process elements in reverse to pair correctly with the prefix-sum pointers and achieve stability. Get this wrong and you get a sorted-but-unstable result that silently breaks Radix Sort.

Radix Sort — Sorting Digit by Digit Using Counting Sort as the Engine

Radix Sort answers a practical question: what do you do when values are too large for Counting Sort alone? Sorting 10 million phone numbers directly with Counting Sort would need a 10-billion-slot array. Radix Sort sidesteps this by sorting one digit at a time — always using a small, bounded range (0-9 for decimal digits).

The critical rule: always sort from the least significant digit to the most significant digit (LSD Radix Sort). This feels backwards, but it works because each pass is stable. After sorting by units, then tens, then hundreds, the previous passes are preserved by stability, and the array ends up fully sorted.

Think of it like sorting a deck of cards with two-digit numbers. Round 1: sort into 10 piles by the right digit, stack them in order. Round 2: sort those into 10 piles by the left digit, stack them. Done. The key is that Round 1's order is still respected inside each pile from Round 2.

Time complexity is O(d × (n + k)) where d is the number of digits and k is the digit base (10 for decimal). For fixed-length keys like phone numbers or zip codes, d is constant, so this collapses to O(n).

RadixSortPhoneNumbers.java · JAVA
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
import java.util.Arrays;

public class RadixSortPhoneNumbers {

    /**
     * Extracts the digit at a specific position from a number.
     * position=0 → units digit, position=1 → tens digit, etc.
     */
    static int getDigit(int number, int position) {
        return (number / (int) Math.pow(10, position)) % 10;
    }

    /**
     * One pass of stable Counting Sort over a single digit position.
     * This is called once per digit, and it MUST be stable.
     */
    static int[] countingSortByDigit(int[] numbers, int digitPosition) {
        int n         = numbers.length;
        int BASE      = 10;           // decimal digits: 0-9
        int[] count   = new int[BASE];
        int[] output  = new int[n];

        // Count occurrences of each digit at this position
        for (int number : numbers) {
            int digit = getDigit(number, digitPosition);
            count[digit]++;
        }

        // Prefix sum: convert counts to starting output indices
        for (int d = 1; d < BASE; d++) {
            count[d] += count[d - 1];
        }

        // Place elements into output — RIGHT TO LEFT for stability
        for (int i = n - 1; i >= 0; i--) {
            int digit = getDigit(numbers[i], digitPosition);
            output[--count[digit]] = numbers[i];
        }

        return output;
    }

    /**
     * Full LSD (Least Significant Digit) Radix Sort.
     * Works correctly for any non-negative integers.
     */
    static int[] radixSort(int[] numbers) {
        if (numbers.length == 0) return numbers;

        // Find the maximum value to know how many digit passes we need
        int maxValue = numbers[0];
        for (int number : numbers) {
            if (number > maxValue) maxValue = number;
        }

        int[] current = Arrays.copyOf(numbers, numbers.length);

        // Sort from the least significant digit up to the most significant
        for (int digitPosition = 0; maxValue / (int) Math.pow(10, digitPosition) > 0; digitPosition++) {
            System.out.println("  Pass " + (digitPosition + 1)
                + " (digit position " + digitPosition + "): " + Arrays.toString(current));
            current = countingSortByDigit(current, digitPosition);
        }

        return current;
    }

    public static void main(String[] args) {
        // Simulating 7-digit subscriber IDs (like the last 7 digits of a phone number)
        int[] subscriberIds = {
            3706142,
            1234567,
            9999001,
            3706140,  // nearly identical to first entry — tests fine-grained ordering
            5550199,
            1000000,
            2468135
        };

        System.out.println("Unsorted: " + Arrays.toString(subscriberIds));
        System.out.println();
        System.out.println("Radix Sort passes:");

        int[] sortedIds = radixSort(subscriberIds);

        System.out.println();
        System.out.println("Sorted:   " + Arrays.toString(sortedIds));

        // Verify correctness against a comparison sort
        int[] expected = Arrays.copyOf(subscriberIds, subscriberIds.length);
        Arrays.sort(expected);
        System.out.println("Correct:  " + Arrays.equals(sortedIds, expected));
    }
}
▶ Output
Unsorted: [3706142, 1234567, 9999001, 3706140, 5550199, 1000000, 2468135]

Radix Sort passes:
Pass 1 (digit position 0): [3706142, 1234567, 9999001, 3706140, 5550199, 1000000, 2468135]
Pass 2 (digit position 1): [3706140, 3706142, 9999001, 1000000, 2468135, 1234567, 5550199]
Pass 3 (digit position 2): [9999001, 1000000, 3706140, 3706142, 5550199, 1234567, 2468135]
Pass 4 (digit position 3): [1000000, 1234567, 2468135, 3706140, 3706142, 5550199, 9999001]
Pass 5 (digit position 4): [1000000, 1234567, 2468135, 3706140, 3706142, 5550199, 9999001]
Pass 6 (digit position 5): [1000000, 1234567, 2468135, 3706140, 3706142, 5550199, 9999001]
Pass 7 (digit position 6): [1000000, 1234567, 2468135, 3706140, 3706142, 5550199, 9999001]

Sorted: [1000000, 1234567, 2468135, 3706140, 3706142, 5550199, 9999001]
Correct: true
⚠️
Watch Out:Radix Sort only works correctly on non-negative integers out of the box. If your data has negative numbers, you need to either offset all values by the minimum (shift to make everything non-negative), or handle the sign separately. Forgetting this is one of the most common Radix Sort bugs in interviews.

Gotchas, Real-World Use Cases and When NOT to Use These Sorts

Counting Sort shines for tightly bounded integer data: ages (0-120), star ratings (1-5), HTTP status codes (100-599), pixel channel values (0-255). It's used in image processing pipelines where sorting millions of pixel brightness values per frame is a real workload. The moment k (range) grows much larger than n (count), you're wasting memory and time.

Radix Sort is the go-to for fixed-width keys: IP addresses, phone numbers, postal codes, database primary keys within a known range, and sorting strings of equal length lexicographically. Some of the fastest network packet classifiers in the world use Radix Sort variants. It's also used in suffix array construction, a building block for search engines and genome sequencing tools.

Neither algorithm is suitable for: floating-point numbers without special handling, strings of variable length without padding, or any data without a natural numeric mapping. For those, stick to comparison sorts. The rule of thumb: if you can describe your key space as 'integers between X and Y', you have a candidate for linear sorting.

RadixSortIPAddresses.java · JAVA
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
import java.util.Arrays;

/**
 * Real-world example: sorting IPv4 addresses.
 * An IPv4 address is just a 32-bit unsigned integer.
 * We sort them as longs to avoid sign issues.
 * This pattern appears in network routing tables and log analysers.
 */
public class RadixSortIPAddresses {

    // Convert a dotted-decimal IP string to a long (avoids sign-bit issues)
    static long ipToLong(String ipAddress) {
        String[] octets = ipAddress.split("\\.");
        long result = 0;
        for (String octet : octets) {
            result = result * 256 + Long.parseLong(octet);
        }
        return result;
    }

    // Convert a long back to dotted-decimal string
    static String longToIp(long value) {
        return ((value >> 24) & 0xFF) + "."
             + ((value >> 16) & 0xFF) + "."
             + ((value >>  8) & 0xFF) + "."
             +  (value        & 0xFF);
    }

    // Counting Sort by one byte (8-bit digit, base 256) — more efficient than base 10
    static long[] countingSortByByte(long[] addresses, int bytePosition) {
        int n      = addresses.length;
        int BASE   = 256; // one byte at a time: 0-255
        int[] count  = new int[BASE];
        long[] output = new long[n];

        for (long address : addresses) {
            int byteValue = (int)((address >> (bytePosition * 8)) & 0xFF);
            count[byteValue]++;
        }

        for (int b = 1; b < BASE; b++) {
            count[b] += count[b - 1];
        }

        for (int i = n - 1; i >= 0; i--) {
            int byteValue = (int)((addresses[i] >> (bytePosition * 8)) & 0xFF);
            output[--count[byteValue]] = addresses[i];
        }

        return output;
    }

    static long[] radixSortIPs(long[] addresses) {
        long[] current = Arrays.copyOf(addresses, addresses.length);
        // IPv4 has 4 bytes — sort by each byte from least to most significant
        for (int bytePos = 0; bytePos < 4; bytePos++) {
            current = countingSortByByte(current, bytePos);
        }
        return current;
    }

    public static void main(String[] args) {
        String[] ipStrings = {
            "192.168.10.255",
            "10.0.0.1",
            "172.16.254.1",
            "192.168.1.1",
            "10.0.0.2",
            "8.8.8.8",
            "192.168.10.1"
        };

        long[] ipLongs = new long[ipStrings.length];
        for (int i = 0; i < ipStrings.length; i++) {
            ipLongs[i] = ipToLong(ipStrings[i]);
        }

        long[] sortedIps = radixSortIPs(ipLongs);

        System.out.println("Sorted IP addresses:");
        for (long ip : sortedIps) {
            System.out.println("  " + longToIp(ip));
        }
    }
}
▶ Output
Sorted IP addresses:
8.8.8.8
10.0.0.1
10.0.0.2
172.16.254.1
192.168.1.1
192.168.10.1
192.168.10.255
⚠️
Performance Tip:Use base 256 (one byte per pass) instead of base 10 (one decimal digit per pass) when sorting integers. For a 32-bit integer, base 256 gives you 4 passes instead of 10. Each pass is slightly more memory-hungry (256-slot count array vs 10-slot), but the halved pass count wins decisively on large datasets.
Feature / AspectCounting SortRadix Sort
Time ComplexityO(n + k), k = value rangeO(d × (n + k)), d = digits, k = base
Space ComplexityO(n + k)O(n + k) per pass
Stable?Yes (prefix-sum version)Yes (depends on stable sub-sort)
Handles large value ranges?No — k must be small relative to nYes — processes digits, not full values
Handles negative numbers?No — needs offset workaroundNo — needs sign handling
Handles non-integers?NoOnly with numeric mapping
Best real-world useExam scores, pixel values, age groupsPhone numbers, IP addresses, zip codes
Memory usageCan explode if k is largeBounded by n + base per pass
Comparison-free?YesYes
When it beats QuickSortWhen k is O(n) or smallerWhen d is small and n is large

🎯 Key Takeaways

  • Counting Sort is O(n + k) — the k is the trap. It's genius when k is small (pixel values, grades, ages) and disastrous when k dwarfs n (sorting 100 numbers where values reach 10 billion).
  • Radix Sort is just Counting Sort applied repeatedly to one digit at a time. Its correctness depends entirely on the sub-sort being stable — break stability and the whole algorithm silently produces wrong output.
  • LSD Radix Sort using base 256 (byte-by-byte) gives you 4 passes over any 32-bit integer, outperforming base-10 (10 passes) with minimal extra memory — a real micro-optimisation worth knowing.
  • Neither algorithm handles negative numbers natively. The standard fix is to shift the entire dataset by abs(minValue) before sorting and shift back after — a two-line patch that makes them fully general for integer data.

⚠ Common Mistakes to Avoid

  • Mistake 1: Allocating the count array based on max value instead of range — if your data is ages between 18 and 65, allocating count[66] wastes index 0-17. The symptom is wasted memory or off-by-one errors when writing back. Fix: allocate count[maxValue - minValue + 1] and use value - minValue as the index throughout.
  • Mistake 2: Using an unstable sort inside Radix Sort — swapping the stable Counting Sort sub-pass for something like Arrays.sort() on each digit group breaks Radix Sort silently. The output looks almost sorted but has subtle ordering errors. Fix: always use a stable sort for each digit pass; the prefix-sum Counting Sort shown here is specifically designed for this.
  • Mistake 3: Applying Radix Sort to negative integers without handling signs — getDigit() on a negative number in Java returns negative remainders due to how Java's modulus operator works, causing ArrayIndexOutOfBoundsException. Fix: before sorting, find the minimum value; if it's negative, add abs(min) to every element to shift the range to non-negative, sort, then subtract abs(min) back from all results.

Interview Questions on This Topic

  • QWhy is Counting Sort said to break the O(n log n) lower bound, and does that mean it's always faster than QuickSort? Walk me through the exact conditions where Counting Sort wins and where it loses.
  • QIf I asked you to sort 50 million user IDs where each ID is a 9-digit number, would you use Counting Sort, Radix Sort, or a comparison sort? Justify your choice with time and space complexity analysis.
  • QRadix Sort processes digits from least significant to most significant. What breaks if you reverse that order and go most significant to least significant? Can MSD (Most Significant Digit) Radix Sort be made to work, and how does it differ from LSD?

Frequently Asked Questions

Is Counting Sort always faster than QuickSort?

No. Counting Sort is faster when the value range k is comparable to or smaller than n (the number of elements). If you're sorting 1000 numbers that range from 0 to 1,000,000,000, Counting Sort allocates a billion-entry array and becomes both slower and memory-exhausting. QuickSort's O(n log n) wins there. The crossover point is roughly when k exceeds n by a large factor.

Why does Radix Sort sort from the least significant digit first?

Because each pass is stable, sorting least-significant-first means earlier passes are preserved within the groupings of later passes. If you sort most-significant-first you'd need to recursively apply the sort within each group (MSD Radix Sort), which is significantly more complex to implement. LSD is simpler, always iterative, and produces a fully sorted array after exactly d passes.

Can Radix Sort be used on strings?

Yes, if the strings are all the same length. Treat each character as a digit and sort from the last character to the first, using Counting Sort with base 128 (ASCII) or base 26 (letters only). For variable-length strings you either pad them to the same length or use MSD Radix Sort with early termination, which is how many suffix array algorithms work in bioinformatics and search engine indexing.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousHeap SortNext →Sorting Algorithm Comparison
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged