图解算法基础--快速排序,附 Go 代码实现

Posted 网管叨bi叨

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了图解算法基础--快速排序,附 Go 代码实现相关的知识,希望对你有一定的参考价值。

很多面试题的解答都是以排序为基础的,如果我们写出一个 O() 的算法,大概率要被挂,今天写个快排的基础文章,后面看情况再把归并和堆排序写一写,至于选择排序、冒泡排序这种时间复杂度高的就不写了,有兴趣的可以找书自己看一下。

文中算法的实现是用 Go 写了一个比较简单的快速排序,方便大家理解(旁边画外音:其实是他好几年没面试了,太厉害的他也写不出来)。

关于更优秀的代码实现,可以在评论区里发出来一起学习,相信咱们读者里一定是卧虎藏龙,有不少算法大拿。

快速排序的思想

快速排序算法首先会在序列中随机选择一个基准值(pivot),然后将除了基准值之外的数分为 "比基准值小的数" 和 "比基准值大的数" 这两个类别,再将其排列成以下形式。

【比基准值小的数】 基准值 【比基准值大的数】

接着,继续对两个序列 "【】"中的数据进行排序之后,整体的排序便完成了。对基准值左右两侧的序列排序时,同样也会使用快速排序。

快速排序是一种"分治法",将原本的问题分解成两个子问题—— 比基准值小的数和比基准值大的数,然后再分别解决这两个子问题。解决子问题的时候会再次使用快速排序,只有在子问题里只剩下一个数字的时候,排序才算完成。

快排的过程

下面我们用示意图更好地理解一下快速排序对一个序列进行排序的过程。

图例出自—《我的第一本算法书》

假定有如下待排序序列

待排序序列

首先在序列中随机选择一个基准值,这里选择了 4。

选择基准值 pivot

将其他数字和基准值进行比较,小于基准值的往左移,大于基准值的往右移。

首先比较第一个元素 3 和基准值4,因为 3 < 4, 所以将 3放在基准值的左边。

首先比较 3 和基准值4,因为 3 < 4, 所以将 3放在基准值的左边

接下来,比较 5 和基准值,因为 5 > 4,所以将 5 放在基准值的右边。

5 > 4, 将5放在基准值右边

对整个序列进行同样操作后,所有小于基准值的数字全都放到了基准值的左边,大于的则全都放在了右边。

一轮排序完成后的结果
把基准值放入序列

现在排序就分成了两个子问题,分别再对基准值左边和右边的数据进行排序。

分解成了两个子问题

两边的排序操作也和前面的一样,也是使用快排算法,选取基准值,把小于的数字放左边大于的放右边。

对子序列使用快速排序

子问题有可能会再分解成子问题,直到子问题里只剩下一个数字,再也无法分解出子问题的时候,整个序列的排序才算完成。

排序完成

因为快速排序算法在对序列进行排序的过程中会再次使用该算法,所以快速排序算法在实现时需要使用"递归”来实现。

快速排序的Go代码实现

下面上一个用 Go 版本的快速排序算法的简单实现


func quickSort(sequence []int, low int, high int) 
 if high <= low 
  return
 
 j := partition(sequence, low, high)
 quickSort(sequence, low, j-1)
 quickSort(sequence, j+1, high)


// 进行快速排序中的一轮排序
func partition(sequence []int, low int, high int) int 
 i, j := low+1, high
 for 
  // 把头元素作为基准值 pivot
  for sequence[i] < sequence[low] 
   // i 坐标从前往后访问序列,如果位置上的值大于基准值,停下来。
   // 准备和 j 坐标访问到的小于基准值的值交换位置
   i++
   if i >= high 
    break
   
  
  for sequence[j] > sequence[low] 
   // j 坐标从后往前访问序列,如果位置上的值小于基准值,停下来。
   // 和 i 坐标指向的大于基准值的值交换位置
   j--
   if j <= low 
    break
   
  
  if i >= j 
   break
  
  sequence[i], sequence[j] = sequence[j], sequence[i]
 
 sequence[low], sequence[j] = sequence[j], sequence[low]

 return j

