Skip to main content

Counting Sort, Bucket Sort, and Radix Sort Use Cases and Implementation

Comprehensive guide to when and how to apply counting sort, bucket sort, and radix sort algorithms with practical Java implementations.

Counting sort, bucket sort, and radix sort are specialized sorting techniques that outperform general-purpose algorithms when data has specific characteristics. These algorithms leverage domain knowledge about input distributions to achieve linear time complexity, making them ideal for particular use cases where traditional comparison-based sorting falls short.

1. Pattern Restatement

Clarifying questions to ask the interviewer:

  1. What is the expected range of input values? Are we dealing with integers in a known bounded range?
  2. Can the input array be mutated, or do we need to preserve the original order?
  3. What is the size constraint on the input? Will it fit comfortably in memory?
  4. Are there any duplicate values, and should they be handled specially?
  5. What is the target time complexity for follow-up optimizations?

Restatement of the pattern: This pattern involves solving problems where we need to count occurrences of elements or categorize items into buckets based on their values, typically leveraging the fact that we have a limited range of possible values. The approach uses counting or bucketing techniques to efficiently organize and process data.

Signal words and constraint shape:

  • "Range of values is limited" or "bounded integer values"
  • "Count occurrences" or "frequency analysis"
  • "Sort by value range" or "group by value"
  • "Fixed-size input domain" (e.g., 0 to k where k is small)
  • Problems that can be solved with O(n + k) time complexity where k is the range size

Assumptions:

  • The input domain has a reasonable upper bound (k) that makes counting feasible
  • We can afford to use additional space proportional to the range size
  • The problem structure allows for bucketing or counting operations without losing necessary information

Scenario fit: This pattern is best suited for static scenarios where the full input set is available upfront. It's less appropriate for streaming data or when the range of values is unbounded or extremely large, as it would require excessive space or time complexity.

The pattern leverages the principle that when we have a limited value range, we can avoid comparison-based sorting and instead use direct indexing into buckets or counters, achieving linear time complexity.

2. When to Use This Pattern

Signal words and constraint shapes in a problem statement that indicate this pattern:

  • "Count the occurrences of elements" or "group by frequency"
  • "Sort by specific criteria" where values are in a known limited range
  • "Find elements that appear K times" or similar frequency-based queries
  • "Rearrange array elements" with specific constraints on value ranges
  • "Maximum/minimum element in a bounded domain"

Why this pattern fits better than the most common alternatives: Counting/bucket/radix sort excels when elements are constrained to a limited range [0, k] where k is small relative to n. Unlike general-purpose sorting (O(n log n)), these approaches achieve O(n + k) time complexity, making them significantly faster for bounded integer inputs. They're particularly advantageous over hash maps when the range is known and small, as they eliminate hashing overhead and provide deterministic ordering.

Conditions where a variation or related pattern applies instead:

  • When the range is large (k >> n), general sorting becomes preferable
  • For floating-point numbers, radix sort requires special handling or conversion to integer representation
  • When elements are not integers or don't fit into a bounded domain, counting sort is inapplicable
  • For streaming data where range isn't known beforehand, bucket sort with dynamic allocation is more suitable

The core insight that makes this pattern work: The key insight is that when element values are bounded, we can use their actual values as indices into a counting array. This allows us to process elements in O(1) time per element, achieving linear overall complexity. The approach leverages the mathematical property that we can directly map discrete values to array indices, eliminating the need for comparisons or complex data structure operations.

This pattern is most effective in scenarios where:

  1. Input elements are integers within a known, small range
  2. The problem requires frequency counting or ordered grouping
  3. Performance is critical and the bounded nature allows for optimization

3. Approach Evolution

Interview communication script:

My first instinct is to use a brute force approach by counting occurrences of each element, which would be O(n²) in the worst case if I'm comparing every element with every other element. But that's not what we want here - we're looking for a more efficient way to count elements.

Let me think about how to optimize this. I notice that if we're counting elements in a bounded range, we can leverage the fact that we know the possible values ahead of time. This suggests we could use a counting sort approach or bucket sort, which would be O(n + k) where k is the range of values.

