排序算法
排序算法分类

🧩冒泡排序
为什么叫 “冒泡”
在水中,越大的泡泡浮力越大,所以最大的泡泡会最先浮到水面。
冒泡操作则是在模拟上述过程,具体做法为:从数组最左端开始向右遍历,依次对比相邻元素大小,若 左元素 > 右元素 则将它俩交换,最终可将最大元素移动至数组最右端。
完成此次冒泡操作后,数组最大元素已在正确位置,接下来只需排序剩余 n−1 个元素。

原理和实现
算法流程
- 设数组长度为 n ,完成第一轮「冒泡」后,数组最大元素已在正确位置,接下来只需排序剩余 n−1 个元素。
- 同理,对剩余 n−1 个元素执行「冒泡」,可将第二大元素交换至正确位置,因而待排序元素只剩 n−2 个。
- 以此类推…… 循环 n−1 轮「冒泡」,即可完成整个数组的排序。

python实现
""" 冒泡排序 """
def bubble_sort(nums):
    n = len(nums)
    # 外循环:待排序元素数量为 n-1, n-2, ..., 1
    for i in range(n - 1, -1, -1):
        # 内循环:冒泡操作
        for j in range(i):
            if nums[j] > nums[j + 1]:
                # 交换 nums[j] 与 nums[j + 1]
                nums[j], nums[j + 1] = nums[j + 1], nums[j]
算法优化
我们发现,若在某轮「冒泡」中未执行任何交换操作,则说明数组已经完成排序,可直接返回结果。考虑可以增加一个标志位 flag 来监听该情况,若出现则直接返回。
优化后,冒泡排序的最差和平均时间复杂度仍为 O(n2) ;而在输入数组 已排序 时,达到 最佳时间复杂度 O(n) 。
""" 冒泡排序(标志优化) """
def bubble_sort_with_flag(nums):
    n = len(nums)
    # 外循环:待排序元素数量为 n-1, n-2, ..., 1
    for i in range(n - 1, -1, -1):
        flag = False  # 初始化标志位
        # 内循环:冒泡操作
        for j in range(i):
            if nums[j] > nums[j + 1]:
                # 交换 nums[j] 与 nums[j + 1]
                nums[j], nums[j + 1] = nums[j + 1], nums[j]
                flag = True  # 记录交换元素
        if not flag:
            break            # 此轮冒泡未交换任何元素,直接跳出
复杂度
时间复杂度 O(n2) : 各轮「冒泡」遍历的数组长度为 n−1 , n−2 , ⋯ , 2 , 1 次,求和为 (n−1)n2 ,因此使用 O(n2) 时间。
空间复杂度 O(1) : 指针 i , j 使用常数大小的额外空间。
特点
原地排序: 指针变量仅使用常数大小额外空间。
稳定排序: 不交换相等元素。
自适排序: 引入 flag 优化后(见下文),最佳时间复杂度为 O(N) 。
🧩插入排序
插入排序 Insertion Sort:是一种基于 数组插入操作 的排序算法。
然而,由于数组在内存中的存储方式是连续的,我们无法直接把 base 插入到目标位置,而是需要将从目标位置到 base 之间的所有元素向右移动一位(本质上是一次数组插入操作)。
 
原理和实现
插入操作
选定数组的某个元素为基准数 base ,将 base 与其左边的元素依次对比大小,并 “插入” 到正确位置。

相当于是一个盒子,将3拿出,其余元素从后往前,依次往后’拨‘,直到找到符合base=3的空档。
算法流程
- 2 个元素已完成排序。
- 第 2 轮选取 第 3 个元素 为 base,执行「插入操作」后, 数组前 3 个元素已完成排序。
- 以此类推……最后一轮选取 数组尾元素 为 base,执行「插入操作」后 所有元素已完成排序。

例子
比如共有n张牌,我们用python中的列表来存储
例如:myList = [2,1,5,3,7,4,2]
len(myList) = 7
当抓第i张牌(从零开始)的时候:可分割为 [0,i-1], [i] ,[i+1,len(myList)]
手中的有序牌[0,i-1]
要插入的牌 [i]
桌子上未抓待排序的牌 [i+1,len(myList)]
python实现
""" 插入排序 """
def insertion_sort(nums):
    # 外循环:从第二张牌开始,依次往外取出,直到最后一个,range区间是左闭右开
    # base = nums[1], nums[2], ..., nums[n-1]
    for i in range(1, len(nums)):     # 
        base = nums[i]                # 要排序的牌      
        pos = i - 1                   #有序序列最大值对应的索引
        # 内循环:与前面有序牌逐个进行比较
        while pos >= 0 and nums[pos] > base: # 前面牌索引有效(即存在这个牌) and 前面牌比要排序的牌更大
            nums[pos + 1] = nums[pos]        # 将nums[pos] 向右移动一位,到pos+1这里
            pos -= 1
        #跳出循环,即不满足那两个条件时,也就是前面牌比要排序的牌小
        nums[pos + 1] = base                 # 假设可以继续循环,那么前面牌应该继续向右移动一位到pos+1这里,因为pos+1这里空
                                             # 但是事实是不可以循环了,那么待排序的牌就应该放到pos+1这里,只有pos+1这里空着
           
