一文搞定十大排序算法(细)

现在对十大常用的排序算法做一个总结,便于记忆。
十大排序算法分别是:冒泡排序、快速排序、插入排序、希尔排序、选择排序、堆排序、归并排序、计数排序、基数排序、桶排序,如下图所示:
请添加图片描述
注:
稳定性:相等的两个数,排序后的相对位置和排序前一样。比如原数组中有三个数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。然后从最低位开始排序,排完一遍后往最高位的方向,继续按下一位开始排,直到按最高位排完。在这里插入图片描述

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值