[十大排序]有的人图画着画着就疯了(1.5w字详细分析+动图+源码)

Posted 君違

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[十大排序]有的人图画着画着就疯了(1.5w字详细分析+动图+源码)相关的知识,希望对你有一定的参考价值。

一个正经严肃的标题


菜鸡大学生的数据结构——排序篇

注意:以下未标注的图片都是纯手工绘制,开放一切权限,希望能够帮到你。
同样是注意:将我的图片说成是你自己画的的行为是不可以的。
祝你愉快~

啥是排序呢?

给你一串数据,我们可以按照一些条件将数据按照递增或者递减的方式进行排列,比如计算机里面的文件:

排序在生活中还是很有用的,比如我们可以通过首字母很快找到通讯录的联系人,找到最大值和最小值,精确找到某一天的消息…
总之,有用,好好学。

排序可以分为内部排序外部排序

内部排序: 数据元素全部放在内存中的排序。
外部排序: 数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。

我们今天讲的所有排序都属于内部排序

常见的排序算法及算法实现

1. 直接插入排序

我们假设书架上有一摞整齐的书:

如何把绿色书放入书架,使这书依旧整齐?
菜鸡大学生说:这简单,从右往左,找到刚好比这本书高的书,放在它右边就行了。

恭喜你,你已经完全掌握插入排序了,它的基本思想就是:

把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止。

由于我们不知道数组前几个元素是有序的,所有我们从数组第二个元素开始执行插入排序,直到数组最后一个元素。

动图演示:

代码:

//插入排序
void InsertSort(int* a, int n)

	for (int i = 0; i < n - 1; i++)
	
		int end = i;
		int tmp = a[i + 1];
		while (end >= 0)
		
			if (a[end] > tmp)
			
				a[end + 1] = a[end];
				end--;
			
			else
			
				break;
			
		
		a[end + 1] = tmp;
	

复杂度及稳定性分析

当数据有序的时候,时间复杂度为O(n),但显然不可能次次都这么巧。
平均下来还是O(N^2)。

稳定性:
假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。

从后往前插入数据又没啥波动,稳定的。

直接插入排序的特性总结:

  1. 元素集合越接近有序,直接插入排序算法的时间效率越高
  2. 时间复杂度:O(N^2)。
  3. 空间复杂度:O(1),它是一种稳定的排序算法。
  4. 稳定性:稳定。

2.希尔排序

又称缩小增量法。

显然,直接插入效率不是特别高。
根据插入排序的总结:元素集合越接近有序,直接插入排序算法的时间效率越高
我们是不是可以想想办法让数据变得有序一点点呢?

1959年,有一个大佬Donald Shell提出了希尔排序。

  • 先选定一个整数gap,把待排序文件中所有记录分成gap个组,所有距离为gap的记录分在同一组内,并对每一组内的元素进行排序。
  • 然后将gap逐渐减小重复上述分组和排序的工作。
  • 当gap=1时,就排好了。

通过图片我们发现,不断的预排序使得数据逐渐变成了小的在前,大的在后的状态。
这样在进行插入排序效率会直线上升。

那我们如何写代码呢?
我们当然可以排完一组,再去排第二组,第三组。
gap=2为例:

for (int j = 0; j < gap; ++j)

	for (int i = j; i < n - gap; i += gap)
	
		int end = i;
		int tmp = a[end + gap];
		while (end >= 0)
		
			if (tmp < a[end])
			
				a[end + gap] = a[end];
				end -= gap;
			
			else
			
				break;
			
		
		a[end + gap] = tmp;
	

然后再套一层循环去控制gap。

但我们完完全全可以将它们合并起来,就可以少一次循环了。

gap如何变化可以随意去设置,但是注意最后一次一定要是1。

//希尔排序
void ShellSort(int* a, int n)

	int gap = n;
	while (gap > 1)
	
		gap = gap / 3 + 1;
		for (int i = 0; i < n - gap; i++)
		
			int end = i;
			int tmp = a[i + gap];
			while (end >= 0)
			
				if (a[end] > tmp)
				
					a[end + gap] = a[end];
					end-=gap;
				
				else
				
					break;
				
			
			a[end + gap] = tmp;
		
	

复杂度及稳定性分析

