本文是根据 Datawhale 开源教程LeetCode 算法笔记(Leetcode-Notes)做的笔记 https://github.com/datawhalechina/leetcode-notes,主要记录学习过程中的一些重要的是知识点。
冒泡排序
基本思想
从小到大
- 从第一个元素开始,和相邻的元素进行比较,如果左小右大,那么就交换,进过依次排序后,可以使最大的元素放大最后
- 以后每一趟都会使剩下未排序中的最大的元素,放到后面
- 总共进行n-1趟排序
- 从左向右进行比较,如果左边的大于右边的,就交换,否则,就保持不变;
- 每趟排序会将未排序里面的最大的移动到右侧
- 剩下最后一个未排序好的元素时,自然就知道他最小不用在进行比较了
基本实现
使用 flag 的目的是为了提前结束排序过程,当在一趟排序中没有发生任何交换时,说明数组已经是有序的,后续的遍历不会再有交换操作,因此可以提前退出循环,节省了不必要的比较和交换次数,从而提高了算法的效率。
def bubbleSort(arr):
n=len(arr)
#不用排序
if n<=1:
return arr
for i in range(0,n-1,1): #n-1趟排序
flag = False #用来标记是否进行了交换
for j in range(0,n-i-1,1): #每趟需要比较的次数
if arr[j] > arr[j+1]:
flag = True #标记为此趟进行了比较
arr[j],arr[j+1]=arr[j+1],arr[j] #交换元素
#(优化部分)判断如果上一趟没有交换,说明已经排好序了,后面的无需再去比较
if not flag: #flag=false
break
return arr
> #测试代码 arr = [7, 2, 1, 6, 8, 5, 3, 4]
> sorted_arr = bubbleSort(arr)
> print(sorted_arr)
[1, 2, 3, 4, 5, 6, 7, 8
时间复杂度
既然排序完需要3趟,第一趟需要比较3次,第二趟需要比较2次,第三趟需要比较1次,那一共比较了 3 + 2 + 1 次
那推广到数量为 n 的规模的话,那就需要 (n-1) + (n-2) +…+2+1 次,这不就是一个等差数列吗,很显然:
复杂度就是O(n^2)了
稳定性
所谓稳定性,其实就是说,当你原来待排的元素中间有相同的元素,在没有排序之前它们之间有先后顺序,
在排完后它们之间的先后顺序不变,我们就称这个算法是稳定的”
(相邻的相同元素,排序后不改变顺序)
注意:
- 最好情况下O(n),也就是初始时序列已经是升序序排列
- 最差的情况下 O(n^2),(初始时序列已经是降序排列,或者最小值元素处在序列的最后)
- 稳定排序算法,可以指定相等的时候不交换
- 空间复杂度: O ( 1 ) O(1) O(1)。冒泡排序为原地排序算法,只用到指针变量 i i i、 j j j 以及标志位 f l a g flag flag 等常数项的变量。
- 适合于参加排序序列的数据量较小的情况,尤其是当序列的初始状态为基本有序的情况。
选择排序
基本思想
选择排序就是不断地从未排序的元素中选择最大(或最小)的元素放入已排好序的元素集合中,直到未排序中仅剩一个元素为止
- 最开始,随机选择一个为最大值,这里默认为第一个元素
- 然后遍历整个未排序的数组,从中选出最大值所在位置的索引,然后将其与第一个最大值所在索引交换
- 这时候 i 表示的就是下一个排序好的元素的位置
基本实现
#从小到大
def selection_sort(arr):
n = len(arr)
for i in range(0,n-1,1): #n-1次遍历
min_index = i #假设当前索引为最小值的索引
# 在未排序部分中找到最小值的索引
for j in range(i+1,n,1):
if arr[j] < arr[min_index]:
min_index = j
# 将最小值与当前位置交换
if i != min_index:
arr[min_index],arr[i] = arr[i],arr[min_index]
return arr
# 测试代码
arr = [7, 2, 1, 6, 8, 5, 3, 4]
sorted_arr = selection_sort(arr)
print(sorted_arr)
[1, 2, 3, 4, 5, 6, 7, 8]
时间复杂度
外层:n-1
内层:n-i
比较次数:(n-1)+(n-2)+(n-3)+(n-4)+…+1 = (n-1)n/2
交换次数: 每层1次,共n次
总计,(n-1)n/2 + n,也就是时间复杂度为O(n^2)
注意:
- 时间复杂度总是O(n^2),因为元素之间的比较次数与序列的原始状态无光
- 空间复杂度O(1),原地排序算法,只用到指针变量 i i i、 j j j 以及最小值位置 m i n ‾ i min\underline{}i mini 等常数项的变量。
- 使用情况:数据量比较小,特别是对空间复杂度有要求时
- 排序稳定性:不稳定 ,选定的最小元素值和未排序区间的最小元素值交换时,可能会改变相同元素的相对顺序
插入排序
基本思想
插入排序是一种比较简单直观的排序算法,适用处理数据量比较少或者部分有序的数据
思想:把未排序的元素一个一个的插入到有序的集合中,插入时,把有序集合从后向前扫描一遍,找到合适的位置就插入
它的思想就像是整理扑克牌的过程。通过逐个将未排序的元素插入到已排序部分的合适位置,逐步构建有序序列。
下面以从小到大排序为例,给出插入排序的通俗易懂的解释:
- 首先,将数组的第一个元素视为已排序部分,而将剩余的元素视为未排序部分。
- 从未排序部分中取出第一个元素,将其与已排序部分中的元素从后往前进行比较。
- 如果已排序部分中的元素大于取出的元素,就将已排序部分中的元素后移一位,为取出的元素腾出位置。
- 重复上述比较和后移的过程,直到找到已排序部分中的元素小于等于取出的元素,或者已经到达已排序部分的开头。
- 将取出的元素插入到合适的位置,形成一个更长的已排序部分。
- 重复步骤2到步骤5,直到未排序部分中的所有元素都被插入到已排序部分。
通过不断地将未排序部分的元素插入到已排序部分,最终形成一个完全有序的数组。
插入排序的特点:
- 每次将一个元素插入到已排序部分,扩大已排序部分的长度。
- 在插入过程中,需要不断地比较和移动元素。
- 每次插入的位置是从已排序部分的末尾开始,逐渐向前找到合适的位置。
总结起来,插入排序就像是将未排序部分的元素逐个插入到已排序部分的合适位置,逐步构建有序序列。尽管它的时间复杂度较高,但在处理小规模数据时,它的性能通常是可以接受,插入排序是一种简单易懂的排序算法。
数据有序程度越高,越高效(移动少)
基本实现
def insertion_sort(arr):
# 从第二个元素开始,将其插入到已排序部分的合适位置
for i in range(1,len(arr)):
val = arr[i] #当前要插入的元素
j = i-1 # 将已排序部分的末尾索引保存在j变量中
# 插入元素找到合适的位置
while j >= 0 and arr[j] > val:
arr[j+1] = arr[j] #后移一位,为插入元素让位
j -= 1
# 将元素插入到合适位置
arr[j+1] = val
return arr
# 测试代码
arr = [7, 2, 1, 6, 8, 5, 3, 4]
sorted_arr = insertion_sort(arr)
print(sorted_arr)
[1, 2, 3, 4, 5, 6, 7, 8]
时间复杂度
下面讨论最坏时间复杂度,即所有元素倒序
然后insertToRightPosition里的内层for循环的循环次数是根据 i 来决定的,i = 1时,循环 1 次,i = 2,循环 2 次,…,i = n-1,循环 n-1次,那总共加起来就是
根据复杂度计算规则,保留高阶项,并去掉系数,那么时间复杂度为O(n^2)
四. 稳定性
注意:
- 最好的情况下(初始时区间已经是升序排列),每个元素只进行一次元素之间的比较,因而总的比较次数最少,为 ∑ i = 2 n 1 = n − 1 ∑^n_{i = 2}1 = n − 1 ∑i=2n1=n−1,并不需要移动元素(记录),这是最好的情况。
- 最差的情况下(初始时区间已经是降序排列),每个元素 n u m s [ i ] nums[i] nums[i] 都要进行 i − 1 i - 1 i−1 次元素之间的比较,元素之间总的比较次数达到最大值,为 ∑ i = 2 n ( i − 1 ) = n ( n − 1 ) 2 ∑^n_{i=2}(i − 1) = \frac{n(n−1)}{2} ∑i=2n(i−1)=2n(n−1)。
- 空间复杂度: O ( 1 ) O(1) O(1)。插入排序算法为原地排序算法,只用到指针变量 i i i、 j j j 以及表示无序区间中第 1 1 1 个元素的变量等常数项的变量。
- 排序稳定性:在插入操作过程中,每次都讲元素插入到相等元素的右侧,并不会改变相等元素的相对顺序。因此,插入排序方法是一种 稳定排序算法。
归并排序
def merge_sort(arr):
n = len(arr)
if n <= 1:
return arr
#分割数组
mid = n // 2 # //:整数除法运算符,它返回的结果是除法运算的商,并且结果将被截断为整数部分,舍弃小数部分
left = arr[0:mid]
right = arr[mid:]
#递归的对左右数组进行排序
left = merge_sort(left)
right = merge_sort(right)
# 合并左右子数组
return merge(left,right)
def merge(left,right):
merged = [] #存放合并后的结果
i = j = 0 #分别指向左右子数组
# 将两个中较小的存放到merged中
while i < len(left) and j < len(right):
if left[i] <= right[j]:
merged.append(left[i])
i += 1
else:
merged.append(right[j])
j += 1
#剩余的添加到结果集中
while i < len(left):
merged.append(left[i])
i += 1
while j < len(right):
merged.append(right[j])
j += 1
return merged
在每一层递归中,需要对n个元素进行合并操作。而递归的层数取决于原始数组的大小。假设原始数组的长度为n,每一次递归都将数组划分为两个相等的子数组,所以递归层数为logn。因此,归并排序的时间复杂度为O(nlogn)。
归并排序的时间复杂度是稳定的,不受原始数据的影响,无论是最好情况、最坏情况还是平均情况,时间复杂度都保持不变
在归并排序的合并过程中,当两个元素值相等时,我们会先将左侧子数组的元素放入合并后的数组中。这就确保了相同元素在合并后的数组中的相对顺序与在原始数组中的相对顺序保持一致。
希尔排序
希尔排序(ShellSort)是以它的发明者Donald Shell名字命名的,希尔排序是插入排序的改进版,实现简单,对于中等规模数据的性能表现还不错
希尔排序是一种基于插入排序的排序算法,它通过将待排序的元素分组进行插入排序,逐渐缩小分组的间隔,最终完成整体的排序
下面给出希尔排序的通俗易懂解释:
- 首先,将待排序的元素按照一定间隔分成几个子序列,对每个子序列分别进行插入排序。
- 初始时,间隔(称为增量)的取值较大,这样每个子序列的元素相对较少,排序起来相对容易。
- 然后,逐渐缩小增量,重复上述步骤,每次都对分组后的子序列进行插入排序。
- 当增量缩小到1时,整个序列被分成一个子序列,这时进行最后一次插入排序,使得序列最终有序。
希尔排序的优点是相对于其他简单排序算法,它在处理大规模数据时性能更好。由于希尔排序每次都将相距较远的元素进行比较和交换,可以更快地将较小的元素移动到正确的位置,从而减少了后续比较和交换的次数。
def shell_sort(arr):
n = len(arr)
gap = n // 2 # 初始间隔设为数组长度的一半
# 根据增量gap进行分组,逐步缩小增量直到为1
while gap > 0:
# 在当前间隔下执行插入排序
for i in range(gap,n):
val = arr[i] # 当前要插入的元素
j = i-gap # 将已排序部分的末尾索引保存在j变量中
# 插入元素找到合适的位置
while j >= 0 and arr[j] > val:
arr[j+gap] = arr[j]
j -= gap
# 将元素插入到合适位置
arr[j+gap] = val
gap //= 2
return arr
)
希尔排序的复杂度和增量序列是相关的
{1,2,4,8,…}这种序列并不是很好的增量序列,使用这个增量序列的时间复杂度(最坏情形)是O(n^2)
Hibbard提出了另一个增量序列{1,3,7,…,2k-1},这种序列的时间复杂度(最坏情形)为O(n1.5)
Sedgewick提出了几种增量序列,其最坏情形运行时间为O(n^1.3),其中最好的一个序列是{1,5,19,41,109,…}
快速排序
基本原理
快速排序是一种常用的排序算法,它的思想非常巧妙。我将用通俗易懂的方式解释它的工作原理。
假设你手上有一堆乱序的扑克牌,你想要将它们按照大小顺序排列。快速排序的方法是这样的:
-
首先,你选择一张牌作为"基准"(pivot),可以是任意一张牌。
-
接下来,你将所有比基准小的牌放在它的左边,所有比基准大的牌放在它的右边。这样,基准牌所在的位置就是它最终排好序的位置。
-
然后,你再对基准牌左边的牌和右边的牌重复上述步骤。也就是说,对左边的牌堆和右边的牌堆分别选择一个新的基准牌,将它们分成更小的两堆,直到每堆只有一张牌或者为空为止。
-
最后,当所有的牌堆都只剩下一张牌或者为空时,排序就完成了。此时,所有的牌就按照大小顺序排列好了。
快速排序的核心思想是通过不断地选取基准牌,将待排序的元素划分成两个部分,然后递归地对这两部分进行排序。通过每一轮的划分,待排序的元素会不断减少,直到最终完成排序。
值得注意的是,快速排序是一种原地排序算法,它不需要额外的空间来存储临时数据,而是通过在原数组上进行交换来实现排序。这也是它高效的一个原因。
总结一下,快速排序是通过选取基准牌将待排序的元素划分为两个部分,然后递归地对这两部分进行排序,最终完成整个排序过程。它的思想简单而巧妙,是一种高效的排序算法。
时间复杂度
快速排序的平均时间复杂度为O(n log n),其中n是待排序数组的长度。在最坏情况下,快速排序的时间复杂度为O(n^2)。
快速排序的时间复杂度的分析如下:
- 每一次划分操作需要遍历数组一次,时间复杂度为O(n)。
- 在划分操作之后,快速排序会递归地对两个子数组进行排序。
- 假设数组被均匀地划分,即每次划分都将数组分成大小相等的两部分,则快速排序的递归树的高度为log n。
- 每一层递归的划分操作都需要O(n)的时间。
因此,在平均情况下,快速排序的时间复杂度为O(n log n)。在最坏情况下,即每次划分都将数组分成大小极不平衡的两部分,递归树的高度为n,时间复杂度为O(n^2)。
需要注意的是,快速排序的时间复杂度是平均情况下的复杂度,而在实际应用中,它通常比其他排序算法表现更好,因为它的常数因子较小。
递归法
def quick_sort(arr):
#不用排序
if len(arr) <= 1:
return arr
#选择一个基准元素
pivot = arr[0]
#将比基准小的放到一个数组中
smaller=[i for i in arr[1:] if i < pivot]
#将比基准大的放到一个数组中
greater=[i for i in arr[1:] if i > pivot]
# 递归地对较小和较大的子列表进行快速排序
return quick_sort(smaller) + [pivot] + quick_sort(greater)
双指针法
def quick_sort(arr, low, high):
if low < high:
# 使用双边循环法划分子数组
pivot_index = partition(arr, low, high)
# 递归地对基准元素的左右两侧子数组进行排序
quick_sort(arr, low, pivot_index - 1)
quick_sort(arr, pivot_index + 1, high)
def partition(arr, low, high):
# 选择基准元素
pivot = arr[low]
left = low + 1
right = high
done = False
while not done:
# 向右找到第一个大于基准元素的位置
while left <= right and arr[left] <= pivot:
left += 1
# 向左找到第一个小于基准元素的位置
while arr[right] >= pivot and right >= left:
right -= 1
if right < left:
done = True
else:
# 交换左右两个元素
arr[left], arr[right] = arr[right], arr[left]
# 将基准元素放在正确的位置
arr[low], arr[right] = arr[right], arr[low]
return right
在时间复杂度上,这两种实现方式是相同的。
然而,双指针法通常在实际应用中效率稍微高一些,因为它避免了递归带来的额外函数调用开销,而是在原地进行数组的划分和交换操作。
这使得双指针法在处理大规模数据时更具有优势。
基数排序
关于基数排序,还有以下几个问题,你不妨也想一想?
1、基数排序是一种用空间换时间的排序算法,数据量越大,额外的空间就越大?
我的想法:我觉得基数排序并非是一种时间换空间的排序,也就是说,数据量越大,额外的空间并非就越大。因为在把元素放进桶的时候,是完全可以用指针指向这个元素的,也就是说,只有初始的那些桶才算是额外的空间。
2、居然额外空间不是限制基数排序速度的原因,那为啥基数排序没有快速排序快呢?
基数的时间复杂度为O(n),不过他是忽略了常数项,即实际排序时间为kn(其中k是常数项),然而在实际排序的过程中,这个常数项k其实是很大的,这会很大程度影响实际的排序时间,而像快速排序虽然是nlogn,但它前面的常数项是相对比较小的,影响也相对比较小。
需要说明的是,基数排序也并非比快速排序慢,这得看具体情况,(不要被标题所影响哈)。而且,数据量越大的话,基数排序会越有优势。
3、有人可能会问,说了这么多,那到底是基数排序快还是快速排序快呢?
对于这样的问题,我只能建议你,自己根据不同的场景,撸几行代码,自己测试一下。
如果你问我,哪个排序在实际中用的更多,那么,我选快速排序。
](attachment:image.png)
这种方法确实可以减少比较的次数,不过请大家注意,在每个小部分的排序中,我们也是需要10个桶来将他们进行排序,最后导致的结果就是,每个不同值的元素都会占据一个“桶”,如果你有1000个元素,并且1000个元素都是不同值的话,那么从最高位排序到最低位,需要1000个桶。
这样子的话,空间花费不仅大,而且看起来有点背离基数排序最初的思想了(“背离”这个词,个人感觉而已)。所以,我们一般采用从最低位到最高位的顺序哦。
def radix_sort(arr):
"""
基数排序函数
Args:
arr: 待排序的数组
Returns:
排序后的数组
"""
# 找到数组中的最大值,确定排序的轮数
max_value = max(arr)
digit_count = len(str(max_value))
# 根据位数依次进行排序
for i in range(digit_count):
counting_sort(arr, i)
return arr
def counting_sort(arr, digit):
"""
计数排序函数,用于基数排序的每一轮排序
Args:
arr: 待排序的数组
digit: 当前排序的位数(从右到左,个位为0,十位为1,依次类推)
"""
n = len(arr)
output = [0] * n # 存储排序结果的临时数组
count = [0] * 10 # 用于计数的辅助数组
# 统计当前位上每个数字的出现次数
for i in range(n):
digit_value = (arr[i] // (10 ** digit)) % 10
count[digit_value] += 1
# 将计数数组进行累加,得到每个数字在排序结果中的位置
for i in range(1, 10):
count[i] += count[i - 1]
# 从原数组中取出元素,根据当前位的值放到正确的位置上
for i in range(n - 1, -1, -1):
digit_value = (arr[i] // (10 ** digit)) % 10
output[count[digit_value] - 1] = arr[i]
count[digit_value] -= 1
# 将排序结果拷贝回原数组
for i in range(n):
arr[i] = output[i]
# 测试代码
arr = [170, 45, 75, 90, 802, 24, 2, 66]
sorted_arr = radix_sort(arr)
print(sorted_arr)
基数排序的时间复杂度为O(d * (n + k)),其中d是待排序元素的位数,n是元素个数,k是每个位数可能的取值范围。
基数排序的时间复杂度是线性的,它不受待排序元素的大小影响。在每一轮排序中,需要对元素进行计数排序,而计数排序的时间复杂度为O(n + k),其中n是元素个数,k是每个位数可能的取值范围。所以,基数排序的总时间复杂度是O(d * (n + k))
基数排序是一种稳定的排序算法。稳定性是指相同元素的相对顺序在排序后保持不变。在基数排序中,每一轮排序都是基于前一轮的排序结果,对相同的数字进行排序,相同数字的相对顺序不会改变。因此,基数排序是稳定的排序算法。
需要注意的是,基数排序的时间复杂度较低,但它对于每个位数的取值范围较大的情况可能不太适用,因为计数排序的时间复杂度会随着k的增大而增加。在实际应用中,可以根据待排序元素的范围和位数的情况选择合适的排序算法。
堆排序
堆排序是一种基于二叉堆数据结构的排序算法,通过构建最大堆或最小堆来实现排序。它的核心思想是利用堆的特性来进行排序操作。
我们先来理解一下什么是堆。堆是一种完全二叉树,它分为最大堆和最小堆两种类型。
- 最大堆:在最大堆中,父节点的值总是大于或等于其子节点的值。
- 最小堆:在最小堆中,父节点的值总是小于或等于其子节点的值。
堆排序的基本思想如下:
- 首先,将待排序的数组构建成一个最大堆或最小堆。
- 接着,将堆顶元素(最大值或最小值)与最后一个元素交换位置,然后将堆的大小减1。
- 再次调整堆,使其满足堆的性质。
- 重复步骤2和3,直到堆的大小为1,此时数组已经排序完成。
在堆排序的过程中,通过不断交换堆顶元素和最后一个元素,并缩小堆的大小,可以确保每次交换后堆顶元素都是当前堆中的最大值或最小值。通过这种方式,最终可以得到一个有序的数组。
堆排序的优点是在最坏情况下的时间复杂度为O(nlogn),且不需要额外的辅助空间。然而,堆排序的缺点是相对于其他排序算法来说,其常数因子较大,因此在实际应用中可能不如其他排序算法效率高。
def heap_sort(arr):
"""
堆排序函数
Args:
arr: 待排序的数组
Returns:
排序后的数组
"""
# 构建最大堆
build_max_heap(arr)
# 逐个将最大值移到末尾,并调整堆
for i in range(len(arr) - 1, 0, -1):
# 将堆顶元素(最大值)与当前未排序部分的末尾元素交换位置
arr[0], arr[i] = arr[i], arr[0]
# 调整堆,将未排序部分重新构建成最大堆
max_heapify(arr, i, 0)
return arr
def build_max_heap(arr):
"""
构建最大堆
Args:
arr: 待构建堆的数组
"""
# 从最后一个非叶子节点开始,依次向上调整堆
for i in range(len(arr) // 2 - 1, -1, -1):
max_heapify(arr, len(arr), i)
def max_heapify(arr, n, i):
"""
最大堆调整函数
Args:
arr: 数组
n: 堆的大小
i: 当前节点的索引
"""
largest = i # 初始化当前节点索引为最大值索引
left = 2 * i + 1 # 左子节点索引
right = 2 * i + 2 # 右子节点索引
# 如果左子节点存在且大于当前节点,更新最大值索引
if left < n and arr[left] > arr[largest]:
largest = left
# 如果右子节点存在且大于当前节点,更新最大值索引
if right < n and arr[right] > arr[largest]:
largest = right
# 如果最大值索引发生变化,交换节点,并递归调整子树
if largest != i:
arr[i], arr[largest] = arr[largest], arr[i]
max_heapify(arr, n, largest)
# 测试代码
arr = [7, 2, 1, 6, 8, 5, 3, 4]
sorted_arr = heap_sort(arr)
print(sorted_arr)
堆排序的时间复杂度为O(nlogn),其中n是待排序数组的长度。
构建最大堆的时间复杂度是O(n),需要遍历数组并调用max_heapify函数进行堆调整,所以时间复杂度为O(n)。
每次调整堆的时间复杂度为O(logn),因为需要沿着树的高度进行节点交换,而树的高度为logn。
在堆排序的过程中,需要进行n-1次交换操作,并对堆进行调整。每次交换的时间复杂度为O(1),调整堆的时间复杂度为O(logn)。因此,总的时间复杂度为O(nlogn)。
堆排序是一种不稳定的排序算法。在堆的构建和调整过程中,涉及到交换操作,可能改变相同元素之间的相对顺序。所以,对于具有相同值的元素,排序后它们的相对位置可能会发生改变,导致排序结果不稳定。
需要注意的是,堆排序虽然不稳定,但由于其时间复杂度较低且不需要额外的辅助空间,它在一些应用场景下仍然具有一定的优势。