算法浅谈——分治算法与归并、快速排序(附代码和动图演示)

在之前的文章当中,我们通过海盗分金币问题详细讲解了递归方法。

我们可以认为在递归的过程当中,我们通过函数自己调用自己,将大问题转化成了小问题,因此简化了编码以及建模。今天这篇文章呢,就正式和大家聊一聊将大问题简化成小问题的分治算法的经典使用场景——排序。

排序算法

排序算法有很多,很多博文都有总结,号称有十大经典的排序算法。我们信手拈来就可以说上来很多,比如插入排序、选择排序、桶排序、希尔排序、快速排序、归并排序等等。老实讲这么多排序算法,但我们实际工作中并不会用到那么多,凡是高级语言都有自带的排序工具,我们直接调用就好。为了应付面试以及提升自己算法能力呢,用到的也就那么几种。今天我们来介绍一下利用分治思想实现的两种经典排序算法——归并排序与快速排序。

归并排序

我们先来讲归并排序,归并排序的思路其实很简单,说白了只有一句话:两个有序数组归并的复杂度是\(O(n)\)。

我们举个例子:

a = [1, 4, 6]
b = [2, 4, 5]
c = []

我们用i和j分别表示a和b两个数组的下标,c表示归并之后的数组,显然一开始的时候i, j = 0, 0。我们不停地比较a和b数组i和j位置大小关系,将小的那个数填入c。

填入一个数之后:

i = 1
j = 0
a = [1, 4, 6]
b = [2, 4, 5]
c = [1]

填入两个数之后:

i = 1
j = 1
a = [1, 4, 6]
b = [2, 4, 5]
c = [1, 2]

我们重复以上步骤,直到a和b数组当中所有的数都填入c数组为止,我们可以很方便地写出以上操作的代码:

def merge(a, b):
  i, j = 0, 0
  c = []
  while i < len(a) or j < len(b):
    # 判断a数组是否已经全部放入
    if i == len(a):
      c.append(b[j])
      j += 1
      continue
    elif j == len(b):
      c.append(a[i])
      i += 1
      continue
    # 判断大小
    if a[i] <= b[j]:
      c.append(a[i])
      i += 1
    else:
      c.append(b[j])
      j += 1
  return c

从上面的代码我们也能看出来,这个过程虽然简单,但是写成代码非常麻烦,因为我们需要判断数组是否已经全部填入的情况。这里有一个简化代码的优化,就是在a和b两个数组当中插入一个”标兵“,这个标兵设置成正无穷大的数,这样当a数组当中其他元素都弹出之后。由于标兵大于b数组当中除了标兵之外其他所有的数,就可以保证a数组永远不会越界,如此就可以简化很多代码了(前提,a和b数组当中不存在和标兵一样大的数)。

我们来看代码:

def merge(a, b):
  i, j = 0, 0
  # 插入标兵
  a.append(MAXINT)
  b.append(MAXINT)
  c = []
  # 由于插入了标兵,所以长度判断的时候需要-1
  while i < len(a)-1 or j < len(b)-1:
    if a[i] <= b[j]:
      c.append(a[i])
      i += 1
    else:
      c.append(b[j])
      j += 1
  return c

这里应该都没有问题,接下来的问题是我们怎么利用归并数组的操作来排序呢?

其实很简单,这也是归并排序的精髓

我们每次将一个数组一分为二,显然,这个划分出来的数组不一定是有序的。但如果我们继续切分呢?直到数组当中只有一个元素的时候,是不是就天然有序了呢?

我们举个例子:

          [4, 1, 3, 2]
           /                [4, 1]    [3, 2]
        /    \    /          [4]   [1]  [3]   [2]
        \    /     \   /
        [1, 4]     [2, 3]
           \        /
          [1, 2, 3, 4]

通过上面的这个过程我们可以发现,在归并排序的时候,我们先一直往下递归切分数组,直到所有的切片当中只有一个元素天然有序。接着一层一层地归并回来,当所有元素归并结束的时候,数组就完成了排序。这也就是归并排序的全部过程。

如果还不理解,还可以参考一下下面的动图。

我们来试着用代码来实现。之前我曾经在面试的时候被要求在白板上写过归并排序,当时我用的C++觉得编码还有一定的难度。现在,当我用习惯了Python之后,我感觉编码难度降低了很多。因为Python支持许多数组相关的高级操作,比如切片,变长等等。整个归并排序的代码不超过20行,我们一起来看下代码:

def merge_sort(arr):
    n = len(arr)
    # 当长度小于等于1,说明天然有序
    if n <= 1:
        return arr
    mid = n // 2
    # 通过切片将数组一分为二,递归排序左边以及右边部分
    L, R = merge_sort(arr[: mid]), merge_sort(arr[mid: ])
    n_l, n_r = len(L), len(R)

    # 数组当中插入标兵
    L.append(sys.maxsize)
    R.append(sys.maxsize)
    new_arr = []
    i, j = 0, 0

    # 归并已经排好序的L和R
    while i < n_l or j < n_r:
        if L[i] <= R[j]:
            new_arr.append(L[i])
            i += 1
        else:
            new_arr.append(R[j])
            j += 1
    return new_arr

