foreach-ui logo
codeLanguages
account_treeDSA

Quick Actions

quizlock Random Quiz
trending_uplock Progress
  • 1
  • 2
  • 3
  • 4
  • quiz
Algorithms
  • Understand QuickSort's divide-and-conquer strategy and how partitioning works
  • Trace through QuickSort's execution step-by-step with examples
  • Analyze why QuickSort is fast in practice despite O(n²) worst case
  • Learn different pivot selection strategies and their impact on performance
  • Recognize when to use QuickSort vs other sorting algorithms

QuickSort: The Practical Workhorse

QuickSort is probably the most widely used sorting algorithm in production code. When you call .sort() in most programming languages, there's a good chance QuickSort (or something based on it) is running under the hood. Why? Because it's fast—really fast—in practice.

Here's the interesting thing about QuickSort: on paper, it's not perfect. It has a worst-case time complexity of O(n²), which is terrible for a sorting algorithm. And yet, software engineers choose it over algorithms with guaranteed O(n log n) performance. That paradox is worth understanding.

The Big Idea: Divide and Conquer

QuickSort belongs to a family of algorithms called "divide and conquer." The strategy goes like this:

  1. Divide: Split the problem into smaller subproblems
  2. Conquer: Solve each subproblem (often recursively)
  3. Combine: Merge the solutions

If you've ever cleaned a messy room by first sorting things into piles (clothes here, books there, trash over there) and then organizing each pile separately, you've used divide and conquer. It's easier to organize ten piles of ten items than one pile of a hundred.

QuickSort's specific approach is clever: pick one element as a "pivot," then rearrange the array so all smaller elements are on the left and all larger elements are on the right. Now you've broken one sorting problem into two smaller sorting problems. Recursively sort the left side, recursively sort the right side, and you're done. No merging step needed—the array is already sorted once the recursion completes.

Walking Through an Example

Let's sort this array: [8, 3, 1, 7, 0, 5, 2]

Step 1: Pick a pivot

We need to choose one element as our pivot. The simplest choice is the last element, so let's pick 2.

Step 2: Partition around the pivot

Now we rearrange the array so all elements less than 2 are on the left, and all elements greater than 2 are on the right. The pivot itself ends up in its final sorted position.

Original:  [8, 3, 1, 7, 0, 5, 2]
                            ↑ pivot (2)

After partitioning: [1, 0, 2, 7, 8, 5, 3]
                     ←left→ ↑ ←right→
                    (< 2)   2  (> 2)

Notice that 2 is now in its correct sorted position (index 2). Everything to its left is smaller, everything to its right is larger. The left and right sides aren't sorted yet, but that's fine.

Step 3: Recursively sort the left side

Now we recursively apply QuickSort to [1, 0]:

  • Pick pivot: 0
  • Partition: [0, 1] (already partitioned correctly)
  • Done (base case reached—arrays of size 1 are sorted)

Step 4: Recursively sort the right side

Apply QuickSort to [7, 8, 5, 3]:

  • Pick pivot: 3
  • Partition: [3, 7, 8, 5]
  • Recursively sort left of 3: empty array, done
  • Recursively sort right of 3: [7, 8, 5]
    • Pick pivot: 5
    • Partition: [5, 7, 8]
    • Continue until done...

Eventually, all the recursive calls complete and you have: [0, 1, 2, 3, 5, 7, 8]

The beauty is that once partitioning is done, the elements naturally fall into place. There's no final "merge" step like in MergeSort.

The Partitioning Algorithm

Partitioning is where QuickSort does its real work. Here's one common approach called the Lomuto partition scheme:

def partition(arr, low, high):
    pivot = arr[high]  # Choose last element as pivot
    i = low - 1        # Index of smaller element
    
    # Move all elements smaller than pivot to the left
    for j in range(low, high):
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]  # Swap
    
    # Place pivot in its correct position
    arr[i + 1], arr[high] = arr[high], arr[i + 1]
    return i + 1  # Return pivot's final position

