排序算法总结(上)

346 阅读6分钟

「这是我参与2022首次更文挑战的第15天,活动详情查看:2022首次更文挑战」。


下面介绍的所有排序默认都为非递减排序!所有算法和测试用例见github

1.简单排序

简单排序算法主要包括冒泡排序、选择排序、插入排序以及由插入排序改进而来的希尔排序,其时间复杂度都为 nlognnlogn

1.1 冒泡排序

算法思想:

属于暴力解法 从第一个元素开始,两两比较大小,如果后者 < 前者,则交换两者顺序,以此类推,直到最大的元素“冒泡”到它的最终位置

这里写图片描述

代码如下:

#include <iostream>
#include <algorithm>

template <typename T>
void bubbleSort (T arr[], int n){
	for (int i = 0 ; i < n - 1; i++){
		for (int j = 0; j < n - 1 - i; j++){
			if (arr[j + 1] < arr[j])
				std::swap(arr[j + 1],arr[j]);
		}
	}
}

1.2 选择排序

算法思想:

属于暴力解法

每次循环遍历是,选择排序默认第一个元素即为本次循环最小的元素,然后依次与后面的元素进行比较,不断更新该次循环中的最小值,最终将得到的最小元素和默认的初始元素交换位置即可,以此类推不断进行比较。

思想和冒泡有点类似,不同点在于冒泡排序时前后元素两两比较,而选择排序是初始元素与后续所有元素进行比较。

这里写图片描述 代码如下:

#include <iostream>
#include <algorithm>

template <typename T>
void selectionSort(T arr[],int n){
	int minIndex = 0;
	for (int i = 0; i < n - 1;i++){
		//这里只需要记录最小值的索引即可,最后交换arr[minIndex]和arr[i]即可,无需其他冗余的交换操作
		minIndex = i;
		for (int j = i + 1; j < n ;j++){
			if (arr[j] < arr[minIndex])
				minIndex = j;
		}
		std::swap(arr[minIndex],arr[i]);
	}
}

1.3 插入排序

算法思想:

自下而上的减治算法

假设数组前面几个元素已经有序,插入排序的思想为,把未排序的第一个元素从后向前依次和已排序的元素进行比较,如果遇到比本身小元素,将未排序元素插入该元素后一个位置,以此类推......

这里写图片描述

代码如下:

#include <iostream>

template <typename T>
void insertionSort(T arr[],int n){
	T target = 0;
	int j = 0;
	for (int i = 1;i < n;i++){
		target = arr[i];
		j = i - 1;
		while (j >= 0 && arr[j] > target){
			arr[j + 1] = arr[j];
			j--;
		}
		arr[j + 1] = target;
	}
}

需要说明的是,虽然插入排序的时间复杂度平均情况下为O(n2)O(n^2),但当待排序数组近乎有序的时候,其时间复杂度退化为O(n)O(n),效率完全不亚于任何一种O(nlogn)O(nlogn)的高级排序算法,因此插入排序也常常作为优化子过程用在高级排序算法中,当高级排序算法将数组整理的近乎有序的时候,此时调用插入排序的效果是非常好的,下文高级排序算法中将会涉及到该优化思想。

1.4 希尔排序

算法思想:

希尔排序(由D.L. 希尔发明)是在总结并放大插入排序优点的基础上被创造出来的,可以理解为是插入排序的改进。

由上文知道,插入排序对近乎有序的元素进行排序相当高效,希尔排序就是根据给定的步长对元素进行插入排序,进而构造近乎有序的数组,并不断进行插入排序,显然这个步长必须以1作为结束。因此,该算法的时间复杂度更准确的说是依赖于步长队列的选取的。对于希尔排序,步长1,4,13,40......的效率是最高的,当然,使用的时候需要反过来。

这里写图片描述

代码如下:

#include <iostream>

template <typename T>
void shellSort(T arr[],int n){
	int h = 1;
	T target = 0;
	int j = 0;
	while (h <= n)
		h = 3 * h + 1;
	while (h > 0){
		//下面即为步长为h的插入排序
		for (int i = h;i < n ; i += h){
			target = arr[i];
			j = i - h; 
			while (j >= 0 && arr[j] > target){
				arr[j + h] = arr[j];
				j -= h;
			}
			arr[j + h] = target;
		}
		h /= 3;
	}
}

2 高级排序

高级排序主要包括归并排序、快速排序和堆排序,算法复杂度平均情况下为O(nlogn)O(nlogn),但是针对不同类型的数据表现出来的性能可能截然不同,下面将会讨论不同的算法适合的应用场景,以及该算法针对大多情况下的改进。

2.1 归并排序

算法思想:

归并排序是应用分而治之技术的一个完美例子

对于一个需要排序的数组A[0...n-1],归并排序将他们一分为二:A[0...n / 2]和A[n / 2 + 1],并对每个子数组递归排序,然后将两个排好序的子数组合并为一个有序数组。

首先我们考虑将数组分成A和B,并保证A和B都是有序的。这里用到了 递归 的思想,每个数组一分为二,知道分成的数组仅仅包含一个元素,因为我们认为一旦只包含一个元素,该数组即为有序数组。 归并的代码如下:

