数据结构与算法 搜索(上)

简介: 数据结构与算法 搜索(上)

评价维度

  • 运行效率:我们期望排序算法的时间复杂度尽量低,且总体操作数量较少(即时间复杂度中的常数项降低)。对于大数据量情况,运行效率显得尤为重要。
  • 就地性:顾名思义,「原地排序」通过在原数组上直接操作实现排序,无须借助额外的辅助数组,从而节省内存。通常情况下,原地排序的数据搬运操作较少,运行速度也更快。
  • 稳定性:「稳定排序」在完成排序后,相等元素在数组中的相对顺序不发生改变。
  • 自适应性:「自适应排序」的时间复杂度会受输入数据的影响,即最佳、最差、平均时间复杂度并不完全相等。
  • 是否基于比较:「基于比较的排序」依赖于比较运算符(<、=、>)来判断元素的相对顺序,从而排序整个数组,理论最优时间复杂度为𝑂(𝑛 log 𝑛) 。而「非比较排序」不使用比较运算符,时间复杂度可达𝑂(𝑛) ,但其通用性相对较差。

选择排序

工作原理

「选择排序 selection sort」的工作原理非常直接:开启一个循环,每轮从未排序区间选择最小的元素,将其放到已排序区间的末尾。设数组的长度为 𝑛 ,选择排序的算法流程如图 11‑2 所示。

  1. 初始状态下,所有元素未排序,即未排序(索引)区间为[0, 𝑛 − 1] 。
  2. 选取区间[0, 𝑛 − 1]中的最小元素,将其与索引0处元素交换。完成后,数组前 1 个元素已排序。
  3. 选取区间[1, 𝑛 − 1]中的最小元素,将其与索引1处元素交换。完成后,数组前 2 个元素已排序。
  4. 以此类推。经过𝑛 − 1轮选择与交换后,数组前𝑛 − 1个元素已排序。
  5. 仅剩的一个元素必定是最大元素,无须排序,因此数组排序完成。
代码
def select_sort(lst):
    size = len(lst)
    for i in range(size):
        min = i
        for j in range(i, size):
            if lst[min] >= lst[j]:
                min = j
        lst[min], lst[i] = lst[i], lst[min]
    return lst
算法特性
  • 时间复杂度为 𝑂(𝑛2 )、非自适应排序:外循环共𝑛 − 1轮,第一轮的未排序区间长度为𝑛,最后一轮的未排序区间长度为2 ,即各轮外循环分别包含𝑛、𝑛 − 1、…、3、2 轮内循环,求和为 (𝑛−1)(𝑛+2)/2 。
  • 空间复杂度 𝑂(1)、原地排序:指针 𝑖和 𝑗 使用常数大小的额外空间。
  • 非稳定排序:如图 11‑3 所示,元素 nums[i] 有可能被交换至与其相等的元素的右边,导致两者相对顺序发生改变。

冒泡排序

工作原理

「冒泡排序 bubble sort」通过连续地比较与交换相邻元素实现排序。这个过程就像气泡从底部升到顶部一样,因此得名冒泡排序。如图 11‑4 所示,冒泡过程可以利用元素交换操作来模拟:从数组最左端开始向右遍历,依次比较相邻元素大小,如果“左元素 > 右元素”就交换它俩。遍历完成后,最大的元素会被移动到数组的最右端。

设数组的长度为 𝑛 ,冒泡排序的步骤如图 11‑5 所示。

  1. 首先,对 𝑛 个元素执行“冒泡”,将数组的最大元素交换至正确位置,
  2. 接下来,对剩余𝑛 − 1个元素执行“冒泡”,将第二大元素交换至正确位置。
  3. 以此类推,经过𝑛 − 1轮“冒泡”后,前 𝑛 − 1 大的元素都被交换至正确位置。
  4. 仅剩的一个元素必定是最小元素,无须排序,因此数组排序完成。
