经典排序算法总结(附带每个算法的C++实现)

2 篇文章 5 订阅

\quad
\quad
本文带来的是七大经典排序算法,每个算法给出C++的实现。

\quad
\quad

排序算法的基础知识

排序的稳定性

假设 k i = k j ( 1 ≤ i ≤ n , 1 ≤ j ≤ n , i ≠ j ) k_i=k_j (1\leq i\leq n,1\leq j\leq n,i\not=j) ki=kj(1in,1jn,i=j),且在排序前的序列中 r i r_i ri领先于 r j r_j rj(即 i < j i<j i<j)。

  • 如果排序后 r i r_i ri仍领先于 r j r_j rj,则称所用的排序方法是稳定的;
  • 若可能使得排序后的序列中 r j r_j rj领先 r i r_i ri,则称所用的排序方法是不稳定的。

内排序与外排序

  • 内排序是在排序整个过程中,待排序的所有记录全部被放置在内存中。
  • 外排序是由于排序的记录个数太多,不能同时放置在内存,整个排序过程需要在内外存之间多次交换数据才能进行。

对于内排序来说,排序算法的性能主要受以下三个方面的影响:

  1. 时间性能:排序算法的时间开销是衡量其好坏的最重要的标志。
    在内排序中,主要进行两种操作:比较和移动。
    • 比较指关键字之间的比较,这是要做排序最起码的操作。
    • 移动指记录从一个位置移动到另一个位置。

总之,高效率的内排序算法应该是具有尽可能少的关键字比较次数和尽可能少的记录移动次数。

  1. 辅助空间:执行算法需要的额外空间。
    辅助存储空间是除了存放待排序所占用的存储空间之外,执行算法所需要的其他存储空间。
  2. 算法的复杂性:是算法本身的复杂度,而不是指算法的时间复杂度。显然算法过复杂也会影响排序的性能。

根据排序过程中借助的主要操作,内排序可以分为:插入排序、交换排序、选择排序和归并排序。

排序算法总结

先来个排序算法的整体总结:

排序算法平均时间复杂度最好情况最坏情况空间复杂度排序方式稳定性
冒泡排序 O ( n 2 ) O(n^2) O(n2) O ( n ) O(n) O(n) O ( n 2 ) O(n^2) O(n2) O ( 1 ) O(1) O(1)In-place稳定
选择排序 O ( n 2 ) O(n^2) O(n2) O ( n 2 ) O(n^2) O(n2) O ( n 2 ) O(n^2) O(n2) O ( 1 ) O(1) O(1)In-place不稳定
插入排序 O ( n 2 ) O(n^2) O(n2) O ( n ) O(n) O(n) O ( n 2 ) O(n^2) O(n2) O ( 1 ) O(1) O(1)In-place稳定
希尔排序 O ( n log ⁡ n ) O(n\log n) O(nlogn) O ( n log ⁡ 2 n ) O(n\log^2 n) O(nlog2n) O ( n log ⁡ 2 n ) O(n\log^2 n) O(nlog2n) O ( 1 ) O(1) O(1)In-place不稳定
归并排序 O ( n log ⁡ n ) O(n\log n) O(nlogn) O ( n log ⁡ n ) O(n\log n) O(nlogn) O ( n log ⁡ n ) O(n\log n) O(nlogn) O ( n ) O(n) O(n)Out-place稳定
快速排序 O ( n log ⁡ n ) O(n\log n) O(nlogn) O ( n log ⁡ n ) O(n\log n) O(nlogn) O ( n 2 ) O(n^2) O(n2) O ( log ⁡ n ) O(\log n) O(logn)In-place不稳定
堆排序 O ( n log ⁡ n ) O(n\log n) O(nlogn) O ( n log ⁡ n ) O(n\log n) O(nlogn) O ( n log ⁡ n ) O(n\log n) O(nlogn) O ( 1 ) O(1) O(1)In-place不稳定

比较排序和非比较排序

