谈谈排序算法相关的一些事

961 阅读16分钟

前言

导论

排序在商业数据处理和现代科学计算中有重要的地位。它能够应用于事务处理、组合优化、天体物理学、分子动力学、语言学、基因组学、天气预报和很多其他领域,其中快速排序被誉为20世纪科学与工程领域的十大算法之一

概述

在不同领域,排序算法的实现各有千秋。总体来看,排序算法大致可分为十类:

  • O(n^2):选择排序、冒泡排序、插入排序
  • O(nlogn):快速排序、归并排序、希尔排序、堆排序
  • O(n):桶排序、计数排序、基数排序 笔者主要会针对前面7种进行浅谈与分析。

O(n^2)级排序算法

  • O(n^2)级排序算法,实质就是通过双重循坏不停比较两数大小,将逆序的两数进行交换。从而达到排序的目的。这也是最早的提出几种排序算法雏形。
  • 一般来说,这类算法,在空间复杂度上面,基本都是O(1)。因此,时间复杂度上面的优化,往往可以从空间复杂度利用这一角度下手。后续优化的排序算法都可以看到这类思维。

冒泡排序

简述:冒牌排序本质最直接思维就是通过双重循坏,不断进行遍历,进行交换。属于基础简单的算法思维一种表现。

代码讲解
  • js
    • 版本1
const bubbleSort = (arr) => {
    // 一般来说,对于数组相关类型题,都先声明变量记录数组长度
    const n = arr.length;
    
    // 双重遍历,查找数组最大值进行交换。
    for (let i = 0; i < n - 1; i++) {
        for (let j = 0; j < n - i - 1; j++) { // 查找n - i - 1中的最大值,将其换到第n - i - 1位
            if (arr[j] > arr[j + 1]) { // 逆序的进行交换
                [arr[j + 1], arr[j]] = [arr[j], arr[j + 1]]; // 交换
            }
        }
    }
    
    return arr;
}

优化:以上代码,细心的读者可以发现。其实还有一定的优化空间。例如:当数组如果提前进入到有序状态中时,完全可以考虑不再遍历,直接break退出。同时也可以考虑在该基础上进一步考虑记录数组最后交换的位置,进行进一步的优化。

    • 版本2
const bubbleSort = (arr) => {
    // 一般来说,对于数组相关类型题,都先声明变量记录数组长度
    const n = arr.length;
    let swapped = true; // 记录是否进行了交换。
    
    // 双重遍历,查找数组最大值进行交换。
    for (let i = 0; i < n - 1; i++) {
        if (!swapped) break; // 没有交换,退出循环
        swapped = false; // 初始化交换标识符
        for (let j = 0; j < n - i - 1; j++) { // 查找n - i - 1中的最大值,将其换到第n - i - 1位
            if (arr[j] > arr[j + 1]) { // 逆序的进行交换
                [arr[j + 1], arr[j]] = [arr[j], arr[j + 1]]; // 交换
                swapped = true; // 进行了交换
            }
        }
    }
    
    return arr;
}
    • 版本三
const bubbleSort = (arr) => {
    // 一般来说,对于数组相关类型题,都先声明变量记录数组长度
    const n = arr.length;
    let swapped = true; // 记录是否进行了交换。
    let lastIndex = n - 1; // 记录最后一次交换的索引
    let index = lastIndex; // 记录变化的最后一次交换索引
    
    // 双重遍历,查找数组最大值进行交换。
    while (swapped) {
        swapped = false;
        for (let i = 0; i < lastIndedx; i++) {
            if (arr[i] < arr[i + 1]) {
                index = i;
                [arr[i], arr[i + 1]] = [arr[i + 1], arr[i]];
                swapped = true;
            }
        }
        lastIndex = index; // 将最后一次交换索引进行赋值
    }
    
    return arr;
}
  • java
    • 版本一
public static void bubbleSort(int[] arr) {
    for (int i = 0; i < arr.length - 1; i++) {
        for (int j = 0; j < arr.length - 1 - i; j++) {
            if (arr[j] > arr[j + 1]) {
                // 如果左边的数大于右边的数,则交换,保证右边的数字最大
                swap(arr, j, j + 1);
            }
        }
    }
}
// 交换元素
private static void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}
    • 版本二
