数据结构与算法 -- LeetCode中常见的排序算法题

提到排序算法,伙伴们肯定很熟悉,包括在做算法题的时候,很多时候是需要将数组排序,然后求得最终的结果,那么此时我们就会考虑算法实现的时间复杂度以及空间复杂度,接下来我会介绍常见的排序算法。

1 冒泡排序

思想

遍历数组中的元素,每轮拿到数组中最大的值,放在数组后面的位置,直到排序完成。

实现

java 复制代码
/**
 * 冒泡排序
 *
 * @param arr 无序数组
 * @return 有序数组
 */
public static int[] BubbleSort(int[] arr) {
    if (arr == null || arr.length < 1) {
        return arr;
    }
    for (int i = 0; i < arr.length; i++) {
        //外层控制遍历的次数
        for (int j = 0; j < arr.length - i - 1; j++) {
            if (arr[j] > arr[j + 1]) {
                swap(arr,j,j+1);
            }
        }
    }

    return arr;
}

通过两个for循环,其中外层的循环用于控制遍历的轮数,内层的循环用于比较大小,从而交换位置,总是把大的数字往后放,当一轮遍历完成之后,数组中最大的数字放在末尾,此时内层的循环将缩小查找范围(由外层循环决定),直到遍历完成。

时间复杂度和空间复杂度

时间复杂度O(n^2)

空间复杂度O(1)

2 选择排序

思想

每次遍历全部的元素,找到数组中最小值的索引,将其放在数组前面的位置,直到遍历完成全部的元素。

实现

java 复制代码
public static int[] SelectSort(int[] arr){

    for (int i = 0;i<arr.length;i++){
        int index = i;
        for (int j = i;j<arr.length;j++){
            if(arr[j] < arr[index]){
                index = j;
            }
        }
        //交换位置
        if (index !=i){
            swap(arr,i,index);
        }
    }
    return arr;
}

通过两个for循环,外层循环控制索引的位置,内层循环用于比较大小,每次默认最小值是外层循环的索引位置,内部循环判断如果有最小的值,那么就更新index的值;

当遍历完成之后,交换位置,有一个小优化,如果后面没有比当前元素更小的值,可以不交换位置,继续下一次遍历。

时间复杂度和空间复杂度

时间复杂度O(n^2)

空间复杂度O(1)

3 插入排序

思想

遍历整个数组,每个元素需要判断与前面元素的大小,如果比前面的元素小,就交换位置。

实现

java 复制代码
public static int[] InsertSort(int[] arr){
    for (int i = 0;i<arr.length;i++){
        int j = i;
        while (j > 0){
            if (arr[j] < arr[j-1]){
                swap(arr,j,j-1);
            }
            j--;
        }
    }
    return arr;
}

时间复杂度和空间复杂度

时间复杂度O(n^2)

空间复杂度O(1)

4 归并排序

思想

归并排序的思想其实就是两个有序的数组,通过两个指针指向两个数组的头部,依次比较数组中每一个元素,最终合并成一个有序数组。归并的思想在很多算法题中也有体现,链表、数组中涉及到排序的问题。

实现

首先对于数组中每个单独的元素来说,就是有序的,在遍历的过程中,拆分最小颗粒度,由小的有序数组合并成大的有序数组。

java 复制代码
public static int[] MergeSort(int[] arr) {
    process(arr, 0, arr.length - 1);
    return arr;
}

private static void process(int[] arr, int left, int right) {
    if (left == right) {
        return;
    }
    //将数组分成两份
    int middle = (right + left) / 2;
    process(arr, left, middle);
    process(arr, middle+1, right);
    merge(arr,left,middle,right);
}

