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   **Step 2: Divide the array** - Find the middle index: `mid = len(nums) // 2` - Split into two halves: `left = nums[:mid]` and `right = nums[mid:]`   **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   **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   **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.