DSA · Algorithms

Sorting Algorithms Compared: Merge, Quick, Heap, Counting & When to Use Each

Working code for every sort that matters, a single comparison table, what your language's built-in sort actually does, and the sorting questions interviewers keep asking.

✍️ Pranjal Jain, Ex-Microsoft · IIT Kanpur 📅 10 October 2026 ⏱ 17 min read
Colored bars being rearranged into sorted order
TL;DR
  • Merge sort: O(n log n) always, stable, O(n) extra space. Best for linked lists and external sorting.
  • Quick sort: O(n log n) average, O(n²) worst, in place, not stable. Usually fastest in practice with a random or median pivot.
  • Heap sort: O(n log n) always, O(1) space, not stable, slower in practice due to poor cache use.
  • Counting/radix sort: linear time for integers in a bounded range; they beat the Ω(n log n) comparison lower bound because they don't compare.
  • Libraries: Python and Java objects use Timsort (stable); Java primitives use dual-pivot quicksort; C++ std::sort is introsort (not stable).

You'll almost never write a sorting algorithm at work, yet sorting comes up in interviews all the time. Partly that's direct ("implement merge sort", "why is quicksort's worst case quadratic?"), but mostly it's indirect: partitioning powers quickselect and Sort Colors, merging powers Merge K Sorted Lists and counting inversions, heaps power top-K problems, and knowing what your language's sort() costs is part of every complexity answer.

This guide gives you working code for each important algorithm in at least two languages, a dry run where it helps, the properties that interviewers probe (stability, in-place, adaptivity, worst case), and what real libraries do. Let's start with the summary table, then justify each row.

Sorting Algorithms Comparison Table

AlgorithmBestAverageWorstExtra spaceStableAdaptive
Bubble sortO(n)*O(n²)O(n²)O(1)YesYes*
Selection sortO(n²)O(n²)O(n²)O(1)NoNo
Insertion sortO(n)O(n²)O(n²)O(1)YesYes
Merge sortO(n log n)O(n log n)O(n log n)O(n)YesNo (plain version)
Quick sortO(n log n)O(n log n)O(n²)O(log n) stackNoNo
Heap sortO(n log n)O(n log n)O(n log n)O(1)NoNo
Counting sortO(n + k)O(n + k)O(n + k)O(n + k)Yes-
Radix sort (LSD)O(d(n + b))O(d(n + b))O(d(n + b))O(n + b)Yes-
TimsortO(n)O(n log n)O(n log n)O(n)YesYes
IntrosortO(n log n)O(n log n)O(n log n)O(log n)NoNo

* Bubble sort is O(n) best case only with the early-exit flag. k is the key range, d the number of digits, b the base. Adaptive means faster on input that's already partly sorted. Quicksort's O(log n) stack assumes you recurse into the smaller partition first; naively it can be O(n) in the worst case.

What Stability Means and Why It Matters

A sort is stable if elements with equal keys keep their original relative order. That sounds academic until you sort records by more than one field.

Say you have orders already sorted by time, and you now sort them by city. With a stable sort, orders within each city stay in time order. With an unstable sort, they're scrambled:

Input (sorted by time)Stable sort by cityPossible unstable result
09:00 Pune09:05 Bengaluru09:20 Bengaluru
09:05 Bengaluru09:20 Bengaluru09:05 Bengaluru
09:10 Pune09:00 Pune09:10 Pune
09:20 Bengaluru09:10 Pune09:00 Pune

This "sort by secondary key, then stable-sort by primary key" trick is exactly how radix sort works, and it's why spreadsheet column sorts and most UI table sorts rely on stable algorithms. Any unstable sort can be made stable by adding the original index as a tie-breaker, at the cost of extra memory.

Insertion Sort: The Small-Array Workhorse

Insertion sort builds a sorted prefix one element at a time, shifting larger elements right to make room. It's O(n²) in general but O(n + inversions), so it's very fast on small or nearly sorted arrays. That's why Timsort and introsort both switch to insertion sort for small subarrays, typically under a few dozen elements.

