Counting Sort and Radix Sort Explained — How to Sort in Linear Time
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.
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)); } }
Comparison-based (Arrays.sort): 87 ms
Counting Sort: 11 ms
Results match: true
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.
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; } }
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
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).
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)); } }
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
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.
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)); } } }
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
| Feature / Aspect | Counting Sort | Radix Sort |
|---|---|---|
| Time Complexity | O(n + k), k = value range | O(d × (n + k)), d = digits, k = base |
| Space Complexity | O(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 n | Yes — processes digits, not full values |
| Handles negative numbers? | No — needs offset workaround | No — needs sign handling |
| Handles non-integers? | No | Only with numeric mapping |
| Best real-world use | Exam scores, pixel values, age groups | Phone numbers, IP addresses, zip codes |
| Memory usage | Can explode if k is large | Bounded by n + base per pass |
| Comparison-free? | Yes | Yes |
| When it beats QuickSort | When k is O(n) or smaller | When 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.
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.