289 lines
12 KiB
YAML
289 lines
12 KiB
YAML
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
|
|
|
|
function_signature: "def sort_array(nums: list[int]) -> list[int]:"
|
|
|
|
test_cases:
|
|
visible:
|
|
- input: { nums: [5, 2, 3, 1] }
|
|
expected: [1, 2, 3, 5]
|
|
- input: { nums: [5, 1, 1, 2, 0, 0] }
|
|
expected: [0, 0, 1, 1, 2, 5]
|
|
hidden:
|
|
- input: { nums: [1] }
|
|
expected: [1]
|
|
- input: { nums: [2, 1] }
|
|
expected: [1, 2]
|
|
- input: { nums: [1, 2, 3, 4, 5] }
|
|
expected: [1, 2, 3, 4, 5]
|
|
- input: { nums: [5, 4, 3, 2, 1] }
|
|
expected: [1, 2, 3, 4, 5]
|
|
- input: { nums: [-1, 0, 1, -5, 3] }
|
|
expected: [-5, -1, 0, 1, 3]
|
|
- input: { nums: [3, 3, 3, 3] }
|
|
expected: [3, 3, 3, 3]
|
|
|
|
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.
|