排序,即将序列(数组,链表)中的元素按照大小顺序进行排列。
排序的基本操作
比较
两个值,看哪个更大或者更小。- 当彼此不在正确的相对位置时, 可能需要
交换
它们。
评估排序算法的整体效率,需要同时考虑 比较 和 交换 的总次数。
Python的交换操作
通常, 交换两个变量的值需要用到一个 辅助变量, 代码如下
temp = a
a = b
b = temp
而在Python中,可以使用 同时分配, 在一个语句中完成交换。
a, b = b, a
P.S.:Python中没有也不需要swap()函数。
Python内置的排序方法和函数——sort()和sorted()
在 Python 中,我们可以直接使用 sort() 和 sorted() 对列表进行排序【两者本质上是 结合了归并排序(merge sort)和插入排序(insertion sort)的 Timsort,具体可参考 Python sort 函数内部实现原理】。
list.sort()
对列表进行 原地 排序(in-place operation)
sorted(list)
不会改变原来的 list,而是会 返回一个新的 排好序的 list
当然,我们这里关心的是不同排序算法的底层实现。
排序算法介绍
没有任何一种排序算法在任何情况下都是最好的。
在B站看到一个排序算法可视化的视频,感觉很有意思,在这里分享一下—— 6分钟演示15种排序算法
O(n^2) 的排序算法
一、冒泡排序(Bubble Sort)
冒泡排序,即 多次遍历列表,遍历过程中 比较相邻的项 并 交换那些无序的项。每次遍历,都能够将下一个最大的数放在其正确的位置【第一次遍历把最大的放到最后,第二次遍历把第二大的放到倒数第二位置,以此类推】,就像是“气泡从水底上升到水面”的过程。
下图展示了冒泡排序的第一次遍历。
冒泡排序的细节:
- 对于大小为 n 的列表,只需要进行 n-1 次遍历【排完 n-1 个数后,剩下的1个数自然就在正确的位置上了】
- 第 i 次遍历只需要操作列表的前 n-i 个项。
Python代码
def bubble_sort(nums):
for passNum in range(len(alist)-1,0,-1):
for i in range(passNum):
if alist[i] > alist[i + 1]:
alist[i], alist[i + 1] = alist[i + 1], alist[i]
nums = [54,26,93,17,77,31,44,55,20]
bubble_sort(nums)
print(nums)
C++代码
复杂度分析
-
比较次数:1 到 n-1 的和,即 n2/2 - n/2,复杂度为 O ( n 2 ) O(n^2) O(n2)
-
交换次数:
- 最好的情况, 列表已经排序, 则不会进行交换
- 最坏的情况, 每次比较都会导致交换元素
- 平均情况下, 我们交换了一半时间
冒泡排序通常被认为是 最低效 的排序方法, 因为它在最终位置被知道之前交换项。 这些“浪费”的交换操作是非常昂贵的。
二、选择排序(Selection Sort)
选择排序是 对冒泡排序的改进, 每次遍历列表最多只做一次交换。具体地, 选择排序 在遍历过程中 定位最大的值, 完成遍历之后再 将其放到正确的位置。
下图展示了选择排序的完整过程。
Python代码
def selectionSort(alist):
for fillslot in range(len(alist)-1,0,-1):
positionOfMax = 0
for location in range(1,fillslot+1):
if alist[location]>alist[positionOfMax]:
positionOfMax = location
temp = alist[fillslot]
alist[fillslot] = alist[positionOfMax]
alist[positionOfMax] = temp
alist = [54,26,93,17,77,31,44,55,20]
selectionSort(alist)
print(alist)
复杂度分析
-
比较次数:与冒泡排序有相同的比较次数,即 O ( n 2 ) O(n^2 ) O(n2)
-
交换次数:选择排序的交换次数 ≤ n-1 ≤ 冒泡排序的交换次数【绝大多数情况下比冒泡排序更快】。对于示例中的列表, 冒泡排序有 20 次交换, 而选择排序只有 8 次
三、插入排序(Insertion Sort)
插入排序的思路是 始终在列表的低位维护一个排序的子列表, 每次将后面的新项逐个 “有序插入” 先前的子列表。
有序插入的实现 针对已经排序的子列表检查当前项,大的项向后移,当到达较小或相等的项或子列表的末尾时, 插入当前项。 如下图所示
Python代码
def insertionSort(alist):
for index in range(1,len(alist)):
currentvalue = alist[index]
position = index
while position > 0 and alist[position-1] > currentvalue:
alist[position] = alist[position-1]
position = position - 1
alist[position] = currentvalue
alist = [54,26,93,17,77,31,44,55,20]
insertionSort(alist)
print(alist)
复杂度分析
-
比较次数:插入排序的最大(差)比较次数是 1 到 n-1 的和,同样是 O ( n 2 ) O(n^2) O(n2)。在最好的情况下【列表已经排序】, 每次通过只需要进行一次比较,即 O ( n ) O(n) O(n)。
-
“交换” 次数: 需要注意的是,插入排序中使用的是移位操作而非交换操作【 移位操作只需要交换大约三分之一的处理工作, 因为仅执行一次分配】。
四、希尔排序(Shell Sort)
待补充
O(nlogn) 的排序算法
五、堆排序(Heap Sort)
六、归并排序(Merge Sort)
《浙江大学数据结构MOOC》 关于这一块讲得很好,提到了实现上一些影响效率的细节【函数统一接口,空间的malloc和free的位置(针对C语言而言)】。
归并排序的 核心 是 有序子列的归并。
基础知识:有序子列的归并
即将 两个排序的子列表 组合成单个排序的新列表。
实现的方式很简单,定义三个指针,比较A, B两个列表当前元素的大小,把小的放进列表C的当前位置,以此类推可。
复杂度:对于两个子列共有N个元素的情况,时间复杂度T(N) = O(N)。
归并排序算法思想
归并排序是一种 递归算法,是典型的 分而治之(即先分后治) 的思想,其思路为
- 将列表从中点处分成两个子列
- 对两个子列递归进行归并排序
- 对排好序的两个子列进行归并
具体流程如下图所示:
Python代码
两种写法
写法一、传入递归函数的是拷贝的列表(代码比较简洁)
def mergeSort(alist):
if len(alist) <= 1: # 递归终止条件:输入为空列表或者单元素列表时,不做处理
return
mid = len(alist) // 2
left_half = alist[:mid]
right_half = alist[mid:]
mergeSort(left_half)
mergeSort(right_half)
left = 0
right = 0
index = 0
while left < len(left_half) and right < len(right_half):
if left_half[left] < right_half[right]:
alist[index] = left_half[left]
left += 1
else:
alist[index] = right_half[right]
right += 1
index += 1
while left < len(left_half):
alist[index] = left_half[left]
left += 1
index += 1
while right < len(right_half):
alist[index] = right_half[right]
right += 1
index += 1
alist = [54,26,93,17,77,31,44,55,20]
mergeSort(alist)
print(alist)
写法二、传入递归函数的是原列表+左右边界
参考:浙江大学数据结构MOOC
def mergeSort(alist):
"""统一函数接口"""
tmp = [0] * len(alist) # 子列归并的辅助数组
mergeSortHelper(alist, 0, len(alist) - 1, tmp)
def mergeSortHelper(alist, start, end, tmp):
"""核心递归函数"""
if start >= end: # 递归终止条件:输入列表为空列表或者单元素列表时,不做处理
return
# 输入列表包含两个或两个以上元素时
mid = (start + end) // 2
mergeSortHelper(alist, start, mid, tmp)
mergeSortHelper(alist, mid + 1, end, tmp)
merge(alist, start, mid ,end, tmp)
def merge(alist, start, mid, end, tmp):
"""归并 alist[start]~alist[mid] 和 alist[mid+1]~alist[end] 两个排序子列"""
left = start
right = mid + 1
index = start
while left <= mid and right <= end:
if alist[left] < alist[right]:
tmp[index] = alist[left]
left += 1
else:
tmp[index] = alist[right]
right += 1
index += 1
while left <= mid:
tmp[index] = alist[left]
left += 1
index +=1
while right <= end:
tmp[index] = alist[right]
right += 1
index +=1
# 将tmp复制回alist
for i in range(start, end + 1):
alist[i] = tmp[i]
alist = [54,26,93,17,77,31,44,55,20]
mergeSort(alist)
print(alist)
复杂度分析
T ( N ) = T ( N / 2 ) + T ( N / 2 ) + O ( N ) → T ( N ) = O ( N l o g N ) T(N)=T(N/2)+T(N/2)+O(N)→T(N)=O(NlogN) T(N)=T(N/2)+T(N/2)+O(N)→T(N)=O(NlogN)
- 时间复杂度:归并算法没有最好时间复杂度,最坏时间复杂度,任何情况下都是 O ( N l o g N ) O(NlogN) O(NlogN),是非常稳定的。
七、快速排序(Quick Sort)
传说中最快的排序算法【准确地来说,在大多数情况下,对于大规模的随机数据,快速排序的表现是相当出色的】。
和归并排序类似,快排也是采用 分而治之,递归 的策略。
快速排序的核心是 partition(划分),也就是
- 在列表中挑选一个元素作为 主元(pivot,/ˈpɪvət/,也叫做枢轴值)
- 利用主元将列表 划分 成小于 (等于) 主元和大于 (等于) 主元的 两个部分 【等于主元的项有几种不同的处理方式,在下面会进行讨论】
快速排序的思路为
- 基于主元对列表进行 partition,小于 (等于) 主元的子列放在主元左边,大于 (等于) 主元的子列放在主元右边
- 对两部分子列递归调用快速排序
每次递归划分完子列后,主元就一次性被放到了最终的正确位置上,再不需要移动,这也是快速排序快的重要原因。
快速排序的细节
快速排序有很多小细节,如果在小细节上没有实现好,快速排序可能会变得“不快速”。
我们要知道,快速排序的最好情况,是每次正好中分,此时T(N)=O(NlogN),在细节上我们要尽可能地满足这点。
1、主元如何选择?
① pivot = A[0]? 感觉更为常用
不过对于排序列表而言不是很聪明,如下图所示。
不过这种情况毕竟是少数。实际上,不管主元选的是哪个,我们总是能构造出一种序列,使得快速排序的时间复杂度最坏,等于O(N^2)。
② pivot = A[mid]?
③ 随机取pivot? rand()函数不便宜。
④ 一个比较好的做法,取头中尾中【start, center, end】的中位数作为pivot(参考 浙大数据结构MOOC)。具体代码如下:
def median3(alist, start, end):
'''三数取中位数'''
center = (start + end) // 2
# 排序,使得A[start] <= A[Center] <= A[end]
if alist[start] > alist[center]:
alist[start], alist[center] = alist[center], alist[start]
if alist[start] > alist[end]:
alist[start], alist[end] = alist[end], alist[start]
if alist[center] > alist[end]:
alist[center], alist[end] = alist[end], alist[center]
# 小技巧
# 将pivot藏到右边,这样考虑子集划分时
# 只需要考虑从 A[start+1] 到 A[end–2]即可
alist[center], alist[end - 1] = alist[end - 1], alist[center]
return alist[end - 1] # 返回 pivot
2、 如何基于主元来划分子集?
划分子集,即将列表划分成小于 (等于) 主元和大于 (等于) 主元的两部分,小于 (等于) 的放到主元左边,大于(等于)的数放在主元右边。
使用辅助空间 来实现 partition 非常简单。
如果 不使用辅助空间,原地(in-place) 划分子集的话,常见的有以下两种:
- 一个指针遍历列表,把小于pivot的数通过交换的方式放到左边
- 双指针,从两边出发向中间移动,交换相对于主元位于错误侧的数
不管是哪种方法,划分子集时都需要 先把 pivot 换到列表的头部位置(当然也可以是尾部),再对列表的剩余部分进行划分,这是为了避免把主元的位置搞丢了。
① 同向双指针,把小于pivot的数放到左边
定义一个small
指针,用于追踪小于 pivot 的数的右边界(指向最后一个小于pivot的数)
def partition(arr, start, end):
pivot = arr[start] # 这里选取第一个数作为pivot
# 先把pivot换到列表头部
# 这里直接取第一个数作为pivot所以省去了这步
# 找到小于pivot的数就丢给small
small = start
for i in range(start + 1, end + 1):
if arr[i] < pivot:
small += 1
arr[i], arr[small] = arr[small], arr[i]
# 此时small指向的位置就是划分点的位置
# 将pivot换到划分点
arr[small], arr[start] = arr[start], arr[small]
# 返回划分点的位置
return small
② 相向双指针,交换相对于主元位于错误侧的数
如下图所示
- 在列表除开start位置的剩余项的头部和尾部定义两个指针。
- 重复以下过程:
- 移动左指针, 找到大于 (等于) 主元的值
- 移动右指针, 找到小于 (等于) 主元的值
- 交换两个 相对于最终划分点位于错误侧 的项,交换该两项
- 当左右指针 交叉(left > right) 时, 停止循环【如果重合就停止循环,此时重合点的项到底是大于 pivot 还是小于 pivot 是不确定的,我们无法确定分界点的位置】。
- 此时,右指针所处的位置即为 主元应该在的位置(分界点)。我们将主元和分界点的内容交换,此时, 分界点左侧的所有项都小于等于主元, 分界点右侧的所有项都大于等于主元。
def partition(alist, start, end):
pivot = alist[start]
left = start + 1
right = end
while left <= right: # 当左右指针交叉(left > right)时,结束循环
while left <= right and alist[left] < pivot: # left指针找大于等于pivot的数
left += 1
while left <= right and pivot < alist[right]: # right指针找小于等于pivot的数
right -= 1
if left <= right:
alist[left], alist[right] = alist[right], alist[left]
left += 1
right -= 1
# 此时right所指向的位置就是划分点的位置
# 将pivot换到划分点的位置
alist[start], alist[right] = alist[right], alist[start]
# 返回划分点的位置
return right
思考:对于等于主元的元素,为什么我们选择停下来交换?
对于等于主元的元素,实际上我们有两种选择:
1、停下来交换?
- 缺点:极端情况下,列表中的所有元素都是同一个数,会导致很多无用交换。
- 优点:最后两个指针会停在比较中间的位置,也就是每一次递归时,序列能够基本上被等分成两个等长序列
2、无视,继续移动指针?
- 优点:极端情况下,列表元素都是同一个数的情况,可以避免很多无用交换
- 缺点:每次主元都会落到边缘处
两者相比较,我们更倾向于 1
①②两种划分方式,我们一般用②不用①,因为它对于 列表中有多个等于主元的项 的情况,它可以更划分地更平均,所以在对于列表中有多个重复元素的情况下效率更高,而①的效率则有可能 退化 为O(n^2)。
Python代码
参考:python-data-structure
简单取第一项作为主元
def quickSort(alist):
'''统一函数接口'''
quickSortHelper(alist, 0, len(alist)-1)
def quickSortHelper(alist, start, end):
'''核心递归函数'''
if start >= end: # 递归结束条件:输入列表为空列表或单元素列表时,不作处理
return
# 输入列表包含两个或两个以上元素时
splitPoint = partition(alist, start, end)
quickSortHelper(alist, start, splitPoint - 1)
quickSortHelper(alist, splitPoint + 1, end)
def partition(alist, start, end):
'''划分函数'''
pivot = alist[start]
left = start + 1
right = end
while left <= right: # 当左右指针交叉(left > right)时,结束循环
while left <= right and alist[left] < pivot: # left指针找大于等于pivot的数
left += 1
while left <= right and pivot < alist[right]: # right指针找小于等于pivot的数
right -= 1
if left <= right:
alist[left], alist[right] = alist[right], alist[left]
left += 1
right -= 1
# 此时right所指向的位置就是划分点的位置
# 将pivot换到划分点的位置
alist[start], alist[right] = alist[right], alist[start]
# 返回划分点的位置
return right
alist = [54,26,93,17,77,31,44,55,20]
quickSort(alist)
print(alist)
复杂度分析
时间复杂度:
- 最好复杂度: O ( n l o g n ) O(nlogn) O(nlogn)
- 最坏复杂度: O ( n 2 ) O(n^2) O(n2)
补充
1、三路快排
面对 有大量重复元素的数据 时,我们可以进一步优化,使用三路快排。
三路快排要做的事情,其实就是将数组分成三部分:小于主元,等于主元和大于主元,然后对小于主元和大于主元的两部分递归进行快速排序。
具体操作:
定义三个指针,left, right, index,其中
- left 用于 追踪 小于主元项 的 右边界
- right 用于追踪 大于主元项 的左边界
- index 从左到右扫描整个数组
- 碰到 小于主元项 就丢给 left
- 碰到 大于主元项 就丢给 right
- 碰到 等于主元项 就跳过
代码:
```python
def three_ways_partition(alist, start, end):
pivot = alist[start]
left = start
right = end
index = start
while index <= right: # 循环结束条件:index > right
if alist[index] < pivot:
alist[index], alist[left] = alist[left], alist[index]
left += 1
index += 1
elif pivot < alist[index]:
alist[index], alist[right] = alist[right], alist[index]
right -= 1 # 注意这里index不需要++
else:
index += 1
# 返回左右子列的边界位置
return left - 1, right + 1
在很多语言的标准库中,排序接口使用的就是三路快排,比如Java。
2、小规模数据的处理:快速排序和插入排序的结合
参考:浙江大学数据结构MOOC
因为快速排序使用了递归,会占用额外的堆栈空间,进栈出栈需要时间。对于小规模的数据(比如N不到100),使用快速排序可能还不如插入排序来的快。
改进方案
- 当递归的数据规模充分小,则停止递归,直接调用简单排序【比如插入排序】
- 在程序中定义一个 Cutoff (阈值)
C语言实现
def insertionSort(alist, start, end):
for index in range(start, end + 1):
currentvalue = alist[index]
position = index
while position > 0 and alist[position-1] > currentvalue:
alist[position] = alist[position-1]
position = position - 1
alist[position] = currentvalue
def median3(alist, start, end):
'''三数取中位数'''
center = (start + end) // 2
# 排序,使得A[start] <= A[Center] <= A[end]
if alist[start] > alist[center]:
alist[start], alist[center] = alist[center], alist[start]
if alist[start] > alist[end]:
alist[start], alist[end] = alist[end], alist[start]
if alist[center] > alist[end]:
alist[center], alist[end] = alist[end], alist[center]
# 将pivot藏到右边
alist[center], alist[end - 1] = alist[end - 1], alist[center]
return alist[end - 1] # 返回 pivot
def partition(alist, start, end):
'''划分函数'''
pivot = median3(alist ,start, end)
left = start + 1
right = end - 2
while 1:
while left <= right and alist[left] < pivot:
left += 1
while left <= right and pivot < alist[right]:
right -= 1
if left < right:
alist[left], alist[right] = alist[right], alist[left]
left += 1
right -= 1
else:
break
alist[left], alist[end-1] = alist[end-1], alist[left]
return left
def quickSortHelper(alist,start,end):
'''核心递归函数,也可以叫xxxCore'''
cutoff = 10
if cutoff < end - start:
splitPoint = partition(alist, start, end)
quickSortHelper(alist, start, splitPoint-1)
quickSortHelper(alist, splitPoint+1, end)
else:
insertionSort(alist,start,end)
def quickSort(alist):
'''统一函数接口'''
if alist == []:
return []
quickSortHelper(alist, 0, len(alist) - 1)
alist = list(range(1000,0,-1))
quickSort(alist)
print(alist)
线性复杂度的排序算法
八、基数排序
总结:各排序算法比较
我们主要从以下几个维度来评价排序算法的好坏:
- 时间复杂度。包括平均时间复杂度和最坏空间复杂度
- 空间复杂度
- 稳定性。即算法是否会改变序列中2个相等的数的相对位置
《problem-solving-with-algorithms-and-data-structure-using-python》