Python的十种常见算法
十种排序算法
1. 常见算法分类
十种常见排序算法一般分为以下几种:
(1)非线性时间比较类排序:
a. 交换类排序(快速排序、冒泡排序)
b. 插入类排序(简单插入排序、希尔排序)
c. 选择类排序(简单选择排序、堆排序)
d. 归并排序(二路归并排序、多路归并排序)
(2)线性时间非比较类排序:
a. 技术排序
b. 基数排序
c. 桶排序
总结:
(1)在比较类排序种,归并排序号称最快,其次是快速排序和堆排序,两者不相伯仲,但是有一点需要注意,数据初始排序状态对堆排序不会产生太大的影响,而快速排序却恰恰相反。
(2)线性时间非比较类排序一般要优于非线性时间比较类排序,但前者对待排序元素的要求较为严格,比如计数排序要求待待排序数的最大值不能太大,桶排序要求元素按照hash分桶后桶内元素的数量要均匀。线性时间非比计较类排序的典型特点是以空间换时间。
2. 算法描述于实现
2.1 交换类排序
交换类排序的基本方法是:两两比较待排序记录的排序码,交换不满足顺序要求的偶对,直到全部满足位置。常见的冒泡排序和快速排序就属于交换类排序。
2.1.1 冒泡排序
算法思想:
从数组中第一个数开始,依次便利数据组中的每一个数,通过相邻比较交换,每一轮循环下来找出剩余未排序数终端最大数并“冒泡”至数列的顶端。
算法步骤:
(1)从数组中第一个数开始,依次与下一个数比较并次交换比自己小的数,直到最后一个数。如果发生交换,则继续下面的步骤,如果未发生交换,则数组有序,排序结束,此时时间复杂度未O(n);
(2)每一轮“冒泡”结束后,最大的数将出现在乱序数列的最后一位。重复步骤1。
稳定性:稳定排序。
时间复杂度:O(n)至O(n2),平均时间复杂度为O(n2)。
最好的情况:如果待排序数据列为正序,则一趟排序就可完成排序,排序码的比较次数为(n-1)次,且没有移动,时间复杂度为O(n)。
最坏的情况:如果待排序数据序列为逆序,则冒泡排序需要(n-1)趟起泡,每趟进行(n-i)次排序码的比较和移动,即比较和移动次数均达到最大值:
比较次数:Cmax=∑i=1n−1(n−i)=n(n−1)/2=O(n^2)
移动次数等于比较次数,因此最坏时间复杂度为O(n^2)
实例代码:
# 冒泡排序
def bubble_sort(nums):
for i in range(len(nums)-1): # 这个循环负责冒泡排序进行的次数
for j in range(len(nums)-i-1): # j为列表下标
if nums[j] > nums[j+1]:
nums[j], nums[j+1] = nums[j+1], nums[j]
return nums
print(bubble_sort([45, 32, 8, 33, 12, 22, 19, 97]))
# 输出:[8, 12, 19, 22, 32, 33, 45, 97]
2.1.2 快速排序
冒泡排序是在相邻的两个记录进行比较和交换,每次交换只能上移或下移一个位置,导致总的比较与移动次数较多。快速排序又称为分区交换排序,是对冒泡排序的改进,快速排序采用的思想是分治思想。
算法原理:
(1)从待排序的n个记录中任意选取一个记录(通常选取第一个记录)为分区标准;
(2)把所有小于该排序列的记录移动到左边,把所有大于该排序码的记录移动到右边,中间放所选记录,称之为第一趟排序;
(3)然后对前后两个子序列分别重复上述过程,直到所有记录都排好序。
稳定性:不稳定排序
时间复杂度:O(nlog2n)至O(n^2),平均时间复杂度为O(nlogn)。
最好的情况:每趟排序结束后,每次划分使两个子文件的长度大致相等,时间复杂度为O(nlogn)。
最坏的情况:使待排序记录已经拍好序,第一趟经过(n-1)次比较后第一个记录保持位置不变,并等到一个(n-1)个元素的子记录;第二趟经过(n-2)次比较,将第二个记录定位在原来的位置上,并得到一个包括(n-2)个记录的子文件,依次类推,这样总的比较次数是:
Cmax=∑i=1n−1(n−i)=n(n−1)/2=O(n2)
实例代码:
# 快速排序
def quick_sort(array):
if len(array) < 2: # 基线条件(停止递归的条件)
return array
else: # 递归条件
base_value = array[0] # 选择基准值
# 由所有小于基准值的元素组成的子数组
less = [m for m in array[1:] if m < base_value]
# 包括基准在内的同时和基准相等的元素
equal = [w for w in array if w == base_value]
# 由所有大于基准值的元素组成的子数组
greater = [n for n in array[1:] if n > base_value]
return quick_sort(less) + equal + quick_sort(greater)
# 示例:
array = [2,3,5,7,1,4,6,15,5,2,7,9,10,15,9,17,12]
print(quickSort(array))
# 输出为[1, 2, 2, 3, 4, 5, 5, 6, 7, 7, 9, 9, 10, 12, 15, 15, 17]
2.2 插入类排序
插入排序的基本方法是:每步将一个待排序的记录,按其排序码大小,插到前面已经排序的文件中的适当位置,直到全部插入完为止。
2.2.1 直接插入排序
原理:从待排序的第n个记录中的第二个记录开始,依次与前面的记录比较并寻找插入的位置,每次外循环结束后,将当前的数插入到合适的位置。
稳定性:稳定排序。
时间复杂度:O(n)至O(n2),平均时间复杂度是O(n2)。
最好情况:当待排序记录已经有序,这时需要比较的次数是Cmin=n−1=O(n) 。
最坏情况:如果待排序记录为逆序,则最多的比较次数为。Cmax=∑i=1n−1(i)=n(n−1)2=O(n2) 。
实例代码:
# 直接插入排序
def insert_sort(array):
n = len(array)
for i in range(1, n):
if array[i] < array[i - 1]:
temp = array[i]
index = i # 待插入的下标
for j in range(i - 1, -1, -1): # 从i-1循环到0(包括0)
if array[j] > temp:
array[j + 1] = array[j]
index = j # 记录待插入下标
else:
break
array[index] = temp
return array
lst = [1, 3, 4, 5, 6, 99, 56, 23, 78, 90]
print(insert_sort(lst))
# [1, 3, 4, 5, 6, 23, 56, 78, 90, 99]
2.2.2 Shell排序
Shell排序又称缩小增量排序,由D.L.Shell在1959年提出,是对直接插入排序的改进。
原理:Shell排序法是对相邻指定距离(称为增量)的元素进行比较,并不断把增量缩小至1,完成排序。
shell排序开始时增量较大,分组较多,每组的记录数目较少,故在各组内采用直接插入排序较快,后来增量di逐渐缩小,分组数减少,各组的记录数增多,但由于已经按(di-1)分组排序,文件较接近于有序状态,所以新的一趟排序过程较块。因此Shell排序在效率上比直接插入排序有较大的改进。
在直接插入排序的基础上,将直接插入排序中的1全部改变称增量d即可,因为shell排序最后一轮的增量d就为1.
稳定性:不稳定排序
时间复杂度:O(n1.3)到O(n2)。Shell排序算法的时间复杂度分析比较复杂,实际所需的时间取决于各次排序时增量的个数和增量的取值。研究表明,若增量的取值比较合理,Shell排序算法的时间复杂度约为O(n^1.3)。
对于增量的选择,Shell最初建议增量选择为n/2,并且对增量取半直到1;D.Knuth教授建议di+1=[di-13]序列。
def shellSort(nums):
# 设定步长
step = len(nums) // 2
while step > 0:
for i in range(step, len(nums)):
# 类似插入排序, 当前值与指定步长之前的值比较, 符合条件则交换位置
while i >= step and nums[i - step] > nums[i]:
nums[i], nums[i - step] = nums[i - step], nums[i]
i -= step
step = step // 2
return nums
if __name__ == '__main__':
nums = [9, 3, 5, 8, 2, 7, 1]
print(shellSort(nums))
2.3 选择类排序
选择类排序的基本方法是:每步从待排序记录中选出排序码最小的记录,顺序放在已排序的记录序列的后面,直到全部排完。
2.3.1 简单选择排序(又称直接选择排序)
原理:从所有记录中选出最小的一个数据元素与第一个位置的记录交换;然后再剩下的记录当中再找最小的与第二个位置的记录交换,循环到只剩下最后一个数据元素为止。
稳定性:不稳定排序。
时间复杂度:最坏、最好和平均复杂度均为O(n^2),因此,简单选择排序也是常见排序算法中性能最差的排序算法。简单选择排序的比较次数与文件的初始状态没有关系,在第i趟排序中选出最小排序码的记录,需要做(n-i)次比较,因此总的比较次数是:∑i=1n−1(n−i)=n(n−1)/2=O(n2)。
# 简单选择排序
def selsectd_sort(array):
# 获取list的长度
length = len(array)
# 进行比较的轮数
for i in range(0, length-1):
smallest = i # 默认设置最小值的index为当前值
# 用当先最小index的值分别与后面的值进行比较,以便获取最小index
for j in range(i+1, length):
# 如果找到比当前值小的index,则进行两值交换
if array[j] < array[smallest]:
array[j], array[smallest] = array[smallest], array[j]
lst = [1, 4, 5, 0, 6]
print(selsectd_sort(lst))
2.3.2 堆排序
直接选择排序中,第一次选择经过了(n-1)次比较,只是从排序码序列中选出了一个最小的排序码,而没有保存其他中间比较结果。所以后一趟排序时又要重复许多比较操作,降低了效率。J.Willioms和Floyd在1964年提出了堆排序方法,避免了这一缺点。
堆的性值:
(1)性质:完全二叉树或者是近似完全二叉树;
(2)分类:
大顶堆:父节点不小于子节点键值。
小顶堆:父节点不大于子节点键值,图展示一个最小堆
(3)左右孩子:没有大小的顺序
(4)堆的存储:
一般都用数组来存储堆,i节点的父节点下标就为(i - 1)/2.。
它的左右子节点下标分别为(2*i+1)和(2*i+2)。
如第0个节点左右子节点下标分别为1和2.
(5)堆的操作
a. 建立堆
>以最小堆为例,如果以数组存储元素时,一个数组具有对应的树表现形式,但树并不满足堆的条件,需要重新排列元素,可以建立“堆化”的树。
b. 插入堆
>将一个新元素插入到表尾,即数组末尾时,如果新构成的二叉树不满足堆的性质,需要重新排列元素。
c. 删除堆
堆排序中,删除一个元素总是发生在堆顶,因为堆顶的元素是最小的(小顶堆中)。表中最后一个元素用来填补空缺位置,结果树被更新以满足堆条件。
稳定性:不稳定排序
插入代码实现:
每次插入都是讲新数据放在数组最后。可以发现从这个新数据的父节点到根节点必然为一个有序的数列,现在的任务是将这个新数据插入到这个有序数据中,这就类似于直接插入排序中将一个数据并入到有序区间中,这是节点“上浮”调整。
(6)堆排序的实现
由于堆也是用数组来存储的,故堆数组进行堆化后,第一次将A[0]与A[n-1]交换,再对A[0...n-2]重新恢复堆。第二次将A[0]与A[n-2]交换,再对A[0...n-3]重新恢复堆,重复这样的操作直到A[0]与A[1]交换。由于每次都是将最小的数据并入到后面的有序区间,故操作完成后整个数组就有序了。有点类似于直接选择排序。
# 堆排序
def sift_down(array, start, end):
"""
调整成大顶堆,初始堆时,从下往上;交换堆顶与堆尾后,从上往下调整
:param array: 列表的引用
:param start: 父结点
:param end: 结束的下标
:return: 无
"""
while True:
# 当列表第一个是以下标0开始,结点下标为i,左孩子则为2*i+1,右孩子下标则为2*i+2;
# 若下标以1开始,左孩子则为2*i,右孩子则为2*i+1
left_child = 2*start + 1 # 左孩子的结点下标
# 当结点的右孩子存在,且大于结点的左孩子时
if left_child > end:
break
if left_child+1 <= end and array[left_child+1] > array[left_child]:
left_child += 1
if array[left_child] > array[start]: # 当左右孩子的最大值大于父结点时,则交换
array[left_child], array[start] = swap(array[left_child], array[start])
start = left_child # 交换之后以交换子结点为根的堆可能不是大顶堆,需重新调整
else: # 若父结点大于左右孩子,则退出循环
break
def heap_sort(array): # 堆排序
# 先初始化大顶堆
first = len(array)//2 - 1 # 最后一个有孩子的节点(//表示取整的意思)
# 第一个结点的下标为0,很多博客&课本教材是从下标1开始,无所谓吧,你随意
for i in range(first, -1, -1): # 从最后一个有孩子的节点开始往上调整
print(array[i])
sift_down(array, i, len(array)-1) # 初始化大顶堆
print("初始化大顶堆结果:", array)
# 交换堆顶与堆尾
for head_end in range(len(array)-1, 0, -1): # start stop step
array[head_end], array[0] = array[0], array[head_end] # 交换堆顶与堆尾
sift_down(array, 0, head_end-1) # 堆长度减一(head_end-1),再从上往下调整成大顶堆
if __name__ == "__main__":
array = [16, 7, 3, 20, 17, 8]
print(array)
heap_sort(array)
print("堆排序最终结果:", array)
(7)堆排序的性能分析
由于每次重新恢复堆的时间复杂度为O(logN),共(N-1)次堆调整操作,再加上前面建立堆时(N/2)次向下调整,每次调整时间复杂度也为O(logN)。两次操作时间相加还是O(N*logN)。故堆排序的时间复杂度为O(N * logN)。
最坏情况:如果待排序数组是有序的,仍然需要O(N*logN)复杂度的比较操作,只是少了移动的操作;
最好情况:如果待排序数组是逆序的,不仅需要O(N*logN)复杂度的比较操作,而且需要O(N*logN)复杂度的交换操作。总的时间复杂度还是O(N*logN)。
因此,堆排序和快速排序再效率上是差不多的,但是堆排序一般优于快速排序的重要一点是,数据的初始分布情况对堆排序的效率没有大的影响。
2.4 归并排序
(1)算法思想:
归并排序属于比较类非线性时间排序,号称比较类排序中性能最佳者,再数据应用中较广。
归并排序是分治法的一个典型应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
(2)稳定性
稳定排序算法
(3)时间复杂度
最坏,最好和平均时间复杂度都是O(nlogn)
# 归并排序
def merge(left, right):
# 从两个右顺序的列表李白你依次取数据比较后放入result
# 每次我们分别拿出两个列表中最小的数比较,把较小的放入result
result = []
while len(left) > 0 and len(right) > 0:
# 为了保持稳定性,当遇到相等的时候优先把左侧的数放进结果列表
# 因为left本来也是大数列中比较靠左的
if left[0] <= right[0]:
result.append(left.pop(0))
else:
result.append(right.pop(0))
# while循环出来之后,说明其中一个数组没有数据了
# 我们把另一个数组添加到结果数组后面
result += left
result += right
return result
def merge_sort(array):
# 不断递归调用自己,一直到拆分成单个元素的时候就返回这个元素,不再拆分了
if len(array) == 1:
return array
# 取拆分的中间位置
middle = len(array) // 2
# 拆分过后左侧子串
array_left = array[:middle]
# 拆分过后右侧子串
array_right = array[middle:]
# 对拆分过后的左右字串再拆分,一直到只有一个元素为止
# 最后一次递归时候, left和right都会接到一个元素的列表
# 最后一次递归之前的left和right会接收到排好序的子序列
left = merge_sort(array_left)
right = merge_sort(array_right)
# 我们对返回的两个拆分结果进行排序后合并再返回正确顺序的字列表
# 这里我们调用一个函数帮助我们按顺序合并left和rigth
return merge(left, right)
lst = [5, 4, 3, 2, 1]
print(merge_sort(lst))
2.5 线性时间非比较类排序
2.5.1 计数排序
计数排序使一个非基于比较的排序算法,该算法于1954年由Harold H.Seward提出,它的优势在于对于较小范围内的整数排序。它的复杂度为O(n+k)(其中K使待排序数的范围),快于任何比较排序算法,缺点就是非常消耗空间。很明显,如果当O(k)>O(n*log(n))的时候其效率反而不如基于比较的排序,比如堆排序和归并排序和快速排序。
(1)算法原理
基本思想是对于给定的输入序列中的每一个元素x,确定该序列中值小于x的元素的个数。一旦有了这个信息,就可以将x直接存放到最终的输出序列的正确位置上。例如,如果输入序列中只有17个元素的值小于x的值,则x可以直接存放在输出序列的第18个位置上。当然,如果有多个元素具有相同的值时,我们不能将这些元素放在输出序列的同一个位置上,在代码中作适当的修改即可。
(2)算法步骤:
a. 找出待排序的数组中最大的元素;
b. 统计数组中每个值为i的元素出现的次数,存入数组c的第i项;
c. 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加);
d. 反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将 C(i)减去1。
(3)时间复杂度
O(n+k)
(4)空间复杂度
O(k)
(5)要求
待排序数中最大数值不能太大
(6)稳定性
稳定
(7)代码示例
# 计数排序
def counting_sort(a, k): # k = max(a)
n = len(a) # 计算a序列的长度
b = [0 for i in range(n)] # 设置输出序列并初始化为0
c = [0 for i in range(k + 1)] # 设置计数序列并初始化为0,
for j in a:
c[j] = c[j] + 1
for i in range(1, len(c)):
c[i] = c[i] + c[i-1]
for j in a:
b[c[j] - 1] = j
c[j] = c[j] - 1
return b
print(counting_sort([1, 3, 5, 32, 423, 5, 23, 5, 75], 423))
注意:计数排序是典型的以空间换时间的排序算法,对待排序的数据有严格的要求,比如待排序的数值中包含负数,最大值都有限制,谨慎使用。
2.5.2 基数排序
基数排序属于“分配式排序”,是非比较类线性时间排序的一种,又称“桶子法”。顾名思义,它是透过键值的部分信息,将要排序的元素分配至某些“桶”中,已达到排序的作用。
# 基数排序
def radix_sort(list, d=3): # 默认三位数,如果是四位数,则d=4,以此类推
for i in range(d): # d轮排序
s = [[] for k in range(10)] # 因每一位数字都是0~9,建10个桶
for j in list:
s[int(j / (10 ** i)) % 10].append(j)
re = [a for b in s for a in b]
return re
print(radix_sort([12, 4, 23, 26, 85, 12, 45], 2))
2.5.3 桶排序
桶排序也是分配排序的一种,但其是基于比较排序的,这也是与基数排序最大的区别所在。
(1)算法思想
桶排序算法类似于散列表。首先要假设待排序的元素输入符合某种均匀分布,例如数据均匀分布在[0, 1]区间上,则可将此区间划分为10个小区间,称为桶,对散布到同一个桶中的元素再排序。
(2)要求
待排序数长度一致
(3)排序过程
a. 设置一个定量的数组当作空桶子;
b. 寻访序列,并且把记录一个一个放到对应的桶子去;
c. 对每个不是空的桶子进行排序;
d. 从不是空的桶子里把项目再放回原来的序列中。
>例如待排序列 k = {49, 38, 35, 97, 76, 73, 27, 49}。这些数据全部在1—100之间。因此我们定制10个桶,然后确定映射函数 f(k) = k/10。则第一个关键字49将定位到第4个桶中(49/10=4)。依次将所有关键字全部堆入桶中,并在每个非空的桶中进行快速排序。
(4)时间复杂度
对N个关键字进行桶排序的时间复杂度分为两个部分:
a. 循环计算每个关键字的桶映射函数,这个时间复杂度是O(N)
b. 利用先进的比较排序算法对每个桶内的所有数据进行排序,对于N个待排数据,M个桶,平均每个桶[N/M]个数据,则桶内排序的时间复杂度为:∑i=1MO(Ni∗logNi)=O(N∗logNM) 。其中Ni为第i个桶的数据量。
因此,平均时间复杂度为线性的O(N+C),C为桶内排序所花费的时间。当每个桶只有一个数,则最好的时间复杂度为: O(N)。
# 桶排序
def bucket_sort(a):
buckets = [0] * ((max(a) - min(a)) + 1) # 初始化桶元素为0
for i in range(len(a)):
buckets[a[i] - min(a)] += 1 # 遍历数组a,在桶的相应位置累加值
b = []
for i in range(len(buckets)):
if buckets[i] != 0:
b += [i + min(a)] * buckets[i]
return b
print(bucket_sort([1,3, 4, 53, 23, 534, 23]))