← All Posts
DSA · Linked Lists · Part 13 of 28

Sorting a Linked List

Sorting is where the linked list flips the usual script. On an array, quicksort is king and merge sort pays an annoying tax; on a list, the tax vanishes and merge sort becomes not just the best choice but the obvious one. This post explains why, then builds four sorts — top-down merge, bottom-up merge, insertion, and quicksort by splicing — with correct, compilable C++ and the exact bugs that bite people writing them from memory.

Why std::sort Cannot Touch a List

Reach for the standard library first and you hit a wall:

std::list<int> lst = {5, 2, 8, 1};
std::sort(lst.begin(), lst.end());   // does not compile

std::sort is declared to take random-access iterators. Its introsort implementation computes mid = first + (last - first) / 2, jumps to a pivot, and partitions by walking two cursors toward each other from both ends — every one of those steps needs O(1) indexing. A std::list iterator is only bidirectional: it can do ++it and --it, but not it + n. The subtraction last - first is not even defined. The type system stops you before a single element moves.

That is why std::list carries its own sort as a member function:

lst.sort();               // uses operator<
lst.sort(std::greater<>{});  // custom comparator

And std::list::sort is a bottom-up merge sort internally — the exact algorithm we build below. It is required by the standard to be stable, and it never allocates a node: it only relinks the ones you already have.

Why Merge Sort Is The List Sort

Merge sort has two well-known irritations on arrays, and both come from the merge step:

Now watch what happens on a list. Merging two sorted lists is pure relinking — you compare the two front nodes, splice the smaller one onto the output tail, and advance. No element is copied; no buffer is allocated. The merge is O(1) extra space by construction:

// Merge two sorted lists by relinking. Nothing is copied.
ListNode* merge(ListNode* a, ListNode* b) {
    ListNode dummy(0);
    ListNode* tail = &dummy;
    while (a && b) {
        if (a->val <= b->val) { tail->next = a; a = a->next; }
        else                  { tail->next = b; b = b->next; }
        tail = tail->next;
    }
    tail->next = a ? a : b;    // attach whatever remains
    return dummy.next;
}

So the two things that make merge sort awkward on an array — the buffer and the not-in-place merge — simply do not exist on a list. Meanwhile the things that make quicksort great on an array — cache locality and a tight in-place partition — disappear on a list: there is no locality to exploit, and partitioning a list means building new sublists, not swapping in place. The trade that favours quicksort on arrays reverses completely. On a linked list, merge sort keeps all of its advantages and loses all of its disadvantages.

One sentence for the interview: "I'd sort a linked list with merge sort, because the merge is just pointer relinking — O(1) extra space, naturally stable — whereas quicksort loses its in-place, cache-friendly partition the moment the data stops being contiguous."

Top-Down Merge Sort

The recursive formulation is the one everyone writes first: split the list in half with the fast/slow pointer trick, sort each half, merge. The whole algorithm is three moves.

ListNode* sortList(ListNode* head) {
    if (!head || !head->next) return head;    // 0 or 1 node is already sorted

    // --- split into two halves ---
    ListNode* slow = head;
    ListNode* fast = head->next;               // NOTE: fast starts one ahead
    while (fast && fast->next) {
        slow = slow->next;
        fast = fast->next->next;
    }
    ListNode* mid = slow->next;
    slow->next = nullptr;                       // cut the first half's tail

    ListNode* left  = sortList(head);
    ListNode* right = sortList(mid);
    return merge(left, right);
}

Two details decide whether this works or loops forever.

You must cut the first half. After the walk, slow sits on the last node of the first half. If you forget slow->next = nullptr, the "first half" still runs all the way to the end of the list, and merge will chase into the second half and produce garbage or a cycle. The cut is what turns one list into two.

The infinite-recursion bug, spelled out. Start fast = head instead of head->next and a two-node list A→B destroys you. The loop runs once: slow lands on B, and mid = B->next = nullptr. The cut slow->next = nullptr touches B, not A — so A->next still points at B, the "first half" is the entire original list, and sortList(head) recurses on the same two nodes forever until the stack overflows. Starting fast one node ahead guarantees slow stops on A, so each half is strictly smaller and the recursion makes progress. The split must shrink both halves.