每一轮快速排序都会经历下面这几个步骤:

  1. 设置两个变量i、j,排序开始的时候:i=0,j=待排序序列长度 - 1。
  2. 以第一个数组元素作为基准值 pivot(也可以是最后一个元素,或者是随机的一个元素)。
  3. i 坐标从开始向后访问序列里的元素,即 i++,找到第一个大于 pivot 的位置 ,和 j 坐标访问到的小于基准值的值交换位置。
  4. j 坐标从末尾向前搜索,即j--,找到第一个小于 pivot 的位置,将i,j坐标上的值进行互换。
  5. 重复第3、4步,直到i=j,然后将 pivot 和 j 坐标上的值互换,完成一轮排序,小于 pivot 的值都放在了它的左边,大于的则放到了右边。

重复进行上面的过程,直到排序完成。最后我们可以生成一个随机数序列对上面的快速排序函数进行测试:

func main() 
 rand.Seed(time.Now().Unix())
 sequence := rand.Perm(34)
 fmt.Printf("sequence before sort: %v", sequence)
 quickSort(sequence, 0len(sequence) - 1)
 fmt.Printf("sequence after sort: %v", sequence)

快速排序的时间复杂度

分割子序列时需要选择基准值,如果每次选择的基准值都能使得两个子序列的长度为原本的一半,那么快速排序的运行时间和归并排序的一样,都为 O(nlogn)。将序列对半分割 log2n 次之后,子序列里便只剩下一个数据,这时子序列的排序也就完成了。

因此,如果像下图这样一行行地展现根据基准值分割序列的过程,那么总共会有 log2n 行。

快排分解序列的次数

每行中每个数字都需要和基准值比较大小,因此每行所需的运行时间为 O(n)。由此可知,整体的时间复杂度为 O(nlogn)。

如果运气不好,每次都选择最小值作为基准值,那么每次都需要把其他数据移到基准值的右边,递归执行 n 行,运行时间也就成了 O()。

所以真正应用的时候基准值的选取也比较讲究,比如以中位数做基准值:本轮排序序列的第一个、最后一个、中间位置三个数的中位数作为基准值进行排序。

今天的内容分享到这里就结束了,喜欢的话还请点赞、在看多多支持,关注不迷路。

文章的图片来自书籍《我的第一本算法书》,想要 PDF 的可以后台私信我,觉得内容不错了建议下单买一本,支持原作者的创作。

下期再见吧。

- END -


扫码关注公众号「网管叨bi叨」

给网管个星标,第一时间吸我的知识 

(附代码)动图图解 | 十大经典排序算法Python版实现



全网搜集目标检测相关,人工筛选最优价值内容

编者荐语
排序算法可以分为内部排序和外部排序,内部排序是数据记录在内存中进行排序,而外部排序是因排序的数据很大,一次不能容纳全部的排序记录,在排序过程中需要访问外存。常见的内部排序算法有:插入排序、希尔排序、选择排序、冒泡排序、归并排序、快速排序、堆排序、基数排序等。

转载自 | 机器学习算法那些事


(附代码)动图图解 | 十大经典排序算法Python版实现

用一张图概括:

(附代码)动图图解 | 十大经典排序算法Python版实现

关于时间复杂度:

  • 平方阶 (O(n2)) 排序:各类简单排序,直接插入、直接选择和冒泡排序;
  • 线性对数阶 (O(nlog2n)) 排序:快速排序、堆排序和归并排序;
  • 尔排序:O(n1+§)) 排序,§ 是介于 0 和 1 之间的常数;
  • 线性阶 (O(n)) 排序:基数排序,此外还有桶、箱排序。

关于稳定性:

  • 序后 2 个相等键值的顺序和排序之前它们的顺序相同。

  • 稳定的排序算法:冒泡排序、插入排序、归并排序和基数排序。

  • 不是稳定的排序算法:选择排序、快速排序、希尔排序、堆排序。

名词解释:


  • n:数据规模。

  • k:“桶”的个数。

  • In-place:占用常数内存,不占用额外内存。

  • Out-place:占用额外内存。


