前端算法小结 | 常见排序算法

213 阅读7分钟

写在前面

这是本系列小结的第五篇。前面几篇跟大家聊完了几种基本数据结构可能涉及到的算法题,今天我们来聊点别的,来看看几种常见的排序算法。

大家如果刚好与我一样正在学(努)习(力)算(刷)法(题),不妨关注下我的专栏: 龙飞的前端算法小结

我们可以一起探讨,一起学习~

常见排序算法

稳定排序与不稳定排序

稳定排序指的就是两个相等的元素在排序前后的位置顺序是不变的。

即假设 A1、A2 的值相等,在排序前 A1 在 A2 前面,排完序之后 A1 还是在 A2 前面,那么这个排序算法就是稳定的。

反之即为不稳定排序。

冒泡排序

原理

给定一个数组:

  1. 两两比较前后两个元素的大小,如果前一个元素比后一个元素大,那就调换两个元素的位置。这样遍历一次后最后的元素就是最大的元素。
  2. 重复执行上述逻辑,直到排序完成,得到的序列就是正序的。

冒泡排序属于稳定排序

代码实现

function bubbleSort (array: number): number[] {
    for (let i: number = 0; i < array.length; i++) {
        for (let j: number = i + 1; j < array.length; j++) {
            if (array[i] > array[j]) {
                const temp: number = array[i];
                array[i] = array[j];
                array[j] = temp;
            }
        }
    }
    
    return array;
}

复杂度分析

时间复杂度:O(n^2)。两层遍历

空间复杂度:O(1)。在原数组上操作,无需开辟新的辅助空间

快速排序

原理

  1. 选定数组中的一个中间值(一般可以选择数组的第一个数)
  2. 遍历数组,将小于中间值的移到左边,大于中间值的移到右边
  3. 递归处理左右两边的数组,最后合并即可得到一个有序数组

插入排序属于不稳定排序

代码实现

function quickSort(arr: number[]): number[] {
    if (arr.length <= 1) {
        return arr;
    }

    const left: number[] = [], 
        right: number[] = [], 
        mid:number = arr.shift();
    
    for (let value of arr) {
        if (value <= mid) {
            left.push(value);
        } else {
            right.push(value);
        }
    }
    
    return quickSort(left).contact(mid, quickSort(right));
}

复杂度分析

时间复杂度:O(n * logN)

空间复杂度:O(logN)

插入排序

原理

给定一个数组:

  1. 从前往后开始遍历,假定第一个元素为已排序数
  2. 从第二个元素起,遍历到每个元素时,该元素从后往前一一进行比较。当比较到比自己更小的元素时,则插入到那个元素后面
  3. 重复执行上述逻辑,直到排序完成,得到的序列就是正序的

插入排序属于稳定排序

代码实现

function insertionSort (array: number[]): number[] {
    for (let i: number = 0; i < array.length; i++) {
        const temp: number = array[i];
        let j: number = i - 1;
        
        while(j >= 0 && array[i] < array[j]) {
            array[j] = array[i];
            j--;
        }
        
        array[j + 1] = temp;
    }

    return array;
}

复杂度分析

时间复杂度:O(n^2)。两层遍历

空间复杂度:O(1)。在原数组上操作,无需开辟新的辅助空间

希尔排序

原理

希尔排序是插入排序的一种改进版本。

  1. 首先要选定一个增量 d (一般是待排序个数 / 2)
  2. 将待排序数组中下标相差 d 的元素分别进行插入排序(假设数组长度为 10,则 d=5,即比较 第 0 个 和 第 5 个元素,第 1 个 和 第 6 个元素,以此类推)
  3. 排序完成后,缩小增量 d,d = d / 2
  4. 重复 2,3 步骤,直到 d = 1
  5. 最后再执行一个插入排序后则排序完成

希尔排序属于不稳定排序

代码实现

function hillSort (arr: number[]): number[] {
    if (arr.length <= 1) {
        return arr;
    }
    
    let d: number = Math.ceil(arr.length / 2);
    
    while(d >= 1){
        arr = insertSort(arr);
        d = Math.ceil(d / 2);
    }
    
    return arr;
}

复杂度分析

时间复杂度:O(n^1.3)。优于直接插入排序

空间复杂度: O(1)。没有开辟额外的空间

归并排序

原理

归并排序的核心思想是分治,也就是将两个有序数组合并成一个有序数组。

它的原理就是将待排序数组拆解为两个数组,然后继续拆解这两个数组,直到每个数组的元素个数都为 1。

然后按拆解的步骤逐步按序合并数组。

归并排序属于稳定排序

代码实现

