Bootstrap

算法:十大排序算法及python实现

十大排序算法概述-Python代码

一、时间复杂度 O ( n 2 ) O(n^2) O(n2)的算法

  本小节介绍三个基本排序算法,基于比较且运行最慢的算法,包括冒泡排序、选择排序以及插入排序这三种算法。这三种算法的共同特点是慢,但也是后面优化的基础,因此需要掌握这三种算法原理。

1.冒泡排序 (最慢,稳定)

算法思想

  第 i ( i = 1 , 2 , . . . ) i(i=1,2,...) i(i=1,2,...)趟排序时从序列中前 n − i + 1 n - i + 1 ni+1 个元素的第 1 1 1个元素开始,相邻两个元素进行比较,若前者大于后者,两者交换位置,否则不交换。

  冒泡排序法是通过相邻元素之间的比较与交换,使值较小的元素逐步从后面移到前面,值较大的元素从前面移到后面,就像水底的气泡一样向上冒,故称这种排序方法为冒泡排序法。
请添加图片描述

算法分析
  • 最好的情况下,初始时序列已经是从小到大有序(升序),则只需经过一趟 n - 1 次元素之间的比较,并且不移动元素,算法就可结束排序。此时,算法的时间复杂度为 O ( n ) O(n) O(n)
  • 最差的情况是当参加排序的初始序列为逆序,或者最小值元素处在序列的最后时,则需要进行 n - 1 趟排序,总共进行 ∑ i = 2 n ( i − 1 ) = n ( n − 1 ) 2 \sum_{i=2}^n (i-1)=\frac{n(n-1)}{2} i=2n(i1)=2n(n1)次元素之间的比较,因此,冒泡排序算法的平均时间复杂度为 O ( n 2 ) O(n^2) O(n2)
  • 冒泡排序方法在排序过程中需要移动较多次数的元素。因此,冒泡排序方法比较适合于参加排序序列的数据量较小的情况,尤其是当序列的初始状态为基本有序的情况;而对于一般情况,这种方法是排序时间效率最低的一种方法。
  • 由于元素交换是在相邻元素之间进行的,不会改变值相同元素的相对位置,因此,冒泡排序法是一种 稳定排序法
Python 代码
def bubbleSort(arr):
	for i in range(len(arr)):
		for j in range(len(arr)-i-1):
			if arr[j]>arr[j+1]:
				arr[j],arr[j+1]=arr[j+1],arr[j]
	
	return arr
if __name__ == '__main__':
    array = [25, 17, 33, 17, 22, 13, 32, 15, 9, 25, 27, 18]
    print(bubbleSort(array))

2.选择排序 (不稳定)

算法思想

选择排序(Selection Sort)基本思想:

  第 i 趟排序从序列的后 n − i + 1 ( i = 1 , 2 , … , n − 1 ) n − i + 1 (i = 1, 2, …, n − 1) ni+1(i=1,2,,n1)个元素中选择一个值最小的元素与该 n − i + 1 n - i + 1 ni+1 个元素的最前面那个元素交换位置,即与整个序列的第 i 个位置上的元素交换位置。如此下去,直到 i = = n − 1 i == n − 1 i==n1,排序结束。

  可以简述为:每一趟排序中,从剩余未排序元素中选择一个最小的元素,与未排好序的元素最前面的那个元素交换位置。
请添加图片描述

算法分析
  • 对于具有 n n n 个元素的序列采用选择排序方法要经过 n − 1 n - 1 n1 趟排序。当原始序列是一个按值递增序列(升序)时,元素的移动次数最少,为 0 0 0 次。当序列初始时是一个按值递减序列(逆序)时,元素的移动次数最多,为 3 ( n − 1 ) 3(n-1) 3(n1)次(3 是交换 a r r [ i ] arr[i] arr[i] a r r [ m i n i ] arr[min_i] arr[mini] 的执行次数)。
  • 但是,无论序列中元素的初始排列状态如何,第 i i i 趟排序要找出值最小元素都需要进行 n − i n − i ni 次元素之间的比较。因此,整个排序过程需要进行的元素之间的比较次数都相同,为 ∑ i − 2 n ( i − 1 ) = n ( n − 1 ) 2 \sum_{i-2}^n (i-1)= \frac{n(n-1)}{2} i2n(i1)=2n(n1) 次。
  • 这说明选择排序法所进行的元素之间的比较次数与序列的原始状态无关,同时可以确定算法的时间复杂度为 O(n^2)。
  • 由于值最小元素与未排好序的元素中第 1 个元素的交换动作是在不相邻的元素之间进行的,因此很有可能会改变值相同元素的前后位置,因此,选择排序法是一种不稳定的排序方法。
