Skip to content

Counting, Radix, and Bucket Sort

Every sort so far — quicksort, heap sort, merge sort — decides order by comparing two elements at a time. There’s a proven floor on how fast that can ever go:

Ω(nlogn)\Omega(n \log n)

is the best any comparison-based sort can guarantee, because sorting must distinguish between n!n! possible orderings, and each comparison only answers a yes/no question — you need log2(n!)=Θ(nlogn)\log_2(n!) = \Theta(n \log n) of them in the worst case. Counting, radix, and bucket sort sidestep this entirely: they never compare two elements to each other. Instead they use the values themselves — as array indices — which unlocks O(n)O(n)-ish time, if the keys are the right shape (bounded integers, or a range you can bucket).

What you’ll learn

  • Counting sort: count occurrences, prefix-sum them into positions, place elements directly — O(n+k)O(n + k), and stable if done carefully.
  • Radix sort (LSD): repeatedly counting-sort by one digit at a time, least-significant first, to sort integers of any size in linear time.
  • Bucket sort: scatter into ranged buckets, sort each bucket small, then concatenate — great for uniformly distributed values.
  • When each one is (and isn’t) the right tool.

Counting sort: count, then place

Counting sort works when every value is a small non-negative integer in a known range 0..k0..k. The idea: count how many times each value appears, turn those counts into prefix sums (so count[v]count[v] becomes “how many elements are v\le v”), and use that number as each element’s landing spot in the output.

counting_sort.py
def counting_sort(arr):
    if not arr:
        return arr
 
    k = max(arr)                # values are 0..k
    count = [0] * (k + 1)
    for x in arr:
        count[x] += 1
 
    for i in range(1, k + 1):
        count[i] += count[i - 1]   # count[v] = number of elements <= v
 
    output = [0] * len(arr)
    for x in reversed(arr):        # reversed = keeps equal elements stable
        count[x] -= 1
        output[count[x]] = x
 
    return output
 
 
arr = [4, 2, 2, 8, 3, 3, 1]
print("input: ", arr)
print("sorted:", counting_sort(arr))
counting_sort.py
def counting_sort(arr):
    if not arr:
        return arr
 
    k = max(arr)                # values are 0..k
    count = [0] * (k + 1)
    for x in arr:
        count[x] += 1
 
    for i in range(1, k + 1):
        count[i] += count[i - 1]   # count[v] = number of elements <= v
 
    output = [0] * len(arr)
    for x in reversed(arr):        # reversed = keeps equal elements stable
        count[x] -= 1
        output[count[x]] = x
 
    return output
 
 
arr = [4, 2, 2, 8, 3, 3, 1]
print("input: ", arr)
print("sorted:", counting_sort(arr))

Three linear passes over the data (count, prefix-sum, place) — no comparisons anywhere. That’s the whole O(n+k)O(n + k): nn for the array, kk for the count array covering the value range.

Watch counting sort place each element

sketch Counting sort: prefix-sum positions, placed in reverse p5.js
Prefix-summed counts give each value its landing index. Scanning the input in reverse and decrementing count[x] before placing keeps equal values in their original order -- this is what makes counting sort stable.

Radix sort: counting sort, one digit at a time

Counting sort’s weakness: if the values span a huge range (say, up to a billion), kk is huge and the count array becomes impractically large. Radix sort fixes that by never counting-sorting on the whole value — only on one digit at a time (base 10, so each digit only needs a count array of size 10), starting from the least significant digit (LSD) and working toward the most significant.