function merge(left: number[], right: number): number[] {
    const result: number[] = [];
    
    while(left.length && right.length) {
        const leftVal: number = left.shift();
        const rightVal: number = right.shift();
        
        if (leftVal <= rightVal) {
            result.push(leftVal);
        } else {
            result.push(rightVal);
        }
    }
    
    while(left.length) {
        result.push(left.shift());
    }
    
    while(right.length) {
        result.push(right.shift());
    }
    
    return result;
}


function mergeSort(arr: number[]): number[] {
    if (arr.length <= 1) {
        return arr;
    }
    
    const mid: number = Math.ceil(arr.length / 2);
    const left: number[] = mergeSort(arr.slice(0, mid));
    const right: number[] = mergeSort(arr.slice(mid));
    
    return merge(left, right);
}

复杂度分析

时间复杂度:O(n * logN)

空间复杂度:O(n)

选择排序

原理

选择排序的原理是每一次从待排序的数组元素中选择最小的一个元素,将其排到第 n - 1 个位置(比如第一次选择的元素放到位置 0),直到排序完成。

归并排序属于不稳定排序

代码实现

const selectSort(arr: number[]): number[] {
    for (let i = 0; i < arr.length - 1; i ++) {
        let temp: number = i;
        
        for (let j = i + 1; j < arr.length; j ++) {
            if (arr[j] < arr[temp]) {
                temp = j;
            }
        }
        
        if (temp !== i) {
            [arr[i], arr[temp]] = [arr[temp], arr[i]];
        }
    }
    
    return arr;
}

复杂度分析

时间复杂度:O(n^2)

空间复杂度:O(1)

堆排序

原理

首先什么是堆?

堆是一种近似二叉树的数据结构,它可以分为大根堆或小根堆:

  1. 大根堆:每个节点的值都大于或等于它左右子节点的值,即根节点最大
  2. 小根堆:每个节点的值都小于或等于它左右子节点的值,即根节点最小

堆排序的思想就是:

  1. 先将待排序数组构造出一个大根堆
  2. 将第一个元素(即大根堆的根节点)和数组最后一个元素交换位置
  3. 剩下的元素再构造一个大根堆,并将构造后的第一个元素和数组第 n-1 个元素交换位置
  4. 重复执行上述逻辑,就能得到有序数组

堆排序属于不稳定排序

代码实现

function adjustMaxHeap(heap,head,heapSize){
    let temp = heap[head];
    let child = head * 2 + 1;
    while(child < heapSize){
        if (child+1 < heapSize && heap[child] < heap[child+1]) {
            child++;
        }
        if (heap[head] < heap[child]) {
            heap[head] = heap[child];
            head = child;
            child = head * 2 + 1;
        } else {
            break;
        }
        
        heap[head] = temp;
    }
}

function heapSort(arr){
    // 第一次构建大根堆
    for(let i = (heap.length-1) / 2; i >= 0; i--){
        adjustMaxHeap(heap, i, heap.length);
    }
    
    
    for(let i = arr.length-1;i > 0;i--){
        [arr[i],arr[0]] = [arr[0],arr[i]];
        adjustMaxHeap(arr, 0, i);
    }
    return arr;
}

复杂度分析

时间复杂度:O(n * logN)

空间复杂度:O(1)

桶排序

原理

桶排序也是分治思想的一种体现。

它的原理就是首先将待排序数组分组,分别放到不同的桶(数组)里,然后再对每个桶里的元素进行排序。最后再把排序后的每个桶里的元素取出,组成有序数组

桶排序属于稳定排序

代码实现

function bucketSort(arr: number[], radix: number): number[] {
    const bucket: number[][] = [];
    // 生成桶
    for (let i = 0; i < arr.length; i++) {
        bucket.push([]);
    }
    
    // 分组放入桶中
    for (let i = 0; i < arr.length; i++) {
        const index = Math.floor(arr[i] / radix) + 1;
        bucket[index].push(arr[i]);
    }
    
    // 队每个桶进行排序
    for (let i = 0; i < bucket.length; i++) {
        quickSort(bucket[i]);
    }
    
    // 拍平
    return bucket.flat(Infinity);
}

复杂度分析

时间复杂度:O(n)

空间复杂度:O(n)

一些涉及到排序算法的题

下面我们一起来看下几道运用到了排序算法的真题

根据身高重建队列(medium)

假设有打乱顺序的一群人站成一个队列,数组 people 表示队列中一些人的属性(不一定按顺序)。每个 people[i] = [hi, ki] 表示第 i 个人的身高为 hi ,前面 正好 有 ki 个身高大于或等于 hi 的人。