private static void merge(int[] arr,int left,int middle,int right) {
    int[] help = new int[right - left + 1];
    int p1 = left;
    int p2 = middle+1;
    int h1 = 0;
    while (p1 <= middle && p2 <= right) {
        if (arr[p1] < arr[p2]) {
            help[h1] = arr[p1];
            p1++;
        } else {
            help[h1] = arr[p2];
            p2++;
        }
        h1++;
    }
    if (p1 <= middle) {
        for (int i = p1; i <= middle; i++) {
            help[h1] = arr[p1];
            h1++;
        }
    }
    if (p2 <= right) {
        for (int i = p2; i <= right; i++) {
            help[h1] = arr[p2];
            h1++;
        }
    }

    for (int i = 0;i<help.length;i++){
        arr[left+i] = help[i];
    }
}

5 快速排序

思想

快速排序其实是"🇳🇱国旗"问题的延伸,给定一个基准值,让一个数组根据此基准值划分,左边均小于基准值,右边均大于基准值,从而通过递归完成整个数组的排序。

荷兰国旗问题

给定一个数组和一个基准值,让小于基准值的数字在左边,大于基准值的数字在右边,不要求左右两边是排序的。

思路

定义一个边界区间,定义一个指针指向数组的第一个位置,主要分为以下几个case

  • case1:当前数值如果小于基准值,那么就与边界的下一个位置的元素互换位置,同时边界值++,指针++;
  • case2:当前数值如果大于基准值,那么指针++。

实现

java 复制代码
public static int[] Solution(int[] arr, int num) {
    int i = 0;
    //边界
    int less = -1;
    while (i < arr.length) {
        if (arr[i] <= num) {
            //当前值与边界的下一个元素交换
            swap(arr, i, ++less);
        }
        i++;
    }
    return arr;
}

前面我们提到了,在分组的过程中,我们不要求有序,只要小于基准值的就放在左边,大于基准值的放在右边,此时这个问题的升级版就是,著名的荷兰国旗问题,将数组分为3份,左边小于基准值,中间等于基准值,右边大于基准值。

此时定义两个边界,左边界是小于基准值的区域,右边界是大于基准值的区域,同时两边推,最终中间的区域就是等于基准值的区域,因此会有以下case:

  • case1:如果当前值小于基准值,那么当前值与左边界的下个元素做交换;左边界++,i++;
  • case2:如果当前值等于基准值,i++;
  • case3:如果当前值大于基准值,那么当前值与有边界的前一个元素做交换;有边界--,i不动,为啥不动,因为换过来的值可能命中其中某个case,还需要判断。
java 复制代码
public static int[] Solution2(int[] arr, int num) {
    int i = 0;
    //左边界
    int less = -1;
    //有边界
    int more = arr.length;

    while (i < more) {
        if (arr[i] <= num) {
            //当前值与边界的下一个元素交换
            swap(arr, i, ++less);
            i++;
        } else if (arr[i] == num) {
            i++;
        } else {
            //当前值大于基准值
            swap(arr, --more, i);
        }
    }
    return arr;
}

快速排序1.0

当你明白了"荷兰国旗"的原理之后,就会很快明白快速排序的真谛。其实快速排序1.0的原理就是如下图所示:

一次partition的过程就是,取数组最后一个元素作为基准值,将数组分为大于基准值和小于基准值的部分,然后将基准值与大于基准值部分的第一个元素交换。此时基准值一定是在有序数组中的位置

随后在大于基准值和小于基准值的部分分别进行partition的操作,最终完成排序。

这个思想与荷兰国旗的初始版本一致,基准值需要做一次替换,从而定位到有序数组的位置。

快速排序2.0

快速排序2.0则是基于荷兰国旗的升级版,在partition的过程中,就完成分组(>num 、=num、<num),此时基准值就已经找到了在有序数组的位置,然后在 > num 和 < num的分组中分别进行partition。

java 复制代码
public static int[] QuickSort_2(int[] arr) {

    return QuickSortInner(arr, 0, arr.length - 1);
}

private static int[] QuickSortInner(int[] arr, int left, int right) {
    if (left < right) {
        int pos = partition(arr, left, right);
        QuickSortInner(arr, left, pos - 1);
        QuickSortInner(arr, pos + 1, right);
    }
    return arr;
}

