算法学习 | 青训营笔记

85 阅读17分钟

排序算法对大家来说肯定都不陌生吧,作为最基础且最重要的算法之一,在面试中经典排序算法也经常被要求手撕代码。可是排序算法实在是太多了(见下图),有些名字听起来都莫名其妙的,比如鸡尾酒排序,侏儒排序,煎饼排序等。preview

预备知识

在正式开始讲解各种排序算法之前,我还希望大家思考一个问题。什么样的排序算法才是一个好的算法,各种各样的排序算法它们的应用场景又有什么不同?希望大家在读完这篇文章之后能够有一个答案。

其实,要想真正学好排序算法,我们要做的不仅仅是了解它的算法原理,然后背下代码就完事。更重要的是,我们要学会去分析和评价一个排序算法。那么对于这么多的排序算法,我们应该关注它们的哪些方面呢?

1.排序算法的时间复杂度

分析一个算法的好坏,第一个当然就是应该分析该算法的时间复杂度。排序算法需要对一组数据进行排序,在实际的工程中,数据的规模可能是10个、100个,也可能是成千上万个。同时,对于要进行排序处理的数据,可能是接近有序的,也可能是完全无序的。因此,在分析其时间复杂度时,我们不仅要考虑平均情况下的时间复杂度,还要分析它在最好情况以及最坏情况下代码的执行效率有何差异。

对于一个常见的排序算法来说,执行过程中往往会涉及两个操作步骤,一个是进行元素的比较,二是对元素进行交换或者移动。所以在分析排序算法的时间复杂度时,也要特别注意算法实现过程中不同的元素比较交换(或移动)的次数。

2.排序算法的空间复杂度

这里需要引入一个新的概念,原地排序。原地排序就是指在排序过程中不必申请额外的存储空间,只利用原来存储待排数据的存储空间进行比较和排序的排序算法。换句话说,原地排序不会产生多余的内存消耗。

3.排序算法的稳定性

对于一般的算法,我们一般只需要分析它的时间复杂度空间复杂度,但是对于排序算法来说,我们还有一个非常重要的分析指标,那就是排序算法的稳定性

稳定性是指,在需要进行排序操作的数据中,如果存在值相等的元素,在排序前后,相等元素之间的排列顺序不发生改变。

大家可能会想,反正都是相等的元素,通过排序后谁在前谁在后有什么不一样呢?对排序算法进行稳定性分析又有什么实际意义呢?

其实,在学习数据结构与算法的过程中,我们解决的问题基本上都是对简单的数字进行排序。这时,我们考虑其是否稳定似乎并没有什么意义。

但是在实际应用中,我们面对的数据对象往往都是复杂的,每个对象可能具有多个数字属性且每个数字属性的排序都是有意义的。所以在排列时,我们需要关注每个数字属性的排序是否会对其他属性进行干扰。

举个例子,假如我们要给大学中的学生进行一个排序。每个学生都有两个数字属性,一个是学生所在年级,另一个是学生的年龄,最终我们希望按照学生年龄大小进行排序。而对于年龄相同的同学,我们希望按照年级从低到高的顺序排序。那么要满足这样的需求,我们应该怎么做呢?

第一个想到的,当然就是先对学生的年龄进行排序,然后再在相同年龄的区间里对年级进行排序。这种办法很直观且似乎没什么问题,但是仔细一想,会发现如果我们要进行一次完整的排序,我们需要采用5次排序算法(按年龄排序1次,四个年级分别排序4次)。那么我们有没有更好地解决办法呢?

如果我们利用具有稳定性的排序算法,这个问题就会更好地解决了。我们先按照年级对学生进行排序,然后利用稳定的排序算法,按年龄进行排序。这样,只需要运用两次排序,我们就完成了我们的目的。

这是因为,稳定的排序算法能够保证在排序过程中,相同年龄的同学,在排序之后,他们的顺序不发生改变。由于第一次我们已经将学生按年级排序好了,于是在第二次排序时,我们运用稳定的排序算法,相同年龄的学生依旧按年级保持顺序。

了解如何分析排序算法后,接下来就可以开始下面各种排序算法的学习了。

算法介绍

1.插入排序(Insertion Sort)