希尔排序的特性总结:

  1. 希尔排序是对直接插入排序的优化。
  2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,排序起来就比较快(可以看动图)。
  3. 希尔排序的时间复杂度不好计算,因为gap的取值方法有很多。

在Knuth《计算机程序设计技巧》一书中,Knuth进行了大量的试验统计,计算出时间复杂度大概在O(N^1.25)O(1.6N^1.5)之间。

  1. 空间复杂度O(1)
  2. 稳定性:不稳定。

3.选择排序

每次学到排序的时候菜鸡大学生总会乳冒泡,但其实选择排序也拉。
冒泡:为什么只有我被乳?
可能是因为冒泡一般是第一个学的排序吧(小声)。

继续说回选择排序。
基本思想:

  • 第一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始(末尾)位置,
  • 然后选出次小(或次大)的一个元素,存放在最大(最小)元素的下一个位置,
  • 重复这样的步骤直到全部待排序的数据元素排完 。

优化方案:同时选最大值和最小值,然后将最大值与末尾交换,最小值与开头交换。

注意特殊情况:

此时由于max在begin位置,当min和begin互换时,max的位置其实是min,但是程序只会交换begin和end。
解决方案:
交换一次后判断max是不是begin,是的话max的值就是min。

同理,反过来也成立。
我们代码写的是相反版本:

//选择排序
void SelectSort(int* a, int n)

	int begin = 0;
	int end = n - 1;
	while (begin < end)
	
		int maxi = begin;
		int mini = begin;

		//寻找最大下标和最小下标
		for (int i = begin; i <= end; i++)
		
			if (a[maxi] < a[i])
			
				maxi = i;
			

			if (a[mini] > a[i])
			
				mini = i;
			
		

		//交换
		Swap(&a[maxi], &a[end]);

		//如果最小值在end位置
		if (mini == end)
		
			mini = maxi;
		
		Swap(&a[mini], &a[begin]);

		begin++;
		end--;
	

复杂度及稳定性分析

直接选择排序的特性总结:

  1. 直接选择排序由于效率不高(可能还不如冒泡),所以日常使用的范围有限。
  2. 时间复杂度:O(N^2)
  3. 空间复杂度:O(1)
  4. 稳定性:不稳定

有一种特殊情况,我们假设一串数据2,2,1,3.
一轮下来第一个2会和1交换,然后两个2的顺序就乱了。

4.堆排序

堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。
它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。

如果是建小堆的话第一个就已经是最小的了,后面的数据还需要重新建堆,那么堆排序的意义就不存在了。

思路:

  • 假设要排的数据有n个。
  • 建大堆找到最大的数据和最后一个数据交换。
  • 堆顶数据向下调整,此时新的堆顶数据是n-1个数据里面最大的数。
  • 堆顶和第n-1个数据交换。
  • 直到只剩一个数据。

我们先不急着建堆,先把后面的代码写出来。

for (size_t i = n - 1; i > 0; i--)
	
		Swap(&a[0], &a[i]);
		AdjustDown(a, i, 0);
	

注:建堆所需要的向下调整函数在二叉树的顺序结构里面有分析,可以去我之前的博客。
又注:这一段基本上都是拷贝的那一篇博客的内容。
又又注:这篇发出来的时候那一篇博客应该还没来的及发,应该下周就能出了。

顺便画个不太好理解的图。

然后在尝试建堆康康。
我们可以采取向上建堆和向下建堆两种建堆方式。
向上建堆就类似于一个个向数组里面插入数据。
向下建堆从倒数第一个非叶子节点开始向下调整

//建大堆
	// 向上建堆
	for (int i = 0; i < n; i++)
	
		AdjustUp(a, i);
	

	//向下建堆
	for (int i = (n-1-1)/2; i >= 0; i--)
	
		AdjustDown(a, n, i);
	

既然有两种建堆方式我们就要比较一下效率了。
我们假设是满二叉树:

显然向下建堆效率更高。

将代码整合一下:

void AdjustDown(HPDataType* a, size_t size, size_t root)

	size_t parent = root;
	size_t child = root*2+1;

	while (child < size)
	
		// 1、选出左右孩子中大的那个
		if (child + 1 < size && a[child] < a[child + 1])
			++child;

		// 2、如果孩子大于父亲,则交换,并继续往下调整
		if (a[child] > a[parent])
		
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		
		else
		
			break;
		
	


