写给算法初学者的分治法和快速排序(js)

1,208 阅读6分钟

「这是我参与2022首次更文挑战的第5天,活动详情查看:2022首次更文挑战」。

分治法和快速排序

要理解快速排序,首先要理解分治法的思想。

分而治之

分治法并非可用于解决问题的算法,而是一种解决问题的思路。分治法的核心思想是:

  • 找出简单的基线条件(终止条件);
  • 确定如何缩小问题的规模,使其符合终止条件。

关于基线条件和终止条件,我在写给前端开发的算法简介一文中有介绍。

分土地问题

来自于《图解算法》

如何将一块地均匀地分成方块(正方形),并确保分出的方块是最大的呢?

image.png

终止条件:余下的土地一条边的长度是另一条边的整数倍。

比如,出现下面这种情况,那么最大方块的边长就是 25m。

image.png

递归条件:先找出可容纳的最大方块,对余下土地使用同样的算法。

image.png

image.png

最终,找到了这块土地能被均匀划分的最大方块!

image.png

数组求和

给定一个数字数组,将数字相加并返回结果。

[2, 4, 6]

使用循环:

const getSum = (arr) => {
  let sum = 0

  for (let i = 0, len = arr.length; i < len; i++) {
    sum += arr[i]
  }
  return sum
}

使用分治:

终止条件:数组为空或数组只有一项。

[]    空数组,值为 0
[5]   数组只有一项,值为 5

递归条件:缩小数组的长度(缩小问题规模)。

sum([2, 4, 6])

2 + sum([4, 6])

2 + 4 + sum([6])

实现代码如下:

const getSum = (arr) => {
  if (arr.length === 0) {               // 如果数组为空,值为0,就返回0
    return 0
  } else {
    const firstNum = arr.shift()        // 每次弹出一个值,缩小数组的长度
    return firstNum + getSum(arr)
  }
}

计算数组包含的元素数

编写一个递归函数来计算一个数组包含的元素数

终止条件:数组为空,长度就是0;数组只有一项,长度就是1

递归条件:缩小数组的长度(缩小问题规模)。

const getLength = (arr) => {
  if (JSON.stringify(arr) === '[]') {
    return 0
  } else {
    arr.shift()
    return 1 + getLength(arr)
  }
}

写法和上面的例2类似,不过不通过length来判断数组为空,总感觉很别扭,hh

找出数组中最大的数字

给定一个数字数组,找出数组中最大的数字 终止条件:数组只有两项,两个做比较,大的那个就是最大值。

递归条件:缩小数组的长度(缩小问题规模)。

const getMax = (arr) => {
  if (arr.length === 2) {
    return arr[0] > arr[1] ? arr[0] : arr[1]
  } else {
    const max = arr.shift()
    return Math.max(max, getMax(arr))
  }
}

二分查找

实现一个二分查找

基线条件:数组只有一个元素,和这个元素做比较,相等说明找到了,不等说明没找到。

递归条件:缩小数组的长度,每次缩小一半。

const binarySearch = (list, item) => {
  let low = 0
  let high = list.length - 1

  while (low < high) {
    const mid = Math.floor((low + high) / 2)
    if (list[mid] === item) {
      return mid
    }
    if (list[mid] < item) {
      low = mid + 1
    }
    if (list[mid] > item) {
      high = mid - 1
    }
  }

  return null
}

快速排序

快排也是分治

快速排序是一种常用的排序方法,比选择排序快得多,快速排序也用到了分治法。

基线条件:数组为空数组或只有一个元素,排序就完成了。

递归条件:缩小数组的长度。

具体来说就是数组中指定一个元素作为标尺,比标尺小的放数组左边,比标尺大的放数组右边。然后再分别递归操作左边的数组和右边的数组,直到全部排序完成。

代码实现如下:

const quickSort = (arr) => {
  if (arr.length < 2) {               // 终止条件:数组为空或只有一个元素
    return arr
  } else {
    const flag = arr[0]               // 随便找一个元素作为标尺
    const less = []
    const greater = []

    for (let i = 1, len = arr.length; i < len; i++) {
      if (arr[i] < flag) {
        less.push(arr[i])             // 比标尺小的放左边
      } else {
        greater.push(arr[i])          // 比标尺大的放右边
      }
    }

    return [...quickSort(less), flag, ...quickSort(greater)]  // 合并
  }
}