/**
 * 返回基准值的位置
 *
 * @param arr   整个数组
 * @param left  需要排序的左边位置
 * @param right 需要排序的右边位置
 */
public static int partition(int[] arr, int left, int right) {

    //基准值
    int num = arr[right];
    int i = left;
    //左边界
    int less = left - 1;
    //有边界
    int more = right + 1;

    while (i < more) {
        if (arr[i] < num) {
            swap(arr, ++less, i);
            i++;
        } else if (arr[i] == num) {
            i++;
        } else {
            swap(arr, --more, i);
        }
    }
    return less + 1;
}

时间复杂度和空间复杂度

快速排序其实还是有3.0版本的,不再以数组的最后一个数字为基准值,而是采用随机数的方式,但是快速排序最差的情况,时间复杂度为O(n^2),平均下来为O(nlogn),空间复杂度为O(logn)。

6 堆排序

堆排序,其实就是用数组实现完全二叉树,什么是完全二叉树?

看上图,如果一棵二叉树的所有节点均存在左右节点,或者从左往右有排满的趋势,那么就认为是完全二叉树,例如倒数第二棵二叉树,虽然第三层没有满,但是从左往右看是连续的,这就是有排满的趋势,所以是完全二叉树;但是倒数第一棵二叉树,第三层不是连续的,因此不是完全二叉树。

而堆则是一种比较特殊的完全二叉树,分为大根堆和小根堆,如下图所示。

大根堆指的是任意一棵子树,头结点都是这棵树的最大值;小根堆指的是任意一棵子树,头结点都是这棵树的最小值。

思路

前面我们主要是讲解了完全二叉树的一些基础知识,因为数组是连续的,所以构建的完全二叉树自然也得是连续的,所以对于如何通过索引找到其左节点、右节点、父节点,记住公式:

所以如果要构建一个大根堆,那么在遍历到某个元素时,与自己的父节点比较,如果比自己的父节点小,那么就继续下个元素的比较;如果比自己的父节点值要大,那么就交换,交换完成之后,如果还有父节点就需要一直比较,一直往上窜。

实现大根堆

首先第一步,遍历数组中的元素,构建完全二叉树,但是要符合大根堆的条件:

当依次遍历数组中的元素时,开始构建完全二叉树(脑海中),当遍历到数字8时,此时二叉树不符合大根堆的条件,此时需要交换与父节点的位置,此时index = 3,其父节点的位置为(index-1)/2,交换完成之后,发现还是不满足大根堆的条件,因此继续往上找,直到符合条件。

交换完成之后,继续遍历,遇到不满足大根堆的条件就进行转换,直到遍历完成,这个步骤叫做HeapInsert.

java 复制代码
/**
 * Heap Insert操作
 *
 * @param arr   待排序的数组
 * @param index 当前遍历的元素
 */
private static void heapInsert(int[] arr, int index) {
    while (arr[index] > arr[(index - 1) / 2]) {
        //当前元素,比父节点要大
        swap(arr, index, (index - 1) / 2);
        index = (index-1) / 2;
    }
}

此时数组中的第一个节点为最大值,如果要获取这个数组中的最大值,那么直接返回数组中第0个元素即可。

如果删除最大值,还要保证是一个最大堆的条件,那么首先把数组中最后一个元素替换到首位。

在替换完成之后,可能会出现不满足大根堆的条件,此时需要从头结点开始,比较左右孩子,拿到最大的值,与头结点替换;替换完成后,此时头结点到了子节点的位置,还需要继续判断它的左右孩子是否满足大根堆的条件,这是heapify的过程。

java 复制代码
/**
 * Heapif操作
 *
 * @param arr   待排序的数组
 * @param index 头结点的索引
 */