#测试
import random
data = [random.randint(-100, 100) for _ in range(10)]
insertion_sort(data)
print(data)
#[-98, -75, -58, -55, -49, -40, -9, -4, 37, 100]
复杂度
时间复杂度 O(n2) : 最差情况下,各轮插入操作循环 n−1 , n−2 , ⋯ , 2 , 1 次,求和为 (n−1)n/2 ,使用 O(n2) 时间。
空间复杂度 O(1) : 指针 i , j 使用常数大小的额外空间。
特点
原地排序: 指针变量仅使用常数大小额外空间。
稳定排序: 不交换相等元素。
自适应排序: 最佳情况下,时间复杂度为 O(n) 。
🧩快速排序
快速排序 Quick Sort:是一种基于 “分治思想” 的排序算法,速度很快、应用很广。
分冶思想:哨兵划分的实质是将 一个长数组的排序问题 简化为 两个短数组的排序问题。

原理和实现
算法流程
快速排序的核心操作为哨兵划分,其目标为:选取数组某个元素为 基准数 ,将所有小于基准数的元素移动至其左边,大于基准数的元素移动至其右边。哨兵划分的实现流程为:
- 
以数组最左端元素作为基准数,初始化两个指针 i,j指向数组两端;  
- 
设置一个循环,每轮中使用 i/j分别寻找首个比基准数大 / 小的元素,并交换此两元素;   
- 
不断循环步骤 2.,直至i,j相遇时跳出,最终把基准数交换至两个子数组的分界线;      
哨兵划分执行完毕后,原数组被划分成两个部分,即 左子数组 和 右子数组 ,且满足 左子数组任意元素 < 基准数 < 右子数组任意元素。因此,接下来我们只需要排序两个子数组即可。

接下来,对 左子数组 和 右子数组 分别 递归执行「哨兵划分」…….
直至子数组长度为 1 时 终止递归 ,即可完成对整个数组的排序。观察发现,快速排序和二分查找的原理类似,都是以对数阶的时间复杂度来缩小处理区间。

python实现
""" 哨兵划分 """
def partition(nums, left, right):
    i, j = left, right     #left, right指的是索引
    # 以 nums[left] 作为基准数
    pivot=nums[left]
    while i < j:    #判断两个指针是否碰到一起,没有碰到一起,则进行下面的 元素交换
        while i < j and nums[j] >= pivot:
            j -= 1  # 从右向左找首个小于基准数的元素
        while i < j and nums[i] <= pivot:
            i += 1  # 从左向右找首个大于基准数的元素
        # 元素交换
        nums[i], nums[j] = nums[j], nums[i]
    # 两个指针碰到一起,将基准数交换至两子数组的分界线
    nums[i], nums[left] = nums[left], nums[i]      #用nums[i], nums[j]都可,因为相等
    return i  # 返回基准数的索引,即两个指针重合的地方,可根据这个将列表分成两部分
 """ 快速排序 """
def quick_sort(nums, left, right):
    # 子数组长度为 1 时终止递归
    if left >= right:
        return
    # 哨兵划分,确定索引是多少
    pivot = partition(nums, left, right)
    # 递归左子数组、右子数组,分别进行快速排序
    quick_sort(nums, left, pivot - 1)
    quick_sort(nums, pivot + 1, right)    #正好不包含pivot
