快速排序(QuickSort) 作为最流行的排序算法之一,又有非常出色的性能,被广大的编程语言作为标准库默认排序方法。

快速排序的设计思想是一个很好的分治法(divide-and-conquer) 的实例,理解他的实现原理将有助于我们在实际生产过程中设计自己的解决问题的算法。最直接的,很多算法题目需要使用到类似的思想。

先贴代码(Go):

func quickSort(nums []int, l, r int) { //[l,r]
   if l < r {
      m := partition(nums, l, r)
      quickSort(nums, l, m-1)
      quickSort(nums, m+1, r)
   }
}

func partition(nums []int, l int, r int) int {
   key := nums[r]
   //all in [l,i) < key
   //all in [i,j] > key
   i := l
   j := l
   for j < r {
      if nums[j] < key {
         nums[i], nums[j] = nums[j], nums[i]
         i++
      }
      j++
   }
   nums[i], nums[r] = nums[r], nums[i]
   return i
}

首先我们先大致介绍一下分治法。很多有用的算法在结构是递归的,为了解决给定的问题,他们多次递归的调用自己去解决一个相关的子问题。这些算法通常遵循分治法,即他们将原问题划分为多个规模更小且与原问题相似的子问题,然后递归的解决子问题,最后合并子问题的答案得到原问题的答案。

分治法在每一次递归阶段都分为三个步骤:

  • 划分: 将问题划分为一系列规模更小的相似子问题。
  • 处理: 递归的解决子问题,如果子问题的规模足够的小,则直接解决它。
  • 合并: 将各个子问题的答案合并为原文题的答案。
    其中在处理过程中,如果子问题规模大到需要递归处理,则我们称它为递归实例(recursive case),如果子问题规模足够的小,递归“到达了最低点”,则我们称它为基础实例(base case)。有时,除了解决相似问题的较小规模的子问题外,我们还必须解决与原问题不太相同的子问题。我们一般将解决此类子问题作为合并步骤的一部分。

快速排序算法是分治法的一个实例,我们将从分治法的角度理解它,对于待排序的数组nums[p..r]

  • 划分: 将数组nums[l..r]划分为两个子数组nums[l..m-1]nums[m+1..r],使得 nums[l..m-1]的每个元素小于或等于nums[m]nums[m+1..r]的每个元素大于或等于nums[m]。 计算索引m的值是此划分过程的一部分。
  • 处理: 递归调用快速排序算法,排序两个子数组nums[l..m-1]nums[m+1..r]
  • 合并: 因为子数组已经排序,所以不需要将它们合并起来,整个数组 nums 现在已排好序。

我们可以发现,算法最核心的部分是划分阶段,我们再次使用循环不变量的概念来帮助我们思考。我们设置的循环不变量如下:
在待划分的数组nums[l..r]中,维护三个范围状态。

  • 数组[l,i)范围的所有元素小于key
  • 数组[l,i)范围的所有元素大于等于key

处理过程如下:

初始: i,j都为l,则数组范围[l,i)[l,i)均无元素,不变量成立。

保持: 在迭代过程中,按照nums[j]的值分为两种处理情况:

  • nums[j]小于key,交换nums[i]nums[j],同时i后移,j后移,不变量成立。
  • nums[j]大于等于keyj后移,不变量成立。

终止:j == r时,循环终止。此时不变量成立。之后交换nums[i](当前数组位置中第一个大于等于 key的值)和 nums[r],使得原nums[r]的值key放入正确位置。同时i即是正确位置的值的数组索引号。

因此,在经过以上的处理后,遵循分治法的三个步骤处理,最终数组是升序的。

快速排序是高效的排序方法,平均时间复杂度为O(nlogn),且处理阶段不需要额外的存储空间(原址排序)。

为了保证快速排序的性能,通常我们可以增加一个随机抽样的处理过程,即随机选择数组中的一个值作为key值,具体原理可以参考《算法导论》。