Let's trace through this with [8, 3, 1, 7, 0, 5, 2] (pivot = 2):

Start: [8, 3, 1, 7, 0, 5, 2]  i = -1
       
j=0: arr[0]=8 > 2, skip
     [8, 3, 1, 7, 0, 5, 2]  i = -1

j=1: arr[1]=3 > 2, skip
     [8, 3, 1, 7, 0, 5, 2]  i = -1

j=2: arr[2]=1 <= 2, i++, swap arr[0] and arr[2]
     [1, 3, 8, 7, 0, 5, 2]  i = 0

j=3: arr[3]=7 > 2, skip
     [1, 3, 8, 7, 0, 5, 2]  i = 0

j=4: arr[4]=0 <= 2, i++, swap arr[1] and arr[4]
     [1, 0, 8, 7, 3, 5, 2]  i = 1

j=5: arr[5]=5 > 2, skip
     [1, 0, 8, 7, 3, 5, 2]  i = 1

Finally: swap arr[2] and pivot
     [1, 0, 2, 7, 3, 5, 8]  pivot at index 2

After partitioning, all elements at indices 0-1 are less than 2, and all elements at indices 3-6 are greater than 2. The variable i keeps track of the boundary between smaller and larger elements.

There's also the Hoare partition scheme, which is slightly more efficient but trickier to get right. Most implementations use Lomuto because it's simpler to understand and implement.

Choosing a Good Pivot

The pivot choice matters a lot. In the worst case, you could pick the smallest (or largest) element every time, which would give you unbalanced partitions:

Bad case: [1, 2, 3, 4, 5]
Pick pivot = 5
Partition: [1, 2, 3, 4] [5] []
           ↑ n-1 elements   ↑ 0 elements

This recurses n times, each doing O(n) work = O(n²) total

That's the dreaded O(n²) worst case. If your array is already sorted and you always pick the last element as pivot, this is exactly what happens.

Common pivot strategies:

Last element - Simple and fast, but vulnerable to already-sorted data
Random element - Avoids worst-case with high probability
Median of three - Pick the median of first, middle, and last elements. Good balance of simplicity and effectiveness
True median - Theoretically optimal but too expensive to compute

In practice, "median of three" or random selection work well. They make the O(n²) worst case extremely unlikely.

Performance Analysis

Average case: O(n log n)

With a good pivot, each partition splits the array roughly in half. That gives you a recursion tree with log n levels, and each level does O(n) work. Total: O(n log n).

Worst case: O(n²)

With consistently bad pivots, you get unbalanced partitions (n-1 on one side, 0 on the other). That's n levels of recursion, each doing O(n) work. Total: O(n²).

Best case: O(n log n)

Perfect splits every time give the same result as the average case.

Space complexity: O(log n)