# 测试代码
import random
data = [random.randint(-100, 100) for _ in range(10)]   # _ 和平时变量意义相同,这里指重复十次random.randint(-100, 100)
quick_sort(data, 0, len(data) - 1)
print(data)
#[-93, -63, -34, -28, 3, 10, 45, 86, 87, 88]
算法优化
基准数优化
普通快速排序在某些输入下的时间效率变差。 举个极端例子,假设输入数组是完全倒序的,由于我们选取最左端元素为基准数,那么在哨兵划分完成后,基准数被交换至数组最右端,从而 左子数组长度为 n−1 、右子数组长度为 0 。这样进一步递归下去,每轮哨兵划分后的右子数组长度都为 0 ,分治策略失效,快速排序退化为「冒泡排序」了。
为了尽量避免这种情况发生,我们可以优化一下基准数的选取策略。首先,在哨兵划分中,我们可以 随机选取一个元素作为基准数 。但如果运气很差,每次都选择到比较差的基准数,那么效率依然不好。
进一步地,我们可以在数组中选取 3 个候选元素(一般为数组的首、尾、中点元素),并将三个候选元素的中位数作为基准数,这样基准数 “既不大也不小” 的概率就大大提升了。当然,如果数组很长的话,我们也可以选取更多候选元素,来进一步提升算法的稳健性。采取该方法后,时间复杂度劣化至 O(n2) 的概率极低。
""" 选取三个元素的中位数 """
def median_three(self, nums, left, mid, right):
    # 使用了异或操作来简化代码
    # 异或规则为 0 ^ 0 = 1 ^ 1 = 0, 0 ^ 1 = 1 ^ 0 = 1
    if (nums[left] > nums[mid]) ^ (nums[left] > nums[right]):
        return left
    elif (nums[mid] < nums[left]) ^ (nums[mid] > nums[right]):
        return mid
    return right