public static void bubbleSort(int[] arr) {
    // 初始时 swapped 为 true,否则排序过程无法启动
    boolean swapped = true;
    for (int i = 0; i < arr.length - 1; i++) {
        // 如果没有发生过交换,说明剩余部分已经有序,排序完成
        if (!swapped) break;
        // 设置 swapped 为 false,如果发生交换,则将其置为 true
        swapped = false;
        for (int j = 0; j < arr.length - 1 - i; j++) {
            if (arr[j] > arr[j + 1]) {
                // 如果左边的数大于右边的数,则交换,保证右边的数字最大
                swap(arr, j, j + 1);
                // 表示发生了交换
                swapped = true;
            }
        }
    }
}
// 交换元素
private static void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}
    • 版本三
public static void bubbleSort(int[] arr) {
    boolean swapped = true;
    // 最后一个没有经过排序的元素的下标
    int indexOfLastUnsortedElement = arr.length - 1;
    // 上次发生交换的位置
    int swappedIndex = -1;
    while (swapped) {
        swapped = false;
        for (int i = 0; i < indexOfLastUnsortedElement; i++) {
            if (arr[i] > arr[i + 1]) {
                // 如果左边的数大于右边的数,则交换,保证右边的数字最大
                swap(arr, i, i + 1);
                // 表示发生了交换
                swapped = true;
                // 更新交换的位置
                swappedIndex = i;
            }
        }
        // 最后一个没有经过排序的元素的下标就是最后一次发生交换的位置
        indexOfLastUnsortedElement = swappedIndex;
    }
}
// 交换元素
private static void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

小结:当然,以上这两种所谓的优化,只是在双重循环的基础进行考虑与思考。实质上,当碰到需要不断交换的情况下,该时间复杂度并没有任何变化。但是作为一位新时代码农来说,我觉得有必要养成一个当功能编写完成时,进行一定代码的优化思维。

选择排序

简述:选择排序本质最直接思维也是通过循坏,不断进行遍历,进行交换。属于基础简单的算法思维一种表现。

代码讲解
  • js
    • 版本1
const selectionSort = (arr) => {
    const n = arr.length; // 记录数组长度
    
    for (let i = 0; i < n - 1; i++) { 
        let minIndex = i; // 记录最小值索引
        for (let j = i + 1; j < n; j++) { // 遍历查找i - n过程的最小值索引
            if (arr[j] < arr[minIndex]) minIndex = j;
        }
        // 遍历之后发现最小值索引不等于当前i索引,进行交换
        if (i !== minIndex) [arr[i], arr[minIndex]] = [arr[minIndex], arr[i]];
    }
    
    return arr;
}

优化:以上代码,通过思考我们发现,既然遍历过程中,发现最小值是ok的。那我们可以考虑,在查找的过程,同时查找当前无序数组查找它的最大值索引。最终交换最小值索引的同时,对最大值索引也进行交换。这样从某种程度上来说可以达到减少一半遍历的效果吧。

    • 版本2
const selectionSort = (arr) => {
    const n = arr.length; // 记录数组长度
    
    for (let i = 0; i < n >> 1; i++) { // 此时只需遍历一般长度次数
        let minIndex = i; // 记录最小值索引
        let maxIndex = i;
        for (let j = i + 1; j < n - i; j++) { // 遍历查找i - n过程的最小值索引
            if (arr[j] < arr[minIndex]) minIndex = j;
            if (arr[j] > arr[maxIndex]) maxIndex = j;
        }
        
        // 最小值索引等于最大值索引,代表此时整个数组已经是有序的了,break跳出循环
        if (maxIndex === minIndex) break;
        // 交换
        [arr[i], arr[minIndex]] = [arr[minIndex], arr[i]];
        if (i === maxIndex) // 当i就是最大值索引,此时i索引值已经变为最小值。而原本的索引值被交换到minIndex索引处了。此时需要修改maxIndex值
        maxIndex = minIndex;
          
        if (n - i - 1 !== maxIndex) 
            [arr[n - i - 1], arr[maxIndex]] = [arr[minIndex], arr[n - i - 1]];
    }
    
    return arr;
}
  • java
    • 版本1