template <typename T>
void __mergeSort(T arr[],int l,int r){
	//递归终止条件
	if (l >= r){
		return ;
	}
	int mid = (r + l) / 2;
	__mergeSort(arr, l, mid);
	__mergeSort(arr, mid + 1, r);
	//分别排序完成后进行归并
	__merge(arr, l, mid, r);
}

以上逻辑很简单,关键在于如何将两个已排序的数组进行归并。与其他排序算法不同的是,为此我们需要引入一个辅助数组来帮助我们完成归并。

这里写图片描述

我们将新开辟的经过赋值的辅助数组命名为aux(上图的第二个数组),原来的数组为arr(上图的第一个数组),用指针k(索引下标,下同)指向arr的第一个元素,并分别用指针i,j分别指向两个待合并的数组的第一个元素,然后比较这两个元素的大小,将较小的元素添加到arr[k],然后指针k后移,被复制的数组的指针后移。上述操作一直持续到aux两部分中的一个被处理完为止,然后,在未处理完的数组中,剩下的元素直接被复制到arr的尾部。 归并排序代码如下:

template <typename T>

//l为数组第一个元素的下标,mid为待归并数组中的第一个数组的最后一个元素的下标,r为数组最后一个元素的下标
void __merge(T arr[],int l,int mid,int r){
	//声明辅助数组aux,注意元素个数(数组下标为闭区间)
	T aux[r - l + 1];
	//辅助数组赋值,注意arr下标从l开始,aux下标从0开始
	for (int i = l;i <= r;i++){
		aux[i - l] = arr[i];
	}
	
	//定义待归并数组的首元素指针
	int i = l, j = mid + 1;
	for (int k = l;k <= r;k++){
		//判断指针i是否越过mid
		//如果是,说明aux[l...mid]已经归并完成
		if (i > mid){
			arr[k] = aux[j - l];
			j++;
		}else if (j > r){//同理,检查j是否越过r
			arr[k] =aux[i - l];
			i++;
		}else if (aux[i - l] < aux[j - l]){
			arr[k] = aux[i - l];
			i++;
		}else{
			arr[k] = aux[j - l];
			j++;
		}
	}
}

最后,我们按照上面对简单排序的函数格式,对归并最后进行一下封装

template <typename T>
void mergeSort(T arr[], int n){
	__mergeSort(arr,0,n-1);
}

2.1.1 归并排序性能测试

我们用函数自动顺序生成了100000个元素,并随即对其中10组进行交换,这样我们得到了一组近乎有序的数组,对同一样的样本数据,叫我们分别用插入排序和归并排序进行排序测试,测试结果如下:

这里写图片描述

由此得出,对近乎有序的数组(更极端点,对完全有序的数组),在大数量级的前提下,插入排序的表现要优于归并排序!

这是由于在近乎有序的情况下,插入排序会退化为O(n)O(n),对已排好序的元素不再进行排序处理。而归并排序依然沿用之前的规则,即使是有序的元素,还是会按照原定的策略进行排序。

2.1.2 归并排序优化

针对这一点,我们引出了归并排序的一点优化,代码如下:

template <typename T>
void __mergeSort(T arr[],int l,int r) {
	//递归终止条件
//	if (l >= r) {
//		return ;
//	}

	//优化2:如果r-l<=15(随便选择),则利用插入排序
	if ( r - l <= 15) {
		insertionSort(arr,l,r);
		return;
	}

	int mid = (r + l) / 2;
	__mergeSort(arr, l, mid);
	__mergeSort(arr, mid + 1, r);
	//分别排序完成后进行归并
	//优化1:如果近乎有序,则加入这个判断会有效提升性能
	if (arr[mid] > arr[mid + 1])
		__merge(arr, l, mid, r);
}

其中,插入排序的代码如下:

template <typename T>
void insertionSort(T arr[],int l, int r){
	T target = 0;
	int j = 0;
	for (int i = l + 1;i <= r;i++){
		target = arr[i];
		j = i - 1;
		while (j >= l && arr[j] > target){
			arr[j + 1] = arr[j];
			j--;
		}
		arr[j + 1] = target;
	}
}

效果如下:

这里写图片描述

虽然还是比插入排序稍微慢一点,但是相比之前的归并排序,速度已经提升了不少

2.1.3 自下而上的归并排序算法

上文中介绍的归并排序属于自顶向下的方法,就是说从整体出发,将整体分成部分,再将部分合成整体。下面我们介绍自下而上的归并排序算法,先从小数组开始归并,然后将归并完成的数组(长度是小数组的2倍)再继续归并,图例如下:

这里写图片描述

这里写图片描述

这里写图片描述

这里写图片描述

这里写图片描述

这里写图片描述

自底而上的思想不需要递归,只需要迭代,代码如下:

#include <iostream>
#include <algorithm>

template <typename T>
void mergeSortBU(T arr[],int n){
	for (int h = 1;h <= n;h += h){
		//保证一定有两个数组进行归并,而不是一个
		for(int i = 0;i + h < n;i += h + h){
			__merge(arr,i,i + h - 1,std::min(r,i + h + h - 1));
		}
	}
}

由于较少利用了数组的索引特性,所以自底而上的归并排序可以扩展用来对链表进行排序,时间复杂度O(nlogn)O(nlogn)