最近,一直在学习业务上的知识,对基础没有怎么重视,因此,这篇文章想对于排序算法进行一个大致的总结。
首先来说一下,关于排序一些相关的基础知识。
- 原地排序:空间复杂度为1的排序算法。即不借用外面的内存,就是在数组本身上排序。
- 稳定性:针对于待排序中存在值相等的元素。经过排序后,相等元素之间原有的先后顺序保持不变(稳定)。
- 排序方法:内排序(所有工作都是在内存中完成)和外排序(数据量太大,需要放在磁盘中,通过磁盘和内存的数据传输才能进行,占用额外内存)。
名词解释:
n
:数据规模k
:桶的个数排序算法 | 平均时间复杂度 | 最好情况 | 最坏情况 | 空间复杂度 | 原地排序 | 稳定性 |
---|---|---|---|---|---|---|
冒泡排序 | O(n^2) |
O(n) |
O(n^2) |
O(1) |
✔ | ✔ |
选择排序 | O(n^2) |
O(n^2) |
O(n^2) |
O(1) |
✔ | ✘ |
插入排序 | O(n^2) |
O(n) |
O(n^2) |
O(1) |
✔ | ✔ |
希尔排序 | O(n log n) |
O(n(log2/2n)) |
O(n(log2/2n)) |
O(1) |
✔ | ✘ |
归并排序 | O(n log n) |
O(n log n) |
O(n log n) |
O(n) |
✘ | ✔ |
快速排序 | O(n log n) |
O(n log n) |
O(n^2) |
O(log n) |
✔ | ✘ |
堆排序 | O(n log n) |
O(n log n) |
O(n log n) |
O(n) |
✘ | ✘ |
计数排序 | O(n + k) |
O(n + k) |
O(n + k) |
O(k) |
✘ | ✔ |
桶排序 | O(n + k) |
O(n + k) |
O(n + k) |
O(n + k) |
✘ | ✔ |
基数排序 | O(n * k) |
O(n * k) |
O(n * k) |
O(n + k) |
✘ | ✔ |
从分类上来讲:
带(*)的排序算法需要额外的空间。
提出一个问题
nlogn比n的平方快多少?
n^2 | nlogn | faster | |
---|---|---|---|
n = 10 | 100 | 33 | 3 |
n = 100 | 10000 | 664 | 15 |
n = 1000 | 10^6 | 9966 | 100 |
n = 10000 | 10^8 | 132877 | 753 |
n = 100000 | 10^10 | 1660964 | 6020 |
数量级越大,快的倍数越多。
顾名思义:冒泡排序的核心就是冒泡。把数组中最小的那个往上冒,冒的过程就是和他 相邻的元素
交换。
有两种方式进行冒泡:
一种是先把小的冒泡到前边去,
另一种是把大的元素冒泡到后边。
实现方案:
- 第一个循环(外循环):负责把需要冒泡的值排除在外
- 第二个循环(内循环):负责两两比较交换
代码实现:
const bubbleSort=(arr)=>{
for(let i=0;i<arr.length;i++) {
for(let j=i+1;j<arr.length;j++) {
if(arr[i]>arr[j]) {
[arr[i],arr[j]]=[arr[j],arr[i]];
}
}
}
}
时间复杂度o(n2)
从 未排序序列 中找到最大(最小)的元素,放到已排序序列的末尾,重复上述步骤,直到所有元素排序完毕。
实现思想:
从未排序的数列中选择最小(最大)的放到队首,然后进入下一个序列
实现方案:
外循环:控制序列的长度
内循环:记录最小的位置和排序序列的第一个进行交换
O(n^2)
const selectionSort = (arr)=>{
let len=arr.length;
for(let i=0; i<len-1; i++){
let min = i;
for(let j=i+1; j<len; j++){
if(arr[j]<arr[min]){
min=j;
}
}
if(min!=i){
[arr[i],arr[min]]=[arr[min],arr[i]]
}
}
}
通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。每次从无序序列中取出一个数据,有序的插入到有序序列中,最终元素取完了,整个序列就变成有序的了。
实现思路:
外循环:控制有序序列
内循环:控制无序序列
代码实现:
const insertSort = (arr) => {
let len=arr.length;
// i以1为起始下标,说明第一个已经排序好了。
for(let i=1; i<len; i++) {
let cur=arr[i];
let j=i-1; // 遍历前面有序的序列,然后寻找小的与之小的数
while(j>=0&&cur<=arr[j]){
arr[j+1]=arr[j];
j--;
}
arr[j+1]=cur;
}
}
希尔排序(Shell Sort)也称 递减增量排序算法,是插入排序的一种更高效的改进版本。
希尔排序是基于插入排序的以下两点性质而提出改进方法的:
- 插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率;
- 但插入排序一般来说是低效的,因为**插入排序每次只能将数据移动一位**;
基本思想:
先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录
基本有序
时,再对全体记录进行依次直接插入排序。采用了分组的思想。
实现思路:
- 分组(设置间隔)
- 组内排序(使用的插入排序方法)
- 重新设置间隔(在原来基础上减半)
- 再次减半设置间隔
- 一直到一 整个数组就为有序的了。
时间复杂度:nlog2n
tips:希尔排序的效率取决于增量值
gap
的选取,时间复杂度并不是一个定值。开始时,
gap
取值较大,子序列中的元素较少,排序速度快,克服了直接插入排序的缺点(直接插入排序如果数据量较大的话,每个元素都要向右移动,导致整个时间复杂度较高);其次,gap
值逐渐变小后,虽然子序列的元素逐渐变多,但大多元素已基本有序,所以继承了直接插入排序的优点,能以近线性的速度排好序。希尔排序并不只是相邻元素的比较,有许多跳跃式的比较,难免会出现相同元素之间的相对位置发生变化。比如上面的例子中希尔排序中相等数据 5 就交换了位置,所以希尔排序是不稳定的算法。
代码实现:
// 这里的gap取n/2,当然gap取值也有其它的方案
// gap的取值凭经验取值,不同的数据合适的gap可能存在差异
const ShellSort = (arr) => {
let len=arr.length;
let gap=Math.floor(len/2);
while(gap>=1){
for(let i=gap;i<len;i++){
let cur=arr[i];
j=i-gap;
// 插入排序的思想
while(j>=0&&cur<arr[j]){
arr[j+gap]=arr[j];
j-=gap;
}
arr[j+gap]=cur;
}
console.log("gap: " + gap);
gap=Math.floor(gap/2);
}
}
采用 分治法(Divide and Conquer)的一个非常典型的应用。
原理:
归并排序是用分治思想,分治模式在每层递归上有三个步骤:
n
个元素分成含 n / 2
个元素的子序列归并排序算法中,归并最后到底都是相邻元素之间的比较交换,并不会发生相同元素的相对位置发生变化,故是稳定性算法
算法实现:
1.主函数,将数组变为小的两个数组,然后这两个小的数组进行归并排序
2.将这两个小的数组进行合并
3.最后返回结果
代码如下:
// 分
const MergeSort = (arr) => {
if(arr.length<=1)return arr;
let len = arr.length;
let left = arr.slice(0, Math.floor(len / 2));
let right = arr.slice(Math.floor(len / 2));
return Merge(MergeSort(left), MergeSort(right));
}
// 合并数组 治
function Merge(left,right){
let len1=left.length;
let len2=right.length;
let i=0,j=0;
let res=[];
while(i<len1&&j<len2){
if(left[i]>=right[j]){
res.push(right[j]);
j++;
}
else
{
res.push(left[i]);
i++;
}
}
while(i<len1){
res.push(left[i]);
i++;
}
while(j<len2){
res.push(right[j]);
j++;
}
return res;
}
分治法:分就是进行分割,治就是进行合并。
快速排序又是一种分而治之思想在排序算法上的典型应用。本质上来看,快速排序应该算是在冒泡排序基础上的 递归分治法。
思路:
- 选择基准值:从数列中挑出一个元素,称为
基准(中心轴)
(Pivot)(有不同的选择方法)- 分割:重新排序数列,所有元素比基准值小的摆放在基准前面,所有比基准大的元素摆在基准的后面(相同的数可以到任意一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作
- 递归排序子序列:递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序
基准值也很重要。
const QuickSort= (arr) => {
// 选取基准元素
if(arr.length<=1)return arr;
let pivotIndex = Math.floor(arr.length/2);
let pivot = arr.splice(pivotIndex,1)[0]
let left=[];
let right=[];
for(let i=0;i<arr.length;i++){
if(arr[i]<pivot){
left.push(arr[i]);
}
else
{
right.push(arr[i]);
}
}
return [...QuickSort(left), pivot, ...QuickSort(right)]
}
堆排序(Heap Sort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,但不是排序二叉树,堆排序可以说是一种利用堆的概念来排序的选择排序,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。分为两种
- 大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列;
- 小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列;
算法原理:
- 先将初始的
Heap[0...n-1]
建立成**最大堆**,此时是无序堆,而堆顶是最大元素- 再将堆顶
Heap[0]
和无序区的最后一个记录Heap[n-1]
交换,由此得到新的 无序区Heap[0...n-2]
和 有序区Heap[n-1]
,且满足Heap[0...n-2].keys <= Heap[n-1].key
- 由于交换后新的根
Heap[1]
可能违反堆性质,故应将当前无序区Heap[1..n-1]
调整为堆。然后再次将Heap[1..n-1]
中关键字最大的记录Heap[1]
和该区间的最后一个记录Heap[n-1]
交换,由此得到新的无序区Heap[1..n-2]
和有序区Heap[n-1..n]
,且仍满足关系Heap[1..n-2].keys≤R[n-1..n].keys
,同样要将Heap[1..n-2]
调整为堆。- 直到无序区只有一个元素为止。
算法实现:
- 构建最大堆
- 然后取出堆顶元素进行排序。
代码如下:
const heapSort = (arr) => {
// 创建堆
let len = arr.length;
for (let i = Math.floor(len / 2) - 1; i >= 0; i--) {
heapify(arr,len, i);
}
for (let i = len - 1; i > 0; i--) {
[arr[0],arr[i]] = [arr[i],arr[0]];
heapify(arr,i,0)
}
// 将最大堆/最小堆放在队尾元素,然后进行排序
}
const heapify = (arr,len, i) => {
let left = i * 2 + 1;
let right = i * 2 + 2;
let maxIndex = i; // 假设根节点为最大,后序还可以进行调整
if (left < len && arr[left] > arr[maxIndex]) {
maxIndex = left;
}
if (right < len && arr[right] > arr[maxIndex]) {
maxIndex = right;
}
if (maxIndex !== i) {
[arr[maxIndex], arr[i]] = [arr[i], arr[maxIndex]]
heapify(arr, maxIndex)
}
}
计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是 有确定范围的整数。
应用场景:适用于量大但是范围小的场景
算法原理:
使用一个额外的数组counter
来计数,这个counter
取决于整个数的范围。比如整个数组的最小数为1,最大数为9,那么counter
数组就要写一个1~9
的数字,然后进行计数。
实现思路:
- 找出最小数和最大数
- 填充数组
- 遍历数组对数进行累加
- 反向填充原数组
代码实现:
const countSort = (arr) => {
let min = Number.MAX_SAFE_INTEGER;
let max = Number.MIN_SAFE_INTEGER;
// let count;
let sort = [];
let currentIndex = 0;
// 找到了最大和最小值 , count数值的值
for (let i = 0; i < arr.length; i++) {
if (min >= arr[i]) {
min = arr[i];
}
if (max <= arr[i]) {
max = arr[i];
}
}
let count = new Array(max - min + 1).fill(0);
for (let i = 0; i < arr.length; i++) {
count[arr[i]] = count[arr[i]] ? count[arr[i]] + 1 : 1;
}
for(let i = 0 ; i < count.length ; i++) {
while(count[i]>0){
sort[currentIndex++]=i;
count[i]--;
}
}
return sort
}
时间复杂度:为线性的 O(n)
桶排序(Bucket Sort)是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。
算法思想:
- 确定每个桶的范围
- 然后将元素均匀地分布在每个桶中
- 然后在每个桶中各自进行排序
- 最后将元素按顺序取出,就是正确地排序了。
算法实现:
const bucketSort=(arr, bucketSize=5) => {
let min=Number.MAX_SAFE_INTEGER;
let max=Number.MIN_SAFE_INTEGER;
let len= arr.length
// 找出最小值和最大值
for(let i=0; i<len; i++) {
min<=arr[i]?min=min:min=arr[i];
max>=arr[i]?max=max:max=arr[i];
}
let buckCount=Math.floor((max-min)/bucketSize)+1;
let bucket=new Array(buckCount).fill(0).map(()=>{
return [];
})
for(let i=0; i<arr.length; i++) {
const bucketIndex=Math.floor((arr[i]-min)/bucketSize);
bucket[bucketIndex].push(arr[i]);
}
let sort=[];
for(let i=0;i<bucket.length; i++) {
selectionSort(bucket[i]);
sort.push(...bucket[i])
}
return sort;
}
const selectionSort=(arr)=>{
let len=arr.length;
for(let i=0;i<len-1;i++){
let min=i;
for(let j=i+1;j<len;j++){
if(arr[j]<arr[min]){
min=j;
}
}
if(min!=i){
[arr[i],arr[min]]=[arr[min],arr[i]];
}
}
}
let arr=[1,8,0,6,2,2,2,2,2,6,1]
console.log(bucketSort(arr));
算法原理:
- 比较个位,放入对应的桶。
- 然后十位
- 依次进行排序,最后就变成有序的序列了。
最后给大家介绍一种常见的算法:滑动窗口
滑动窗口核心思路:
到这,排序算法算是介绍完了。希望对大家有所帮助!