public static void selectionSort(int[] arr) {
    int minIndex;
    for (int i = 0; i < arr.length - 1; i++) {
        minIndex = i;
        for (int j = i + 1; j < arr.length; j++) {
            if (arr[minIndex] > arr[j]) {
                // 记录最小值的下标
                minIndex = j;
            }
        }
        // 将最小元素交换至首位
        int temp = arr[i];
        arr[i] = arr[minIndex];
        arr[minIndex] = temp;
    }
}
    • 版本2
public static void selectionSort2(int[] arr) {
    int minIndex, maxIndex;
    // i 只需要遍历一半
    for (int i = 0; i < arr.length / 2; i++) {
        minIndex = i;
        maxIndex = i;
        for (int j = i + 1; j < arr.length - i; j++) {
            if (arr[minIndex] > arr[j]) {
                // 记录最小值的下标
                minIndex = j;
            }
            if (arr[maxIndex] < arr[j]) {
                // 记录最大值的下标
                maxIndex = j;
            }
        }
        // 如果 minIndex 和 maxIndex 都相等,那么他们必定都等于 i,且后面的所有数字都与 arr[i] 相等,此时已经排序完成
        if (minIndex == maxIndex) break;
        // 将最小元素交换至首位
        int temp = arr[i];
        arr[i] = arr[minIndex];
        arr[minIndex] = temp;
        // 如果最大值的下标刚好是 i,由于 arr[i] 和 arr[minIndex] 已经交换了,所以这里要更新 maxIndex 的值。
        if (maxIndex == i) maxIndex = minIndex;
        // 将最大元素交换至末尾
        int lastIndex = arr.length - 1 - i;
        temp = arr[lastIndex];
        arr[lastIndex] = arr[maxIndex];
        arr[maxIndex] = temp;
    }
}

小结:选择排序这种查找最小值的行为,咋一听类似于冒泡的找最大值。但是从思维上去仔细思考的话,会发现,这其实是一个不稳定的算法。但是如果读者仔细观察的话,会发现最终交换的结果是数组原有的相对顺序会很有可能被打乱。但是简单从交换次数上来说,选择排序的交换次数应该是最少的。因此在应用方面,可以从两方面看待选择排序和冒泡排序

算法稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。

插入排序

简述:个人理解插入排序就是对数组进行遍历查找索引-查找适合当前值插入的索引。可以想象成玩扑克牌时,当你抓牌时,一般来说,下意识会把牌插到合适的位置,使当前的牌序是有一定规律的。也就是在循环遍历的过程中,将每一个数放到合适的位置中去。可以理解成遍历每一个数,将其交换到有序数列中的合适位置

代码讲解:
  • js
    • 版本1
const insertSort = (arr) => {
    const n = arr.length;
    
    for (let i = 1; i < n; i++) { // 从索引1开始查找最小值
        let j = i;
        
        while (j >= 1 && arr[j] < arr[j - 1]) { // 交换,对合适位置进行查找
            [arr[j], arr[j - 1]] = [arr[j - 1], arr[j]];
            j--; // 索引缩减,查找合适位置
        }
    }
    
    return arr;
}

该版本是基于之前的比较交换的思维进行排序。还有一种通过移动有序数列,将数值插入的方法。

    • 版本2
const insertSort = (arr) => {
    const n = arr.length;
    
    for (let i = 1; i < n; i++) { // 从索引1开始查找最小值
        let cur = arr[i]; // 记录当前无序数列首位值
        let j = i - 1; // 记录合适位置
        
        while (j >= 0 && arr[j] > arr[cur]) { // 对有序数列进行部分后移
            arr[j + 1] = arr[j];
            j--;
        }
        
        arr[j + 1] = cur; // 将值插入到有序数列合适位置
    }
    
    return arr;
}
  • java
    • 版本1
