[数据结构与算法系列]排序算法(二)

c/c++

浏览数:140

2020-6-15

我的上一篇文章向大家介绍了排序算法中的冒泡排序、插入排序和选择排序。它们都是平均时间复杂度为
O(n^2) 的排序算法,同时还为大家讲解了什么是原地排序和什么是排序的稳定性。下图是这三种算法的比较,不熟悉的同学也可以点击上一篇文章(链接)回顾一下。

由于它们的时间复杂度都比较高,所以在处理大规模数据的时候这三种算法就不那么适用了。接下我将为大家介绍另外两种更高效的排序算法——归并排序和快速排序。

声明:在下面所有的算法讲解中,我们默认我们需要对待处理数组进行升序排序,即排序好的数组中,左边的元素要小于右边的元素。

1.分治思想(Divide and Conquer)

在计算机科学中,分治法是基于多项分支递归的一种重要的算法思想。从名字可以看出,“分治”也就是“分而治之”的意思,就是把一个复杂的问题分成两个或多个相同或类似的子问题,直到子问题可以简单直接地解决,原问题的解即为子问题的合并。

分治算法一般是用递归来实现的,具体的分治算法可以按照下面三个步骤来解决问题:

  1. 分解: 将原问题分解为若干个规模较小,相对独立,与原问题形式相同的子问题。
  2. 解决: 若子问题规模较小且易于解决时,则直接解。否则,递归地解决各子问题。
  3. 合并: 将各子问题的解合并为原问题的解。

2.算法介绍

Ⅰ.归并排序(Merge Sort)

该算法是利用分治思想解决问题的一个非常典型的应用,归并排序的基本思路就是先把数组一分为二,然后分别把左右数组排好序,再将排好序的左右两个数组合并成一个新的数组,最后整个数组就是有序的了。

运用递归法归并排序的主要步骤:

  1. 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列
  2. 设定两个指针,最初位置分别为两个已经排序序列的起始位置

比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置

  1. 重复步骤3直到某一指针到达序列尾

将另一序列剩下的所有元素直接复制到合并序列尾

  • 归并算法图解如下:

  • 归并排序算法实现代码:
#include<iostream>
#include<vector>

using namespace std;

void Merge(vector<int>& , int , int , int );
void MergeSort(vector<int>& , int , int );

int main() {
    vector<int> test = { 3, 7, 6, 4, 5, 1, 2, 8 };
    MergeSort(test,0,test.size()-1);

    for (auto x : test)
        cout << x << " ";

    return 0;
}


void Merge(vector<int>& arr, int left, int mid, int right) {
    int i = left;
    int j = mid + 1;
    int k = 0;
    vector<int> temp(right - left + 1);
    while (i <= mid && j <= right)
        temp[k++] = arr[i] <= arr[j] ? arr[i++] : arr[j++];

    while (i <= mid)
        temp[k++] = arr[i++];

    while (j <= right)
        temp[k++] = arr[j++];

    for (int m = 0; m < temp.size(); m++)
        arr[left + m] = temp[m];
}


void MergeSort(vector<int>& arr,int left, int right) {
    if (left >= right) return;

    int mid = left + (right - left) / 2;
    MergeSort(arr, left, mid);
    MergeSort(arr, mid + 1, right);
    Merge(arr, left, mid, right);
}

代码分析:

归并排序中需要用到两个函数,一个是MergeSort函数,一个是Merge函数。MergeSort函数的作用是把数组中left至right的元素全部排列好。而Merge函数的作用是把左右两个已经排序好的数组合并成一个数组。

  • Merge函数的编写非常重要,我们需要创建一个新的数组temp,数组大小为right-left+1.然后定义两个下标ij, 其中i=left, j=mid+1,i表示第一个数组的起始位置,j表示第二个数组的起始位置。同时还需要一个下标k来标记temp数组中填入元素的位置。

    开始遍历两个数组,比较ij所指元素的大小,将较小者放入temp数组中,同时脚标和k向后移动。当其中一个子数组循环完后,将剩下数组中的元素依次放入temp数组中。

    最终,将temp数组拷贝回原数组array,返回的数组就是经过归并排序好的数组了。

  • MeergeSort函数主要是用于递归调用。当right>=left时,就直接return。否则,找到数组的中间下标,将数组一分为二,分别两边两边数组进行归并排序,最后将两个数组用Merge函数合并起来。

算法分析:

  • 归并排序的时间复杂度?

归并排序的递推公式为T(n)=2*T(n/2)+n

该递归式表明,对n个元素递归排序所需时间复杂度,等于左右子区间n/2个元素分别递归排序的时间,加上将两个已排好的子区间合并起来的时间O(n)

