给你一个整数数组 nums,请你将该数组升序排列。
你必须在 不使用任何内置函数 的情况下解决问题,时间复杂度为 O(nlog(n)),并且空间复杂度尽可能小。
借这个排序过程我们来认识一下常见的排序算法
选择排序的实现
python
class Solution:
def sortArray(self, nums: List[int]) -> List[int]:
for i in range(len(nums)-1):
min_index = i # 假设现在是最小
for j in range(i+1, len(nums)): # 继续遍历,不过不用多余的遍历,所以直接i+1,len(nums),但是这样的话会导致范围操作len(nums)(也就是说nums[len(nums)-1]是最后一个,不能再大了),所以前面的最多只能是len(nums)-2,由于是开区间,所以前面的for就是len(nums)了
if nums[j]<nums[min_index]:# 开始比较,选出最小的j,此时j就是最小值了
min_index = j
nums[i], nums[min_index] = nums[min_index], nums[i] # 更新最小值的索引为最小,然后把当前的i换到min_index的地方进行交换
return nums
思想还是很明确的,那就是不断的两两交换,注意这个交换的时候是怎么遍历的即可,举个例子,我的1 3 5 2 4中,先1,然后遍历时min=0,不变,然后i+1,也就是3,遍历选小的,选到了2,index为3,交换1和3的index值,然后i+1到了5,遍历。。。。等等就完成了
所以是不断往后的,每一次都是往后找一个最小的和当前的交换。
冒泡排序
有点类似,但是是相邻两个之间进行交换,循环过程就应该是
python
class Solution:
def sortArray(self, nums: List[int]) -> List[int]:
n = len(nums)
for i in range(n-1):
# 每次比较相邻元素,如果顺序错误就交换位置,直到整个都有序
# swapped = False
for j in range(n-1-i):
if nums[j] > nums[j+1]:
nums[j], nums[j+1] = nums[j+1], nums[j]
# swapped = True
return nums
注意,外层循环是控制多少轮的遍历,每一轮的结果是把最大的元素放到了最后面。内层循环j不断的在变少,因为已经排序好了的就不需要再遍历了,这个排序最难的是理解它的循环的逻辑,我们通过一个具体的例子来说明这个问题。
python
初始数组:[3, 1, 4, 2]
数组长度 n = 4,外层循环范围是 range(n-1),即 i = 0, 1, 2(共 3 轮,因为最后一个元素无需再比较)。
第 1 轮(i=0)
目标:把当前未排序部分(整个数组)的最大元素 "推" 到数组末尾(索引 3 的位置)。内层循环范围:j = 0 to n-1 - i - 1 → j = 0 to 4-1-0-1 = 2(即 j=0,1,2)。
j=0:比较 nums[0] 和 nums[1] → 3 > 1,交换 → 数组变为 [1, 3, 4, 2]
j=1:比较 nums[1] 和 nums[2] → 3 < 4,不交换 → 数组仍为 [1, 3, 4, 2]
j=2:比较 nums[2] 和 nums[3] → 4 > 2,交换 → 数组变为 [1, 3, 2, 4]
本轮结果:最大元素 4 被推到了末尾(索引 3),未排序部分变为 [1, 3, 2](末尾的 4 已排好)。
第 2 轮(i=1)
目标:把当前未排序部分([1, 3, 2])的最大元素 "推" 到它的最终位置(索引 2 的位置)。内层循环范围:j = 0 to n-1 - i - 1 → j = 0 to 4-1-1-1 = 1(即 j=0,1)。
j=0:比较 nums[0] 和 nums[1] → 1 < 3,不交换 → 数组仍为 [1, 3, 2, 4]
j=1:比较 nums[1] 和 nums[2] → 3 > 2,交换 → 数组变为 [1, 2, 3, 4]
本轮结果:未排序部分的最大元素 3 被推到了索引 2 的位置,未排序部分变为 [1, 2](索引 2 和 3 已排好)。
第 3 轮(i=2)
目标:把当前未排序部分([1, 2])的最大元素 "推" 到它的最终位置(索引 1 的位置)。内层循环范围:j = 0 to n-1 - i - 1 → j = 0 to 4-1-2-1 = 0(即 j=0)。
j=0:比较 nums[0] 和 nums[1] → 1 < 2,不交换 → 数组仍为 [1, 2, 3, 4]
本轮结果:未排序部分的最大元素 2 被推到了索引 1 的位置,整个数组已完全有序。
复杂度还是比较高的。。。
所以很明显和选择排序的循环有很大的区别的。
另外也很容易想到优化方案,那就是如果在某一轮遍历的时候没有发生1次交换,那就说明已经排好了,所以我们优化一下:
python
class Solution:
def sortArray(self, nums: List[int]) -> List[int]:
n = len(nums)
for i in range(n-1):
# 每次比较相邻元素,如果顺序错误就交换位置,直到整个都有序
swapped = False
for j in range(n-1-i):
if nums[j] > nums[j+1]:
nums[j], nums[j+1] = nums[j+1], nums[j]
swapped = True
if not swapped:
break
return nums
插入排序
以上方法的复杂度还是比较高的,插入排序是选择排序的一种优化方案。
python
class Solution:
def sortArray(self, nums: List[int]) -> List[int]:
n = len(nums)
for i in range(1, n):# 默认第一个元素已经排序
current = nums[i] # 当前要插入的元素
j = i - 1 # 已经排序部分的最后一个元素索引 也就是前面的元素
while j>=0 and nums[j] > current:
nums[j+1] = nums[j] # 已排序的元素后移
j-=1
# 插入当前元素到对应的位置
nums[j+1] = current
return nums
插入排序的思想也很明确,那就是找到当前元素应该插入的地方,遍历不再向后,而是向前了。
同样的举个例子来进行说明。
以数组 [3, 1, 4, 2] 为例,逐步展示插入排序过程:
python
初始状态
已排序部分:[3](索引 0)
未排序部分:[1, 4, 2](索引 1、2、3)
处理第 1 个未排序元素(索引 1,值 1)
current = 1
在已排序部分 [3] 中从后往前找位置:3 > 1,继续向前(已到开头)。
插入位置为索引 0,将已排序部分元素(3)后移一位 → 已排序部分变为 [1, 3]。
数组现在:[1, 3, 4, 2]
处理第 2 个未排序元素(索引 2,值 4)
current = 4
在已排序部分 [1, 3] 中从后往前找位置:3 < 4,找到插入位置(索引 2)。
无需后移元素,直接插入 → 已排序部分变为 [1, 3, 4]。
数组现在:[1, 3, 4, 2]
处理第 3 个未排序元素(索引 3,值 2)
current = 2
在已排序部分 [1, 3, 4] 中从后往前找位置:
4 > 2 → 4 后移一位 → 已排序部分临时变为 [1, 3, _, 4]
3 > 2 → 3 后移一位 → 已排序部分临时变为 [1, _, 3, 4]
1 < 2 → 找到插入位置(索引 1)。
插入current → 已排序部分变为 [1, 2, 3, 4]。
数组现在:[1, 2, 3, 4](排序完成)
希尔排序
到这里我们将会第一次实现小于O(n^2)的排序。它是插入排序的改进,通过预处理增加数组的有序性,突破插入排序的时间复杂度。
首先需要知道一个概念,那就是h有序数组,也就是间隔h的元素,如果一个数组是h有序的,那么中间间隔h个元素组成的数组也是有序的。
完全有序的数组本身是一个1有序数组(当完成了排序之后的)。思想上来认识希尔排序,他就是一个16有序,8有序,4有序逐渐收束到1有序的过程,这个过程里面我们定义了对应的h=2
*h #1 2 4 8 16,也就是2^(k-1),最大的时候h<n//2的,n是nums的长度。
为什么需要希尔排序?
插入排序对几乎有序的数组效率很高(接近 O (n)),但对逆序数组效率低(O (n²))。希尔排序通过先 "宏观调整" 数组(大步长分组排序,让数组整体更接近有序),再进行 "微观调整"(步长 1 的插入排序),从而提升效率。
步长选择
步长的选择会影响希尔排序的效率,常见的步长序列有:
初始步长为n//2,之后每次减半(n//4, n//8, ..., 1),这是最常用的简单实现。
更优的步长序列(如 Knuth 序列)可进一步提升效率,但实现稍复杂。这里以 "步长减半" 为例讲解。
举例说明
以数组 [8, 9, 1, 7, 2, 3, 5, 4, 6, 0] 为例,步长初始为 10//2 = 5,逐步减半:
- 步长 = 5(将数组分为 5 组,每组 2 个元素)
分组:[8,3], [9,5], [1,4], [7,6], [2,0]
每组插入排序后:[3,8], [5,9], [1,4], [6,7], [0,2]
数组变为:[3,5,1,6,0,8,9,4,7,2] - 步长 = 2(5//2 = 2,分为 2 组)
分组:[3,1,0,9,7], [5,6,8,4,2]
每组插入排序后:[0,1,3,7,9], [2,4,5,6,8]
数组变为:[0,2,1,4,3,5,7,6,9,8] - 步长 = 1(2//2 = 1,整个数组为 1 组)
对数组 [0,2,1,4,3,5,7,6,9,8] 进行插入排序,最终得到:0,1,2,3,4,5,6,7,8,9\](排序完成) 所以这里面还是一个插入排序的内核
class Solution:
def sortArray(self, nums: List[int]) -> List[int]:
# 希尔排序--改进版本的插入排序
n = len(nums)
gap = n //2
while gap > 0:
for i in range(gap, n):
current = nums[i]
j = i- gap
while j >= 0 and nums[j] > current:
nums[j+gap] = nums[j]
j -= gap
nums[j+gap] = current
gap //= 2
## 快速排序
从算法的思维上来看待问题,我们先把一个排列好,然后再去把剩下的元素排好序就是快速排序的思想。这里我们通过一种递归的思路来进行排序。
选择基准(pivot):从数组中选一个元素作为基准(常见选择:第一个元素、最后一个元素、中间元素或随机元素)。
分区(partition):将数组重新排列,所有比基准小的元素移到基准左边,所有比基准大的元素移到基准右边(相等元素可放任意一边)。此时基准元素的位置已确定。
递归排序:递归地对基准左边的子数组和右边的子数组执行上述步骤,直到子数组长度为 0 或 1(天然有序)。
举例说明
以数组 \[3, 6, 8, 10, 1, 2, 1\] 为例,选择最右边的元素 1 作为初始基准:
第 1 次分区
目标:将小于 1 的元素放左边(无),大于 1 的元素放右边。
分区后:\[1, 6, 8, 10, 1, 2, 3\](基准1的最终位置为索引 0)。
递归处理右子数组 \[6, 8, 10, 1, 2, 3\]。
第 2 次分区(子数组 \[6, 8, 10, 1, 2, 3\])
选择最右边元素 3 作为基准。
分区后:\[1, 2, 3, 10, 6, 8, 6\](原数组片段,基准3的位置为索引 2)。
递归处理左子数组 \[1, 2\] 和右子数组 \[10, 6, 8\]。
后续递归
左子数组 \[1, 2\] 分区后基准2到位,左子数组\[1\]有序。
右子数组 \[10, 6, 8\] 选择8为基准,分区后\[6, 8, 10\],基准8到位,子数组\[6\]和\[10\]有序。
最终结果
\[1, 1, 2, 3, 6, 8, 10\]
```python
class Solution:
def sortArray(self, nums: List[int]) -> List[int]:
def quick_sort(left, right):
if left>=right:
return
pivot_index = partition(left, right) # 分区并选择基准
quick_sort(left, pivot_index - 1)
quick_sort(pivot_index+1, right)
def partition(left, right):
pivot = nums[right]
i = left - 1 # 左边界,不能小于i
for j in range(left, right):
if nums[j] <= pivot:
i+=1
nums[i], nums[j] = nums[j], nums[i]
nums[i+1], nums[right] = nums[right], nums[i+1] # 交换基准元素
return i + 1 # 基准元素的索引变为i+1
quick_sort(0, len(nums)-1) # 最左和最右边
return nums
递归终止条件:if left >= right 正确处理了子数组长度为 0 或 1 的情况(天然有序,无需排序)。
分区函数(partition):
选择最右元素作为基准(pivot = nums[right]),逻辑清晰。
i 初始化为 left - 1,正确标记 "小于等于基准元素区域" 的右边界(初始为空)。
遍历 j 从 left 到 right-1,将所有 <= pivot 的元素交换到 i 指向的区域(i 递增后交换),确保左区域元素均不大于基准。
最后将基准元素交换到 i+1 位置(左区域和右区域的中间),此时基准位置已确定,返回 i+1 正确。 基准元素要怎么变化!!!
递归调用:对基准左侧(left 到 pivot_index-1)和右侧(pivot_index+1 到 right)的子数组递归排序,覆盖了所有元素,无遗漏。
归并排序
排序算法我们就了解到这里就够了。归并是二叉树的后序位置进行的排序。
直接举例就能说明了:
以数组 [3, 1, 4, 2] 为例,步骤如下:
- 分解阶段
初始数组:[3, 1, 4, 2]
第一次拆分:[3, 1] 和 [4, 2]
第二次拆分:[3]、[1] 和 [4]、[2](子数组长度为 1,停止拆分) - 合并阶段
合并 [3] 和 [1]:比较 3 和 1,取 1,再取 3 → 得到 [1, 3]
合并 [4] 和 [2]:比较 4 和 2,取 2,再取 4 → 得到 [2, 4]
合并 [1, 3] 和 [2, 4]:
指针 i 指向 1,指针 j 指向 2 → 取 1(i 后移)
指针 i 指向 3,指针 j 指向 2 → 取 2(j 后移)
指针 i 指向 3,指针 j 指向 4 → 取 3(i 后移)
指针 i 越界,取剩余的 4 → 最终得到 [1, 2, 3, 4]
核心步骤
分解(Divide):将当前数组从中间拆分为两个子数组,递归拆分每个子数组,直到子数组长度为 1。
合并(Merge):将两个已排序的子数组合并为一个更大的有序数组。合并时通过双指针遍历两个子数组,每次选择较小的元素放入结果数组,直到所有元素合并完成。
可以看到分解不是什么问题,主要是合并的时候按照一定的顺序进行合并。本方法比快速方法又更好一点。
这里又遇到了递归,我其实还不太能够理解递归到最后是什么结果。。。
python
class Solution:
def sortArray(self, nums: List[int]) -> List[int]:
if len(nums)<=1:
return nums
mid = len(nums) // 2
left = self.sortArray(nums[:mid]) # 左数组
right = self.sortArray(nums[mid:]) # 右数组递归 # 由于是递归,所以left和right都会走到最后的
# 有点好奇left和right最后是什么?
return self.merge(left, right)
def merge(self, left, right):
result = []
i = j = 0
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
result.extend(left[i:])
result.extend(right[j:])
return result
递归调用的执行步骤(以 [3,1,4,2] 为例)
第 1 次调用:sortArray([3,1,4,2])
数组长度 = 4 > 1,不满足终止条件。
拆分:mid=2,左子数组 [3,1],右子数组 [4,2]。
先调用 sortArray([3,1])(左子问题),暂停当前函数,等待左子问题返回结果。
第 2 次调用:sortArray([3,1])
数组长度 = 2 > 1,不满足终止条件。
拆分:mid=1,左子数组 [3],右子数组 [1]。
先调用 sortArray([3])(左子问题),暂停当前函数。
第 3 次调用:sortArray([3])
数组长度 = 1,满足终止条件,直接返回 [3]。
回到第 2 次调用(sortArray([3,1])),拿到左子结果 [3]。
继续调用 sortArray([1])(右子问题),暂停当前函数。
第 4 次调用:sortArray([1])
数组长度 = 1,满足终止条件,直接返回 [1]。
回到第 2 次调用(sortArray([3,1])),拿到右子结果 [1]。
执行 merge([3], [1]),得到 [1,3],返回该结果。
回到第 1 次调用(sortArray([3,1,4,2])),拿到左子结果 [1,3]。
继续调用 sortArray([4,2])(右子问题),暂停当前函数。
第 5 次调用:sortArray([4,2])
数组长度 = 2 > 1,不满足终止条件。
拆分:mid=1,左子数组 [4],右子数组 [2]。
先调用 sortArray([4])(左子问题),暂停当前函数。
第 6 次调用:sortArray([4])
数组长度 = 1,返回 [4]。
回到第 5 次调用(sortArray([4,2])),拿到左子结果 [4]。
继续调用 sortArray([2])(右子问题),暂停当前函数。
第 7 次调用:sortArray([2])
数组长度 = 1,返回 [2]。
回到第 5 次调用(sortArray([4,2])),拿到右子结果 [2]。
执行 merge([4], [2]),得到 [2,4],返回该结果。
回到第 1 次调用(sortArray([3,1,4,2])),拿到右子结果 [2,4]。
执行 merge([1,3], [2,4]),得到 [1,2,3,4],返回最终结果。
递归调用的核心要素
终止条件:必须存在一个 "最小子问题" 的解(如归并排序中 "子数组长度≤1"),否则递归会无限循环,导致栈溢出。
子问题拆分:每次调用必须将问题拆分为 "规模更小、结构相同" 的子问题(如归并排序中拆分左右子数组),确保问题能逐步简化到终止条件。
结果合并:子问题的解必须能合并为原问题的解(如归并排序中merge函数合并两个有序子数组)。
务必要记住插入排序和插入排序的优化(希尔排序)来实现排序。