最近在慕课上学习《数据结构》课程,在这里记录一下利用python实现经典排序算法的过程。
1. 排序算法简单介绍
1.1 算法分类
1.2 算法复杂度
注意: 上表中,虽然堆排序和快速排序的平均复杂度都是O(NlogN),但是堆排序的系数比快速排序大得多。
《数据结构》课程的一道思考题:挑战名企面试官
某名企的面试题有一道是这样的:
从1000个数字中找出最大的10个数字,最快的算法是——
A. 归并排序 B. 快速排序 C. 堆排序 D. 选择排序
答案是C。但是这个答案真的对吗?
在网上看到的一个回答:
首先归并排序是肯定被排除的,因为多了空间开销不说,不到最后一步归并完成,谁也不敢确定最大的10个数在哪里。
然而剩下的三种算法到底谁快,可真说不好 —— 因为才1000个数字,这个规模实在是太小啦!
当我们根据复杂度比较各种算法快慢的时候,一定要记得,这个比较只有当N很大的时候才科学。虽然根据复杂度分析,堆排序应该是最快的那个(在建好堆以后,只要10步就得到前10个最大数),但实际上,且不说写个堆排序有多么麻烦,就建立堆的过程也并不很快—— 是的,复杂度是O(N),也就是某常数乘以N,但是那个“某常数”可不小呢!所以你写个堆排序去完成这个任务,很可能不如写一句qsort然后直接取前10个数快,甚至可能不如选择排序快!
1.3 相关概念
没有一种排序算法是在任何情况下都表现最好的
稳定: 如果a原本在b前面,而a=b,排序之后a仍然在b的前面。
不稳定: 如果a原本在b的前面,而a=b,排序之后 a 可能会出现在 b 的后面。
时间复杂度: 对排序数据的总的操作次数。反映当n变化时,操作次数呈现什么规律。
空间复杂度: 是指算法在计算机内执行时所需存储空间的度量,它也是数据规模n的函数。
逆序对: 对于下标i<j,如果A[i]>A[j],则称(i,j)是一对逆序对(inversion)【前段时间学习离散数学的时候见过这个概念】
交换2个相邻元素正好可以消去1个逆序对!
定理: 任意N个不同元素组成的序列平均具有 N ( N − 1 ) 4 \frac{N(N-1)}{4} 4N(N−1)个逆序对
定理: 任何仅以交换相邻元素来排序的算法,其平均时间复杂度为 Ω ( N 2 ) \Omega(N^2) Ω(N2) 这两个定理意味着:要提高算法效率,必须:
- 每次消去不止一个逆序对!
- 每次交换相隔较远的2个元素!
注: 下面所有排序的实现均默认从小到大。
2. 简单排序
2.1 简单选择排序(Select Sort)
2.1.1 算法描述
工作原理
首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
算法特点
- 运行时间与输入无关
一个已经有序的数组或者数组内元素全部相等的数组和一个元素随机排列的数组所用的排序时间竟然一样长!而其他算法会更善于利用输入的初始状态,如插入排序对于基本有序的数组就比较高效。 - 数据移动是最少的
选择排序的交换次数和数组大小的关系是线性关系,选择排序无疑是最简单直观的排序。
动图演示
2.1.2 代码实现
def select_sort(arry):
n = len(arry)
for i in range(n):
indx = i
for j in range(i+1,n):
if arry[j] < arry[indx] :
indx = j #记录最小值下标
arry[indx],arry[i] = arry[i], arry[indx] #则交换两者
return arry
2.1.3 算法分析
表现最稳定的排序算法之一,因为无论什么数据进去都是O(n2)的时间复杂度,所以用到它的时候,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间了吧。理论上讲,选择排序可能也是平时排序一般人想到的最多的排序方法了吧。
2.2 冒泡排序(Bubble Sort)
2.2.1 算法描述
工作原理
重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。
动图演示
2.2.2 代码实现
def bubble_sort(arry):
n = len(arry) #获得数组的长度
for i in range(n):
for j in range(0, n-i+1):
if arry[j] > arry[j+1] : #如果前者比后者大
arry[j],arry[j+1] = arry[j+1], arry[j] #则交换两者
return arry
2种优化方案
(1)某一趟遍历如果没有数据交换,则说明已经排好序了,因此不用再进行迭代了。用一个标记记录这个状态即可。
def bubble_sort(arry):
n = len(arry)
for i in range(n):
flag = 0
for j in range(0, n-i+1):
if arry[j] > arry[j+1] :
arry[j],arry[j+1] = arry[j+1], arry[j]
flag = 1
if flag == 0: #某一趟遍历如果没有数据交换,则说明已经排好序了,跳出循环
break
return arry
(2)记录某次遍历时最后发生数据交换的位置,这个位置之后的数据显然已经有序,不用再排序了。因此通过记录最后发生数据交换的位置就可以确定下次循环的范围了。
def bubble_sort(arry):
n = len(arry)
k = n - 1 #k为循环的范围,初始值n-1
for i in range(n):
flag = 0
for j in range(0, k):
if arry[j] > arry[j+1] :
arry[j],arry[j+1] = arry[j+1], arry[j]
k = j #记录最后交换的位置
flag = 1
if flag == 0:
break
return arry
2.3 简单插入排序(Insert Sort)
2.3.1 算法描述
工作原理
对于每个未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。举个大家熟悉的例子:打牌时,假设拿到手的牌要按顺序排列,每摸到一张牌,将它插入到正确的位置,直至摸牌结束。这就是插入排序的过程。
算法步骤
1.从第一个元素开始,该元素可以认为已经被排序
2.取出下一个元素,在已经排序的元素序列中从后向前扫描
3.如果被扫描的元素(已排序)大于新元素,将该元素后移一位
4.重复步骤3,直到找到已排序的元素小于或者等于新元素的位置
5.将新元素插入到该位置后
6.重复步骤2~5
动图演示
2.3.2 代码实现
def insertion_sort(ary):
n = len(ary)
for P in range(1,n):
temp = ary[P] #取出一个元素
i = P
while i > 0 and ary[i-1] > temp:
ary[i] = ary[i-1] #移出空位
i -= 1
ary[i] = temp #取出的元素插入
return ary
2.3.3 算法分析
如果序列基本有序,则插入排序简单且高效
- 时间复杂度 O(n2)
- 空间复杂度O(1)
3. 快速排序(Quick Sort)
3.1 算法描述
工作原理
每趟找一个主元,通过一趟排序将待排记录分隔成独立的两部分,其中比基准小的在一侧,比基准大的在另一侧,则可分别对这两部分记录继续进行排序,以达到整个序列有序。
快速排序采用了分治法 的思想。
图例演示
算法步骤
1.从数列中挑出一个元素,称为 “主元”(pivot);
2.重新排序数列,所有元素比主元值小的摆放在主元前面,所有元素比主元值大的摆在主元的后面(相同的数可以到任一边)。在这个分区退出之后,该主元就处于数列的中间位置。这个称为分区(partition)操作;
3.再对左右区间递归执行1~2步,直至各区间只有一个数。
3.2 代码实现
def quick_sort(ary):
return qsort(ary, 0, len(ary) - 1)
def qsort(ary, start, end):
if start >= end:
return
else:
left = start
right = end
key = start # 划分参考数索引,默认为第一个数为基准数,可优化
while left < right:
while left < right and ary[right] >= ary[key]: # 如果列表后边的数,比基准数大或相等,则前移一位直到有比基准数小的数出现
right -= 1
while left < right and ary[left] < ary[key]: # 如果列表前边的数,比基准数小或相等,则后移一位直到有比基准数大的数出现
left += 1
ary[left], ary[right] = ary[right], ary[left] # 此时已找到一个比基准大的数,和一个比基准小的数,将他们互换位置
# 当从两边分别逼近,直到两个位置相等时结束,将左边小的同基准进行交换
ary[key],ary[left] = ary[left],ary[key]
qsort(ary, start, left - 1)
qsort(ary, left + 1, end)
return ary
3.3 算法分析
最好/最坏情况
快速排序算法的最好情况:主元每次都能将待排序列中分,时间复杂度O(nlogn)
最坏的情况:主元每次都在待排序列的一端,如下图所示,时间复杂度
O
(
n
2
)
O(n^2)
O(n2)
选主元的方法
- 随机选取主元:rand()函数时间花销不便宜
- 取头、中、尾的中位数:(这里有个小技巧,具体参见陈越《数据结构》的10.1节)
元素相等的情况怎么办
- 停下来交换:时间复杂度O(nlogn)
- 不理它,继续移动指针:指针会走到一端,时间复杂度 O ( n 2 ) O(n^2) O(n2)
小规模数据的处理
因为快速排序是用递归实现的,对于小规模数据,反复申请/释放空间的花销占比大,总体速度可能还不如插入排序快。
解决方案:
定义一个cutoff阈值,当递归的数据规模充分小时,则停止递归,直接调用简单排序(例如插入排序)。
4. 希尔排序(Shell Sort)
希尔排序的实质就是分组插入排序,该方法又称缩小增量排序,因DL.Shell于1959年提出而得名。
4.1 算法描述
工作原理
先将整个待排元素序列分割成若干个子序列(由相隔某个“增量”的元素组成的)分别进行直接插入排序,然后依次缩减增量再进行排序,待整个序列中的元素基本有序(增量足够小)时,再对全体元素进行一次直接插入排序。因为直接插入排序在元素基本有序的情况下(接近最好情况),效率是很高的,因此希尔排序在时间效率上有较大提高。
动图演示
4.2 代码实现
def shell_sort(ary):
n = len(ary)
gap = round(n / 2) #增量
# 双杠用于整除(向下取整),在python直接用 “/” 得到的永远是浮点数,
# 用round()得到四舍五入值
while gap >= 1:
for P in range(gap,n): # 到这里与插入排序一样了
temp = ary[P]
i = P
while i >= gap and ary[i-gap] > temp:
ary[i] = ary[i-gap]
i -= gap
ary[i] = temp
gap = round(gap / 2)
return ary
4.1 算法分析
增量序列
希尔排序的核心在于间隔序列的设定。下图是最坏的情况,时间复杂度
O
(
n
2
)
O(n^2)
O(n2)
5. 堆排序(Heap Sort)
5.0 预备知识
堆排序是利用堆这种数据结构而设计的一种排序算法,堆排序是一种选择排序,它的最坏,最好,平均时间复杂度均为O(nlogn),它也是不稳定排序。首先简单了解下堆结构。
堆
堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。如下图:
同时,我们对堆中的结点按层进行编号,将这种逻辑结构映射到数组中就是下面这个样子:
该数组从逻辑上讲就是一个堆结构,我们用简单的公式来描述一下堆的定义就是:
-
大顶堆:arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2]
-
小顶堆:arr[i] <= arr[2i+1] && arr[i] <= arr[2i+2]
5.1 算法描述
工作原理
将待排序序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点【数组中第一个元素】。将其与末尾元素进行交换,此时末尾就为最大值。然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。如此反复执行,便能得到一个有序序列了
动图演示
算法步骤
(1) 构造初始堆。将给定无序序列构造成一个大顶堆(一般升序采用大顶堆,降序采用小顶堆)。
a.假设给定无序序列结构如下
b.此时我们从最后一个非叶子结点开始(叶结点自然不用调整,第一个非叶子结点 arr.length/2-1=5/2-1=1,也就是下面的6结点),从左至右,从下至上进行调整。
c.找到第二个非叶节点4,由于[4,9,8]中9元素最大,4和9交换。
这时,交换导致了子根[4,5,6]结构混乱,继续调整,[4,5,6]中6最大,交换4和6。
此时,我们就将一个无需序列构造成了一个大顶堆。
(2) 将堆顶元素与末尾元素进行交换,使末尾元素最大。然后继续调整堆,再将堆顶元素与末尾元素交换,得到第二大元素。如此反复进行交换、重建、交换。
a.将堆顶元素9和末尾元素4进行交换
b.重新调整结构,使其继续满足堆定义
c.再将堆顶元素8与末尾元素5进行交换,得到第二大元素8.
d.后续过程,继续进行调整,交换,如此反复进行,最终使得整个序列有序
再简单总结下堆排序的基本思路:
1.构造最大堆(Build_Max_Heap):若数组下标范围为0~n,考虑到单独一个元素是大根堆,则从下标n/2开始的元素均为大根堆。于是只要从n/2-1开始,向前依次构造大根堆,这样就能保证,构造到某个节点时,它的左右子树都已经是大根堆。
2.将堆顶元素与末尾元素交换,将最大元素"沉"到数组末端;
3.重新调整结构,使其满足堆定义,然后继续交换堆顶元素与当前末尾元素,反复执行调整+交换步骤,直到整个序列有序。
5.2 代码实现
def buildMaxHeap(arr):
for i in range(len(arr)//2-1,-1,-1):
heapify(arr,i)
def heapify(arr,i):
left = 2*i+1
right = 2*i+2
largest = i
if left < arrLen and arr[left] > arr[largest]:
largest = left
if right < arrLen and arr[right] > arr[largest]:
largest = right
if largest != i:
swap(arr, i, largest)
heapify(arr, largest)
def swap(arr, i, j):
arr[i], arr[j] = arr[j], arr[i]
def heap_sort(arr):
global arrlen
arrlen = len(arr)
buildMaxHeap(arr)
for i in range(len(arr)-1,0,-1):
swap(arr,0,i)
arrlen -= 1
heapify(arr,0)
return arr
6. 归并排序(Merge Sort)
6.1 算法描述
工作原理
归并(Merge)排序法是将两个(或两个以上)有序表合并成一个新的有序表,即把待排序序列分为若干个子序列,每个子序列是有序的。然后再把有序子序列合并为整体有序序列。
归并排序是一种稳定的排序方法。和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是O(nlogn)的时间复杂度。代价是需要额外的内存空间。
动图演示
6.2 代码实现-递归版
def merge_sort(arr):
n = len(arr)
if n <= 1:
return arr
middle = n // 2
left = merge_sort(arr[0:middle])
right = merge_sort(arr[middle:])
return merge(left, right)
def merge(left, right):
'''合并操作'''
result = []
while left and right:
if left[0] <= right[0]:
result.append(left.pop(0))
else:
result.append(right.pop(0))
#while循环出来之后 说明其中一个数组没有数据了,我们把另一个数组添加到结果数组后面
while left:
result.append(left.pop(0))
while right:
result.append(right.pop(0))
return result
6.3 代码实现-迭代版
理解并写归并排序迭代版本的程序花费了半天的时间,还是感觉怪怪的
import numpy as np
def merge_sort(arr):
n = len(arr)
tmpA = np.zeros(n,dtype=int)
length = 1 #初始化子序列长度
while length<n:
i = 0
while(i <= n-2*length):
merge(arr, tmpA, i, i+length, i+2*length-1)
i += 2*length
if i+length < n: #剩下2个子列
merge(arr, tmpA, i, i+length, n-1)
length *= 2
return arr
def merge(arr,tmpA,L,R,end):
'''合并操作'''
leftEnd = R - 1#左边终点位置
tmp = L #有序序列的起始位置
numElements = end - L + 1
while L <= leftEnd and R <= end:
if arr[L] <= arr[R]:
tmpA[tmp] = arr[L]
tmp += 1
L += 1
else:
tmpA[tmp] = arr[R]
tmp += 1
R += 1
while L <= leftEnd: #复制左边剩下的
tmpA[tmp] = arr[L]
tmp += 1
L += 1
while R <= end: #复制右边剩下的
tmpA[tmp] = arr[R]
tmp += 1
R += 1
for i in range(0,numElements): #将tmpA[]复制回arr[]
arr[end] = tmpA[end]
end -= 1
7. 线性时间非比较类排序
待补充