现在对十大常用的排序算法做一个总结,便于记忆。
十大排序算法分别是:冒泡排序、快速排序、插入排序、希尔排序、选择排序、堆排序、归并排序、计数排序、基数排序、桶排序,如下图所示:
注:
稳定性:相等的两个数,排序后的相对位置和排序前一样。比如原数组中有三个数abA,如果a=A=1,b=2。排序后的结果是aAb,那就是稳定排序,如果排序结果是Aab那就是不稳定排序。
原地性:不重新申请数组,只在原数组上进行比较和交换。
其中前面七种排序是基于比较的排序,后面三种是非比较的排序,或者称为基于统计的排序,这里主要介绍前面七种基于比较的排序。这七种排序方法中,有三种基本的排序方法,分别是冒泡排序(基于交换)、插入排序、选择排序。还有在这三种基本排序方法的基础之上进行排序的方法(改进的方法),分别是快速排序、希尔排序、堆排序,然后归并排序可以分为特殊的一类。
记忆小技巧:
时间复杂度只记四种改进的算法(O(nlogn)):快速、希尔、堆、归并。其他三种都是O(n2),毕竟改进嘛,改的就是时间复杂度。但是可以发现改进后的算法要么不稳定,要么非原地,所以想要快,就要牺牲其他方面。
空间复杂度只记两种不是O(1)的:快速(O(logn))和归并(O(n+logn))。
稳定性只记四种不稳定的算法:快速、希尔、选择、堆。
原地性只记一种非原地算法:归并排序。
几个小问题:
快速排序是原地算法,为什么空间复杂度是O(logn)?
因为快速排序常用递归的方法实现,递归需要系统的栈空间。如果用迭代的方法实现,那就是O(1)空间复杂度。
堆排序也要用递归实现,为什么空间复杂度是O(1)?
和快速排序一样,用迭代的方法实现就是O(1)的,用递归方法实现就是O(logn)的。
下面就这七种排序算法,详细讲一下各自的原理、代码实现(C++)、时间复杂度、空间复杂度、稳定性和原地性,代码都经过测试,有不对的地方欢迎评论。
冒泡排序
原理
冒泡排序是一种交换排序,每一轮通过与相邻的元素交换,将最大值换到当前元素遍历过的元素的末端。(除了前面第一张图,以下动图和图片来源于网络,感谢大佬制作的动态,真的非常生动形象)
代码实现
void bullleSort(vector<int>& nums) {
//i表示轮次,j表示每轮元素的下标
for (int i = 0; i < nums.size() - 1; i++) {
for (int j = 0; j < nums.size() - 1 - i; j++) {
if (nums[j] > nums[j + 1]) {
swap(nums[j], nums[j + 1]);
}
}
}
}
改进1:设置标记位,如果一遍内循环后没有发生交换,说明数组已经排好序,直接返回。
void bullleSort(vector<int>& nums) {
//i表示轮次,j表示每轮元素的下标
for (int i = 0; i < nums.size() - 1; i++) {
bool flag = true;
for (int j = 0; j < nums.size() - 1 - i; j++) {
if (nums[j] > nums[j + 1]) {
swap(nums[j], nums[j + 1]);
flag = false;
}
}
if (flag) return ;
}
}
改进2:记录某个内循环最后一次发生数据交换的位置,该位置后面的数肯定已经排好序,下一次遍历到该位置即可。
void bullleSort(vector<int>& nums) {
int last_index = nums.size() - 1; //记录内循环最后一次发生数据交换的位置
//i表示轮次,j表示每轮元素的下标
for (int i = 0; i < nums.size() - 1; i++) {
bool flag = true;
int temp_index = last_index; //每次发生数据交换时更新
for (int j = 0; j < last_index; j++) {
if (nums[j] > nums[j + 1]) {
swap(nums[j], nums[j + 1]);
flag = false;
temp_index = j + 1;
}
}
last_index = temp_index;
if (flag) return;
}
}
性能分析
时间复杂度:平均O(n2),最佳O(n),最差O(n2)
空间复杂度:O(1)
稳定性:稳定
原地性:交换排序,原地
快速排序
原理
快速排序也是交换排序,主要是利用partition函数找到一个基准(pivot),使比pivot小的元素在其左边部分,比pivot大的元素在其右边部分,这样相当于把pivot放到了正确的位置。然后再利用partition函数分别对左右两部分元素进行处理,直到处理的部分长度为1。
代码实现
int partition(vector<int>& nums, int left, int right) {
int pivot = nums[left]; //一般取第一个元素
int i = left, j = right + 1; //i=left,j=right,因为下面while里面是++i,--j,即使得第一次循环的双指针分别是left+1和right
while (true) {
while (nums[++i] < pivot && i < right);
while (nums[--j] > pivot && j > left);
if (i >= j) break;
//到这里说明,nums[i]>=pivot并且nums[j]<=pivot,需要把nums[i]放到左边,把nums[j]放到右边部分,通过交换实现
swap(nums[i], nums[j]);
}
//这里要把pivot放到正确的位置,与nums[j]交换是因为,到这里说明i>=j,此时nums[j]<=pivot
swap(nums[left], nums[j]);
return j;
}
void quickSort(vector<int>& nums, int left, int right) {
if (left >= right) return;
int index = partition(nums, left, right);
quickSort(nums, left, index - 1);
quickSort(nums, index + 1, right);
}
int main() {
vector<int> arr = {1, 4, 5, 6, 0, 4, 5, 56, 333, 4, 6, 9, 53};
quickSort(arr, 0, arr.size()-1);
for (auto a : arr) {
cout << a << endl;
}
return 0;
}
改进:递归改迭代。有时间再写
性能分析
时间复杂度:平均O(nlogn),最佳O(nlogn),最差O(n2)
空间复杂度:O(logn),递归使用栈空间
稳定性:不稳定,比如3141’,这里1’表示和前面一个1区分,swap(nums[i], nums[j])后变成311’4,swap(nums[left], nums[j])后变成1’134,这就不稳定了。
原地性:原地
插入排序
原理
插入排序有点像打扑克牌时的理牌过程,从左开始依次处理每张牌,把这张牌插到前面正确的位置上,只不过代码实现上使用移位来实现。插入排序适用于数据量小的情况,特别是基本上排好序的情况。
代码实现
void insertSort(vector<int>& nums) {
//一个for循环
for (int i = 1; i < nums.size(); i++) {
int pre_index = i - 1, temp = nums[i];
while (pre_index >= 0 && nums[pre_index] > temp) {
nums[pre_index + 1] = nums[pre_index]; //后移
--pre_index;
}
//pre_index+1是因为,pre_index < 0 或者 nums[pre_index]<=temp,要往后一位插入
nums[pre_index+1] = temp;
}
}
改进1:在往前找合适的插入位置时使用二分查找,找到后仍然要挨个移动元素,提升不明显。
改进2:希尔排序。
性能分析
时间复杂度:平均O(n2),最佳O(n),最差O(n2)
空间复杂度:O(1)
稳定性:稳定
原地性:原地
希尔排序
原理
希尔排序就是分组插入排序,又称递减增量排序算法。因为插入排序对数据量小、几乎已经排好序的数组排序时,效率很高,所以可以将整个数组,按某个间隔分割成若干个子序列,先对这些数据量小的子序列排序,然后减小间隔继续排序,等间隔为1时整个数组基本有序,最后对所有元素进行一次直接插入排序即可。(帅地的图)
代码实现
void shellSort(vector<int>& nums) {
//在插入排序外面再嵌套一个for循环
for (int gap = nums.size() / 2; gap > 0; gap /= 2) {
//插入排序的for循环
for (int i = gap; i < nums.size(); i++) {
int pre_index = i - gap, temp = nums[i];
while (pre_index >= 0 && nums[pre_index] > temp) {
nums[pre_index + 1] = nums[pre_index]; //后移
pre_index -= gap;
}
nums[pre_index + gap] = temp;
}
}
}
可以发现,插入排序不过是希尔排序在的gap=1的情况下的排序算法,所以只要在插入排序外面再嵌套一个for循环,把1换成gap,就可以了,希尔排序还有另外一种写法,不过我比较喜欢这种,毕竟记一种就相当于记两种排序算法了。
性能分析
时间复杂度:平均O(nlogn),最佳O(n),最差O(n2)
空间复杂度:O(1)
稳定性:不稳定,相等的数可能被分到不同组,结果可能不稳定。
原地性:原地
选择排序
原理
选择排序是在未排序数组中找到最小的元素(或者最大的元素),将其放在数组的起始位置,然后继续从剩下的未排序数组中找到最小的元素,放在已排序序列的末尾,直到排序完成。选择排序元素的移动次数比较少。
黄色表示已排序序列,红色表示当前最小的元素。
代码实现
void seleceSort(vector<int>& nums) {
//未排序序列第一个元素下标为i
for (int i = 0; i < nums.size()-1; i++) {
int min_index = i; //开始找最小元素前,保存未排序序列第一个元素的下标
for (int j = i+1; j < nums.size(); j++) {
if (nums[j] < nums[min_index]) {
min_index = j;
}
}
swap(nums[i], nums[min_index]);
}
}
改进1:一趟排序同时找到最小值和最大值,分别放到已排序序列的前段的末端,后段的前端。
void seleceSort(vector<int>& nums) {
//未排序序列第一个元素下标为i
for (int i = 0; i < nums.size()-1; i++) {
int min_index = i; //开始找最小元素前,保存未排序序列第一个元素的下标
int max_index = nums.size() - 1 - i; //开始找最大元素前,保存未排序序列最后一个元素的下标
//这里注意j的遍历范围要缩小到nums.size()-i
for (int j = i+1; j < nums.size() - i; j++) {
if (nums[j] < nums[min_index]) {
min_index = j;
}
if (nums[j] > nums[max_index]) {
max_index = j;
}
}
swap(nums[i], nums[min_index]);
swap(nums[nums.size() - 1 - i], nums[max_index]);
}
}
改进2:堆排序
性能分析
时间复杂度:平均O(n2),最佳O(n2),最差O(n2)
空间复杂度:O(1)
稳定性:不稳定,两个相等的元素为最小元素时,找到的最小元素是后面那个元素,进行交换后,后面的元素就跑到前面去了。
原地性:原地
堆排序
原理
堆排序也是找到最大/最小元素,把它放到已排序序列的前端/末端,只不过这里找最大/最小元素是用大顶堆/小顶堆来实现的。
最大堆:每个结点的值都大于或等于其子结点的值。
最小堆:每个结点的值都小于或等于其子结点的值。
堆一般指二叉堆,是一个完全二叉树,即树上的结点从上到下,从左到右依次排列。由于完全二叉树的这个特性,从上到下、从左到右遍历,得到的元素都是连续的,所以一个完全二叉树可以用一个数组来表示,并且有如下特性:
数组起始下标为0时,对于一个下标为i的结点,其父结点为(i-1)/2,左右子结点分别为2i+1、2i+2。
堆排序的过程:建堆->堆排序->调整堆->堆排序->调整堆->堆排序->…。
推荐B站视频:堆排序.可以多看几遍。
代码实现
别看代码长,理解了堆排序的过程,代码就很好理解。
//对nums[0]-nums[sublen-1]进行堆调整,i表示从下标为i的结点开始堆调整(最大堆)
void heapify(vector<int>& nums, int sublen, int i) {
//求结点i的左右子结点的下标
int left = 2 * i + 1, right = 2 * i + 2;
int largest = i; // 结点i及其左右子结点最大值的下标
//求最大值下标,注意左右子结点的约束,可能没有左右子结点
if (left < sublen && nums[left] > nums[largest]) largest = left;
if (right < sublen && nums[right] > nums[largest]) largest = right;
//如果最大值不是根结点,就需要调整堆,把最大值交换到根结点,并对交换后的子结点位置递归进行堆调整(没有交换的那个子结点没有交换,不需要调整)
if (largest != i) {
swap(nums[i], nums[largest]);
heapify(nums, sublen, largest);
}
}
//这里从最后一个非叶子结点开始建最大堆,找最后一个非叶子结点的下标很简单:就是最后一个叶子结点的父节点
void buildHeap(vector<int>& nums, int len) {
int node = (len - 2) / 2;
for (int i = node; i >= 0; i--) {
heapify(nums, len, i);
}
}
void heapSort(vector<int>& nums, int len) {
buildHeap(nums, len); //建堆,从最后一个非叶子结点开始建最大堆
for (int i = len - 1; i >= 0; i--) {
swap(nums[0], nums[i]); //堆排序,把堆顶的最大元素换到已排序序列的前端,跟选择排序一样
heapify(nums, i, 0); //调整堆,因为交换后堆顶不一定最大,需要从堆顶开始调整堆
}
}
int main() {
vector<int> arr = {1, 4, 5, 6, 0, 4, 5, 56, 333, 4, 6, 9, 53};
heapSort(arr, arr.size());
for (auto a : arr) {
cout << a << endl;
}
system("pause");
return 0;
}
改进:递归改迭代。
性能分析
时间复杂度:平均O(nlogn),最佳O(nlogn),最差O(nlogn)
空间复杂度:O(logn),递归栈空间
稳定性:不稳定,跟选择排序一个道理
原地性:原地
这里空间复杂度不是O(1)是因为用的递归方法实现。
归并排序
原理
归并排序用的是分治的思想,把整个数组二等分,接着把分出来的两个子数组二等分,这样一直进行下去,知道分出来的子数组长度为1,然后就开始合并,在合并的过程中进行排序,也就是在合并的过程中,从左到右,按元素大小顺序来合并。合并的过程和一道力扣题很像:88.合并两个有序数组
代码实现
代码很多,代码很简单。
//从左到右,按元素大小合并两个递增子序列nums[left, mid]和nums[mid+1, right]
void mergeSubSeque(vector<int>& nums, int left, int mid, int right) {
//先用两个vector保存两个递增子序列
vector<int> left_sequence(nums.begin() + left, nums.begin() + mid + 1); //左闭右开
vector<int> right_sequence(nums.begin() + mid + 1, nums.begin() + right + 1);
int i = left;
int left_index = 0, right_index = 0;
//从左到右开始合并,合并结果放到nums中,直到其中一个递增子序列合并完了
while (left_index < left_sequence.size() && right_index < right_sequence.size()) {
if (left_sequence[left_index] < right_sequence[right_index]) {
nums[i++] = left_sequence[left_index++];
}
else {
nums[i++] = right_sequence[right_index++];
}
}
//注意,这里很容易忽略,前面一个递增子序列合并完了,另一个子序列可能没合并完,直接加到合并结果的末尾即可
while (left_index < left_sequence.size()) {
nums[i++] = left_sequence[left_index++];
}
while (right_index < right_sequence.size()) {
nums[i++] = right_sequence[right_index++];
}
}
void mergeSort(vector<int>& nums, int left, int right) {
if (left >= right) return; //子序列长度小于等于1,就返回
//分解
int mid = left + (right - left) / 2;
mergeSort(nums, left, mid);
mergeSort(nums, mid+1, right);
//合并
mergeSubSeque(nums, left, mid, right);
}
int main() {
vector<int> arr = {1, 4, 5, 6, 0, 4, 5, 56, 333, 4, 6, 9, 53};
mergeSort(arr, 0, arr.size()-1);
for (auto a : arr) {
cout << a << endl;
}
system("pause");
return 0;
}
改进:递归改迭代。
可以发现快排、堆排、归并排序都可以用递归和迭代来实现,递归的方法更直观,更容易理解,只不过递归的方法需要更多的栈空间。
性能分析
时间复杂度:平均O(nlogn),最佳O(nlogn),最差O(nlogn)
空间复杂度:O(n+logn),额外数组占用O(n),递归栈占用O(logn)
稳定性:稳定
原地性:非原地,需要额外数组,七大比较排序算法中唯一一个非原地算法
总结
对上面七种排序算法做一个简单的总结:
可以简单分为三种基本的排序算法、三种改进的排序算法和一种基于二分的排序算法。
三种基本的排序算法
三种基本的排序算法,冒泡排序、插入排序、选择排序,平均时间复杂度O(n2),空间复杂度O(1),是原地算法。冒泡排序和插入排序是稳定算法,选择排序比较特殊,是非稳定算法,因为当两个相等元素是最小值时,最后选的是排在后面的那个最小值,再经过交换,就换到前面去了,相对位置发生了改变。
三种改进的排序算法
这三种基本排序算法的时间复杂度有点高啊,怎么降低他们的时间复杂度呢?
对于冒泡排序:
1.设置一个标志位,如果一轮比较没有发生交换,说明数组已经排好序,直接返回。
2.记录每一轮交换的最后位置,该位置后面的序列已经排好序,下一轮遍历到该记录的位置即可。
3.快速排序
对于插入排序,由于插入排序对于小数据量、基本已排好序的数组,排序效率比较高,可以改进为希尔排序。先按某个间隔gap对数组分组,就是先对小数据量数组进行插入排序,然后减少gap的值,直到gap=1,最后进行一趟插入排序,也就是说随着数组越大,数组的有序程度越好,正好解决了插入排序的问题。
对于选择排序,找最大/最小值可以使用最大堆/最小堆来完成,也就是改进为堆排序。
这三种改进的排序算法,平均时间复杂度都是O(nlogn),最好的空间复杂度都是O(1),都是不稳定、原地算法。到这里可以发现,原地和空间复杂度O(1)是对应的,因为既然是原地算法,没有额外申请数组,空间复杂度自然是O(1)。为什么快排和归并排序有不同的空间复杂度呢?这和它们的实现方式有关,如果用迭代的方法实现,空间复杂度就是O(1)的,如果用递归的方法实现,因为要使用额外的栈空间,所以空间复杂度就是O(logn)的,但是它们还是原地算法,因为没有额外申请数组。
一种基于二分的排序算法
归并排序是基于二分的一种排序算法,应用了分治的思想,平均时间复杂度O(nlogn),最好空间复杂度O(n),如果用递归实现,空间复杂度O(n+logn),是一种稳定算法,也是七种比较排序算法中唯一一种非原地算法,因为在合并两个有序子序列时,需要申请额外数组空间。
综上有三种速度比较慢的基本算法:冒泡、插入、选择。
有四种速度比较快的算法:快排、希尔、堆排、归并。速度快了,但是牺牲了其他东西,快排、希尔、堆排都是不稳定的算法,归并排序是稳定算法,但是最好的空间复杂度O(n)。
讲完了上面七种比较排序,再讲讲下面三种统计排序,这三种排序都需要额外数组来统计,所以都是非原地算法,统计排序我没有深入了解,所以讲个大概。
计数排序
计数排序使用额外的数组C来统计原数组中的每个元素,C[i]表示原数组中值为i的元素的个数。统计完所有元素后,对数组C顺序遍历,反向还原数组,还原后的数组即排好序的数组。
桶排序
桶排序设定了一些桶,每个桶有各自的取值范围,将原数组的每个元素放到对应的桶中,再对每个桶进行排序,最后按顺序从桶中取出数据,就是排好序的数据。桶划分越多,每个桶里面的数据越少,排序时间越短,但相应地占用空间越大。
桶排序和计数排序很像,计数排序相当于每个桶的范围只有一个值。
基数排序
基数排序和计数排序是完全不同的。基数排序先将每个元素的数位长度(十进制长度)统一为数位长度最长的情况,数位较短的补0。然后从最低位开始排序,排完一遍后往最高位的方向,继续按下一位开始排,直到按最高位排完。