148. Sort List - Explanation

Problem Link



Prerequisites

Before attempting this problem, you should be comfortable with:

  • Linked List Traversal - Iterating through nodes and manipulating pointers
  • Merge Sort Algorithm - Understanding divide-and-conquer sorting with O(n log n) complexity
  • Two Pointers (Slow/Fast) - Finding the middle of a linked list using the tortoise and hare technique
  • Merging Two Sorted Lists - Combining two sorted sequences into one sorted sequence

1. Convert To Array

Intuition

Linked lists are notoriously difficult to sort in place due to lack of random access. A straightforward workaround is to extract all node values into an array, sort the array using a built-in sorting algorithm, and then write the sorted values back into the linked list nodes. This leverages efficient array sorting while preserving the original list structure.

Algorithm

  1. Traverse the linked list and collect all node values into an array.
  2. Sort the array using a standard sorting algorithm.
  3. Traverse the linked list again, updating each node's value with the corresponding sorted value from the array.
  4. Return the head of the list.
# Definition for singly-linked list.
# class ListNode:
#     def __init__(self, val=0, next=None):
#         self.val = val
#         self.next = next
class Solution:
    def sortList(self, head: Optional[ListNode]) -> Optional[ListNode]:
        arr = []
        cur = head

        while cur:
            arr.append(cur.val)
            cur = cur.next

        arr.sort()
        cur = head
        for val in arr:
            cur.val = val
            cur = cur.next

        return head

Time & Space Complexity

  • Time complexity: O(nlogn)O(n \log n)
  • Space complexity: O(n)O(n)

2. Recursive Merge Sort

Intuition

Merge sort is well suited for linked lists because merging two sorted lists can be done efficiently without extra space by rearranging pointers. We recursively split the list in half using the slow and fast pointer technique to find the middle, sort each half, and then merge the two sorted halves together. This divide-and-conquer approach achieves O(n log n) time complexity.

Algorithm

  1. Base case: if the list is empty or has one node, return it as is.
  2. Use slow and fast pointers to find the middle of the list. The slow pointer will end at the node before the midpoint.
  3. Split the list into two halves by setting the next pointer of the middle node to null.
  4. Recursively sort the left half and right half.
  5. Merge the two sorted halves by comparing node values and linking nodes in sorted order.
  6. Return the merged sorted list.
# Definition for singly-linked list.
# class ListNode:
#     def __init__(self, val=0, next=None):
#         self.val = val
#         self.next = next
class Solution:
    def sortList(self, head: Optional[ListNode]) -> Optional[ListNode]:
        if not head or not head.next:
            return head

        left = head
        right = self.getMid(head)
        tmp = right.next
        right.next = None
        right = tmp

        left = self.sortList(left)
        right = self.sortList(right)
        return self.merge(left, right)

    def getMid(self, head):
        slow, fast = head, head.next
        while fast and fast.next:
            slow = slow.next
            fast = fast.next.next
        return slow

    def merge(self, list1, list2):
        tail = dummy = ListNode()
        while list1 and list2:
            if list1.val < list2.val:
                tail.next = list1
                list1 = list1.next
            else:
                tail.next = list2
                list2 = list2.next
            tail = tail.next

        if list1:
            tail.next = list1
        if list2:
            tail.next = list2

        return dummy.next

Time & Space Complexity

  • Time complexity: O(nlogn)O(n \log n)
  • Space complexity: O(logn)O(\log n) for recursion stack.

3. Iterative Merge Sort

Intuition

The recursive merge sort uses O(log n) space for the call stack. To achieve true O(1) extra space, we can implement merge sort iteratively using a bottom-up approach. Instead of recursively splitting the list, we start by treating each node as a sorted sublist of size 1, then merge adjacent pairs into sorted sublists of size 2, then 4, and so on until the entire list is sorted.

Algorithm

  1. Count the total length of the linked list.
  2. Use a dummy node to simplify head management during merges.
  3. Start with a step size of 1 and double it each iteration until it reaches the list length.
  4. For each step size, traverse the list and split off pairs of sublists of that size.
  5. Merge each pair of sublists and attach the merged result to the previous portion of the list.
  6. After all iterations complete, return the sorted list starting from dummy.next.
# Definition for singly-linked list.
# class ListNode:
#     def __init__(self, val=0, next=None):
#         self.val = val
#         self.next = next
class Solution:
    def sortList(self, head: Optional[ListNode]) -> Optional[ListNode]:
        if not head or not head.next:
            return head

        length = 0
        cur = head
        while cur:
            length += 1
            cur = cur.next

        dummy = ListNode(0)
        dummy.next = head
        step = 1

        while step < length:
            prev, curr = dummy, dummy.next
            while curr:
                left = curr
                right = self.split(left, step)
                curr = self.split(right, step)
                merged = self.merge(left, right)
                prev.next = merged
                while prev.next:
                    prev = prev.next
            step *= 2

        return dummy.next

    def split(self, head, step):
        if not head:
            return None
        for _ in range(step - 1):
            if not head.next:
                break
            head = head.next
        next_part = head.next
        head.next = None
        return next_part

    def merge(self, list1, list2):
        tail = dummy = ListNode(0)
        while list1 and list2:
            if list1.val < list2.val:
                tail.next = list1
                list1 = list1.next
            else:
                tail.next = list2
                list2 = list2.next
            tail = tail.next

        tail.next = list1 or list2
        return dummy.next

Time & Space Complexity

  • Time complexity: O(nlogn)O(n \log n)
  • Space complexity: O(1)O(1)

Common Pitfalls

Incorrect Mid-Point Calculation for List Splitting

When finding the middle of the linked list, starting fast at head instead of head.next can lead to incorrect splitting, especially for lists with two elements. This causes infinite recursion as the list never gets properly divided.

Forgetting to Disconnect the Two Halves

After finding the middle node, you must set mid.next = null to properly split the list into two separate halves. Failing to disconnect them results in the merge sort operating on overlapping lists, causing incorrect results or infinite loops.

Not Handling Edge Cases for Empty or Single-Node Lists

The base case must return immediately if head is null or head.next is null. Missing this check leads to null pointer exceptions or infinite recursion when the algorithm tries to split a list that cannot be divided further.