Python
def insertion_sort(a):
    for i in range(1, len(a)):
        key = a[i]
        j = i - 1
        while j >= 0 and a[j] > key:     # strict > keeps it stable
            a[j + 1] = a[j]
            j -= 1
        a[j + 1] = key
Java
static void insertionSort(int[] a) {
    for (int i = 1; i < a.length; i++) {
        int key = a[i], j = i - 1;
        while (j >= 0 && a[j] > key) {
            a[j + 1] = a[j];
            j--;
        }
        a[j + 1] = key;
    }
}

Bubble sort and selection sort exist mostly for teaching. Selection sort does the minimum number of swaps (n − 1), which matters only when writes are very expensive. Neither is used by real libraries.

Merge Sort: Guaranteed O(n log n) and Stable

Split the array in half, sort each half recursively, merge the two sorted halves. The recurrence T(n) = 2T(n/2) + O(n) gives O(n log n) in every case: log n levels, O(n) merge work per level.

Python: readable version
def merge_sort(a):
    if len(a) <= 1:
        return a
    mid = len(a) // 2
    return merge(merge_sort(a[:mid]), merge_sort(a[mid:]))

def merge(left, right):
    out = []
    i = j = 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:      # <= takes from the left on ties: stable
            out.append(left[i])
            i += 1
        else:
            out.append(right[j])
            j += 1
    out.extend(left[i:])
    out.extend(right[j:])
    return out
Java: one shared buffer, no per-call allocation
static void mergeSort(int[] a) {
    mergeSort(a, new int[a.length], 0, a.length);
}

// sorts a[lo, hi)
static void mergeSort(int[] a, int[] tmp, int lo, int hi) {
    if (hi - lo < 2) return;
    int mid = (lo + hi) >>> 1;
    mergeSort(a, tmp, lo, mid);
    mergeSort(a, tmp, mid, hi);
    int i = lo, j = mid, k = lo;
    while (i < mid && j < hi) tmp[k++] = (a[i] <= a[j]) ? a[i++] : a[j++];
    while (i < mid) tmp[k++] = a[i++];
    while (j < hi) tmp[k++] = a[j++];
    System.arraycopy(tmp, lo, a, lo, hi - lo);
}

The Python version is clearest for explaining, but its slices allocate new lists at every level (still O(n log n) time, more memory traffic). The Java version allocates one buffer up front, which is what you'd mention as the production-quality approach. (lo + hi) >>> 1 avoids the integer overflow that (lo + hi) / 2 can hit on huge arrays.

Dry run on [5, 2, 4, 7, 1, 3, 2, 6]:

split: [5 2 4 7] [1 3 2 6] split: [5 2] [4 7] [1 3] [2 6] merge: [2 5] [4 7] [1 3] [2 6] merge: [2 4 5 7] [1 2 3 6] merge: [1 2 2 3 4 5 6 7] three merge levels = log2(8), each touching all 8 elements

Where merge sort wins:

  • Linked lists (Sort List, LeetCode 148): merging only relinks nodes, so no buffer is needed, and splitting uses slow/fast pointers.
  • External sorting: data bigger than RAM is sorted in chunks, then the chunks are k-way merged with a heap. This is how databases sort large result sets.
  • Counting inversions: during the merge, when you take from the right half, every remaining element in the left half forms an inversion with it. Add mid − i and you count inversions in O(n log n).
  • Guaranteed worst case and stability, when both are required.

Quick Sort: Partitioning, Pivots and the Worst Case

Pick a pivot, partition the array so smaller elements go left and larger go right, then recurse on both sides. No merge step is needed because partitioning already puts the pivot in its final position.

Lomuto partition with a random pivot

C++
#include <random>
#include <vector>
#include <utility>
using namespace std;

mt19937 rng(random_device{}());

