数据结构和算法(十三)之高级排序
前面我们讲了一些简单排序: 冒泡排序 - 选择排序 - 插入排序, 并且也分析了它们的效率.
这一章节, 我们讲一些高级排序: 希尔排序 - 快速排序. 相对于简单排序, 它们的效率会更高一些.
一. 希尔排序
希尔排序是插入排序的一种高效的改进版, 并且效率比插入排序要更快.
希尔排序的介绍
-
希尔排序的历史背景:
- 希尔排序按其设计者希尔(Donald Shell)的名字命名,该算法由 1959 年公布。
- 我们知道, 优先的排序算法首要条件就是速度. 在简单排序出现后的很多一段时间内, 人们发明了各种各样的算法. 但是最终发现算法的时间复杂度都是 O(N²), 似乎没法超越了.
- 此时, 计算机学术界充斥着 “排序算法不可能突破 O(N²)” 的声音. 就像之前普遍认为人类 100 米短跑不可能突破 10 秒大关一样.
- 终于有一天, 一位科学家发布超越了 O(N²) 的新排序算法 (后来为了纪念这个里程碑, 用 Shell 来命名了该算法).
- 紧接着出现了好几种可以超越 O(N²) 的排序算法, 我们后面将的快速排序也是其中之一.
-
回顾插入排序:
- 由于希尔排序基于插入排序, 所以有必须回顾一下前面的插入排序.
- 我们设想一下, 在插入排序执行到一半的时候, 标记符左边这部分数据项都是排好序的, 而标识符右边的数据项是没有排序的.
- 这个时候, 取出指向的那个数据项, 把它存储在一个临时变量中, 接着, 从刚刚移除的位置左边第一个单元开始, 每次把有序的数据项向右移动一个单元, 直到存储在临时变量中的数据项可以成功插入.
-
插入排序的问题:
- 假设一个很小的数据项在很靠近右端的位置上, 这里本来应该是较大的数据项的位置.
- 把这个小数据项移动到左边的正确位置, 所有的中间数据项都必须向右移动一位.
- 如果每个步骤对数据项都进行 N 次复制, 平均下来是移动 N/2, N 个元素就是 N*N/2 = N²/2.
- 所以我们通常认为插入排序的效率是 O(N²)
- 如果有某种方式, 不需要一个个移动所有中间的数据项, 就能把较小的数据项移动到左边, 那么这个算法的执行效率就会有很大的改进.
-
希尔排序的做法:
- 比如下面的数字, 81, 94, 11, 96, 12, 35, 17, 95, 28, 58, 41, 75, 15.
- 我们先让间隔为 5, 进行排序. (35, 81), (94, 17), (11, 95), (96, 28), (12, 58), (35, 41), (17, 75), (95, 15)
- 排序后的新序列, 一定可以让数字离自己的正确位置更近一步.
- 我们再让间隔位 3, 进行排序. (35, 28, 75, 58, 95), (17, 12, 15, 81), (11, 41, 96, 94)
- 排序后的新序列, 一定可以让数字离自己的正确位置又近了一步.
- 最后, 我们让间隔为 1, 也就是正确的插入排序. 这个时候数字都离自己的位置更近, 那么需要复制的次数一定会减少很多.
-
选择合适的增量:
- 在希尔排序的原稿中, 他建议的初始间距是 N / 2, 简单的把每趟排序分成两半.
- 也就是说, 对于 N = 100 的数组, 增量间隔序列为: 50, 25, 12, 6, 3, 1.
- 这个方法的好处是不需要在开始排序前为找合适的增量而进行任何的计算.
- 我们先按照这个增量来实现我们的代码.
希尔排序的实现
-
希尔排序的实现:
ArrayList.prototype.shellSort = function () { // 1.获取数组的长度 let length = this.array.length // 2.根据长度计算增量 let gap = Math.floor(length / 2) // 3.增量不断变量小, 大于0就继续排序 while (gap > 0) { // 4.实现插入排序 for (let i = gap; i < length; i++) { // 4.1.保存临时变量 let j = i let temp = this.array[i] // 4.2.插入排序的内层循环 while (j > gap - 1 && this.array[j - gap] > temp) { this.array[j] = this.array[j - gap] j -= gap } // 4.3.将选出的j位置设置为temp this.array[j] = temp } // 5.重新计算新的间隔 gap = Math.floor(gap / 2) } }
-
代码解析
- 代码序号 1: 获取数组的长度
- 代码序号 2: 计算第一次的间隔, 我们按照希尔提出的间隔实现.
- 代码序号 3: 增量不断变小, 大于 0 就继续改变增量
- 代码序号 4: 实际上就是实现了插入排序
- 代码序号 4.1: 保存临时变量, j 位置从 i 开始, 保存该位置的值到变量 temp 中
- 代码序号 4.2: 内层循环, j > gap - 1 并且 temp 大于 this.array[j - gap], 那么就进行复制.
- 代码序号 4.3: 将 j 位置设置为变量 temp
- 代码序号 5: 每次 while 循环后都重新计算新的间隔.
-
测试代码:
// 测试希尔排序 list.shellSort() alert(list)
希尔排序的效率
- 希尔排序的效率
- 希尔排序的效率很增量是有关系的.
- 但是, 它的效率证明非常困难, 甚至某些增量的效率到目前依然没有被证明出来.
- 但是经过统计, 希尔排序使用原始增量, 最坏的情况下时间复杂度为 O(N²), 通常情况下都要好于 O(N²)
- Hibbard 增量序列
- 增量的算法为 2^k - 1. 也就是为 1 3 5 7… 等等.
- 这种增量的最坏复杂度为 O(N^3/2), 猜想的平均复杂度为 O(N^5/4), 目前尚未被证明.
- Sedgewick 增量序列
- {1, 5, 19, 41, 109, …}, 该序列中的项或者是 9_4^i - 9*2^i + 1 或者是 4^i - 3_2^i + 1
- 这种增量的最坏复杂度为 O(N^4/3), 平均复杂度为 O(N^7/6), 但是均未被证明.
- 总之, 我们使用希尔排序大多数情况下效率都高于简单排序, 甚至在合适的增量和 N 的情况下, 还好好于快速排序.
二. 快速排序
快速排序几乎可以说是目前所有排序算法中, 最快的一种排序算法.
当然, 没有任何一种算法是在任意情况下都是最优的, 比如希尔排序确实在某些情况下可能好于快速排序. 但是大多数情况下, 快速排序还是比较好的选择.
快速排序的介绍
-
快速排序的重要性:
- 如果有一天你面试的时候, 让你写一个排序算法, 你可以洋洋洒洒的写出多个排序算法, 但是如果其中没有快速排序, 那么证明你对排序算法也只是浅尝辄止, 并没有深入的研究过.
- 因为快速排序可以说是排序算法中最常见的, 无论是 C++ 的 STL 中, 还是 Java 的 SDK 中其实都能找到它的影子.
- 快速排序也被列为 20 世纪十大算法之一.
-
快速排序是什么?
- 希尔排序相当于插入排序的升级版, 快速排序其实是我们学习过的最慢的冒泡排序的升级版.
- 我们知道冒泡排序需要经过很多次交换, 才能在一次循环中, 将最大值放在正确的位置.
- 而快速排序可以在一次循环中 (其实是递归调用) 找出某个元素的正确位置, 并且该元素之后不需要任何移动.
-
快速排序的思想:
- 快速排序最重要的思想是分而治之.
- 比如我们下面有这样一顿数字需要排序:
- 第一步: 从其中选出了 65. (其实可以是选出任意的数字, 我们以 65 举个栗子)
- 第二步: 我们通过算法: 将所有小于 65 的数字放在 65 的左边, 将所有大于 65 的数字放在 65 的右边.
- 第三步: 递归的处理左边的数据.(比如你选择 31 来处理左侧), 递归的处理右边的数据.(比如选择 75 来处理右侧, 当然选择 81 可能更合适)
- 最终: 排序完成
- 和冒泡排序不同的是什么呢?
- 我们选择的 65 可以一次性将它放在最正确的位置, 之后不需要任何移动.
- 需要从开始位置两个两个比较, 如果第一个就是最大值, 它需要一直向后移动, 直到走到最后.
- 也就是即使已经找到了最大值, 也需要不断继续移动最大值. 而插入排序对数字的定位是一次性的.
快速排序的枢纽
-
在快速排序中有一个很重要的步骤就是选取枢纽 (pivot 也人称为主元).
-
如何选择才是最合适的枢纽呢?
-
一种方案是直接选择第一个元素作为枢纽.
- 但第一个作为枢纽在某些情况下, 效率并不是特别高.
-
另一种方案是使用随机数:
- 随机取 pivot?但是随即函数本身就是一个耗性能的操作.
-
另一种比较优秀的解决方案: 取头、中、尾的中位数
- 例如 8、12、3 的中位数就是 8
-
-
枢纽选择的代码实现:
// 选择枢纽 ArrayList.prototype.median = function (left, right) { // 1.求出中间的位置 let center = Math.floor((left + right) / 2) // 2.判断并且进行交换 if (this.array[left] > this.array[center]) { this.swap(left, center) } if (this.array[center] > this.array[right]) { this.swap(center, right) } if (this.array[left] > this.array[right]) { this.swap(left, right) } // 3.巧妙的操作: 将center移动到right - 1的位置. this.swap(center, right - 1) // 4.返回pivot return this.array[right - 1] }
-
代码解析:
- 我们封装了一个函数, 该函数用于选择出来合适的枢纽.
- 该函数要求传入 left 和 right, 这样可以根据 left 和 right 求出一个 center, 在选择它们三者的中位数.
- 代码序号 1: 根据 left/right 求出 center.
- 代码序号 2: 将 left 放在最前面, 将 center 放在中间, 将 right 放在右边.
- 代码序号 3: 这里有一个巧妙的操作, 我们将 pivot 值放在了 right 的紧挨着的左边, 为什么这样操作呢?
- 这样操作的目的是在之后交换的时候, pivot 的值不需要移动来移动去.
- 可以在最后选定位置后, 直接再交换到正确的位置即可 (也是最终的位置).
- 代码序号 4: 返回选择出来的枢纽.
-
测试代码:
// 测试中位数选取 // 原来的数组: 3,6,4,2,11,10,5 let pivot = list.median(1, 6) // left:6 right:5 center:2 alert(pivot) // pivot:5 alert(list) // 3,2,4,10,11,5,6
快速排序的实现
-
下面我们来写出快速排序的实现:
// 快速排序实现 ArrayList.prototype.quickSort = function () { this.quickSortRec(0, this.array.length - 1) } ArrayList.prototype.quickSortRec = function (left, right) { // 0.递归结束条件 if (left >= right) return // 1.获取枢纽 let pivot = this.median(left, right) // 2.开始进行交换 // 2.1.记录左边开始位置和右边开始位置 let i = left let j = right - 1 // 2.2.循环查找位置 while (true) { while (this.array[++i] < pivot) { } while (this.array[--j] > pivot) { } if (i < j) { // 2.3.交换两个数值 this.swap(i, j) } else { // 2.4.当i<j的时候(一定不会=, 看下面解释中的序号3), 停止循环因为两边已经找到了相同的位置 break } } // 3.将枢纽放在正确的位置 this.swap(i, right - 1) // 4.递归调用左边 this.quickSortRec(left, i - 1) this.quickSortRec(i + 1, right) }
-
代码解析:
- 这里有两个函数: quickSort 和 quickSortRec.
- 外部调用时, 会调用 quickSort
- 内部递归时, 会调用 quickSortRec
- 我们这里主要讲解一下 quickSortRec 方法.
- 代码序号 0: 是递归的结束条件. 可以回头再来看这个函数.
- 代码序号 1: 从三个数中获取枢纽值, 这个方法我们在上一节中已经讲过, 这里不再累述.
- 代码序号 2: 我们的重点代码
- 代码序号 2.1: 循环交换合适位置的数值.
- 代码序号 2.2: 使用两个 while 循环, 递归的查找合适的 i(大于枢纽的值) 和合适的 j(小于枢纽的值).
- 代码序号 2.3: 交换 i 和 j 位置的值.
- 代码序号 2.4: 当 i<j 的时候, 两边查找到了同一个位置, 这个时候停止循环.
- 代码序号 3: 刚才我们查找到的 i 位置正是 pivot 应该所在的位置, 和 pivot 替换即可.
- 这里你可能会有一个疑问, 为什么将 i 位置可以换到最后呢? 万一它比 pivot 小呢?
- 这是因为我们在 while (this.array[++i] < pivot) 先使用的是 i, 而不是 j. 但是这意味着什么呢?
- 意味着 i 找到的一个值, 现在停下来的, 必然是大于 pivot. 而 j 会超过 i 的位置向后找了一个小于 pivot.
- 但是, 这个时候已经不需要继续进行交换了, 直接退出即可.
- 而退出后, i 位置的数值是大于 pivot, 所以可以将其换到后面.
- 代码序号 4: 递归调用该函数, 将 left, i - 1 传入就是左边排序, 将 i + 1, right 就是右边排序.
- 这里有两个函数: quickSort 和 quickSortRec.
-
来自维基百科的图解快速排序:
-
测试代码:
// 测试快速排序 list.quickSort() alert(list)
快速排序的效率
- 快速排序的最坏情况效率
- 什么情况下会有最坏的效率呢? 就是每次选择的枢纽都是最左边或者最后边的.
- 那么效率等同于冒泡排序.
- 而我们的例子可能有最坏的情况吗? 是不可能的. 因为我们是选择三个值的中位值.
- 快速排序的平均效率:
- 快速排序的平均效率是 O(N * logN).
- 虽然其他某些算法的效率也可以达到 O(N * logN), 但是快速排序是最好的.
三. 封装完整代码
-
最后, 我们还是给大家一份完整的代码
// 封装ArrayList function ArrayList() { this.array = [] ArrayList.prototype.insert = function (item) { this.array.push(item) } ArrayList.prototype.toString = function () { return this.array.join() } ArrayList.prototype.bubbleSort = function () { // 1.获取数组的长度 let length = this.array.length // 2.反向循环, 因此次数越来越少 for (let i = length - 1; i >= 0; i--) { // 3.根据i的次数, 比较循环到i位置 for (let j = 0; j < i; j++) { // 4.如果j位置比j+1位置的数据大, 那么就交换 if (this.array[j] > this.array[j+1]) { // 交换 this.swap(j, j+1) } } } } ArrayList.prototype.selectionSort = function () { // 1.获取数组的长度 let length = this.array.length // 2.外层循环: 从0位置开始取出数据, 直到length-2位置 for (let i = 0; i < length - 1; i++) { // 3.内层循环: 从i+1位置开始, 和后面的内容比较 let min = i for (let j = min + 1; j < length; j++) { // 4.如果i位置的数据大于j位置的数据, 记录最小的位置 if (this.array[min] > this.array[j]) { min = j } } this.swap(min, i) } } ArrayList.prototype.insertionSort = function () { // 1.获取数组的长度 let length = this.array.length // 2.外层循环: 外层循环是从1位置开始, 依次遍历到最后 for (let i = 1; i < length; i++) { // 3.记录选出的元素, 放在变量temp中 let j = i let temp = this.array[i] // 4.内层循环: 内层循环不确定循环的次数, 最好使用while循环 while (j > 0 && this.array[j-1] > temp) { this.array[j] = this.array[j-1] j-- } // 5.将选出的j位置, 放入temp元素 this.array[j] = temp } } ArrayList.prototype.shellSort = function () { // 1.获取数组的长度 let length = this.array.length // 2.根据长度计算增量 let gap = Math.floor(length / 2) // 3.增量不断变量小, 大于0就继续排序 while (gap > 0) { // 4.实现插入排序 for (let i = gap; i < length; i++) { // 4.1.保存临时变量 let j = i let temp = this.array[i] // 4.2.插入排序的内存循环 while (j > gap - 1 && this.array[j - gap] > temp) { this.array[j] = this.array[j - gap] j -= gap } // 4.3.将选出的j位置设置为temp this.array[j] = temp } // 5.重新计算新的间隔 gap = Math.floor(gap / 2) } } ArrayList.prototype.swap = function (m, n) { let temp = this.array[m] this.array[m] = this.array[n] this.array[n] = temp } // 选择枢纽 ArrayList.prototype.median = function (left, right) { // 1.求出中间的位置 let center = Math.floor((left + right) / 2) // 2.判断并且进行交换 if (this.array[left] > this.array[center]) { this.swap(left, center) } if (this.array[center] > this.array[right]) { this.swap(center, right) } if (this.array[left] > this.array[right]) { this.swap(left, right) } // 3.巧妙的操作: 将center移动到right - 1的位置. this.swap(center, right - 1) // 4.返回pivot return this.array[right - 1] } // 快速排序实现 ArrayList.prototype.quickSort = function () { this.quickSortRec(0, this.array.length - 1) } ArrayList.prototype.quickSortRec = function (left, right) { // 0.递归结束条件 if (left >= right) return // 1.获取枢纽 let pivot = this.median(left, right) // 2.开始进行交换 let i = left let j = right - 1 while (true) { while (this.array[++i] < pivot) { } while (this.array[--j] > pivot) { } if (i < j) { this.swap(i, j) } else { break } } // 3.将枢纽放在正确的位置 this.swap(i, right - 1) // 4.递归调用左边 this.quickSortRec(left, i - 1) this.quickSortRec(i + 1, right) } }