【数据结构与算法】常用排序算法(冒泡、选择、插入、归并、快速)超详细介绍,附代码

排序,即将序列(数组,链表)中的元素按照大小顺序进行排列

排序的基本操作

  1. 比较 两个值,看哪个更大或者更小。
  2. 当彼此不在正确的相对位置时, 可能需要 交换 它们。

评估排序算法的整体效率,需要同时考虑 比较 和 交换 的总次数

Python的交换操作

通常, 交换两个变量的值需要用到一个 辅助变量, 代码如下

temp = a
a = b
b = temp

而在Python中,可以使用 同时分配在一个语句中完成交换

a, b = b, a

P.S.:Python中没有也不需要swap()函数。

Python内置的排序方法和函数——sort()和sorted()

python sort 和 sorted 排序

在 Python 中,我们可以直接使用 sort() 和 sorted() 对列表进行排序两者本质上是 结合了归并排序(merge sort)和插入排序(insertion sort)的 Timsort,具体可参考 Python sort 函数内部实现原理】。

list.sort()

对列表进行 原地 排序(in-place operation)
在这里插入图片描述
sorted(list)

不会改变原来的 list,而是会 返回一个新的 排好序的 list
在这里插入图片描述
当然,我们这里关心的是不同排序算法的底层实现。


排序算法介绍

没有任何一种排序算法在任何情况下都是最好的。

在B站看到一个排序算法可视化的视频,感觉很有意思,在这里分享一下—— 6分钟演示15种排序算法

O(n^2) 的排序算法

一、冒泡排序(Bubble Sort)

冒泡排序,即 多次遍历列表,遍历过程中 比较相邻的项交换那些无序的项每次遍历,都能够将下一个最大的数放在其正确的位置第一次遍历把最大的放到最后,第二次遍历把第二大的放到倒数第二位置,以此类推】,就像是“气泡从水底上升到水面”的过程。

下图展示了冒泡排序的第一次遍历。
在这里插入图片描述
冒泡排序的细节:

  1. 对于大小为 n 的列表,只需要进行 n-1 次遍历【排完 n-1 个数后,剩下的1个数自然就在正确的位置上了
  2. 第 i 次遍历只需要操作列表的前 n-i 个项。
Python代码
def bubble_sort(nums):
	for passNum in range(len(alist)-1,0,-1):
		for i in range(passNum):
			if alist[i] > alist[i + 1]:
				alist[i], alist[i + 1] = alist[i + 1], alist[i]