QuickSort is in-place (doesn't need extra arrays), but recursion uses stack space. With balanced partitions, the recursion depth is O(log n). In the worst case, it could be O(n), which could cause stack overflow for very large arrays.

Why QuickSort Wins in Practice

Here's the paradox: MergeSort guarantees O(n log n) in all cases. HeapSort does too. So why is QuickSort often faster?

Cache efficiency: QuickSort operates on contiguous array sections, which plays nicely with CPU caches. MergeSort jumps around more and needs extra memory, which causes cache misses.

Small constant factors: The O(n log n) notation hides constant factors. QuickSort's partitioning is very efficient—just comparisons and swaps. MergeSort has more overhead.

In-place: No memory allocation during sorting (other than recursion stack). Memory allocation is slow, and avoiding it matters.

Real data isn't adversarial: The worst case requires consistently bad pivot choices. With randomization or median-of-three, it's astronomically unlikely. For random data, QuickSort flies.

That said, there are scenarios where you'd prefer something else. If you're building a system where worst-case performance absolutely cannot be terrible (real-time systems, security-critical code), use MergeSort or HeapSort instead.

Stability: QuickSort's Weakness

QuickSort is not stable. If you have two equal elements, their relative order might change during sorting.

Example: [(Alice, 85), (Bob, 90), (Carol, 85)]

After QuickSort: [(Alice, 85), (Carol, 85), (Bob, 90)]
or maybe:        [(Carol, 85), (Alice, 85), (Bob, 90)]

If you sorted first by score, then by name, instability means the name ordering within each score group might not be preserved. For many applications, this doesn't matter. But if you're doing multi-column sorts in a spreadsheet or database, it can be a problem.

Optimizations and Variations

Real-world QuickSort implementations include several optimizations:

Switch to insertion sort for small subarrays: When a subarray gets small (say, fewer than 10 elements), the overhead of recursion outweighs its benefits. Insertion sort is faster for tiny arrays.

Three-way partitioning: If your data has many duplicate values, you can partition into three sections: less than pivot, equal to pivot, and greater than pivot. This handles duplicates much better.

Instead of: [< pivot] [pivot] [> pivot]
Use:        [< pivot] [= pivot] [> pivot]

Introsort: QuickSort with a bailout. If recursion depth gets too deep (sign of worst-case behavior), switch to HeapSort. You get QuickSort's average-case speed with HeapSort's worst-case guarantee. This is what C++'s std::sort uses.

When to Use QuickSort

QuickSort is great when:

  • You need fast average-case performance
  • Memory is limited (it's in-place)
  • Stability doesn't matter
  • You're sorting general-purpose data

Consider alternatives when:

  • Worst-case guarantees are critical (use MergeSort or HeapSort)
  • Stability is required (use MergeSort)
  • Data has many duplicates (use three-way QuickSort or something else)
  • You're on a tiny embedded system with limited stack space

Code Implementation

Here's a complete Python implementation:

def quicksort(arr, low=0, high=None):
    if high is None:
        high = len(arr) - 1
    
    if low < high:
        # Partition the array and get pivot position
        pivot_index = partition(arr, low, high)
        
        # Recursively sort left and right subarrays
        quicksort(arr, low, pivot_index - 1)
        quicksort(arr, pivot_index + 1, high)

def partition(arr, low, high):
    pivot = arr[high]
    i = low - 1
    
    for j in range(low, high):
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]
    
    arr[i + 1], arr[high] = arr[high], arr[i + 1]
    return i + 1

# Example usage
numbers = [8, 3, 1, 7, 0, 5, 2]
quicksort(numbers)
print(numbers)  # [0, 1, 2, 3, 5, 7, 8]

With randomized pivot selection:

import random

def quicksort_random(arr, low=0, high=None):
    if high is None:
        high = len(arr) - 1
    
    if low < high:
        pivot_index = partition_random(arr, low, high)
        quicksort_random(arr, low, pivot_index - 1)
        quicksort_random(arr, pivot_index + 1, high)

def partition_random(arr, low, high):
    # Pick random pivot and swap it to the end
    random_index = random.randint(low, high)
    arr[random_index], arr[high] = arr[high], arr[random_index]
    
    # Now partition as usual
    return partition(arr, low, high)

The Bigger Picture

QuickSort is a masterclass in practical algorithm design. It's not theoretically perfect—that O(n²) worst case is a real blemish. But through clever engineering (good pivot selection, hybrid approaches, optimizations), it became the default sorting algorithm for a huge swath of software.

Understanding QuickSort teaches you:

  • Divide and conquer thinking: Break problems into smaller pieces
  • The gap between theory and practice: Worst-case bounds aren't everything
  • How small optimizations matter: Pivot selection, switching to insertion sort, etc.
  • Trade-offs: Speed vs guarantees, simplicity vs optimality

When you're choosing algorithms in real projects, you'll face similar decisions. Sometimes the "optimal" algorithm on paper isn't the right choice. Sometimes a simple heuristic (like random pivot selection) solves a theoretical problem in practice. QuickSort embodies these lessons beautifully.

© 2026 forEach. All rights reserved.

Privacy Policy•Terms of Service