public static void insertSort(int[] arr) {
    // 从第二个数开始,往前插入数字
    for (int i = 1; i < arr.length; i++) {
        // j 记录当前数字下标
        int j = i;
        // 当前数字比前一个数字小,则将当前数字与前一个数字交换
        while (j >= 1 && arr[j] < arr[j - 1]) {
            swap(arr, j, j - 1);
            // 更新当前数字下标
            j--;
        }
    }
}
private static void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}
    • 版本2
public static void insertSort(int[] arr) {
    // 从第二个数开始,往前插入数字
    for (int i = 1; i < arr.length; i++) {
        int currentNumber = arr[i];
        int j = i - 1;
        // 寻找插入位置的过程中,不断地将比 currentNumber 大的数字向后挪
        while (j >= 0 && currentNumber < arr[j]) {
            arr[j + 1] = arr[j];
            j--;
        }
        
        // 对合适位置进行赋值
        arr[j + 1] = currentNumber;
    }
}

小结:从插入过程中,如果有过链表排序相关经验的读者可以联想到,这种排序思维在链表结构中,可以有一定的发挥空间。尤其是后一种移动法,其实可以将其过程理解为链表指针的移动。

总结

以上就是O(n^2)的几种常见排序算法了。从复杂度来说,它们都是时间O(n^2),空间O(1)的算法。从应用上说,这几种算法已经基本上不怎么应用了,只有特殊场景或许可以考虑一下。但是在远古年代,正是前辈们思考的这些算法,才启动了后续优秀排序算法的出现,因此还是有必要了解熟悉的。

O(nlogn)算法

O(nlogn)级排序算法,是优秀的前辈们通过以上算法,进行思考优化总结出来的算法。结合了递归、双指针等一些常见算法思维,进行了时间复杂度上的优化。本质上来说,就是通过一定的空间,减少时间的复杂度。但是目前计算机发展的趋势,不就是空间越来越充分嘛(个人理解)?更需要的关注点往往是时间复杂度上的优化。从这点上来说,这类算法得充分应用也成为理所当然的现象。 (友情提醒:如果对递归、树结构遍历等不太熟悉的读者,可以先去进行一定的练习,更好方便以下某些算法思维)

希尔排序

简述:希尔排序其实是在上述三种排序算法之后,由Donald Shell博士最先提出来的O(nlogn)级的算法。其本质上是对插入排序进行一定的优化,插入排序主要是比较交换相邻数据,但其实这是我们人类的惯性思维,对于计算机来说,它并不在乎数据之间是否相邻这个条件。因此当我们划定一定间隔时,有规律的进行比较交换,则可以在一定程度上来说,进行时间上面的优化。 代码讲解:

  • js
const shellSort = (arr) => {
    const n = arr.length;
    
    for (let gap = n >> 1; gap > 0; gap >>= 1) { // 划分间隔
        for (let group = 0; group < gap; group++) { // 遍历所有间隔
            for (let j = group + gap; j < n; j++) { // 对所有间隔区域排序
                const cur = arr[j]; // 记录当前数值
                let pre = j - gap; // 当前位置的前一个索引
                
                while (pre >= 0 && arr[pre] > cur) { // 找寻合适位置进行插入
                    arr[pre + gap] = arr[pre]; // 数值往后推
                    pre -= gap; // 前一个位置索引递减,类似于之前插入排序的j--
                } 
                
                arr[pre + gap] = cur; // 合适位置进行插入
            }
        }
    }
    
    return arr;
}
  • 观察以上代码,会发现希尔排序关于gap的取值很重要,甚至一定程度上会影响该算法的效率。事实上,希尔排序的增量序列如何选择是一个数学界的难题,但它也是希尔排序算法的核心优化点。数学界有不少的大牛做过这方面的研究。比较著名的有 Hibbard 增量序列、Knuth 增量序列、Sedgewick 增量序列。
  • java
