【数据结构】--八大排序算法【完整版】

【数据结构】--八大排序算法【完整版】_第1张图片

匠心制作,后续有问题会加以修改的 ,全文均是自己写的,几张图有参考网络

————————————————

目录

一、直接插入排序

二、希尔排序(直接插入排序的改良版)

三、选择排序(直接选择排序)

四、堆排序

五、冒泡排序

六、快速排序

1、 左右指针法

2、挖坑法:

3、前后指针法:

4、快速排序的非递归实现

七、归并排序

1、归并排序的递归实现

2、归并排序的非递归实现

八、计数排序

九、总结:


【数据结构】--八大排序算法【完整版】_第2张图片

一、直接插入排序

void InsertSort(int* a, int n)
{   //加一个for循环就成了复合排序
	for (int i = 0; i < n - 1; i++)
	{
		//单趟排序
        //把下标为end+1的数据插入到下标为[0,end]的有序区间
		int end = i;
		int tmp = a[end + 1];
		while (end >= 0)
		{
			if (tmp < a[end])
			{
				a[end + 1] = a[end];
				--end;
			}
			else
			{
				break;
			}
		}
		a[end + 1] = tmp;
	}
}

1、单趟排序的原理 

 原理:在数组前面已经是升序下,插入一个数据,让数组重新变为升序该怎么做。

【数据结构】--八大排序算法【完整版】_第3张图片

2、整体复合排序

原理:既然单趟排序是利用已存在升序原理做的。那我们就对整个数组从头开始都假设升序然后插入一个数据。

【数据结构】--八大排序算法【完整版】_第4张图片

3、直接插入排序的时间复杂度和空间复杂度:

时间复杂度:O(N*N)

插入排序的时间复杂度可以通过分析算法的执行过程来计算在最坏的情况下,即待排序的数组是逆序排列的情况下,插入排序需要比较和移动元素的次数最多。

假设待排序数组的长度为 n。在插入排序中,我们从第二个元素开始,依次将每个元素插入到已排序的子数组中。为了将当前元素插入到正确的位置,我们需要将其与已排序子数组中的元素进行比较,并移动比它大的元素。

在最坏情况下,每个元素都需要与其前面的所有元素进行比较,并且可能需要移动整个已排序子数组。因此,对于第 i 个元素,需要比较 i-1 次,并且可能需要移动 i-1 次。

假设移动一个元素需要花费 O(1) 的时间,比较两个元素也需要花费 O(1) 的时间。在最坏情况下,对于第 i 个元素,我们需要比较和移动 i-1 次。所以总的时间复杂度可以表示为:

T(n) = 1 + 2 + 3 + ... + (n-1) = (n-1) * n / 2 = O(n^2)

因此,插入排序的时间复杂度为 O(n^2)。需要注意的是,这只是最坏情况下的时间复杂度,而在最好情况下,即数组已经是有序的情况下,插入排序的时间复杂度为 O(n)。

空间复杂度:  O(1) 

二、希尔排序(直接插入排序的改良版)

 1、为什么要引入希尔排序?

在直接插入排序的基础上进行优化,效率更高,如果数据很多,我们是需要这个效率的

那直接插入排序的效率:顺序有序最好( O(N) ),逆序最坏,越接近有序,效率越好

但直接插入排序本身不知道数组是否有序还是逆序,有不确定性,在此之上,希尔提出了优化的方式。

2、希尔排序:

①、预排序(使数组接近有序)

②、直接插入排序

把间距为gap的值分为一组,进行插入排序

gap越大,前面大的数据可以越快到后面,后面小的数,可以越快到前面,gap越大,越不接近有序。

gap越小越接近有序,如果gap=1其实就相当于直接插入排序,就有序了。

对于一组的预排序应该如下:

【数据结构】--八大排序算法【完整版】_第5张图片

            int end //这里应该是一组中待插入数据前面那个数据的下标;
			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;

多组并排(希尔排序完整代码):

