经典排序算法总结与实现(python版)


(anaiw) #1

经典排序算法总结与实现

经典排序算法在面试中占有很大的比重,也是基础,为了未雨绸缪,在寒假里整理并用Python实现了七大经典排序算法,包括冒泡排序,插入排序,选择排序,希尔排序,归并排序,快速排序,堆排序。希望能帮助到有需要的同学。之所以用Python实现,主要是因为它更接近伪代码,能用更少的代码实现算法,更利于理解。

本文所有排序实现均默认从小到大。

一、冒泡排序BubbleSort

介绍:

冒泡排序的原理非常简单,它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。

步骤:

  1. 比较相邻的元素。如果第一个比第二个大,就交换他们两个。
  2. 对第0个到第n-1个数据做同样的工作。这时,最大的数就“浮”到了数组最后的位置上。
  3. 针对所有的元素重复以上的步骤,除了最后一个。
  4. 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。

Python源代码(错误版本):

def bubble_sort(arry):
    n = len(arry)                   #获得数组的长度
    for i in range(n):
        for j in range(i+1, n):
            if  arry[i] > arry[j] :       #如果前者比后者大
                arry[i],arry[j] = arry[j],arry[i]      #则交换两者
    return arry

注:上述代码是没有问题的,但是实现却不是冒泡排序,而是选择排序(原理见选择排序),注意冒泡排序的本质是“相邻元素”的顺序交换,而非每次完成一个最小数字的选定。

Python源代码(正确版本):

def bubble_sort(arry):
    n = len(arry)                   #获得数组的长度
    for i in range(n):
        for j in range(1, n-i):    # 每轮找到最大数值
            if  arry[j-1] > arry[j] :       #如果前者比后者大
                arry[j-1],arry[j] = arry[j-1],arry[i]      #则交换两者
    return arry

不过针对上述代码还有两种优化方案。

优化1:

某一趟遍历如果没有数据交换,则说明已经排好序了,因此不用再进行迭代了。用一个标记记录这个状态即可。

Python源代码:

def bubble_sort2(ary):
    n = len(ary)
    for i in range(n):
        flag = False    # 标记
        for j in range(1, n - i):
            if ary[j] < ary[j-1]:
                ary[j], ary[j-1] = ary[j-1], ary[j]
                flag = True
        # 某一趟遍历如果没有数据交换,则说明已经排好序了,因此不用再进行迭代了
        if flag:    
            break
    return ary

优化2:

记录某次遍历时最后发生数据交换的位置,这个位置之后的数据显然已经有序,不用再排序了。因此通过记录最后发生数据交换的位置就可以确定下次循环的范围了。

def bubble_sort3(ary):
    n = len(ary)
    k = n    #k为循环的范围,初始值n
    for i in range(n):
        flag = True
        for j in range(1, k):    #只遍历到最后交换的位置即可
            if ary[j-1] > ary[j]:
                ary[j-1], ary[j] = ary[j-1], ary[j]
                k = j     #记录最后交换的位置
                flag = Flase
        if flag:
            break
    return ary

二、选择排序SelectionSort

介绍:

选择排序是另一个很容易理解和实现的简单排序算法。学习它之前首先要知道它的两个很鲜明的特点。 1. 运行时间和输入无关 为了找出最小的元素而扫描一遍数组并不能为下一遍扫描提供任何实质性帮助的信息。因此使用这种排序的我们会惊讶的发现,一个已经有序的数组或者数组内元素全部相等的数组和一个元素随机排列的数组所用的排序时间竟然一样长!而其他算法会更善于利用输入的初始状态,选择排序则不然。 2. 数据移动是最少的 选择排序的交换次数和数组大小关系是线性关系,选择排序无疑是最简单直观的排序。看下面的原理时可以很容易明白这一点。

步骤:

  1. 在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。
  2. 再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
  3. 以此类推,直到所有元素均排序完毕。

源代码:(python实现)

def select_sort(ary):
    n = len(ary)
    for i in range(0,n):
        min = i                             #最小元素下标标记
        for j in range(i+1,n):
            if ary[j] < ary[min] :
                min = j                     #找到最小值的下标
        ary[min],ary[i] = ary[i],ary[min]   #交换两者
    return ary

三、插入排序 InsertionSort

介绍:

插入排序的工作原理是,对于每个未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

步骤:

  1. 从第一个元素开始,该元素可以认为已经被排序
  2. 取出下一个元素,在已经排序的元素序列中从后向前扫描
  3. 如果被扫描的元素(已排序)大于新元素,将该元素后移一位
  4. 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置
  5. 将新元素插入到该位置后
  6. 重复步骤2~5

排序演示