当递归循环至最后一层时,即n=1时,T(1)=1,于是可以推导出归并排序的时间复杂度为O(nlongn)

  • 归并排序是原地排序吗?

从原理中可以看出,在归并排序过程中我们需要分配临时数组temp,所以不是原地排序算法,空间复杂度为O(n).

  • 归并排序是稳定的排序算法吗?

当我们遇到左右数组中的元素相同时,我们可以先把左边的元素放入temp数组中,再放入右边数组的元素,这样就保证了相同元素的前后顺序不发生改变。所以,归并排序是一个稳定的排序算法。

Ⅱ.快速排序(Quicksort)

快速排序,也就是我们常说的“快排”。其实,快排也是利用的分治思想。它具体的做法是在数组中取一个基准pivotpivot位置可以随机选择(一般我们选择数组中的最后一个元素)。选择完pivot之后,将小于pivot的所有元素放在pivot左边,将大于pivot的所有元素放在右边。最终,pivot左侧元素都将小于右侧元素。接下来我们依次把左侧的子数组和右侧子数组进行快速排序。如果左右两侧的数组都是有序的话,那么我们的整个数组就处于有序的状态了。

快速排序的主要步骤为:

  1. 挑选基准值:从数组中挑出一个元素,称为“基准”(pivot
  2. 分割:重新排序数组,所有比pivot小的元素摆放在pivot前面,所有比pivot值大的元素摆在pivot后面(与pivot值相等的数可以到任何一边)。
  3. 递归排序子数组:递归地将小于pivot元素的子序列和大于pivot元素的子序列排序。
  4. 递归到最底部的判断条件是数列的大小是零或一,此时该数列显然已经有序。
  • 快速排序图解如下:

  • 快速排序实现代码
#include<iostream>
#include<vector>

using namespace std;

int partition(vector<int>& , int , int );
void QuickSort(vector<int>& , int , int );

int main() {
    vector<int> test = { 3, 7, 6, 4, 5, 1, 2, 8 };
    QuickSort(test,0,test.size()-1);

    for (auto x : test)
        cout << x << " ";

    return 0;
}


int partition(vector<int>& arr, int left, int right) {
    int pivot = right;
    int location = left;
    for (int i = left; i < right; i++) {
        if (arr[i] < arr[pivot]) {
            int temp = arr[i]; arr[i] = arr[location]; arr[location] = temp;
            location++;
        }
    }
    int temp = arr[pivot]; arr[pivot] = arr[location]; arr[location] = temp;
    return location;
}


void QuickSort(vector<int>& arr,int left, int right) {
    if (left >= right) return;

    int pivot = partition(arr, left, right);
    QuickSort(arr, left, pivot-1);
    QuickSort(arr, pivot + 1, right);
}

代码分析

快速排序算法中有两个函数,QuickSort函数和partition函数。partition函数的作用返回pivot下标,意思是此时,所有在pivot左侧的元素都比pivot的值小,在右侧的值比pivot大。接下来对左右两侧的数组递归调用QuickSort函数进行快排。

我们每次指定pivot指向最后一个元素,同时定义一个变量location,用来标记pivot最后应该置于的位置。在location左侧的所有元素都是比pivot值小的,从location开始,右侧所有元素都比pivot大。

只要遍历到的元素比pivot的值小,就与location所指的元素进行交换,同时location加一,更新pivot应该在的位置。

数组遍历结束,最后元素pivotlocation所指元素进行交换,这样,pivot左侧的元素就全部比pivot小,右侧元素全部比pivot大了。

算法分析:

  • 快速排序的时间复杂度?

    快排的时间复杂度也可以像归并排序那样用递推公式计算出来。如果每次分区都刚好把数组分成两个大小一样的区间,那么它的时间复杂度也为O(nlogn).但是如果遇到最坏情况下,该算法可能退化成O(n^2).

  • 快速排序是原地排序吗?

    根据原理可以知道,快速排序没有额外的内存消耗,故是一种原地排序算法。

  • 快速排序是稳定的排序算法吗?

    因为分区操作涉及元素之间的交换,例如下图,当遍历到第一个小于2的元素1时,会交换1与前面的3,因此两个相等3的顺序就发生了改变。所以快速排序不是一个稳定的排序算法。

3.总结回顾

本文介绍的两个排序算法归并排序和快速排序的平均时间复杂度都为O(nlogn),但是他们的思路有所不同。

归并排序:先排序左右子数组,然后合并两个子数组成有序数组。

快速排序:先通过pivot找出左右子数组,然后对左右子数组递归调用快速排序。

下图为本文总结

未完待续……

如果喜欢我的文章,欢迎扫描下方二维码我的注公众号《小R在编程》了解更多算法知识以及LeetCode题解思路!

作者:Roger