nums = [54,26,93,17,77,31,44,55,20]
bubble_sort(nums)
print(nums)
C++代码
复杂度分析
  • 比较次数:1 到 n-1 的和,即 n2/2 - n/2,复杂度为 O ( n 2 ) O(n^2) O(n2

  • 交换次数

    • 最好的情况, 列表已经排序, 则不会进行交换
    • 最坏的情况, 每次比较都会导致交换元素
    • 平均情况下, 我们交换了一半时间

冒泡排序通常被认为是 最低效 的排序方法, 因为它在最终位置被知道之前交换项。 这些“浪费”的交换操作是非常昂贵的。

二、选择排序(Selection Sort)

选择排序是 对冒泡排序的改进, 每次遍历列表最多只做一次交换。具体地, 选择排序 在遍历过程中 定位最大的值, 完成遍历之后再 将其放到正确的位置

下图展示了选择排序的完整过程。
在这里插入图片描述

Python代码
def selectionSort(alist):
	for fillslot in range(len(alist)-1,0,-1):
		positionOfMax = 0
		for location in range(1,fillslot+1):
			if alist[location]>alist[positionOfMax]:
				positionOfMax = location
		
		temp = alist[fillslot]
		alist[fillslot] = alist[positionOfMax]
		alist[positionOfMax] = temp

alist = [54,26,93,17,77,31,44,55,20]
selectionSort(alist)
print(alist)
复杂度分析
  • 比较次数:与冒泡排序有相同的比较次数,即 O ( n 2 ) O(n^2 ) O(n2)

  • 交换次数:选择排序的交换次数 ≤ n-1 ≤ 冒泡排序的交换次数【绝大多数情况下比冒泡排序更快】。对于示例中的列表, 冒泡排序有 20 次交换, 而选择排序只有 8 次

三、插入排序(Insertion Sort)

插入排序的思路是 始终在列表的低位维护一个排序的子列表, 每次将后面的新项逐个 “有序插入” 先前的子列表
在这里插入图片描述
有序插入的实现 针对已经排序的子列表检查当前项,大的项向后移,当到达较小或相等的项或子列表的末尾时, 插入当前项。 如下图所示在这里插入图片描述

Python代码
def insertionSort(alist):
	for index in range(1,len(alist)):
		currentvalue = alist[index]
		position = index
		
		while position > 0 and alist[position-1] > currentvalue:
			alist[position] = alist[position-1]
			position = position - 1

		alist[position] = currentvalue

alist = [54,26,93,17,77,31,44,55,20]
insertionSort(alist)
print(alist)
复杂度分析
  • 比较次数:插入排序的最大(差)比较次数是 1 到 n-1 的和,同样是 O ( n 2 ) O(n^2) O(n2)。在最好的情况下【列表已经排序】, 每次通过只需要进行一次比较,即 O ( n ) O(n) O(n)

  • “交换” 次数: 需要注意的是,插入排序中使用的是移位操作而非交换操作移位操作只需要交换大约三分之一的处理工作, 因为仅执行一次分配】。

四、希尔排序(Shell Sort)

待补充

O(nlogn) 的排序算法

五、堆排序(Heap Sort)

六、归并排序(Merge Sort)

《浙江大学数据结构MOOC》 关于这一块讲得很好,提到了实现上一些影响效率的细节【函数统一接口,空间的malloc和free的位置(针对C语言而言)】。

归并排序的 核心有序子列的归并

基础知识:有序子列的归并

即将 两个排序的子列表 组合成单个排序的新列表。

实现的方式很简单,定义三个指针,比较A, B两个列表当前元素的大小,把小的放进列表C的当前位置,以此类推可。
在这里插入图片描述
复杂度:对于两个子列共有N个元素的情况,时间复杂度T(N) = O(N)。

归并排序算法思想

归并排序是一种 递归算法,是典型的 分而治之(即先分后治) 的思想,其思路为

  1. 将列表从中点处分成两个子列
  2. 对两个子列递归进行归并排序
  3. 对排好序的两个子列进行归并

具体流程如下图所示:

在这里插入图片描述
在这里插入图片描述

Python代码

两种写法

参考:用python实现归并排序(模版)

写法一、传入递归函数的是拷贝的列表(代码比较简洁)

def mergeSort(alist):
	if len(alist) <= 1:  # 递归终止条件:输入为空列表或者单元素列表时,不做处理
		return 

	mid = len(alist) // 2
	left_half = alist[:mid]
	right_half = alist[mid:]
	
	mergeSort(left_half)
	mergeSort(right_half)
	
	left = 0
	right = 0
	index = 0
	while left < len(left_half) and right < len(right_half):
		if left_half[left] < right_half[right]:
			alist[index] = left_half[left]
			left += 1
		else:
			alist[index] = right_half[right]
			right += 1
		index += 1
	
	while left < len(left_half):
		alist[index] = left_half[left]
		left += 1
		index += 1
	while right < len(right_half):
		alist[index] = right_half[right]
		right += 1
		index += 1

alist = [54,26,93,17,77,31,44,55,20]
mergeSort(alist)
print(alist)

写法二、传入递归函数的是原列表+左右边界

参考:浙江大学数据结构MOOC

def mergeSort(alist):
	"""统一函数接口"""
	tmp = [0] * len(alist)  # 子列归并的辅助数组
	mergeSortHelper(alist, 0, len(alist) - 1, tmp)

def mergeSortHelper(alist, start, end, tmp):
	"""核心递归函数"""
	if start >= end:  # 递归终止条件:输入列表为空列表或者单元素列表时,不做处理
		return
	
	# 输入列表包含两个或两个以上元素时	
	mid = (start + end) // 2
	mergeSortHelper(alist, start, mid, tmp)
	mergeSortHelper(alist, mid + 1, end, tmp)
	merge(alist, start, mid ,end, tmp)
	
def merge(alist, start, mid, end, tmp):
	"""归并 alist[start]~alist[mid] 和 alist[mid+1]~alist[end] 两个排序子列"""
	left = start
	right = mid + 1
	index = start

	while left <= mid and right <= end:
		if alist[left] < alist[right]:
			tmp[index] = alist[left]
			left += 1
		else:
			tmp[index] = alist[right]
			right += 1
		index += 1
		
	while left <= mid:
		tmp[index] = alist[left]
		left += 1
		index +=1
	while right <= end:
		tmp[index] = alist[right]
		right += 1
		index +=1
	
	# 将tmp复制回alist
	for i in range(start, end + 1):
		alist[i] = tmp[i]

alist = [54,26,93,17,77,31,44,55,20]
mergeSort(alist)
print(alist)
复杂度分析

T ( N ) = T ( N / 2 ) + T ( N / 2 ) + O ( N ) → T ( N ) = O ( N l o g N ) T(N)=T(N/2)+T(N/2)+O(N)→T(N)=O(NlogN) T(N)=T(N/2)+T(N/2)+O(N)T(N)=O(NlogN)

  • 时间复杂度归并算法没有最好时间复杂度,最坏时间复杂度,任何情况下都是 O ( N l o g N ) O(NlogN) O(NlogN),是非常稳定的

七、快速排序(Quick Sort)

传说中最快的排序算法准确地来说,在大多数情况下,对于大规模的随机数据,快速排序的表现是相当出色的】。

和归并排序类似,快排也是采用 分而治之,递归 的策略。

快速排序的核心是 partition(划分),也就是

  1. 在列表中挑选一个元素作为 主元pivot,/ˈpɪvət/,也叫做枢轴值)
  2. 利用主元将列表 划分 成小于 (等于) 主元和大于 (等于) 主元的 两个部分 【等于主元的项有几种不同的处理方式,在下面会进行讨论】