![](data:image/svg+xml;utf8,<svg%20xmlns=‘http://www.w3.org/2000/svg’%20width=‘300’%20height='180’>)

源代码:(python实现)

# 插入排序
def insert_sort(ary):
    count = len(ary)
    for i in range(1, count):
        key = i - 1
        mark = ary[i]
        while key >= 0 and ary[key] > mark:
            ary[key+1] = ary[key]
            key -= 1
        ary[key+1] = mark
    return ary

四、希尔排序 ShellSort

介绍:

希尔排序的实质就是分组插入排序,该方法又称缩小增量排序,因DL.Shell于1959年提出而得名。

该方法的基本思想是:先将整个待排元素序列分割成若干个子序列(由相隔某个“增量”的元素组成的)分别进行直接插入排序,然后依次缩减增量再进行排序,待整个序列中的元素基本有序(增量足够小)时,再对全体元素进行一次直接插入排序。因为直接插入排序在元素基本有序的情况下(接近最好情况),效率是很高的,因此希尔排序在时间效率上比前两种方法有较大提高。

n=10的一个数组49, 38, 65, 97, 26, 13, 27, 49, 55, 4为例

第一次 gap = 10/2 = 5

49 38 65 97 26 13 27 49 55 4

1A 1B 2A 2B 3A 3B 4A 4B 5A 5B

1A, 1B, 2A, 2B等为分组标记,数字相同的表示在同一组,大写字母表示是该组的第几个元素, 每次对同一组的数据进行直接插入排序。即分成了五组(49, 13) (38, 27) (65, 49) (97, 55) (26, 4)这样每组排序后就变成了(13, 49) (27, 38) (49, 65) (55, 97) (4, 26),下同。 第二次 gap = 5 / 2 = 2

排序后

13 27 49 55 4 49 38 65 97 26

1A 1B 1C 1D 1E 2A 2B 2C 2D 2E

第三次 gap = 2 / 2 = 1

4 26 13 27 38 49 49 55 97 65

1A 1B 1C 1D 1E 1F 1G 1H 1I 1J

第四次 gap = 1 / 2 = 0 排序完成得到数组:

4 13 26 27 38 49 49 55 65 97

下面给出严格按照定义来写的希尔排序

源代码:(python实现)

def shell_sort(ary):
        count = len(ary)
        gap = round(count/2)    
        # 双杠用于整除(向下取整),在python直接用 “/” 得到的永远是浮点数,用round()得到四舍五入值
        while gap >= 1:
            for i in range(gap, count):
                temp = ary[i]
                j = i
                while j - gap >= 0 and ary[j-gap] > temp:    # 到这里与插入排序一样了
                    ary[j] = ary[j-gap]
                    j -= gap
                ary[j] = temp
            gap = round(gap/2)
        return ary

五、归并排序 MergeSort

介绍:

归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。

首先考虑下如何将将二个有序数列合并。这个非常简单,只要从比较二个数列的第一个数,谁小就先取谁,取了后就在对应数列中删除这个数。然后再进行比较,如果有数列为空,那直接将另一个数列的数据依次取出即可。

原理

归并(Merge)排序法是将两个(或两个以上)有序表合并成一个新的有序表,即把待排序序列分为若干个子序列,每个子序列是有序的。然后再把有序子序列合并为整体有序序列。

image

合并方法:

设r[i…n]由两个有序子表r[i…m]和r[m+1…n]组成,两个子表长度分别为n-i +1、n-m。

1、j=m+1;k=i;i=i; //置两个子表的起始下标及辅助数组的起始下标
2、若i>m 或j>n,转⑷ //其中一个子表已合并完,比较选取结束
3、//选取r[i]和r[j]较小的存入辅助数组rf
        如果r[i]<r[j],rf[k]=r[i]; i++; k++; 转⑵
        否则,rf[k]=r[j]; j++; k++; 转⑵
4、//将尚未处理完的子表中元素存入rf
        如果i<=m,将r[i…m]存入rf[k…n] //前一子表非空
        如果j<=n ,  将r[j…n] 存入rf[k…n] //后一子表非空
5、合并结束。

排序演示

![](data:image/svg+xml;utf8,<svg%20xmlns=‘http://www.w3.org/2000/svg’%20width=‘300’%20height='180’>)

源代码:(python实现)

# 归并排序

    def merge_sort(self, ary):

        if len(ary) <= 1:
            return ary

        median = int(len(ary)/2)    # 二分分解
        left = self.merge_sort(ary[:median])
        right = self.merge_sort(ary[median:])
        return self.merge(left, right)    # 合并数组

    def merge(self, left, right):
     '''合并操作,
    将两个有序数组left[]和right[]合并成一个大的有序数组'''
        res = []
        i = j = k = 0
        while(i < len(left) and j < len(right)):
            if left[i] < right[j]:
                res.append(left[i])
                i += 1
            else:
                res.append(right[j])
                j += 1

        res = res + left[i:] + right[j:]
        return res

六、快速排序 QuickSort

介绍:

快速排序通常明显比同为Ο(n log n)的其他算法更快,因此常被采用,而且快排采用了分治法的思想,所以在很多笔试面试中能经常看到快排的影子。可见掌握快排的重要性。

步骤:

  1. 从数列中挑出一个元素作为基准数。
  2. 分区过程,将比基准数大的放到右边,小于或等于它的数都放到左边。
  3. 再对左右区间递归执行第二步,直至各区间只有一个数。

虽然快速排序称为分治法,但分治法这三个字显然无法很好的概括快速排序的全部步骤。因此我的对快速排序作了进一步的说明:挖坑填数+分治法:

以一个数组作为示例,取区间第一个数为基准数。

0 1 2 3 4 5 6 7 8 9

72 6 57 88 60 42 83 73 48 85

初始时,i = 0; j = 9; X = a[i] = 72

由于已经将a[0]中的数保存到X中,可以理解成在数组a[0]上挖了个坑,可以将其它数据填充到这来。

从j开始向前找一个比X小或等于X的数。当j=8,符合条件,将a[8]挖出再填到上一个坑a[0]中。a[0]=a[8]; i++; 这样一个坑a[0]就被搞定了,但又形成了一个新坑a[8],这怎么办了?简单,再找数字来填a[8]这个坑。这次从i开始向后找一个大于X的数,当i=3,符合条件,将a[3]挖出再填到上一个坑中a[8]=a[3]; j–;

数组变为:

0 1 2 3 4 5 6 7 8 9

48 6 57 88 60 42 83 73 88 85

i = 3; j = 7; X=72

再重复上面的步骤,先从后向前找,再从前向后找。

从j开始向前找,当j=5,符合条件,将a[5]挖出填到上一个坑中,a[3] = a[5]; i++;

从i开始向后找,当i=5时,由于i==j退出。

此时,i = j = 5,而a[5]刚好又是上次挖的坑,因此将X填入a[5]。

数组变为:

0 1 2 3 4 5 6 7 8 9

48 6 57 42 60 72 83 73 88 85

可以看出a[5]前面的数字都小于它,a[5]后面的数字都大于它。因此再对a[0…4]和a[6…9]这二个子区间重复上述步骤就可以了。

对挖坑填数进行总结:

  1. i =L; j = R; 将基准数挖出形成第一个坑a[i]。

  2. j–由后向前找比它小的数,找到后挖出此数填前一个坑a[i]中。

  3. i++由前向后找比它大的数,找到后也挖出此数填到前一个坑a[j]中。

4 再重复执行2,3二步,直到i==j,将基准数填入a[i]中。

照着这个总结很容易实现挖坑填数的代码.

排序演示

![](data:image/svg+xml;utf8,<svg%20xmlns=‘http://www.w3.org/2000/svg’%20width=‘300’%20height='180’>)

源代码:(python实现)

def quick_srot(ary):

    return qsort(ary, 0, len(ary)-1)

def qsrot(ary, start, end):
    if start < end:
        left = start
        right = end
        key = ary[start]
        while left < right:
            while left < right and ary[right] >= key:
                right -= 1
            if left < right:    #说明打破while循环的原因是ary[right] <= key
                ary[left] = ary[right]
                left += 1
            while left < right and ary[left] < key:
                left += 1
            if left < right:    #说明打破while循环的原因是ary[left] >= key
                ary[right] = ary[left]
                right -= 1
            ary[left] = key    #此时,left=right,用key来填坑

        qsort(ary, start, left-1)
        qsort(ary, left+1, end)
    return ary

七、堆排序 HeapSort

介绍:

堆排序与快速排序,归并排序一样都是时间复杂度为$O(N*logN)$的几种常见排序方法。学习堆排序前,先讲解下什么是数据结构中的二叉堆。

堆排序在 top K 问题中使用比较频繁。堆排序是采用二叉堆的数据结构来实现的,虽然实质上还是一维数组。二叉堆是一个近似完全二叉树 。

二叉堆定义及性质:

二叉堆是完全二叉树或者是近似完全二叉树。

二叉堆满足二个特性:

  1. 父结点的键值总是大于或等于(小于或等于)任何一个子节点的键值。

  2. 每个结点的左子树和右子树都是一个二叉堆(都是最大堆或最小堆)。

当父结点的键值总是大于或等于任何一个子节点的键值时为最大堆。当父结点的键值总是小于或等于任何一个子节点的键值时为最小堆。下图展示一个最小堆

1

父节点的键值总是大于或等于(小于或等于)任何一个子节点的键值。 每个节点的左右子树都是一个二叉堆(都是最大堆或最小堆)。

由于其它几种堆(二项式堆,斐波纳契堆等)用的较少,一般将二叉堆就简称为堆。

堆的存储

一般都用数组来表示堆,i结点的父结点下标就为(i – 1) / 2。它的左右子结点下标分别为2 * i + 1和2 * i + 2。如第0个结点左右子结点下标分别为1和2。

2

堆的操作——插入删除

下面先给出《数据结构C++语言描述》中最小堆的建立插入删除的图解,再给出本人的实现代码,最好是先看明白图后再去看代码。

堆化数组

有了堆的插入和删除后,再考虑下如何对一个数据进行堆化操作。要一个一个的从数组中取出数据来建立堆吧,不用!先看一个数组,如下图:

4

很明显,对叶子结点来说,可以认为它已经是一个合法的堆了即20,60, 65, 4, 49都分别是一个合法的堆。只要从A[4]=50开始向下调整就可以了。然后再取A[3]=30,A[2] = 17,A[1] = 12,A[0] = 9分别作一次向下调整操作就可以了。

5

下图展示了这些步骤:

步骤:

  1. 构造最大堆(Build_Max_Heap):若数组下标范围为0~n,考虑到单独一个元素是大根堆,则从下标n/2开始的元素均为大根堆。于是只要从n/2-1开始,向前依次构造大根堆,这样就能保证,构造到某个节点时,它的左右子树都已经是大根堆。

  2. 堆排序(HeapSort):由于堆是用数组模拟的。得到一个大根堆后,数组内部并不是有序的。因此需要将堆化数组有序化。思想是移除根节点,并做最大堆调整的递归运算。第一次将heap[0]与heap[n-1]交换,再对heap[0…n-2]做最大堆调整。第二次将heap[0]与heap[n-2]交换,再对heap[0…n-3]做最大堆调整。重复该操作直至heap[0]和heap[1]交换。由于每次都是将最大的数并入到后面的有序区间,故操作完后整个数组就是有序的了。

  3. 最大堆调整(Max_Heapify):该方法是提供给上述两个过程调用的。目的是将堆的末端子节点作调整,使得子节点永远小于父节点 。

排序演示:

![](data:image/svg+xml;utf8,<svg%20xmlns=‘http://www.w3.org/2000/svg’%20width=‘350’%20height='280’>)

源代码:(python实现)

def heap_sort(ary):
    n = len(ary)
    first = int(n/2-1)    #最后一个非叶子节点
    for start in range(first,-1,-1):    #构建最大堆
        max_heapify(ary,start,n-1)
    for end in range(n-1,0,-1):    #堆排,将最大跟堆转换成有序数组
        ary[end],ary[0] = ary[0], ary[end]    #将根节点元素与最后叶子节点进行互换,取出最大根节点元素,对剩余节点重新构建最大堆
        max_heapify(ary,0,end-1)    #因为end上面取的是n-1,故而这里直接放end-1,相当于忽略了最后最大根节点元素ary[n-1]
    return ary


#最大堆调整:将堆的末端子节点作调整,使得子节点永远小于父节点
#start为当前需要调整最大堆的位置,end为调整边界
def max_heapify(ary,start,end):
    root = start
    while True:
        child = root * 2 + 1    #调整节点的子节点
        if child > end:
            break
        if child + 1 <= end and ary[child] < ary[child+1]:
            child = child + 1   #取较大的子节点
        if ary[root] < ary[child]:    #较大的子节点成为父节点
            ary[root], ary[child] = ary[child], ary[root]    #交换
            root = child
        else:
            break

总结

时间复杂度

下面为七种经典排序算法指标对比情况:

O(n)这样的标志叫做渐近时间复杂度,是个近似值.各种渐近时间复杂度由小到大的顺序如下

O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < O(n^3) < O(2^n) < O(n!) < O(n^n)

一般时间复杂度到了$2^n$(指数阶)及更大的时间复杂度,这样的算法我们基本上不会用了,太不实用了.比如递归实现的汉诺塔问题算法就是$O(2^n)$.

平方阶($n^2$)的算法是勉强能用,而nlogn及更小的时间复杂度算法那就是非常高效的算法了啊.

空间复杂度

冒泡排序,简单选择排序,堆排序,直接插入排序,希尔排序的空间复杂度为O(1),因为需要一个临时变量来交换元素位置,(另外遍历序列时自然少不了用一个变量来做索引)

快速排序空间复杂度为logn(因为递归调用了) ,归并排序空间复杂是O(n),需要一个大小为n的临时数组.

基数排序的空间复杂是O(n),桶排序的空间复杂度不确定

最快的排序算法是桶排序

所有排序算法中最快的应该是桶排序(很多人误以为是快速排序,实际上不是.不过实际应用中快速排序用的多)但桶排序一般用的不多,因为有几个比较大的缺陷.

1.待排序的元素不能是负数,小数.

2.空间复杂度不确定,要看待排序元素中最大值是多少.

所需要的辅助数组大小即为最大元素的值.