I notice that if I maintain a frequency array or hash map, I can reduce it to O(n) time complexity by making one pass through the input and another through the counting structure. The key insight is that when we have a bounded integer range, we can avoid comparisons entirely and instead use direct indexing.

The core optimization here is that instead of sorting or doing repeated comparisons, we can directly map each element to its count in a pre-allocated array or hash table. This is particularly powerful when we know the range of values in advance.

Brute-force approach and why it is not ideal:

The brute force approach would be to iterate through each element and for every element, count how many times it appears in the array by scanning the entire array again. This results in O(n²) time complexity because for each of the n elements, we perform up to n comparisons. This approach is inefficient because it does redundant work - we're recalculating the same counts multiple times.

Improved approach and the key optimization:

The improved approach uses a frequency counting data structure. The key optimization is that we make a single pass through the input array to populate our frequency counts, then make a second pass through our counting structure to process the results. This reduces time complexity from O(n²) to O(n + k) where k is the range of possible values.

The optimization works because we're leveraging the fact that we can directly index into a counting structure rather than performing comparisons. This is particularly effective when the range of values is small or bounded.

Final approach with step-by-step reasoning:

  1. First, I'll determine the range of possible values in our input - this tells us how large our counting array needs to be
  2. Create a frequency counting array or hash map initialized to zero counts
  3. Make one pass through the input array, incrementing the count for each element at its corresponding index
  4. Process the counts in whatever order is required by the problem (e.g., sorted order, or just all elements)

This approach works because:

  • We eliminate redundant comparisons by using direct indexing
  • We process each element exactly once in the counting phase
  • The final processing is linear in the number of unique elements
  • We can handle any bounded integer range efficiently

Important invariants that must hold:

  1. The counting array indices must map directly to the input values (or we need a mapping function)
  2. All elements in the input must be within the valid range for our counting structure
  3. The frequency counts must be properly maintained during the counting pass
  4. The range of values should be known or bounded to make this approach practical

This approach is optimal for problems where we have a bounded integer range and need to count occurrences, as it achieves linear time complexity with minimal overhead.

4. Data Structure Choice

For counting/bucket/radix sort use cases, the primary data structures are:

1. Integer arrays for counting buckets

  • Used to store frequency counts of elements in specific ranges
  • Time complexity: O(1) for access and updates
  • Space complexity: O(k) where k is the range of possible values (e.g., 0-9 for digits)
  • Essential because we need to track how many elements fall into each bucket

2. ArrayList or array for result storage

  • Stores the final sorted output
  • Time complexity: O(n) for insertion operations
  • Space complexity: O(n) for storing all elements
  • Needed to maintain the sorted order and return the final result

3. Auxiliary arrays for intermediate steps (in radix sort)

  • Used in radix sort to maintain stable sorting across digit positions
  • Time complexity: O(n) for copying operations
  • Space complexity: O(n + k) where n is the number of elements and k is the base
  • Critical for maintaining stability when sorting by individual digits

4. Stack or queue (optional)

  • May be used in some variations for processing elements in specific orders
  • Time complexity: O(1) for push/pop operations
  • Space complexity: O(n) in worst case
  • Less commonly needed but can help with specific implementation patterns

The key insight is that these structures work together to provide optimal time complexity while maintaining the stability and correctness required for counting-based sorting algorithms. The array-based bucket approach is preferred over hash maps due to its predictable performance and minimal overhead.

The choice of integer arrays for buckets is particularly important because:

  • They provide O(1) access time
  • They eliminate hash collision overhead
  • They allow for direct indexing based on element values
  • They enable efficient range-based processing

The space trade-off is acceptable because the bucket size is typically small (e.g., 0-255 for bytes, 0-9 for digits) and the resulting space complexity remains manageable even with large input sizes.

5. Algorithm Walkthrough

Let me trace through the algorithm using the example [170, 45, 75, 90, 2, 802, 24, 66] to demonstrate how the Radix Sort algorithm works step by step.

First, I'll identify that we have numbers with varying digits - the maximum is 802 (3 digits), so we need 3 passes of counting sort.