你看,无论是思想还是代码实现,归并排序并不难,就算一开始不熟悉,写个两遍也一定没问题了。

理解了归并排序之后,再来学快速排序就不难了,我们一起来看快速排序的算法原理。

快速排序

快速排序同样利用了分治的思想,我们每次做一个小的操作,让数组的一部分变得有序,之后我们通过递归,将这些有序的部分组合在一起,达到整体有序。

在归并排序当中,我们划分问题的方法是横向切分,我们直接将数组一分为二,针对这两个部分分别排序。快排稍稍不同,它并不是针对数组的横向切分,而是从问题本身出发的”纵向“切分。在快速排序当中,我们解决的子问题不是对数组的一部分排序,而是提升数组的有序程度。怎么提升呢?我们在数组当中寻找一个数,作为标杆,我们利用这个标杆调整数组当中元素的顺序。将小于它的放到它的左侧,大于它的放到它的右侧。这么一个操作结束之后,可以肯定的是,这个标杆所在的位置就是排序完成之后,它应该在的位置。

我们来看个例子:

a = [8, 4, 3, 9, 10, 2, 7]

我们选择7作为标杆,一轮操作之后可以得到:

a = [2, 4, 3, 7, 9, 10, 8]

接着我们怎么做呢?很简单,我们只需要针对标杆前面以及标杆后面的部分重复上述操作即可。如果还不明白的同学可以看一下下面这张动图:

如果用C++写过快排的同学肯定对于快排的代码印象深刻,它是属于典型的原理不难,但是写起来很麻烦的算法。因为快速排序需要用到两个下标,写的时候一不小心很容易写出bug。同样,由于Python当中动态数组的支持非常好,我们可以避免使用下标来实现快排,这样代码的可读性以及编码难度都要降低很多。

多说无益,我们来看代码:

def quick_sort(arr):
    n = len(arr)
    # 长度小于等于1说明天然有序
    if n <= 1:
        return arr

    # pop出最后一个元素作为标杆
    mark = arr.pop()
    # 用less和greater分别存储比mark小或者大的数
    less, greater = [], []
    for x in arr:
        if x <= mark:
            less.append(x)
        else:
            greater.append(x)
    arr.append(mark)
    return quick_sort(less) + [mark] + quick_sort(greater)

整个代码出去注释,不到15行,我想大家应该都非常容易理解。

最后,我们来分析一下这两个算法的复杂度,为什么说这两个算法都是\(nlogn\)的算法呢?(不考虑快速排序最差情况)这个证明非常简单,我们放一张图大家一看就明白了:

我们在递归的过程当中,我们只遍历了一遍数组,虽然我们每一层都会讲数组拆分。但是在递归树上同一层的递归函数遍历的总数加起来应该是等于数组的总长也就是n的。

而且递归的层数是有限制的,因为我们每次都将数组一分为二。而一个数组的最小长度是1,也就是说极端情况下我们一共能有\(\log_2^n\)层,每一层的复杂度总和是n,所以整体的复杂度是\(nlogn\)。

当然对于快速排序算法来说,如果数组是倒序的,我们默认取最后一个元素作为标杆的话,我们是无法切分数组的,因为除它之外所有的元素都比它大。在这种情况下算法的复杂度会退化到\(n^2\)。所以我们说快速排序算法最差复杂度是\(O(n^2)\)。

到这里,关于归并排序与快速排序的算法就讲完了。这两个算法并不难,我想学过算法和数据结构的同学应该都有印象,但是在实际面试当中,真正能把代码写出来并且没有明显bug的实在是不多。我想,不论之前是否已经学会了,回顾一下都是很有必要的吧。

今天的文章就到这里,希望大家有所收获。如果喜欢本文,请顺手点个关注吧。

原文地址:https://www.cnblogs.com/techflow/p/12267607.html

时间: 2024-11-08 20:54:55

算法浅谈——分治算法与归并、快速排序(附代码和动图演示)的相关文章

hdu1875浅谈prim算法的朴素实现

阅读原题 题目大意 给你几个(<=100)小岛的坐标,然后你把所有的岛都修上桥连接起来,求最小花费,还有个附加的限制:只有岛之间的距离大于等于10,或小于等于1000时才能修桥. 大概是因为十米以内不用建桥,千米以上无法建桥.哈哈,说着玩的. 很明显这是一道MST(最小生成树)的题目,貌似也有人用并查集AC过. 最小生成树算法 概述 最小生成树的常用算法有两个kruskal和prim算法.两者都是不停地执行归并操作,然而一言以蔽之,两者的不同之处在于:kruskal----归并边:prim---