private static void heapify(int[] arr, int heap_size, int index) {
    int left = 2 * index + 1;
    while (left < heap_size) {
        //根节点
        int head = arr[index];
        //两个孩子的最大节点
        int largestChild = left;
        //先比较两个孩子
        //如果有右孩子,而且右孩子比左孩子大,那么两个孩子的最大节点就是右孩子
        //否则就是左孩子
        if (left + 1 < heap_size && arr[left + 1] > arr[left]) {
            largestChild = left + 1;
        }
        if (head < arr[largestChild]) {
            //交换
            swap(arr, index, largestChild);
        }
        index = largestChild;
        left = 2 * index + 1;
    }
}

如此操作之后,所有最大堆的根节点都被放在的数组的后面,而且还是排序的,最终拿到的数组就是排序完成的。

java 复制代码
public static int[] HeapSort(int[] arr) {

    int heap_size = 0;
    while (heap_size < arr.length) {
        heapInsert(arr, heap_size);
        heap_size++;
    }
    while (heap_size > 0) {
        heap_size--;
        swap(arr, heap_size, 0);
        heapify(arr, heap_size, 0);
    }

    return arr;

}

时间复杂度和空间复杂度

时间复杂度O(nlogn)

空间复杂度O(1)

7 计数排序

前面我们见到的排序算法,均为数字之间的比较,通过比较大小交换顺序从而完成数组的排序,而计数排序和桶排序,则是在空间上的计数,从而完成排序规则的复原。

思路

举个例子,有一组员工的年龄数组,我们知道员工的年龄是在[16-100]这个区间内的,超过或者低于这个区间我们认为是不合理的,因此我们可以创建一个数组长度为100的空数组,遍历一次数组,那么在空数组中便可以得到每个数字的计数。

例如:16出现2次,20出现3次,21出现1次,35出现1次,40出现2次,那么最终的排序就是[16,16,20,20,20,35,40,40].

所以,此类排序使用的场景有限:假如数组中的元素就是随机数,在-10^5 <= num <= 10^5 之间,显然就不能使用这种计数的方式了。

实现

java 复制代码
public static int[] CountSort(int[] arr) {

    int max = getMaxValue(arr);
    int[] res = new int[max + 1];
    for (int i = 0; i < arr.length; i++) {
        res[arr[i]]++;
    }
    int index = 0;
    for (int i = 0; i < res.length; i++) {
        while (res[i] > 0) {
            arr[index] = i;
            index++;
            res[i]--;
        }
    }
    return arr;
}

private static int getMaxValue(int[] arr) {
    int max = Integer.MIN_VALUE;
    for (int i = 0; i < arr.length; i++) {
        if (arr[i] > max) {
            max = arr[i];
        }
    }
    return max;
}

8 基数排序 & 桶排序

桶排序或者基数排序,是基于计数排序的升级版本,因为计数排序存在的弊端在于,必须要直到数组中的最大值,决定映射数组的长度。

未完待续~

相关推荐
算法歌者5 分钟前
[算法]入门1.矩阵转置
算法
林开落L20 分钟前
前缀和算法习题篇(上)
c++·算法·leetcode
远望清一色21 分钟前
基于MATLAB边缘检测博文
开发语言·算法·matlab
tyler_download23 分钟前
手撸 chatgpt 大模型:简述 LLM 的架构,算法和训练流程
算法·chatgpt
SoraLuna43 分钟前
「Mac玩转仓颉内测版7」入门篇7 - Cangjie控制结构(下)
算法·macos·动态规划·cangjie
我狠狠地刷刷刷刷刷1 小时前
中文分词模拟器
开发语言·python·算法
鸽鸽程序猿1 小时前
【算法】【优选算法】前缀和(上)
java·算法·前缀和
九圣残炎1 小时前
【从零开始的LeetCode-算法】2559. 统计范围内的元音字符串数
java·算法·leetcode
YSRM1 小时前
Experimental Analysis of Dedicated GPU in Virtual Framework using vGPU 论文分析
算法·gpu算力·vgpu·pci直通
韭菜盖饭2 小时前
LeetCode每日一题3261---统计满足 K 约束的子字符串数量 II
数据结构·算法·leetcode