int partition(vector<int>& a, int lo, int hi) {          // inclusive bounds
    int p = uniform_int_distribution<int>(lo, hi)(rng);
    swap(a[p], a[hi]);                                    // pivot to the end
    int pivot = a[hi], i = lo;                            // a[lo..i-1] < pivot
    for (int j = lo; j < hi; ++j)
        if (a[j] < pivot) swap(a[i++], a[j]);
    swap(a[i], a[hi]);                                    // pivot to final spot
    return i;
}

void quickSort(vector<int>& a, int lo, int hi) {
    while (lo < hi) {
        int p = partition(a, lo, hi);
        if (p - lo < hi - p) {            // recurse on the smaller side,
            quickSort(a, lo, p - 1);      // loop on the larger one:
            lo = p + 1;                   // stack depth stays O(log n)
        } else {
            quickSort(a, p + 1, hi);
            hi = p - 1;
        }
    }
}

Lomuto dry run on [3, 6, 1, 8, 2, 5] with pivot 5 (already last): j=0, 3 < 5, swap into slot 0. j=1, 6 stays. j=2, 1 < 5, swap into slot 1 giving [3,1,6,8,2,5]. j=3, 8 stays. j=4, 2 < 5, swap into slot 2 giving [3,1,2,8,6,5]. Finally swap the pivot into slot 3: [3,1,2,5,6,8]. Everything left of index 3 is smaller, everything right is larger.

Why the worst case is O(n²), and how libraries avoid it

If the pivot is always the smallest or largest element (for example, choosing the last element of an already sorted array), each partition peels off just one element: T(n) = T(n−1) + n = O(n²), with O(n) recursion depth. The fixes:

  • Random pivot: expected O(n log n) on every input; the bad case needs consistently bad luck.
  • Median-of-three (first, middle, last): handles sorted and reverse-sorted input well.
  • Introsort: track recursion depth, and if it exceeds about 2·log n, switch to heap sort. Guarantees O(n log n) worst case. This is what C++ std::sort implementations do.
  • Three-way partitioning: handles many duplicate keys, where plain Lomuto degrades to O(n²) on an all-equal array.
Python: three-way (Dutch flag) quicksort
import random

def quicksort(a, lo=0, hi=None):
    if hi is None:
        hi = len(a) - 1
    if lo >= hi:
        return
    pivot = a[random.randint(lo, hi)]
    lt, i, gt = lo, lo, hi          # a[lo:lt] < p, a[lt:i] == p, a[gt+1:] > p
    while i <= gt:
        if a[i] < pivot:
            a[lt], a[i] = a[i], a[lt]
            lt += 1
            i += 1
        elif a[i] > pivot:
            a[i], a[gt] = a[gt], a[i]
            gt -= 1
        else:
            i += 1
    quicksort(a, lo, lt - 1)        # equal keys are never touched again
    quicksort(a, gt + 1, hi)

Partitioning is also the engine of quickselect: partition once, then recurse only into the side containing the k-th position. That's O(n) average for finding the k-th smallest element, covered in Kth Largest Element in an Array. The three-way partition is the same algorithm as Sort Colors in the two pointers guide.

Heap Sort: O(n log n) Worst Case in O(1) Space

Turn the array into a max-heap in place, then repeatedly swap the root (the maximum) with the last unsorted element and restore the heap on the shrunk prefix.

Java
static void heapSort(int[] a) {
    int n = a.length;
    for (int i = n / 2 - 1; i >= 0; i--) siftDown(a, i, n);   // O(n) build
    for (int end = n - 1; end > 0; end--) {
        swap(a, 0, end);            // max goes to its final position
        siftDown(a, 0, end);        // heap is now a[0, end)
    }
}

static void siftDown(int[] a, int i, int n) {
    while (true) {
        int l = 2 * i + 1, r = l + 1, big = i;
        if (l < n && a[l] > a[big]) big = l;
        if (r < n && a[r] > a[big]) big = r;
        if (big == i) return;
        swap(a, i, big);
        i = big;
    }
}

