经典排序算法
最近在复习以前学过的基本算法,并且用python实现它们。为了加深记忆以及以后回顾,特开此博客。所有内容仅供参考。
根据在排序过程中待排序的记录是否全部被放置在内存中,排序分为:内排序和外排序。
- 内排序:在排序整个过程中,待排序的所有记录全部被就置在内存中。
- 外排序:由于排序的记录个数太多, 不能同时放置在内存,整个排序过程需要在内外存之间多次交换数据才能进行。
在这里主要只介绍内排序的多种方法。
内排序算法的性能
- 时间性能: 排序算法的时间开销是衡量其好坏的最重要的标志,高效率的内排序算法应该是具有尽可能少的关键字比较次数和尽可能少的记录移动次数。
- 辅助空间: 执行算法所需要的辅助存储空间,包括存放待排序所占用的存储空间以及执行算法所需要的其他存储空间。
- 算法的复杂度: 算法本身的复杂度。
冒泡排序 (Bubble Sort)
冒泡排序 一种交换排序,基本思想是:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。
例如:待排序的关键字为{9,1,5,8,3,7,4,6,2}。当i=1时,如下图所示:
之后以此类推。
python代码实现如下:
// bubble sort
#!/usr/bin/env python
list_uo = [9, 1, 5, 8, 3, 7, 4, 6, 2]
def bubble_sort(li):
n = len(li)
count = 0
for i in range(n-1):
for j in range(n-1, i, -1):
count += 1
if li[j] < li[j-1]:
li[j], li[j-1] = li[j-1], li[j]
print(li, count)
bubble_sort(list_uo)
运行结果如下:
[1, 9, 2, 5, 8, 3, 7, 4, 6] 8
[1, 2, 9, 3, 5, 8, 4, 7, 6] 15
[1, 2, 3, 9, 4, 5, 8, 6, 7] 21
[1, 2, 3, 4, 9, 5, 6, 8, 7] 26
[1, 2, 3, 4, 5, 9, 6, 7, 8] 30
[1, 2, 3, 4, 5, 6, 9, 7, 8] 33
[1, 2, 3, 4, 5, 6, 7, 9, 8] 35
[1, 2, 3, 4, 5, 6, 7, 8, 9] 36
可以看到上面的排序算法运行了36次,
(
1
+
n
)
n
/
2
(1+n)n/2
(1+n)n/2. 时间复杂度是
O
(
n
2
)
O(n^2)
O(n2).
但实际上,上述的冒泡排序算法是可以优化的。比如,待排序的数组为{2, 1, 3, 4, 5, 6, 7, 8, 9}:
[1, 2, 3, 4, 5, 6, 7, 8, 9] 8
[1, 2, 3, 4, 5, 6, 7, 8, 9] 15
[1, 2, 3, 4, 5, 6, 7, 8, 9] 21
[1, 2, 3, 4, 5, 6, 7, 8, 9] 26
[1, 2, 3, 4, 5, 6, 7, 8, 9] 30
[1, 2, 3, 4, 5, 6, 7, 8, 9] 33
[1, 2, 3, 4, 5, 6, 7, 8, 9] 35
[1, 2, 3, 4, 5, 6, 7, 8, 9] 36
可以看到,该数组在第一次i循环之后就已经排列完成,不再需要后面的工作。所以可以设置一个flag来判断是否还需要继续循环。
改进如下:
def bubble_sort(li):
n = len(li)
count = 0
flag = True
for i in range(n-1):
if flag:
flag = False
for j in range(n-1, i, -1):
count += 1
if li[j] < li[j-1]:
li[j], li[j-1] = li[j-1], li[j]
flag = True
print(li, count, flag, i, j)
[1, 2, 3, 4, 5, 6, 7, 8, 9] 8 True 0 1
[1, 2, 3, 4, 5, 6, 7, 8, 9] 15 False 1 2
[1, 2, 3, 4, 5, 6, 7, 8, 9] 15 False 2 2
[1, 2, 3, 4, 5, 6, 7, 8, 9] 15 False 3 2
[1, 2, 3, 4, 5, 6, 7, 8, 9] 15 False 4 2
[1, 2, 3, 4, 5, 6, 7, 8, 9] 15 False 5 2
[1, 2, 3, 4, 5, 6, 7, 8, 9] 15 False 6 2
[1, 2, 3, 4, 5, 6, 7, 8, 9] 15 False 7 2
改进后,该算法运行了15次。
简单选择排序(Simple Selection Sort)
简单选择排序是通过 ( n − i ) (n-i) (n−i)次关键字之间的比较,从 ( n − i + 1 ) (n-i+1) (n−i+1)中选出最小值,并和第 i ( 0 < = i < = n ) i(0<=i<=n) i(0<=i<=n)个值交换位置。
如下图所示:
此后以此类推。
Python代码实现如下:
#!/usr/bin/env python
def simple_selection_sort(li):
n = len(li)
count = 0
for i in range(n):
min_value = li[i]
mi = i
for j in range(i+1, n, 1):
count += 1
if li[j] < min_value:
min_value = li[j]
mi = j
if mi != i:
li[i], li[mi] = li[mi], li[i]
print('i =', i, ', min =', mi, ', min value is',
min_value, ',', li, ',', count)
list_uo = [9, 1, 5, 8, 3, 7, 4, 6, 2]
simple_selection_sort(list_uo)
i = 0 , min = 1 , min value is 1 , [1, 9, 5, 8, 3, 7, 4, 6, 2] , 8
i = 1 , min = 8 , min value is 2 , [1, 2, 5, 8, 3, 7, 4, 6, 9] , 15
i = 2 , min = 4 , min value is 3 , [1, 2, 3, 8, 5, 7, 4, 6, 9] , 21
i = 3 , min = 6 , min value is 4 , [1, 2, 3, 4, 5, 7, 8, 6, 9] , 26
i = 4 , min = 4 , min value is 5 , [1, 2, 3, 4, 5, 7, 8, 6, 9] , 30
i = 5 , min = 7 , min value is 6 , [1, 2, 3, 4, 5, 6, 8, 7, 9] , 33
i = 6 , min = 7 , min value is 7 , [1, 2, 3, 4, 5, 6, 7, 8, 9] , 35
i = 7 , min = 7 , min value is 8 , [1, 2, 3, 4, 5, 6, 7, 8, 9] , 36
i = 8 , min = 8 , min value is 9 , [1, 2, 3, 4, 5, 6, 7, 8, 9] , 36
可以看到简单选择排序的时间复杂度依然是 ( 1 + n ) n / 2 (1+n)n/2 (1+n)n/2, O ( n 2 ) O(n^2) O(n2). 虽然时间复杂度与冒泡排序相同,但实际上算法性能比冒泡排序略好。
直接插入排序(Straight Insertion Sort)
直接插入排序是将数据插入到一个已经排好顺序的有序表中,从而得到一个新的,记录数加1的有序表。
Python代码实现如下:
#!/usr/bin/env python
def insert_sort(li):
order_list = []
count = 0
for i in range(len(li)):
if order_list:
order_list.append(li[i])
for j in range(i-1, -1, -1):
if li[i] < order_list[j]:
count += 1
order_list[j], order_list[j+1] = order_list[j+1], order_list[j]
else:
order_list.append(li[i])
print(order_list, count)
list_uo = [9, 1, 5, 8, 3, 7, 4, 6, 2]
insert_sort(list_uo)
[9] 0
[1, 9] 1
[1, 5, 9] 2
[1, 5, 8, 9] 3
[1, 3, 5, 8, 9] 6
[1, 3, 5, 7, 8, 9] 8
[1, 3, 4, 5, 7, 8, 9] 12
[1, 3, 4, 5, 6, 7, 8, 9] 15
[1, 2, 3, 4, 5, 6, 7, 8, 9] 22
从上述代码中,可以看到,直接插入排序就是在一个有序的数组中,通过与该数组中的数从前到后的比较,将一个数插入到正确的位置,使得该数组继续保持有序。
当然,实际上,并不需要构建一个新的空数组。只需要在当前无序数组中按照上述思想排序即可。
所以代码改进如下:
def insert_sort(li):
count = 0
for i in range(1, len(li), 1):
value = li[i]
for j in range(i-1, -1, -1):
if value < li[j]:
count += 1
li[j], li[j+1] = li[j+1], li[j]
print(li, count)
list_uo = [9, 1, 5, 8, 3, 7, 4, 6, 2]
insert_sort(list_uo)
[1, 9, 5, 8, 3, 7, 4, 6, 2] 1
[1, 5, 9, 8, 3, 7, 4, 6, 2] 2
[1, 5, 8, 9, 3, 7, 4, 6, 2] 3
[1, 3, 5, 8, 9, 7, 4, 6, 2] 6
[1, 3, 5, 7, 8, 9, 4, 6, 2] 8
[1, 3, 4, 5, 7, 8, 9, 6, 2] 12
[1, 3, 4, 5, 6, 7, 8, 9, 2] 15
[1, 2, 3, 4, 5, 6, 7, 8, 9] 22
排序复杂度:最好的情况,待排序的数组本身有序,即只有比较,并没有移动。此时算法复杂度为 O ( n ) O(n) O(n)。最坏的情况,待排序数组本身为逆序, ∑ i = 2 n i = 2 + 3 + 4 + . . . + n = ( n + 2 ) ( n − 1 ) 2 \sum_{i=2}^ni=2+3+4+...+n=\frac{(n+2)(n-1)}{2} ∑i=2ni=2+3+4+...+n=2(n+2)(n−1)。此时,移动的次数为也达到最大值 ∑ i = 2 n ( i + 1 ) = 3 + 4 + 5 + . . . + ( n + 1 ) = ( n + 4 ) ( n − 1 ) 2 \sum_{i=2}^n(i+1)=3+4+5+...+(n+1)=\frac{(n+4)(n-1)}{2} ∑i=2n(i+1)=3+4+5+...+(n+1)=2(n+4)(n−1)。即算法复杂度为 O ( n 2 ) O(n^2) O(n2)。如果排序记录是随机的,那么根据概率相同的原则,平均比较和移动的复杂度为 n 2 4 \frac{n^2}{4} 4n2。比冒泡排序和简单选择排序性能要好。
堆排序(Heap Sort)
堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆。或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。
如果按照层序遍历的方式给结点从1 开始编号,则结点之间满足如下关系:
{ k i ≥ k 2 i k i ≥ k ( 2 i + 1 ) o r { k i ≤ k 2 i k i ≤ k ( 2 i + 1 ) , 1 ≤ i ≤ [ n 2 ] \begin{cases} k_i \geq\ k_{2i} \\ k_i \geq k_{(2i+1)} \end{cases} or \begin{cases} k_i \leq\ k_{2i} \\ k_i \leq k_{(2i+1)} \end{cases}, 1 \leq i \leq \bigg[\frac{n}{2}\bigg] {ki≥ k2iki≥k(2i+1)or{ki≤ k2iki≤k(2i+1),1≤i≤[2n]
对于一个完全二叉树来说,如果 i = 1 i=1 i=1,则结点 i i i是二叉树的根,无双亲 i i i如果 i > 1 i>1 i>1, 则其双亲是结点 i 2 \frac{i}{2} 2i。
堆排序(Heap sort)就是利用堆(假设利用大顶堆)进行排序的方法。它的基本思想是, 将待排序的序列构造成一个大顶堆。此时,整个序列的最大值就是堆顶的根结点。将它移走(其实就是将其与堆数组的末尾元素交换,此时末尾元素就是最大值) .然后将剩余的 n − 1 n - 1 n−1 个序列重新构造成一个堆,这样就刽寻到 n n n 个元素中的次小值。如此反复执行, 便能得到一个有序序列了。
比如:
实现该算法需要解决两个问题:
- 如何由一个无序序列构建成一个堆?
- 如果在输出堆顶元素后,调整剩余元素成为一个新的堆?
这两个问题可以转化为下面这段代码:
def heap_sort(li):
n = len(li)
for i in range(n//2 - 1, -1, -1):
adjust_heap(li, i, n)
for i in range(n-1, 1, -1):
li[i], li[0] = li[0], li[i]
if i == 2 and li[1] < li[0]:
li[1], li[0] = li[0], li[1]
adjust_heap(li, 0, i)
上面这段代码中的第一个for循环解决的是第一个问题,将无序的数组转换成为一个大顶堆。这时, i i i是从 ∣ l e n ( l i ) / 2 ∣ − 1 |len(li)/2|-1 ∣len(li)/2∣−1 开始循环的。堆本身是一个完全二叉树,所以符合完全二叉树的性质。从根结点开始从 1 1 1 到 n n n 顺序编号, 则该二叉树中的父母结点的个数为 [ n / 2 ] [n/2] [n/2]。所以第一个for循环是遍历的所有父母结点。而由于python中数组的下标是从 0 0 0 开始的,所以减去一。即上述代码中的第一个for循环为调整每一个根结点使其大于其左右孩子,即能初始化一个大顶堆。
第二个for循环则是排序的过程。如上述所说,将根节点与编号为 0 0 0 的互换,然后再调整剩下的 n − 1 n-1 n−1 个结点使其再次成为一个大顶堆。其中 i i i是倒序遍历,即始终代表根节点,也同时代表了剩下的未排序的元素数量。另外当 i = 2 i = 2 i=2 时,此时只有两个元素没有排序,而这两个元素肯定是没有叶子结点的,于是也就不会执行下面的调整大顶堆的操作,这样会导致这两个结点总是没办法正确排序。所以上述代码对 i = 2 i = 2 i=2 的情况单独进行了一次比较。
下面是 adjust_heap( ) 函数的python实现:
def adjust_heap(li, i, m):
j = 2 * i + 1
while j + 1 < m:
if li[j] < li[j+1]:
j += 1
if li[i] < li[j]:
li[i], li[j] = li[j], li[i]
i = j
j = j*2 + 1
print(li)
该代码中 i i i 代表的是当前结点, m m m 代表的是待调整的结点数量。而 j = 2 ∗ i + 1 j = 2 * i + 1 j=2∗i+1 则代表的是结点 i i i 的左孩子。上面代码则表示,将当前结点的左右孩子中较大的一个与其替换。而在替换之后,如果下一个 j + 1 j + 1 j+1 依然小于 m m m, 则再比较下一个 j j j 的左右孩子。
下面为运行的结果:
list_uo = [50, 10, 90, 30, 70, 40, 80, 60, 20]
heap_sort(list_uo)
[50, 10, 90, 60, 70, 40, 80, 30, 20]
[50, 10, 90, 60, 70, 40, 80, 30, 20]
[50, 70, 90, 60, 10, 40, 80, 30, 20]
[90, 70, 80, 60, 10, 40, 50, 30, 20]
[80, 70, 50, 60, 10, 40, 20, 30, 90]
[70, 60, 50, 30, 10, 40, 20, 80, 90]
[60, 30, 50, 20, 10, 40, 70, 80, 90]
[50, 30, 40, 20, 10, 60, 70, 80, 90]
[40, 30, 10, 20, 50, 60, 70, 80, 90]
[30, 20, 10, 40, 50, 60, 70, 80, 90]
[10, 20, 30, 40, 50, 60, 70, 80, 90]
堆排序复杂度分析:
初始化堆时,我们从完全二叉树从最下层最右边的非终端结点开始构建,将其与左右孩子比较和若有必要的互换,对于每个非终端结点来说,其实最多进行两次比较和互换操作,因此整个构建堆的时间复杂度为 O ( n ) O(n) O(n)。
在后续排序的过程中,第 i i i 次取堆顶记录重建堆需要用 O ( l o g i ) O(logi) O(logi) 的时间(完全二叉树的某个结点到根结点的距离为 ∣ l o g 2 i ∣ + 1 |log_2^{i}| +1 ∣log2i∣+1 ,并且需要取 n − 1 n-1 n−1 次堆顶记录,因此,重建堆的时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)。
所以总体来说,堆排序的时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)。 由于堆排序对原始记录的排序状态并不敏感,因此它无论是最好、最坏和平均时间复杂度均为 O ( n l o g n ) O(nlogn) O(nlogn)。
空阔复杂度上,它只有一个用来交换的暂存单元, 也非常的不错。不过由于记录
的比较与交换是跳跃式进行,因此堆排序也是一种不稳定的排序方法。另外,由于初始构建堆所需的比较次数较多,因此,它并不适合待排序序列个数较少的情况。
归并排序 (Merging Sort)
归并就是合并的意思,归并排序,故名思意,就是通过合并来排序。
它的原理是假设初始序列含有 n n n 个记录, 则可以看成是 n n n 个有序的子序列,每个子序列的长度为 1 1 1,然后两两归并,得到 ∣ n / 2 ∣ |n/2| ∣n/2∣, ( ∣ x ∣ |x| ∣x∣ 表示不小于 x x x 的最小整数)个长度为 Z Z Z 或 1 1 1 的有序子序列;再两两归并,……,如此重复, 直至得到一个长度为 n n n 的有序序列为止,这种排序方法称为2路归并排序。
从上面图中可以看到,归并排序可以分为两个部分,切分和合并。切分即把一个待排序的数组不断的对半分,直到不可再分。然后对这些切分后的数组按照大小不断进行合并。直到合成一个完全有序的数组。
python 算法实现如下:
#!/usr/bin/env python
def merge_sort(li):
n = len(li)
if n < 2:
return li
else:
mid = n // 2
left = merge_sort(li[:mid])
right = merge_sort(li[mid:])
print("left is", left, ", right is ", right)
return merge(left, right)
def merge(left, right):
i, j = 0, 0
list_o = []
while i < len(left) and j < len(right):
if left[i] < right[j]:
list_o.append(left[i])
i += 1
else:
list_o.append(right[j])
j += 1
list_o += left[i:]
list_o += right[j:]
return list_o
list_uo = [50, 10, 90, 30, 70, 40, 80, 60, 20]
print("This result is ", merge_sort(list_uo))
上述代码很明显分为两段来实现,即切分和合并。切分采用递归的方式,可进行不断的切分。合并的过程比较直观,即对比左右数组中元素的大小,遇到较小的,则加入有序的列表中,同时指针往后移动一位。
为了便于理解,这里打出了每次 left 和 right 的变化。
left is [50] , right is [10]
left is [90] , right is [30]
left is [10, 50] , right is [30, 90]
left is [70] , right is [40]
left is [60] , right is [20]
left is [80] , right is [20, 60]
left is [40, 70] , right is [20, 60, 80]
left is [10, 30, 50, 90] , right is [20, 40, 60, 70, 80]
This result is [10, 20, 30, 40, 50, 60, 70, 80, 90]
归并排序的复杂度分析:
一趟归并需要将 SR[1]-SR[n] 中相邻的长度为
h
h
h 的有序序列进行两两归并。并将结果放到 TR1[1] -TR1[n] 中,这需要将待排序序列中的所有记录扫描一遍,因此耗费
O
(
n
)
O(n)
O(n) 时间,而自完全二叉树的深度可知,整个归并排序需要进行
[
l
o
g
2
n
]
[log_2n]
[log2n] 次,因此,总的时间复杂度为
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn), 而且这是归并排序算法中最好、最坏、平均的时间性能。
由于归并排序在归并过程中需要与原始记录序列同样数量的存储空间存放归并结果以及递归时深度为 I o g 2 n Iog_2^n Iog2n 的横空间,因此空间复杂度为 O ( n + I o g n ) O(n + Iogn) O(n+Iogn) 。
快速排序 (Quick Sort)
快速排序的基本思想是: 通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。
如图:
python实现如下:
#!/usr/bin/env python
def quick_sort(li, low, high):
if low < high:
pivot = partition(li, low, high)
quick_sort(li, low, pivot-1)
quick_sort(li, pivot + 1, high)
return li
def partition(li, low, high):
while low < high:
while low < high and li[low] < li[high]:
high -= 1
li[low], li[high] = li[high], li[low]
while low < high and li[low] <= li[high]:
low += 1
li[low], li[high] = li[high], li[low]
return low
运行结果:
low is: 0 high is: 8
[20, 10, 40, 30, 50, 70, 80, 60, 90]
low is: 0 high is: 3
[10, 20, 40, 30, 50, 70, 80, 60, 90]
low is: 2 high is: 3
[10, 20, 30, 40, 50, 70, 80, 60, 90]
low is: 5 high is: 8
[10, 20, 30, 40, 50, 60, 70, 80, 90]
low is: 7 high is: 8
[10, 20, 30, 40, 50, 60, 70, 80, 90]
快速排序的复杂度分析:
快速排序的时间性能取决于快速排序递归的深度,可以用递归树来描述递归算法的执行情况。在最优情况下, Partition 每次都划分得很均匀,如果排序
n
n
n 个关键字,其递归树的深度就为
[
l
o
g
2
n
]
+
1
[log_2n] + 1
[log2n]+1 (
[
x
]
[x]
[x] 表示不大子
x
x
x 的最大整数) ,即仅需递归
l
o
g
2
n
log_2n
log2n 次,需要时间为
T
(
n
)
T ( n )
T(n) 的话,第一次 Partition 应该是需要对整个数组扫描一遍,做
n
n
n 次比较。然后,获得的枢轴将数组一分为二,那么各自还需要
T
(
n
/
2
)
T ( n/2 )
T(n/2) 的时间 (注意是最好情况,所以平分两半) 。也就是说,在最优的情况下,快速排序算法的时间复杂度为
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn) 。
在最坏的情况下,待排序的序列为正序或者逆序,每次划分只得到一个比上一次划分少)个记录的子序列,注意另一个为空。如果递归树画出来,就是一棵斜树。此时需要执行 n − 1 n - 1 n−1 次递归调用,且第 i i i 次划分需要经过 n − i n-i n−i 次关键字的比较才能找到第 i i i 个记录,也就是枢轴的位置, 因此比较次数为 ∑ i = 1 n − 1 ( n − i ) = n − 1 + n − 2 … 1 = n ( n − 1 ) 2 \sum_{i=1}^{n-1}(n - i) = n - 1+ n - 2 … 1 = \frac{n(n-1)}{2} ∑i=1n−1(n−i)=n−1+n−2…1=2n(n−1), 最终其时间复杂度为 O ( n 2 ) O(n^2) O(n2) 。
平均的情况,设枢轴的关键字应该在第 k k k 的位置( l < = k < = n l <= k<=n l<=k<=n) 时,那么:
T ( n ) = 1 n ∑ k = 1 n ( T ( k − 1 ) + T ( n − k ) ) + n = 2 n ∑ k 1 T ( k ) + n T(n) = \frac{1}{n}\sum_{k=1}^{n}(T(k-1) + T(n-k)) +n = \frac{2}{n}\sum_{k}^1T(k) +n T(n)=n1∑k=1n(T(k−1)+T(n−k))+n=n2∑k1T(k)+n
由数学归纳法可证明,其数量级为 O ( n l o g n ) O(nlogn) O(nlogn) 。
就空间复杂度来说,主要是递归造成的横空间的使用,最好情况,递归树的深度为 l o g 2 n log_2n log2n,其空间复杂度也就为 O ( l o g n ) O(logn) O(logn), 最坏情况,需要进行 n − 1 n - 1 n−1 递归调用,其空间复杂度为 O ( n ) O(n) O(n), 平均情况, 空间复杂度也为 O ( l o g n ) O(logn) O(logn) 。
可惜的是, 由于关键字的比较和交换是跳跃进行的,因此,快速排序是一种不稳定的排序方法。