常见的快速排序归并排序堆排序冒泡排序等属于比较排序 。在排序的最终结果里,元素之间的次序依赖于它们之间的比较。每个数都必须和其他数进行比较,才能确定自己的位置。比较排序的优势是,适用于各种规模的数据,也不在乎数据的分布,都能进行排序。可以说,比较排序适用于一切需要排序的情况

\quad
\quad

冒泡排序(Bubble Sort)

冒泡排序一种交换排序,它重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。

其基本思想是:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。

排序动图:

在这里插入图片描述

代码实现:

void BubbleSort(vector<int> &nums) {
    for (int i = 0; i < nums.size(); i++) {
        for (int j = nums.size() - 1; j > i; j--) {
            if (nums[j - 1] > nums[j]) {
                int temp = nums[j];
                nums[j] = nums[j - 1];
                nums[j - 1] = temp;
            }
        }
    }
}

改进冒泡排序:在for循环中,增加对flag的判断,可以避免在已经基本有序的情况下的无意义循环判断。

void BubbleSortUpdate(vector<int> &nums) {
    bool flag = true;
    for (int i = 0; i < nums.size() && flag; i++) {
        flag = false; // 初始化flag为false
        for (int j = nums.size() - 1; i < j; j--) {
            if (nums[j - 1] > nums[j]) {
                int temp = nums[j];
                nums[j] = nums[j - 1];
                nums[j - 1] = temp;
                flag = true; // 如果有数据交换,则置flag为true
            }
        }
    }
}

冒泡排序复杂度分析:

∑ i = 2 n ( i − 1 ) = 1 + 2 + 3 + ⋯ + ( n − 1 ) = n ( n − 1 ) 2 \sum_{i=2}^{n}(i-1)=1+2+3+\cdots+(n-1)=\frac{n(n-1)}{2} i=2n(i1)=1+2+3++(n1)=2n(n1)
所以,冒泡排序的时间复杂度为 O ( n 2 ) O(n^2) O(n2)

\quad
\quad

选择排序(Selection Sort)

选择排序是一种简单直观的排序算法。选择排序是表现最稳定的排序算法之一,因为无论什么数据进去都是 O ( n 2 ) O(n^2) O(n2)的时间复杂度 ,所以用到它的时候,数据规模越小越好。唯一的优点就是不占用额外的内存空间。

其基本思想是:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。

也就是通过 n − i n-i ni次关键字间的比较,从 n − i + 1 n-i+1 ni+1个记录中选出关键字最小的记录,并和第 i ( 1 ≤ i ≤ n ) i(1\leq i\leq n) i(1in)个记录交换之。

排序动图:
在这里插入图片描述

代码实现:

void SelectSort(vector<int> &nums) {
    for (int i = 0; i < nums.size(); i++) {
        int min = i;
        for (int j = i + 1; j < nums.size(); j++) {
            if (nums[j] < nums[min]) {  // 找到最小的数
                min = j;  // 保存最小的数的索引
            }
        }
        if (i != min) {  // 交换
            int temp = nums[i];
            nums[i] = nums[min];
            nums[min] = temp;
        }
    }
}

选择排序复杂度分析:

从选择排序的过程来看,它最大的特点就是交换移动数据次数相当少,这样也就节约了相应的时间。分析它的时间复杂度发现,无论最好最差的情况,其比较次数都是一样的多,第 i i i趟排序需要进行 n − i n-i ni次关键字的比较,此时需要比较
∑ i = 1 n ( n − i ) = 1 + 2 + 3 + ⋯ + ( n − 1 ) = n ( n − 1 ) 2 \sum_{i=1}^{n}(n-i)=1+2+3+\cdots+(n-1)=\frac{n(n-1)}{2} i=1n(ni)=1+2+3++(n1)=2n(n1)
次。而对于交换次数而言,当最好的时候交换为0次,最差的时候,也就初始降序时,交换次数为 n − 1 n-1 n1次,基于最终的排序时间是比较与交换的次数总和,因此,总的时间复杂度依然为 O ( n 2 ) O(n^2) O(n2)。应该说,尽管与冒泡排序同为 O ( n 2 ) O(n^2) O(n2),但选择排序的性能上还是要略优于冒泡排序

