排序算法是将 n 个给定元素按照大小顺序进行排列的算法。
1. 对无序表的排序
1.1 冒泡排序
冒泡排序的算法思路在于多趟遍历比较无序表,每趟遍历依次对比相邻两数据项的大小,若逆序排序则交换数据项位置,使得每次遍历数据项中最大的一项都能移动到本次遍历长度的序列末尾,每次遍历结束后下次遍历的数据项序列长度减一,只到遍历序列长度小于等于1时结束计算(此时排序已完成)。
冒泡排序的劣势在于:
- 时间复杂度较高,为 O ( n 2 ) O(n^2) O(n2),这是因为每个数据项在找到其最终位置之前,必须要经过多次比对和交换,其中大部分的操作是无效的1。
其优势在于:
- 无需任何额外的存储空间开销;
- 算法适应性好,在链式存储的线性数据结构上也可以进行排序,而其他排序算法很难应用到链式存储的线性表上;
- 通过监测每趟比对是否发生过交换,可以提前确定排序是否完成,这也是其它多数排序算法无法做到的;如果某趟比对没有发生任何交换,说明列表已经排好序,可以提前结束算法。
实现样例:
# 冒泡排序
def BubbleSort(alist):
for i in range(len(alist)-1):
for j in range(len(alist)-i-1):
if alist[j] > alist[j+1]:
alist[j], alist[j+1] = alist[j+1], alist[j]
return alist
temp = [4, 3, 5]
BubbleSort(temp)
1.2 选择排序
选择排序对冒泡排序进行了改进,保留了其基本的多趟比对思路,每趟都使当前最大项就位。但选择排序对交换进行了削减,相比起冒泡排序进行多次交换,每趟仅进行1次交换,记录最大项的所在位置,最后再跟本趟最后一项交换。
选择排序的时间复杂度比冒泡排序稍优,比对次数不变还是 O ( n 2 ) O(n^2) O(n2),交换次数则减少为 O ( n ) O(n) O(n)。
实现样例:
# 选择排序
def SelectSort(alist):
for i in range(len(alist)-1):
max_index = -1
for j in range(len(alist)-i-1):
if alist[j] > alist[max_index]:
max_index = j
alist[-1], alist[max_index] = alist[max_index], alist[-1]
return alist
temp = [4, 3, 5]
BubbleSort(temp)
1.3 归并排序
归并排序是递归算法,思路是将数据表持续分裂为两半,对两半分别进行归并排序。
- 递归的基本结束条件是:数据表仅有1个数据项,自然是排好序的;
- 缩小规模:将数据表分裂为相等的两半,规模减为原来的二分之一;
- 调用自身:将两半分别调用自身排序,然后将分别排好序的两半进行归并,得到排好序的数据表。
将归并排序分为两个过程来分析:分裂和归并。分裂的过程,借鉴二分查找中的分析结果,是对数复杂度,时间复杂度为 O ( l o g ( n ) ) O(log(n)) O(log(n));归并的过程,相对于分裂的每个部分,其所有数据项都会被比较和放置一次,所以是线性复杂度,其时间复杂度是 O ( n ) O(n) O(n)。所以总的来说,算法整体的时间复杂度是 O ( n l o g ( n ) ) O(nlog(n)) O(nlog(n)),空间复杂度是 O ( n ) O(n) O(n),关于空间复杂度大于 O ( 1 ) O(1) O(1) 这个特性在对特大数据集进行排序的时候要考虑进去。
实现样例:
# 归并排序
def MergeSort(alist):
# 递归结束条件
if len(alist) <= 1:
return alist
# 分解问题,并递归调用
m = len(alist)//2
left = MergeSort(alist[:m])
right = MergeSort(alist[m:])
# 合并左右半部,完成排序
merge = []
while left != [] and right != []:
if left[0] <= right[0]:
merge.append(left.pop(0))
else:
merge.append(right.pop(0))
merge.extend(left if left != [] else right) # 若左右两部分长度不等
return merge
MergeSort([54, 26, 93, 17, 77, 31, 44, 55, 20])
1.4 快速排序
快速排序的思路是依据一个“中值”数据项来把数据表分为两半:小于中值的一半和大于中值的一半,然后每部分分别进行快速排序(递归)。如果希望这两半拥有相等数量的数据项,则应该找到数据表的“中位数”但找中位数需要计算开销!要想没有开销,只能随意找一个数来充当“中值”比如,第1个数。
快速排序过程分为分裂和移动两部分,如果分裂总能把数据表分为相等的两部分(即输入序列是完全随机排列的,选取首项数据充当“中值”十分有效,此时序列中任意一项数据排列在首项数据左边或右边的概率都相等),那么快速排序算法整体的时间复杂度是 O ( n l o g ( n ) ) O(nlog(n)) O(nlog(n)),而且算法运行过程中不需要额外的存储空间,空间复杂度为 O ( 1 ) O(1) O(1);
但如果选定“中值”所在的分裂点过于偏离中部,造成左右两部分数量不平衡,甚至极端情况下,有一部分始终没有数据(即输入序列已经是正序排列或逆序排列的了),那么快速排序的时间复杂度就会退化到 O ( n 2 ) O(n^2) O(n2)(且还要加上递归调用的开销,此情况下的算法效率甚至比冒泡排序还要糟糕)。所以掌握输入序列排序分布的基本情况,对于排序算法选择尤为重要!!!
快速排序的递归算法“递归三要素”如下:
- 基本结束条件:数据表仅有1个数据项,自然是排好序的;
- 缩小规模:根据“中值”,将数据表分为两半,最好情况是相等规模的两半;
- 调用自身:将两半分别调用自身进行排序。
实现样例:
# 快速排序
def QuickSort(alist, start=None, end=None):
s = s if s is not None else 0
e = e if e is not None else len(nums) - 1
i, j = s, e
key = alist[i] # 必须是列首元素做虚拟中值
#
while i < j:
while i < j and alist[j] >= key:
j -= 1
alist[i] = alist[j]
while i < j and alist[i] <= key:
i += 1
alist[j] = alist[i]
alist[i] = key
#
if s < e:
quick_sort(nums, s, s if i - 1 < s else i - 1)
quick_sort(nums, e if i + 1 > e else i + 1, e)
return nums
QuickSort([54, 26, 93, 17, 77, 31, 44, 55, 20])
2. 对基本有序表的排序
2.1 插入排序
插入排序维持一个已排好序的子列表,其位置始终在列表的前部,然后逐步扩大这个子列表直到全表。插入排序时间复杂度仍然是 O ( n 2 ) O(n^2) O(n2),但算法思路与冒泡排序、选择排序不同。插入排序的比对主要用来寻找“新项”的插入位置,最差情况是每趟都与子列表中所有项进行比对,总比对次数与冒泡排序相同,数量级仍是 O ( n 2 ) O(n^2) O(n2);最好情况,列表已经排好序的时候,每趟仅需1次比对,总次数是 O ( n ) O(n) O(n)。所以列表越接近有序,插入排序的时间复杂度就越小。
在寻找“新项”的插入位置时,根据所用查找算法的不同,可衍生出两种插入排序算法的亚型:若采用顺序查找,则产生直接插入排序算法,查询和数据项交换的时间复杂度都是 O ( n ) O(n) O(n);若采用二分查找,则产生半插入排序算法,其查询的时间复杂度是 O ( l o g ( n ) ) O(log(n)) O(log(n))、数据项交换的时间复杂度仍是 O ( n ) O(n) O(n)。不论是哪种插入排序算法,它整体的时间复杂度都还是 O ( n 2 ) O(n^2) O(n2)。
实现样例:
# 直接插入排序
def gapInsertSort(alist, start, gap):
# 从第二项开始遍历插入到有序子集合
for i in range(start+gap, len(alist), gap):
# 顺序查找插入位置并逐次交换元素
j = i-gap
while j >= 0 and alist[j+gap] < alist[j]:
alist[j], alist[j+gap] = alist[j+gap], alist[j]
j -= gap
return alist
temp = [4, 3, 5]
gapInsertSort(temp, 0, 1)
# 半插入排序
def gapDiInsertSort(alist, start, gap):
# 从第二项开始遍历插入到有序子集合
for i in range(start+gap, len(alist), gap):
# 二分查找插入位置
s, e = start, i-gap
while s<=e:
m = (s+e)//2
if alist[m] <= alist[i]:
s = m + gap
else:
e = m - gap
# 元素移动
for j in range(i-gap, s-gap, -gap):
alist[j], alist[j+gap] = alist[j+gap], alist[j]
return alist
temp = [4, 3, 5]
gapDiInsertSort(temp, 0, 1)
2.3 Shell排序
Shell 排序的核心思路是:首先将整个序列中相隔某一增量的数据项组成一个子集合,对每个子集合分别进行插入排序;然后减小增量大小,并重复上述过程,直到增量减小为1时结束循环,此时已完成输入序列的排序。
Shell 排序以插入排序为基础,由于每趟都使得列表更加接近有序,这可有序减少排序所需的对比和数据移动次数,使其时间复杂度优于插入排序算法。总体来说,根据选取的增量(间隔)不同,Shell排序的时间复杂度介于 O ( n ) O(n) O(n) 到 O ( n 2 ) O(n^2) O(n2) 之间。
且如何选择增量能使算法排序效率最高,目前还处于研究阶段,尚无通用的选定方法;一般来说,选定增量保持为2k-1(1、3、5、7、15、31等等)不失为一种选择,此时 Shell 排序的时间复杂度约为 O ( n 3 2 ) O(n^{\frac{3}{2}}) O(n23)。
实现样例:
# 设置增量规则
def get_gap(num):
return num // 2
# shell排序
def ShellSort(alist):
# 设置增量
sublist_count = get_gap(len(alist))
while sublist_count > 0:
for start_position in range(sublist_count):
gapInsertSort(alist, start_position, sublist_count)
# gapDiInsertSort(alist, start_position, sublist_count)
# 更新增量
sublist_count = get_gap(sublist_count)
return alist
ShellSort([3, 1, 4, 2, 5])
冒泡排序算法过程总需要n-1趟,随着趟数的增加,比对次数逐步从n-1减少到1,并包括可能发生的数据项交换。比对的时间复杂度是O(n2),交换的时间复杂度也是O(n2),通常每次交换包括3次赋值(Python语言中允许直接参数交换 a, b = b, a,可减少为两次赋值操作) ↩︎