Complexity. The recurrence is T(n) = 2·T(n/2) + O(n), giving O(n log n) time. Space is O(log n) for the recursion stack — not O(1), because each recursive frame is live while its two children run. That stack is the one thing bottom-up removes.

Bottom-Up Merge Sort — O(1) Extra Space

The bottom-up version throws away recursion entirely. Instead of splitting top-down, it builds sorted runs from the ground up: first treat every node as a sorted run of width 1, merge adjacent runs into runs of width 2, then width 4, 8, and so on until one run spans the whole list. No stack, no allocation — genuinely O(1) extra space. This is what std::list::sort and libstdc++'s list sort do.

It needs two helpers: split(head, n), which detaches the first n nodes and returns the rest, and a merge that appends onto a running tail so we can stitch pass after pass.

int listLength(ListNode* head) {
    int n = 0;
    for (ListNode* p = head; p; p = p->next) ++n;
    return n;
}

// Keep the first n nodes of `head` as a run; return the (n+1)-th node onward.
// If the list is shorter than n, return nullptr and leave `head` intact.
ListNode* split(ListNode* head, int n) {
    for (int i = 1; head && i < n; ++i) head = head->next;
    if (!head) return nullptr;
    ListNode* rest = head->next;
    head->next = nullptr;
    return rest;
}

// Merge a and b, appending onto *tailRef, and advance *tailRef to the new end.
void mergeInto(ListNode* a, ListNode* b, ListNode** tailRef) {
    ListNode* tail = *tailRef;
    while (a && b) {
        if (a->val <= b->val) { tail->next = a; a = a->next; }
        else                  { tail->next = b; b = b->next; }
        tail = tail->next;
    }
    tail->next = a ? a : b;
    while (tail->next) tail = tail->next;    // walk to the real end
    *tailRef = tail;
}

ListNode* sortList(ListNode* head) {
    int n = listLength(head);
    ListNode dummy(0);
    dummy.next = head;

    for (int width = 1; width < n; width *= 2) {
        ListNode* cur  = dummy.next;
        ListNode* tail = &dummy;
        while (cur) {
            ListNode* left  = cur;
            ListNode* right = split(left,  width);   // left  = first  width nodes
            cur             = split(right, width);   // right = second width nodes
            mergeInto(left, right, &tail);           // merge them onto the output
        }
    }
    return dummy.next;
}

The outer loop runs log₂(n) times (each pass doubles width); each pass touches every node once, so it is O(n) per pass. Total: O(n log n) time, O(1) extra space, and it is stable because every merge uses <=. The dummy node means we never special-case an empty output tail.

▶ Bottom-Up Merge Sort — the widening passes

Each pass doubles the run width and merges adjacent runs. Watch the runs (shaded brackets) widen from 1 → 2 → 4 → 8 until the whole list is one sorted run.

Dry Run: bottom-up on 4→2→1→3

Four nodes, so n = 4 and the passes are width = 1 then width = 2 (the loop stops once width < n fails).

PassRuns being mergedList after the pass
width = 1[4]+[2], then [1]+[3]2 → 4 → 1 → 3
width = 2[2,4]+[1,3]1 → 2 → 3 → 4
width = 4loop ends: 4 < 4 is false1 → 2 → 3 → 4 (sorted)

Notice the first pass merges [4] with [2] to get 2→4, and [1] with [3] to get 1→3; the second pass merges those two width-2 runs into the final answer. Two passes, four merges, zero allocations.

Insertion Sort on a List

Insertion sort is O(n²) in the worst case, but it has two real virtues on a list: it is O(1) extra space, and it is O(n) on already-sorted or nearly-sorted input — the case that shows up constantly in practice (log streams, append-mostly data). The list version also dodges the array version's biggest cost: there is no shifting. Inserting a node is a splice, not a memmove.

Keep a sorted prefix behind a dummy head, and for each incoming node either extend the run (fast path) or scan the prefix for its slot:

ListNode* insertionSortList(ListNode* head) {
    ListNode dummy(0);              // sorted region is dummy -> ... -> tail (null-terminated)
    ListNode* tail = &dummy;
    for (ListNode* cur = head; cur; ) {
        ListNode* next = cur->next;
        cur->next = nullptr;        // detach cur from the unsorted remainder
        if (tail != &dummy && tail->val <= cur->val) {
            tail->next = cur;       // fast path: input still ascending, append in O(1)
            tail = cur;
        } else {
            ListNode* p = &dummy;                       // scan the sorted prefix
            while (p->next && p->next->val <= cur->val)
                p = p->next;
            cur->next = p->next;    // splice cur in
            p->next = cur;
            if (p == tail) tail = cur;                  // ended up at the very end
        }
        cur = next;
    }
    return dummy.next;
}

The cur->next = nullptr right after saving next is the invariant that keeps the sorted region null-terminated at all times, so the scan loop can never run off into the still-unsorted tail. Drop that line and the fast-path append leaves cur->next pointing back into the unprocessed input — a classic corruption bug. On sorted input every node takes the fast path: O(n). On adversarial input every node scans the whole prefix: O(n²). Extra space is always O(1).

Quicksort by Splicing

You can quicksort a list. Pick a pivot, partition the nodes into three lists — less, equal, greater — recurse on the outer two, then concatenate. The three-way split handles duplicates cleanly.

ListNode* concat(ListNode* a, ListNode* b) {
    if (!a) return b;
    ListNode* t = a;
    while (t->next) t = t->next;
    t->next = b;
    return a;
}

ListNode* quickSortList(ListNode* head) {
    if (!head || !head->next) return head;
    int pivot = head->val;                 // first element as pivot (a weak choice)

    ListNode lessD(0), eqD(0), grtD(0);
    ListNode *lt = &lessD, *eq = &eqD, *gt = &grtD;
    for (ListNode* cur = head; cur; ) {
        ListNode* next = cur->next;
        cur->next = nullptr;
        if      (cur->val < pivot) lt = lt->next = cur;
        else if (cur->val == pivot) eq = eq->next = cur;
        else                        gt = gt->next = cur;
        cur = next;
    }

    ListNode* less = quickSortList(lessD.next);
    ListNode* grt  = quickSortList(grtD.next);
    return concat(less, concat(eqD.next, grt));   // less ++ equal ++ greater
}

It works, and yet nobody sorts production lists this way. Three reasons:

On stability: the array quicksort you know is not stable, because its in-place partition swaps equal keys past one another. This list version happens to preserve order within each bucket, so it can be made stable — but that buys nothing when merge sort is already stable, O(n log n) worst case, and O(1) space. Quicksort on a list is a curiosity, not a tool.

Stability — and Why It Matters

A sort is stable when equal keys keep their original relative order. Merge sort on a list is stable if and only if the merge breaks ties toward the left run:

if (a->val <= b->val) { take a; }   // <=  keeps equal elements in original order (STABLE)
// if you wrote < instead, ties go to b, and equal keys swap => UNSTABLE

The single character <= versus < is the entire difference between a stable and an unstable merge sort. Stability matters whenever the records carry more than the sort key. Sort a list of orders by price after it was already sorted by timestamp, and a stable sort leaves equal-priced orders in timestamp order — a free secondary sort. An unstable sort scrambles them. This is why std::stable_sort and std::list::sort exist as distinct, guaranteed-stable tools.

The Four Sorts, Side by Side

AlgorithmBestAverageWorstExtra spaceStable?Verdict
Merge (top-down)n log nn log nn log nO(log n) stackYes (<=)Clean default; easy to write.
Merge (bottom-up)n log nn log nn log nO(1)YesBest overall; what std::list::sort uses.
InsertionnO(1)YesOnly for tiny or nearly-sorted lists.
Quicksort (splice)n log nn log nO(log n) stackPreservableAvoid: bad pivots, no locality gain.

The takeaway is unusually clean for an algorithms topic: on a linked list, bottom-up merge sort is simply the answer. It is asymptotically optimal for a comparison sort, stable, and uses no extra memory. Every other option is either strictly worse or a special-case tool for nearly-sorted data.

Check Yourself

Each item gives a situation; pick the statement that is actually true.

Practice