01 冒泡排序


冒泡排序(Bubble Sort)也是一种简单直观的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。

作为最简单的排序算法之一,冒泡排序给我的感觉就像 Abandon 在单词书里出现的感觉一样,每次都在第一页第一位,所以最熟悉。冒泡排序还有一种优化算法,就是立一个 flag,当在一趟序列遍历中元素没有发生交换,则证明该序列已经有序。但这种改进对于提升性能来说并没有什么太大作用。

1. 算法步骤


  1. 比较相邻的元素。如果第一个比第二个大,就交换他们两个。
  2. 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
  3. 针对所有的元素重复以上的步骤,除了最后一个。
  4. 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。

2. 动图演示

(附代码)动图图解 | 十大经典排序算法Python版实现

3. Python 代码

   
     
     
   
def bubbleSort(arr): for i in range(1, len(arr)): for j in range(0, len(arr)-i): if arr[j] > arr[j+1]: arr[j], arr[j + 1] = arr[j + 1], arr[j] return arr


02 选择排序


选择排序是一种简单直观的排序算法,无论什么数据进去都是 O(n²) 的时间复杂度。所以用到它的时候,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间了吧。

1. 算法步骤


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


2. 动图演示

(附代码)动图图解 | 十大经典排序算法Python版实现

3. Python 代码

def selectionSort(arr): for i in range(len(arr) - 1): # 记录最小数的索引 minIndex = i for j in range(i + 1, len(arr)): if arr[j] < arr[minIndex]: minIndex = j # i 不是最小数时,将 i 和最小数进行交换 if i != minIndex: arr[i], arr[minIndex] = arr[minIndex], arr[i] return arr

03 插入排序


插入排序的代码实现虽然没有冒泡排序和选择排序那么简单粗暴,但它的原理应该是最容易理解的了,因为只要打过扑克牌的人都应该能够秒懂。插入排序是一种最简单直观的排序算法,它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

插入排序和冒泡排序一样,也有一种优化算法,叫做拆半插入。

1. 算法步骤


  1. 将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。
  2. 从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)

2. 动图演示

(附代码)动图图解 | 十大经典排序算法Python版实现

3. Python 代码

   
     
     
   
def insertionSort(arr): for i in range(len(arr)): preIndex = i-1 current = arr[i] while preIndex >= 0 and arr[preIndex] > current: arr[preIndex+1] = arr[preIndex] preIndex-=1 arr[preIndex+1] = current return arr

04 希尔排序


希尔排序,也称递减增量排序算法,是插入排序的一种更高效的改进版本。但希尔排序是非稳定排序算法。

希尔排序是基于插入排序的以下两点性质而提出改进方法的:

  • 插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率;
  • 但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位。

希尔排序的基本思想是:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全体记录进行依次直接插入排序。

1. 算法步骤


  1. 选择一个增量序列 t1,t2,……,tk,其中 ti > tj, tk = 1;
  2. 按增量序列个数 k,对序列进行 k 趟排序;
  3. 每趟排序,根据对应的增量 ti,将待排序列分割成若干长度为 m 的子序列,分别对各子表进行直接插入排序。仅增量因子为 1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。


2. Python 代码

   
     
     
   
def shellSort(arr): import math gap=1 while(gap < len(arr)/3): gap = gap*3+1 while gap > 0: for i in range(gap,len(arr)): temp = arr[i] j = i-gap while j >=0 and arr[j] > temp: arr[j+gap]=arr[j] j-=gap arr[j+gap] = temp gap = math.floor(gap/3) return arr}

05 归并排序


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

作为一种典型的分而治之思想的算法应用,归并排序的实现由两种方法:

  • 自上而下的递归(所有递归的方法都可以用迭代重写,所以就有了第 2 种方法);
  • 自下而上的迭代。

和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是 O(nlogn) 的时间复杂度。代价是需要额外的内存空间。