代码实现
def selectSort(arr):
    for i in range(len(arr)-1):
        min_i=i
        for j in range(i+1,len(arr)):
            if arr[j]<arr[min_i]:
                min_i=j
        if i!=min_i:
            arr[i],arr[min_i]=arr[min_i],arr[i]
    return arr

if __name__ == '__main__':
    array = [25, 17, 33, 17, 22, 13, 32, 15, 9, 25, 27, 18]
    print(selectSort(array))

3. 插入排序

算法思想

  将整个序列切分为两部分:前 i - 1个元素是有序序列,后 n - i + 1个元素是无序序列。每一次排序,将无序序列的首元素,在有序序列中找到相应的位置并插入。
  可以简述为:每一趟排序中,将剩余无序序列的第一个元素,插入到有序序列的适当位置上。
请添加图片描述

算法分析
  • 对于具有 n个元素的序列,插入排序方法一共要进行 n - 1趟排序。
  • 对于插入排序算法,整个排序过程只需要一个辅助空间 temp
  • 当原始序列是一个按值递增序列(升序)时,对应的每个 i值只进行一次元素之间的比较,因而总的比较次数最少,为 ∑ i = 2 n 1 = n − 1 \sum_{i=2}^n1=n-1 i=2n1=n1,并不需要移动元素(记录),这是最好的情况。
  • 最坏的情况是,序列初始时是一个按值递减序列(逆序),则对应的每个 i 值都要进行 i - 1 次元素之间的比较,总的元素之间的比较次数达到最大值,为 ∑ i = 2 n ( i − 1 ) = n ( n − 1 ) 2 \sum_{i=2}^{n}(i-1)=\frac{n(n-1)}{2} i=2n(i1)=2n(n1)
  • 如果序列的初始情况是随机的,即参加排序的序列中元素可能出现的各种排列的概率相同,则可取上述最小值和最大值的平均值作为插入排序时所进行的元素之间的比较次数,约为 n 2 4 \frac{n^2}{4} 4n2 。由此得知,插入排序算法的时间复杂度 O ( n 2 ) O(n^2) O(n2)
  • 插入排序方法属于稳定性排序方法。
代码实现
def insertSort(arr):
    for i in range(1,len(arr)):
        temp=arr[i]
        j=i
        while j>0 and arr[j-1]>temp:
            arr[j]=arr[j-1]
            j-=1
        arr[j]=temp

    return arr

if __name__=='__main__':
    array = [25, 17, 33, 17, 22, 13, 32, 15, 9, 25, 27, 18]
    print(insertSort(array))  

二、改进后的排序算法

  本小节介绍四个排序算法,分别是希尔排序、归并排序、快速排序和堆排序。基于上面的三种算法进行适当改进,时间复杂度得到提升,但也会出现算法不稳定的一些情况。因此熟练掌握上面三种基本算法是比较排序的根本。

3.希尔排序

算法思想

  将整个序列切按照一定的间隔取值划分为若干个子序列,每个子序列分别进行插入排序。然后逐渐缩小间隔进行下一轮划分子序列和插入排序。直至最后一轮排序间隔为 1,对整个序列进行插入排序。
请添加图片描述

算法分析
  • 希尔排序方法的速度是一系列间隔数 g a p i gap_i gapi的函数,不太容易弄清楚比较次数与 gap之间的依赖关系,并给出完整的数学分析。
  • 上面算法中,由于采用 g a p i = ⌊ g a p i − 1 / 2 ⌋ gap_i=\lfloor gap_{i-1}/2 \rfloor gapi=gapi1/2的方法缩小间隔数,对于具有 n个元素的序列,若 g a p 1 = ⌊ n / 2 ⌋ gap_1=\lfloor n/2 \rfloor gap1=n/2 ,则经过 p = ⌊ l o g 2 n ⌋ p=\lfloor log_2 n \rfloor p=log2n 趟排序后就有 ,因此,希尔排序方法的排序总趟数为 ⌊ l o g 2 n ⌋ \lfloor log_2 n \rfloor log2n
  • 从算法中也可以看到,最外层的 while循环为 l o g 2 n log_2 n log2n 数量级,中间层 do-while循环为 n数量级。当子序列分得越多时,子序列内的元素就越少,最内层的 for循环的次数也就越少;反之,当所分的子序列个数减少时,子序列内的元素也随之增多,但整个序列也逐步接近有序,而循环次数却不会随之增加。因此,希尔排序算法的时间复杂度在 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) O ( n 2 ) O(n^2) O(n2)之间。
  • 希尔排序方法是一种不稳定排序算法。