public static void shellSort(int[] arr) {
    // 间隔序列,在希尔排序中我们称之为增量序列
    for (int gap = arr.length / 2; gap > 0; gap /= 2) {
        // 分组
        for (int groupStartIndex = 0; groupStartIndex < gap; groupStartIndex++) {
            // 插入排序
            for (int currentIndex = groupStartIndex + gap; currentIndex < arr.length; currentIndex += gap) {
                // currentNumber 站起来,开始找位置
                int currentNumber = arr[currentIndex];
                int preIndex = currentIndex - gap;
                while (preIndex >= groupStartIndex && currentNumber < arr[preIndex]) {
                    // 向后挪位置
                    arr[preIndex + gap] = arr[preIndex];
                    preIndex -= gap;
                }
                // currentNumber 找到了自己的位置,坐下
                arr[preIndex + gap] = currentNumber;
            }
        }
    }
}

小结:希尔排序实质上将数组进行划分,再对每个子数组进行插入排序。之所以说实质上取得了一定的优化,是建立在子数组有序时,也就是先对划分的子数组排序,再对其数组进行插入排序时,交换次数将会大幅度减少。从而达到了优化的效果。但从极端情况上来说,它还是有可能达到O(n^2)复杂度的。当间隔为1,实质上就是进行一次插入排序过程。但从宏观角度来看,希尔排序还是取得很大进步的,也开启了O(nlogn)级排序算法的篇章,其划分子数组的思维,在后续优秀排序算法常常可以看到该思维的一些影子。

堆排序

简述:堆排序吸取了树结构遍历的思维。将数组先初始化为一棵树,之后找寻最大(小)值,并与当前无序数列最后叶节点进行交换,最后达到有序的目的。这么说其实跟冒泡排序有点类似,但其实在笔者看来它确实只不过是过程更复杂一点的冒泡排序,主要是要通过递归不断调整树节点,最终有序。

主要思路:
  • 遍历所有非叶节点数据,对数组进行构建堆(树)
  • 遍历所有数据,找出当前无序数列最大值,将其交换到底部。
  • 由于是树结构遍历,不可避免的要使用到一些树结构知识。例如:完全二叉树第i个数左子节点下标为left = 2i + 1,右子节点下标:right = left + 1,最后一个非叶子结点的下标:n/2 - 1(以上知识点如果有不明白的读者可自行查询了解)
代码讲解:
  • js
const heapSort = (arr) => {
    const n = arr.length;
    // 遍历所有非叶子节点数据,对堆进行构建
    for (let i = (n >> 1) - 1; i >= 0; i--) { 从下到上查找最大值,确保当前节点值是以该节点为根节点的树的最大值节点
        headMod(i, n);
    }

    const headMod = (i, size) => { // 当前节点索引,当前无序数列子数组长度
        const l = 2 * i + 1; // 当前左子节点
        const r = l + 1; // 当前右子节点
        let maxIndex = i; // 最大值索引
        
        if (l < size && arr[l] > arr[maxIndex]) maxIndex = l;
        if (r < size && arr[r] > arr[maxIndex]) maxIndex = r;
        
        if (i !== maxIndex) { // 当前最大值索引改变了,则进行交换
            [arr[i], arr[maxIndex]] = [arr[maxIndex], arr[i]];
            heapMod(maxIndex, size); // 递归遍历交换的节点树结构
        }
    }
    
    for (let i = n - 1; i > 0; i--) {
        // 经过之前构建堆的操作之后,可以保证当前树根节点为当前无序数列最大值,因此将其交换到无序数列尾部
        [arr[i], arr[0]] = [arr[i], arr[0]];
        heapMod(0, i); // 遍历剩余无序数列,继续查找最大值。由于经过了先前构建堆的操作,已经保证了,当前堆,所有节点是在以本身节点为根节点的树的最大值节点。
    }
    
    return arr;
}
  • 堆排序需要对树结构遍历等类型思维有一定的了解,可以比较好理解这种思想。
  • 笔者总结如下
    • 首先我们是要无序数列的最大(小)值将其交换到尾部或者首部,这种思想在冒泡,选择等思想已经尽情体现了。不过叙述。
    • 其次是查找的过程中,做到时间上的优化,这一点堆排序充分利用了树结构遍历递归的思维进行查找。从宏观上理解就是遍历树结构,不明白的读者可以去看下树的前序、中序、后序遍历三种思维。先从下往上遍历,保证当前非叶节点的值是围绕非叶节点的树结构的最大值,既保证该二叉树每个子树根节点都是子树的最大值,当然也包括该二叉树的根节点既为当前树结构的最大值
  • java