1. 算法步骤


  1. 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列;
  2. 设定两个指针,最初位置分别为两个已经排序序列的起始位置;
  3. 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置;
  4. 重复步骤 3 直到某一指针达到序列尾;
  5. 将另一序列剩下的所有元素直接复制到合并序列尾。

2. 动图演示

(附代码)动图图解 | 十大经典排序算法Python版实现

3. Python 代码

   
     
     
   
def mergeSort(arr): import math if(len(arr)<2): return arr middle = math.floor(len(arr)/2) left, right = arr[0:middle], arr[middle:] return merge(mergeSort(left), mergeSort(right))
def merge(left,right): result = [] while left and right: if left[0] <= right[0]: result.append(left.pop(0)); else: result.append(right.pop(0)); while left: result.append(left.pop(0)); while right: result.append(right.pop(0)); return result

06 快速排序


快速排序是由东尼·霍尔所发展的一种排序算法。在平均状况下,排序 n 个项目要 Ο(nlogn) 次比较。在最坏状况下则需要 Ο(n2) 次比较,但这种状况并不常见。事实上,快速排序通常明显比其他 Ο(nlogn) 算法更快,因为它的内部循环(inner loop)可以在大部分的架构上很有效率地被实现出来。

快速排序使用分治法(Divide and conquer)策略来把一个串行(list)分为两个子串行(sub-lists)。

快速排序又是一种分而治之思想在排序算法上的典型应用。本质上来看,快速排序应该算是在冒泡排序基础上的递归分治法。

快速排序的名字起的是简单粗暴,因为一听到这个名字你就知道它存在的意义,就是快,而且效率高!它是处理大数据最快的排序算法之一了。

虽然 Worst Case 的时间复杂度达到了 O(n²),但是人家就是优秀,在大多数情况下都比平均时间复杂度为 O(n logn) 的排序算法表现要更好,可是这是为什么呢,我也不知道。好在我的强迫症又犯了,查了 N 多资料终于在《算法艺术与信息学竞赛》上找到了满意的答案:

快速排序的最坏运行情况是 O(n²),比如说顺序数列的快排。但它的平摊期望时间是 O(nlogn),且 O(nlogn) 记号中隐含的常数因子很小,比复杂度稳定等于 O(nlogn) 的归并排序要小很多。所以,对绝大多数顺序性较弱的随机数列而言,快速排序总是优于归并排序。

1. 算法步骤


  1. 从数列中挑出一个元素,称为 “基准”(pivot);
  2. 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
  3. 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。

递归的最底部情形,是数列的大小是零或一,也就是永远都已经被排序好了。虽然一直递归下去,但是这个算法总会退出,因为在每次的迭代(iteration)中,它至少会把一个元素摆到它最后的位置去。

2. 动图演示

(附代码)动图图解 | 十大经典排序算法Python版实现

3. Python 代码

   
     
     
   
def quickSort(arr, left=None, right=None): left = 0 if not isinstance(left,(int, float)) else left right = len(arr)-1 if not isinstance(right,(int, float)) else right if left < right: partitionIndex = partition(arr, left, right) quickSort(arr, left, partitionIndex-1) quickSort(arr, partitionIndex+1, right)    return arr
def partition(arr, left, right): pivot = left index = pivot+1 i = index while i <= right: if arr[i] < arr[pivot]: swap(arr, i, index) index+=1 i+=1 swap(arr,pivot,index-1) return index-1
def swap(arr, i, j):    arr[i], arr[j] = arr[j], arr[i]

07 堆排序


堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序。分为两种方法:

  • 大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列;
  • 小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列。

堆排序的平均时间复杂度为 Ο(nlogn)。

1. 算法步骤


  1. 创建一个堆 H[0……n-1];
  2. 把堆首(最大值)和堆尾互换;
  3. 把堆的尺寸缩小 1,并调用 shift_down(0),目的是把新的数组顶端数据调整到相应位置;
  4. 重复步骤 2,直到堆的尺寸为 1。

2. 动图演示

(附代码)动图图解 | 十大经典排序算法Python版实现

3. Python 代码

   
     
     
   
