排序算法分析
常见(内)排序算法分类:
- 比较排序
- 插入排序/选择排序/冒泡排序
- 归并排序
- 快速排序
- 堆排序
- Shell排序
- 非比较排序
- 计数排序
- 基数排序
- 桶排序
排序的稳定性&复杂度分析
稳定性是针对相同大小的元素,如果排序算法不改变相同大小的元素原来的顺序,则算法是稳定的。也就是说,稳定的排序算法会让原本有相同键值的记录维持相同的次序。
时间复杂度:执行时间取决于比较次数和交换次数
空间复杂度:取决于消耗的额外内存空间(auxiliary space,相对于in-place来讲)
- 使用堆栈、记录表
- 使用链表(指针)、数组(索引)来访问元素
- 排序元素的副本
稳定排序:
- 插入/冒泡,
O(n2)
- 归并,
O(nlogn)
,需要
O(n)
额外空间
- 原地归并,
O(n2)
- 计数,
O(n+k)
,需要
O(K)
额外空间
- 桶,
O(n)
,需要
O(k)
额外空间
- 二叉树排序,期望
O(nlogn)
,最坏
O(n2)
,需要
O(n)
额外空间
不稳定排序:
- 选择,
O(n2)
- Shell排序
- 堆排序,
O(nlogn)
- 快排,期望
O(nlogn)
,最坏
O(n2)
说明:如无例外,以下的算法说明和实现均以升序排序为例。
插入排序
插入排序可以类比打牌:每次摸到一张牌,会和左手上排好序的牌,从后往前逐一比较大小,直到找到合适的位置(第一个大于的元素之后)插好。
主要步骤
- 循环遍历元素A[i]
- 将A[i]和其之前的元素A[j]循环逐一比较大小,只要当前A[j]大于A[i],A[j]就往后移动一位(覆盖下一个值),并继续比较A[i]和再前一个A[j]的大下
- 直到第一个A[j]小于A[i],跳出循环
- 此时A[j]的后一位,就是A[i]要放的位置
"""
Insert Sort
@author: Shangru
@date: 2015/03/11
"""
def insert_sort(A):
for i in range(len(A)):
cur = A[i]
j = i - 1 # j之前的已排好
while j >= 0 and A[j] > cur:
# 当前数小于排好的,排好的后移一位,空出位置
A[j + 1] = A[j]
j = j - 1
A[j + 1] = cur
return A
复杂度分析
对于小规模数据,插入排序是快速的原地排序算法。
- 时间:最好 O(n) ,最坏 O(n2) ,平均 O(n2)
- 空间: O(1)
- 稳定排序
选择排序
选择排序也可以类比为打扑克牌:每次从未排序的牌里选择抽出最小的那张牌,插到左边已排好的牌末尾。
主要步骤
- 循环遍历每一个元素A[i]
- 在每次循环内,逐个访问未排序的元素A[j],比较A[i]和未排序元素A[j]的大小
- 如果当前元素A[i]大于A[j],则交换两个元素。比较完这一趟后,未排序元素中的最小值会放到A[i],成为已排好的元素。
- 继续访问下一个A[i],对比未排序的A[j]
def select_sort(A):
n = len(A)
for i in range(n - 1):
for j in range(i + 1, n):
if A[j] < A[i]:
A[i], A[j] = A[j], A[i]
return A
复杂度分析
- 时间:最好 O(n2) ,最坏 O(n2) ,平均 O(n2)
- 空间: O(1)
- 不稳定排序
冒泡排序
主要原理
每次比较相邻两个元素,如果存在逆序则互换两个元素的位置,使得小数在前,大数在后。类似于冒泡,每次遍历完后,较大的元素都会往后移(“沉”)。重复n次可以让数组有序。
主要步骤
def bubble_sort(A):
n = len(A)
for i in xrange(n):
for j in xrange(n - 1, i, -1):
if A[j] < A[j - 1]:
A[j], A[j - 1] = A[j - 1], A[j]
return A
print bubble_sort([9, 8, 7, 6, 5, 4])
复杂度分析
- 时间:最好 O(n) ,最坏 O(n2) ,平均 O(n2)
- 空间: O(1)
- 稳定排序
对于原始的算法,有2种优化方法:
1. 如果某一趟遍历,没有发生数据交换,则不需要再循环访问。设flag标识,跳出循环。
2. 记录某次遍历时最后发生数据交换的位置,这个位置之后的数据有序,不用再排序。记录这个位置,可以确定下次循环的范围。
实现方法参考:https://github.com/wuchong/Algorithm-Interview/blob/master/Sort/python/BubbleSort.py
希尔排序
Shell排序(Donald Shell, 1959)本质上是分组插入排序,但却是非稳定排序。
主要步骤
将输入待排序元素分成多个组(相隔gap个数的元素组成),分别对各组进行插入排序后,按原来对应的方式拼接回来。然后依次减小gap,再进行排序。直到整个序列基本有序,gap足够小,为1时就变成插入排序,这可以保证数据一定会被排序。
参考Wikipedia(https://en.wikipedia.org/wiki/Shellsort)的例子:
假设有一序列[ 13 14 94 33 82 25 59 94 65 23 45 27 73 25 39 10 ]
,以步长(增量)为5,每隔5个取元素,得到5列分组:
13 14 94 33 82
25 59 94 65 23
45 27 73 25 39
10
对每组(即每列)插入排序,得到
10 14 73 25 23
13 27 94 33 39
25 59 94 65 82
45
将四行数字拼接一起,得到[ 10 14 73 25 23 13 27 94 33 39 25 59 94 65 82 45 ]
,然后递减步长,以3为步长排序:
10 14 73
25 23 13
27 94 33
39 25 59
94 65 82
45
排序后得到:
10 14 13
25 23 33
27 25 59
39 65 73
45 94 82
94
再以1为步长进行排序即可。
代码实现
def shell_sort(A):
n = len(A)
gap = int(round(n / 2)) # initial gap
while gap > 0:
for i in xrange(gap, n):
temp = A[i] # insert sort
j = i
while j >= gap and A[j - gap] > temp:
A[j] = A[j - gap]
j -= gap
A[j] = temp
gap = int(round(gap / 2)) # update gap
return A
复杂度分析
Shell排序的复杂度与gap大小有关:
- 当gap取n/2^i
,最差复杂度为
O(n2)
- 当gap取2^k - 1
,最差为
O(n1.5)
- 当gap取2^i*3^j
,最差为
O(nlog2n)
归并排序
归并排序是分治法的经典使用:
1. 分解:把原问题(n个元素的排序)分解为两个子问题(n/2个元素的排序),
2. 递归求解:归并法(Merge)对两个子序列递归的排序
3. 合并:再回到上层,合并这两个子问题的结果(合并两个已排序的子序列)
优势:
1. 归并对于连续存储的数据结构有优势(顺序地merge),如链表(只需要
O(1)
的额外空间开销),链表不适合随机访问,此时归并排序远优于快排和堆排序;而快排需要随机读取,对于RAM-based存储有优势。
2. 归并可以不用递归实现。
3. 稳定排序
劣势:
1. 和堆排序
O(1)
比,额外空间往往是
O(n)
"""
Merge Sort
@author: Shangru
"""
def MergeSort(A, l, r):
"""
Sort A[l..r], divide into sorting A[l..mid], A[mid+1..r]
"""
if l < r:
mid = (l + r) / 2
MergeSort(A, l, mid)
MergeSort(A, mid + 1, r)
Merge(A, l, mid, r)
return A
def Merge(A, l, mid, r):
"""
Merge two sorted arrays into one
A[l..mid], A[mid+1..r] -> A[l..r]
Need extra space left & right
"""
leftlen = mid - l + 1
rightlen = r - mid
left = A[l : mid + 1]
right = A[mid + 1: r + 1]
print left, right
i, j, k = 0, 0, l
while i < leftlen and j < rightlen:
if left[i] <= right[j]:
A[k] = left[i]
i += 1
else:
A[k] = right[j]
j += 1
k += 1
if i == leftlen:
A[k : r + 1] = right[j : rightlen]
else:
A[k : r + 1] = left[i : leftlen]
return A
if __name__ == '__main__':
print MergeSort([9,8,7,6,5,4,3,2,1], 0, 8)
复杂度分析
- 时间:最优 O(nlogn) ,最差 O(nlogn) ,平均 O(nlogn)
- 空间:最差 O(n)
快速排序
快排主要是划分函数partition加分治法,通过划分函数把原序列划分成两个子序列,一个全部比基准数小,另一个全部比基准数大,分别对两个子序列递归调用自身快排。
优势:
1. 实际应用中比其他
O(nlogn)
算法快,因为内循环可以在大多数architecture和真实数据中高效实现,可以减少
O(n2)
的出现概率
2. 原地排序,额外空间复杂度
O(1)
劣势:
1. 递归算法
2. 不是稳定算法
"""
Quick Sort
@date: 2015/03/11
"""
def quick_sort(A, l, r):
if l < r:
pivot = partition(A, l, r)
quick_sort(A, l, pivot - 1)
quick_sort(A, pivot + 1, r)
else:
return A
def partition(A, l, r):
x = A[r]
pivot = l - 1
for i in xrange(l, r + 1):
if A[i] < x:
pivot += 1
A[pivot], A[i] = A[i], A[pivot]
A[pivot + 1], A[r] = A[r], A[pivot + 1]
return pivot + 1
划分函数partition的时间复杂的为 Θ(n) 。最佳划分是平衡划分,T(n)满足 T(n)<=2T(n/2)+Θ(n) ,由主定理可以求得 T(n)= O(nlogn) 。但是当原序列相对有序时,每次选取最右边元素作为基准来划分,极端情况下会导致得到的划分不对称的两个子序列,一个没有元素,另一个只比原序列少1个。如果快排的每一次递归调用都出现不对称划分,这种划分下的T(n)满足 T(n) = T(n-1) + \Theta(n) ,算法的时间复杂度达到 O(n^2) 。采用随机采样划分元素改进原来算法,平均情况下的期望时间是 O(nlogn) ,但在最怀情况下,随机快排和普通的快排相同,为 \Theta(n^2)$。
import random
def random_quick_sort(A, l, r):
if l < r:
pivot = random_partition(A, l, r)
random_quick_sort(A, l, pivot - 1)
random_quick_sort(A, pivot + 1, r)
else:
return
def random_partition(A, l, r):
rand = random.randint(l, r)
A[rand], A[r] = A[r], A[rand]
pivot = l - 1
for i in xrange(l, r + 1):
if A[i] < A[r]:
pivot += 1
A[pivot], A[i] = A[i], A[pivot]
A[pivot + 1], A[r] = A[r], A[pivot + 1]
return pivot + 1
复杂度分析
- 时间:最优 O(nlogn) ,最差 O(n2) ,平均 O(nlogn)
- 空间: O(n) , O(logn) (Sedgewick 1978)