void ShellSort(int* a, int n)
{
	assert(a);
	//1、gap>1相当于预排序,让数组接近有序
	//2、gap==1相当于直接插入排序,保证有序
	int gap = n;
	while (gap > 1)
	{
		gap = gap / 3 + 1;//+1保证了最后一次一定是1,因为一个数一直/3一定会有0
		//gap==1最后一次就相当于直接插入排序
		for (int i = 0; i < n - gap; i++)//加上for循环可以实现多组并排
		{
			//进行直接插入排序
			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的所有组的排序都排完,从end=0开始,排的是一组,排完后end++排的可能就是另一组,因为有间距gap。( i < n - gap,每次循环再i++ 就可以实现一趟把所有组都排一遍,最后end会=n-1-gap,这正是我们所期望的倒数第二个元素 ),gap>1用来预排序,因为预排序后基本就会接近有序,然后gap=1用来直接插入排序。

解释:

1、for循环的添加可以实现从一组的预排序到多组的预排序

2、gap每次循环 gap = gap / 3 + 1是经过测试和预算,针对各种场景基本最佳的代码,有利于预排序排成有序,当然有的人会用gap /= 2,但没有gap = gap / 3 + 1优化 ,gap不断缩小的目的是为了更加有序,因为更加有序的直接插入排序效率高。

3、循环进行条件是gap > 1,gap > 1就会进行预排序,gap=1本质上就是直接插入排序,而gap=1一定会进行一次的,因为gap=gap/3+1

 希尔排序的时间复杂度:

它的时间复杂度不好算,需要一系列推导,所以基本记一下即可

O(N^1.3 ~ N^2)一般情况下优于插入排序,但在本来有序的情况下插入排序就比希尔排序效率高,但这种情况很少。

三、选择排序(直接选择排序)

思想:

我们一般用的是一次选出一个最大值或最小值,但我们可一次选出两个值,一个最大值,一个最小值来提高效率(最小值放在第一位,最大值放在最后一位)。然后选出这两个值begin++,end--即可,用来忽视上一次选出的最大值和最小值,在新的区间再次找最大值和最小值,并再次将最大值放开头,最小值放末尾。

问题:

如果最大值一开始就在区间的开头,而每次区间的最小值都会放到开头的,可最大值在最小值放在开头后,还要放到末尾,可此时最大值已经被覆盖,他被换到了a[minimal]的位置了,所以如果begin==maximal(即最大值一开始在开头),maximal应=minimal 

 下列代码中Swap函数利用指针交换两个元素

void SelectSort(int* a, int n)
{
	assert(a);
	int begin = 0;
	int end = n - 1;
	while (begin < end)
	{
		int maximal = begin, minimal = begin;
		for (int i = begin + 1; i <= end; i++)
		{//一次选出区间内最大的一个和最小的一个
			if (a[i] > a[maximal])
			{
				maximal = i;
			}
			if (a[i] < a[minimal])
			{
				minimal = i;
			}
		}
		//交换的是两者本身,并不是仅仅交换值那么简单
		Swap(&a[begin], &a[minimal]);//最小值放区间开头
		if (begin == maximal)
		{//如果一开始就相等,那么a[maximal]会=a[minimal],会影响后续
		//maximal的使用,但是此时maximal是=minimal的,即下标相等
		//就是最小值会把最大值覆盖,而最大值后面还要用
			maximal = minimal;
		}
		Swap(&a[end], &a[maximal]);//最大值放区间末尾
		begin++;
		end--;
	}

}

选择排序的时间和空间复杂度:

时间复杂度:O(N^2)

因为第一次需比较n-1次,第二次n-3,第三次n-5次以此类推,就是个等差数列,利用等差数列前n项和公式就知道为O(N^2) 

但是直接选择排序的效率不是很高,不管数组如何都会那么排,对于巧合,比如本就是升序的情况,还是会那么排,实际应用中我们不常使用

空间复杂度:O(1)

四、堆排序

 堆排序在我的另一篇博客堆的讲解中有详细讲过,这里不过多赘述

//向下调整算法。前提:左右子树都是小堆
void AdjustDown(int* a, int n, int root)
{
	//找出左右孩子中小的那一个,默认认为左孩子是小的那一个,否则就加以调整即可
	int parent = root;
	int child = parent * 2 + 1;//先默认child是左孩子,我们的目的是让child成为小的那一个
	while (child < n)
	{//当孩子的下标=父亲了,满足小堆的条件了,就无须继续往下判断,
			//因为在调整的过程中可能就存在在越界之前,孩子>=父亲的情况
            //谨记向下调整法用于只有堆顶不满足,而左右子树满足堆的性质的时候使用
		}
		//仅交换一次还不能够满足小堆,应该持续比较并交换,所以应该是个循环
	}

}

//堆排序
void HeapSort(int* a, int n)
{
	//1、数组建堆
	for (int i = (n - 1 - 1) / 2; i >= 0; --i)
	{
		AdjustDown(a, n, i);
	//当然i也可初始化为n-1,即从叶子结点开始调,但是这么做肯定没有从叶子结点
	//的父节点开始调高效
	}
	//2、找次小,排序
	int end = n - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);
		
		//再继续选次小的
		AdjustDown(a, end, 0);
		--end;
//没有真的删除最后一个数据,只是说我下次再找小交换,最后一个数据
//不被看作堆里面的,不造成影响
	}
}