def buildMaxHeap(arr): import math for i in range(math.floor(len(arr)/2),-1,-1): heapify(arr,i)
def heapify(arr, i): left = 2*i+1 right = 2*i+2 largest = i if left < arrLen and arr[left] > arr[largest]: largest = left if right < arrLen and arr[right] > arr[largest]: largest = right
if largest != i: swap(arr, i, largest) heapify(arr, largest)
def swap(arr, i, j): arr[i], arr[j] = arr[j], arr[i]
def heapSort(arr): global arrLen arrLen = len(arr) buildMaxHeap(arr) for i in range(len(arr)-1,0,-1): swap(arr,0,i) arrLen -=1 heapify(arr, 0)    return arr

08 计数排序


计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。

1. 动图演示

(附代码)动图图解 | 十大经典排序算法Python版实现

2. Python 代码
   
     
     
   

def countingSort(arr, maxValue): bucketLen = maxValue+1 bucket = [0]*bucketLen sortedIndex =0 arrLen = len(arr) for i in range(arrLen): if not bucket[arr[i]]: bucket[arr[i]]=0 bucket[arr[i]]+=1 for j in range(bucketLen): while bucket[j]>0: arr[sortedIndex] = j sortedIndex+=1 bucket[j]-=1    return arr


09 桶排序


桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。为了使桶排序更加高效,我们需要做到这两点:


  1. 在额外空间充足的情况下,尽量增大桶的数量。
  2. 使用的映射函数能够将输入的 N 个数据均匀的分配到 K 个桶中。


同时,对于桶中元素的排序,选择何种比较排序算法对于性能的影响至关重要。

  • 什么时候最快
当输入的数据可以均匀的分配到每一个桶中。

  • 什么时候最慢

当输入的数据被分配到了同一个桶中。

  • Python 代码
   
     
     
   

def bucket_sort(s): """桶排序""" min_num = min(s) max_num = max(s) # 桶的大小 bucket_range = (max_num-min_num) / len(s) # 桶数组 count_list = [ [] for i in range(len(s) + 1)] # 向桶数组填数 for i in s: count_list[int((i-min_num)//bucket_range)].append(i) s.clear() # 回填,这里桶内部排序直接调用了sorted for i in count_list: for j in sorted(i): s.append(j)
if __name__ == __main__ : a = [3.2,6,8,4,2,6,7,3]bucket_sort(a)print(a) # [2, 3, 3.2, 4, 6, 6, 7, 8]

10 基数排序


基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。

1. 基数排序 vs 计数排序 vs 桶排序


基数排序有两种方法:

这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:

  • 基数排序:根据键值的每位数字来分配桶;
  • 计数排序:每个桶只存储单一键值;
  • 桶排序:每个桶存储一定范围的数值。

2. 动图演示


3. Python 代码

   
     
     
   
def RadixSort(list): i = 0 #初始为个位排序 n = 1 #最小的位数置为1(包含0) max_num = max(list) #得到带排序数组中最大数 while max_num > 10**n: #得到最大数是几位数 n += 1 while i < n: bucket = {} #用字典构建桶 for x in range(10): bucket.setdefault(x, []) #将每个桶置空 for x in list: #对每一位进行排序 radix =int((x / (10**i)) % 10) #得到每位的基数 bucket[radix].append(x) #将对应的数 组元素加入到相 #应位基数的桶中 j = 0 for k in range(10): if len(bucket[k]) != 0: #若桶不为空 for y in bucket[k]: #将该桶中每个元素 list[j] = y #放回到数组中 j += 1 i += 1return list


END



双一流大学研究生团队创建,专注于目标检测与深度学习,希望可以将分享变成一种习惯!

整理不易,点赞三连↓

以上是关于图解算法基础--快速排序,附 Go 代码实现的主要内容,如果未能解决你的问题,请参考以下文章

算法图解之快速排序

(附代码)动图图解 | 十大经典排序算法Python版实现

排序算法:图解快速排序算法--附带基于Python和JavaScript的实现

《图解算法》--快速排序哈希表图广度优先搜索算法

算法:快速排序(ScratchPython)

《算法图解》2