radix_sort.py
def counting_sort_by_digit(arr, exp):
    """Stable counting sort keyed on the digit at place value `exp`
    (exp = 1 for ones, 10 for tens, 100 for hundreds, ...)."""
    n = len(arr)
    output = [0] * n
    count = [0] * 10
 
    for x in arr:
        digit = (x // exp) % 10
        count[digit] += 1
 
    for i in range(1, 10):
        count[i] += count[i - 1]
 
    for x in reversed(arr):        # reversed again -- stability matters here too
        digit = (x // exp) % 10
        count[digit] -= 1
        output[count[digit]] = x
 
    return output
 
 
def radix_sort(arr):
    if not arr:
        return arr
    max_val = max(arr)
    exp = 1
    while max_val // exp > 0:
        arr = counting_sort_by_digit(arr, exp)
        exp *= 10
    return arr
 
 
nums = [170, 45, 75, 90, 802, 24, 2, 66]
print("sorted:", radix_sort(nums))
radix_sort.py
def counting_sort_by_digit(arr, exp):
    """Stable counting sort keyed on the digit at place value `exp`
    (exp = 1 for ones, 10 for tens, 100 for hundreds, ...)."""
    n = len(arr)
    output = [0] * n
    count = [0] * 10
 
    for x in arr:
        digit = (x // exp) % 10
        count[digit] += 1
 
    for i in range(1, 10):
        count[i] += count[i - 1]
 
    for x in reversed(arr):        # reversed again -- stability matters here too
        digit = (x // exp) % 10
        count[digit] -= 1
        output[count[digit]] = x
 
    return output
 
 
def radix_sort(arr):
    if not arr:
        return arr
    max_val = max(arr)
    exp = 1
    while max_val // exp > 0:
        arr = counting_sort_by_digit(arr, exp)
        exp *= 10
    return arr
 
 
nums = [170, 45, 75, 90, 802, 24, 2, 66]
print("sorted:", radix_sort(nums))

For dd digits and nn numbers, that’s dd passes of an O(n+10)O(n + 10) counting sort each — O(dn)O(d \cdot n) total, which is linear in nn for any fixed number of digits (i.e., fixed-width integers, like 32-bit or 64-bit).

Bucket sort: scatter, sort small, concatenate

Bucket sort targets values uniformly spread across a known range (the classic case: floats in [0, 1)[0, 1)). Scatter each value into one of kk equal-width buckets by its magnitude, sort each (small) bucket with whatever comparison sort you like, then concatenate the buckets in order.

bucket_sort.py
def bucket_sort(arr, bucket_count=10):
    if not arr:
        return arr
 
    buckets = [[] for _ in range(bucket_count)]
    for x in arr:
        idx = int(x * bucket_count)
        idx = min(idx, bucket_count - 1)   # guard x == 1.0 landing out of range
        buckets[idx].append(x)
 
    for b in buckets:
        b.sort()   # each bucket is small -- even O(n^2) insertion sort is fine here
 
    result = []
    for b in buckets:
        result.extend(b)
    return result
 
 
data = [0.78, 0.17, 0.39, 0.26, 0.72, 0.94, 0.21, 0.12, 0.23, 0.68]
print("sorted:", bucket_sort(data))
bucket_sort.py
def bucket_sort(arr, bucket_count=10):
    if not arr:
        return arr
 
    buckets = [[] for _ in range(bucket_count)]
    for x in arr:
        idx = int(x * bucket_count)
        idx = min(idx, bucket_count - 1)   # guard x == 1.0 landing out of range
        buckets[idx].append(x)
 
    for b in buckets:
        b.sort()   # each bucket is small -- even O(n^2) insertion sort is fine here
 
    result = []
    for b in buckets:
        result.extend(b)
    return result
 
 
data = [0.78, 0.17, 0.39, 0.26, 0.72, 0.94, 0.21, 0.12, 0.23, 0.68]
print("sorted:", bucket_sort(data))

If the input really is uniformly distributed, every bucket ends up with roughly n / kn / k elements, so sorting all buckets costs O(n)O(n) total on average — the O(nlogn)O(n \log n) (or worse) inner sorts are each working on a tiny slice. If the data is instead heavily clustered into one bucket, you’re back to sorting almost the whole array with one comparison sort, and the worst case becomes O(n2)O(n^2).

When these are (and aren’t) the right tool

  • Counting sort: values are small non-negative integers with a known, small range kk (e.g., ages, grades, byte values 0-255). Bad idea if kk is huge relative to nn — you’d allocate a giant, mostly-empty array.
  • Radix sort: fixed-width integers (or strings treated digit-by-digit) of any magnitude — the digit count dd stays small (10 for a billion) even when the values themselves are huge.
  • Bucket sort: values you know are roughly uniformly distributed over a range — floats in [0, 1)[0, 1), timestamps spread across a day, etc. Clustered/skewed data defeats it.
  • None of these three handle arbitrary comparable objects (strings by dictionary order, custom objects) as directly as sorted()sorted() does — that’s what the next lesson, Timsort and custom sorting, covers.

Complexity at a glance

SortTimeSpaceStable?Needs
Counting sortO(n+k)O(n + k)O(n+k)O(n + k)Yes (with reverse-scan placement)small integer range kk
Radix sort (LSD)O(d(n+b))O(d(n + b))O(n+b)O(n + b)Yesfixed-width integers, dd digits, base bb
Bucket sortO(n+k)O(n + k) avg, O(n2)O(n^2) worstO(n+k)O(n + k)Depends on the inner sortroughly uniform distribution

kk = value range (counting sort) or bucket count (bucket sort); dd = number of digits; bb = digit base (10 for decimal digits).

Practice — real LeetCode problems

Each exercise is the actual LeetCode problem with its real method signature and LeetCode’s own examples as the test. Write the body, press Run, and match the expected output.

LC 1122 — Relative Sort Array · Easy

Problem. Sort arr1arr1 so that its elements follow the relative order given by arr2arr2. Elements not present in arr2arr2 go at the end, in ascending order.

Constraints. 1 <= len(arr1), len(arr2) <= 10001 <= len(arr1), len(arr2) <= 1000, 0 <= values <= 10000 <= values <= 1000, arr2arr2 has distinct values, all of which appear in arr1arr1.

Examples. arr1 = [2,3,1,3,2,4,6,7,9,2,19], arr2 = [2,1,4,3,9,6]arr1 = [2,3,1,3,2,4,6,7,9,2,19], arr2 = [2,1,4,3,9,6] gives [2,2,2,1,4,3,3,9,6,7,19][2,2,2,1,4,3,3,9,6,7,19]

Editorial

Converting “this arbitrary order” into a numeric rank turns the problem into a standard sort. The tuple key then layers the fallback rule on top.

Time O(nlogn)O(n \log n). Space O(n)O(n).

rank.get(v, len(arr2))rank.get(v, len(arr2)) is doing two jobs: it supplies the rank for known values, and it assigns unknown values a rank strictly larger than every known one, so they sort last. The second tuple component then orders them ascending among themselves — which is exactly what the problem asks.

Because values are bounded by 1000, a counting sort is the O(n+1000)O(n + 1000) alternative and the direct connection to this page: tally arr1arr1, then emit values in arr2arr2’s order followed by the remaining values in ascending index order. That avoids comparison sorting entirely.

[28,6,22,8,44,17][28,6,22,8,44,17] shows the fallback: 1717 and 4444 are unranked and appear at the end in ascending order.

Follow-ups: “Do it with counting sort?” — the bounded-range version above; the expected answer on this page. “What if arr2arr2 had values absent from arr1arr1?” — the rank map is unaffected. “Descending fallback?” — negate the second key component.

LC 274 — H-Index · Medium

Problem. Given citation counts for a researcher’s papers, return their h-index: the largest hh such that at least hh papers have at least hh citations each.

Constraints. 1 <= len(citations) <= 50001 <= len(citations) <= 5000, 0 <= citations[i] <= 10000 <= citations[i] <= 1000.

Examples. [3,0,6,1,5][3,0,6,1,5] gives 33 · [1,3,1][1,3,1] gives 11 · [100][100] gives 11

Editorial

The key observation is that the h-index cannot exceed the number of papers, so any citation count above nn is indistinguishable from nn for this purpose. That caps the value range at n + 1n + 1, which is exactly the condition counting sort needs.

Time O(n)O(n). Space O(n)O(n).

Walking hh downward and accumulating gives “how many papers have at least hh citations” without re-scanning: each step adds the papers with exactly hh. The first hh where the running total reaches hh is the answer, and because we come down from the top it is the largest such hh.

[100][100] giving 11 is the capping case: one paper with 100 citations still yields an h-index of only 1, since there is only one paper. [0][0] and [0,0][0,0] give 00.

The O(nlogn)O(n \log n) answer — sort descending and find the last index where citations[i] >= i + 1citations[i] >= i + 1 — is simpler and worth stating first. The counting version is the improvement this page exists to teach.

Follow-ups: “Input already sorted (LC 275)?” — binary search for the crossover in O(logn)O(\log n). “Why cap at nn?” — the bound argument; this is the expected question. “What is the g-index?” — a related metric using cumulative citations, solvable the same way.

LC 164 — Maximum Gap · Medium

Problem. Return the maximum difference between two successive elements in the array’s sorted form. If the array has fewer than two elements, return 00. Aim for linear time and space.

Constraints. 1 <= len(nums) <= 10^51 <= len(nums) <= 10^5, 0 <= nums[i] <= 10^90 <= nums[i] <= 10^9.

Examples. [3,6,9,1][3,6,9,1] gives 33 · [10][10] gives 00

Editorial

Sorting and scanning adjacent pairs is O(nlogn)O(n \log n) and accepted. Start there.

Time O(nlogn)O(n \log n) as written. Space O(1)O(1) beyond the sort.

The problem asks for linear time, and the route is a pigeonhole / bucket argument worth understanding:

With nn values spanning max - minmax - min, the average gap is (max - min) / (n - 1)(max - min) / (n - 1). The maximum gap must be at least the average, so if you create buckets of exactly that width, no two values inside a single bucket can be the maximum gap apart — meaning the answer always spans a boundary between buckets.

So you only need each bucket’s minimum and maximum, then take the largest difference between one bucket’s max and the next non-empty bucket’s min. That is O(n)O(n) time and O(n)O(n) space, with no sorting.

Radix sort is the other linear option: values are bounded by 10910^9, so a handful of digit passes sorts them in O(n)O(n).

[1,1,1][1,1,1] giving 00 (all equal) and [10][10] giving 00 (single element) are the degenerate cases the guard exists for.

Follow-uups: “Achieve O(n)O(n)?” — the bucket argument above, or radix sort; this is the expected follow-up. “Why is the max gap at least the average?” — if every gap were below average their total would be below max - minmax - min, a contradiction. “Negative values?” — the constraints exclude them, but the argument is unchanged.

Recap

  • Comparison sorts are stuck at Ω(nlogn)\Omega(n \log n) — a proven lower bound. Counting, radix, and bucket sort escape it by using key values directly as positions, never comparing elements to each other.
  • Counting sort (O(n+k)O(n + k)): count, prefix-sum into positions, place in reverse for stability. Needs a small integer range kk.
  • Radix sort (O(d(n+b))O(d(n+b))): repeated stable counting sort, one digit at a time, LSD first — correctness depends on every pass being stable.
  • Bucket sort (O(n+k)O(n+k) average): scatter into ranged buckets, sort each small bucket, concatenate — shines on uniformly distributed data, degrades to O(n2)O(n^2) if the data clusters into one bucket.

Next: Timsort and Custom Sorting — how Python’s built-in sorted()sorted() and .sort().sort() actually work, and how to sort arbitrary objects with key=key= and custom comparators.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did