快速排序的思路为

  1. 基于主元对列表进行 partition,小于 (等于) 主元的子列放在主元左边,大于 (等于) 主元的子列放在主元右边
  2. 对两部分子列递归调用快速排序
    在这里插入图片描述

每次递归划分完子列后,主元就一次性被放到了最终的正确位置上,再不需要移动,这也是快速排序快的重要原因。

快速排序的细节

快速排序有很多小细节,如果在小细节上没有实现好,快速排序可能会变得“不快速”。

我们要知道,快速排序的最好情况,是每次正好中分,此时T(N)=O(NlogN),在细节上我们要尽可能地满足这点。

1、主元如何选择?

pivot = A[0]? 感觉更为常用

不过对于排序列表而言不是很聪明,如下图所示。
在这里插入图片描述
不过这种情况毕竟是少数。实际上,不管主元选的是哪个,我们总是能构造出一种序列,使得快速排序的时间复杂度最坏,等于O(N^2)

pivot = A[mid]?

随机取pivot? rand()函数不便宜。

④ 一个比较好的做法,取头中尾中【start, center, end】的中位数作为pivot(参考 浙大数据结构MOOC)。具体代码如下:

def median3(alist, start, end):
	'''三数取中位数'''
	center = (start + end) // 2
	
	# 排序,使得A[start] <= A[Center] <= A[end]
	if alist[start] > alist[center]:
		alist[start], alist[center] = alist[center], alist[start]
	if alist[start] > alist[end]:
		alist[start], alist[end] = alist[end], alist[start]
	if alist[center] > alist[end]:
		alist[center], alist[end] = alist[end], alist[center] 
	
	# 小技巧
	# 将pivot藏到右边,这样考虑子集划分时
	# 只需要考虑从 A[start+1] 到 A[end–2]即可
	alist[center], alist[end - 1] = alist[end - 1], alist[center] 

	return alist[end - 1]  # 返回 pivot
2、 如何基于主元来划分子集?

划分子集,即将列表划分成小于 (等于) 主元和大于 (等于) 主元的两部分,小于 (等于) 的放到主元左边,大于(等于)的数放在主元右边。
在这里插入图片描述

使用辅助空间 来实现 partition 非常简单。

如果 不使用辅助空间,原地(in-place) 划分子集的话,常见的有以下两种:

  1. 一个指针遍历列表,把小于pivot的数通过交换的方式放到左边
  2. 双指针,从两边出发向中间移动,交换相对于主元位于错误侧的数

不管是哪种方法,划分子集时都需要 先把 pivot 换到列表的头部位置(当然也可以是尾部),再对列表的剩余部分进行划分,这是为了避免把主元的位置搞丢了。

① 同向双指针,把小于pivot的数放到左边

定义一个small指针,用于追踪小于 pivot 的数的右边界(指向最后一个小于pivot的数)