代码
def bubble_sort(lst):
    size = len(lst)
    for i in range(size, 0, -1):
        flag = True
        for j in range(i-1):
            if lst[j] > lst[j+1]:
                lst[j], lst[j+1] = lst[j+1], lst[j]
                flag = False
        if flag:
            break
    return lst
算法特性
  • 时间复杂度为𝑂(𝑛2)、自适应排序:各轮“冒泡”遍历的数组长度依次为𝑛 − 1、𝑛 − 2、…、2、1 ,总和为(𝑛 − 1)𝑛/2。在引入flag优化后,最佳时间复杂度可达到𝑂(𝑛)。
  • 空间复杂度为𝑂(1)、原地排序:指针𝑖 和 𝑗 使用常数大小的额外空间。
  • 稳定排序:由于在“冒泡”中遇到相等元素不交换。

插入排序

工作原理

「插入排序 insertion sort」是一种简单的排序算法,它的工作原理与手动整理一副牌的过程非常相似。具体来说,我们在未排序区间选择一个基准元素,将该元素与其左侧已排序区间的元素逐一比较大小,并将该元素插入到正确的位置。图 11‑6 展示了数组插入元素的操作流程。设基准元素为 base ,我们需要将从目标索引到 base 之间的所有元素向右移动一位,然后再将 base 赋值给目标索引。

代码
def insert_sort(lst):
    size = len(lst)
    for i in range(size-1):
        for j in range(i, 0, -1):
            if lst[i+1] < lst[j]:
                lst[i+1], lst[j] = lst[j], lst[i+1]
    return lst
  
def insert_sort(lst):
    size = len(lst)
    for i in range(size-1):
        val = lst[i+1]
        j = i
        while j>=0 and lst[j] > val:
            lst[j+1] = lst[j]
            j -= 1
        lst[j+1] = val
    return lst
算法特性
  • 时间复杂度 𝑂(𝑛2 )、自适应排序:最差情况下,每次插入操作分别需要循环𝑛 − 1、𝑛 − 2、…、2、1次,求和得到(𝑛 − 1)𝑛/2,因此时间复杂度为𝑂(𝑛2) 。在遇到有序数据时,插入操作会提前终止。当输入数组完全有序时,插入排序达到最佳时间复杂度𝑂(𝑛)。
  • 空间复杂度 𝑂(1)、原地排序:指针 𝑖 和 𝑗 使用常数大小的额外空间。
  • 稳定排序:在插入操作过程中,我们会将元素插入到相等元素的右侧,不会改变它们的顺序。

快速排序

快速排序 quick sort:是一种基于分治策略的排序算法,运行高效,应用广泛。

快速排序的核心操作是“哨兵划分”,其目标是:选择数组中的某个元素作为“基准数”,将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧。具体来说,哨兵划分的流程如图 11‑8 所示。

  1. 选取数组最左端元素作为基准数,初始化两个指针 i 和 j 分别指向数组的两端。
  2. 设置一个循环,在每轮中使用 i(j)分别寻找第一个比基准数大(小)的元素,然后交换这两个元素。
  3. 循环执行步骤 2. ,直到 i 和 j 相遇时停止,最后将基准数交换至两个子数组的分界线。
def partition(lst, left, right):
    i, j = left, right
    while i < j:
        ## 必须先从右往左找
        while i < j and lst[j] >= lst[left]:
            j -= 1
        while i < j and lst[i] <= lst[left]:
            i += 1
        lst[i], lst[j] = lst[j], lst[i]
    lst[left], lst[i] = lst[i], lst[left]
    return i
  
def quick_sort(lst, left, right):
    if left >= right:
        return None
    pivot = partition(lst, left, right)
    quick_sort(lst, left, pivot-1)
    quick_sort(lst, pivot+1, right)
    return lst
基准数优化

快速排序在某些输入下的时间效率可能降低。举一个极端例子,假设输入数组是完全倒序的,由于我们选择最左端元素作为基准数,那么在哨兵划分完成后,基准数被交换至数组最右端,导致左子数组长度为 𝑛 − 1、右子数组长度为 0 。如此递归下去,每轮哨兵划分后的右子数组长度都为 0 ,分治策略失效,快速排序退化为“冒泡排序”。