Pass 1 (Units place):

  • Input: [170, 45, 75, 90, 2, 802, 24, 66]
  • Extract units digit: [0, 5, 5, 0, 2, 2, 4, 6]
  • Apply counting sort on units digits:
    • Count array: [2, 0, 1, 0, 1, 2, 1, 0, 0, 0]
    • Sorted by units: [170, 90, 2, 802, 45, 75, 24, 66]

Pass 2 (Tens place):

  • Input: [170, 90, 2, 802, 45, 75, 24, 66]
  • Extract tens digit: [7, 9, 0, 0, 4, 7, 2, 6]
  • Apply counting sort on tens digits:
    • Count array: [1, 0, 1, 0, 1, 0, 1, 2, 0, 1]
    • Sorted by tens: [2, 802, 170, 45, 75, 24, 66, 90]

Pass 3 (Hundreds place):

  • Input: [2, 802, 170, 45, 75, 24, 66, 90]
  • Extract hundreds digit: [0, 8, 1, 0, 0, 0, 0, 0]
  • Apply counting sort on hundreds digits:
    • Count array: [1, 1, 0, 0, 0, 0, 0, 0, 1, 0]
    • Final sorted array: [2, 45, 66, 75, 90, 170, 24, 802]

The algorithm correctly handles all edge cases:

  • Numbers with different digit lengths are properly aligned by padding with leading zeros conceptually
  • The stable nature of counting sort ensures that equal elements maintain their relative order from previous passes
  • The process terminates when all digit positions have been processed

Each pass maintains the relative ordering from previous passes due to the stable nature of counting sort, ensuring that the final result is properly sorted in ascending order.

6. Java Solution

public class CountingSort {
    
    /**
     * Sorts an array of integers using counting sort.
     * Assumes input contains only non-negative integers in range [0, k]
     * where k is known and relatively small.
     */
    public int[] countingSort(int[] nums) {
        if (nums == null || nums.length <= 1) {
            return nums;
        }
        
        // Find the maximum value to determine range
        int max = findMax(nums);
        
        // Create counting array - size is max + 1 to handle 0-based indexing
        int[] count = new int[max + 1];
        
        // Count occurrences of each element
        for (int num : nums) {
            count[num]++;
        }
        
        // Reconstruct sorted array from counts
        int index = 0;
        for (int i = 0; i <= max; i++) {
            while (count[i] > 0) {
                nums[index++] = i;
                count[i]--;
            }
        }
        
        return nums;
    }
    
    /**
     * For sorting integers in a known limited range [0, k]
     * This version returns a new array to avoid mutating input
     */
    public int[] countingSortNewArray(int[] nums) {
        if (nums == null || nums.length <= 1) {
            return nums;
        }
        
        int max = findMax(nums);
        int[] count = new int[max + 1];
        int[] result = new int[nums.length];
        
        // Count occurrences
        for (int num : nums) {
            count[num]++;
        }
        
        // Convert counts to cumulative counts
        for (int i = 1; i <= max; i++) {
            count[i] += count[i - 1];
        }
        
        // Build result array from right to maintain stability
        for (int i = nums.length - 1; i >= 0; i--) {
            result[count[nums[i]] - 1] = nums[i];
            count[nums[i]]--;
        }
        
        return result;
    }
    
    /**
     * Generic counting sort for any integer range
     * Handles negative numbers by shifting the range
     */
    public int[] countingSortWithNegatives(int[] nums) {
        if (nums == null || nums.length <= 1) {
            return nums;
        }
        
        // Find min and max to determine range
        int min = findMin(nums);
        int max = findMax(nums);
        int range = max - min + 1;
        
        // Create count array with offset to handle negatives
        int[] count = new int[range];
        
        // Count occurrences (shift by min to handle negatives)
        for (int num : nums) {
            count[num - min]++;
        }
        
        // Reconstruct sorted array
        int index = 0;
        for (int i = 0; i < range; i++) {
            int value = i + min;
            while (count[i] > 0) {
                nums[index++] = value;
                count[i]--;
            }
        }
        
        return nums;
    }
    