浅谈分词算法(2)基于词典的分词方法

[TOC] 前言 在浅谈分词算法(1)分词中的基本问题中我们探讨了分词中的基本问题,也提到了基于词典的分词方法.基于词典的分词方法是一种比较传统的方式,这类分词方法有很多,如:正向最大匹配(forward maximum matching method, FMM).逆向最大匹配(backward maximum matching method,BMM).双向扫描法.逐词遍历法.N-最短路径方法以及基于词的n-gram语法模型的分词方法等等.对于这类方法,词典的整理选择在其中占到了很重要的作用,本

浅谈分词算法(1)分词中的基本问题

[TOC] 前言 分词或说切词是自然语言处理中一个经典且基础的问题,在平时的工作中也反复的接触到分词问题,用到了不同的模型,不同的方法应用在各个领域中,所以想对分词问题做一个系统的梳理.大多数分词问题主要是针对类似汉语.韩语.日语等,词语之间并没有天然的分割,而像英语等,句子中是带有天然的分割的.但是英语也会涉及到分词问题,比如实体识别.词性标注等内容.而本系列文章更多的是讨论汉语中的分词问题,首先我们从分词问题的基本出发,之后从传统的词典分词到将分词转为序列标注问题的分词,以及最新的结合深度学

Kmp算法浅谈

Kmp算法浅谈 一.Kmp算法思想 在主串和模式串进行匹配时,利用next数组不改变主串的匹配指针而是改变模式串的匹配指针,减少大量的重复匹配时间.在Kmp算法中,next数组的构建是整个Kmp算法的核心所在. 二.Kmp核心之next数组的构建 (1)前缀,后缀的定义 (2)最长公共前后缀定义 (3)next数组的含义 next数组代表各个长度子串(这些字串的起始位置都为0)的最长公共前后缀长度,为了方便代码的编写next[0]定为-1,表示前0个字符根本不存在前后缀这一说法.其他的例如nex

浅谈欧洲算法——模拟退火

初听说退火这个名词感觉就很(zhuang)帅(A__CDEFG...) 直到学了退火之后,我才发现: 退火不只是帅,而且非常万能 甚至比 D (大) F (法) S (师)还要万能 简直就是骗(de)分神器啊 简介 作为一个计算机算法,它竟然在百度上有物理词条! 当时我看了就懵了,你说计算机一个算法,跟冶炼金属有什么关系啊? 后来我看了算法的词条... 是不是更懵了... 方便大家理解(变得更懵),我搬了百度上的定义: Simulate Anneal Arithmetic (SAA,模拟退火算法

(转)五大常用算法之一:分治算法

五大常用算法之一:分治算法 一.基本概念 在计算机科学中,分治法是一种很重要的算法.字面上的解释是“分而治之”,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并.这个技巧是很多高效算法的基础,如排序算法(快速排序,归并排序),傅立叶变换(快速傅立叶变换)…… 任何一个可以用计算机求解的问题所需的计算时间都与其规模有关.问题的规模越小,越容易直接求解,解题所需的计算时间也越少.例如,对于n个元素的排

五大常用算法之一:分治算法(转)

五大常用算法之一:分治算法 分治算法 一.基本概念 在计算机科学中,分治法是一种很重要的算法.字面上的解释是“分而治之”,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并.这个技巧是很多高效算法的基础,如排序算法(快速排序,归并排序),傅立叶变换(快速傅立叶变换)…… 任何一个可以用计算机求解的问题所需的计算时间都与其规模有关.问题的规模越小,越容易直接求解,解题所需的计算时间也越少.例如,对于n

经典排序算法(动图演示)

算法概述 0.1 算法分类 十种常见排序算法可以分为两大类: 非线性时间比较类排序:通过比较来决定元素间的相对次序,由于其时间复杂度不能突破O(nlogn),因此称为非线性时间比较类排序. 线性时间非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此称为线性时间非比较类排序. 0.2 算法复杂度 0.3 相关概念 稳定:如果a原本在b前面,而a=b,排序之后a仍然在b的前面. 不稳定:如果a原本在b的前面,而a=b,排序之后 a 可能会出现在 b

浅谈UML的概念和模型之UML九种图

文件夹: UML的视图 UML的九种图 UML中类间的关系 上文我们介绍了,UML的视图,在每一种视图中都包括一个或多种图.本文我们重点解说UML每种图的细节问题: 1.用例图(use case diagrams) [概念]描写叙述用户需求,从用户的角度描写叙述系统的功能 [描写叙述方式]椭圆表示某个用例:人形符号表示角色 [目的]帮组开发团队以一种可视化的方式理解系统的功能需求 [用例图] 2.静态图 类图(class  diagrams) [概念]显示系统的静态结构,表示不同的实体是怎样相关