代码实现
def shellSort(arr):
    size=len(arr)
    gap=size//2
    while gap>0:
        for i in range(gap,size):
            temp=arr[i]
            j=i
            while j>=gap and arr[j-gap]>temp:
                arr[j]=arr[j-gap]
                j-=gap
            arr[j]=temp
        gap=gap//2
    return arr 

if __name__=='__main__':
    array = [25, 17, 33, 17, 22, 13, 32, 15, 9, 25, 27, 18]
    print(insertSort(array))  

4.归并排序

算法思想

  采用分治策略,先递归地将当前序列平均分成两半。然后将有序序列两两合并,最终合并成一个有序序列。
请添加图片描述

算法分析
  • 归并排序算法的时间复杂度等于归并趟数与每一趟归并的时间复杂度成绩。子算法 merge(left_arr, right_arr)的时间复杂度是 O ( n ) O(n) O(n),因此,归并排序算法总的时间复杂度为 O ( n l o g 2 n ) O(nlog_2 n) O(nlog2n)
  • 归并排序方法需要用到与参加排序的序列同样大小的辅助空间。因此算法的空间复杂度为 O ( n ) O(n) O(n)
  • 因为在两个有序子序列的归并过程中,如果两个有序序列中出现相同元素,merge(left_arr, right_arr)算法能够使前一个序列中那个相同元素先被复制,从而确保这两个元素的相对次序不发生改变。所以归并排序算法是 稳定排序算法
代码实现
#归并排序
#分
def merge(arr):
    if len(arr)==1:
        return arr
    size=len(arr)
    mid=size//2
    left,right=arr[:mid],arr[mid:]
    return mergesort(merge(left),merge(right))
#并
def mergesort(left,right):
    arr=[]
    while left and right:
        if left[0]<=right[0]:
            arr.append(left.pop(0))
        else:
            arr.append(right.pop(0))
    arr+=left
    arr+=right
    return arr

arr=[6,5,3,1,8,7,2,4]
merge(arr)

5.快速排序

算法思想

  通过一趟排序将无序序列分为独立的两个序列,第一个序列的值均比第二个序列的值小。然后递归地排列两个子序列,以达到整个序列有序。
请添加图片描述

算法分析
  • 在参加排序的元素初始时已经有序的情况下,快速排序方法花费的时间最长。此时,第 1趟排序经过 n - 1次比较以后,将第1个元素仍然确定在原来的位置上,并得到 1个长度为 n - 1的子序列;第 2趟排序进过n - 2次比较以后,将第 2个元素确定在它原来的位置上,又得到 1个长度为 n - 2的子序列;依次类推,最终总的比较次数为 ( n − 1 ) + ( n − 2 ) + . . . + 1 = n ( n − 1 ) 2 (n-1)+(n-2)+...+1=\frac{n(n-1)}{2} (n1)+(n2)+...+1=2n(n1)。因此时间复杂度为 O ( n 2 ) O(n^2) O(n2)

  • 还有一种情况,若每趟排序后,分界元素正好定位在序列的中间,从而把当前参加排序的序列分成大小相等的前后两个子序列,则对长度为 n 的序列进行快速排序所需要的时间为:
    T ( n ) ≤ n + 2 T ( n / 2 ) ≤ 2 n + 4 T ( n / 2 ) ≤ 3 n + 8 T ( n / 8 ) . . . ≤ ( l o g 2 n ) n + n T ( 1 ) = O ( n l o g 2 n ) T(n)\le n+2T(n/2)\le 2n+4T(n/2)\le 3n+8T(n/8)...\le(log_2n)n+nT(1)=O(nlog_2n) T(n)n+2T(n/2)2n+4T(n/2)3n+8T(n/8)...(log2n)n+nT(1)=O(nlog2n)因此,快速排序方法的时间复杂度为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) ,时间性能显然优于前面讨论的几种排序算法。

  • 无论快速排序算法递归与否,排序过程中都需要用到堆栈或其他结构的辅助空间来存放当前待排序序列的首、尾位置。最坏的情况下,空间复杂度为 O ( n ) O(n) O(n)

  • 若对算法进行一些改写,在一趟排序之后比较被划分所得到的两个子序列的长度,并且首先对长度较短的子序列进行快速排序,这时候需要的空间复杂度可以达到 O ( l o g 2 n ) O(log_2n) O(log2n)

  • 快速排序时一种 不稳定排序算法,也是一种不适合在链表结构上实现的排序算法。

