title: Sort an Array
slug: sort-an-array
difficulty: medium
leetcode_id: 912
leetcode_url: https://leetcode.com/problems/sort-an-array/
categories:
  - arrays
  - sorting
  - recursion
patterns:
  - dynamic-programming

description: |
  Given an array of integers `nums`, sort the array in ascending order and return it.

  You must solve the problem **without using any built-in** functions in `O(n log n)` time complexity and with the smallest space complexity possible.

constraints: |
  - `1 <= nums.length <= 5 * 10^4`
  - `-5 * 10^4 <= nums[i] <= 5 * 10^4`

examples:
  - input: "nums = [5,2,3,1]"
    output: "[1,2,3,5]"
    explanation: "After sorting the array, the positions of some numbers are not changed (for example, 2 and 3), while the positions of other numbers are changed (for example, 1 and 5)."
  - input: "nums = [5,1,1,2,0,0]"
    output: "[0,0,1,1,2,5]"
    explanation: "Note that the values of nums are not necessarily unique."

explanation:
  intuition: |
    Imagine you have a messy deck of cards and need to sort them. One natural approach is **divide and conquer**: split the deck in half, sort each half separately, and then merge them back together.

    This is the essence of **Merge Sort**. The key insight is that merging two *already sorted* arrays is easy and efficient - you just compare the front elements of each array and pick the smaller one, repeating until both arrays are exhausted.

    Think of it like this: sorting a huge pile of papers is overwhelming, but sorting two small piles and then combining them? That's manageable. By recursively applying this principle, we break the problem into trivially small pieces (single elements, which are inherently sorted) and build up the solution.

    The constraint requiring `O(n log n)` time rules out simple algorithms like Bubble Sort or Insertion Sort (which are `O(n^2)`). Merge Sort guarantees `O(n log n)` in all cases - worst, average, and best - making it a reliable choice.

  approach: |
    We solve this using **Merge Sort**, a classic divide-and-conquer algorithm:

    **Step 1: Base case**

    - If the array has 0 or 1 elements, it's already sorted - return it as-is
    - This is the termination condition for our recursion

    &nbsp;

    **Step 2: Divide the array**

    - Find the middle index: `mid = len(nums) // 2`
    - Split into two halves: `left = nums[:mid]` and `right = nums[mid:]`

    &nbsp;

    **Step 3: Recursively sort each half**

    - Call merge sort on the left half
    - Call merge sort on the right half
    - Trust the recursion - each half will come back sorted

    &nbsp;

    **Step 4: Merge the sorted halves**

    - Create a result array
    - Use two pointers, one for each sorted half
    - Compare elements at both pointers, add the smaller one to result
    - Advance the pointer of the array from which we took the element
    - When one array is exhausted, append all remaining elements from the other

    &nbsp;

    **Step 5: Return the merged result**

    - The merged array is now fully sorted
    - This bubbles up through the recursion to produce the final sorted array

  common_pitfalls:
    - title: Using O(n^2) Sorting Algorithms
      description: |
        Simple algorithms like Bubble Sort, Selection Sort, or Insertion Sort have `O(n^2)` time complexity.

        With `n = 5 * 10^4`, this means up to 2.5 billion operations - far too slow and will result in **Time Limit Exceeded (TLE)**.

        The problem explicitly requires `O(n log n)`, so you must use Merge Sort, Heap Sort, or Quick Sort (with proper pivot selection).
      wrong_approach: "Nested loops comparing/swapping adjacent elements"
      correct_approach: "Divide and conquer with O(n log n) guarantee"

    - title: Quick Sort Worst Case
      description: |
        While Quick Sort is often fast in practice, its worst case is `O(n^2)` - which happens with already sorted arrays or when the pivot is always the smallest/largest element.

        LeetCode tests often include edge cases that trigger this worst case. Merge Sort avoids this entirely with guaranteed `O(n log n)` performance.

        If using Quick Sort, randomize pivot selection or use median-of-three to avoid worst case.
      wrong_approach: "Quick Sort with first/last element as pivot"
      correct_approach: "Merge Sort or Quick Sort with randomized pivot"

    - title: Inefficient Merging
      description: |
        A common mistake is using inefficient operations during the merge step, like repeatedly inserting at the beginning of a list (`O(n)` per insert) or using `pop(0)` in Python.

        Always append to the end of your result array and use index pointers to track position in the source arrays.
      wrong_approach: "Using pop(0) or insert(0) during merge"
      correct_approach: "Use index pointers and append to result"

    - title: Not Handling Remaining Elements
      description: |
        After the main merge loop, one of the two halves may still have elements remaining.

        Forgetting to append these leftover elements results in missing data in the output. Always extend the result with any remaining elements from both halves.
      wrong_approach: "Stopping when one array is exhausted"
      correct_approach: "Append remaining elements from both arrays after loop"

  key_takeaways:
    - "**Divide and conquer**: Break large problems into smaller subproblems, solve them, and combine results"
    - "**Merge Sort guarantees**: Unlike Quick Sort, Merge Sort is `O(n log n)` in all cases - no worst-case degradation"
    - "**Space tradeoff**: Merge Sort uses `O(n)` extra space for the merge step, but this is acceptable for guaranteed performance"
    - "**Foundation for advanced algorithms**: The merge technique appears in external sorting, merge intervals, and many other problems"

  time_complexity: "O(n log n). We divide the array log n times, and each level of recursion does O(n) work to merge."
  space_complexity: "O(n). We need auxiliary space for the temporary arrays during merging. The recursion stack adds O(log n), but O(n) dominates."