    private int findMax(int[] nums) {
        int max = Integer.MIN_VALUE;
        for (int num : nums) {
            max = Math.max(max, num);
        }
        return max;
    }
    
    private int findMin(int[] nums) {
        int min = Integer.MAX_VALUE;
        for (int num : nums) {
            min = Math.min(min, num);
        }
        return min;
    }
}

7. Complexity Analysis

Time Complexity: O(d × n) where d is the number of digits and n is the number of elements.

For radix sort, we process each digit position once per element, and we do this for all d digits. If we're sorting integers with at most k digits, the complexity becomes O(k × n). In the case of fixed-size integers (e.g., 32-bit integers), k is constant, making the overall complexity O(n).

Space Complexity: O(n + k) where n is the number of elements and k is the range of input values.

We need O(n) extra space for the temporary arrays used in each digit pass, and O(k) space for the counting array (bucket array). For integer sorting with a known range, k is constant, so space complexity becomes O(n).

Why these bounds are correct:

The algorithm performs exactly d passes, each pass doing O(n) work to distribute elements into buckets and then O(n) work to collect them back. The bucket size is bounded by the input range, so each pass maintains linear time complexity.

Optimality:

This is optimal for comparison-based sorting algorithms in the general case, but for specific constraints like fixed-digit integers or bounded ranges, it's better than O(n log n) comparison sorts. The solution is near-optimal when the number of digits d is small relative to n, making it highly efficient for practical applications where data has limited digit count or bounded value ranges.

The time complexity is optimal for the problem constraints, and space usage is minimal compared to other sorting approaches that require O(n log n) time or additional memory proportional to input size.

8. Edge Cases & Pitfalls

Empty and Minimal Inputs

  • Empty array: Should return empty result without errors
  • Single element: Trivial case that should work correctly
  • Two elements: Need to verify proper bucket assignment and sorting

Duplicate Values and Repeated States

  • All duplicates: Algorithm must handle when all elements are identical
  • Mixed duplicates: Elements that appear multiple times but with different values
  • Duplicate buckets: When multiple elements hash to the same bucket index

Boundary Conditions

  • Maximum integer values: Integer overflow risks in bucket indexing calculations
  • Negative numbers: Radix sort typically assumes non-negative integers; special handling needed for negatives
  • Large range vs. small count: When range is much larger than element count, bucket sort becomes inefficient

Common Implementation Mistakes

  • Incorrect bucket size calculation: Using max - min instead of max - min + 1 for bucket count
  • Off-by-one errors in index calculation: Misaligning element values with bucket indices
  • Not preserving original order: In stable sorting, maintaining relative order of equal elements
  • Memory allocation issues: Not pre-allocating buckets properly or using incorrect data structures
  • Integer overflow in bucket indexing: When element values are very large, calculations might overflow

Special Case Handling

  • Negative numbers: Radix sort requires modification for negative integers (separate positive/negative handling)
  • Very large ranges: When range exceeds reasonable memory constraints, alternative approaches may be needed
  • Unsorted input order: Ensure algorithm works regardless of initial arrangement of elements

9. Alternative Approaches

There are several alternative approaches to solving problems that might seem like counting/bucket sort use cases, each with their own trade-offs:

1. Generic Sorting Approach

  • Sort the array first using a comparison-based sort (like quicksort or mergesort)
  • Then process elements in sorted order to count occurrences
  • Time: O(n log n), Space: O(1) if in-place sort, O(n) otherwise
  • This is simpler to implement but less efficient than bucket/radix sort when the range is known and small

2. Hash Map Counting

  • Use a HashMap to count frequency of each element
  • This works for any range but requires O(n) space for the hash map
  • Time: O(n), Space: O(k) where k is the number of distinct elements
  • Best when the range is large or unknown, but we can afford the extra space

3. Binary Search Tree Approach

  • Insert elements into a BST and maintain count at each node
  • Useful when we need to process elements in sorted order or want to support dynamic updates
  • Time: O(n log n) average case, O(n²) worst case for skewed trees
  • Space: O(n)
  • More complex than bucket sort but provides additional functionality