void HeapSort(int* a, int n)

	//向下建堆
	for (int i = (n-1-1)/2; i >= 0; i--)
	
		AdjustDown(a, n, i);
	

	for (size_t i = n - 1; i > 0; i--)
	
		Swap(&a[0], &a[i]);
		AdjustDown(a, i, 0);
	

复杂度及稳定性分析

堆排序的特性总结:

  1. 时间复杂度:O(N*logN)
  2. 空间复杂度:O(1)
  3. 稳定性:不稳定

5. 冒泡排序

冒泡一到店,所有排序的算法便都看着他笑,有的叫道,“冒泡,你又被人嫌弃了”他不回答,对菜鸡大学生说,“温两个循环,要一碟变量。”便排出九行代码。
他们又故意的高声嚷道, “你一定又占了人家的时间了!”冒泡睁大眼睛说,“你怎么这样凭空污人清白……”
“什么清白?我前天亲眼见你拖慢了菜鸡大学生的程序,吊着打。”冒泡便涨红了脸,额上的青筋条条绽出,争辩道,“低效不能算拖……低效!……十几秒的事,能算拖么?”
接连便是难懂的话,什么“有序效率爆高“,什么“稳定”之类,引得众人都哄笑起来:店内外充满了快活的空气。

思想: 两两比较,大的就向后移动,直到最大的数据到达末尾。
执行n-1次即可。(n-1次的原因是最后一次两个数据只要交换一次就行,当然n也没啥问题。)

//冒泡排序
void BubbleSort(int* a, int n)

	for (int i = 0; i < n - 1 ; i++)
	
		int exchange = 0;
		for (int j = 0; j < n - i - 1; j++)
		
			if (a[j] > a[j + 1])
			
				Swap(&a[j], &a[j + 1]);
				exchange = 1;
			
		

		if (exchange == 0)
			break;
	

复杂度及稳定性分析

冒泡排序的特性总结:

  1. 时间复杂度:O(N^2)
  2. 空间复杂度:O(1)
  3. 稳定性:稳定

6. 快速排序

我们的好朋友,它来啦。
存在于库里的唯一大爹,它来啦。

那啥事快排呢?
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法。
基本思想很简单,随便取一个基准值key,保证所有的元素,比key大的在一边,比key小的在另一边。然后左右序列重复这个过程直到有序。

菜鸡大学生嗅到了递归的味道!菜鸡大学生把持不住了!
菜鸡大学生准备先写主干部分!

我们可以通过递归把数据分成左右两部分。
直到左边的数据等于右边的数据或者大于右边的数据。
(见图,按颜色分组)

注意,这里讲的主要是思想,图和PartSort代码没有关系,画的时候只追求了比key大的在一边,比key小的在另一边这个条件。
这张图主要是展示边界控制的问题。

//快速排序
void QuickSort(int* a, int left, int right)

	if (left >= right)
		return;

	int keyi = PartSort(a, left, right);
	QuickSort(a, left, keyi - 1);
	QuickSort(a, keyi+1, right);

下面就是考虑PartSort函数的问题。
茴字有四种写法,而快排如果算上非递归也有四种写法。
可以推断出:茴香豆就是快排。

Hoare版本

既然是hoare提出的,我们就得放在第一个,给创始人最大的排面。

思路:

  1. 选key,一般是第一个或者最后一个。
  2. 选定左指针和右指针从两头遍历数组。
  3. 如果是从第一个开始就右指针先走,最后一个开始就左指针先走。 * (画图解释)*
  4. 左指针遇到大于key的值就停下来,右指针遇到小于key的值就停下来。
  5. 交换左右指针的值。
  6. 直到相遇,交换key和左右指针位置的值。

注:这张静态图是指针移动和交换同时进行的。

为什么不能第一个开始左指针先走呢?
第一个开始左指针走之前不会有什么问题,但是到了最后一步左指针会找到比key值大的数,然后停下和key交换。
而如果是右指针的话,它找到的是比key小的数,交换不会出现任何问题。

最后一个开始右指针先走同理。


铺垫了这么多我们来写代码吧。

//快速排序hoare版本
int PartSort1(int* a, int left, int right)

	int keyi = left;
	while (left < right)
	
		//右指针先走
		while (left < right && a[right] >= a[keyi])
		
			right--;
		
		//左指针再走
		while (left < right && a[left] <= a[keyi])
		
			left++;
		
		Swap(&a[left], &a[right]);
	
	Swap(&a[keyi], &a[left]);

	return left;