我们可以优化哨兵划分中的基准数的选取策略我们可以在数组中选取三个候选元素(通常为数组的首、尾、中点元素),并将这三个候选元素的中位数作为基准数。

优化代码
def median_three(lst, left, mid, right):
    # 异或规则为 0 ^ 0 = 1 ^ 1 = 0, 0 ^ 1 = 1 ^ 0 = 1
    if (lst[left] < lst[mid]) ^ (lst[left] < lst[right]):
        return left
    elif (lst[mid] < lst[left]) ^ (lst[mid] < lst[right]):
        return mid
    return right
  
  
def partition(lst, left, right):
    mid = median_three(lst, left, int(left + (right - left)/2), right)
    lst[mid], lst[left] = lst[left], lst[mid]
    i, j = left, right
    while i < j:
        ## 必须先从右往左找
        while i < j and lst[j] >= lst[left]:
            j -= 1
        while i < j and lst[i] <= lst[left]:
            i += 1
        lst[i], lst[j] = lst[j], lst[i]
    lst[left], lst[i] = lst[i], lst[left]
    return i
  
def quick_sort(lst, left, right):
    if left >= right:
        return None
    pivot = partition(lst, left, right)
    quick_sort(lst, left, pivot-1)
    quick_sort(lst, pivot+1, right)
    return lst
尾递归优化

在某些输入下,快速排序可能占用空间较多。以完全倒序的输入数组为例,由于每轮哨兵划分后右子数组长度为 0 ,递归树的高度会达到 𝑛 − 1 ,此时需要占用 𝑂(𝑛) 大小的栈帧空间。我们可以在每轮哨兵排序完成后,比较两个子数组的长度,仅对较短的子数组进行递归。因此这种方法能确保递归深度不超过 log 𝑛 ,从而将最差空间复杂度优化至 𝑂(log 𝑛) 。

优化代码
def median_three(lst, left, mid, right):
    # 异或规则为 0 ^ 0 = 1 ^ 1 = 0, 0 ^ 1 = 1 ^ 0 = 1
    if (lst[left] < lst[mid]) ^ (lst[left] < lst[right]):
        return left
    elif (lst[mid] < lst[left]) ^ (lst[mid] < lst[right]):
        return mid
    return right
  
  
def partition(lst, left, right):
    mid = median_three(lst, left, int(left + (right - left)/2), right)
    lst[mid], lst[left] = lst[left], lst[mid]
    i, j = left, right
    while i < j:
        ## 必须先从右往左找
        while i < j and lst[j] >= lst[left]:
            j -= 1
        while i < j and lst[i] <= lst[left]:
            i += 1
        lst[i], lst[j] = lst[j], lst[i]
    lst[left], lst[i] = lst[i], lst[left]
    return i
  
  
def quick_sort(nums: list[int], left: int, right: int):
    """ 快速排序(尾递归优化)"""
    while left < right:
        # 哨兵划分操作
        pivot = partition(nums, left, right)
        # 对两个子数组中较短的那个执行快排
        if pivot - left < right - pivot:
            quick_sort(nums, left, pivot - 1) # 递归排序左子数组
            left = pivot + 1 # 剩余未排序区间为 [pivot + 1, right]
        else:
            quick_sort(nums, pivot + 1, right) # 递归排序右子数组
            right = pivot - 1 # 剩余未排序区间为 [left, pivot - 1]
    return nums
