首页 > 技术文章 > 【从0到1学算法】快速排序

KEN-DO-EVERTHING 2020-03-12 20:28 原文

系列文章导航:
【从0到1学算法】二分查找法
【从0到1学算法】大O表示法
【从0到1学算法】 数组和链表
【从0到1学算法】选择排序
【从0到1学算法】递归

今天我们将学习快速排序,是最快的排序算法之一,速度比选择排序快得多!

一、分而治之

在学习快速排序前,先上开胃菜,快速排序中用到的算法--分而治之(divide and conquer, D&C,分治法)。只能解决一种问题的算法毕竟用处有限,而D&C提供了解决问题的思路。当面对新问题束手无策时,不妨自问:使用分而治之能解决吗?来个示例:假设你是农场主,有一块土地。

image

你要将这块土地均匀分成方块(正方形),且分出的方块要尽可能大。显然,下面的分法都不符合要求。

image

使用D&C解决问题分为两个步骤:

  1. 找出基线条件,这个条件必须尽可能简单。

  2. 不断将问题分解(或者说缩小规模),直到符合基线条件。

下面就来使用D&C找出解决方案。首先,找出基线条件。最容易处理的情况是,一条边的长度是另一条的整数倍。比如下图,一边是50m,另一边是25m,那么最大方块为25mx25m。

image

接下来是缩小问题规模,首先找出这块地可容纳的最大方块。

image

划出了两块640mx640m的方块,同时余下一小块地。接下来我们将继续对余下的小块地使用相同的算法。

image

适用于这小块地的最大方块,也适用于整块地的最大方块(可参阅欧几里算法)。问题规模缩小了,最初问题是均匀划分1680mx640m土地的问题,缩小为均匀划分640mx400m土地的问题。

image

接着继续缩小规模,直到满足基线条件。

image

image

image

余下的土地满足基线条件,160是80的整数倍,刚好将土地平分为两个方块。

image

因此,对于最初的土地,适用的最大方块为80mx80m。

image

这便是分治法,重申一下它的原理:

  1. 找出基线条件。(最简单的条件)

  2. 缩小规模,使其符合基线条件。

二、快速排序

快速排序是最快的排序算法之一,也是D&C的典范。对排序算法来说,最简单的数组是什么样子的呢?就是根本不需要排序的数组。

image

因此,我们的基线条件为数组为空或只包含一个元素。快速排序的步骤如下:

  1. 选择基准值。(可随机选择)

  2. 将数组分成两个子数组:小于基准值的元素和大于基准值的元素。(缩小问题规模,运用D&C)

  3. 对这两个子元素进行快速排序。(递归)

  4. 重复步骤2~3,直至子数组元素数量小于2,将子数组与基准合并(基线条件)。

换个思维想想,其实就是每轮都将基准放到正确的位置上,直至排序完成。这里有个数组为[3,5,2,1,4]假设选择3为基准,对子数组快速排序。

image

可能这里会有点懵,qsort([2,1])怎么操作?具体操作为如下(其实就是重复第一次的操作):

  1. 选择2为基准(随机)

  2. 分为两个子数组,[1],[]

  3. 然后将子数组与基准合并,[1]+[2]+[] = [1,2] 得到有序数组

代码实现如下:

def quick_sort(arr):
   # 基线条件
   if len(arr) < 2:
       return arr
   else:
       # 随机选择基准
       rd = random.randint(0, len(arr) - 1)
       pivot = arr.pop(rd)
       # 比基准小的数组
       less = []
       # 比基准大的数组
       greater = []
       for i in range(len(arr)):
           # 比基准小或等于的数放到less
           if arr[i] <= pivot:
               less.append(arr[i])
           # 比基准小或等于的数放到greater
           else:
               greater.append(arr[i])              
       # 递归,并与基准合并
       return quick_sort(less) + [pivot] + quick_sort(greater)</pre>

但我们在网上看到的,一般都不是这种写法,而是另一种,依然是开源节流的优化,不用新数组而是在数组中不断交换元素位置。代码如下:

def quick_sort(arr, start, end):
   # 基线条件
   if start > end:
       return
   # 设置一个基准
   pivot = arr[start]
   left = start
   right = end
   # 递归条件
   while left != right:
       # 从右边开始遍历,小于等于基准时,准备将它与左侧大于基准值的值交换
       while arr[right] > pivot and left < right:
           right -= 1
       # 从右左开始遍历,大于基准时,准备将它与右侧小于基准值的值交换
       while arr[left] <= pivot and left < right:
           left += 1
       if left < right:
           # 将左侧大于基准值的值与右侧小于基准值的值交换
           temp = arr[left]
           arr[left] = arr[right]
           arr[right] = temp
   # 将基准放到正确位置上
   arr[start] = arr[left]
   arr[left] = pivot
   # 左边数组与右边数组继续快排
   quick_sort(arr, start, left - 1)
   quick_sort(arr, left + 1, end)</pre>

还是同样的配方:每轮都将基准放到正确的位置上,直至排序完成

扩展:基准的选择

快速排序的性能高度依赖于选择的基准值。最坏情况下,每次划分成两个数组分别包含n-1个元素和1个元素,其时间复杂度为O(n2)。在最好的情况下,每次划分所取的基准都恰好是中值,即每次划分都产生两个大小为n/2的数组。此时,快排的时间复杂度为O(nlogn)。下面有3中基准选择方式(1)固定基准(不推荐)当待排数组有序或基本有序的情况下,很容易出现最坏情况,导致性能低下。(2)随机基准(未知待排数组有序性时,推荐)随机数算法随机选择一个元素作为划分基准,算法的平均性能较好,从而避免了最坏情况的多次发生。此时,它的平均运行时间为O(nlogn)。

# 在start和end间随机选择一元素作为划分的基准
def random(arr, start, end):  
   rd = random.randint(0, len(arr) - 1)
   pivot = arr[rd]
   # 把随机基准位置的元素和low位置元素互换
   # swap交换两个元素位置的函数,这里就忽略不写了
   swap(a[pivot],a[start])
   return a[low]</pre>

(3)3分取值(待排数组基本有序时,推荐)选取数组开头,中间和结尾的元素,通过比较,选择中间的值作为快排的基准。这种方式能很好的解决待排数组基本有序的情况,而且选取的基准没有随机性。

def number_of_three(arr,start,end):
   # 右移相当于除以2
   mid = end + ((start - end) >> 1)
   if arr[mid] > arr[start]:
       # 把随机基准位置的元素和low位置元素互换
       swap(arr[mid], arr[start])
   if arr[end] > arr[start]:
       swap(arr[end], arr[start])
   if arr[mid] > arr[end]:
       swap(arr[mid], arr[end])
   # 此时,arr[mid] <= arr[end] <= arr[start]
   return arr[end]</pre>
总结
  • D&C(分治法)将问题逐步分解。对问题无头绪时,可尝试使用。

  • 快速排序是最快的排序算法之一,也是D&C的典范。

  • 未知待排数组有序性时,推荐使用随机基准

    待排数组基本有序时,推荐使用3分取值选取基准


如果文章觉得不错,别忘了点赞、评论、转发哦!

文章首发于公众号【KEN DO EVERTHING】
本公众号专注于java相关技术,但不限于java、mysql、python、面试技巧、生活感悟等。分享优质博文,技术干货,学习资源等优质内容。
欢迎关注,一起学习,共成长!

推荐阅读