【从0到1学算法】快速排序

时间:2022-07-22
本文章向大家介绍【从0到1学算法】快速排序,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

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

一、分而治之

在学习快速排序前,先上开胃菜,快速排序中用到的算法--分而治之(divide and conquer, D&C,分治法)。

只能解决一种问题的算法毕竟用处有限,而D&C提供了解决问题的思路。当面对新问题束手无策时,不妨自问:使用分而治之能解决吗?

来个示例:

假设你是农场主,有一块土地。

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

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

  1. 找出基线条件,这个条件必须尽可能简单。
  2. 不断将问题分解(或者说缩小规模),直到符合基线条件。

下面就来使用D&C找出解决方案。

首先,找出基线条件。最容易处理的情况是,一条边的长度是另一条的整数倍。

比如下图,一边是50m,另一边是25m,那么最大方块为25mx25m。

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

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

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

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

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

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

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

  1. 找出基线条件。(最简单的条件)
  2. 缩小规模,使其符合基线条件。
二、快速排序

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

对排序算法来说,最简单的数组是什么样子的呢?就是根本不需要排序的数组。

因此,我们的基线条件为数组为空或只包含一个元素。

快速排序的步骤如下:

  1. 选择基准值。(可随机选择)
  2. 将数组分成两个子数组:小于基准值的元素和大于基准值的元素。(缩小问题规模,运用D&C)
  3. 对这两个子元素进行快速排序。(递归)
  4. 重复步骤2~3,直至子数组元素数量小于2,将子数组与基准合并(基线条件)。

换个思维想想,其实就是每轮都将基准放到正确的位置上,直至排序完成。

这里有个数组为[3,5,2,1,4]

假设选择3为基准,对子数组快速排序。

可能这里会有点懵,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)

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

代码如下:

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)

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

扩展:基准的选择

快速排序的性能高度依赖于选择的基准值。

最坏情况下,每次划分成两个数组分别包含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]

(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]
总结
  • D&C(分治法)将问题逐步分解。对问题无头绪时,可尝试使用。
  • 快速排序是最快的排序算法之一,也是D&C的典范。
  • 未知待排数组有序性时,推荐使用随机基准; 待排数组基本有序时,推荐使用3分取值选取基准

THANDKS

- End -