算法复杂度
1.冒泡排序
解法
当前解法为升序
冒泡排序的特点,是一个个数进行处理。第i个数,需要与后续的len-i-1个数进行逐个比较。
为什么是 `len-i-1`个数?
因为数组末尾的i个数,已经是排好序的,确认位置不变的了。
为什么确认位置不变,因为它们固定下来之前,已经和前面的数字都一一比较过了。
function bubbleSort(arr){
const len = arr.length;
for(let i = 0; i < len - 1; i++){
for(let j = 0; j < len - i - 1; j++){
if(arr[j] > arr[j+1]){
const tmp = arr[j+1];
arr[j+1] = arr[j];
arr[j] = tmp;
}
}
}
return arr;
}
2.快速排序
解法
快速排序,使用的是分治法的思想。
通过选定一个数字作为比较值,将要排序其他数字,分为 >比较值 和 ,两个部分。并不断重复这个步骤,直到只剩要排序的数字只有本身,则排序完成。
sort(arr, 0, arr.length - 1);
return arr;
function sort(arr, low, high){//左指针 右指针 要排序的区间
if(low >= high){//判断数组是否有值
return;
}
let i = low;//左指针
let j = high;//右指针
const x = arr[i]; // 取出比较值x,当前位置i空出,等待填入
while(i < j){
// 从数组尾部,找出比x小的数字/下标
while(arr[j] >= x && i < j){
j--;
}
// 将空出的位置,填入当前值, 下标j位置空出
// ps:比较值已经缓存在变量x中
if(i < j){
arr[i] = arr[j].
i++;
}
// 从数组头部,找出比x大的数字
while(arr[i] <= x && i < j){
i++;
}
// 将数字填入下标j中,下标i位置突出
if(i < j){
arr[j] = arr[i]
j--;
}
// 一直循环到左右指针i、j相遇,
// 相遇时,i==j, 所以下标i位置是空出的
}
arr[i] = x; // 将空出的位置,填入缓存的数字x,一轮排序完成
// 分别对剩下的两个区间进行递归排序
sort(arr, low, i - 1);
sort(arr, i+1, high);
}
或
function quiSuSort2(arr) {
if (arr.length <= 1) return arr;
const num = arr[0];
let left = [],
right = [];
for (let i = 1; i < arr.length; i++) {
if (arr[i] <= num) left.push(arr[i]);
else right.push(arr[i]);
}
return quiSuSort2(left).concat([num], quiSuSort2(right));
}
3.插入排序
解法
将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。
从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)
function insertionSort(arr) {
let len = arr.length;
let preIndex, current; //已排序下标,当前比较值
for (let i = 1; i < len; i++) {
preIndex = i - 1;
current = arr[i];
// 位置i之前,是已排好序的数字,while的作用是找到一个坑位,给当前数字current插入
while (preIndex >= 0 && arr[preIndex] > current) {
arr[preIndex + 1] = arr[preIndex];
preIndex--;
}
arr[preIndex + 1] = current;
}
return arr;
}
4.选择排序
解法
选择排序是一种简单直观的排序算法,无论什么数据进去都是 O(n²) 的时间复杂度。所以用到它的时候,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间了吧。
首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。
再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
重复第二步,直到所有元素均排序完毕。
function selectionSort(arr) {
let len = arr.length;
let minIndes, temp; //最小下标,缓存交换值
for (let i = 0; i < len - 1; i++) { //不到最后一个
minIndes = i;
for (let j = i + 1; j < len; j++) {
if (arr[j] < arr[minIndes]) { // 寻找最小的数
minIndes = j; // 将最小数的索引保存
}
}
temp = arr[i];
arr[i] = arr[minIndes];
arr[minIndes] = temp;
}
return arr;
}
5.希尔排序
解法
希尔排序是一种插入排序的算法,它是对简单的插入排序进行改进后,更高效的版本。由希尔(Donald Shell)于1959年提出。
特点是利用增量,将数组分成一组组子序列,然后对子序列进行插入排序。
由于增量是从大到小,逐次递减,所以也称为缩小增量排序。
但希尔排序是非稳定排序算法。
希尔排序是基于插入排序的以下两点性质而提出改进方法的:
- 插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率;
- 但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位;
希尔排序的基本思想是:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录"基本有序"时,再对全体记录进行依次直接插入排序。
function shellSort(arr) {
let len = arr.length,
temp;
for (let gap = Math.floor(len / 2); gap > 0; gap = Math.floor(gap / 2)) {
for (let i = gap; i < len; i++) {
let j = i;
while (j - gap >= 0 && arr[j] < arr[j - gap]) {
temp = arr[j];
arr[j = arr[j - gap]];
arr[j - gap] = temp;
j = j - gap;
}
}
}
return arr;
}
6.归并排序
解法
归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是 O(nlogn) 的时间复杂度。代价是需要额外的内存空间。
-
申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列;
-
设定两个指针,最初位置分别为两个已经排序序列的起始位置;
-
比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置;
-
重复步骤 3 直到某一指针达到序列尾;
-
将另一序列剩下的所有元素直接复制到合并序列尾。
// 归并排序
function mergeSort(arr) {
return sort(arr, 0, arr.length - 1); // 注意右区间是arr.length - 1
// sort方法,进行递归
function sort(arr, left, right) {
// 当left !== right时,证明还没分拆到最小元素
if (left < right) {
// 取中间值,分拆为两个小的数组
const mid = Math.floor((left + right) / 2);
const leftArr = sort(arr, left, mid);
const rightArr = sort(arr, mid + 1, right);
// 递归合并
return merge(leftArr, rightArr)
}
// left == right, 已经是最小元素,直接返回即可 只运行
return left >= 0 ? [arr[left]] : [];
}
// 合并两个有序数组
function merge(leftArr, rightArr) {
let left = 0;
let right = 0;
const tmp = [];
// 使用双指针,对两个数组进行扫描 必然会有没有加入数组的值
while (left < leftArr.length && right < rightArr.length) {
if (leftArr[left] <= rightArr[right]) {
tmp.push(leftArr[left++]);
} else {
tmp.push(rightArr[right++]);
}
}
// 合并没加入 剩下的内容
if (left < leftArr.length) {
while (left < leftArr.length) {
tmp.push(leftArr[left++]);
}
}
if (right < rightArr.length) {
while (right < rightArr.length) {
tmp.push(rightArr[right++]);
}
}
return tmp;
}
}
或
// 归并排序写法2
function mergeSort2(arr) {
return mergeSort(arr)
// 分治
function mergeSort(arr) {
if (arr.length < 2) {
return arr;
}
let middle = Math.floor(arr.length / 2);
let left = arr.slice(0, middle);
return merge(mergeSort(left), mergeSort(arr.slice(middle)))
}
// 传入两个有序数组合并为一个有序数组
function merge(leftArr, rightArr) {
let arr = []
let left = leftArr,
right = rightArr;
while (left.length && right.length) {
if (left[0] <= right[0]) {
arr.push(left.shift())
} else {
arr.push(right.shift())
}
}
return arr.concat(left, right)
}
}
7.堆排序
堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序。分为两种方法:
- 大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列;
- 小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列;
堆排序的平均时间复杂度为 Ο(nlogn)。
-
创建一个堆 H[0……n-1];
-
把堆首(最大值)和堆尾互换;
-
把堆的尺寸缩小 1,并调用 shift_down(0),目的是把新的数组顶端数据调整到相应位置;
-
重复步骤 2,直到堆的尺寸为 1。
function heapSort(arr){
// 初次构建大顶堆
for(let i = Math.floor(arr.length/2) - 1; i >= 0; i--){
// 开始的第一个节点是 树的最后一个非叶子节点
// 从构建子树开始,逐步调整
buildHeap(arr, i, arr.length);
}
// 逐个抽出堆顶最大值
for(let j = arr.length -1 ; j > 0; j--){
swap(arr, 0, j); // 抽出堆顶(下标0)的值,与最后的叶子节点进行交换
// 重新构建大顶堆
// 由于上一步的堆顶最大值已经交换到数组的末尾,所以,它的位置固定下来
// 剩下要比较的数组,长度是j,所以这里的值length == j
buildHeap(arr, 0, j);
}
return arr;
// 构建大顶堆
function buildHeap(arr, i, length){
let tmp = arr[i];
for(let k = 2*i+1; k < length; k = 2*k+1){
// 先判断左右叶子节点,哪个比较大
if(k+1 < length && arr[k+1] > arr[k]){
k++;
}
// 将最大的叶子节点,与当前的值进行比较
if(arr[k] > tmp){
// k节点大于i节点的值,需要交换
arr[i] = arr[k]; // 将k节点的值与i节点的值交换
i = k; // 注意:交换后,当前值tmp的下标是k,所以需要更新
}else{
// 如果tmp大于左右子节点,则它们的子树也不用判断,都是小于当前值
break;
}
}
// i是交换后的下标,更新为tmp
arr[i] = tmp;
}
// 交换值
function swap(arr, i, j){
const tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
}
8. 计数排序
解法
计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。
1. 计数排序的特征
当输入的元素是 n 个 0 到 k 之间的整数时,它的运行时间是 Θ(n + k)。计数排序不是比较排序,排序的速度快于任何比较排序算法。
由于用来计数的数组C的长度取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加上1),这使得计数排序对于数据范围很大的数组,需要大量时间和内存。例如:计数排序是用来排序0到100之间的数字的最好的算法,但是它不适合按字母顺序排序人名。但是,计数排序可以用在基数排序中的算法来排序数据范围很大的数组。
通俗地理解,例如有 10 个年龄不同的人,统计出有 8 个人的年龄比 A 小,那 A 的年龄就排在第 9 位,用这个方法可以得到其他每个人的位置,也就排好了序。当然,年龄有重复时需要特殊处理(保证稳定性),这就是为什么最后要反向填充目标数组,以及将每个数字的统计减去 1 的原因。
算法的步骤如下:
- (1)找出待排序的数组中最大和最小的元素
- (2)统计数组中每个值为i的元素出现的次数,存入数组C的第i项
- (3)对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加)
- (4)反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1
function countingSort(arr) {
let maxValue = 0,
minValue = 0,
offset = 0;
const result = [];
// 取出数组的最大值, 最小值
arr.forEach(num => {
maxValue = num > maxValue ? num : maxValue;
minValue = num > minValue ? minValue : num;
});
if (minValue < 0) {
offset = -minValue;
}
const bucket = new Array(maxValue + offset + 1).fill(0); // 初始化连续的格子,fill用0填充数组
// 将数组中的每个数字,根据值放入对应的下标中,
// `bucket[num] == n`格子的意义:存在n个数字,值为num
arr.forEach(num => {
bucket[num + offset]++;
});
// 读取格子中的数
bucket.forEach((store, index) => {
while (store--) {
result.push(index - offset);
}
});
return result;
}
9.桶排序
然后,元素在每个桶中排序:
解法
桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。为了使桶排序更加高效,我们需要做到这两点:
- 在额外空间充足的情况下,尽量增大桶的数量
- 使用的映射函数能够将输入的 N 个数据均匀的分配到 K 个桶中
同时,对于桶中元素的排序,选择何种比较排序算法对于性能的影响至关重要。
function bucketSort(arr, bucketSize = 10) {
// bucketSize 每个桶可以存放的数字区间(0, 9]
if (arr.length <= 1) {
return arr;
}
let maxValue = arr[0];
let minValue = arr[0];
let result = [];
// 取出数组的最大值, 最小值
arr.forEach(num => {
maxValue = num > maxValue ? num : maxValue;
minValue = num > minValue ? minValue : num;
});
// 初始化桶的数量
const bucketCount = Math.floor((maxValue - minValue) / bucketSize) + 1; // 桶的数量
// 初始化桶的容器
// 注意这里的js语法,不能直接fill([]),因为生成的二维下标数组,是同一个地址
const buckets = new Array(bucketCount).fill(0).map(() => []);
// 将数字按照映射的规则,放入桶中
arr.forEach(num => {
const bucketIndex = Math.floor((num - minValue) / bucketSize);
buckets[bucketIndex].push(num);
});
// 遍历每个桶内存储的数字
buckets.forEach(store => {
// 桶内只有1个数字或者空桶,或者都是重复数字,则直接合并到结果中
if (store.length <= 1 || bucketSize == 1) {
result = result.concat(store);
return;
}
// 递归,将桶内的数字,再进行一次划分到不同的桶中
const subSize = Math.floor(bucketSize / 2); // 减少桶内的数字区间,但必须是最少为1
const tmp = bucketSort(store, subSize <= 1 ? 1 : subSize); //妙
result = result.concat(tmp);
});
return result;
}
10. 基数排序
解法
基数排序,一般是从右到左,对进制位上的数字进行比较,存入[0, 9]的10个桶中,进行排序。
从低位开始比较,逐位进行比较,让每个进制位(个、十、百、千、万)上的数字,都能放入对应的桶中,形成局部有序。
为什么10个桶?
因为十进制数,是由0-9数字组成,对应的进制位上的数字,都会落在这个区间内,所以是10个桶。
基数排序有两种方式:
- MSD 从高位开始进行排序
- LSD 从低位开始进行排序
function radixSort(arr) {
let maxNum = arr[0];
// 求出最大的数字,用于确定最大进制位
arr.forEach(num => {
if (num > maxNum) {
maxNum = num;
}
});
// 获取最大数字有几位
let maxDigitNum = 0;
while (maxNum > 0) {
maxNum = Math.floor(maxNum / 10);
maxDigitNum++;
}
// 对每个进制位上的数进行排序
for (let i = 0; i < maxDigitNum; i++) {
let buckets = new Array(10).fill(0).map(() => []); // 初始化10个桶
for (let k = 0; k < arr.length; k++) {
const bucketIndex = getDigitNum(arr[k], i); // 获取当前进制位上的数字
buckets[bucketIndex].push(arr[k]); // 排序的数字放入对应桶中
}
// 所有数字放入桶中后,现从0-9的顺序将桶中的数字取出
const res = [];
buckets.forEach(store => {
store.forEach(num => {
res.push(num); // 注意这里,先存入桶中的数字,先取出,这样才能保持局部有序
})
});
arr = res;
}
return arr;
/**
求出数字每个进制位上的数字,只支持正整数
@param num 整数
@param digit 位数,从0开始
*/
function getDigitNum(num, digit) {
//求余
return Math.floor(num / Math.pow(10, digit) % 10)
}
}