优化空间复杂度

上面代码的写法定义了两个数组 less 和 greater 来存放比标尺小或者大的元素,空间复杂度高,其实还可以优化一下:

我们不占用额外的空间,通过交换数组内元素来实现:

const swap = (arr, i, j) => {                       // 交换数组内元素方法
  [arr[i], arr[j]] = [arr[j], arr[i]]
}

const finndCenter = (arr, left, right) => {         // 这个函数用来做分区操作,返回一个idx,分区后的数组 idx 左边都比它小,idx 右边都比它大
  const flag = arr[left]                            // 随便找一个元素作为标尺,这里找数组第一个 
  let idx = left + 1                                // 定义一个指针指向标尺右边的元素,从标尺右边的元素开始遍历
  for (let i = idx; i <= right; i++) {              
    if (arr[i] < flag) {
      swap(arr, i, idx)                             // 如果比标尺元素小,就和标尺右边的元素进行交换
      idx++                                         // 交换完了,idx向右移一位
    }
  }
  swap(arr, left, idx - 1)                          // 遍历完了之后,把标尺元素交换到比它小的元素右边去,只需要和最右边的元素交换即可。
  return idx
}

const quickSort = (arr, left = 0, right = arr.length - 1) => {
  if (left < right) {
    const center = finndCenter(arr, left, right)    // 拿到标尺元素分区后的下标
    quickSort(arr, left, center - 1)                // 对左边的元素继续重复上面的操作
    quickSort(arr, center + 1, right)               // 对右边的元素继续重复上面的操作
  }
  return arr                                        // 元素已排好序,直接返回 arr
}

最坏情况和最好情况

上面的代码中,我随机选择了数组中的一个元素作为基准元素,假如我刚好选到的是数组中的最小值或者最大值,就会出现最坏的情况,如下图:

image.png

我们来分析下时间复杂度,要排序肯定要把数组遍历一遍,就占去了 n,然后又遇到上图这种选到最小值为基准值分治,递归调用栈的长度也为 n。

这样的话就是最坏的时间复杂度 O(n^2)

如果刚好选中中间值,情况会好一些:

image.png

调用栈长度为 logn,时间复杂度为 O(nlogn)。

所以快速排序最坏的时间复杂度是 O(n^2),平均时间复杂度是 O(nlogn),平均时间复杂度也是最佳情况。

快速排序和归并排序

快排和归排的复杂度都是O(n*log n),且归并排序更稳定,为什么都用快排而不用归排?

算法的每一步实际上都需要一个固定时间量,被称为常量。

我们平时考虑时间复杂度的时候并不考虑常量的影响。

举个例子,简单查找和二分查找,假设简单查找常量为 10毫秒,二分查找常量为 1秒

现在在有 40个亿元素的列表中查找:

简单查找二分查找
10毫秒 * 40亿 = 463 天1秒 * log(40亿) = 32 秒

可以看到,二分查找还是快得多,常量根本没什么影响。

但是对于快速排序和归并排序来说,常量就有影响了。

快排的常量比合并排序小,他们的运行时间都为 O(nlogn),快排的速度会更快。

但是你可能会问,人家归排比较稳定,最坏也是 O(nlogn),快排最坏是 O(n^2),怎么不考虑最坏的情况呢?

其实,绝大多数情况下,快排遇到的都是平均情况,也就是最佳情况,只有极个别的时候会是最坏情况,因此往往不考虑这种糟糕的情况。

小结

本文介绍了分治法,快速排序以及大 O 表示法的常量因素。

  • 分治法将问题逐步分解,使用分治法处理列表时,基线情况很可能是空数组或只包含一个元素。
  • 实现快速排序时,随机选择基准元素的情况下,就是时间复杂度的平均情况,也是最佳情况。
  • 决定算法快慢还有常量因素,不同时间复杂度(简单查找和二分查找)的情况下常量无关紧要,相同时间复杂度(快排和归排)的情况下常量很重要。