4. Bit Manipulation with Bit Vectors

  • For very specific ranges (like 0 to 31), use bit vectors where each bit represents presence of an element
  • Space: O(1) for fixed-size ranges, but only works when the range is small and known
  • Time: O(n) for counting, O(1) for lookup

The chosen bucket/radix sort approach is optimal when we know the range and want to achieve O(n) time complexity with minimal space overhead. The alternatives are more flexible but generally less efficient for the specific use case of counting elements within a known, bounded range.

10. Test Cases

public class TestCases {
    public static void main(String[] args) {
        // Test case 1: Basic example from problem description
        int[] nums1 = {1, 3, 2, 3, 1};
        // Expected: 2 (pairs: (1,3) and (1,3))
        
        // Test case 2: Empty array
        int[] nums2 = {};
        // Expected: 0
        
        // Test case 3: Single element
        int[] nums3 = {5};
        // Expected: 0
        
        // Test case 4: All same elements
        int[] nums4 = {2, 2, 2, 2};
        // Expected: 6 (all combinations of 2 elements from 4)
        
        // Test case 5: Large numbers with duplicates
        int[] nums5 = {1, 1, 2, 2, 3, 3};
        // Expected: 4 (pairs: (1,2), (1,2), (2,3), (2,3))
        
        // Test case 6: Negative numbers
        int[] nums6 = {-1, 0, 1, 2, -1, -4};
        // Expected: 2 (pairs: (-1,0) and (-1,1))
        
        // Test case 7: Mixed positive/negative with zero
        int[] nums7 = {0, 1, -1, 2, -2};
        // Expected: 4 (pairs: (0,1), (0,-1), (1,-2), (-1,2))
    }
}

Sample test cases include:

  • Basic valid input with mixed elements
  • Edge case of empty array (returns 0)
  • Boundary case of single element (no pairs possible)
  • Duplicate elements scenario (maximizes pair count)
  • Large numbers with duplicates to test counting logic
  • Negative numbers to verify handling of all integer ranges
  • Mixed positive/negative with zero values for comprehensive coverage

The test cases cover the core functionality while ensuring edge conditions are handled properly, including arrays with no valid pairs, arrays where all elements form pairs, and arrays with mixed value types that require careful counting.

11. Final Summary

The core insight is that when we have a bounded range of integer values or can map values to a small range, counting sort provides an optimal O(n + k) solution where k is the range size. This approach works exceptionally well for problems like sorting integers within a known range, counting occurrences of elements, or solving problems where we can leverage the discrete nature of data to achieve linear time complexity.

The solution is efficient because it eliminates the need for comparisons entirely, instead using direct indexing into a count array to place elements in their correct positions. What makes it particularly powerful is that when k is small relative to n, we effectively get linear time performance that beats the O(n log n) lower bound for comparison-based sorting.

The key trade-off is between space and time - we use O(k) extra space to achieve O(n + k) time, which is optimal for this class of problems. This makes it ideal when we have bounded integer inputs or can transform the problem into a counting scenario, but becomes impractical when the range k is large compared to n.

Was this article helpful?

Topics in this article

More from DSA Patterns

  • DSA Patterns

    Dijkstra's Algorithm and BFS for Shortest Path Problems

    Comprehensive guide to implementing Dijkstra's algorithm and BFS for finding shortest paths in weighted and unweighted graphs, with practical Java solutions and performance considerations.

  • DSA Patterns

    Mastering Prefix Sum Arrays in Java

    Comprehensive guide to prefix sum arrays with implementation examples, time complexity analysis, and practical use cases for FAANG interviews.

  • DSA Patterns

    Fast Slow Pointer Algorithm Patterns in Java

    Comprehensive guide to fast/slow pointer techniques for linked list cycle detection, middle element finding, and related algorithmic problems with Java implementations.

  • DSA Patterns

    Rotate Array Algorithm Solutions and Implementation Patterns

    Comprehensive Java solutions for rotating arrays including optimal approaches, time complexity analysis, and practical implementation strategies for FAANG technical interviews.

Stay in the Loop

Get curated DSA patterns, coding problems, frontend and backend LLD/HLD, and AI/ML System Design insights delivered straight to your inbox. No spam, just focused interview and engineering content.