算法特性
  • 时间复杂度 𝑂(𝑛 log 𝑛)、自适应排序:在平均情况下,哨兵划分的递归层数为 log 𝑛 ,每层中的总循环数为 𝑛 ,总体使用 𝑂(𝑛 log 𝑛) 时间。在最差情况下,每轮哨兵划分操作都将长度为 𝑛 的数组划分为长度为 0 和 𝑛−1 的两个子数组,此时递归层数达到 𝑛 层,每层中的循环数为 𝑛 ,总体使用 𝑂(𝑛2 ) 时间。
  • 空间复杂度 𝑂(𝑛)、原地排序:在输入数组完全倒序的情况下,达到最差递归深度 𝑛 ,使用 𝑂(𝑛) 栈帧空间。排序操作是在原数组上进行的,未借助额外数组。
  • 非稳定排序:在哨兵划分的最后一步,基准数可能会被交换至相等元素的右侧。
快排为什么快
  • 从名称上就能看出,快速排序在效率方面应该具有一定的优势。尽管快速排序的平均时间复杂度与“归并排序”和“堆排序”相同,但通常快速排序的效率更高,主要有以下原因。
  • 出现最差情况的概率很低:虽然快速排序的最差时间复杂度为 𝑂(𝑛2 ) ,没有归并排序稳定,但在绝大多数情况下,快速排序能在 𝑂(𝑛 log 𝑛) 的时间复杂度下运行。
  • 缓存使用效率高:在执行哨兵划分操作时,系统可将整个子数组加载到缓存,因此访问元素的效率较高。而像“堆排序”这类算法需要跳跃式访问元素,从而缺乏这一特性。
  • 复杂度的常数系数低:在上述三种算法中,快速排序的比较、赋值、交换等操作的总数量最少。这与“插入排序”比“冒泡排序”更快的原因类似。

归并排序

归并排序(merge sort):是一种基于分治策略的排序算法,包含图 11‑10 所示的“划分”和“合并”阶段。

  1. 划分阶段:通过递归不断地将数组从中点处分开,将长数组的排序问题转换为短数组的排序问题。
  2. 合并阶段:当子数组长度为 1 时终止划分,开始合并,持续地将左右两个较短的有序数组合并为一个较长的有序数组,直至结束。

归并排序与二叉树后序遍历的递归顺序是一致的。

  • 后序遍历:先递归左子树,再递归右子树,最后处理根节点。
  • 归并排序:先递归左子数组,再递归右子数组,最后处理合并。
def merge(lst, left, mid, right):
    tmp = list(lst[left:right+1])
  
    left_start = 0
    left_end = mid - left
  
    right_start = mid - left + 1
    right_end = right - left
  
    i = left_start
    j = right_start
    for k in range(left, right + 1):
        if i > left_end:
            lst[k] = tmp[j]
            j += 1
        elif j > right_end or tmp[i] <= tmp[j]:
            lst[k] = tmp[i]
            i += 1
        else:
            lst[k] = tmp[j]
            j += 1
  
def merge_sort(lst, left, right):
    if left >= right:
        return None
    mid = int(left + (right - left)/2)
    merge_sort(lst, left, mid)
    merge_sort(lst, mid+1, right)
    merge(lst, left, mid, right)
    return lst
算法特性
  • 时间复杂度 𝑂(𝑛 log 𝑛)、非自适应排序:划分产生高度为 log 𝑛 的递归树,每层合并的总操作数量为𝑛 ,因此总体时间复杂度为 𝑂(𝑛 log 𝑛) 。
  • 空间复杂度 𝑂(𝑛)、非原地排序:递归深度为 log 𝑛 ,使用 𝑂(log 𝑛) 大小的栈帧空间。合并操作需要借助辅助数组实现,使用 𝑂(𝑛) 大小的额外空间。
  • 稳定排序:在合并过程中,相等元素的次序保持不变。
链表排序

对于链表,归并排序相较于其他排序算法具有显著优势,可以将链表排序任务的空间复杂度优化至 𝑂(1) 。

  • 划分阶段:可以通过使用“迭代”替代“递归”来实现链表划分工作,从而省去递归使用的栈帧空间。
  • 合并阶段:在链表中,节点增删操作仅需改变引用(指针)即可实现,因此合并阶段(将两个短有序链表合并为一个长有序链表)无须创建额外链表。