solutions:
  - approach_name: Merge Sort
    is_optimal: true
    code: |
      def sortArray(nums: list[int]) -> list[int]:
          # Base case: single element or empty array is already sorted
          if len(nums) <= 1:
              return nums

          # Divide: find the middle and split
          mid = len(nums) // 2
          left = nums[:mid]
          right = nums[mid:]

          # Conquer: recursively sort both halves
          sorted_left = sortArray(left)
          sorted_right = sortArray(right)

          # Combine: merge the two sorted halves
          return merge(sorted_left, sorted_right)


      def merge(left: list[int], right: list[int]) -> list[int]:
          result = []
          i = j = 0

          # Compare elements from both arrays and add smaller one
          while i < len(left) and j < len(right):
              if left[i] <= right[j]:
                  result.append(left[i])
                  i += 1
              else:
                  result.append(right[j])
                  j += 1

          # Add any remaining elements from left array
          while i < len(left):
              result.append(left[i])
              i += 1

          # Add any remaining elements from right array
          while j < len(right):
              result.append(right[j])
              j += 1

          return result
    explanation: |
      **Time Complexity:** O(n log n) - We divide the array into halves log n times, and merging at each level takes O(n) total.

      **Space Complexity:** O(n) - We need extra space for the temporary arrays created during slicing and merging.

      This is the classic Merge Sort implementation. It recursively divides the array until we have single elements, then merges them back in sorted order. The `<=` comparison in merge ensures stability (equal elements maintain their relative order).

  - approach_name: Heap Sort
    is_optimal: true
    code: |
      def sortArray(nums: list[int]) -> list[int]:
          n = len(nums)

          # Build max heap - start from last non-leaf node
          for i in range(n // 2 - 1, -1, -1):
              heapify(nums, n, i)

          # Extract elements from heap one by one
          for i in range(n - 1, 0, -1):
              # Move current root (max) to end
              nums[0], nums[i] = nums[i], nums[0]
              # Heapify the reduced heap
              heapify(nums, i, 0)

          return nums


      def heapify(nums: list[int], n: int, i: int) -> None:
          largest = i       # Initialize largest as root
          left = 2 * i + 1  # Left child index
          right = 2 * i + 2 # Right child index

          # Check if left child exists and is greater than root
          if left < n and nums[left] > nums[largest]:
              largest = left

          # Check if right child exists and is greater than current largest
          if right < n and nums[right] > nums[largest]:
              largest = right

          # If largest is not root, swap and continue heapifying
          if largest != i:
              nums[i], nums[largest] = nums[largest], nums[i]
              heapify(nums, n, largest)
    explanation: |
      **Time Complexity:** O(n log n) - Building the heap takes O(n), and we perform n extractions each taking O(log n).

      **Space Complexity:** O(1) - Heap Sort is in-place, using only the input array (ignoring recursion stack for heapify, which can be made iterative).

      Heap Sort first builds a max heap from the array. Then it repeatedly extracts the maximum element (root), places it at the end, and restores the heap property. This achieves O(n log n) with O(1) extra space, though it's not stable.

  - approach_name: Quick Sort (Randomized)
    is_optimal: false
    code: |
      import random


      def sortArray(nums: list[int]) -> list[int]:
          quick_sort(nums, 0, len(nums) - 1)
          return nums


      def quick_sort(nums: list[int], low: int, high: int) -> None:
          if low < high:
              # Randomize pivot to avoid worst case
              pivot_idx = random.randint(low, high)
              nums[pivot_idx], nums[high] = nums[high], nums[pivot_idx]

              # Partition and get pivot position
              pivot_pos = partition(nums, low, high)

              # Recursively sort elements before and after partition
              quick_sort(nums, low, pivot_pos - 1)
              quick_sort(nums, pivot_pos + 1, high)


      def partition(nums: list[int], low: int, high: int) -> int:
          pivot = nums[high]  # Pivot is at the end
          i = low - 1         # Index of smaller element

          for j in range(low, high):
              # If current element is smaller than or equal to pivot
              if nums[j] <= pivot:
                  i += 1
                  nums[i], nums[j] = nums[j], nums[i]

          # Place pivot in correct position
          nums[i + 1], nums[high] = nums[high], nums[i + 1]
          return i + 1
    explanation: |
      **Time Complexity:** O(n log n) average, O(n^2) worst case - Randomization makes worst case extremely unlikely.

      **Space Complexity:** O(log n) average for recursion stack, O(n) worst case.

      Quick Sort partitions the array around a pivot, placing smaller elements before it and larger elements after. Randomizing the pivot selection prevents adversarial inputs from triggering the O(n^2) worst case. While fast in practice, Merge Sort is preferred when guaranteed O(n log n) is required.