请你重新构造并返回输入数组 people 所表示的队列。返回的队列应该格式化为数组 queue ,其中 queue[j] = [hj, kj] 是队列中第 j 个人的属性(queue[0] 是排在队列前面的人)。

输入:people = [[7,0],[4,4],[7,1],[5,0],[6,1],[5,2]]
输出:[[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]
解释:
编号为 0 的人身高为 5 ,没有身高更高或者相同的人排在他前面。
编号为 1 的人身高为 7 ,没有身高更高或者相同的人排在他前面。
编号为 2 的人身高为 5 ,有 2 个身高更高或者相同的人排在他前面,即编号为 01 的人。
编号为 3 的人身高为 6 ,有 1 个身高更高或者相同的人排在他前面,即编号为 1 的人。
编号为 4 的人身高为 4 ,有 4 个身高更高或者相同的人排在他前面,即编号为 0123 的人。
编号为 5 的人身高为 7 ,有 1 个身高更高或者相同的人排在他前面,即编号为 1 的人。
因此 [[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]] 是重新构造后的队列。

这道题光看题目确实有点难以读懂,但是看了示例之后还是比较清晰的。

题目其实就是想要我们对一个二维数组,按一定的规则进行排序。

一般对于二维数组的排序,我们有一个小套路:

对二维数组的第一个元素降序排序,第二个元素升序排序(反之也行)

这样可以很大程度简化我们的难度。

针对这道题,我们也尝试先对第一个元素降序排序,第二个元素升序排序。这样排序过后得到的数组就是:身高高的在前面

排序之后我们只需要判断二维数组的第二个元素,也就是有几个人排在前面。逻辑也不复杂,可以借助一个结果数组,同时遍历二维数组,然后判断结果数组的长度与第二个元素的大小即可。

// 按第一个元素降序排序,第二个元素升序排序
function compare(a: number[], b: number[]): number {
    if(a[0]==b[0]){
        return a[1]-b[1];
    }
    return b[0]-a[0];
}

function reconstructQueue(people: number[][]): number[][] {
    const sortedPeople = people.sort(compare);

    const res: number[][] = [];
    for(let i = 0; i < sortedPeople.length; i++) {
        const current: number[] = sortedPeople[i];
        
        // 当前人数(res.length) > 第二个元素中的人数,则需要插入到对应位置
        if (res.length > current[1]) {
            res.splice(current[1], 0, current);
        } else {
            // 否则直接插入到尾部
            res.push(current);
        }
    }

    return res;
};

排序链表(medium)

给你链表的头结点 head ,请将其按 升序 排列并返回 排序后的链表 。

image.png

输入:head = [4,2,1,3]
输出:[1,2,3,4]

这道题就是要对一个无序链表进行升序排序。对于链表我们优先选择归并排序

前端算法小结 | 链表篇 里面我提到过链表中快慢指针的操作,我们可以利用快慢指针对列表进行分割。

根据归并排序的思想,分将链表分割到最小节点后,再依次比较左右节点的值,重组成有序链表。

function sortList(head: ListNode | null): ListNode | null {
    if (head === null || head.next === null) {
        return head;
    }

    // 归并排序,快慢指针分割链表
    let fast: ListNode = head.next;
    let slow: ListNode = head;

    while(fast && fast.next) {
        fast = fast.next.next;
        slow = slow.next;
    }

    let mid: ListNode = slow.next;
    slow.next = null;

    // 递归分割
    let left: ListNode = sortList(head);
    let right: ListNode = sortList(mid);

    // 排序,利用哨兵节点
    let sentry: ListNode = new ListNode();
    const res: ListNode = sentry;

    while(left && right) {
        if (left.val < right.val) {
            sentry.next = left;
            left = left.next;
        } else {
            sentry.next = right;
            right = right.next;
        }

        sentry = sentry.next;
    }

    sentry.next = left ? left: right;
    return res.next;
};

写在最后

面试时一般情况下不会直接让你手写一个排序算法,当面试官问到某个排序算法时,一般我们要从下面几点来回答:

  1. 该排序算法的原理
  2. 该排序算法的实现
  3. 最后才是代码实现

而一般情况下,面试时的算法题会间接的涉及到排序算法,这时候就要考验我们如何选择一个排序算法了:

  • 在数组完全无序的情况下:选择快速排序,时间复杂度为O(nlogn)
  • 在数组基本有序的情况下:选择插入排序,因为这样只需要比较大小,不需要移动,时间复杂度趋近于O(n)
  • 归并排序适用于链表、二叉树等数据结构的排序
  • 外部排序常用的算法也是归并排序