对常见的排序算法进行一个总结,包括主要思想,Python实现,复杂度、稳定性分析(如有错误请指出)
没写完,待续。
参考:
- 牛客网左神算法系列课程
- hellozhxy的博客——十大经典排序算法最强总结
- 那十的博客——数据结构中排序方法2
0 一些知识
1 master公式计算算法的时间复杂度
2 算法的稳定性稳定性
同样值的个体之间,如果不因为排序而改变相对次序,说明这个排序算法是稳定的
1 选择排序
1.1 算法思想
0到(n-1)位置上选择最小的值放在0位置上
1到(n-1)位置上选择最小的值放在1位置上
2到(n-1)位置上选择最小的值放在2位置上
...
1.2 Python实现:
###选择排序
def selection_sort(array):
if not array:
return array
for i in range(len(array)):
min = i
for j in range(i+1,len(array)):
if array[min] > array[j]:
min = j
array[i],array[min] = array[min] ,array[i]
return array
1.3 选择排序复杂度分析
对于比较:对i位置需要进行(n-i)次的比较,即进行1+2+3+…(n-1)次;对于交换,最好情况0次(顺序),最差n-1次(即全部逆序),所以时间复杂度O(n^2);没有申请额外空间,故空间复杂度O(1)
1.4 选择排序稳定性分析
不稳定
2 冒泡排序
2.1 算法思想1(不太正宗)
0-1上哪个大,哪个向后走
1-2上哪个大,哪个向后走
...
则可以把第一大的数字调到末尾
然后继续上面的循环,把第二大的数字放在倒数第二位,如此循环
关于冒泡排序的优化
考虑如[2,1,3,4,5,6,7,8,9]这样的序列,只要交换前两位,就已经是整体有序的了,所以可以引入一个布尔值,
判断是否需要接着交换,起到优化的目的。
Python实现
###冒泡排序
###关于i和j的边界问题要想清楚
def bubble(a):
for i in range(len(a)-1,0,-1): ###总共进行n-1次操作(因为首位不需要再进行操作了)
for j in range(i):
swag = False ###考虑到有些位置之后不需再交换了,进行优化,初始化为False,即假设不做交换
if a[j] > a[j+1]: ###每一次操作两两进行对比
a[j], a[j+1] = a[j+1], a[j]
swag= True ###进行交换
if not swag: ##
break
return a
2.2 算法思想2(正宗)
第一种算法思想有些不正宗,是因为冒泡的方向都是向上的,而第一种算法是向下进行交换,所以正宗的冒泡算法描述为:
两两比较相邻数值,如果反序则交换,直到无反序为止
其实把第一种算法稍微改一下就可以,即谁小谁往前走,把最小的放在第一个位置,第二小的放在第二个位置,如此循环
优化方法和1相同
Python实现
#冒泡排序(正宗+优化)
def Authentic_BubbleSort_2(arr):
count = len(arr)
for i in range(count):
swapFlag = False #先假设未做交换操作
for j in range(i + 1, count):
if arr[i] > arr[j]:
arr[i],arr[j] = arr[j],arr[i]
swapFlag = True #设置交互操作标志
if not swapFlag:
break #无交换操作,表示已完成排序,退出循环
return arr
2.3 冒泡排序复杂度分析
最好的情况,要排序的序列本身就是有序的,那么只需要i=1,然后j从1到n-1比较n-1次就可以了,时间复杂度为O(n)
最差的情况,待排序序列每一个位置都需要交换,即完全逆序,则需要进行1+2+3+…(n-1)次比较和交换,时间复杂度为O(n^2)
则冒泡排序的时间复杂度为O(n^2)
2.4 冒泡排序稳定性分析
可以令值相等的时候不交换顺序,此时等值相对位置不变,算法稳定。(即冒泡算法也可以不稳定)
3 插入排序
3.1算法思想
左神算法课所讲的思想如下:
令数组在0-0上有序(直接有序)
令数组在0到1上有序
令数组在0到2上有序
...
令数组在0到(n-1)上有序
保证有序的方法是在某范围内如果当前数比前一个数大,则一直与前一个数进行交换
交换停止的条件:a.后一个数不比前一个数小;b.前面没数了
我觉得这样的描述对于“插入”的思想并没有叙述得容易理解,可以与下面的描述结合起来,更好理解:
将一个记录插入到已经排好序的有序表中,从而得到一个新的,记录数增1的有序表
解释的稍微通俗点就是,你拿到一个序列{9,1,5,8,3,7,4,6,2},从第二个数字开始与前面比较,1比9大,所以1,9交换位置,
那个1,9算是个有序数列列了,现在看数字5,大小介于1和9之间,于是将之插入到1与9之间,如此循环,便能得到有序数列
思想图如下:
3.2 Python实现
def insert_sort(arr):
if not arr:
return None
for i in range(1,len(arr)): ###0-0范围上直接有序,不用排序,所以只要n-1次操作
for j in range(i,0,-1): ###倒序比较
if arr[j] < arr[j-1]:
arr[j],arr[j-1] = arr[j-1],arr[j]
#一旦后当前数没有小于前一个数,那么就没有接着比较下去的必要了
#此时一定要break,否则比较次数会增加(增加至且固定为到1+2+...+(n-1)次),会增加时间复杂度
else:
break
return arr
3.3 插入排序复杂度分析
最好的情况,待排序的序列本身即是有序的,那么实际上只在代码第六行进行了n-1次比较,不用进行交换,时间复杂度为O(n);最坏的情况,即全部逆序,那么此时需要进行1+2+3+…+n-1次比较,且进行1+2+3+…+n-1次移动,所以时间复杂度为O(n^2),即为插入排序的时间复杂度。
3.4 插入排序稳定性分析
与冒泡排序相同,等值的时候设置不进行交换,则可以达到稳定。
4 希尔排序
在第三节中降到了插入排序,插入排序比较适合小规模数据或者数据本身基本有序时,比较高效。
希尔排序则是对插入排序的改进,使得它对于大规模且无需的数据也非常有效率。
4.1 算法思想
希尔排序的基本思想是:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录"基本有序"时,再对全体记录进行依次直接插入排序。也称为递减增量排序算法
4.2 代码实现
def shellSort(arr):
if not arr:
return None
n = len(arr)
group = n // 2
while group > 0:
for i in range(group, n):
while i >= group and arr[i] < arr[i-group]:
arr[i], arr[i-group] = arr[i-group], arr[i]
i -= group
group = int(group/2)
return arr
4.3 复杂度分析
希尔排序的复杂度和增量序列是相关的
{1,2,4,8,…}这种序列并不是很好的增量序列,使用这个增量序列的时间复杂度(最坏情形)是O(n^2)
Hibbard提出了另一个增量序列{1,3,7,…,2^k-1 },这种序列的时间复杂度(最坏情形)为O(n^1.5)
Sedgewick提出了几种增量序列,其最坏情形运行时间为O(n^1.3),其中最好的一个序列是{1,5,19,41,109,…}
4.4 稳定性分析
在分组进行插入排序时,相同的元素的相对位置可能会发生变化,因此希尔排序是不稳定的算法
5 归并排序
5.1 算法思想
整体是一个简单递归,二分,让左边排好序,右边排好序,让其整体有序
如何有序:首先定义一个长度与原数组相同的辅助数组。在原数组左右两边,定义两个指针,谁小拷贝谁,相等时默认拷贝左边的,如果越界,那就把没越界的那一边全部添加到辅助数组。
(归并排序用到了外排序的方法)
补充:外排序与内排序
根据排序过程中待排序的记录是否全部被放置在内存中,分为内、外排序
归并排序算法的示意图::
5.2 Pyhton实现
###归并排序
def merge_sort(arr):
if len(arr) <= 1:
return arr
m = int(len(arr)/2) ###二分点
left = merge_sort(arr[:m]) ###递归的将二分之后的左边进行归并排序
right = merge_sort(arr[m:]) ###递归的对二分的右边进行归并排序
return merge(left,right) ###每次递归返回都调用merge函数,从而进行左右排序
def merge(left,right): ###进行排序
s = []
p1 = 0
p2 = 0
while p1 <= len(left)-1 and p2 <= len(right)-1:
if left[p1] <= right[p2]:
s.append(left[p1])
p1 += 1
else:
s.append(right[p2])
p2 += 1
while p1 <= len(left)-1: ###这里判断p2是否在右边越界,如果越界了则把没越界的那一边全部加到辅助数组
s.append(left[p1])
p1 += 1
while p2 <= len(right)-1: ###这里判断p1是否越界
s.append(right[p2])
p2 += 1
return s
5.3 归并排序复杂度分析
可利用master公式进行分析
T(N) = 2*T(N/2) + O(N)
a = 2,b =2,d =1
log(b,a) = 1 = d
所以时间复杂度为O(NlogN)
且归并排序需要申请与原序列等长的辅助数组,故额外空间复杂度为O(N)
5.4 归并排序稳定性分析
因为Merge函数中指针所在位置的数值相等时,默认拷贝左边的值,所以排序后相等值的相对位置不会发生改变,因此归并算法是稳定的算法
6 快速排序
6.1 算法思想
通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,
以达到整个序列有序
具体:
从数列中挑出一个元素,称为 “基准”(pivot),或者叫枢轴;
重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。
在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。
6.2 partition算法
6.3 Python实现
###快速排序
def quick_sort(nums, low, high):
if low < high:
index = partition(nums, low, high)
quick_sort(nums, low, index - 1)
quick_sort(nums, index + 1, high)
def partition(nums, l, r):
pivot = l # 利用第一个值作为pivot
less, more = l, r+1 #定义小于区域和大于区域,注意r+1,因为有more-1的操作
while l < more:
if nums[l] < nums[pivot]:
nums[l], nums[less+1] = nums[less+1], nums[l]
less += 1
l += 1
elif nums[l] > nums[pivot]:
nums[l], nums[more-1] = nums[more-1], nums[l]
more -= 1
else:
l += 1
nums[pivot], nums[less] = nums[less], nums[pivot]
return less
测试用例:
if __name__ == '__main__':
arr = [5,3,2,5,68,1,2]
print('original:',arr)
quick_sort(arr,0,len(arr)-1)
print('sorted:',arr)
输出:
original: [5, 3, 2, 5, 68, 1, 2]
sorted: [1, 2, 2, 3, 5, 5, 68]
简洁版:
参考了下那什的博客,发现用python可以很简洁的实现partition代码的部分…
(复杂度都是一样的)
def quicksort(array):
if len(array) < 2:
return array # 递归条件
else:
pivot = array[0] #递归条件,选择第一个值/最后一个值作为划分值
less = [i for i in array[1:] if i <= pivot] #由所有小于基准值的元素构成的数组
more= [i for i in array[1:] if i > pivot] #由所有大于基准值的元素构成的数组
return quicksort(less) + [pivot] + quicksort(more) #对左范围和右范围递归执行partition
寻找第k大的数
def findKthLargest(self, nums: List[int], k: int) -> int:
# 快排
def partition(nums, low, high):
pivot = nums[low]
# 三分国旗问题:先从右边high位置开始搜索,遇到比基准值小的就赋给low位置,从左边low位置搜索,如此交替
while low < high:
# 从右边high位置开始,high位置区域都比基准值大,直接跳过
while low < high and nums[high] >= pivot:
high -= 1
# 否则给low位置赋值
nums[low] = nums[high]
# 然后从左边low位置开始,low位置区域都比pivot小,直接跳过
while low < high and nums[low] <= pivot:
low += 1
# 否则给high位置赋值
nums[high] = nums[low]
# 跳出循环时low = high
nums[low] = pivot
return low
def quick_sort(nums, low, high):
if low < high:
index = partition(nums, low, high)
quick_sort(nums, low, index - 1)
quick_sort(nums, index + 1, high)
quick_sort(nums, 0, len(nums) - 1)
return nums[len(nums) - k]
5.4 快速排序复杂度分析
5.4.1 时间复杂度
1)最好的情况:每次partition的点很正,几乎在中点,那么此时左右范围就差不多大,利用master公式计算复杂度为:T(n)=2T(n/2)+O(n),则a=2,b=2,d=1,复杂度为O(nlogn)
2)最差情况:每次partition的点都很偏(比如在最后),则此时复杂度为O(n^2)
由于partition出现的点的位置是有一定概率的,那么经过推算,概率相加,时间复杂度收敛于O(N*logN)
5.4.2 空间复杂度
最好情况O(logn)
最差情况O(n)
那么同样根据概率相加,空间复杂度收敛于O(logn)
5.5 快排稳定性分析
快排在partition过程中,当前值与<=区域的前一个数做交换时,会改变等值的相对次序,所以是不稳定的
6 堆排序
堆排序部分参考博文:
1、堆排序的Python实现(附详细过程图和讲解)
2、PYTHON实现堆排序
3、anwen的github
4、左神相关算法课(牛客网)
6.1 相关概念
6.1.1 堆:
堆结构就是用数组表示的完全二叉树结构将
堆结构又分为大根堆和小跟堆
大根堆:每个结点的值都大于或等于左右孩子结点
小根堆:每个结点的值都小于或等于左右孩子结点
6.1.2 完全二叉树
完全二叉树是除了最后一层之外的其他每一层都被完全填充,并且所有结点都保持向左对齐的树。
向左对齐指的是:
而下面这种情况则不是向左对齐
6.2 关于节点的下标
1、下图为下标从0开始
2、若下标从1开始,则某节点i的父节点为i//2,左孩子为2i,右孩子为2i+1
6.3 heapify和heapInsert
大根堆最重要的两个操作就是heapify和heapInsert。
heapify是当堆中某个结点的数值发生变化时,应不断向下与其孩子结点中的最大值比较,若小于则交换。
heapInsert是当一个元素加入到大根堆时应该自底向上与其父结点比较,若大于父结点则交换;
代码见6.5节
6.4 堆排序思想
1、首先将待排序的数组构造出一个大根堆
2、取出这个大根堆的堆顶节点(最大值),与堆的最下最右的元素进行交换,然后减少大根堆的大小(即heapSize-1),把剩下的元素再构造出一个大根堆
3、重复第二步,直到这个大根堆的长度为1(heapSize=1),此时完成排序。
图解释:
还推荐观看堆排序B站动画演示视频,加深理解:数据结构排序算法之堆排序演示
6.5 代码实现
注:我的代码都是以第一个节点下标为0开始,也可根据网上大部分做法,从1开始。
##堆排序
##heapinsert
def heap_insert(arr,i):
while arr[i] > arr[(i-1)//2]:
arr[i],arr[(i-1)//2] = arr[(i-1)//2],arr[i]
i = (i-1)//2
##heapify
def heapify(L,index,heapSize):
left = index * 2 + 1
largest = index
while left < heapSize:
if left +1 < heapSize and L[left+1] > L[left]:
left += 1 #左右子孩子哪个大,哪个下标是left
if L[index] < L[left]: #当前节点小于孩子就更新最大值下标
largest = left
else: #如果不小于,则不用继续向下比较
break
L[index],L[largest] = L[largest],L[index] #交换值
#进行下一轮
index = largest
left = index * 2 + 1
##堆排序主函数
def heap_sort(arr):
if not arr or len(arr) < 2: #判断特殊情况
return
#先通过heap_insert建立大根堆
for i in range(len(arr)):
heap_insert(arr,i)
#交换最后一个和第一个数,把剩余元素调整为大根堆,依次循环
heapSize = len(arr) #定义heapSize
arr[0],arr[heapSize-1] = arr[heapSize-1],arr[0] #交换
while heapSize > 0: #循环,直到只剩一个元素
heapify(arr,0,heapSize) #调整为大根堆
heapSize -= 1 #堆长度-1
arr[0],arr[heapSize] = arr[heapSize],arr[0] #下一次交换
测试用例:
arr= [9,7,1,3,6,8,4,2,5]
#arr = [74, 73, 59, 72, 64, 69, 43, 36, 70, 61, 40, 16, 47, 67, 17, 31, 19, 24, 14, 20, 48, 5, 7, 3, 78, 84, 92, 97, 98, 99]
heap_sort(arr)
输出
[1, 2, 3, 4, 5, 6, 7, 8, 9]
6.6 复杂度分析
堆排序的优势在于无论是入堆一个元素heapInsert还是出堆一个元素之后的heapify都不是将整个样本遍历一遍(O(n)级别的操作),而是树层次上的遍历(O(logn)级别的操作),比如对于heapInsert来说,没插入一个元素,置于父节点进行比较,则最多比较一个树的深度,所以时间复杂度为O(logn)。
这样的话堆排序过程中,建立堆的时间复杂度为O(nlogn)(因为有n个元素,所以复杂度为n*logn),循环弹出堆顶元素并heapify的时间复杂度为O(nlogn),整个堆排序的时间复杂度为O(nlogn),额外空间复杂度为O(1)。
6.7 借助库函数heapq进行堆排序
借助Python中的库函数heapq可以使书写过程简便
因为在heappush方法的时候heapq就自动给你建立好了一个堆,而heappop方法每次都弹出并返回列表中最小的值
注意,heapq里面没有直接提供建立大根堆的方法,可以利用一个小trick:即每次heappush当前元素的相反数(即取负),这样实际最大的数变为最小,就可以处于栈顶,return时再加个负号就行。
import heapq
def heap_sort(arr):
if not arr:
return []
h = []
for i in arr:
heapq.heappush(h,i) #heappush自动建立小根堆
return [heapq.heappop(h) for i in range(len(h))] #heappop每次删除并返回列表中最小的值
同时,heapq提供了heapify方法,可以直接把list转化为小根堆
a = [1,3,5,7,9,2,4,6,8,0]
heapq.heapify(a)
print(a)
输出
[0, 1, 2, 6, 3, 5, 4, 7, 8, 9]
7 桶排序
8 基数排序
9 计数排序
10 总结
10.1 各排序算法总结
10.2 一些小trick
1)目前没有找到时间复杂度O(n*logn),额外空间复杂度O(1),又稳定的排序算法
2)归并排序的额外空间复杂度可以变成O(1),即内部缓存法(hard,一般不需掌握)
3)快速排序可以做到稳定(hard,不需掌握)
4)如果面试中遇到类似的题目:期数放在数组左边,偶数放在数组右边,还要求原始的相对次序不变,时间复杂度O(n),额外空间复杂度 O(1),那么直接认怂,务必请面试官帮你解答此题。
5)不考虑稳定性的话,尽量用快排(常数项时间较短)
6)一般非基础类型的排序都要求稳定性,而基础类型的排序一般都是快排