挖坑法

挖坑法和上面的方法很像,但是相对便于理解。

思路:

  1. 选key,然后取出,留下一个坑。这里以左边第一个为key举例。
  2. 右边的指针遇到小于基准值的数时,直接将该值放入坑中,而右指针指向的位置形成新的坑位。
  3. 然后左指针遇到大于基准值的数时,将该值放入坑中,左指针指向的位置形成坑位。
  4. 重复过程直到两指针相遇,将key值放入坑中。

我们先看一下单次排序的动图:

顺着来就好,不像hoare版本需要考虑一些小小的细节。

好累啊不想画全趟了怎么办

代码:

//快速排序挖坑法
int PartSort2(int* a, int left, int right)

	int key = a[left];
	int pit = left;

	while (left < right)
	
		while (left < right && a[right] >= key)
		
			right--;
		
		a[pit] = a[right];
		pit = right;

		while (left < right && a[left] <= key)
		
			left++;
		
		a[pit] = a[left];
		pit = left;
	

	a[pit] = key;
	return pit;

前后指针

很方便的写法,就是不太好理解。

思路:(以key为第一个数为例)

  1. 选定key,定义prev和cur指针. (cur = prev + 1)
  2. cur先走,遇到小于基准值的数停下,prev向后移动一个位置。
  3. 交换prev和cur的值。
  4. 重复上面的步骤,直到cur走出数组范围
  5. 最后将key值与prev对应位置交换。

我们先来看一下单趟动图:

这个方法的核心就是保证prev指向及prev之前的所有数据的值都小于key。

  • 当cur还没遇见比key大的值的时候,prev是跟着cur一起移动的。
  • 当cur第一次遇见比key大的值的时候,prev停下,此时prev包括prev前的数据都是小于key的。
  • 当cur再一次遇见比key小的数据时,prev的下一个一定是比key大的数据,所以prev++和cur交换。
  • 直到遍历结束。prev之前包括prev都比key小,key和prev交换。
  • key之前都比key小,key之后都比key大。

然后我们来考虑一下key取最右边:
为了保证prev包括prev前的数据都是小于key的。 prev就不能从0位置开始了,万一第一个数就大于key呢?

接下来的路与取左边完全一样,直到cur在key位置的时候:
prev包括prev前的数据都是小于key的。(哇,这话我说了多少次)

  • 在左边的时候prev前面有key,所以可以直接交换。
  • 在右边的时候直接交换会把小的数换到右边,所以交换的时候是换prev++的位置。

好了,终于到代码了:

// 快速排序前后指针法
int PartSort3(int* a, int left, int right)

	//keyi=left
	int keyi = left;
	int cur = left + 1;
	int prev = left;
	while (cur <= right)
	
		if (a[cur] < a[keyi] && a[++prev] != a[cur])
			Swap(&a[prev], &a[cur]);

		cur++;
	
	Swap(&a[prev], &a[keyi]);

	return prev;


int PartSort3(int* a, int left, int right)

	//keyi=right
	int keyi = right;
	int cur = left;
	int prev = left-1;
	while (cur < right)
	
		if (a[cur] < a[keyi] && a[++prev] != a[cur])
			Swap(&a[prev], &a[cur]);

		cur++;
	
	Swap(&a[++prev], &a[keyi]);

	return prev;

快速排序优化

我们先讨论一下快速排序的两种情况:

  • 最好: 所有的key刚好是中位数。时间复杂度O(NlogN).
  • 最坏: 每一个key都是最小或最大值。时间复杂度O(N^2).

如果我们不幸用快排排了大量有序数据,程序很可能因为递归调用过多导致栈溢出

所以我们可以进行如下优化:

  1. 三数取中法选key。
  2. 递归到小的子区间时,可以考虑使用插入排序。

完全优化过的代码:

int PartSort3(int* a, int left, int right)

	int midi = GetMidIndex(a, left, right);
	Swap(&a[midi], &a[left])万字长文|十大基本排序,一次搞定!

需要人陪

流程图系列:ProcessOn如何扩大页面?

前端进阶之路:1.5w字整理23种前端设计模式

Railfence

十大经典排序,你全都会了吗?(附源码动图万字详解)