Skip to content

Bottom-up merge sort

Problem

Implement iterative merge sort for a Python list or linked list.

Key trick

Merge adjacent runs of sizes \(1, 2, 4, 8, \ldots\) until the input is sorted.

Trap

It uses \(O(n)\) auxiliary space for ordinary list merging, but only \(O(1)\) for linked-list node merging.

Why is it interesting?

It retains stable \(O(n \log n)\) sorting while avoiding recursion.

Python solution

Bottom-up merge sort for a Python list

class sort_merge_sort_bottom_up:
    def sort(self, nums: list[int]) -> list[int]:
        arr = nums[:]
        n = len(arr)

        # Each pass writes merged runs into the other list.
        buf = [0] * n

        # Runs of width one are already sorted.
        width = 1

        while width < n:
            # Merge adjacent runs:
            # `[lo, mid)` with `[mid, hi)`.
            for lo in range(0, n, 2 * width):
                mid = min(lo + width, n)
                hi = min(lo + 2 * width, n)

                self._merge_runs(arr, buf, lo, mid, hi)

            # The buffer now contains the sorted runs for this pass.
            # Swapping references avoids copying the whole list.
            arr, buf = buf, arr

            # Each new run combines two runs from the previous pass.
            width *= 2

        return arr

    def _merge_runs(
        self,
        arr: list[int],
        buf: list[int],
        lo: int,
        mid: int,
        hi: int,
    ) -> None:
        i = lo
        j = mid
        k = lo

        # Merge the two runs into `buf`.
        while i < mid and j < hi:
            # Choosing the left value on equality makes this stable.
            if arr[i] <= arr[j]:
                buf[k] = arr[i]
                i += 1
            else:
                buf[k] = arr[j]
                j += 1

            k += 1

        # Copy the unconsumed part of either run.
        while i < mid:
            buf[k] = arr[i]
            i += 1
            k += 1

        while j < hi:
            buf[k] = arr[j]
            j += 1
            k += 1

Bottom-up merge sort for a singly linked list

class sort_merge_sort_linked_bottom_up:
    def sort(
        self,
        head: ListNode | None,
    ) -> ListNode | None:
        # The length determines how many run sizes must be merged.
        n = 0
        cur = head

        while cur:
            n += 1
            cur = cur.next

        # The dummy node makes replacement of the head straightforward.
        dummy = ListNode(next=head)

        # One-node runs are initially sorted.
        width = 1

        while width < n:
            # Rebuild the list from merged runs during this pass.
            prev = dummy
            cur = dummy.next

            while cur:
                # Cut out two adjacent runs of at most `width` nodes.
                l = cur
                r = self._split_run(l, width)
                cur = self._split_run(r, width)

                # Merge the detached runs, then attach them to the result.
                merged_head, merged_tail = self._merge_runs(l, r)
                prev.next = merged_head
                prev = merged_tail

            # Each pass doubles the size of sorted runs.
            width *= 2

        return dummy.next

    def _split_run(
        self,
        head: ListNode | None,
        size: int,
    ) -> ListNode | None:
        """Cut after at most `size` nodes and return the following run."""

        if head is None:
            return None

        # Stop at the final node belonging to this run.
        for _ in range(size - 1):
            if head.next is None:
                break

            head = head.next

        # Detach this run from the rest of the list.
        nxt = head.next
        head.next = None

        return nxt

    def _merge_runs(
        self,
        l: ListNode | None,
        r: ListNode | None,
    ) -> tuple[ListNode | None, ListNode]:
        # Return both ends because the caller must append another merged run.
        dummy = ListNode()
        tail = dummy

        while l and r:
            # Take from the left on equality to preserve stability.
            if l.val <= r.val:
                tail.next = l
                l = l.next
            else:
                tail.next = r
                r = r.next

            tail = tail.next

        # Attach the unconsumed run without copying its nodes.
        tail.next = l if l else r

        # Find the actual tail required by the outer merge loop.
        while tail.next:
            tail = tail.next

        return dummy.next, tail