代码实现
def quick_sort(lists,i,j):
    if i >= j:
        return list
    pivot = lists[i]
    low = i
    high = j
    while i < j:
        while i < j and lists[j] >= pivot:
            j -= 1
        lists[i]=lists[j]
        while i < j and lists[i] <=pivot:
            i += 1
        lists[j]=lists[i]
    lists[j] = pivot
    quick_sort(lists,low,i-1)
    quick_sort(lists,i+1,high)
    return lists

if __name__=="__main__":
    lists=[30,24,5,58,18,36,12,42,39]
    print("排序前的序列为:")
    for i in lists:
        print(i,end =" ")
    print("\n排序后的序列为:")
    for i in quick_sort(lists,0,len(lists)-1):
        print(i,end=" ")

6.堆排序

算法思想

  借用「堆结构」所设计的排序算法。将数组转化为大顶堆,重复从大顶堆中取出数值最大的节点,并让剩余的堆维持大顶堆性质。

堆定义

堆:符合以下两个条件之一的完全二叉树:

  • 大顶堆:根节点值 ≥ 子节点值。
  • 小顶堆:根节点值 ≤ 子节点值。
动画演示
  • 对调整方法
    请添加图片描述
  • 初始堆建立
    请添加图片描述
  • 堆排序
    请添加图片描述
算法分析
  • 堆积排序的时间主要花费在两个方面:
    • 将原始序列构造为一个初始堆积。
    • 排序过程中不断将移走最大值元素,然后将剩下元素重新调整为一个新堆积。
  • 设原始序列所对应的完全二叉树深度为 d,算法由两个独立的循环组成:
    • 在第 1个循环构造初始堆积时,从 i = d - 1层开始,到 i = 1 层为止,对每个分支节点都要调用一次 adjust 算法,每一次 adjust算法,对于第 i层一个节点到第d 层上建立的子堆积,所有节点可能移动的最大距离为该子堆积根节点移动到最后一层(第 d层) 的距离即 d - i
    • 而第 i层上节点最多有 2 i − 1 2^{i-1} 2i1个,所以每一次 adjust算法最大移动距离为 2 i − 1 ∗ ( d − i ) 2^{i-1}*(d-i) 2i1(di)。因此,堆积排序算法的第 1个循环所需时间应该是各层上的节点数与该层上节点可移动的最大距离之积的总和,即:
      ∑ i = d − 1 1 2 i − 1 ∗ ( d − i ) = ∑ j = 1 d − 1 2 d − j − 1 × j = ∑ j = 1 d − 1 2 d − 1 × j 2 j ≤ n ∑ j = 1 d − 1 j 2 j < 2 n \sum_{i=d-1}^12^{i-1}*(d-i)=\sum_{j=1}^{d-1}2^{d-j-1}\times j=\sum_{j=1}^{d-1}2^{d-1}\times \frac{j}{2^j} \le n\sum_{j=1}^{d-1}\frac{j}{2^j} <2n i=d112i1(di)=j=1d12dj1×j=j=1d12d1×2jjnj=1d12jj<2n这一部分时间花费为 O ( n ) O(n) O(n)
    • 在算法的第 2个循环中每次调用 adjust 算法一次,节点移动的最大距离为这棵完全二叉树的深度
      d = ⌊ l o g 2 ( n ) ⌋ + 1 d=\lfloor log_2(n) \rfloor+1 d=log2(n)+1,一共调用了n - 1adjust算法,所以,第 2个循环的时间花费为
      ( n − 1 ) ( ⌊ l o g 2 ( n ) ⌋ + 1 ) = O ( n l o g 2 n ) (n-1)(\lfloor log_2(n) \rfloor+1)=O(nlog_2n) (n1)(log2(n)+1)=O(nlog2n)
  • 因此,堆积排序的时间复杂度为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)
  • 由于在堆积排序中只需要一个记录大小的辅助空间,因此,堆积排序的空间复杂度为: O ( 1 ) O(1) O(1)
  • 堆排序属于 不稳定排序算法。堆排序也是一种不适合在链表上实现的排序。
代码实现
def heapify(arr, n, i): 
    largest = i  
    l = 2 * i + 1     # left = 2*i + 1 
    r = 2 * i + 2     # right = 2*i + 2 
    if l < n and arr[largest] < arr[l]: 
        largest = l 
    if r < n and arr[largest] < arr[r]: 
        largest = r 
    if largest != i: 
        arr[i],arr[largest] = arr[largest],arr[i]  # 交换
        heapify(arr, n, largest) 