public static void heapSort(int[] arr) {
    // 构建初始大顶堆
    buildMaxHeap(arr);
    for (int i = arr.length - 1; i > 0; i--) {
        // 将最大值交换到数组最后
        swap(arr, 0, i);
        // 调整剩余数组,使其满足大顶堆
        maxHeapify(arr, 0, i);
    }
}
// 构建初始大顶堆
private static void buildMaxHeap(int[] arr) {
    // 从最后一个非叶子结点开始调整大顶堆,最后一个非叶子结点的下标就是 arr.length / 2-1
    for (int i = arr.length / 2 - 1; i >= 0; i--) {
        maxHeapify(arr, i, arr.length);
    }
}
// 调整大顶堆,第三个参数表示剩余未排序的数字的数量,也就是剩余堆的大小
private static void maxHeapify(int[] arr, int i, int heapSize) {
    // 左子结点下标
    int l = 2 * i + 1;
    // 右子结点下标
    int r = l + 1;
    // 记录根结点、左子树结点、右子树结点三者中的最大值下标
    int largest = i;
    // 与左子树结点比较
    if (l < heapSize && arr[l] > arr[largest]) {
        largest = l;
    }
    // 与右子树结点比较
    if (r < heapSize && arr[r] > arr[largest]) {
        largest = r;
    }
    if (largest != i) {
        // 将最大值交换为根结点
        swap(arr, i, largest);
        // 再次调整交换数字后的大顶堆
        maxHeapify(arr, largest, heapSize);
    }
}
private static void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

小结:堆排序从树遍历过程上来看,时间复杂度可以看成是O(nlogn)。只能说它是充分利用树结构遍历的一种思想,但是从交换手段上看,会发现这其实也是不稳定的排序算法。

快速排序

简述:总算到了最重要的环节了,快排作为众多排序算法脱颖而出的经典算法,其主要原因在于在应用场景方面,相对于其他排序算法更高概率有着更高效的效率。从这点上看,所有的算法终究还是要服务于实际应用才更香。其思维主要是吸取了分治思想的一些思路。

思路如下:
  • 取当前无序数组中的某一个数为基数(一般来说取首部或者尾部,但实质上从效率来说,最好取随机数)
  • 将该无序数组大于该基数放右边,小于该基数的放左边。最后将基数交换到中间位置
  • 采用递归,重复上述步骤,最终达到有序的目的。 代码讲解:
  • js
const quickSort = (arr) => {
    const n = arr.length;
    
    const partition = (start, end) => {
        const privot = arr[start]; // 记录基数
        let l = start, r = end;
        
        while (l < r) { // 遍历无序数组,进行右大左小的交换
            while (l < r && arr[l] <= privot) l++; // 找寻左边序列第一个不合理的数据
            
            if (l !== r) { // 与右边数据进行交换
                [arr[l], arr[r]] = [arr[r], arr[l]];
                r--;
            }
        }
        
        // 遍历过后,当前索引数据大于基数,则需要减一,保证左小右大规则。倘若取尾部为基数,则需要考虑arr[l] < privot这种情形。
        if (l === r && arr[r] > privot) r--;
        
        // 交换基数索引,将基数交换至合适位置,保证左小右大的区间划分
        [arr[r], privot] = [privot, arr[r]];
        
        return r; // 返回基数索引,便于进一步递归划分左右区间。
     }
    
    // 将目标数组划分成一个个无序子数组
    const sort = (start, end) => { // 当前无序数组的start索引-end索引
        if (start >= end) return; // 递归出口
        
        const middle = partition(start, end); // 对当前无序数组进行划分,大的放右边,小的放左边
        
        sort(start, middle - 1); // 递归遍历剩下左边的无序子数组
        sort(middle + 1, end); // 递归遍历剩下右边的无序子数组
    }
    
    sort(0, n - 1); // 进行排序
    
    return arr;
}
  • 通过以上过程我们发现,其主要思想是利用分治思想-将一个难以直接解决的大问题,分割成一些规模较小的相同问题,以便各个击破,分而治之的思维。将大数组不停的转换成一个个无序的小数组进行解析。
  • java
