排序算法
一、大O表示法<a name="sort1"></a>
-
在计算机中, 粗略的度量被称作
大O表示法
-
在算法的比较的过程中, 我们可能更喜欢说:算法A比算法B快两倍, 但是这样的比较有时候没有意义
-
在数据个数发生变化时, 算法的效率会跟着发生改变
-
所以我们通常使用一种算法的速度会如何跟随着数据量变化的
1.1 常见的大O表示形式
符号 | 名称 |
---|---|
O(1) | 常数的 |
O(log(n)) | 对数的 |
O(n) | 线性的 |
O(nlog(n)) | 线性和对数乘积 |
O(n²) | 平方 |
O(2^n) | 指数的 |
-
推导大O表示法的方式:
-
①用常量1取代运行时间中所有的加法常量
-
②在修改后的运行次数函数中, 只保留最高阶项
-
③如果最高存在且不为1, 则去除这个相相乘的常熟
-
如2n² + 4n + 999的大 O表示法为O(n²)
-
1.2 认识排序算法
-
种类: 冒泡排序 / 选择排序 / 插入排序 / 插并排序 / 计数排序(counting sort) / 基数排序(radix sort) / 希尔排序 / 堆排序 / 桶排序
-
这里介绍简单排序和高级排序
-
简单排序: 冒泡排序 -> 选择排序 -> 插入排序
-
高级排序: 希尔排序 -> 快速排序
二、冒泡排序<a name="sort2"></a>
2.1 冒泡排序思路
-
对未排序的各元素从头到尾依次比较相邻两个元素的大小
-
如果符合条件就在每次比较的时候将两个数互换位置
-
这样每循环一次, 最高的人一定放在了当前循环列表的最右端
-
每次遍历从0开始, 按照遍历次数依次减少次数, 最开始的遍历到length - 1即可, 因为要比较i + 1的数数值
2.2 冒泡排序示意图
-
详细过程
-
动态过程
2.3 冒泡排序的代码实现
// Sort为储存数组的一个实例
Sort.prototype.bubbleSort = function () {
console.log(this.arrayList)
for (let i = 0; i < this.len() - 1; i++) {
for (let j = 0; j < this.len() - i - 1; j++) {
if (this.arrayList[j] > this.arrayList[j + 1]) {
// 两数交换,用到了ES6的解构赋值
[this.arrayList[j], this.arrayList[j + 1]] = [this.arrayList[j + 1], this.arrayList[j]]
}
}
}
// console.log(this.arrayList)
}
2.4 冒泡排序的效率
-
比较次数: O(N²)
-
交换次数: O(N²)
三、选择排序<a name="sort3"></a>
3.1 选择排序的思路
-
选定第一个索引的位置, 然后依次和后面的元素进行比较
-
如果后面的元素小于选定的索引指向的值, 则与选定的索引进行交换
-
这样每次遍历之后都可以确定该次循环索引最靠前的位置是准确的, 即每次确定一个最小值
3.2 选择排序示意图
-
动态过程
3.3 选择排序的代码实现
Sort.prototype.selectionSort = function () {
for (let i = 0; i < this.len() - 1; i ++) {
// 每次循环记录最小的下标值
let minIndex = i
for (let j = i + 1; j < this.len() - 1; j ++) {
if (this.arrayList[j] < this.arrayList[minIndex]) {
// 发现更小的, 记录该值
minIndex = j
}
}
// 循环结束之后把最小的下标值与循环开始默认的最小下标值i进行交换
[this.arrayList[minIndex], this.arrayList[i]] = [this.arrayList[i], this.arrayList[minIndex]]
}
// console.log(this.arrayList)
}
3.4 选择排序的效率
-
选择排序的比较次数为: N * (N - 1) / 2 --- O(N²)
-
选择排序的交换次数为: (N - 1) / 2 --- O(N), 而冒泡排序的交换次数为O(N²)
-
所以冒泡排序的效率高于选择排序
四、插入排序<a name="sort4"></a>
4.1 插入排序的思路
-
插入排序的核心是局部有序, 如下图所示, X左边的人称为局部有序
-
在循环的开始, 选好当前排序的临界值(局部有序最后一个下标值)
-
每次循环依次比较前面的一个数
-
若大于则放置在右边(不处理)
-
若小于则先记录该值(temp), 依次向左循环找比当前系啊表更小的数, 每次找到更小的, 把当前下标的数与当前下标-1的数交换, 最后没有更小的数的时候, 把temp赋予该下标
-
4.2 插入排序示意图
-
详细过程
-
动态示意
4.3 插入排序的代码实现
Sort.prototype.insertSort = function () {
for (let i = 1; i < this.len(); i++) {
// 记录被选择的下标的值
let temp = this.arrayList[i]
// 使循环在当前下标开始
let j = i
while (this.arrayList[j - 1] > temp && j > 0) {
// 找到更小的, 使当前下标的值等于当前下标-1的值
this.arrayList[j] = this.arrayList[j - 1]
j--
}
// 最后没有符合条件(比当前值小)的时候, 让当前下标的值等于被选择的值
this.arrayList[j] = temp
}
// console.log(this.arrayList)
}
4.4 插入排序的效率
-
比较次数: N * (N - 1) / 4 --- O(N²)
-
交换次数: N * (N - 1) / 2 --- O(N²)
-
明明选择排序的交换次所大O表示法与插入排序小, 比较次数大O表示一样, 为什么说插入排序平均效率要快于选择排序呢?
-
因为比较次数用大O表示法虽然相同, 但是比较次数插入排序是
N * (N - 1) / 4
, 而选择排序是N * (N - 1) / 2
所以总的操作数量计算下来, 插入排序的平均效率是更快的 -
选择排序无论情况好快, 比较次数是固定的, 而插入排序则灵活许多, 所以平均下来插入排序是优于选择排序的
-
五、希尔排序<a name="sort5"></a>
5.1 希尔排序的历史背景
-
希尔排序按其设计者希尔(Donald Shell)的名字命名,该算法由1959年公布;
-
希尔算法首次突破了计算机界一直认为的算法的时间复杂度都是O(N^2)的大关,为了纪念该算法里程碑式 的意义,用Shell来命名该算法;
5.2 插入排序的问题
-
为什么要将插入排序呢, 因为希尔排序是基于插入排序演变而来的
-
假设一个很小的数据项在很靠近右端的位置上, 这里本应该是较大的数据项的位置
-
将这一个小数据项移动到左边正确的位置, 则中间的所有项都要向右移一位, 这使得效率非常低下
-
如果通过某种方式, 不需要一个个移动中间所有的数据项就能把最右边的较小数移动到左边正确位置的话, 速度会有很大改进
5.3 希尔排序的思路
-
希尔排序主要通过对数据进行分组实现快速的排序
-
根据设定的增量(gap), 将数据分为gap个组, 每次在组内进行局部排序
-
排序之后, 减小增量, 再继续进行分组, 并且局部排序, 直到增量gap为1即止
5.4 希尔排序的实现步骤
-
初始状态
-
按照gap分好gap个组
-
分好组之后进行排序比如8跟3, 1跟4
-
上一步完成之后, 计算新的gap这里是除以2并向下取整 -> 2, 并且再进行分组
-
新的分组排序完成
-
这时候gap已经为1了
-
循环依次, 若大于该项则交换, 这样就完成了
5.5 希尔排序的动态示意图
5.6 希尔排序的代码实现
Sort.prototype.shellSort = function () {
let gap = Math.floor(this.len() / 2)
// 里面的局部排序使用到了插入排序, 如果到最后gap为1的时候, 即对整组数据进行一次比较简单的插入排序即可
while (gap >= 1) {
// 分好gap个组
for (let z = 0; z < gap; z++) {
// 每个组都进行一次插入排序
for (let i = z + gap; i < this.len(); i = i + gap) {
const temp = this.arrayList[i]
let j = i
while (this.arrayList[j - gap] > temp && j >= gap) {
this.arrayList[j] = this.arrayList[j - gap]
j -= gap
}
this.arrayList[j] = temp
// console.log(this.arrayList, i, z)
}
}
gap = Math.floor(gap / 2)
}
// console.log(this.arrayList)
}
5.7 希尔排序的效率与增量
-
希尔排序效率和增量有直接的关系, 使用原稿中的增量效率都高于简单排序
-
增量就是前面一直提到的gap, 英文有间隙的意思
-
希尔原稿建议的初始间距为N / 2, 比如N = 100的数组, 增量就分别为: 50, 25, 12, 6, 3, 1
-
更快的增量序列
-
Hibbard增量序列: 增量的算法为2^k - 1, 即1, 3, 5, 7..., 这种情况最坏的复杂度为O(N^3/2), 平均复杂度为O(N^5/4), 但未被证明
-
Sedgewick增量序列
-
六、快速排序<a name="sort6"></a>
6.1 快速排序的介绍
-
快速排序可以说式目前所有排序算法中, 最快的一种排序算法, 当然, 没有任何一种算法是任何情况下是最优的. 但是, 大多数情况下快速排序是比较好的选择
-
快速排序实质上是冒泡排序的升级版
快速排序的核心思想是分而治之, 先选出一个数据作为枢纽, 将比其小的数据都放在它的左边, 将比它大的数据都放在它的右边
和冒泡排序的不同:
-
一次循环就可以把下图中的65放到正确的位置(比他小的放左边, 大的放右边)
-
那么有一个疑问: 该怎么放才能比原始的排序方法更快呢
-
因为即使是分类放好左右两边, 也是需要根据排序存储的
-
而快速排序的优点就在于, 它不用做任何移动就能在正确的位置
-
6.2 快速排序的思路
快速排序的枢纽
-
首先要提起快速排序的枢纽
-
枢纽有举足轻重的重要性, 选一个好的枢纽可以让排序更简单
-
那么该怎么选呢?
-
第一种方法: 直接选择第一个元素作为枢纽, 但是如果第一个元素不是最小值的情况的话, 效率不高
-
第二种方法: 使用随机数, 随机数本身就耗性能, 并且具有不稳定性, 所以不推荐
-
推荐的方法: 取index为头, length - 1为尾部, 算出其中间值, 让这三个值进行排序, 取排在中间的那个值作为枢纽, 并选好之后放在length - 2的位置
-
快速排序的思路
-
首先枢纽怎么选在前面已经介绍了
-
每次循环, 都有一个左指针指向最左边, 右指针指向枢纽位置(为什么不选right - 2呢, 是因为当数量比较小如2的时候, 就没有-2这个数了)
-
然后每次循环, 左指针找比枢纽大的, 右指针找比枢纽小的, 找到之后如果左指针还在右指针左边, 则左右指针的元素进行交换, 然后接着进行该操作
-
如果左指针在右指针右边的话, 则退出本次循环, 把最后左指针的元素与枢纽的元素进行交换(这时候左指针已经找到了一个大于枢纽的值, 交换这两个元素, 既可以让枢纽处于正确的位置, 也可以让左指针原本指向的元素去到相对于枢纽来说正确的位置)
-
为什么不与右指针交换呢
-
右指针如果交换的话就会出现把一个较小值跟枢纽交换, 这是应该要避免的操作
-
-
-
再交换完枢纽的位置的话, 就可以把枢纽左右部分的再进行递归, 就结束快速排序了
6.3 快速排序代码实现
// 使用到了递归
Sort.prototype.quickSort = function () {
this.quickSortTco(0, this.len() - 1)
// console.log(this.arrayList)
}
Sort.prototype.quickSortTco = function (left, right) {
// 选择枢纽
if (left >= right) return
let mid = Math.floor((left + right) / 2)
// 将三者排序
if (this.arrayList[left] > this.arrayList[mid]) this.exchange(left, mid)
if (this.arrayList[mid] > this.arrayList[right]) this.exchange(mid, right)
if (this.arrayList[left] > this.arrayList[mid]) this.exchange(left, mid)
this.exchange(mid, right - 1)
let pivot = right - 1
let i = left
let j = right - 1
while (true) {
// 左指针不断向右查找比枢纽大的值
while (this.arrayList[i] < this.arrayList[pivot]) i++
// 右指针不断向左查找比枢纽小的值
while (this.arrayList[j] > this.arrayList[pivot]) j--
// 当左指针索引小于右指针索引, 则交换两个元素
if (i < j) {
this.exchange(i, j)
} else {
// 当左指针大于右指针索引, 则说明两个指针相交过, break
break
}
}
// 循环完毕, 左指针所在的地方与枢纽元素交换, 这时候, 枢纽的位置即是正确的
this.exchange(i, pivot)
// 将枢纽的左右两边进行递归操作
this.quickSortTco(left, i - 1)
this.quickSortTco(i + 1, right)
}
6.4 快速排序的效率
-
快速有排序最坏的情况下(每次选都是最左边或者最右边的数据, 这样即等于冒泡排序)的效率, 时间复杂度O(N²), 可以选择枢纽来避免这个情况
-
平均效率: O(Nlog(N)), 虽然其他算法效率也可以达到O, 但是快速排序是