堆排序的时间和空间复杂度:

时间复杂度:O(N*logN)

空间复杂度:O(1) 

五、冒泡排序

思路:

比较基础,两个两个比,最后把最大的放最后(或把最小的放最前)即可。但有一种情况:如果在第一次比较中,一次都没交换说明已经有序了,故利用标志性flag来判断是否有序


void BubbleSort(int* a, int n)
{
    assert(a);
	int i = 0;
	int flag = 0;
 
    //有n个元素的数组进行n-1次排序即可
	for (i = 0; i < n-1; i++)
	{
		int j = 0;
 
        //一次冒泡排序
		for (j = 0; j < n - i - 1; j++)
		{
			if (a[j] > a[j+1])
			{
				Swap(&a[j], &a[j+1]);
				flag = 1;
			}
		}
 
        //flag==0说明本来就有序,一次交换都没有,就不用往下进行了
		if (flag == 0)
		{
			break;
		}
	}
}

冒泡排序的时间复杂度和空间复杂度:

时间复杂度:O(N^2)

n-1+n-2+n-3+......还是一个等差数列,所以是O (N^2)

空间复杂度:O(1)

六、快速排序

快速排序分为单趟排序和整体排序

单趟排序有三种方法:

1、左右指针法

2、挖坑法(左右指针法的变形)

3、前后指针法

三种方法只是写法上的差异,性能没有差别

1、 左右指针法

单趟排序的思路(假设排升序,n为数组元素个数):

选定一个基准值(数组中的值)Key,通常会选第一个值或最后一个值都可以,然后会设置两个值begin=0,end=n-1,即begin和end都为数组元素下标,begin从左往右走,end从右往左走,begin找比Key小的值,end找比Key大的值,(如果begin和end遇到和Key相等的值,也会停下,因为与Key相等的值在Key的左边还是右边无所谓)并且会一直找,end和begin找到后两者交换即可,一直找并交换,直到begin和end重合再把key与两者重合的那个值交换,交换后key的左边就是比key小的,右边就是比key大的。

要点:

①、左边要比Key要小,右边比Key大

②、Key要放到他正确的位置(最终要放的位置)

③、Key设置为第一个值还是最后一个值的区别仅在于begin先走还是end先走

选取最后一个数为基准值,则一定要begin先走,因为左边先走能保证最后落的位置比Key要大(然后key会与重合位置交换),因为我们想让在Key右边的值都比Key大,而begin就是用来找比Key要大的,在最后一次要重合时,begin停下来的位置一定比Key要大,而end受到循环条件begin

选取第一个数作为基准值,则一定要end先走,因为右边先走能保证最后落的位置比Key要小,因为我们想让在Key左边的值都比Key小,而end就是用来找比Key要小的,在最后一次要重合时,end停下来的位置一定比Key要小,而begin受到循环条件begin=Key的,最后也会落到和end相同的位置

如果不这么做,就可能导致Key的右边存在比Key小的,Key的左边存在比Key大的

【数据结构】--八大排序算法【完整版】_第6张图片

问题:

代码中begin和end走的前提是begin因为可能begin和end都重合了,可能end还在往左走或begin还在往右走从而导致又不重合了,但我们要保证他们第一次重合后就不要再走了,因为此时的Key与重合位置一交换他左边一定比他都小,右边一定比他都大,已经可以了。所以加个条件begin