def heapSort(arr): 
    n = len(arr) 
    # Build a maxheap. 
    for i in range(n, -1, -1): 
        heapify(arr, n, i) 
    # 一个个交换元素
    for i in range(n-1, 0, -1): 
        arr[i], arr[0] = arr[0], arr[i]   # 交换
        heapify(arr, i, 0) 
arr = [ 12, 11, 13, 5, 6, 7] 
heapSort(arr) 
n = len(arr) 
print ("排序后") 
for i in range(n): 
    print ("%d" %arr[i])

三、基于计数的排序

前面介绍的排序算法都是基于比较的排序算法,时间复杂度最小为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n),本小节介绍的排序是非比较的思想,采用以时间换空间的策略,将介绍三种排序方法:计数排序,桶排序以及基数排序。

8.计数排序

算法思想

  使用一个额外的数组 counts,其中第i个元素 counts[i] 是待排序数组arr中值等于i的元素个数。然后根据数组counts来将arr中的元素排到正确的位置。
请添加图片描述

算法分析
  • 当输入元素是n0 ~ k之间的整数时,计数排序的时间复杂度为 O ( n + k ) O(n+k) O(n+k)
  • 由于用于计数的数组 counts的长度取决于待排序数组中数据的范围(等于待排序数组最大值减去最小值再加 1)。所以计数排序对于数据范围很大的数组,需要大量的时间和内存。
  • 计数排序一般用于排序整数,不适用于按字母顺序排序人名。
  • 计数排序是 稳定排序算法
代码实现
def counting_sort(arr):
    if len(arr) < 2:
        return arr
    max_num = max(arr)
    count = [0] * (max_num + 1)
    for num in arr:
        count[num] += 1
    new_array = []
    for i in range(len(count)):
        for j in range(count[i]):
            new_array.append(i)
    return new_array
 
 
if __name__ == '__main__':
    array = [5, 7, 3, 7, 2, 3, 2, 5, 9, 5, 7, 6]
    print(counting_sort(array))

9.桶排序

算法思想

将未排序的数组分到若干个「桶」中,每个桶的元素再进行单独排序。

  • 划分子区间
    请添加图片描述
  • 将数组元素装入桶中,并对桶内元素单独排序
    请添加图片描述
  • 将桶内元素合并起来,完成排序
    请添加图片描述
算法分析
  • 桶排序可以在线性时间内完成排序,当输入元素个数为 n,桶的个数是 m 时,桶排序时间复杂度为 O ( n + m ) O(n+m) O(n+m)
  • 由于桶排序使用了辅助空间,所以桶排序的空间复杂度是 o(n + m)
  • 如果桶内使用插入排序算法等稳定排序算法,则桶排序也是 稳定排序算法
代码实现
def bucket_sort(array):
    min_num, max_num = min(array), max(array)
    bucket_num = (max_num-min_num)//3 + 1
    buckets = [[] for _ in range(int(bucket_num))]
    for num in array:
        buckets[int((num-min_num)//3)].append(num)
    new_array = []
    for i in buckets:
        for j in sorted(i):
            new_array.append(j)
    return new_array
 
 
if __name__ == '__main__':
    array = [5, 7, 3, 7, 2, 3, 2, 5, 9, 5, 7, 8]
    print(bucket_sort(array))

10.基数排序

算法思想

将整数按位数切割成不同的数字,然后按每个位数分别比较进行排序。
请添加图片描述

算法分析
  • 基数排序的时间复杂度是 O ( k ∗ n ) O(k*n) O(kn) 。其中 n是待排序元素的个数,k是数字位数。k 的大小取决于数字位的选择(十进制位、二进制位)和待排序元素所属数据类型全集的大小。
  • 基数排序的空间复杂度是 O ( n + k ) O(n+k) O(n+k)
    基数排序是 稳定排序算法
代码实现
def radixSort(arr):
    size = len(str(max(arr)))
    for i in range(size):
        buckets = [[] for _ in range(10)]
        for num in arr:
            buckets[num // (10**i) % 10].append(num)
            arr.clear()
    	for bucket in buckets:
            for num in bucket:
                arr.append(num)
    return arr

if __name__ == '__main__':
    array = [25, 17, 33, 17, 22, 13, 32, 15, 9, 25, 27, 18]
    print(radixSort(array))

以上是要介绍的所有排序算法的思路及方法,排序算法是数据结构基础中的基础,因此搞明白其含义以及熟练书写代码是基本操作了。后面会结合leetcode中的题目进行排序算法的进一步练习,此外还有一些出现频次不高的排序算法没有涉及。

敬请期待~

参考文献

[1] 算法通关手册
[2] DataWhale第31期组队学习-leetcode刷题

;