\quad
\quad

插入排序(Insertion Sort)

插入排序是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。插入排序在实现上,通常采用In-place排序(即只需用到O(1)的额外空间的排序),因而在从后向前扫描过程中,需要反复把已排序的元素逐步向后挪位,为最新元素提供插入空间。

对于少量元素的排序,插入排序是一个有效的算法。在其实现过程使用双层循环,外层循环对除了第一个元素之外的所有元素,内层循环对当前元素前面有序表进行待插入位置查找,并进行移动。

插入排序动图:
在这里插入图片描述

代码实现:

void InsertSort(vector<int> &nums) {
    int i, j;
    for (i = 1; i < nums.size(); i++) {
        if (nums[i - 1] > nums[i]) {
            int temp = nums[i];  // 设置一个哨兵
            for (j = i - 1; nums[j] > temp && j >= 0; j--) {
                nums[j + 1] = nums[j];  // 数据往后移,找到该插入的位置
            }
            nums[j + 1] = temp;  // 在对应位置插入数据
        }
    }
}

直接插入排序复杂度分析:

插入排序的平均时间复杂度也是 O ( n 2 ) O(n^2) O(n2),空间复杂度为常数阶 O ( 1 ) O(1) O(1),具体时间复杂度和数组的有序性也是有关联的。插入排序中,当待排序数组是有序时,是最优的情况,只需当前数跟前一个数比较一下就可以了,这时一共需要比较 N − 1 N-1 N1 次,时间复杂度为 O ( N ) O(N) O(N)。最坏的情况是待排序数组是逆序的,此时需要比较次数最多,最坏的情况是 O ( n 2 ) O(n^2) O(n2)

\quad
\quad

希尔排序(Shell Sort)

希尔排序是第一批时间复杂度突破 O ( n 2 ) O(n^2) O(n2)的算法之一。它也是一种插入排序,它是简单插入排序经过改进之后的一个更高效的版本,也称为缩小增量排序。它与插入排序的不同之处在于,它会优先比较距离较远的元素。

希尔排序将原本有大量记录数的记录进行分组。分割成若干个子序列,此时每个子序列待排序的记录个数就比较少了,然后在这些子序列内分别进行直接插入排序,当整个序列都基本有序时,注意只是基本有序时,再对全体记录进行一次直接插入排序。

基本有序:小的关键字基本在前面,大的基本在后面,不大不小的基本在中间。

为了得到基本有序的结果,可以采取这样的跳跃分割策略:将相距某个“增量”的记录组成一个序列,这样才能保证在子序列内分别进行直接插入排序后得到的结果是基本有序而不是局部有序。

增量的选取:增量序列的最后一个值一定取1,增量序列中的值应尽量们没有除1之外的公因子。

希尔排序示意图:

代码实现:

void ShellSort(vector<int> &nums) {
    int i, j;
    int increment = nums.size();
    while (increment > 1) {
        increment = increment / 3 + 1;
        for (i = increment; i < nums.size(); i++) {
            if (nums[i] < nums[i - increment]) {
                int temp = nums[i];
                for (j = i - increment; j >= 0 && temp < nums[j]; j -= increment) {
                    nums[j + increment] = nums[j];
                }
                nums[j + increment] = temp;
            }
        }
    }
}

希尔排序时间复杂度分析:

希尔排序的关键并不是随便分组后各自排序,而是将相隔某个“增量”的记录组成一个子序列,实现跳跃式的移动,使得排序的效率提高。这里“增量”的选取就非常关键,可究竟应该选取什么样的增量才是最好,目前还是一个数学难题,迄今为止还没有人找到一种最好的增量序列。其时间复杂度为 O ( n 3 / 2 ) O(n^{3/2}) O(n3/2),要好于直接排序的 O ( n 2 ) O(n^2) O(n2)。需要注意的是,增量序列的最后一个增量值必须等于1才行。另外由于记录是跳跃式的移动,希尔排序并不是种稳定的排序算法。