int PartSort(int* a, int begin, int end)
{
	int Keyindex = end;//若最后一个值为基准值
	while (begin < end)
	{
		//begin找比Key大的值,目的是放在Key的右边
		while (begin < end && a[begin] <= a[Keyindex])//等于放在Key的左边或右边均可
		{//这里一定要有=的判断条件,因为没=的话,循环不进行,begin和end都不动
		//而begin= a[Keyindex])
		{
			--end;
		}
		
		Swap(&a[begin], &a[end]);
	}

	Swap(&a[begin], &a[Keyindex]);//重合位置和基准值换,这里用a[end]也可

	return begin;//返回相遇的位置的下标,方便实现下一次的单趟排序
}

整体排序的思路(类似于二叉树的前序遍历):

  单趟排序后,只要Key的左边和右边都有序了,整体是不是就是有序了。那Key的左边和右边要有序,可以再选一次Key1再次单趟排序,使Key1左边的都Key1,然后此时想Key1左边的有序,右边的也有序,整个就有序了,那对于Key右边的同理,选一个Key2再次单趟排序,而对于Key2本身的左边和右边又可以选一次Key4,不断递归下去,终止条件就是划分的只剩一个值了,一个值我们就可认为是升序了,就不用再往下划分了,直接递归往回返就能实现升序,即整体排序完毕。

【数据结构】--八大排序算法【完整版】_第7张图片

void QuickSort(int* a, int left, int right)
{
	assert(a);
	//递归终止的情况是left==right这是只有一个值情况下一定可为升序
	//还有一种就是left= right)
		return;

	int div = PartSort(a, left, right);
	//[left,div-1] div [div+1,right]
	QuickSort(a, left, div - 1);
	QuickSort(a, div + 1, right);

	//也可以这么写
	//if (left < right)
	//{
	//	int div = PartSort(a, left, right);
	//	//[left,div-1] div [div+1,right]
	//	QuickSort(a, left, div - 1);
	//	QuickSort(a, div + 1, right);
	//}

}

快排的时间复杂度和空间复杂度分析:

 时间复杂度最好的情况下是每次选的key都是中位数,而单趟排序的时间复杂度是O(N)(要看它的思想,不用看代码),因为begin往右走,end往左走,最后重合,相当于把整个数组走了一遍,所以为O(N)

 时间复杂度最好的情况下:

【数据结构】--八大排序算法【完整版】_第8张图片

 时间复杂度最坏的情况下:

【数据结构】--八大排序算法【完整版】_第9张图片

 空间复杂度:O(logN) (以2为底,N的对数)

因为递归调用的空间复杂度是计算它的深度(每一层都要建立栈帧,而栈帧是要消耗空间),因为类似二叉树,故深度就可算出来。

缺陷:

实际当中无法保证选key是中位数,但我们可以考虑至少不要选到最大的或者最小的做key,当有序的情况下(一定是最坏的情况下),选到最大的或最小的值作为key效率不高,这种是运气很不好的情况下,一般的情况下都还很不错的。但严重的问题是面对有序的情况下需要建很多栈,而栈的空间本来就不大,堆的空间才大,如果数据很多就会导致栈溢出,程序崩溃,怎么解决呢?

三数取中:保证不要选到最小或者最大,让有序时变成最优。(即三个数中找中间数)

 即最中间的数和最大的数和最小的数选择那个最中间的数作为key,但我们之前写的逻辑都是最后一个数作为key的,那就再把这个最中间的数和最后一个数换一下就行了。

意义:让原来不是二分->利用三数取中趋近二分->效率提高

int GetMidIndex(int* a, int begin, int end)
{
	int mid = (begin + end) / 2;
	if (a[begin] < a[mid])
	{
		if (a[mid] < a[end])
		{
			return mid;
		}
		else if (a[begin] > a[end])
		{
			return begin;
		}
		else
		{
			return end;
		}
	}
	else //a[begin] > a[mid]
	{
		if (a[mid] > a[end])
		{
			return mid;
		}
		else if (a[begin] < a[end])//a[mid]是最小的,此时看a[begin]和a[end]大小关系就知道谁是中间的
		{
			return begin;
		}
		else
		{
			return end;
		}
	}

}

 三数取中只要在PartSort中改一下即可,三数取中让最坏的情况不再出现,时间复杂度不再看最坏,综合而言快排时间复杂度:O(N*logN)

int PartSort(int* a, int begin, int end)
{
	//三数取中进行优化
	int midIndex = GetMidIndex(a, begin, end);
	Swap(&a[midIndex], &a[end]);

	int Keyindex = end;//若最后一个值为基准值
	while (begin < end)
	{
		//begin找比Key大的值,目的是放在Key的右边
		while (begin < end && a[begin] <= a[Keyindex])//等于的话放在Key的左边或者右边都是可以的
		{//这里一定要有=的判断条件,因为没=的话,循环不进行,begin和end都不动
		//而begin= a[Keyindex])
		{
			--end;
		}
		
		Swap(&a[begin], &a[end]);
	}

	Swap(&a[begin], &a[Keyindex]);

	return begin;//返回相遇的位置的下标,方便实现下一次的单趟排序
}

2、挖坑法:

跟左右指针法的思路差不多,也可以说比左右指针法好理解那么一点点

具体思路:

先利用三数取中方法把中间的数放在最后面

1、先将选定的基准值(最右边)直接取出,然后留下一个坑, 
2、当右指针遇到大于key时,直接将该值放入坑中,而右指针指向的位置形成新的坑位,
3、然后左指针遇到小于基准值的数时,将该值放入坑中,左指针指向的位置形成坑位,
4、循环上述步骤,直到左右指针相等。最后将基准值放入坑位之中。

下图中先展示不用三数取中法的过程(若用了三数取中法key应=3) 

【数据结构】--八大排序算法【完整版】_第10张图片

int PartSort2(int* a, int begin, int end)
{
	int midIndex = GetMidIndex(a, begin, end);
	Swap(&a[midIndex], &a[end]);

	//坑(坑的意思是这位置的值被拿走了,可以覆盖填新的值)
	int key = a[end];

	while (begin < end)
	{
		while (begin < end && a[begin] <= key)
		{
			++begin;
		}
		//左边找到比key大的填到右边的坑,begin位置就形成为了新的坑
		a[end] = a[begin];

		while (begin < end && a[end] >= key)
		{
			--end;
		}
		//右边找到比keng小的填到左边的坑,end位置就形成为了新的坑
		a[begin] = a[end];
	}
	a[begin] = key;//因为begin和end相遇了,最后停的位置肯定就是坑

	return begin;
}

3、前后指针法:

代码简单,但不易理解。

具体思路:

1、选定基准值,定义prev和cur指针(cur = prev + 1)
2、cur先走,遇到小于基准值的数停下,然后将prev向后移动一个位置
3、将prev对应值与cur对应值交换
4、循环上面的步骤,直到cur出了数组范围
5、最后将基准值与++prev的对应位置交换
6、递归排序以基准值为界限的左右区间

【数据结构】--八大排序算法【完整版】_第11张图片

最终代码改的就是PartSort3而已,QuickSort引用PartSort3函数 

int PartSort3(int* a, int begin, int end)
{
	int midIndex = GetMidIndex(a, begin, end);
	Swap(&a[midIndex], &a[end]);

	int cur = begin;
	int prev = begin - 1;//不能给-1,因为begin不一定就是1,快排分区间的时候begin会有变化
	int keyindex = end;

	while (cur < end)
	{//循环判断条件为什么没cur==end的条件呢?
	//cur==end的话,完全没必要交换,没必要执行if中的语句
	//没必要多执行一次,当然cur<=end也行,不影响结果

		//若++prev==cur,交不交换都行,但是最好不交换
		//即使++prev==cur,prev也会++,因为这条判断语句执行过了
		if (a[cur] < a[keyindex] && ++prev != cur)
			Swap(&a[cur], &a[prev]);

		//不管a[cur]和a[keyindex]的关系如何,cur都会往后走
		cur++;
	}
	Swap(&a[++prev], &a[keyindex]);//cur走到最后只需++prev和keyindex交换即可

	return prev;
}

 快排的一些优化:

在递归中,若递归到后面已没什么数据了,而你还在一直递归找key值往下划分,这样就会效率低下,而且建的栈也相对多了一些。

小区间使用插入排序排,不再使用快速排序的递归排,减少整体的递归次数。

void QuickSort(int* a, int left, int right)
{
	assert(a);
	//递归终止的情况是left==right这是只有一个值情况下一定可为升序
	//还有一种就是left= right)
		return;

	if ((right - left + 1) > 10)
	{//若在[left,right]间的数据个数>10,用快排效率高
		int div = PartSort3(a, left, right);
		//[left,div-1] div [div+1,right]
		QuickSort(a, left, div - 1);
		QuickSort(a, div + 1, right);
	}
	else
	{
		//小于等于10个以内的区间,不再递归排序,用插入排序
		InsertSort(a + left, right - left + 1);
	}

}


4、快速排序的非递归实现

非递归的意义:

1、提高效率(递归建立栈帧还是有消耗的,但对于现代计算机,这个优化微乎其微,可以忽略)

2、递归最大的缺陷:若栈帧的深度太深,可能导致栈溢出,因为系统栈空间一般不大,再兆(M)级别,数据结构栈模拟非递归,数据是存储在堆上的,堆是G级别的空间。

非递归的实现思路:

用栈来保存需要排序的左右区间,那肯定是先用左,再用右,那就要利用栈先进后出的性质,先利用的肯定是后入栈。

首先跟递归一样利用单趟排序,利用那三种方法哪种都行,所谓递归无非就在于他把区间不断递归划分然后找基准值key,那就让栈来做这件事就可实现非递归。每次利用栈先进后出的性质,把区间放入栈内并从栈中取出来

void QuickSortNonR(int* a, int left, int right)
{
	//创建栈
	struct Stack st;
	StackInit(&st);

	//原始数组区间入栈,先入右再入左,才会先出左后出右
	StackPush(&st, right);
	StackPush(&st, left);

	//将栈中区间排序
	while (!StackEmpty(&st))
	{
		//如果right先入栈,栈顶为left
		left = StackTop(&st);
		StackPop(&st);
		right = StackTop(&st);
		StackPop(&st);

		//得到基准值
		int div = PartSort3(a, left, right);
		//经过这个步骤说明key的左边都key
		//只要key左边和右边有序,整个数组就有序了
		//故还要往下划分区间,找key,跟递归思路一样

		// 以基准值为分割点,形成左右两部分
		//这里先入key右边的区间,再入key左边的区间
		//这样取出来就先是key左边的,再是key右边的区间
		if (div + 1 < right)
		{//如果div+1>=right,==说明就剩一个值了,不用排序了,>说明区间不可以了,也不用排了
		//入栈就说明要拿出来排,这里跟之前讲的递归终止条件差不多
			StackPush(&st, right);
			StackPush(&st, div + 1);
		}
		if (left < div - 1)
		{
			StackPush(&st, div - 1);
			StackPush(&st, left);
		}
	}
	StackDestroy(&st);//栈是动态开辟的,用完它一定要释放
}

【数据结构】--八大排序算法【完整版】_第12张图片

引申的总结:

递归改非递归(所有的递归都能改为非递归)

1、改为循环(如斐波那契数列),一般一些简单的递归才能改为循环

2、栈模拟存储数据非递归

七、归并排序

归并排序的思想:

 归并排序的单趟排序的思想,合并两段有序数组,合并以后依旧有序,在有序的前提下归并一次单趟排序就有序了。

但给你一个数组你无法保证它左右两端是有序的,那就把它的左端和右端分一半(n->n/2->n/4.....直到只剩一个数),并且一直分,直到剩一个数,一个数肯定是有序的,再往回归并即可(此时满足,左端和右端有序,只有整体不保证有序就可以用归并

【数据结构】--八大排序算法【完整版】_第13张图片

整个过程:分解(递归的过程)+ 合并(递归往回退) 

 整个递归过程类似于后序遍历(左右根),不会先合并,而是会一直往下分解,直到分解到剩一个数(即分割到不可分割时)才开始合并。

归并:(和之前讲过的合并两个有序的链表思路一样),对于两个有序的区间,从两个区间开头比(两个区间begin1和begin2分别指向开头,其实就是下标),建立一个tmp数组来存储,小的数放在tmp数组中,然后对应的小的数存在的区间下标++(即begin1++或者begin2++),另一个区间不动,然后再比较,还是两个区间中小的数放入tmp中,并且下标++......一直比,直到其中有一个区间走完了,再把另一个区间的数拷贝到tmp数组的后面,即整个tmp数组就是两端区间整体有序的合并,再拷贝回a数组中即可。

那为什么需要创建额外空间tmp数组?

在归并排序算法中,我们将待排序的数组分成两个子数组,然后分别对这两个子数组进行排序,最后将两个有序的子数组合并成一个有序的数组。

在合并两个有序的子数组时,我们需要使用一个临时数组来存储合并后的结果。这是因为在合并过程中,我们需要按照一定的顺序比较两个子数组中的元素,并将较小的元素依次放入临时数组中。如果直接在原始数组中进行合并操作,可能会导致元素的位置被覆盖,从而导致错误的排序结果。

通过使用临时数组,我们可以保证合并操作不会影响到原始数组中的元素位置,从而确保排序的正确性。在合并完成后,我们再将临时数组中的元素复制回原始数组的对应位置,完成整个归并排序过程。

因此,在归并排序中建立临时数组是为了保证合并操作的正确性和稳定性。

归并排序的时间复杂度和空间复杂度:

时间复杂度:O(N*logN)

本质上就跟二叉树一样,高度logN,而每一层都是N,故为N*logN

空间复杂度:O(N)(用空间换时间

因开辟了额外的临时空间

1、归并排序的递归实现

void _MergeSort(int* a, int left, int right, int* tmp)
{
	//只有一个元素,或区间不对则终止
	if (left >= right)
	{
		return;
	}

	//划分数组,每次一分为二
	int mid = (left + right) / 2;
	//左区间[left,mid]右区间[mid+1,right],左右区间有序,才可合并
	//现他们没有序,故用子问题解决

	_MergeSort(a, left, mid, tmp);//划分左区间
	_MergeSort(a, mid + 1, right, tmp);//划分右区间

	//直到有序才开始合并有序序列
	int begin1 = left, end1 = mid;//有序序列1
	int begin2 = mid + 1, end2 = right;//有序序列2
	int index = begin1;
	while (begin1 <= end1 && begin2 <= end2)//任意一个区间到头了就会结束
	{
		if (a[begin1] < a[begin2])
		{
			tmp[index++] = a[begin1++];
		}
		else
		{
			tmp[index++] = a[begin2++];
		}
	}

	//对于还剩有数的序列,它的元素加到tmp数组中
	while (begin1 <= end1)
	{
		tmp[index++] = a[begin1++];
	}

	while (begin2 <= end2)
	{
		tmp[index++] = a[begin2++];
	}

	//将合并后的序列拷贝到原数组中
	//在这里拷贝的原因是 保证返回到上一层递归后两个子序列中的元素是有序的
	int j = 0;
	for (j = left; j <= right; j++)
	{
		a[j] = tmp[j];
	}
}

void MergeSort(int* a, int n)
{
	assert(a);
	//因为需要将两个有序序列合并,需借助额外数组
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc");
		exit(-1);
	}

	_MergeSort(a, 0, n - 1, tmp);

	free(tmp);
	tmp = NULL;
}

2、归并排序的非递归实现

归并排序的递归->非递归:可以用栈和队列,但是都不够好,因为他还有空间复杂度的消耗,这里递归改为非递归用迭代好一点。

思路:

理想情况下:

【数据结构】--八大排序算法【完整版】_第14张图片

本质上gap控制的是几个数据合并,gap=1,就1个1个合。

 那么对于代码实现,区间应该如何划分?

 若用 for (int i = 0; i < n; i++)

一对就是两部分(两组),每次都是一组一组来走,那每一组都对应一个由gap划分的区间

若为开区间则为:[i,i+gap) [i+gap,i+2*gap)

若为闭区间则为:[i,i+gap-1]   [i+gap,i+2*gap-1] 

因为我们之前写的归并代码是在闭区间的基础上写的,所以这里用闭区间的,并且我们把归并部分的代码单独放在一个函数中MergeArr以方便这里的非递归的归并。

【数据结构】--八大排序算法【完整版】_第15张图片

//归并排序的非递归实现
void MergeArr(int* a, int begin1, int end1, int begin2, int end2, int* tmp)
{
	int left = begin1, right = end2;
	int index = begin1;
	while (begin1 <= end1 && begin2 <= end2)//任意一个区间到头了就会结束
	{
		if (a[begin1] < a[begin2])
		{
			tmp[index++] = a[begin1++];
		}
		else
		{
			tmp[index++] = a[begin2++];
		}
	}

	//对于还剩有数的序列,它的元素加到tmp数组中
	while (begin1 <= end1)
	{
		tmp[index++] = a[begin1++];
	}

	while (begin2 <= end2)
	{
		tmp[index++] = a[begin2++];
	}

	//将合并后的序列拷贝到原数组中
	//在这里拷贝的原因是 保证返回到上一层递归后两个子序列中的元素是有序的
	int j = 0;
	for (j = left; j <= right; j++)
	{
		a[j] = tmp[j];
	}

}

void MergeSortNonR(int* a, int n)
{
	assert(a);
	//因为需要将两个有序序列合并,需借助额外数组
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc");
		exit(-1);
	}

	int gap = 1;
	
	while (gap <= n / 2)
	{//当对于这个数组来说,被分成最多的两半,即gap=n/2

		//对于gap=一个数时,for循环内部就能实现gap=某个数时的排序
		for (int i = 0; i < n; i += 2 * gap)
		{//每次i+=2*gap就能实现这区间内每一对的排序,且不会越界
			//[i,i+gap-1] [i+gap,i+2*gap-1]
			int begin1 = i, end1 = i + gap - 1;
			int begin2 = i + gap, end2 = i + 2 * gap - 1;
			MergeArr(a, begin1, end1, begin2, end2, tmp);
		}
		gap *= 2;
		PrintArray(a, n);
	}

	free(tmp);
	tmp = NULL;
}

执行结果如下: 

 【数据结构】--八大排序算法【完整版】_第16张图片

如果把a数组的元素个数改为6,运行下代码,程序直接崩溃

【数据结构】--八大排序算法【完整版】_第17张图片

 故上述代码还存在问题,当元素个数是2的倍数时才会恰好排好序本质上是因为gap每次都能正好把区间正好分为一半,因为gap每次都*=2,所以恰好分割时正好排好序,但不是2的倍数的情况下,比如只有6个元素,最终可能左边分为4个,右边分为两个才对,但代码中我们的区间范围会产生越界等问题,所以要考虑一下。

下面说的第一个,第二个数组是因为从左到右分对一对有两部分每部分又称为每个组,左面的那部分为第一组,右面那部分为第二组,每一组都是gap个数据。

 【数据结构】--八大排序算法【完整版】_第18张图片

1、第一个数组越界时,第二个数组不存在,所以不用合并,第一个数组本身就是有序数组
2、第二个数组完全越界时,第二个数组依然不存在,所以不用合并
3、部分组越界时,第二个数组存在但是不完整,此时我们将第二个数组的结束位置调整为原数组末尾位置即可,让第一个数组和第二个数组合并。

//归并排序的非递归实现
void MergeArr(int* a, int begin1, int end1, int begin2, int end2, int* tmp)
{
	int left = begin1, right = end2;
	int index = begin1;
	while (begin1 <= end1 && begin2 <= end2)//任意一个区间到头了就会结束
	{
		if (a[begin1] < a[begin2])
		{
			tmp[index++] = a[begin1++];
		}
		else
		{
			tmp[index++] = a[begin2++];
		}
	}

	//对于还剩有数的序列,它的元素加到tmp数组中
	while (begin1 <= end1)
	{
		tmp[index++] = a[begin1++];
	}

	while (begin2 <= end2)
	{
		tmp[index++] = a[begin2++];
	}

	//将合并后的序列拷贝到原数组中
	//在这里拷贝的原因是 保证返回到上一层递归后两个子序列中的元素是有序的
	int j = 0;
	for (j = left; j <= right; j++)
	{
		a[j] = tmp[j];
	}

}

void MergeSortNonR(int* a, int n)
{
	assert(a);
	//因为需要将两个有序序列合并,需借助额外数组
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc");
		exit(-1);
	}

	int gap = 1;
	
	while (gap < n)
	{//gap不是每次折半二分二分往下走了,= n)
				break;
			//2、合并时只有第一组,则无需合并
			if (begin2 >= n)
				break;
			//3、合并时第二组只有部分数据,需修正end2边界
			if (end2 >= n)
				end2 = n - 1;

			MergeArr(a, begin1, end1, begin2, end2, tmp);
		}
		gap *= 2;
	}

	free(tmp);
	tmp = NULL;
}

