Counting Sort & Radix Sort — Negative Number Pitfalls
Java's modulus returns negative remainders, crashing Radix Sort on negative inputs.
20+ years shipping performance-critical code where algorithms decide the bill. Drawn from code that ran under real load.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Counting Sort and Radix Sort break O(n log n) by avoiding comparisons entirely.
- Counting Sort works for small integer ranges (k << n), placing each element directly.
- Radix Sort processes digits one at a time, using stable Counting Sort on each digit.
- Base 256 Radix Sort sorts 32-bit integers in 4 passes, outperforming base 10's 10 passes.
- Production risk: Counting Sort's memory blows up if value range is huge; Radix Sort fails silently with negative numbers.
Counting sort and radix sort are non-comparison sorting algorithms that achieve linear time complexity (O(n+k) and O(d*(n+k)), respectively) by exploiting the structure of the data rather than pairwise comparisons. Counting sort works by tallying the frequency of each distinct key value, then computing prefix sums to determine the final positions of elements — a stable, in-place variant that avoids the naive bucket approach.
Radix sort extends this by sorting numbers digit by digit (least significant digit first, typically), using counting sort as its stable subroutine for each digit pass. These algorithms exist because comparison-based sorts are provably bounded by Ω(n log n) comparisons; when your keys are integers or fixed-length strings within a known range, you can bypass that limit entirely.
In practice, counting sort shines when the range of possible values (k) is small relative to the number of items (n) — think sorting 10 million ages (range 0-120) in O(n) time. Radix sort handles larger ranges by breaking them into digits; for 32-bit integers, 4 passes of counting sort on 8-bit digits gives O(4n) time, often beating quicksort on real data.
However, these algorithms fail catastrophically with negative numbers unless you offset them into non-negative space (e.g., add |min| to all values) or handle sign bits separately — a common pitfall that silently produces wrong results. They also consume O(k) memory, which becomes prohibitive when k dwarfs n (e.g., sorting 1000 64-bit floats).
Alternatives include comparison sorts like introsort (used in C++ std::sort) or timsort (Python, Java) for general-purpose needs, and bucket sort for uniformly distributed floats. You should avoid counting/radix sorts when keys are sparse, floating-point (without careful bit-level manipulation), or when memory is constrained — a 64-bit radix sort with 256 buckets per digit requires 2048 counters plus the output array.
Real-world usage includes GPU-based sorting (CUB library), database index construction, and string sorting (MSD radix sort). The key insight: these sorts trade generality for speed, and the negative number gotcha is the most common reason production code silently corrupts data.
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.
Counting Sort & Radix Sort — The Non-Comparison Sorting Duo
Counting sort and radix sort are integer sorting algorithms that bypass the Ω(n log n) lower bound of comparison-based sorts by exploiting key structure. Counting sort works by tallying occurrences of each distinct key value, then computing prefix sums to determine final positions — it runs in O(n + k) time and O(k) space, where k is the range of input values. Radix sort extends this by sorting numbers digit by digit, typically using counting sort as its stable subroutine, achieving O(d * (n + b)) where d is digit count and b is the base.
In practice, counting sort is only efficient when k is not significantly larger than n — for example, sorting 1 million 32-bit integers (k = 2^32) would require 4 GB of auxiliary memory, making it impractical. Radix sort avoids this by processing digits, but its performance depends on digit width and base choice; a base of 256 (byte-wise) is common because it aligns with memory hierarchy and reduces passes. Both algorithms are stable, which is critical when sorting by multiple keys.
Use counting sort when the key range is small and known — e.g., sorting ages in a census (0–120) or grades (A–F). Use radix sort for fixed-width integers or strings of equal length, like sorting IP addresses or 64-bit timestamps in a log processing pipeline. These algorithms shine in systems where predictable, linear-time performance matters more than in-place memory — think database sort operators or network packet classification.
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) {\n int[] frequency = new int[maxValue + 1]; // bucket for every possible value\n\n // Pass 1: count how many times each value appears\n for (int value : data) {\n frequency[value]++;\n } // 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)); } }
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) {\n this.name = name;\n this.grade = grade;\n } @Override public String toString() { return name + "(" + grade + ")"; } } static Student[] stableCountingSort(Student[] students, int maxGrade) {\n int n = students.length;\n int[] frequency = new int[maxGrade + 1];\n\n // Pass 1: count how many students have each grade\n for (Student student : students) {\n frequency[student.grade]++;\n } // 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; } }
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) {\n int n = numbers.length;\n int BASE = 10; // decimal digits: 0-9\n int[] count = new int[BASE];\n int[] output = new int[n];\n\n // Count occurrences of each digit at this position\n for (int number : numbers) {\n int digit = getDigit(number, digitPosition);\n count[digit]++;\n } // 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++) {\n System.out.println(\"Pass \" + (digitPosition + 1)\n + \" (digit position \" + digitPosition + \"): \" + Arrays.toString(current));\n current = countingSortByDigit(current, digitPosition);\n }\n\n return current;\n }\n\n public static void main(String[] args) {\n // Simulating 7-digit subscriber IDs (like the last 7 digits of a phone number)\n int[] subscriberIds = {\n 3706142,\n 1234567,\n 9999001,\n 3706140, // nearly identical to first entry — tests fine-grained ordering\n 5550199,\n 1000000,\n 2468135\n };\n\n System.out.println(\"Unsorted: \" + Arrays.toString(subscriberIds));\n System.out.println();\n System.out.println(\"Radix Sort passes:\");\n\n int[] sortedIds = radixSort(subscriberIds);\n\n System.out.println();\n System.out.println(\"Sorted: \" + Arrays.toString(sortedIds));\n\n // Verify correctness against a comparison sort\n int[] expected = Arrays.copyOf(subscriberIds, subscriberIds.length);\n Arrays.sort(expected);\n System.out.println(\"Correct: \" + Arrays.equals(sortedIds, expected));\n }\n}", "output": "Unsorted: [3706142, 1234567, 9999001, 3706140, 5550199, 1000000, 2468135]\n\nRadix Sort passes:\n Pass 1 (digit position 0): [3706142, 1234567, 9999001, 3706140, 5550199, 1000000, 2468135]\n Pass 2 (digit position 1): [3706140, 3706142, 9999001, 1000000, 2468135, 1234567, 5550199]\n Pass 3 (digit position 2): [9999001, 1000000, 3706140, 3706142, 5550199, 1234567, 2468135]\n Pass 4 (digit position 3): [1000000, 1234567, 2468135, 3706140, 3706142, 5550199, 9999001]\n Pass 5 (digit position 4): [1000000, 1234567, 2468135, 3706140, 3706142, 5550199, 9999001]\n Pass 6 (digit position 5): [1000000, 1234567, 2468135, 3706140, 3706142, 5550199, 9999001]\n Pass 7 (digit position 6): [1000000, 1234567, 2468135, 3706140, 3706142, 5550199, 9999001]\n\nSorted: [1000000, 1234567, 2468135, 3706140, 3706142, 5550199, 9999001]\nCorrect: true" }
Choosing Between Counting Sort, Radix Sort, and Comparison Sorts
You now know three families of sorts. When should you use each? The decision depends on two dimensions: the size of the data (n) and the range of values (k or number of digits d).
Counting Sort wins when k is small — roughly k <= n and k <= 10^6 for practical memory. For exam scores (0-100), it's unbeatable. Radix Sort wins when k is large but the keys are fixed-width (e.g., 32-bit integers, IPv4 addresses). Comparison sorts win when you need generic sorting of arbitrary objects, floats, or when k is unbounded.
- Can you map your data to integers in a known range? -> Non-comparison candidate.
- Is k small (<= n)? Use Counting Sort.
- Is k large but keys have fixed width? Use Radix Sort (base 256 for performance).
- Otherwise, use a comparison sort (QuickSort, MergeSort, or Java's
Arrays.sort()).
Memory also matters: Counting Sort uses O(n+k) and can blow up. Radix Sort uses O(n+b) per pass where b is base (256). Comparison sorts typically use O(n) or O(log n) extra space.
public class SortDecisionExample { // Example: sorting 10 million 32-bit integers // k = 2^32, far too large for Counting Sort. // d = 32 bits -> base 256 gives 4 passes. // Radix Sort is ideal. // Example: sorting 1000 test scores (0-100) // n=1000, k=100. Counting Sort uses 101-slot array. // Radix Sort would require 3 decimal passes or 1 byte pass. // Counting Sort wins because k < n. // Example: sorting 1000 strings of varying length // No integer mapping -> use MergeSort. }
- Counting Sort budget: k slots. If k = 1B, that's 4 GB just for the count array.
- Radix Sort budget: base slots (256 for byte) per pass, plus n for output.
- Comparison sort budget: O(n) auxiliary space for MergeSort, O(log n) for QuickSort.
- Rule of thumb: if k > 10*n, Radix Sort or comparison sort is better.
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) {\n int n = addresses.length;\n int BASE = 256; // one byte at a time: 0-255\n int[] count = new int[BASE];\n long[] output = new long[n];\n\n for (long address : addresses) {\n int byteValue = (int)((address >> (bytePosition * 8)) & 0xFF);\n count[byteValue]++;\n } 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)); } } }
C++ and Python Implementations: Language-Specific Idioms and Performance
While Java's verbosity makes the algorithm explicit, C++ and Python offer different trade-offs. C++ allows in-place arrays and high-performance bit manipulation, while Python's list comprehensions and built-in sorting can make implementations concise but slower due to interpreter overhead.
Below are stable Counting Sort and LSD Radix Sort implementations in both languages. Notice how C++ uses std::vector and manual loops, while Python leverages list operations and the // integer division operator. The core logic (prefix-sum, right-to-left placement) remains identical across languages.
# Counting Sort (stable prefix-sum version) — Python def counting_sort(arr, max_val): n = len(arr) count = [0] * (max_val + 1) output = [0] * n for x in arr: count[x] += 1 for i in range(1, max_val + 1): count[i] += count[i-1] for i in range(n-1, -1, -1): output[count[arr[i]] - 1] = arr[i] count[arr[i]] -= 1 return output # Radix Sort (LSD, base 10) — Python def radix_sort(arr): max_val = max(arr) exp = 1 while max_val // exp > 0: count = [0] * 10 output = [0] * len(arr) for x in arr: count[(x // exp) % 10] += 1 for i in range(1, 10): count[i] += count[i-1] for i in range(len(arr)-1, -1, -1): digit = (arr[i] // exp) % 10 output[count[digit] - 1] = arr[i] count[digit] -= 1 arr = output exp *= 10 return arr
np.sort() or C-extensions. The Python implementation above is clean but not optimised — expect 10-100x slowdown compared to C++ or Java for arrays over 100k elements.Digit-by-Digit Walkthrough: Radix Sort on [329, 457, 657, 839, 436, 720, 355]
Let's trace Radix Sort on a small array of three-digit numbers. The algorithm processes units, then tens, then hundreds.
Initial array: [329, 457, 657, 839, 436, 720, 355]
Pass 1 (units digit): - Group by last digit: 9→[329,839], 7→[457,657], 6→[436], 0→[720], 5→[355]. - After stable counting sort: [720, 329, 839, 436, 355, 457, 657] (units sorted ascending).
Pass 2 (tens digit): - Group by middle digit: 2→[720, 329, 839], 3→[436, 355], 5→[457, 657]. - After stable sort: [720, 329, 839, 436, 355, 457, 657] (tens sorted; notice 720 and 329 both have tens=2 but original order preserved).
Pass 3 (hundreds digit): - Group by first digit: 7→[720], 3→[329, 355], 8→[839], 4→[436, 457], 6→[657]. - After stable sort: [329, 355, 436, 457, 657, 720, 839].
The array is now fully sorted. Each pass refines the order without ever comparing two numbers directly.
DEBUG flag that logs the array after each pass. It helps catch stability bugs early (if equal digits are reordered, the final sort will be corrupt).Advantages and Disadvantages of Counting Sort and Radix Sort
Both algorithms trade generality for speed. Here's a succinct comparison:
| Algorithm | Advantages | Disadvantages |
|---|---|---|
| Counting Sort | – O(n + k) time (linear when k small) | |
| Radix Sort | – O(d(n + b)) time (linear for fixed-width) |
Both are unsuitable for strings of varying length or floating-point data without transformation.
LSD vs MSD Radix Sort: When to Use Each Variant
LSD (Least Significant Digit) Radix Sort processes digits from rightmost to leftmost. It is the simpler, iterative approach: exactly d passes for d-digit numbers. It works only for fixed-width keys and relies on the sub-sort being stable. Because it never creates recursive branches, it's cache-friendly and easy to parallelise per pass.
MSD (Most Significant Digit) Radix Sort sorts by the most significant digit first, then recursively sorts each bucket. It can handle variable-length keys (e.g., strings) because you can stop recursion when a bucket has size 1. However, recursion overhead is heavy, and it is generally slower for fixed-width numeric keys. MSD is often used in suffix array construction and string sorting where keys have different lengths.
Rule of thumb: Use LSD for fixed-width integers; use MSD for variable-length strings or when early termination can prune large subproblems.
Real-World Applications: Suffix Arrays, String Sorting, Packet Routing
Radix Sort appears in three critical domains:
- Suffix arrays – Genome sequencing and text indexing construct suffix arrays by sorting all suffixes of a string. Since suffixes are strings of equal length (up to n), LSD Radix Sort on the last character, then second-last, etc., builds the suffix array in O(n log n) but often faster in practice due to cache efficiency. This is the basis of tools like
samtoolsandBowtie. - String sorting – When sorting fixed-length strings (e.g., licence plate numbers, telephone area codes), Radix Sort outperforms comparison sorts by a wide margin. Some databases use it internally for columnar storage indexes.
- Packet routing – Network routers classify packets by IP prefix (32-bit or 128-bit). Radix Sort on the binary representation allows O(n) sorting of routing table entries, enabling fast longest-prefix-match lookups. Cisco's IOS has used radix-like algorithms for decades.
These applications exploit the same core idea: transform the problem into sorting integers, then let Radix Sort's speed do the heavy lifting.
Complexity Analysis — Why Radix Sort Beats O(n log n) but Comes With Strings Attached
Radix sort's time complexity isn't just one number — it depends on the number of digits in your keys. For an array of n integers, each with d digits, and a base k (typically 10), the complexity is O(d * (n + k)). Counting sort runs in O(n + k) per digit pass, and we run d passes.
Here's the thing: if d is small and fixed (like 32-bit integers, d = 10 in base 10), then Radix sort is effectively O(n). That's why it wrecks comparison sorts in benchmarks on large integer datasets. But the catch is space: counting sort allocates an auxiliary array of size k for each pass, so total space is O(n + k). For base 10, that's trivial. If you naively use base 256 to reduce passes, your aux array grows to 256 — still fine. But if your keys are 64-bit floats or variable-length strings, d can blow up, and suddenly those n log n comparison sorts start looking good again.
Best case: O(n) with fixed-width keys. Worst case: O(n * d) where d grows — like sorting UUIDs as strings. Then you're paying for the digits, not the array size.
// io.thecodeforge — dsa tutorial public class RadixTimeAnalysis { public static void main(String[] args) { int n = 1_000_000; int digits = 10; // 32-bit integer in base 10 int base = 10; double operations = (double) n * digits * (n + base) / n; System.out.println("Approx ops per element: " + operations); // vs comparison sort ~ n log2 n ≈ 20 ops per el System.out.println("Comparison sort O(n log n): ~" + (int)(Math.log(n)/Math.log(2)) + " ops/el"); System.out.println("Radix wins for fixed-width ints."); } }
Pseudocode — The Algorithm Without the Language-Specific Cruft
Before you write a single line of C++ or Java, you need the skeleton. Radix sort is deceptively simple: it's just Counting Sort wrapped in a loop over digits. The base determines how many buckets Counting Sort uses. LSD Radix sort iterates from least significant digit to most — that's the stable version that works on integers.
Here's the pseudocode that survives code reviews: find max value to determine number of digits, then for each digit position (starting from 1s place), call Counting Sort on that digit. Counting Sort must be stable — meaning it preserves the relative order of elements with equal digits. That stability is what makes Radix sort work: each pass builds on the previous one.
The loop invariant? After sorting by the i-th least significant digit, the array is sorted by those i digits. Think of it like sorting dates: first by day, then month, then year — each pass refines the order.
// io.thecodeforge — dsa tutorial // LSD Radix Sort Pseudocode (Base 10) function radixSort(int[] arr): int max = findMax(arr) int digits = countDigits(max) for int exp = 1; max / exp > 0; exp *= 10: countingSortByDigit(arr, exp) function countingSortByDigit(int[] arr, int exp): int[] output = new int[arr.length] int[] count = new int[10] // 0-9 digits for int val : arr: count[(val / exp) % 10]++ // prefix sum for stability for i = 1 to 9: count[i] += count[i - 1] for i = arr.length - 1 down to 0: int digit = (arr[i] / exp) % 10 output[count[digit] - 1] = arr[i] count[digit]-- copy output back to arr
Radix Sort Breaks on Negative Numbers in Production Log Analyzer
getDigit(-10, 0) returns 0 instead of 0, but the issue is that the digit extraction algorithm (number / 10^k) % 10 on negative numbers can produce negative digits, which index into the count array with base 10 causing out-of-bounds.abs(min) before sorting and subtract back after sorting. This ensures all values are non-negative during Radix Sort passes.- Non-comparison sorts assume input domain is non-negative integers. Always validate or normalise input.
- Add a precondition check:
if (any value < 0) throw new IllegalArgumentExceptionor apply the shift transform. - Unit tests must include negative numbers and edge cases like Integer.MIN_VALUE.
Arrays.parallelSort() if comparison sort is acceptable.| Feature / Aspect | Counting Sort | Radix Sort | Comparison Sort (QuickSort) |
|---|---|---|---|
| Time Complexity | O(n + k) | O(d * (n + k)) | O(n log n) average |
| Space Complexity | O(n + k) | O(n + k) per pass | O(log n) in-place |
| Stable? | Yes (prefix-sum version) | Yes (depends on stable sub-sort) | No (typical) |
| Handles large value ranges? | No — k must be small | Yes — processes digits | Yes — no range limit |
| Handles negative numbers? | No — needs offset | No — needs offset | Yes |
| Handles non-integers? | No | Only with numeric mapping | Yes |
| Best real-world use | Exam scores, pixel values | Phone numbers, IP addresses | Generic data, strings |
| When it wins | k <= n | Fixed-width keys, large n | Unbounded or complex keys |
| File | Command / Code | Purpose |
|---|---|---|
| WhyLinearSortingMatters.java | public class WhyLinearSortingMatters { | Why Comparison-Based Sorts Hit a Wall at O(n log n) |
| StableCountingSort.java | public class StableCountingSort { | Counting Sort Deep Dive |
| RadixSortPhoneNumbers.java | public class RadixSortPhoneNumbers { | Radix Sort |
| SortDecisionExample.java | public class SortDecisionExample { | Choosing Between Counting Sort, Radix Sort, and Comparison S |
| RadixSortIPAddresses.java | /** | Gotchas, Real-World Use Cases and When NOT to Use These Sort |
| counting_radix_py.py | def counting_sort(arr, max_val): | C++ and Python Implementations |
| RadixTimeAnalysis.java | public class RadixTimeAnalysis { | Complexity Analysis |
| RadixPseudo.java | function radixSort(int[] arr): | Pseudocode |
Key takeaways
Common mistakes to avoid
4 patternsAllocating count array based on max value instead of range
Using an unstable sort inside Radix Sort
Applying Radix Sort to negative integers without handling signs
Assuming Counting Sort is always faster than QuickSort
Practice These on LeetCode
Interview Questions on This Topic
Why 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.
If 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.
Radix 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?
How would you implement Radix Sort for a set of IPv4 addresses stored as strings in dotted-decimal format? What base would you use and how many passes?
Frequently Asked Questions
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.
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.
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.
Standard Radix Sort is not in-place. It requires O(n) extra space for the output array per pass (though you can reuse two arrays). In-place Radix Sort exists but is significantly slower. The typical space complexity is O(n + base) per pass, where base is typically 256. If you reuse the same two arrays, total extra space is O(n) plus O(base).
The stable version with prefix sums handles duplicates naturally. Each duplicate is placed in a separate slot determined by its original order (right-to-left traversal ensures stability). The count array records frequency, and the prefix sum gives each value a contiguous block of output positions. Duplicates fill this block in reverse input order, preserving relative order.
20+ years shipping performance-critical code where algorithms decide the bill. Drawn from code that ran under real load.
That's Sorting. Mark it forged?
8 min read · try the examples if you haven't