由于多次插入排序,我们知道一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以希尔排序是不稳定的

\quad
\quad

堆排序(Heap Sort)

是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值(大顶堆),或者每个结点的值都小于或等于其左右孩子结点的值(小顶堆)。从堆的定义可知,根结点一定是堆中所有结点最大(小)者。

如果按照层序遍历的方式给结点从1开始编号,则结点之间满足如下关系:

{ k i ⩾ k 2 i k i ⩾ k 2 i + 1  或  { k i ⩽ k 2 i k i ⩽ k 2 i + 1 1 ⩽ i ⩽ ⌊ n 2 ⌋ \left\{\begin{array}{l} \boldsymbol{k}_{i} \geqslant \boldsymbol{k}_{2 i} \\ \boldsymbol{k}_{i} \geqslant \boldsymbol{k}_{2 i+1} \end{array} \right. \text { 或 } \left\{\begin{array}{l} \boldsymbol{k}_{i} \leqslant \boldsymbol{k}_{2 i} \\ \boldsymbol{k}_{i} \leqslant \boldsymbol{k}_{2 i+1} \end{array}\right. \quad 1 \leqslant \boldsymbol{i} \leqslant \left\lfloor\frac{\boldsymbol{n}}{2}\right\rfloor {kik2ikik2i+1  {kik2ikik2i+11i2n

一棵完全二叉树,如果 i = 1 i=1 i=1,则结点是二叉树的根,无双亲;如果 i > 1 i>1 i>1 ,则其双亲是结点 ⌊ i / 2 ⌋ \lfloor i/2\rfloor i/2


堆排序就是利用堆(假设利用大顶堆)进行排序的方法。它的基本思想是,将待排序的序列构造成一个大顶堆。此时,整个序列的最大值就是堆顶的根结点。将它移走(其实就是将其与堆数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余的 n − 1 n-1 n1个序列重新构造成一个堆,这样就会得到 n n n个元素中的次小值。如此反复执行,便能得到一个有序序列了。

堆排序动图:
在这里插入图片描述

代码实现:

整个排序过程分为两个 for循环:第一个循环要完成的就是将现在的待排序序列构建成一个大顶堆。第二个循环要完成的就是逐步将每个最大值的根结点与末尾元素交换,并且再调整其成为大顶堆。

void HeapAdjust(vector<int> &nums, int start, int end) {
    int dad = start;
    int son = dad * 2 + 1;
    while (son <= end) {
        if (son + 1 <= end && nums[son] < nums[son + 1]) {
            son++;
        }
        if (nums[dad] > nums[son]) {
            break;
        } else {
            swap(nums[dad], nums[son]);
            dad = son;
            son = dad * 2 + 1;
        }
    }
}

void HeapSort(vector<int> &nums) {
    for (int i = nums.size() / 2; i >= 0; i--) {
        HeapAdjust(nums, i, nums.size() - 1);  // 构建一个大顶堆
    }
    
    // 逐步将每个最大值的根结点与末尾元素交换,并且再调整其成为大顶堆
    for (int i = nums.size() - 1; i > 0; i--) {
        swap(nums[0], nums[i]); // 将堆顶记录和当前未经排序子序列的最后一个记录交换
        HeapAdjust(nums, 0, i - 1); // 将nums[0:i-1]重新调整为大顶堆
    }
}

堆排序时间复杂度分析:

堆排序是一种选择排序,整体主要由构建初始堆+交换堆顶元素和末尾元素并重建堆两部分组成。

在构建堆的过程中,因为我们是完全二叉树从最下层最右边的非终端结点开始构建,将它与其孩子进行比较和若有必要的互换,对于每个非终端结点来说,其实最多进行两次比较和互换操作,因此整个构建堆的时间复杂度为 O ( n ) O(n) O(n)。在正式排序时,第 i i i次取堆顶记录重建堆需要用 O ( log ⁡ i ) O(\log i) O(logi) 的时间(完全二叉树的某个结点到根结点的距离为 ⌊ log ⁡ 2 i ⌋ + 1 \lfloor \log_2 i\rfloor+1 log2i+1),并且需要取 n − 1 n-1 n1次堆顶记录,因此,重建堆的时间复杂度为 O ( n log ⁡ n ) O(n\log n) O(nlogn)

所以总体来说,堆排序的时间复杂度为 O ( n log ⁡ n ) O(n\log n) O(nlogn)。由于堆排序对原始记录的排序状态并不敏感,因此它无论是最好、最坏和平均时间复杂度均为 O ( n log ⁡ n ) O(n\log n) O(nlogn)。这在性能上要远远好过于冒泡、简单选择、直接插入的 O ( n 2 ) O(n^2) O(n2) 的时间复杂度。

空间复杂度上,它只有一个用来交换的暂存单元,也非常的不错。不过由于记录的比较与交换是跳跃式进行,因此堆排序也是一种不稳定的排序方法。另外,由于初始构建堆所需的比较次数较多,因此,它并不适合待排序序列个数较少的情况。

\quad
\quad

归并排序(Merge Sort)

归并在数据结构中的定义是将两个或两个以上的有序表组合成一个新的有序表。

归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法的一个非常典型的应用。归并排序是一种稳定的排序方法。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2-路归并。

和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是 O ( n log ⁡ n ) O(n \log n) O(nlogn)的时间复杂度。代价是需要额外的内存空间

排序动图:

在这里插入图片描述

代码实现:

// 将有序的SR[s..m]和SR[m+1..t]归并到TR[s..t]
void Merge(vector<int> &SR, vector<int> &TR, int i, int m, int n) {
    int j, k, l;
    // 将SR中的记录由小到大归并入TR中
    for (j = m + 1, k = i; i <= m && j <= n; k++) {
        if (SR[i] < SR[j]) {
            TR[k] = SR[i++];
        } else {
            TR[k] = SR[j++];
        }
    }
    if (i <= m) {
        for (l = 0; l <= m - i; l++) {
            TR[k + l] = SR[i + l]; // 将剩余的SR[i..m]复制到TR
        }
    }
    if (j <= n) {
        for (l = 0; l <= n - j; l++) {
            TR[k + l] = SR[j + l];// 将剩余的SR[j..n]复制到TR
        }
    }
}

void MSort(vector<int> &SR, vector<int> &TR1, int s, int t) {
    vector<int> TR2(SR.size(), 0);
    if (s == t) {
        TR1[s] = SR[s];
    } else {
        int m = (s + t) / 2;  // 平分SR[s..t]数组为: SR[s..m] 和 SR[m+1..t]
        MSort(SR, TR2, s, m); // 递归将SR[s..m]归并为有序TR2[s..m]
        MSort(SR, TR2, m + 1, t); // 递归将SR[m+1..t]归并为有序TR2[m+1..t]
        Merge(TR2, TR1, s, m, t); // 将有序的TR2[s..m]和TR2[m+1..t]归并到TR1[s..t]
    }
}

void MergeSort(vector<int> &nums) {
    MSort(nums, nums, 0, nums.size() - 1);
}
// 将有序的SR[s..m]和SR[m+1..t]归并到TR[s..t]
void Merge(vector<int> &SR, vector<int> &TR, int i, int m, int n) {
    int j, k, l;
    // 将SR中的记录由小到大归并入TR中
    for (j = m + 1, k = i; i <= m && j <= n; k++) {
        if (SR[i] < SR[j]) {
            TR[k] = SR[i++];
        } else {
            TR[k] = SR[j++];
        }
    }
    if (i <= m) {
        for (l = 0; l <= m - i; l++) {
            TR[k + l] = SR[i + l]; // 将剩余的SR[i..m]复制到TR
        }
    }
    if (j <= n) {
        for (l = 0; l <= n - j; l++) {
            TR[k + l] = SR[j + l];// 将剩余的SR[j..n]复制到TR
        }
    }
}

void MergePass(vector<int> &SR, vector<int> &TR, int s, int n) {
    int i = 0, j;
    while (i <= n - 2 * s + 1) {
        Merge(SR, TR, i, i + s - 1, i + 2 * s - 1); // 两两归并
        i = i + 2 * s;
    }
    if (i < n - s + 1) {  // 归并最后两个序列
        Merge(SR, TR, i, i + s - 1, n);
    } else {  // 若最后只剩下单个子序列
        for (j = i; j <= n; j++) {
            TR[j] = SR[j];
        }
    }
}

void MergeSort2(vector<int> &nums) {
    vector<int> TR(nums.size());
    int k = 1;
    while (k < nums.size()) {
        MergePass(nums, TR, k, nums.size() - 1);
        k = 2 * k;   // 子序列长度加倍
        MergePass(TR, nums, k, nums.size() - 1);
        k = 2 * k;   // 子序列长度加倍
    }
}

分析归并排序的时间复杂度:

一趟归并需要将SR[1]~SR[n]中相邻的长度为h 的有序序列进行两两归并,并将结果放到TR1[1..n]中,这需要将待排序序列中的所有记录扫描一遍,因此耗费 O ( n ) O(n) O(n)时间,而由完全二叉树的深度可知,整个归并排序需要进行 ⌊ log ⁡ 2 n ⌋ \lfloor\log_2 n\rfloor log2n次,因此,总的时间复杂度为 O ( n log ⁡ n ) O(n\log n) O(nlogn),而且这是归并排序算法中最好、最坏、平均的时间性能。

由于归并排序在归并过程中需要与原始记录序列同样数量的存储空间存放归并结果以及递归时深度为 log ⁡ 2 n \log_2 n log2n的栈空间,因此空间复杂度为 O ( n + log ⁡ n ) O(n+\log n) O(n+logn)。另外,对代码进行仔细研究,可以发现 Merge函数中有if(SR[i]<SR[j])语句,这就说明它需要两两比较,不存在跳跃,因此归并排序是一种稳定的排序算法。

也就是说,归并排序是一种比较占用内存,但却效率高且稳定的算法。

\quad
\quad

快速排序(Quick Sort)

  • 希尔排序相当于直接插入排序的升级,它们同属于插入排序类;
  • 堆排序相当于简单选择排序的升级,它们同属于选择排序类;
  • 快速排序其实就是前面认为最慢的冒泡排序的升级,它们都属于交换排序类。

原始快速排序

快排通过不断比较和移动交换来实现排序的,只不过它的实现增大了记录的比较和移动的距离,将关键字较大的记录从前面直接移动到后面,关键字较小的记录从后面直接移动到前面,从而减少了总的比较次数和移动交换次数。

快速排序的基本思想:通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。

快速排序动图:
在这里插入图片描述

代码实现:

// Partition函数要做的,就是先选取当中的一个关键字,然后将它放到一个位置,使得它左边的值都比它小,右边的值比它大
int Partition(vector<int> &nums, int low, int high) {
    int pivotKey = nums[low];  // 用子表的第一个记录作枢轴记录
    while (low < high) {  // 从表的两端交替向中间扫描
        while (low < high && nums[high] >= pivotKey) {
            high--;
        }
        swap(nums[low], nums[high]);  // 将比枢轴记录小的记录交换到低端
        while (low < high && nums[low] <= pivotKey) {
            low++;
        }
        swap(nums[low], nums[high]);  // 将比枢轴记录大的记录交换到高端
    }
    return low;  // 返回枢轴所在位置
}

void QSort(vector<int> &nums, int low, int high) {
    int pivot;
    if (low < high) {
        pivot = Partition(nums, low, high);  // 将nums[low..high]一分为二,算出枢纽轴值
        QSort(nums, low, pivot - 1);  // 对低子表递归排序
        QSort(nums, pivot + 1, high);  // 对高子表递归排序
    }
}

void QuickSort(vector<int> &nums) {
    QSort(nums, 0, nums.size() - 1);
}

快速排序时间复杂度分析:

在最优情况下,Partition每次都划分得很均匀,如果排序 n n n个关键字,其递归树的深度就为 ⌊ log ⁡ 2 n ⌋ + 1 \lfloor\log_2n\rfloor+1 log2n+1 ,即仅需递归 log ⁡ 2 n \log_2n log2n次,需要时间为 T ( n ) T(n) T(n)的话,第一次 Partiation应该是需要对整个数组扫描一遍,做 n n n次比较。然后,获得的枢轴将数组一分为二,那么各自还需要 T ( n / 2 ) T (n/2) T(n/2)的时间,于是不断划分下去,可以得到快排的时间复杂度为 O ( n log ⁡ n ) O(n\log n) O(nlogn)

在最坏的情况下,待排序的序列为正序或者逆序,每次划分只得到一个比上一次划分少一个记录的子序列,注意另一个为空。如果递归树画出来,它就是一棵斜树。此时需要执行 n − 1 n-1 n1次递归调用,且第i次划分需要经过 n − i n-i ni次关键字的比较才能找到第 i i i个记录,也就是枢轴的位置,因此比较次数为
∑ i = 1 n − 1 ( n − i ) = n − 1 + n − 2 + ⋯ + 1 = n ( n − 1 ) 2 \sum_{i=1}^{n-1}(n-i)=n-1+n-2+\cdots+1=\frac{n(n-1)}{2} i=1n1(ni)=n1+n2++1=2n(n1)
最终其时间复杂度为 O ( n 2 ) O(n^2) O(n2)

平均的情况,设枢轴的关键字应该在第 k k k的位置( 1 ≤ k ≤ n 1≤k≤n 1kn),那么:
T ( n ) = 1 n ∑ k = 1 n ( T ( k − 1 ) + T ( n − k ) ) + n = 2 n ∑ k = 1 n T ( k ) + n T(n)=\frac{1}{n} \sum_{k=1}^{n}(T(k-1)+T(n-k))+n=\frac{2}{n} \sum_{k=1}^{n} T(k)+n T(n)=n1k=1n(T(k1)+T(nk))+n=n2k=1nT(k)+n
由数学归纳法可证明,其数量级为 O ( n log ⁡ n ) O(n\log n) O(nlogn)

就空间复杂度来说,主要是递归造成的栈空间的使用,最好情况,递归树的深度为 log ⁡ 2 n \log_2n log2n,其空间复杂度也就为 O ( log ⁡ n ) O(\log n) O(logn),最坏情况,需要进行 n − 1 n-1 n1递归调用,其空间复杂度为 O ( n ) O(n) O(n),平均情况,空间复杂度也为 O ( log ⁡ n ) O(\log n) O(logn)

由于关键字的比较和交换是跳跃进行的,因此,快速排序是一种不稳定的排序方法。

\quad


快速排序优化

上述叙述的快排其实存在很多问题,下面列举两个优化。

优化选取枢轴

从前面的讲解可以很容易看出,枢轴的选取决定了快排的效率。也就是说,枢轴的选取变成了快排潜在的性能瓶颈。前面的讲解中,我们固定的选取第一个关键字作为枢轴,这其实是不合理的做法。

首先有人提出随机获取一个lowhigh之间的数rnd,让nums[rnd]nums[low]交换,这被称为随机选取枢轴法。这在某种程度上,解决了对于基本有序的序列快速排序时的性能瓶颈。但是由于随机性的存在,仍然不是很有效的选取到合理的枢轴。

再改进,有了三数取中法,即取三个关键字先进行排序,将中间数作为枢轴。一般是取左端右端中间三个数。这样至少这个中间数一定不会是最小或者最大的数,从概率来说,取三个数均为最小或最大数的可能性是微乎其微的,因此中间数位于较为中间的值的可能性就大大提高了。

int Partition(vector<int> &nums, int low, int high) {    
    int pivotKey;
    
    // -------------------------------------------------------------------
    int m = low + (high - low) / 2;
    if (nums[low] > nums[high]) {  // 交换左端与右端数据,保证左端数据较小
        swap(nums[low], nums[high]);
    }
    if (nums[low] > nums[m]) {    // 交换左端与中间数据,保证左端数据较小
        swap(nums[low], nums[m]);
    }
    if (nums[m] > nums[high]) {  // 交换右端与中间数据,保证中间数据较小
        swap(nums[m], nums[high]);
    }
    // -------------------------------------------------------------------

    pivotKey = nums[low];  
    while (low < high) {  // 从表的两端交替向中间扫描
        while (low < high && nums[high] >= pivotKey) {
            high--;
        }
        swap(nums[low], nums[high]);  // 将比枢轴记录小的记录交换到低端
        while (low < high && nums[low] <= pivotKey) {
            low++;
        }
        swap(nums[low], nums[high]);  // 将比枢轴记录大的记录交换到高端
    }
    return low;  // 返回枢轴所在位置
}

void QSort(vector<int> &nums, int low, int high) {
    int pivot;

    if (low < high) {
        pivot = Partition(nums, low, high);  // 将nums[low..high]一分为二,算出枢纽轴值
        QSort(nums, low, pivot - 1);  // 对低子表递归排序
        QSort(nums, pivot + 1, high);  // 对高子表递归排序
    }
}

void QuickSort(vector<int> &nums) {
    QSort(nums, 0, nums.size() - 1);
}

三数取中对小数组来说有很大的概率选择到一个比较好的 pivotkey,但是对于非常大的待排序的序列来说还是不足以保证能够选择出一个好的pivotkey,因此还有个办法是所谓九数取中(median-of-nine),它先从数组中分三次取样,每次取三个数,三个样品各取出中数,然后从这三个中数当中再取出一个中数作为枢轴。显然这就更加保证了取到的pivotkey是比较接近中间值的关键字。这里不再过多叙述了。

优化不必要的交换

仔细看排序过程就可以看出很多交换是不必要的。这里可以做一定的改进来减少不必要的交换。

int Partition(vector<int> &nums, int low, int high) {
    int pivotKey;

    int m = low + (high - low) / 2;
    if (nums[low] > nums[high]) {  // 交换左端与右端数据,保证左端数据较小
        swap(nums[low], nums[high]);
    }
    if (nums[low] > nums[m]) {    // 交换左端与中间数据,保证左端数据较小
        swap(nums[low], nums[m]);
    }
    if (nums[m] > nums[high]) {  // 交换右端与中间数据,保证中间数据较小
        swap(nums[m], nums[high]);
    }

    pivotKey = nums[low];
    int tmp = pivotKey;   // 备份枢轴关键字
    while (low < high) {  // 从表的两端交替向中间扫描
        while (low < high && nums[high] >= pivotKey) {
            high--;
        }
        nums[low] = nums[high];  // 采用替换方式操作
        while (low < high && nums[low] <= pivotKey) {
            low++;
        }
        nums[high] = nums[low];  // 采用替换方式操作
    }
    nums[low] = tmp;
    return low;  // 返回枢轴所在位置
}

void QSort(vector<int> &nums, int low, int high) {
    int pivot;
    if (low < high) {
        pivot = Partition(nums, low, high);  // 将nums[low..high]一分为二,算出枢纽轴值
        QSort(nums, low, pivot - 1);  // 对低子表递归排序
        QSort(nums, pivot + 1, high);  // 对高子表递归排序
    }
}

void QuickSort(vector<int> &nums) {
    QSort(nums, 0, nums.size() - 1);
}

\quad
\quad

代码链接

这几个算法的代码均已整理到gitee上了:

SortAlgorithm

\quad

参考

  • 3
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

奋斗的西瓜瓜

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值