def partition(arr, start, end):
	pivot = arr[start]  # 这里选取第一个数作为pivot
	
	# 先把pivot换到列表头部
	# 这里直接取第一个数作为pivot所以省去了这步
	
	# 找到小于pivot的数就丢给small
	small = start
	for i in range(start + 1, end + 1):
		if arr[i] < pivot:
			small += 1
		    arr[i], arr[small] = arr[small], arr[i]

	# 此时small指向的位置就是划分点的位置
	# 将pivot换到划分点	
	arr[small], arr[start] = arr[start], arr[small] 
	
	# 返回划分点的位置	
	return small

② 相向双指针,交换相对于主元位于错误侧的数

如下图所示
在这里插入图片描述
在这里插入图片描述

  • 在列表除开start位置的剩余项的头部和尾部定义两个指针。
  • 重复以下过程:
    • 移动左指针, 找到大于 (等于) 主元的值
    • 移动右指针, 找到小于 (等于) 主元的值
    • 交换两个 相对于最终划分点位于错误侧 的项,交换该两项
  • 当左右指针 交叉(left > right) 时, 停止循环【如果重合就停止循环,此时重合点的项到底是大于 pivot 还是小于 pivot 是不确定的,我们无法确定分界点的位置】。
  • 此时,右指针所处的位置即为 主元应该在的位置(分界点)。我们将主元和分界点的内容交换,此时, 分界点左侧的所有项都小于等于主元, 分界点右侧的所有项都大于等于主元。
def partition(alist, start, end):
	pivot = alist[start]
	left = start + 1
	right = end
	
	while left <= right:  # 当左右指针交叉(left > right)时,结束循环
		while left <= right and alist[left] < pivot:  # left指针找大于等于pivot的数
			left += 1
		while left <= right and pivot < alist[right]:  # right指针找小于等于pivot的数
			right -= 1
		if left <= right:
			alist[left], alist[right] = alist[right], alist[left]
			left += 1
			right -= 1
	
	# 此时right所指向的位置就是划分点的位置
	# 将pivot换到划分点的位置
	alist[start], alist[right] = alist[right], alist[start]
	
	# 返回划分点的位置
	return right

思考:对于等于主元的元素,为什么我们选择停下来交换?

对于等于主元的元素,实际上我们有两种选择:

1、停下来交换?

  • 缺点:极端情况下,列表中的所有元素都是同一个数,会导致很多无用交换。
  • 优点:最后两个指针会停在比较中间的位置,也就是每一次递归时,序列能够基本上被等分成两个等长序列

2、无视,继续移动指针?

  • 优点:极端情况下,列表元素都是同一个数的情况,可以避免很多无用交换
  • 缺点:每次主元都会落到边缘处

两者相比较,我们更倾向于 1

①②两种划分方式,我们一般用②不用①,因为它对于 列表中有多个等于主元的项 的情况,它可以更划分地更平均,所以在对于列表中有多个重复元素的情况下效率更高,而①的效率则有可能 退化 为O(n^2)。

Python代码

参考:python-data-structure

简单取第一项作为主元

def quickSort(alist): 
	'''统一函数接口'''
	quickSortHelper(alist, 0, len(alist)-1)

def quickSortHelper(alist, start, end):  
	'''核心递归函数'''
	if start >= end:  # 递归结束条件:输入列表为空列表或单元素列表时,不作处理
		return
	
	# 输入列表包含两个或两个以上元素时
	splitPoint = partition(alist, start, end)
	quickSortHelper(alist, start, splitPoint - 1)
	quickSortHelper(alist, splitPoint + 1, end)
		
def partition(alist, start, end):
	'''划分函数'''
	pivot = alist[start]
	left = start + 1
	right = end
	
	while left <= right:  # 当左右指针交叉(left > right)时,结束循环
		while left <= right and alist[left] < pivot:  # left指针找大于等于pivot的数
			left += 1
		while left <= right and pivot < alist[right]:  # right指针找小于等于pivot的数
			right -= 1
		if left <= right:
			alist[left], alist[right] = alist[right], alist[left]
			left += 1
			right -= 1
	
	# 此时right所指向的位置就是划分点的位置
	# 将pivot换到划分点的位置
	alist[start], alist[right] = alist[right], alist[start]
	
	# 返回划分点的位置
	return right
	
alist = [54,26,93,17,77,31,44,55,20]
quickSort(alist)
print(alist)
复杂度分析