static void swap(int[] a, int i, int j) { int t = a[i]; a[i] = a[j]; a[j] = t; }
Python
def heap_sort(a):
    n = len(a)

    def sift_down(i, size):
        while True:
            l, r, big = 2 * i + 1, 2 * i + 2, i
            if l < size and a[l] > a[big]:
                big = l
            if r < size and a[r] > a[big]:
                big = r
            if big == i:
                return
            a[i], a[big] = a[big], a[i]
            i = big

    for i in range(n // 2 - 1, -1, -1):
        sift_down(i, n)
    for end in range(n - 1, 0, -1):
        a[0], a[end] = a[end], a[0]
        sift_down(0, end)

Building the heap bottom-up is O(n), not O(n log n), because most nodes are near the bottom and sift only a short distance. The extraction phase is n × O(log n). Heap sort is unstable because the root swap jumps elements across long distances, and it's slower in practice than quicksort because sift-down accesses memory in a scattered pattern (index i, then 2i+1) that's unfriendly to CPU caches. Its niche is as introsort's fallback. In interviews, heaps matter far more as priority queues than as a sort; see heap and priority queue problems.

Counting Sort and Radix Sort: Beating n log n

Counting sort

When keys are integers in a small range [0, k], count occurrences, turn counts into positions with a prefix sum, and place each element directly. O(n + k) time and space.

Python: stable counting sort
def counting_sort(a, max_val):
    count = [0] * (max_val + 1)
    for x in a:
        count[x] += 1
    for v in range(1, max_val + 1):
        count[v] += count[v - 1]          # count[v] = number of elements <= v
    out = [0] * len(a)
    for x in reversed(a):                 # reverse pass keeps equal keys in order
        count[x] -= 1
        out[count[x]] = x
    return out
C++
vector<int> countingSort(const vector<int>& a, int maxVal) {
    vector<int> count(maxVal + 1, 0), out(a.size());
    for (int x : a) count[x]++;
    for (int v = 1; v <= maxVal; ++v) count[v] += count[v - 1];
    for (int i = (int)a.size() - 1; i >= 0; --i) out[--count[a[i]]] = a[i];
    return out;
}

For plain integers, the simpler "write each value count[v] times" version works. The prefix-sum version matters when you're sorting records by an integer key and need stability, which is exactly what radix sort needs. Use counting sort when k is O(n); sorting 10 numbers in the range 0 to 109 this way would allocate a billion counters.

Radix sort (LSD)

Sort by the least significant digit, then the next, and so on, using a stable sort at each step. Because each pass is stable, ties on the current digit keep the order from earlier passes.

Python: LSD radix sort for non-negative integers
def radix_sort(a):
    if not a:
        return a
    exp, biggest = 1, max(a)
    while biggest // exp > 0:
        buckets = [[] for _ in range(10)]
        for x in a:
            buckets[(x // exp) % 10].append(x)   # appending keeps it stable
        a = [x for b in buckets for x in b]
        exp *= 10
    return a

Example on [170, 45, 75, 90, 802, 24, 2, 66]: by ones digit → [170, 90, 802, 2, 24, 45, 75, 66]; by tens → [802, 2, 24, 45, 66, 170, 75, 90]; by hundreds → [2, 24, 45, 66, 75, 90, 170, 802]. Complexity is O(d·(n + b)) for d digits in base b. For 32-bit integers with base 256 that's 4 passes, which is linear in n. Negative numbers need an offset or a separate pass for the sign.

Bucket sort is the third non-comparison sort: scatter values into buckets by range, sort each bucket, concatenate. It's O(n) expected for uniformly distributed data. In interviews it appears as Top K Frequent Elements (bucket by frequency) and Maximum Gap (pigeonhole buckets).

Why Comparison Sorts Can't Beat O(n log n)

Any comparison sort can be modelled as a binary decision tree: each internal node is a comparison "is a[i] < a[j]?", and each leaf is one possible output ordering. To sort correctly, the tree needs a distinct leaf for each of the n! input orderings. A binary tree with n! leaves has height at least log2(n!), and

log2(n!) ≥ log2((n/2)n/2) = (n/2)·log2(n/2) = Ω(n log n)

So some input forces at least that many comparisons. Merge sort and heap sort are therefore asymptotically optimal among comparison sorts. Counting and radix sort escape the bound because they index by key values instead of comparing pairs. Being able to sketch this argument is a solid answer to "can you sort faster than n log n?"

What Python, Java, C++ and Others Actually Use

Language / callAlgorithmStable?Notes
Python list.sort, sortedTimsortYesDetects natural runs; 3.11 adopted the powersort merge policy
Java Arrays.sort(int[]) etc.Dual-pivot quicksortNo (irrelevant for primitives)Since Java 7; recent JDKs add fallbacks against degenerate inputs
Java Arrays.sort(Object[]), Collections.sort, List.sortTimSortYesComparator must be consistent, or it may throw "Comparison method violates its general contract"
C++ std::sortIntrosort (quicksort + heapsort + insertion sort)NoO(n log n) worst case required since C++11
C++ std::stable_sortMerge sortYesO(n log n) with a buffer, O(n log² n) if no memory is available
JavaScript Array.prototype.sortImplementation-defined (V8 uses TimSort)YesStability required by the spec since ES2019; default comparator compares strings
Go sort.Sort, slices.SortPattern-defeating quicksort (pdqsort)NoSince Go 1.19; use sort.Stable for stability

Two practical consequences come up in interviews. First, sorting an Integer[] or List<Integer> in Java is stable and somewhat slower (boxed objects, TimSort) than sorting an int[]. Second, the JavaScript default sort compares as strings, so [10, 9, 1].sort() gives [1, 10, 9]. Always pass a comparator for numbers.

Which Sorting Algorithm to Use When

SituationChoiceWhy
General-purpose, in an interview or productionThe built-in sortTuned hybrid; say O(n log n)
Need stability (multi-key sort)Merge sort / TimsortStable by design
Memory is tight, need a worst-case guaranteeHeap sortO(1) extra, O(n log n) worst
Linked listMerge sortSequential access only
Data larger than RAMExternal merge sortSequential I/O, k-way merge
Small integer range (ages, grades, 0–9 digits)Counting sortO(n + k)
Fixed-width integers or strings, huge nRadix sortLinear in n
Nearly sorted (each element ≤ k from its place)Insertion sort or a size-k min-heapO(nk) or O(n log k)
Only need the k-th element or top kQuickselect or a heap, not a full sortO(n) average or O(n log k)

Sorting Interview Questions and How to Answer Them

Conceptual questions

  • "Merge sort or quicksort?" Quicksort for in-memory arrays (cache-friendly, in place, fast average); merge sort when you need stability, guaranteed worst case, linked lists or external data.
  • "How do you avoid quicksort's worst case?" Random or median-of-three pivot, three-way partitioning for duplicates, introsort's depth limit.
  • "Is heap sort stable? Why not?" No; the root-to-end swap moves equal elements past each other.
  • "What does your language's sort use?" Use the table above; knowing Java's primitive/object split is a nice signal.
  • "Sort a million 32-bit integers with limited memory." In-place quicksort or heap sort; if they're bounded, counting sort. If they don't fit in memory, external merge sort.

Coding problems built on sorting

ProblemSorting idea
Sort Colors (75)Three-way partition
Kth Largest Element (215)Quickselect or size-k heap
Sort List (148)Merge sort on a linked list
Merge k Sorted Lists (23)k-way merge with a heap
Count of Smaller Numbers After Self (315)Merge sort with inversion counting
Largest Number (179)Custom comparator: a+b vs b+a
Top K Frequent Elements (347)Bucket sort by frequency
Maximum Gap (164)Radix sort or pigeonhole buckets
Merge Intervals (56)Sort by start, then scan; see interval problems

Custom comparators: the bug magnet

Java: Largest Number (179)
public String largestNumber(int[] nums) {
    String[] s = new String[nums.length];
    for (int i = 0; i < nums.length; i++) s[i] = String.valueOf(nums[i]);
    Arrays.sort(s, (a, b) -> (b + a).compareTo(a + b));   // descending by concat
    if (s[0].equals("0")) return "0";                     // all zeros
    return String.join("", s);
}
Python
from functools import cmp_to_key

def largestNumber(nums):
    s = list(map(str, nums))
    s.sort(key=cmp_to_key(lambda a, b: (a + b < b + a) - (a + b > b + a)))
    return "0" if s[0] == "0" else "".join(s)
🚫
Comparator pitfallsNever write (a, b) -> a - b for ints that can be large or negative; it overflows. Use Integer.compare(a, b). A comparator must be consistent (if a < b and b < c then a < c); a random or inconsistent comparator can make Java's TimSort throw an exception. In Python, prefer key= over cmp_to_key whenever a key function exists; it's faster and harder to get wrong.

If you can write merge sort, randomised quicksort and heap sort from memory, explain the stability column, and state what your language's built-in sort does, you've covered what interviewers ask about sorting directly. Everything else is applying these ideas inside other problems, and the complexity analysis guide covers how to cost those applications correctly.

Frequently Asked Questions

Which sorting algorithm is the fastest?▾
There is no single fastest sort. For general comparison sorting, a well-implemented quicksort variant such as introsort or pdqsort is usually fastest in practice because of good cache behaviour, while Timsort is very fast on partially sorted data. For integers in a small range, counting sort or radix sort beat any comparison sort because they run in linear time.
What is a stable sorting algorithm?▾
A stable sort keeps elements with equal keys in the same relative order they had in the input. Merge sort, insertion sort, counting sort and Timsort are stable; quicksort, heap sort and selection sort, in their usual forms, are not. Stability matters when you sort by one key after another, such as sorting employees by name and then by department.
Why is quicksort faster than merge sort in practice if both are O(n log n)?▾
Quicksort works in place on contiguous memory, so it has excellent cache locality and small constant factors, and it does not need the O(n) buffer that array merge sort uses. Its O(n^2) worst case is avoided with random or median-of-three pivots, and introsort falls back to heap sort if recursion gets too deep.
What sorting algorithm does Python use?▾
Python's list.sort and sorted use Timsort, a stable hybrid of merge sort and insertion sort that detects already-sorted runs in the data. It runs in O(n log n) worst case and close to O(n) on nearly sorted input. Python 3.11 updated its run-merging policy to the powersort strategy.
What sorting algorithm does Java use?▾
Arrays.sort on primitive arrays uses a dual-pivot quicksort, which is not stable but stability is meaningless for primitives. Arrays.sort on object arrays and Collections.sort use TimSort, which is stable. This is why sorting an int[] and an Integer[] of the same values use different algorithms.
Can any sorting algorithm be faster than O(n log n)?▾
Comparison-based sorts cannot beat Omega(n log n) comparisons in the worst case, which follows from a decision-tree argument over the n! possible orderings. Non-comparison sorts like counting sort, radix sort and bucket sort avoid that bound by using the structure of the keys, running in O(n + k) or O(d(n + b)) time.
Which sorting algorithm is best for linked lists?▾
Merge sort. It needs only sequential access, splitting a list with fast and slow pointers and merging by relinking nodes, so it runs in O(n log n) time with O(log n) recursion stack or O(1) extra space bottom-up. Quicksort and heap sort depend on random access, which linked lists do not provide efficiently.

Master DSA Fundamentals with Prepflix

Prepflix's DSA program covers sorting, searching and every major pattern with structured practice, dry runs and mock interviews.

Explore the Prepflix Course →
Pranjal Jain
Pranjal Jain

Ex-Microsoft SDE · IIT Kanpur · Founder of Prepflix. Helps engineers crack startup and product company interviews across India.