数据结构与算法 搜索(下)https://developer.aliyun.com/article/1504031?spm=a2c6h.13148508.setting.45.36834f0eMJOehx

目录
相关文章
|
3月前
|
算法
【算法】二分算法——搜索插入位置
【算法】二分算法——搜索插入位置
|
13天前
|
算法 搜索推荐 数据库
二分搜索:高效的查找算法
【10月更文挑战第29天】通过对二分搜索的深入研究和应用,我们可以不断挖掘其潜力,为各种复杂问题提供高效的解决方案。相信在未来的科技发展中,二分搜索将继续发挥着重要的作用,为我们的生活和工作带来更多的便利和创新。
21 1
|
1月前
|
算法 决策智能
基于禁忌搜索算法的VRP问题求解matlab仿真,带GUI界面,可设置参数
该程序基于禁忌搜索算法求解车辆路径问题(VRP),使用MATLAB2022a版本实现,并带有GUI界面。用户可通过界面设置参数并查看结果。禁忌搜索算法通过迭代改进当前解,并利用记忆机制避免陷入局部最优。程序包含初始化、定义邻域结构、设置禁忌列表等步骤,最终输出最优路径和相关数据图表。
|
2月前
|
大数据 UED 开发者
实战演练:利用Python的Trie树优化搜索算法,性能飙升不是梦!
在数据密集型应用中,高效搜索算法至关重要。Trie树(前缀树/字典树)通过优化字符串处理和搜索效率成为理想选择。本文通过Python实战演示Trie树构建与应用,显著提升搜索性能。Trie树利用公共前缀减少查询时间,支持快速插入、删除和搜索。以下为简单示例代码,展示如何构建及使用Trie树进行搜索与前缀匹配,适用于自动补全、拼写检查等场景,助力提升应用性能与用户体验。
54 2
|
1月前
|
存储 算法 C++
【搜索算法】 跳马问题(C/C++)
【搜索算法】 跳马问题(C/C++)
|
1月前
|
人工智能 算法 Java
【搜索算法】数字游戏(C/C++)
【搜索算法】数字游戏(C/C++)
|
3月前
|
机器学习/深度学习 算法 文件存储
【博士每天一篇文献-算法】 PNN网络启发的神经网络结构搜索算法Progressive neural architecture search
本文提出了一种名为渐进式神经架构搜索(Progressive Neural Architecture Search, PNAS)的方法,它使用顺序模型优化策略和替代模型来逐步搜索并优化卷积神经网络结构,从而提高了搜索效率并减少了训练成本。
55 9
|
3月前
|
算法
【算法】递归、搜索与回溯——汉诺塔
【算法】递归、搜索与回溯——汉诺塔
|
3月前
|
存储 算法 调度
基于和声搜索算法(Harmony Search,HS)的机器设备工作最优调度方案求解matlab仿真
通过和声搜索算法(HS)实现多机器并行工作调度,以最小化任务完成时间。在MATLAB2022a环境下,不仅输出了工作调度甘特图,还展示了算法适应度值的收敛曲线。HS算法模拟音乐家即兴创作过程,随机生成初始解(和声库),并通过选择、微调生成新解,不断迭代直至获得最优调度方案。参数包括和声库大小、记忆考虑率、音调微调率及带宽。编码策略将任务与设备分配映射为和声,目标是最小化完成时间,同时确保满足各种约束条件。
|
4月前
|
数据采集 算法 JavaScript
揭开JavaScript字符串搜索的秘密:indexOf、includes与KMP算法
JavaScript字符串搜索涵盖`indexOf`、`includes`及KMP算法。`indexOf`返回子字符串位置,`includes`检查是否包含子字符串。KMP是高效的搜索算法,尤其适合长模式匹配。示例展示了如何在数据采集(如网页爬虫)中使用这些方法,结合代理IP进行安全搜索。代码示例中,搜索百度新闻结果并检测是否含有特定字符串。学习这些技术能提升编程效率和性能。
118 1
揭开JavaScript字符串搜索的秘密:indexOf、includes与KMP算法