算法为王。javascript
想学好前端,先练好内功,只有内功深厚者,前端之路才会走得更远。html
笔者写的 JavaScript 数据结构与算法之美 系列用的语言是 JavaScript ,旨在入门数据结构与算法和方便之后复习。前端
之因此把归并排序、快速排序、希尔排序、堆排序
放在一块儿比较,是由于它们的平均时间复杂度都为 O(nlogn)。java
请你们带着问题:快排和归并用的都是分治思想,递推公式和递归代码也很是类似,那它们的区别在哪里呢 ?
来阅读下文。git
思想github
排序一个数组,咱们先把数组从中间分红先后两部分,而后对先后两部分分别排序,再将排好序的两部分合并在一块儿,这样整个数组就都有序了。算法
归并排序采用的是分治思想
。shell
分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。segmentfault
注:x >> 1 是位运算中的右移运算,表示右移一位,等同于 x 除以 2 再取整,即 x >> 1 === Math.floor(x / 2) 。api
实现
const mergeSort = arr => {
//采用自上而下的递归方法
const len = arr.length;
if (len < 2) {
return arr;
}
// length >> 1 和 Math.floor(len / 2) 等价
let middle = Math.floor(len / 2),
left = arr.slice(0, middle),
right = arr.slice(middle); // 拆分为两个子数组
return merge(mergeSort(left), mergeSort(right));
};
const merge = (left, right) => {
const result = [];
while (left.length && right.length) {
// 注意: 判断的条件是小于或等于,若是只是小于,那么排序将不稳定.
if (left[0] <= right[0]) {
result.push(left.shift());
} else {
result.push(right.shift());
}
}
while (left.length) result.push(left.shift());
while (right.length) result.push(right.shift());
return result;
};
复制代码
测试
// 测试
const arr = [3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48];
console.time('归并排序耗时');
console.log('arr :', mergeSort(arr));
console.timeEnd('归并排序耗时');
// arr : [2, 3, 4, 5, 15, 19, 26, 27, 36, 38, 44, 46, 47, 48, 50]
// 归并排序耗时: 0.739990234375ms
复制代码
分析
第一,归并排序是原地排序算法吗 ? 这是由于归并排序的合并函数,在合并两个有序数组为一个有序数组时,须要借助额外的存储空间。 实际上,尽管每次合并操做都须要申请额外的内存空间,但在合并完成以后,临时开辟的内存空间就被释放掉了。在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,因此空间复杂度是 O(n)。 因此,归并排序不是原地排序算法。
第二,归并排序是稳定的排序算法吗 ? merge 方法里面的 left[0] <= right[0] ,保证了值相同的元素,在合并先后的前后顺序不变。归并排序是一种稳定的排序方法。
第三,归并排序的时间复杂度是多少 ? 从效率上看,归并排序可算是排序算法中的佼佼者
。假设数组长度为 n,那么拆分数组共需 logn 步, 又每步都是一个普通的合并子数组的过程,时间复杂度为 O(n),故其综合时间复杂度为 O(nlogn)。 最佳状况:T(n) = O(nlogn)。 最差状况:T(n) = O(nlogn)。 平均状况:T(n) = O(nlogn)。
动画
快速排序的特色就是快,并且效率高!它是处理大数据最快的排序算法之一。
思想
特色:快速,经常使用。
缺点:须要另外声明两个数组,浪费了内存空间资源。
实现
方法一:
const quickSort1 = arr => {
if (arr.length <= 1) {
return arr;
}
//取基准点
const midIndex = Math.floor(arr.length / 2);
//取基准点的值,splice(index,1) 则返回的是含有被删除的元素的数组。
const valArr = arr.splice(midIndex, 1);
const midIndexVal = valArr[0];
const left = []; //存放比基准点小的数组
const right = []; //存放比基准点大的数组
//遍历数组,进行判断分配
for (let i = 0; i < arr.length; i++) {
if (arr[i] < midIndexVal) {
left.push(arr[i]); //比基准点小的放在左边数组
} else {
right.push(arr[i]); //比基准点大的放在右边数组
}
}
//递归执行以上操做,对左右两个数组进行操做,直到数组长度为 <= 1
return quickSort1(left).concat(midIndexVal, quickSort1(right));
};
const array2 = [5, 4, 3, 2, 1];
console.log('quickSort1 ', quickSort1(array2));
// quickSort1: [1, 2, 3, 4, 5]
复制代码
方法二:
// 快速排序
const quickSort = (arr, left, right) => {
let len = arr.length,
partitionIndex;
left = typeof left != 'number' ? 0 : left;
right = typeof right != 'number' ? len - 1 : right;
if (left < right) {
partitionIndex = partition(arr, left, right);
quickSort(arr, left, partitionIndex - 1);
quickSort(arr, partitionIndex + 1, right);
}
return arr;
};
const partition = (arr, left, right) => {
//分区操做
let pivot = left, //设定基准值(pivot)
index = pivot + 1;
for (let i = index; i <= right; i++) {
if (arr[i] < arr[pivot]) {
swap(arr, i, index);
index++;
}
}
swap(arr, pivot, index - 1);
return index - 1;
};
const swap = (arr, i, j) => {
let temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
};
复制代码
测试
// 测试
const array = [5, 4, 3, 2, 1];
console.log('原始array:', array);
const newArr = quickSort(array);
console.log('newArr:', newArr);
// 原始 array: [5, 4, 3, 2, 1]
// newArr: [1, 4, 3, 2, 5]
复制代码
分析
第一,快速排序是原地排序算法吗 ? 由于 partition() 函数进行分区时,不须要不少额外的内存空间,因此快排是原地排序
算法。
第二,快速排序是稳定的排序算法吗 ? 和选择排序类似,快速排序每次交换的元素都有可能不是相邻的,所以它有可能打破原来值为相同的元素之间的顺序。所以,快速排序并不稳定。
第三,快速排序的时间复杂度是多少 ? 极端的例子:若是数组中的数据原来已是有序的了,好比 1,3,5,6,8。若是咱们每次选择最后一个元素做为 pivot,那每次分区获得的两个区间都是不均等的。咱们须要进行大约 n 次分区操做,才能完成快排的整个过程。每次分区咱们平均要扫描大约 n / 2 个元素,这种状况下,快排的时间复杂度就从 O(nlogn) 退化成了 O(n2)。 最佳状况:T(n) = O(nlogn)。 最差状况:T(n) = O(n2)。 平均状况:T(n) = O(nlogn)。
动画
解答开篇问题
快排和归并用的都是分治思想,递推公式和递归代码也很是类似,那它们的区别在哪里呢 ?
能够发现:
由下而上
的,先处理子问题,而后再合并。由上而下
的,先分区,而后再处理子问题。思想
过程
实现
const shellSort = arr => {
let len = arr.length,
temp,
gap = 1;
console.time('希尔排序耗时');
while (gap < len / 3) {
//动态定义间隔序列
gap = gap * 3 + 1;
}
for (gap; gap > 0; gap = Math.floor(gap / 3)) {
for (let i = gap; i < len; i++) {
temp = arr[i];
let j = i - gap;
for (; j >= 0 && arr[j] > temp; j -= gap) {
arr[j + gap] = arr[j];
}
arr[j + gap] = temp;
console.log('arr :', arr);
}
}
console.timeEnd('希尔排序耗时');
return arr;
};
复制代码
测试
// 测试
const array = [35, 33, 42, 10, 14, 19, 27, 44];
console.log('原始array:', array);
const newArr = shellSort(array);
console.log('newArr:', newArr);
// 原始 array: [35, 33, 42, 10, 14, 19, 27, 44]
// arr : [14, 33, 42, 10, 35, 19, 27, 44]
// arr : [14, 19, 42, 10, 35, 33, 27, 44]
// arr : [14, 19, 27, 10, 35, 33, 42, 44]
// arr : [14, 19, 27, 10, 35, 33, 42, 44]
// arr : [14, 19, 27, 10, 35, 33, 42, 44]
// arr : [14, 19, 27, 10, 35, 33, 42, 44]
// arr : [10, 14, 19, 27, 35, 33, 42, 44]
// arr : [10, 14, 19, 27, 35, 33, 42, 44]
// arr : [10, 14, 19, 27, 33, 35, 42, 44]
// arr : [10, 14, 19, 27, 33, 35, 42, 44]
// arr : [10, 14, 19, 27, 33, 35, 42, 44]
// 希尔排序耗时: 3.592041015625ms
// newArr: [10, 14, 19, 27, 33, 35, 42, 44]
复制代码
分析
第一,希尔排序是原地排序算法吗 ? 希尔排序过程当中,只涉及相邻数据的交换操做,只须要常量级的临时空间,空间复杂度为 O(1) 。因此,希尔排序是原地排序
算法。
第二,希尔排序是稳定的排序算法吗 ? 咱们知道,单次直接插入排序是稳定的,它不会改变相同元素之间的相对顺序,但在屡次不一样的插入排序过程当中,相同的元素可能在各自的插入排序中移动,可能致使相同元素相对顺序发生变化。 所以,希尔排序不稳定
。
第三,希尔排序的时间复杂度是多少 ? 最佳状况:T(n) = O(n logn)。 最差状况:T(n) = O(n (log(n))2)。 平均状况:T(n) = 取决于间隙序列。
动画
堆的定义
堆实际上是一种特殊的树。只要知足这两点,它就是一个堆。
对于每一个节点的值都大于等于
子树中每一个节点值的堆,咱们叫做大顶堆
。 对于每一个节点的值都小于等于
子树中每一个节点值的堆,咱们叫做小顶堆
。
其中图 1 和 图 2 是大顶堆,图 3 是小顶堆,图 4 不是堆。除此以外,从图中还能够看出来,对于同一组数据,咱们能够构建多种不一样形态的堆。
思想
实现
// 堆排序
const heapSort = array => {
console.time('堆排序耗时');
// 初始化大顶堆,从第一个非叶子结点开始
for (let i = Math.floor(array.length / 2 - 1); i >= 0; i--) {
heapify(array, i, array.length);
}
// 排序,每一次 for 循环找出一个当前最大值,数组长度减一
for (let i = Math.floor(array.length - 1); i > 0; i--) {
// 根节点与最后一个节点交换
swap(array, 0, i);
// 从根节点开始调整,而且最后一个结点已经为当前最大值,不须要再参与比较,因此第三个参数为 i,即比较到最后一个结点前一个便可
heapify(array, 0, i);
}
console.timeEnd('堆排序耗时');
return array;
};
// 交换两个节点
const swap = (array, i, j) => {
let temp = array[i];
array[i] = array[j];
array[j] = temp;
};
// 将 i 结点如下的堆整理为大顶堆,注意这一步实现的基础其实是:
// 假设结点 i 如下的子堆已是一个大顶堆,heapify 函数实现的
// 功能是其实是:找到 结点 i 在包括结点 i 的堆中的正确位置。
// 后面将写一个 for 循环,从第一个非叶子结点开始,对每个非叶子结点
// 都执行 heapify 操做,因此就知足告终点 i 如下的子堆已是一大顶堆
const heapify = (array, i, length) => {
let temp = array[i]; // 当前父节点
// j < length 的目的是对结点 i 如下的结点所有作顺序调整
for (let j = 2 * i + 1; j < length; j = 2 * j + 1) {
temp = array[i]; // 将 array[i] 取出,整个过程至关于找到 array[i] 应处于的位置
if (j + 1 < length && array[j] < array[j + 1]) {
j++; // 找到两个孩子中较大的一个,再与父节点比较
}
if (temp < array[j]) {
swap(array, i, j); // 若是父节点小于子节点:交换;不然跳出
i = j; // 交换后,temp 的下标变为 j
} else {
break;
}
}
};
复制代码
测试
const array = [4, 6, 8, 5, 9, 1, 2, 5, 3, 2];
console.log('原始array:', array);
const newArr = heapSort(array);
console.log('newArr:', newArr);
// 原始 array: [4, 6, 8, 5, 9, 1, 2, 5, 3, 2]
// 堆排序耗时: 0.15087890625ms
// newArr: [1, 2, 2, 3, 4, 5, 5, 6, 8, 9]
复制代码
分析
第一,堆排序是原地排序算法吗 ? 整个堆排序的过程,都只须要极个别临时存储空间,因此堆排序是原地排序算法。
第二,堆排序是稳定的排序算法吗 ? 由于在排序的过程,存在将堆的最后一个节点跟堆顶节点互换的操做,因此就有可能改变值相同数据的原始相对顺序。 因此,堆排序是不稳定
的排序算法。
第三,堆排序的时间复杂度是多少 ? 堆排序包括建堆和排序两个操做,建堆过程的时间复杂度是 O(n),排序过程的时间复杂度是 O(nlogn),因此,堆排序总体的时间复杂度是 O(nlogn)。 最佳状况:T(n) = O(nlogn)。 最差状况:T(n) = O(nlogn)。 平均状况:T(n) = O(nlogn)。
动画
复杂性对比
名称 | 最好 | 平均 | 最坏 | 内存 | 稳定性 | 备注 |
---|---|---|---|---|---|---|
归并排序 | nlog(n) | nlog(n) | nlog(n) | n | Yes | ... |
快速排序 | nlog(n) | nlog(n) | n2 | log(n) | No | 在 in-place 版本下,内存复杂度一般是 O(log(n)) |
希尔排序 | nlog(n) | 取决于差距序列 | n(log(n))2 | 1 | No | ... |
堆排序 | nlog(n) | nlog(n) | nlog(n) | 1 | No | ... |
算法可视化工具
效果以下图。
旨在经过交互式可视化的执行来揭示算法背后的机制。
算法可视化来源 visualgo.net/en 效果以下图。
变量和操做的可视化表示加强了控制流和实际源代码。您能够快速前进和后退执行,以密切观察算法的工做方式。
JavaScript 数据结构与算法之美 的系列文章,坚持 3 - 7 天左右更新一篇,暂定计划以下表。
若是有错误或者不严谨的地方,请务必给予指正,十分感谢。
文中全部的代码及测试事例都已经放到个人 GitHub 上了。
以为有用 ?喜欢就收藏,顺便点个赞吧。
参考文章: