最近刷了下算法题,其实作为前端开发,算法方面我接触的确实很少,一些算法涉及到排序相关的思想,比如 2-路归并 等思想,于是想到重新过一遍排序算法,确实有所收获。
本文将网上的一些经典排序算法总结了一下,对于一些难以理解的部分添加了注释,一些晦涩的方法进行了改写,所有算法都亲自验证过,相关参考文献在文章尾部列出,大家也可以参考原文。
文章内容不算长,但完全理解也需要一点时间,希望点赞加关注,我会不定期更新一些小知识点
本人能力有限,文章难免出现错误纰漏,望各位批评指出,共同进步,阿里嘎多 ;-)
1. 排序类型
- 稳定:如果a原本在b前面,而a=b,排序之后a仍然在b的前面。
- 不稳定:如果a原本在b的前面,而a=b,排序之后 a 可能会出现在 b 的后面。
- 时间复杂度:对排序数据的总的操作次数。反映当n变化时,操作次数呈现什么规律。
- 空间复杂度:是指算法在计算机内执行时所需存储空间的度量,它也是数据规模n的函数。
2. 比较类排序
2.1 冒泡排序(Bubble Sort)
冒泡排序是一种简单的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。
- 比较相邻的元素。如果第一个比第二个大,就交换它们两个;
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数;
- 针对所有的元素重复以上的步骤,除了最后一个;
- 重复步骤1~3,直到排序完成。
function bubbleSort(arr) {
for (let i = 0; i < arr.length; i++) {
for (let j = 0; j < arr.length - 1 - i; j++) { // 每次循环都会获取一个最大值,排在最后,故循环长度每次减少1
if (arr[j] > arr[j + 1]) { // 相邻元素对比
// 相邻元素交换
// const temp = arr[j];
// arr[j] = arr[j + 1];
// arr[j + 1] = temp;
[arr[j], arr[j+1]] = [arr[j+1], arr[j]];
}
}
}
return arr;
}
2.2 选择排序(Selection Sort)
选择排序(Selection-sort)是一种简单直观的排序算法。它的工作原理:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
选择排序,是从起始位置开始,找最小的数值所在的索引,如果最终存储的索引不是起始位置,就与起始位置交换存储数据,每执行一次循环,会将最小值存储在起始位置上。
function selectionSort(arr) {
for (let i = 0; i < arr.length - 1; i++) {
let minIdx = i;
// 循环查找最小元素
for(let j = i + 1; j < arr.length; j++) {
if (arr[i] > arr[j]) {
minIdx = j; // 记录最小元素下标
}
}
// 若下标不等于i,则找到更小的元素,交换值
if (minIdx !== i) {
const temp = arr[minIdx];
arr[minIdx] = arr[i];
arr[i] = temp;
}
}
return arr;
}
2.3 插入排序(Insertion Sort)
插入排序(Insertion-Sort)的算法描述是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
- 从第一个元素开始,该元素可以认为已经被排序;
- 取出下一个元素,在已经排序的元素序列中从后向前扫描;
- 如果该元素(已排序)大于新元素,将该元素移到下一位置;
- 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置;
- 将新元素插入到该位置后;
- 重复步骤2~5。
function insertionSort(arr) {
// 默认第一项已排序,从第二项开始遍历
for(let i = 1; i < arr.length; i++) {
let preIdx = i - 1; // 指针,从有序列表最后一项开始
const current = arr[i]; // 当前插入项
while(preIdx >= 0 && current < arr[preIdx]) { // 边界限定,最多查找到第一项(preIdx === 0),条件 当前插入项 小于 当前比较到的有序列表项
arr[preIdx + 1] = arr[preIdx]; // 当前比较项后移
preIdx--; // 指针前移,继续比较
}
// 插入项大于比较项,current插入到指针后
arr[preIdx + 1] = current;
}
return arr;
}
2.4 希尔排序(Shell Sort)
1959年Shell发明,第一个突破O(n2)的排序算法,是简单插入排序的改进版。它与插入排序的不同之处在于,它会优先比较距离较远的元素。希尔排序又叫缩小增量排序。
希尔排序其实是插入排序的改进,将序列分组然后进行插入排序
- 选择一个增量序列t1,t2,…,tk,其中ti>tj,tk=1;
- 按增量序列个数k,对序列进行k 趟排序;
- 每趟排序,根据对应的增量ti,将待排序列分割成若干长度为m 的子序列,分别对各子表进行直接插入排序。仅增量因子为1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
function shellSort(arr) {
// 设置增量默认为 arr.length / 2, 依次除2
for(let gap = Math.floor(arr.length/2); gap > 0; gap = Math.floor(gap/2)) {
// 注意此处与示意图有所区别,示意图是按照分组逐项对比,而此处则未明确体现分组,直接逐项对比
for(let i = gap; i < arr.length; i += 1) {
let preIdx = i - gap; // 初始上一组对应值
while(preIdx >= 0 && arr[preIdx + 1] < arr[preIdx]) {
// 若当前项小于前一项(同组),则交换值
// const temp = arr[preIdx];
// arr[preIdx] = arr[preIdx + 1];
// arr[preIdx + 1] = temp;
[arr[preIdx], arr[preIdx + 1]] = [arr[preIdx + 1], arr[preIdx]];
// 每次对比结束将上一项前移(一个增量长度【同组】)
preIdx -= gap;
}
}
}
return arr;
}
2.5 归并排序(Merge Sort)
归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2-路归并。
- 把长度为n的输入序列分成两个长度为n/2的子序列;
- 对这两个子序列分别采用归并排序;
- 将两个排序好的子序列合并成一个最终的排序序列。
// 归并算法,(2-路归并)
function merge(left, right){
const resArr = [];
while(left.length > 0 || right.length > 0) {
if (left.length > 0 && right.length > 0) {
// 因为每个序列都是有序的,所以只比较第一项即可
if (left[0] > right[0]) {
// 注意需要用shift将当前项从原序列中取出
resArr.push(right.shift());
} else {
resArr.push(left.shift());
}
} else {
// if (left.length > 0) resArr.push(left.shift());
// if (right.length > 0) resArr.push(right.shift());
// 优化,当left或right其一为空时,则可以将另外一组直接插入而无需循环插入,因其已经是有序列表
if (left.length > 0) resArr.push(...left.splice(0));
if (right.length > 0) resArr.push(...right.splice(0));
}
}
return resArr;
}
function mergeSort(arr) {
const len = arr.length;
if (len < 2) return arr;
const middle = Math.floor(len / 2);
const left = arr.slice(0, middle), right = arr.slice(middle);
return merge(mergeSort(left), mergeSort(right));
}
2.6 快速排序(Quick Sort)
快速排序的基本思想:通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。
- 在数据集之中,选择一个元素作为"基准"(pivot)。
- 所有小于"基准"的元素,都移到"基准"的左边;所有大于"基准"的元素,都移到"基准"的右边。
- 对"基准"左边和右边的两个子集,不断重复第一步和第二步,直到所有子集只剩下一个元素为止。
2.6.1 递归基础版
function quickSort(arr) {
if (arr.length< 2) return arr;
// 选取中间元素为基准值
// const pivotIdx = Math.floor(arr.length / 2);
// 基准值其实没有固定规则,这里选择数组第一项
const pivotIdx = 0;
const pivot = arr.splice(pivotIdx, 1)[0];
const left = [], right = [];
// 遍历序列,小于基准插入left,否则插入right
for (let i = 0; i < arr.length; i++) {
const current = arr[i];
if (current <= pivot) {
left.push(current);
} else {
right.push(current);
}
}
return [...quickSort(left), pivot, ...quickSort(right)];
}
2.6.2 递归优化版
上面的算法比较容易理解,但相对的会浪费更多的内存空间,快排另外一个优势就是节省空间浪费,所以采用原地排序实现。
/**
* 分区方法
* 取第一项为基准值,满足基准值大于左侧小于右侧,并返回基准值下标
*/
function partition(arr, left, right) {
const pivotIdx = left; // 设置基准值第一项
const pivot = arr[pivotIdx]; // 基准值
let point = pivotIdx + 1; // 设置指针,从基准值下一项开始逐项比较
for (let i = point; i <= right; i++) {
// 若当前项小于基准值
if (arr[i] < pivot) {
// 将当前比较项与指针项交换位置(保证指针左侧小于指针项)
[arr[point], arr[i]] = [arr[i], arr[point]];
point ++; // 指针后移
}
// console.log('分区移动元素', arr);
}
// 遍历结束, 指针前所有元素小于基准值,将指针前一项与基准值交换位置,此时基准值满足大于左侧小于右侧,此处的index则成功分割序列
point -= 1;
[arr[point], arr[pivotIdx]] = [arr[pivotIdx], arr[point]];
// console.log('分区完成', arr);
return point; // 返回中间值下标
}
// 排序
function quickSort(arr, left, right) {
const len = arr.length;
left = typeof left === 'number' ? left : 0;
right = typeof right === 'number' ? right : arr.length - 1;
if (left < right) {
const partitionIdx = partition(arr, left, right);
// 两个序列同时排序,根据分区构造left,right
quickSort(arr, left, partitionIdx - 1);
quickSort(arr, partitionIdx + 1, right);
}
return arr;
}
2.6.3 循环实现
与大多数的递归到循环的转换方案一样,最先想到的是用栈来模拟递归调用。这样做可以重用一些我们熟悉的递归逻辑,并在循环中使用。
-
用栈记录待排序数组的
left, right(起止下标),基准值index - 1 < left或 基准值index + 1 > right则表示基准值左/右还有未排序的序列,生成新的left,right压入栈中;每次循环从栈中弹出left,right,直至栈为空,循环结束。 -
因为基准值的规则是大于左侧小于右侧,所以当序列有序时,序列应为3位,即
index - 1, index, index + 1, 所以当left < index - 1则表示 index 左侧还有未排序数组,left = left, right = index - 1,右侧同理;
// 分组算法,同上
function partition(arr, left, right) {
// ...
}
function quickSort(arr) {
// 设置栈,存储待排序数组的起止index,初始化为整个数组
const stack = [0, arr.length - 1];
// 当栈清空循环结束
while (stack.length) {
// 每次循环从栈顶弹出待处理的起止值
const right = stack.pop();
const left = stack.pop();
// 获取分区中值index,处理分区
const partitionIdx = partition(arr, left, right);
// 当排序数组left小于中值index-1,则代表左侧数组无序,生成新的left,right压入栈中
if (left < partitionIdx - 1) {
stack.push(left, partitionIdx - 1);
}
if (right > partitionIdx + 1) {
stack.push(partitionIdx + 1, right);
}
}
return arr;
}
2.7 堆排序 (Heap Sort)
堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。
2.7.1 预备知识
堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。如下图:
同时,我们对堆中的结点按层进行编号,将这种逻辑结构映射到数组中就是下面这个样子
0
/ \
1 2 # 1 = 2*0+1 2 = 2*0+2
/ \ / \
3 4 5 6 # 3 = 2*1+1 4 = 2*1+2 5 = 2*2+1 6 = 2*2+2
# leftChildIdx = 2 * parentIdx + 1
# rightChildIdx = 2 * parentIdx + 2
# lastUnLeafNodeIdx = Math.ceil(len / 2) - 1
假设当前节点 index = i , 则其左右叶子节点分别为 2*index + 1; 2*index + 2;
最后一个非叶子节点 index = Math.ceil(len / 2) - 1
该数组从逻辑上讲就是一个堆结构,我们用简单的公式来描述一下堆的定义就是:
-
大顶堆:arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2]
-
小顶堆:arr[i] <= arr[2i+1] && arr[i] <= arr[2i+2]
3.7.2 堆排序思想及步骤
- 构造初始堆。将给定无序序列构造成一个大顶堆(一般升序采用大顶堆,降序采用小顶堆)。
- 从最后一个非叶子节点开始,从右至左,从下到上处理,构建大顶堆
- 交换堆顶与最后一个元素,将最大元素沉至数组末端
- 重复以上步骤,至序列有序
3.7.3 代码实现
/**
* 处理子堆
* arr: 待处理序列
* i: 当前子堆堆顶index
* len: 限制arr长度
*/
function heapify(arr, i, len) {
let largestIdx = i; // 设置最大值的index,默认当前项
const leftIdx = 2*i+1; // 左子节点index
const rightIdx = 2*i+2; // 右子节点index
// 当左子节点在排序列表范围内,并且大于最大值,将最大值索引指向左子节点
if (leftIdx < len && arr[leftIdx] > current) largestIdx = leftIdx;
if (rightIdx < len && arr[rightIdx] > current) largestIdx = rightIdx;
// 当最大值索引不为i,则表示最大值改变
if (largestIdx !== i) {
// 将最大值移到堆顶
[arr[largestIdx], arr[i]] = [arr[i], arr[largestIdx]];
// 由于当前堆改变,将影响到对应子堆,
heapify(arr, largestIdx, len);
}
}
// 堆排序
function heapSort(arr) {
let len = arr.length;
// 生成大顶堆,Math.ceil(len / 2) - 1 为当前完全二叉树的最后一项非叶子节点,每一个单位二叉树循环调用heapify()
for (let i = Math.ceil(len / 2) - 1; i >= 0; i--) {
heapify(arr, i, len);
}
console.log('大顶堆:', arr);
for (let i = Math.ceil(len / 2) - 1; i >= 0; i--) {
// 每次循环将最后一位(根据len变化)与堆顶元素交换位置,最大值始终插入最后(index = len)至数组有序
[arr[0], arr[len - 1]] = [arr[len - 1], arr[0]];
len --; // 每次操作完成,len减1
heapify(arr, i, len);
}
return arr;
}
3. 非比较类排序
3.1 计数排序(Counting Sort)
计数排序不是基于比较的排序算法,其核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。 作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的正整数。
- 找出待排序数组的最大值
- 统计数组中每个值为i的元素出现的次数,存入计数数组的第i项;
- 填充目标数组,循环计数数组,将元素依次取出插入目标数组,每次数量减一
// 计数排序
function countingSort(arr) {
const max = Math.max(...arr);
const len = max + 1; // 设置统计数组长度
const stack = new Array(len).fill(0); // 新建统计数组并填充0,用统计数组的index对应arr的值,统计数组的value记录该值出现次数
const countArr = [];
// 遍历待排序数组,并将对应index的值加一进行数量统计
for (let i = 0; i < arr.length; i++) {
stack[arr[i]] ++;
}
// 遍历计数数组,根据每一项数量生成新数组
for (let i = 0; i < len; i++) {
while(stack[i]) { // 当前项不为0,则将i插入结果数组中,并自减一
countArr.push(i);
stack[i] --;
}
}
}
// 优化版
function countingSort(arr) {
const max = Math.max(...arr);
const min = Math.min(...arr);
const len = max - min + 1; // 根据最大值和最小值生成计数数组长度
const stack = new Array(len).fill(0);
let resIdx = 0; // 记录当前处理的结果数组下标
for(let i = 0; i < arr.length; i++) {
stack[arr[i] - min]++; // 因为待排序数组范围为[min ~ max],但我们的排序数组则为[0 ~ max-min],所以此处对值进行处理 arr[i] - min
}
for(let i = 0; i < len; i++) {
while(stack[i] > 0) {
arr[resIdx] = i + min; // 补偿存储到计数数组中index-min, 此处i + min进行存储
resIdx ++;
stack[i]--;
}
}
return arr;
}
3.2 桶排序(Bucket Sort)
桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。桶排序 (Bucket sort)的工作的原理:假设输入数据服从均匀分布,将数据分到有限数量的桶里,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排)。
- 设置一个定量为桶的容量
size(接受的数据范围); - 根据待排序数组的最大最小值和桶的容量确定桶的数量,
count = Math.ceil((max-min) / size); - 遍历待排序数组,将对应项插入到指定桶中
Math.floor(arr[i] / size); - 对每个不是空的桶进行排序;
- 从不是空的桶里把排好序的数据拼接起来;
// 冒泡排序
function sort(arr){
for(let i=0; i<arr.length; i++){
for(let j=0; j<arr.length - i - 1; j++){
if (arr[j + 1] < arr[j]) {
[arr[j], arr[j+1]] = [arr[j+1], arr[j]];
}
}
}
return arr;
}
// 桶排序,arr:待排序数组;bucketSize:桶接收值范围
function bucketSort(arr, bucketSize = 5) {
const res = [];
// let max = arr[0];
// let min = arr[0];
// for (let i = 1; i < arr.length; i++) {
// if (arr[i] > max) max = arr[i];
// if (arr[i] < min) max = arr[i];
// }
const max = Math.max(...arr);
const min = Math.min(...arr);
// 根据桶的容量范围计算桶数量
const bucketLen = Math.ceil((max - min) / bucketSize);
// bug 注意下面的fill()方法有问题
// const bucket = new Array(bucketLen).fill([]);
// 构造桶数据
const bucket = new Array(bucketLen);
for(let i = 0; i < arr.length; i++) {
const bucketIdx = Math.floor((arr[i] - min) / bucketSize); // 判断当前值位于第几个桶内
if (!bucket[bucketIdx]) bucket[bucketIdx] = []; // 空桶设置默认值 []
bucket[bucketIdx].push(arr[i]);
}
for(let i = 0; i < bucket.length; i++) {
// 每个桶内数据执行排序(无固定要求,此处采用冒泡排序)
res.push(...sort(bucket[i] || []));
}
return res;
}
tips: array.fill(target, begin, end) 方法用于给数组填充元素,并可以指定起止,但是需要注意填充的元素若为引用类型,则所有元素会公用一个内存空间!
var a = new Array(10);
a.fill(0, 0, 5); // [0, 0, 0, 0, 0, empty × 5]
a.fill([], 5, 10); // [0, 0, 0, 0, 0, Array(0), Array(0), Array(0), Array(0), Array(0)]
a[1] = 1; // [0, 1, 0, 0, 0, Array(0), Array(0), Array(0), Array(0), Array(0)]
a[6].push(6) // [0, 1, 0, 0, 0, [6], [6], [6], [6], [6]]
3.3 基数排序(Radix Sort)
基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。
- 取得数组中的最大数,并取得位数;
- arr为原始数组,从最低位开始取每个位组成radix数组;
- 对radix进行计数排序(利用计数排序适用于小范围数的特点);
// 基数排序
function radixSort(arr) {
let dev = 1; // 默认从个位开始排序 1 => 10 => 100 => ... 10^n
const max = Math.max(...arr);
// 最大值 / 基数 大于0则表示当前基数在待排数组范围内
while(max / dev > 0) {
const mod = dev * 10;
const countArr = new Array(10); // 创建计数数组,进行排序
for(let i = 0; i < arr.length; i++) {
const radix = (arr[i] % mod / dev) | 0; // 当前排序位的值
if(!countArr[radix]) countArr[radix] = []; // 若当前为空则赋默认空数组
countArr[radix].push(arr[i]);
}
arr = []; // 重置arr,生成新的基数位有序数组
for(let i = 0; i < countArr.length; i++) {
while(countArr[i]?.length) {
// 注意此处从栈底取出,因基数位项有序,要按序取出
arr.push(countArr[i].shift());
}
}
dev **; // 每次循环结束 dev * 10
}
return arr;
}
3. 参考文献
visualgo-算法图例网站
十大经典排序算法(动图演示)
图解排序算法(二)之希尔排序
图解排序算法(三)之堆排序
面试官超级喜欢问的排序算法