时间复杂度

  • 最好复杂度: O ( n l o g n ) O(nlogn) O(nlogn)
  • 最坏复杂度: O ( n 2 ) O(n^2) O(n2)
补充
1、三路快排

面对 有大量重复元素的数据 时,我们可以进一步优化,使用三路快排。

三路快排要做的事情,其实就是将数组分成三部分:小于主元,等于主元和大于主元,然后对小于主元和大于主元的两部分递归进行快速排序。
在这里插入图片描述
具体操作:

定义三个指针,left, right, index,其中

  • left 用于 追踪 小于主元项 的 右边界
  • right 用于追踪 大于主元项 的左边界
  • index 从左到右扫描整个数组
    • 碰到 小于主元项 就丢给 left
    • 碰到 大于主元项 就丢给 right
    • 碰到 等于主元项 就跳过

代码:

```python
def three_ways_partition(alist, start, end):
	pivot = alist[start]

	left = start
	right = end
	index = start
	while index <= right:  # 循环结束条件:index > right
		if alist[index] < pivot:
			alist[index], alist[left] = alist[left], alist[index]
			left += 1
			index += 1
		elif pivot < alist[index]:
			alist[index], alist[right] = alist[right], alist[index]
			right -= 1  # 注意这里index不需要++
		else:
			index += 1

	# 返回左右子列的边界位置
	return left - 1, right + 1

在很多语言的标准库中,排序接口使用的就是三路快排,比如Java。

2、小规模数据的处理:快速排序和插入排序的结合

参考:浙江大学数据结构MOOC

因为快速排序使用了递归,会占用额外的堆栈空间,进栈出栈需要时间对于小规模的数据(比如N不到100),使用快速排序可能还不如插入排序来的快

改进方案

  • 当递归的数据规模充分小,则停止递归,直接调用简单排序【比如插入排序】
  • 在程序中定义一个 Cutoff (阈值)

C语言实现
在这里插入图片描述

def insertionSort(alist, start, end):
	for index in range(start, end + 1):
		currentvalue = alist[index]
		position = index

		while position > 0 and alist[position-1] > currentvalue:
			alist[position] = alist[position-1]
			position = position - 1

		alist[position] = currentvalue


def median3(alist, start, end):
	'''三数取中位数'''
	center = (start + end) // 2

	# 排序,使得A[start] <= A[Center] <= A[end]
	if alist[start] > alist[center]:
		alist[start], alist[center] = alist[center], alist[start]
	if alist[start] > alist[end]:
		alist[start], alist[end] = alist[end], alist[start]
	if alist[center] > alist[end]:
		alist[center], alist[end] = alist[end], alist[center]

	# 将pivot藏到右边
	alist[center], alist[end - 1] = alist[end - 1], alist[center]

	return alist[end - 1]  # 返回 pivot


def partition(alist, start, end):
	'''划分函数'''
	pivot = median3(alist ,start, end)
	left = start + 1
	right = end - 2

	while 1:
		while left <= right and alist[left] < pivot:
			left += 1
		while left <= right and pivot < alist[right]:
			right -= 1
		if left < right:
			alist[left], alist[right] = alist[right], alist[left]
			left += 1
			right -= 1
		else:
			break
	alist[left], alist[end-1] = alist[end-1], alist[left]
	return left


def quickSortHelper(alist,start,end):
	'''核心递归函数,也可以叫xxxCore'''
	cutoff = 10

	if cutoff < end - start:
		splitPoint = partition(alist, start, end)
		quickSortHelper(alist, start, splitPoint-1)
		quickSortHelper(alist, splitPoint+1, end)
	else:
		insertionSort(alist,start,end)


def quickSort(alist):
	'''统一函数接口'''
	if alist == []:
		return []
	quickSortHelper(alist, 0, len(alist) - 1)


alist = list(range(1000,0,-1))
quickSort(alist)
print(alist)

线性复杂度的排序算法

八、基数排序

线性时间复杂度排序

基数排序–基于计数排序的线性时间复杂度的排序算法

排序算法系列:基数排序

总结:各排序算法比较

我们主要从以下几个维度来评价排序算法的好坏:

  1. 时间复杂度。包括平均时间复杂度和最坏空间复杂度
  2. 空间复杂度
  3. 稳定性。即算法是否会改变序列中2个相等的数的相对位置

在这里插入图片描述


《浙江大学数据结构MOOC》

《problem-solving-with-algorithms-and-data-structure-using-python》

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值