JS数据结构与算法==>排序算法 -- 冒泡排序、选择排序、插入排序、希尔排序、快速排序
冒泡排序
人们开始学习排序算法时,通常都先学冒泡算法,因为它在所有排序算法中最简单。然而,从运行时间的角度来看,冒泡排序是最差的一个,接下来你会知晓原因。
冒泡排序比较所有相邻的两个项,如果第一个比第二个大,则交换它们。元素项向上移动至正确的顺序,就好像气泡升至表面一样,冒泡排序因此得名。
让我们来实现一下冒泡排序。
import { Compare, defaultCompare, swap } from '../../util'
// 冒泡排序
export function bubbleSort(array, compareFn = defaultCompare) {
const { length } = array
for (let i = 0; i < length - 1; i++){
for (let j = 0; j < length - 1; j++){
if (compareFn(array[j], array[j + 1]) === Compare.BIGGER_THAN) {
swap(array, j, j + 1)
}
}
}
return array
}
本章创建的非分布式排序算法都会接收一个待排序的数组作为参数以及一个比较函数。为了使测试更容易理解,我们会在例子中使用包含数字的数组。不过如果需要对包含复杂对象的数组进行排序(对包含people对象的数组按age属性排序),我们的算法也可以奏效。
swap函数的代码如下。
function swap(array, a, b) {
/* const temp = array[a];
array[a] = array[b];
array[b] = temp; */
[array[a], array[b]] = [array[b], array[a]];
}
下面的示意图展示了冒泡排序的工作过程。
该示意图中每一小段表示外循环的一轮,而相邻两项的比较则是在内循环中进行的。
注意当算法执行外循环的第二轮的时候,数字4和5已经是正确排序的了。尽管如此,在后续比较中,它们还在一直进行着比较,即使这是不必要的。因此,我们可以稍稍改进一下冒泡排序算法。
改进后的冒泡排序
如果从内循环减去外循环中已跑过的轮数,就可以避免内循环中所有不必要的比较。
import { Compare, defaultCompare, swap } from '../../util';
// 冒泡排序改进版
export function modifiedBubbleSort(array, compareFn = defaultCompare) {
// 声明一个名为length的变量,用来存储数组的长度
const { length } = array;
// n个数则n-1轮排序即可
for (let i = 0; i < length - 1; i++) {
// 从内循环减去外循环中已跑过的轮数,以避免内循环中所有不必要的比较
// 从第一位迭代至倒数第二位,将最大的项移至最后一位,之后依次选出第二,三...大的项即可
for (let j = 0; j < length - 1 - i; j++) {
if (compareFn(array[j], array[j + 1]) === Compare.BIGGER_THAN) {
swap(array, j, j + 1);
}
}
}
return array;
}
下面这个示意图展示了改进后的冒泡排序算法是如何执行的。
注意,已经在正确位置上的数字没有被比较。即便我们做了这个小改变来改进冒泡排序算法,但还是不推荐该算法,它的复杂度是O()。
选择排序
选择排序算法是一种原址比较排序算法。选择排序大致的思路是找到数据结构中的最小值并将其放置在第一位,接着找到第二小的值并将其放在第二位,以此类推。
下面是选择排序算法的源代码。
import { Compare, defaultCompare, swap } from '../../util'
// 选择排序
export const selectionSort = (array, compareFn = defaultCompare) => {
const { length } = array
let indexMin
for (let i = 0; i < length - 1; i++){
// 假设本轮循环第一个值为数组最小值
indexMin = i
// 从第2个数开始到数组结束比较找出数组最小值索引,有比当前最小值更小的就更新索引
for (let j = i + 1; j < length; j++){
if (compareFn(array[indexMin], array[j]) === Compare.BIGGER_THAN) {
indexMin = j
}
}
// 当内循环结束,将得出数组第n小的值
// 如果循环结束找出的最小值和原最小值不同,则交换其值使得最小值到数组头部
if (indexMin !== i) {
swap(array, indexMin, i)
}
}
return array
}
下面的示意图展示了选择排序算法,此例基于之前代码中所用的数组,也就是[5, 4, 3, 2, 1]。
数组底部的箭头指示出当前迭代轮寻找最小值的数组范围(内循环),示意图中的每一步则表示外循环。
选择排序同样也是一个复杂度为O()的算法。和冒泡排序一样,它包含有嵌套的两个循环,这导致了二次方的复杂度。然而,接下来要学的插入排序比选择排序性能要好。
插入排序
插入排序每次排一个数组项,以此方式构建最后的排序数组。假定第一项已经排序了。接着,它和第二项进行比较——第二项是应该待在原位还是插到第一项之前呢?这样,头两项就已正确排序,接着和第三项比较(它是该插入到第一、第二还是第三的位置呢),以此类推。
下面这段代码表示插入排序算法。
import { Compare, defaultCompare } from '../../util'
// 插入排序
export const insertionSort = (array, compareFn = defaultCompare) => {
const { length } = array
let temp
// 从第二个位置(索引1)而不是0位置开始(我们认为第一项已排序了)
for (let i = 1; i < length; i++){
// j存储要插入的正确位置,初始为元素当前所在位置
let j = i
// 暂存要插入元素的值,便于比较和后续插入
temp = array[i]
// 循环找出正确的插入位置
// 只要变量j比0大(因为数组的第一个索引是0——没有负值的索引)并且数组中前面的值比待比较的值大,我们就把这个值移到当前位置上并减小j
while (j > 0 && compareFn(array[j - 1], temp) === Compare.BIGGER_THAN) {
array[j] = array[j - 1]
j--
}
// 循环结束时j即正确的插入位置,直接插入即可
array[j] = temp
}
return array
}
下面的示意图展示了一个插入排序的实例。
举个例子,假定待排序数组是[3, 5, 1, 4, 2]。这些值将被插入排序算法按照下面的步骤进行排序。
- 3已被排序,所以我们从数组第二个值5开始。3比5小,所以5待在原位(数组的第二位)。3和5排序完毕。
- 下一个待排序和插到正确位置上的值是1(目前在数组的第三位)。5比1大,所以5被移至第三位去了。我们得分析1是否应该被插入到第二位——1比3大吗?不,所以3被移到第二位去了。接着,我们得证明1应该插入到数组的第一位上。因为0是第一个位置且没有负数位,所以1必须被插入第一位。1、3、5三个数字已经排序。
- 然后看下一个值:4。4应该在当前位置(索引3)还是要移动到索引较低的位置上呢?4比5小,所以5移动到索引3位置上去。那么应该把4插到索引2的位置上去吗?4比3大,所以把4插入数组的位置3上。
- 下一个待插入的数字是2(数组的位置4)。5比2大,所以5移动至索引4。4比2大,所以4也得移动(位置3)。3也比2大,所以3还得移动。1比2小,所以2插入到数组的第二位置上。至此,数组已排序完成。
排序小型数组时,此算法比选择排序和冒泡排序性能要好。
快速排序
思想
快速排序也许是最常用的排序算法了。它的复杂度为O(nlog(n)),且性能通常比其他复杂度为O(nlog(n))的排序算法要好。和归并排序一样,快速排序也使用分而治之的方法,将原始数组分为较小的数组(但它没有像归并排序那样将它们分割开)。
"快速排序"的思想很简单,整个排序过程只需要三步:
- 在数据集之中,选择一个元素作为"基准"(pivot)。
- 所有小于"基准"的元素,都移到"基准"的左边;所有大于"基准"的元素,都移到"基准"的右边。
- 对"基准"左边和右边的两个子集,不断重复第一步和第二步,直到所有子集只剩下一个元素为止。
举例来说,现在有一个数据集{85, 24, 63, 45, 17, 31, 96, 50},怎么对其排序呢?
第一步,选择中间的元素45作为"基准"。(基准值可以任意选择,但是选择中间的值比较容易理解。) 第二步,按照顺序,将每个元素与"基准"进行比较,形成两个子集,一个"小于45",另一个"大于等于45"。
第三步,对两个子集不断重复第一步和第二步,直到所有子集只剩下一个元素为止。
下面参照网上的资料,用Javascript语言实现上面的算法。
写法1
// 快速排序,简单易懂版本
export const quickSort = (array) => {
// 检查数组的元素个数,如果小于等于1,就返回
if (array.length <= 1) {
return array
}
// 选择"基准"(pivot),并将其与原数组分离,再定义两个空数组,用来存放一左一右的两个子集
const pivotIndex = Math.floor(array.length / 2)
const pivot = array.splice(pivotIndex, 1)[0]
const left = []
const right = []
// 遍历数组,小于"基准"的元素放入左边的子集,大于基准的元素放入右边的子集
for (let i = 0; i < array.length; i++){
if (array[i] < pivot) {
left.push(array[i])
} else {
right.push(array[i])
}
}
// 使用递归不断重复这个过程,就可以得到排序后的数组
return quickSort(left).concat(pivot, quickSort(right))
}
- 单独开辟两个存储空间
left和right来存储每次递归比pivot小和大的序列 - 每次递归直接返回
left、pivot、right拼接后的数组 - 浪费大量存储空间,写法简单
写法2
import { swap } from '../../util'
// 选择枢纽
function getPivot(array, left, right) {
// 1.求出中间的位置
const center = Math.floor((left + right) / 2)
// 2.判断并且进行交换
if (array[left] > array[center]) {
swap(array, left, center)
}
if (array[center] > array[right]) {
swap(array, center, right)
}
if (array[left] > array[center]) {
swap(array, left, center)
}
// 3.巧妙的操作: 将center移动到right - 1的位置.
swap(array, center, right - 1)
// 4.返回pivot
return array[right - 1]
}
// 快速排序的内部递归主函数
function quick(array, left, right) {
// 0.递归结束条件
if (left >= right) return array
// 1.获取枢纽
const pivot = getPivot(array, left, right)
// 2.开始进行交换
// 2.1.记录左边开始位置和右边开始位置
let i = left
let j = right - 1
// 2.2.循环查找位置
while (i < j) {
while (array[++i] < pivot) { }
while (array[--j] > pivot) { }
if (i < j) {
// 2.3.交换两个数值
swap(array, i, j)
}
}
// 3.将枢纽放在正确的位置
swap(array, i, right - 1)
// 4.递归调用左边
quick(array, left, i - 1)
quick(array, i + 1, right)
return array
}
// 快速排序原地排序版,不借助新数组
export function quickSort1(array) {
return quick(array, 0, array.length - 1)
}
- 原地排序,不借助新数组
- 枢纽选择采取一种比较优秀的解决方案: 取头、中、尾的中位数
- 划分(partition)操作比较巧妙