public static void quickSort(int[] arr) {
    quickSort(arr, 0, arr.length - 1);
}
public static void quickSort(int[] arr, int start, int end) {
    // 如果区域内的数字少于 2 个,退出递归
    if (start >= end) return;
    // 将数组分区,并获得中间值的下标
    int middle = partition(arr, start, end);
    // 对左边区域快速排序
    quickSort(arr, start, middle - 1);
    // 对右边区域快速排序
    quickSort(arr, middle + 1, end);
}
// 将 arr 从 start 到 end 分区,左边区域比基数小,右边区域比基数大,然后返回中间值的下标
public static int partition(int[] arr, int start, int end) {
    // 取第一个数为基数
    int pivot = arr[start];
    // 从第二个数开始分区
    int left = start + 1;
    // 右边界
    int right = end;
    // left、right 相遇时退出循环
    while (left < right) {
        // 找到第一个大于基数的位置
        while (left < right && arr[left] <= pivot) left++;
        // 交换这两个数,使得左边分区都小于或等于基数,右边分区大于或等于基数
        if (left != right) {
            exchange(arr, left, right);
            right--;
        }
    }
    // 如果 left 和 right 相等,单独比较 arr[right] 和 pivot
    if (left == right && arr[right] > pivot) right--;
    // 将基数和中间数交换
    if (right != start) exchange(arr, start, right);
    // 返回中间值的下标
    return right;
}
private static void exchange(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

小结:

  • 快排的时间复杂度,从过程来看会发现它主要取决于递归的层数,而递归层数则主要取决于基数索引的变化,既数组的混乱逆序程度。因此平均上来讲,快排在效率上面很有可能高于其他排序算法。
  • 其次,在取基数这一块,我们可以发现,当它取随机数时,也会一定程度影响变化的可能,从而影响递归的层数。笔者在这不过多详解了,有兴趣的读者可以去查找了解一下。
  • 快排作为当今应用最广泛的排序算法,无论从应用或者基本的职业素养来说,新时代码农都是应该必须掌握它的。从它的解题思维,去体会其中的分治递归的算法之美。

归并排序

简述:归并其实说到底也是应用了分治的一些思维,在加上结合树结构思维的一种解决方式(个人理解)。

思路如下:
  • 首先我们看待两个有序数组结合,或者说使用双指针遍历两个有序数组,将其所有按照有序的方式放入一个新数组中去。这个新数组应该就是最终的有序数组了。
  • 因此,从以上思维出发,我们需要做的只是将数组划分,不断的划分。直至最后都划分最小子数组时,再将其按照上述过程进行合并,则达到了子数组排序的目的。
  • 最终,再重复上述操作,不断递归合并。则最终数组将会成为一个有序的数组。
代码讲解
  • js
const mergeSort = (arr) => {
    const n = arr.length;
    const result = new Array(n).fill(0); // 新数组存储排序后的数据
    
    const merge = (start, end, result) => { // 合并有序数组进行处理
        const start2 = (start + end) >> 1 + 1; // 第二个数组取中间索引为界限
        let i1 = start, i2 = start2; // 设置两个有序数组初始索引
        
        // i1 + i2 - start2代表新有序数组的当前索引,i1代表进入result中第一个数组的数据个数,i2 - start2代表第二个数组进入result的数据个数
        while (i1 < m && i2 < n) {
            result[i1 + i2 - start2] = arr1[i1] > arr2[i2] ? arr2[i2++] : arr1[i1++];
        }
        
        while (i1 < m) {
            result[i1 + i2 - start2] = arr1[i1++];
        }
        
        while (i2 < n) {
            result[i1 + i2 - start2] = arr2[i2++];
        }
        
        for (let i = start; i <= end; i++) { // 将有序数组值赋值给原数组
            arr[i] = result[i];
        }
    }
    
    const sort = (start, end) => { // 进行递归划分
        if (start === end) return; // 递归出口
        
        const middle = (start + end) >> 1; // 取中间索引进行划分
        
        sort(start, middle, result);  // 递归排序左边数组
        sort(middle + 1, end, result); // 递归排序右边排序数组
        
        merge(start, end, result); // 合并左右有序子数组
    }
    
    sort(0, n - 1);
    
    return arr;
}
  • 从以上过程中我们可以模拟该过程会发现这个递归过程实质上是一棵树不停的划分节点,直到所有值都变成子节点,然后再由下而上的形成一个个有序子数组,这一个个有序子数组代表其父节点。这也是典型的一种分支思想的体现,不停划分,然后求解。在递归求解,最终求出原问题的答案。
  • java
public static void mergeSort(int[] arr) {
    if (arr.length == 0) return;
    int[] result = new int[arr.length];
    mergeSort(arr, 0, arr.length - 1, result);
}

// 对 arr 的 [start, end] 区间归并排序
private static void mergeSort(int[] arr, int start, int end, int[] result) {
    // 只剩下一个数字,停止拆分
    if (start == end) return;
    int middle = (start + end) / 2;
    // 拆分左边区域,并将归并排序的结果保存到 result 的 [start, middle] 区间
    mergeSort(arr, start, middle, result);
    // 拆分右边区域,并将归并排序的结果保存到 result 的 [middle + 1, end] 区间
    mergeSort(arr, middle + 1, end, result);
    // 合并左右区域到 result 的 [start, end] 区间
    merge(arr, start, end, result);
}

// 将 result 的 [start, middle] 和 [middle + 1, end] 区间合并
private static void merge(int[] arr, int start, int end, int[] result) {
    int end1 = (start + end) / 2;
    int start2 = end1 + 1;
    // 用来遍历数组的指针
    int index1 = start;
    int index2 = start2;
    while (index1 <= end1 && index2 <= end) {
        if (arr[index1] <= arr[index2]) {
            result[index1 + index2 - start2] = arr[index1++];
        } else {
            result[index1 + index2 - start2] = arr[index2++];
        }
    }
    // 将剩余数字补到结果数组之后
    while (index1 <= end1) {
        result[index1 + index2 - start2] = arr[index1++];
    }
    while (index2 <= end) {
        result[index1 + index2 - start2] = arr[index2++];
    }
    // 将 result 操作区间的数字拷贝到 arr 数组中,以便下次比较
    while (start <= end) {
        arr[start] = result[start++];
    }
}

小结:归并排序的时间复杂度是一个稳定的划分递归求解的过程,因此其复杂度可以说是稳稳的O(nlogn)级别。但是需要新建一个额外数组记录递归过程中的有序子数组。因此空间复杂度也可以说是稳稳的O(n)复杂度。从过程上来看,可以发现归并实际上是一种稳定的算法。由于以上相较而言的优点,归并也是一种应用比较多的算法。新时代码农是有必要掌握的。

全文总结

  • 排序算法总共有十种经典的算法,但后面的O(n)级算法由于应用场景的局限性,并没有多排上用场。再加上篇幅有限,所以笔者并没有过多介绍剩下另外三种算法,读者有兴趣可自行去了解。
  • 排序算法,作为编程历史长河中的历史产物。存在流传下来则一定有其合理性和相对应的价值。身为程序员,无论是前端亦或后端,或者其他方向的开发人员。都有必要对其进行一定的了解与掌握。我们不能光会Arrays.sort、Arrat.prototype.sort等api,而完全不知道其背后的一些逻辑,那样在编写应用有可能容易出现思维盲区。

你如太阳般耀眼,不能倒在黎明之前。