在讲解插入排序之前,我们先来回顾一下,在一个有序数组中,我们是如何插入一个新的元素并使数组保持有序的呢?

我们需要遍历整个数组,直到找到该元素应该插入的位置,然后将后面相应的元素往后移动,最后插入我们的目标元素。(插入过程如下图)

插入排序其实就是借助这样的思想,首先我们将数组中的数据分为两个区间,一个是已排序区间,另一个是未排序区间,同时这两个区间都是动态的。开始时,假设最左侧的元素已被排序,即为已排序区间,每一次将未排序区间的首个数据放入排序好的区间中,直达未排序空间为空。

  • 插入排序算法图解如下:

  • 插入排序代码实现:

    #include #include

    using namespace std;

    void InsertionSort(vector&, int);

    int main() { vector test = { 3, 7, 6, 4, 5, 1, 2, 8 }; InsertionSort(test, test.size());

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

    return 0; }

    void InsertionSort(vector& arr, int len) { for (int i = 1; i < len; ++i) { //注意i从1开始 int key = arr[i]; //需要插入的元素
    int j = i - 1; //已排序区间 while ((j >= 0) && (arr[j] > key)) { arr[j + 1] = arr[j]; //元素向后移动 j--; } arr[j + 1] = key; } }

  • 算法分析:

插入排序的时间复杂度?

最好情况: 即该数据已经有序,我们不需要移动任何元素。于是我们需要从头到尾遍历整个数组中的元素O(n).

最坏情况: 即数组中的元素刚好是倒序的,每次插入时都需要和已排序区间中所有元素进行比较,并移动元素。因此最坏情况下的时间复杂度是O(n^2).

平均时间复杂度:类似我们在一个数组中插入一个元素那样,该算法的平均时间复杂度为O(n^2).

插入排序是原地排序吗?

从插入排序的原理中可以看出,在排序过程中并不需要额外的内存消耗,也就是说,插入排序是一个原地排序算法

插入排序是稳定的排序算法吗?

其实,我们在插入的过程中,如果遇到相同的元素,我们可以选择将其插入到之前元素的前面也可以选择插入到后面。所以,插入排序可以是稳定的也可能是不稳定的。

2.选择排序(Selection Sort)

选择排序和插入排序类似,也将数组分为已排序未排序两个区间。但是在选择排序的实现过程中,不会发生元素的移动,而是直接进行元素的交换

选择排序的实现过程: 在不断未排序的区间中找到最小的元素,将其放入已排序区间的尾部

  • 选择排序方法比较直观,图解如下:

  • 选择排序代码实现

    #include #include

    using namespace std;

    void SelectionSort(vector&);

    int main() { vector test = { 3, 7, 6, 4, 5, 1, 2, 8 }; SelectionSort(test);

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

    return 0; }

    void SelectionSort(vector& arr) { for (int i = 0; i < arr.size()-1; i++) { int min = i; for (int j = i + 1; j < arr.size(); j++) if (arr[j] < arr[min]) min = j;

    swap(arr[i], arr[min]);
    

    } }

  • 算法分析:

选择排序的时间复杂度?

最好情况最坏情况:都需要遍历未排序区间,找到最小元素。所以都为O(n^2).因此,平均复杂度也为O(n^2).

选择排序是原地排序吗?

与插入排序一样,选择排序没有额外的内存消耗,为原地排序算法

插入排序是稳定的排序算法吗?

答案是否定的,因为每次都要在未排序区间找到最小的值和前面的元素进行交换,这样如果遇到相同的元素,会使他们的顺序发生交换

比如下图的这组数据,使用选择排序算法来排序的话,第一次找到最小元素1,与第一个2交换位置,那前面的2和后面的2顺序就变了,所以就不稳定了。

3.冒泡排序(Bubble Sort)

冒泡排序和插入排序和选择排序不太一样。冒泡排序每次只对相邻两个元素进行操作。每次冒泡操作,都会比较相邻两个元素的大小,若不满足排序要求,就将它俩交换。每一次冒泡,会将一个元素移动到它相应的位置,该元素就是未排序元素中最大的元素。

  • 将数组元素倒置,第一次冒泡图解如下:

  • 冒泡排序整个过程图解如下:

  • 冒泡排序代码实现:

    #include #include

    using namespace std;

    void BubbleSort(vector&);

    int main() { vector test = { 3, 7, 6, 4, 5, 1, 2, 8 }; BubbleSort(test);

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

    return 0; }

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

  • 冒泡排序的递归实现

如果我们仔细观察冒泡排序算法,我们会注意到在第一次冒泡中,我们已经将最大的元素移到末尾。在第二次冒泡中,我们将第二大元素移至倒数第二个位置,然后以此类推,所以很容易想到利用递归来实现冒泡排序。

递归思路:

  1. 如果数组大小为1,则直接返回。

  2. 每进行一次冒泡排序,可修复当前数组的最后一个元素。

  3. 每完成一次冒泡操作,对剩下未排序所有元素进行递归冒泡。

  4. 冒泡排序递归代码实现:

    #include #include

    using namespace std;

    void Recursive_BubbleSort(vector&, int);

    int main() { vector test = { 3, 7, 6, 4, 5, 1, 2, 8 }; Recursive_BubbleSort(test,test.size());

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

    return 0; }

    void Recursive_BubbleSort(vector& arr, int n) { if (n == 1) return;

    for (int i = 0; i < arr.size() - 1; i++) { if (arr[i] > arr[i + 1]) swap(arr[i], arr[i + 1]); }

    Recursive_BubbleSort(arr, n - 1); }

  • 算法分析:

冒泡排序的时间复杂度?

最好情况:我们只需要进行一次冒泡操作,没有任何元素发生交换,此时就可以结束程序,所以最好情况时间复杂度是O(n).

最坏情况: 要排序的数据完全倒序排列的,我们需要进行n次冒泡操作,每次冒泡时间复杂度为O(n),所以最坏情况时间复杂度为O(n^2)

平均复杂度O(n^2)

冒泡排序是原地排序吗?

冒泡的过程只涉及相邻数据之间的交换操作而没有额外的内存消耗,故冒泡排序为原地排序算法

冒泡排序是稳定的排序算法吗?

在冒泡排序的过程中,只有每一次冒泡操作才会交换两个元素的顺序。所以我们为了冒泡排序的稳定性,在元素相等的情况下,我们不予交换,此时冒泡排序即为稳定的排序算法

接下来将为大家介绍两种最重要同时也最常用的排序算法,大家一定要提起精神认真看了,这可是10次面试9次都会问到的排序算法。但是在介绍这两种排序算法之前还需要给大家讲一讲什么是分治思想

分治思想(Divide and Conquer)

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

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

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

算法介绍

4.归并排序(Merge Sort)

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

运用递归法实现归并操作的主要步骤:

  1. 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列。
  2. 设定两个指针,最初位置分别为两个已经排序序列的起始位置。
  3. 比较两个指针所指向的元素,选择较小的元素放入到合并空间,并将指针移动到下一位置
  4. 重复步骤3直到某一指针到达序列尾,然后将另一序列剩下的所有元素直接复制到合并序列尾
  • 归并算法图解如下:

  • 归并排序算法实现代码:

    #include #include

    using namespace std;

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

    int main() { vector 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& arr, int left, int mid, int right) { int i = left; int j = mid + 1; int k = 0; vector 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& 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函数的作用是把数组中leftright的元素全部排列好。而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数组中,再放入右边数组的元素,这样就保证了相同元素的前后顺序不发生改变。所以,归并排序是一个稳定的排序算法。

5.快速排序(Quicksort)

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

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

  • 快速排序图解如下:

  • 快速排序实现代码

    #include #include

    using namespace std;

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

    int main() { vector 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& 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& 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的顺序就发生了改变。所以快速排序不是一个稳定的排序算法。

上面讲的5种排序算法过程都是基于元素之间的比较和交换,所以我们常常把这种排序算法称为比较类排序。同时上面介绍的5种排序算法的时间复杂度最快也只能达到O(nlogn),所以我们也称这类排序算法称为非线性时间比较类排序
接下将介绍另外几种线性时间非比较类排序,虽然这几种排序算法似乎效率更高,但是经过下面的介绍你就会发现,它们也并不是万能的。