运行结果正确: 

【数据结构】--八大排序算法【完整版】_第19张图片

八、计数排序

思路:

如果能统计数组中每个数出现的次数,就能把他们排出来

效率,但是如果范围很大就会很费空间。计数排序针对一些特殊场景比较适用。适用数据范围很集中,不适用于最大值与最小值相差特别大的那种,不然动态开辟会开辟很多空间。并且只适用于整形,如果是浮点型或字符串排序,还得用比较排序。

【数据结构】--八大排序算法【完整版】_第20张图片

【数据结构】--八大排序算法【完整版】_第21张图片

//计数排序
void CountSort(int* a, int n)
{
	//1、确定范围,先找出最大值最小值
	assert(a);
	
	int max = a[0];
	int min = a[0];
	for (int i = 1; i < n; i++)
	{
		if (max < a[i])
			max = a[i];
		if (min > a[i])
			min = a[i];
	}

	int range = max - min + 1;//统计次数的数组的元素个数应为range
	int* countArr = (int*)malloc(sizeof(int) * range);//统计出现次数的数组
	memset(countArr, 0, sizeof(int) * range);//初始化countArr数组

	//统计次数
	for (int i = 0; i < n; i++)
	{//每个数出现是多少,它的相对下标就是多少(但用的是相对位置)
		countArr[a[i] - min]++;//a[i]-min表示的是相对位置
	}
	//排序
	int index = 0;
	for (int j = 0; j < range; j++)
	{
		while (countArr[j]--)
		{	//出现几次就放几个
			a[index++] = j + min;
			//因为是相对位置,所以对应数是j+min
		}
	}

	free(countArr);
}

计数排序的时间复杂度和空间复杂度:

时间复杂度:O(N+range)(本来为2N,故约等于N)

空间复杂度:O(range)

九、总结:

1、掌握排序的实现

2、排序的时间复杂度和空间复杂度(要理解,不要硬背)

3、稳定性

4、排序之间特性的对比

归并文件排序(了解)我的另一篇博客有写

计数排序(了解)

【数据结构】--八大排序算法【完整版】_第22张图片

稳定性:

数组中相同值,排完序相对顺序可以做到不变就是稳定的,否则就不稳定。

稳定性还是重要的,因为比如考试中给班级前三名同学颁发奖品,如果现班级前几名分数依次为100,99,96,96,96,那三个96我们肯定认为谁先交谁就是第三名,如果这个排序稳定,那就是公平的,如果不稳定,就可能造成不公平,后交的变成了第三名。

那么判断这个排序是否稳定可以看如果数组中有相同值,相同值会不会换,主要依靠的就是对应排序的思想。

若想比较各大排序算法的效率,可以用以下的测试代码来测试。

你可能感兴趣的:(【数据结构】知识篇+代码讲解,排序算法,数据结构,算法)