""" 哨兵划分(三数取中值) """
def partition(self, nums, left, right):
    # 以 nums[left] 作为基准数
    med = self.median_three(nums, left, (left + right) // 2, right)
    # 将中位数交换至数组最左端
    nums[left], nums[med] = nums[med], nums[left]
    # 以 nums[left] 作为基准数
    i, j = left, right
    while i < j:
        while i < j and nums[j] >= nums[left]:
            j -= 1  # 从右向左找首个小于基准数的元素
        while i < j and nums[i] <= nums[left]:
            i += 1  # 从左向右找首个大于基准数的元素
        # 元素交换
        nums[i], nums[j] = nums[j], nums[i]
    # 将基准数交换至两子数组的分界线
    nums[i], nums[left] = nums[left], nums[i]
    return i  # 返回基准数的索引
 """ 快速排序 """
def quick_sort(self, nums, left, right):
    # 子数组长度为 1 时终止递归
    if left >= right:
        return
    # 哨兵划分
    pivot = self.partition(nums, left, right)
    # 递归左子数组、右子数组
    self.quick_sort(nums, left, pivot - 1)
    self.quick_sort(nums, pivot + 1, right)
尾递归优化
普通快速排序在某些输入下的空间效率变差。 仍然以完全倒序的输入数组为例,由于每轮哨兵划分后右子数组长度为 0 ,那么将形成一个高度为 n−1 的递归树,此时使用的栈帧空间大小劣化至 O(n) 。
为了避免栈帧空间的累积,我们可以在每轮哨兵排序完成后,判断两个子数组的长度大小,仅递归排序较短的子数组。由于较短的子数组长度不会超过 n/2 ,因此这样做能保证递归深度不超过 logn ,即最差空间复杂度被优化至 O(logn) 。
""" 快速排序(尾递归优化) """
def quick_sort(self, nums, left, right):
    # 子数组长度为 1 时终止
    while left < right:
        # 哨兵划分操作
        pivot = self.partition(nums, left, right)
        # 对两个子数组中较短的那个执行快排
        if pivot - left < right - pivot:
            self.quick_sort(nums, left, pivot - 1)  # 递归排序左子数组
            left = pivot + 1     # 剩余待排序区间为 [pivot + 1, right]
        else:
            self.quick_sort(nums, pivot + 1, right)  # 递归排序右子数组
            right = pivot - 1    # 剩余待排序区间为 [left, pivot - 1]
复杂度
平均时间复杂度 O(nlogn) : 平均情况下,哨兵划分的递归层数为 logn ,每层中的总循环数为 n ,总体使用 O(nlogn) 时间。
最差时间复杂度 O(n2) : 最差情况下,哨兵划分操作将长度为 n 的数组划分为长度为 0 和 n−1 的两个子数组,此时递归层数达到 n 层,每层中的循环数为 n ,总体使用 O(n2) 时间。
空间复杂度 O(n) : 输入数组完全倒序下,达到最差递归深度 n 。
特点
原地排序: 只在递归中使用 O(logn) 大小的栈帧空间。
非稳定排序: 哨兵划分操作可能改变相等元素的相对位置。
自适应排序: 最差情况下,时间复杂度劣化至 O(n2) 。
快排为什么快?
从命名能够看出,快速排序在效率方面一定 “有两把刷子” 。快速排序的平均时间复杂度虽然与「归并排序」和「堆排序」一致,但实际 效率更高 ,这是因为:
- 出现最差情况的概率很低: 虽然快速排序的最差时间复杂度为 O(n2) ,不如归并排序,但绝大部分情况下,快速排序可以达到 O(nlogn) 的复杂度。
- 缓存使用效率高: 哨兵划分操作时,将整个子数组加载入缓存中,访问元素效率很高。而诸如「堆排序」需要跳跃式访问元素,因此不具有此特性。
- 复杂度的常数系数低: 在提及的三种算法中,快速排序的 比较、赋值、交换 三种操作的总体数量最少(类似于「插入排序」快于「冒泡排序」的原因)
🧩归并排序
归并排序 Merge Sort:是算法中 “分治思想” 的典型体现,其有划分和合并两个阶段:
- 划分阶段: 通过递归不断 将数组从中点位置划分开,将长数组的排序问题转化为短数组的排序问题;
- 合并阶段: 划分到子数组长度为 1 时,开始向上合并,不断将 左、右两个短排序数组 合并为 一个长排序数组,直至合并至原数组时完成排序;


原理和实现
算法流程
递归划分 从顶至底递归地 将数组从中点切为两个子数组 ,直至长度为 1 ;
- 计算数组中点 mid,递归划分左子数组(区间[left, mid])和右子数组(区间[mid + 1, right]);
- 递归执行 1.步骤,直至子数组区间长度为 1 时,终止递归划分;
回溯合并从底至顶地将左子数组和右子数组合并为一个 有序数组 ;
需要注意,由于从长度为 1 的子数组开始合并,所以 每个子数组都是有序的 。因此,合并任务本质是要 将两个有序子数组合并为一个有序数组 。
python实现
"""
合并左子数组和右子数组
左子数组区间 [left, mid]
右子数组区间 [mid + 1, right]
"""
def merge(nums, left, mid, right):
    # 初始化辅助数组 借助 copy模块
    tmp = nums[left:right + 1]
    # 左子数组的起始索引和结束索引
    left_start, left_end = left - left, mid - left
    # 右子数组的起始索引和结束索引
    right_start, right_end = mid + 1 - left, right - left
    # i, j 分别指向左子数组、右子数组的首元素
    i, j = left_start, right_start
    # 通过覆盖原数组 nums 来合并左子数组和右子数组
    for k in range(left, right + 1):
        # 若 “左子数组已全部合并完”,则选取右子数组元素,并且 j++
        if i > left_end:
            nums[k] = tmp[j]
            j += 1
        # 否则,若 “右子数组已全部合并完” 或 “左子数组元素 < 右子数组元素”,则选取左子数组元素,并且 i++
        elif j > right_end or tmp[i] <= tmp[j]:
            nums[k] = tmp[i]
            i += 1
        # 否则,若 “左子数组元素 > 右子数组元素”,则选取右子数组元素,并且 j++
        else:
            nums[k] = tmp[j]
            j += 1
""" 归并排序 """
def merge_sort(nums, left, right):
    # 终止条件
    if left >= right:
        return                        # 当子数组长度为 1 时终止递归
    # 划分阶段
    mid = (left + right) // 2         # 计算中点
    merge_sort(nums, left, mid)       # 递归左子数组
    merge_sort(nums, mid + 1, right)  # 递归右子数组
    # 合并阶段
    merge(nums, left, mid, right)
插入排序VS冒泡排序
虽然「插入排序」和「冒泡排序」的时间复杂度皆为 O(n2) ,但实际运行速度却有很大差别,这是为什么呢?
回顾复杂度分析,两个方法的循环次数都是 (n−1)n/2 。但不同的是,「冒泡操作」是在做 元素交换 ,需要借助一个临时变量实现,共 3 个单元操作;而「插入操作」是在做 赋值 ,只需 1 个单元操作;因此,可以粗略估计出冒泡排序的计算开销约为插入排序的 3 倍。
插入排序运行速度快,并且具有原地、稳定、自适应的优点,因此很受欢迎。实际上,包括 Java 在内的许多编程语言的排序库函数的实现都用到了插入排序。库函数的大致思路:
- 对于 长数组,采用基于分治的排序算法,例如「快速排序」,时间复杂度为 O(nlogn) ;
- 对于 短数组,直接使用「插入排序」,时间复杂度为 O(n2) ;
在数组较短时,复杂度中的常数项(即每轮中的单元操作数量)占主导作用,此时插入排序运行地更快。这个现象与「线性查找」和「二分查找」的情况类似。
 
本文来自博客园,作者:happyfeliz,转载请注明原文链接:https://www.cnblogs.com/happyfeliz/p/16967048.html

 
                
            
         
         浙公网安备 33010602011771号
浙公网安备 33010602011771号