数据结构初阶最终章------>经典八大排序(C语言实现)

前言:
  正如标题所言,本篇博客是数据结构初阶的最终章节.但不是数据结构的最终章节!事实上,诸如AVL 树,红黑树这样高阶复杂的数据结构使用C语言非常麻烦,这些数据结构我会放在后续的C++的博客中去讲解!今天我们讲解的是八大经典的排序算法。因为排序真的是太太太重要了!!!不仅是是在生活中我们经常需要排序,更因为排序更是面试中的必考题!!!,所以接下来请跟进我的脚步,我来带你走进面试常问的八大排序算法。
本文重点

1.冒泡排序
2.直接插入排序
3.选择排序
4.希尔排序
5.堆排序
6.快速排序(多种方法实现)
7.归并排序

8.计数排序

我们接下来的所有排序都是默认升序。
1.冒泡排序
  作为我们大学学习C语言的第一个接触的排序,这个排序的思想非常简单:两两比较,如果前面大则交换,就这样大的数最终就会到后面,小的数就会到前面,如同水里的气泡一样,因此得名冒泡排序,接下来我们来看一看一趟冒泡排序的动图演示:

在这里插入图片描述
接下来,我们写出代码。

void Swap(int* pa, int* pb)
{
	int tmp = *pa;
	*pa = *pb;
	*pb = tmp;
}
//冒泡排序
void BubbleSort(int* a, int n)
{  //i是冒泡的趟数,确定的也是冒泡最后落到的数字区间
	for (int i = 0; i < n; ++i)
	{     //标杆变量
		  int exchange = 0;
		for (int j = 1; j <n-i ; ++j)
		{
			if (a[j - 1] > a[j])
			{
				Swap(&a[j - 1], &a[j]);
				exchange = 1;
			}
		}
		if (exchange == 0)
			break;
	}
}

这里我们做了一点小优化,我们设置了一个标杆变量exchange,当发生交换的时候,exchange置成1,而如果在交换的过程中没有发生交换,那么就说明已经是有序了,不用再进行比较,就可以break了。

最好的时间复杂度:O(N):当数据有序的时候,冒泡排序的时间复杂度最低
最坏的时间复杂度:O(N^2):当数据是逆序的时候,总的需要比较交换的次数的和是一个等差数列的求和.

所以当数据量非常大,并且是逆序的时候,使用冒泡排序就会非常慢!但是冒泡排序也有优点,那就是思想易于理解。适合初学者学习。
2.插入排序
  插入排序的思想也比较好理解,举个例子:大家应该都玩过斗地主把,斗地主抽完牌以后,你整理扑克的过程就是插入排序的思想。

[0,end]是一个有序的区间,那么这时候我们拿了一个数x,我们需要把x插入这个区间使得这个区间仍然有序,那么我们就可以这么做
1.如果当前的数大于end,那么把元素后挪。
2.找到了大于当前元素的元素,那么就把这个元素插入

具体我们可以通过这样的一个动图来体会:
在这里插入图片描述
具体的每一步就是找到合适位置插入有序区间,使得新区间仍然有序!所以我们写出如下的代码:

//插入排序
void InsertSort(int* a, int n)
{
	for (int i = 0; i < n - 1; ++i)
	{
		int end=i;
		int x = a[end + 1];
		while (end >= 0)
		{
			if (a[end] >x)
			{
				a[end + 1] = a[end];
				--end;
			}
			else
			{
				break;
			}
		}
		a[end + 1] = x;
	}
}

注意这里最后一个数字落到的是n-1!,这里注意的是,无论是找到了合适的位置还是end走到了–1,我们都要放入数字。所以找到合适位置我们便可以结束循环提前插入,做到代码不冗余。接下来,我们来分析一下插入排序的时间复杂度:

1.最好时间复杂度:O(N):同样是序列有序的情况下,直接插入排序的时间复杂度是最优秀的。
2.最坏的时间复杂度:O(N^2):在面对同样是逆序的情况下,直接插入排序的时间复杂度也是一个等差数列的求和,是O(N*N)

  那么这时候你可能就会疑问?插入排序和冒泡排序的最好和最坏时间复杂度都是相同的,是不是意味着这两个排序是差不多的呢?答案是否定的!接下来我们给定一组测试用例来分析,看看再数据接近有序或部分有序的情况下,插入排序会更加优秀!从思想上来看,冒泡只要前大于后就要进行交换,但是插入排序在序列接近有序的时候只要插入数据就可以了!如果能够让序列接近有序,那么插入排序将会非常高效!
3.选择排序
  选择 排序的思想也是相对来说比较好理解的,每一去遍历选出最小的数放到数组的最开头,然后接下来把这个数字剔除,从剩下的数据里面 继续重复先前的动作即可。那么接下来我们做一点小的优化,我们一次遍历选出最小和最大,然后把最小交换到左边,最大交换到右边,接下来剔除这两个数缩减区间继续重复相同的步骤。

//选择排序
void SelectSort(int* a, int n)
{
	int left = 0, right = n - 1;
	while (left < right)
	{ //mini和maxi分别表示最小、最大数的下标
		int mini = left, maxi = left;
		for (int i = left + 1; i <= right;++i)
		{
			if (a[i] < a[mini])
			{
				mini = i;
			}
			if (a[i] > a[maxi])
			{
				maxi = i;
			}
		}
		Swap(&a[left], &a[mini]);
		Swap(&a[right], &a[maxi]);
		++left;
		--right;
	}
}

但是实际上这段代码是有问题的,给定如下的测试用例:

int a[]={6,5,4,3,2,1};

我们按照代码逻辑执行完第一遍选数以后结果如下:
在这里插入图片描述
接下来我们把mini和left位置的数交换,同时再把right和maxi位置的数交换,发现6的位置又会被放到left的位置,就没有做到选出最大的数放到右边的效果!也就是如果遇到left和maxi重叠的情况代码没办法很好的处理!。解决 的方案是当left和maxi重叠,就修正maxi为mini即可!

//选择排序
void SelectSort(int* a, int n)
{
	int left = 0, right = n - 1;
	while (left < right)
	{ //mini和maxi分别表示最小、最大数的下标
		int mini = left, maxi = left;
		for (int i = left + 1; i <= right;++i)
		{
			if (a[i] < a[mini])
			{
				mini = i;
			}
			if (a[i] > a[maxi])
			{
				maxi = i;
			}
		}
		Swap(&a[left], &a[mini]);
		//如果maxi和left重叠,修正maxi即可
		if (left == maxi)
		{
			maxi = mini;
		}
		Swap(&a[right], &a[maxi]);
		++left;
		--right;
	}
}

接下来我们从选择排序的思想上来分析它的时间复杂度:

时间复杂度:O(N^2):由于无论序列是否有序,选择排序每一都要遍历选数,也正因为这样,对于选择排序而言没有什么最优的情况。所以读者可以认为选择排序是相对而言效率非常低的排序(冒泡:我也有翻身的一天!

讲完了冒泡,直接插入和选择排序,我们不难可以看出:在序列接近有序的情况下,插入排序算法的效率最高而当序列完全有序的情况下,冒泡排序的效率也相当不错,而对于选择排序几乎都是O(N*N)!
那么聪明的你可能就会想,假设给我一个随机序列,如果使用直接插入排序效率会非常低,而如果我先把这个序列进行整理,让整个序列接近有序那么再使用插入排序就更高效!那么希尔排序就是基于这个思想的排序,下面我们正式来介绍希尔排序
4.希尔排序:
  听名字就知道这个算法最早是一个叫做希尔的人提出的(膜拜大佬),希尔排序的核心思想就是:就是选定一个间距gap,以gap为间距对序列进行分组,对每一组序列进行预排序,使得每组序列接近有序,最后当gap==1的时候就相当于是对一个接近有序的序列进行直接插入排序,接下来我们通过图片来看一看希尔排序的整个过程:
在这里插入图片描述
那么从这张图片我们不难得出,当gap越大,数据越不接近有序,但是大的数字越快能够到达后面。而当gap越小,gap=1的时候就是直接插入排序!

所以我们可以写出如下单趟希尔排序的代码:

for (int i = 0; i < n - gap; ++i)
		{
			int end = i;
			//预排序,保存a[end+gap]位置的值
			int tmp = a[end + gap];
			while (end >= 0)
			{
				if (a[end] > tmp)
				{
					a[end + gap] = a[end];
					end -= gap;
				}
				else
				{
					break;
				}
			}
			a[end + gap] = tmp;
		}

那么这里最关键的就是这个间距怎么给比较合适,给固定值肯定是不行的!那么根据具体的问题的规模给定即可,一般没有特别的规定,大致有如下两种给法:

方法1:gap=gap/3+1;//官方的给法,注意最后一次一定要取到1才可以进行插入排序。
方法2:gap=gap/2;//这种给定的方法是一定能够取到1的

所以最终的实现代码如下:

//希尔排序
//进行预排序,使数据接近有序,最后使用插入排序
void ShellSort(int* a, int n)
{
	int gap=n;
	while (gap > 1)
	{   //一定要保证最后一次gap取到1,进行插入排序!
		//不是一次性预排序,而是一次预排一组一部分,下一次预排另一组一部分,多组预排
		for (int i = 0; i < n - gap; ++i)
		{
			int end = i;
			//预排序,保存a[end+gap]位置的值
			int tmp = a[end + gap];
			while (end >= 0)
			{
				if (a[end] > tmp)
				{
					a[end + gap] = a[end];
					end -= gap;
				}
				else
				{
					break;
				}
			}
			a[end + gap] = tmp;
		}
	}
}

预排序的时间复杂度是O(logN),而接近有序序列后最后一次插入排序的时间复杂度是O(N),那么希尔排序的时间复杂度就是O(N*log(N)),也有的地方计算出希尔排序的平均时间复杂度是O(N^1.3),至于为什么是这个结果,我也不知道,大家记个结论就可以了

3.堆排序
前面我们讲了堆这个数据结构,实际上也有一个排序基于这个数据结构,那就是堆排序,我们可以建立一个小堆,然后每次取堆顶的数据,然后删除堆顶元素,循环重复一直到堆为空,这样数据就被排序了!所以这个思路的代码如下:

//建立小堆取堆顶元素建堆
void HeapSort(HPDataType* a, size_t size)
{ 
	Heap hp;
	HeapInit(&hp);
	//把数据整理成堆
	for (int i = 0; i < size; ++i)
	{
		HeapPush(&hp, a[i]);
	}
	int index = 0;
	//取出堆顶元素返回原数组
	while (!HeapEmpty(&hp))
	{
		printf("%d ", HeapTop(&hp));
		HeapPop(&hp);
	}
	HeapDestroy(&hp);
}

这是我们利用堆这个数据结构进行堆排序,然而这么做开销太大,为了排序我们特意去实现这么一个数据结构,在先前的堆的博客中我们也提到了直接利用数组进行建堆的方法(不了解的可以点击这个传送门:二叉树顺序存储之堆结构那么问题来了,如果直接在原来的数组里建堆,我们要还是建立小堆吗?
答案是否定的!在原数组建堆排升序我们恰恰要建立大堆!建立小堆虽然确定了最小的,但是取走堆顶以后,所有的节点的逻辑关系全都乱了,还要重新建堆!而建堆的时间复杂度是O(N)(稍后证明)!所以我们要建大堆来排升序。


//向下调整算法
void AdjustDown(int* a, size_t n, size_t root)
{   
	size_t parent = root;
	size_t child = 2 * parent + 1;
	while (child < n)
	{   //去左右孩子大的那一个
		if (child + 1 < n && a[child + 1] > a[child])
		{
			++child;
		}
		//调整
		if (a[child] > a[parent])
		{
			Swap(&a[parent], &a[child]);
			parent = child;
			child = 2 * parent + 1;
		}
		else
		{
			break;
		}
	}
}

//堆排序
void HeapSort(int* a, int n)
{  //从最后一个非叶子节点开始向下调整
	for (int i = (n - 1 - 1) / 2; i >= 0; --i)
	{
		AdjustDown(a, n, i);
	}
	//头尾交换,不把这个节点看作堆里的节点进行调整
	size_t end = n - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);
		AdjustDown(a, end, 0);
		--end;
	}
}

接下来我们来分析一下,堆排序的算法时间复杂度。显然,堆排序的时间复杂度是由向上调整算法或者向下调整算法决定的,那么我们来看一看这两个算法具体的时间复杂度是如何计算的。

由于时间复杂度是一个悲观的指标量,所以这里我们就以满二叉树的情况来分析这两个算法的时间复杂度:

向上调整算法:
在这里插入图片描述
所以向上调整算法的时间复杂的是O(nlogn)接下来我们对向下调整算法进行分析:
在这里插入图片描述
  从这里不难可以看出,向下调整算法的时间复杂度优于向上调整算法,也就是可以这样认为:建堆的时间复杂的是O(N),而每次选完数以后的调整的次数是O(logN),所以堆排序的时间复杂度是O(NlogN)
最会整花活的快速排序
  正如标题所言,快速排序可以说是到目前为止最能整事的排序了。不仅因为它很快,而且快速排序的玩法非常多,hoare法,挖坑法,前后指针法,并且这些方法里面的细节非常多!另外,快排也是利用了我们先前二叉树里面的的分治思想,也就意味着快排需要和递归挂钩。但是由于递归存在溢出的风险,所以在特定的情境下我们还要把快排实现成非递归的版本!另外还要对快排进行一些小的优化等等!所以说快排是一个很能“整事”

快排的递归版本:
快速排序的思想是基于二叉树而来,每次都选出一个数字做关键字(key),那么将小于key的数字都划分在key的左边,把大于key的数字都划分到key的右边,最后key的位置就不变了。接着左半区间有序,右半区间有序,整个序列就整体有序了!

那么我们接下来介绍3种单趟排序的方法:

法一:hoare法:使用两个指针left,right,key=a[left],右指针先出发,向左寻找比key小的值,如果找到就停下,换。左指针开始向前寻找,同时左指针出发找到大于key的就和右指针交换,一直重复知道二者相遇!

注意这里的key我采取的是关键字的下标,在最后left,right相遇的时候我们交换key和left的下标即可
通过一张动图来体会hoare单趟快排的过程:
在这里插入图片描述
接下来我们写出单趟的排序:

//版本一:hoare
//单趟排序
int PartionByleft(int* a, int left, int right)
{
	int keyi = left;
	//选左边做为key,那么右边先走才能相遇!
	while (left < right)
	{  //第一个防止找不到而越界,第二个则是防止当遇到值和key相同的数死循环!
		while (left < right && a[right] >= a[keyi])
		{
			--right;
		}
		while (left < right && a[left] <= a[keyi])
		{
			++left;

		}
		Swap(&a[left], &a[right]);
	}
	Swap(&a[left], &a[keyi]);
	return left;
}

经过这么一趟排序,左边的数据都比key小,右边的数据都比key大,那么key最终确定就在这个位置。接下来只要key的左半区间有序,右半区间有序,那么这个序列就整体有序了。如何做才能让左右区间有序呢?->分治递归左半区间,递归右半区间即可。

//快速排序
void QuickSort(int* a, int begin, int end)
{   
	if (begin >= end)
		return;
	//hoare
	int keyi = PartionByleft(a, begin, end);
	QuickSort(a, begin, keyi - 1);
	QuickSort(a, keyi+1, end);
}

如果不能很好理解这个分治递归的童鞋,可以自己动手尝试画出递归展开图。
到了这里你可能还是不太理解hoare法为什么就能把一个数排到最终确定得位置,那么接下来我就再给你介绍另一个方法------>挖坑法

法二:挖坑法:
这个方法是基于hoare法做了一个改进,定义了一个坑(pivot),假如我们选左边做key,那么最开始的pivot就是key的下标,接下来右边开始出发找小于key的元素,找到以后把这个元素的值赋给当前pivot指向的元素,然后它成为新的pivot。这时候再左边开始寻找大于key的元素,找到后把值给给pivot指向的元素,然后变成新的坑。重复知道left和right相遇(一定相遇在pivot),最后把key放入就可以了。

同样通过一张动图来体会这个单趟排序的过程:
在这里插入图片描述
那么接下来我们就可以动手写出挖坑法的单趟排序:

int PartionByPiv(int* a, int left, int right)
{    //定义坑位
	int pivot = left;
	int key = a[left];
	while (left < right)
	{  //左边做key,右边先走
		while (left < right && a[right] >= key)
			--right;
       //找到小的数放置,挖新坑
		a[pivot] = a[right];
		pivot = right;
		//左边找大数
		while (left < right && a[left] <= key)
			++left;
		a[pivot] = a[left];
		pivot = left;
	}
	a[pivot] = key;
	return pivot;
}

和hoare法一样,整体有序只要左区间有序,右区间有序,整体就有序了。所以也是分治递归。
介绍了hoare法,挖坑法,最后再来一个前后指针法:
前后指针法:
  相对于前面的两个方法,前后双指针方法不太好理解,所以接下来我们通过一张动态图片来分析一下前后指针法怎么个原理:
在这里插入图片描述
从这个动图可以看出:

当cur一直遇到小于key的值,那么prev和cur就永远是紧挨着一个距离的。
而当cur遇到大的值的时候,就会逐步拉开和prev的距离

所以我们可以写出如下的代码:

int PartionByPrevCur(int* a, int left, int right)
{   
	int prev = left, cur = left + 1;
	int keyi = left;
	while (cur <= right)
	{
		//cur找到小并且交换有意义才进行交换
		if (a[cur] < a[keyi] && a[++prev] != a[cur])
		{
			Swap(&a[prev], &a[cur]);
		}
		++cur;
 	}
	Swap(&a[prev], &a[keyi]);
	return prev;
}

接下来也是和之前一样,对选出来的keyi的左右区间分治递归即可。
介绍完了这3种方法,我们来分析一下这个算法的时间复杂度:

首先我们先来看单趟遍历:不难看出,我们需要选keyi是要遍历整个区间的,所以单趟排序的时间复杂度是O(N),那么接下来递归分治成2个区间去处理,分治logN次,所以快排的平均时间复杂度是O(n*logn)

同时因为递归的原因,快排还存在O(logn)的空间复杂度
正因为递归存在栈溢出的问题,面试官就又会向你提出要求:小伙子实现以下非递归的快速排序吧。虽然心里有万般的不愿意,但是没办法。快排的非递归我们同样需要掌握!
首先我们仔细想一想递归的时候发生的事情:创建栈帧,保存begin,end区间。假如我们能够保存begin和end区间,那么就不需要递归也可以选出keyi,这里我们就可以利用数据结构的栈进行操作来模拟递归的动作:

//快速排序非递归实现
//每次快排的递归存储的都是分治的区间,使用一个栈来模拟
void QuickSortNonR(int* a, int begin, int end)
{
	Stack st;
	StackInit(&st);
	StackPush(&st, begin);
	StackPush(&st, end);
	while (!StackEmpty(&st))
	{
		int right = StackTop(&st);
		StackPop(&st);
		int left = StackTop(&st);
		StackPop(&st);
		//单趟排序
		int keyi = PartionByPiv(a, left, right);
		//符合条件的区间才入
		if (left < keyi - 1)
		{
			StackPush(&st, left);
			StackPush(&st, keyi - 1);
		}
		if (keyi+1 < right)
		{
			StackPush(&st, keyi+1);
			StackPush(&st, right);
		}
	}
	StackDestroy(&st);
}

  这时候你就会很好奇,快排真的什么时候都是最快速的吗?答案显然是否定的仔细想一想,快排的思想有点类似于二叉树,如果这个二叉树是一个单边的树,即每次选到的keyi都是端点值得画,那么快排也退化成了一个O(N*N)的算法!为了处理这种极端情况,官方还提供了一种三数取中选keyi的方法,具体的代码如下:

//三数取中优化
int GetMidIndex(int* a, int left, int right)
{
	int midi = ((right - left) >> 1) + left;
	if (a[left] < a[midi])
	{
		if (a[midi] < a[right])
		{
			return midi;
		}
		//a[midi]>a[right] a[left]和a[right]比
		else if (a[left]<a[right])
		{
			return right;
		}
		else
		{
			return left;
		}
	}
	//a[left]>a[midi]
	else
	{
		if (a[midi] > a[right])
		{
			return midi;
		}
		//a[midi]<a[right] 比 l和r
		else if (a[left]<a[right])
		{
			return left;
		}
		else
		{
			return right;
		}
	}
}

  有了三数取中,就可以很好避免出现上面单边树那种分治的极端情况,这时候的快排可以说是在绝大多数的情况下都很快了。
  对于快速排序,官方还进行了一个小区间优化。我们知道,快速排序要递归,那么就要占用栈空间,为了能够解放部分的栈空间,减少不必要的递归,那么在区间长度小于10的时候,调用直接插入排序是一个不错的选择。

//快速排序
void QuickSort(int* a, int begin, int end)
{   
	if (begin >= end)
		return;
	if (end - begin + 1 <= 10)
	{
		InsertSort(a, end - begin + 1);
	}
	else
	{   
		int keyi = PartionByleft(a, begin, end);
		QuickSort(a, begin, keyi - 1);
		QuickSort(a, keyi + 1, end);
	}	
}

归并排序:
  在链表那一个章节,我们曾经做过一道叫做合并两个有序链表的OJ题,那道题目我们的思路就是:两个链表有序,那么每次从两个链表里取小插入新链表得到最后的序列依然有序。其实这就是我们接下来要讲得归并排序的思路:接下来我们通过一张图片来体会一下归并排序的流程:
在这里插入图片描述不难可以看出,整个归并排序的流程也是和快排类似,递归分割区间然后知道不可分割就开始有序的合并区间!具体我们需要开辟一个额外的临时空间,在这个区间里归并,然后把临时数组里的内容拷贝到原来的空间。

void _MergeSort(int* a, int* tmp, int begin, int end)
{
	int mid = ((end - begin) >> 1) + begin;
	if (begin >= end)
		return;
	_MergeSort(a, tmp, begin, mid);
	_MergeSort(a, tmp, mid + 1, end);
	//走到这里,递归结束开始归并
	int index = begin;
	int begin1 = begin, end1 = mid, begin2 = mid + 1, end2 = end;
	while (begin1 <= end1 && begin2 <= end2)
	{
		if (a[begin1] < a[begin2])
		{
			tmp[index++] = a[begin1++];
		}
		else
		{
			tmp[index++] = a[begin2++];
		}
	}
	while (begin1 <= end1)
	{
		tmp[index++] = a[begin1++];
	}
	while (begin2 <= end2)
	{
		tmp[index++] = a[begin2++];
	}
	memcpy(a + begin, tmp + begin, (end - begin + 1) * sizeof(int));
}

//归并排序
void MergeSort(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	assert(tmp);
	_MergeSort(a,tmp ,0, n - 1);
	free(tmp);
}

接下来我们画出递归展开图来帮助分析和理解
在这里插入图片描述
这里的区间的划分如果是[begin,mid-1][mid,end]那么在特定的情况下会出现死递归!

比如在递归分治(0,1)区间,可得到mid=0,那么分治的区间是(0,-1)和(0,1)(0,1)这里就出现了死递归,最终的结果就是栈溢出!

  递归版本的归并排序还算比较人性化,但是由于递归存在栈溢出的问题,那么我们还要实现归并排序的非递归版本
首先我们来通过图解体会一下非递归的归并排序:
在这里插入图片描述
每次控制两个区间[i,i+dis-1],[i+dis,i+2*dis-1]区间进行单趟归并,接下来再用dis来控制归并区间的操作次数,所以最终的代码如下:

  int* tmp = (int*)malloc(sizeof(int) * n);
	  assert(tmp);
	  int dis = 1;
	  while (dis < n)
	  {
		  for (int i = 0; i < n; i += 2 * dis )
		  {
			  int begin1 = i, end1 = i + dis - 1;
			  int begin2 = i + dis , end2 = i + 2 * dis - 1;
			  int index = i;
			  while (begin1 <= end1 && begin2 <= end2)
			  {
				  if (a[begin1] < a[begin2])
				  {
					  tmp[index++] = a[begin1++];
				  }
				  else
				  {
					  tmp[index++] = a[begin2++];
				  }
			  }
			  while (begin1 <= end1)
			  {
				  tmp[index++] = a[begin1++];
			  }
			  while (begin2 <= end2)
			  {
				  tmp[index++] = a[begin2++];
			  }
		  }
		  dis *= 2;
		  memcpy(a, tmp, sizeof(int) * n);
	  }

测试用例:int a[]={10,6,7,1,3,9,4,2};

运行结果如下:
在这里插入图片描述
程序运行出了正确的结果,但是我们的代码就真的是正确的吗?多加一个数字11,看看结果如何
运行结果如下:
在这里插入图片描述
我们发现程序报了个内存错误,通常这种错误都是在free的时候发现的,说明说我们的代码存在越界问题。除了调试,我们还可以借助打印日志来分析错误:

//归并排序非递归版本
void MergeSortNoneR(int* a, int n)
{
	  int* tmp = (int*)malloc(sizeof(int) * n);
	  assert(tmp);
	  int gap = 1;
	  while (gap < n)
	  {
		  for (int i = 0; i < n; i += 2 * gap)
		  {
			  int begin1 = i, end1 = i + gap - 1;
			  int begin2 = i + gap, end2 = i + 2 * gap - 1;
			  int index = i;

			  printf("归并[%d,%d][%d,%d]\n", begin1, end1, begin2, end2);
			  while (begin1 <= end1 && begin2 <= end2)
			  {
				  if (a[begin1] < a[begin2])
				  {
					  tmp[index++] = a[begin1++];
				  }
				  else
				  {
					  tmp[index++] = a[begin2++];
				  }
			  }
			  while (begin1 <= end1)
			  {
				  tmp[index++] = a[begin1++];
			  }
			  while (begin2 <= end2)
			  {
				  tmp[index++] = a[begin2++];
			  }
		  }
		  gap *= 2;
		  memcpy(a, tmp, sizeof(int) * n);
	  }
	free(tmp);
}

打印日志如下:
在这里插入图片描述
这里很明显看到,end1,begin2,end2发生了越界访问!原因就是区间长度不是2的倍数就很可能出现越界问题! 那么接下来我们就要对end1,begin2,end2进行修正!

修正方式1:只要越界就修正成n-1

//归并排序非递归版本
void MergeSortNoneR(int* a, int n)
{
	  int* tmp = (int*)malloc(sizeof(int) * n);
	  assert(tmp);
	  int gap = 1;
	  while (gap < n)
	  {
		  for (int i = 0; i < n; i += 2 * gap)
		  {
			  int begin1 = i, end1 = i + gap - 1;
			  int begin2 = i + gap, end2 = i + 2 * gap - 1;
			  int index = i;

			  printf("归并[%d,%d][%d,%d]\n", begin1, end1, begin2, end2);
			  if (end1 >= n)
				  end1 = n - 1;
			  if (begin2 >= n)
				  begin2 = n - 1;
			  if (end2 >= n)
				  end2 = n - 1;
			  
			  while (begin1 <= end1 && begin2 <= end2)
			  {
				  if (a[begin1] < a[begin2])
				  {
					  tmp[index++] = a[begin1++];
				  }
				  else
				  {
					  tmp[index++] = a[begin2++];
				  }
			  }
			  while (begin1 <= end1)
			  {
				  tmp[index++] = a[begin1++];
			  }
			  while (begin2 <= end2)
			  {
				  tmp[index++] = a[begin2++];
			  }
		  }
		  gap *= 2;
		  memcpy(a, tmp, sizeof(int) * n);
	  }
	free(tmp);
}

实际上这样修正依然没有完整处理越界问题!,假设最后出现了这种情况:其中一个待归并的区间是另外一个区间的子区间这时候就会出现数据重复写入index越界的情况!
所以正确的修正情况要分如下几种情况:

情况1:end1越界,修正end1即可
情况2:begin2越界,那么把第二个区间修正成一个不存在的区间
情况3:begin2正常,end2越界,那么修正end2就可以了

所以最后的排序代码如下:

//归并排序非递归版本
void MergeSortNoneR(int* a, int n)
{
	  int* tmp = (int*)malloc(sizeof(int) * n);
	  assert(tmp);
	  int dis= 1;
	  while (dis< n)
	  {
		  for (int i = 0; i < n; i += 2 * dis)
		  {
			  int begin1 = i, end1 = i + dis- 1;
			  int begin2 = i + dis, end2 = i + 2 * dis- 1;
			  int index = i;

			  printf("归并[%d,%d][%d,%d]\n", begin1, end1, begin2, end2);
			
			  end1越界修正即可
			  if (end1 >=n)
			  {
				  end1 = n - 1;
			  }
			  //begin2越界,设置成不存在的区间
			  if (begin2 >=n)
			  {
				  begin2 = n+1;
				  end2 = 2;
			  }
			  //如果begin2有效,end2越界,修正end2
			  if (begin2<n && end2>=n)
			  {
				  end2 = n - 1;
			  }
			  while (begin1 <= end1 && begin2 <= end2)
			  {
				  if (a[begin1] < a[begin2])
				  {
					  tmp[index++] = a[begin1++];
				  }
				  else
				  {
					  tmp[index++] = a[begin2++];
				  }
			  }
			  while (begin1 <= end1)
			  {
				  tmp[index++] = a[begin1++];
			  }
			  while (begin2 <= end2)
			  {
				  tmp[index++] = a[begin2++];
			  }
		  }
		  dis*= 2;
		  memcpy(a, tmp, sizeof(int) * n);
	  }
	free(tmp);
}

  最后我们来分析一下归并排序的时间复杂度:
和快排不一样,归并排序是绝对的二分,所以说归并排序是绝对的O(N*log(N))
  介绍完了前面的这些比较排序,最后再介绍一个“巧妙”的排序---->计数排序

开辟一个数组,里面的元素全是0,如果碰到对应位置的数就++,然后在取的时候把对应位置的数的下标取出,然后把个数–,最后得到的数据就是有序的。

代码如下:

// 计数排序
void CountSort(int* a, int n)
{
	int min = INT_MAX, max = INT_MIN;
	for (int i = 0; i < n; ++i)
	{
		if (a[i] < min)
		{
			min = a[i];
		}
		if (a[i] > max)
		{
			max = a[i];
		}
	}
	int range = max - min + 1;
	int * countArray = (int*)calloc(range, sizeof(int));
	assert(countArray);
	for (int i = 0; i < n; ++i)
	{
		countArray[a[i] - min]++;
	}
	//写数据
	int index = 0;
	for (int i = 0; i < range; ++i)
	{
		while (countArray[i]--)
		{
			a[index++] = i + min;
		}
	}
	free(countArray);
}

这个代码使用了相对映射,也就是根据数据的最大范围来分配空间,然后再取出的时候加上min即可.
时间复杂度:O(range+n)
空间复杂度:O(range)

这个排序的局限性很大:因为这个排序只能排int类型的数据,对于其它的数据类型并不能排序.
7.排序的稳定性:
  一直以来,可能你们对排序的稳定性一直有误解,稳定性并不是说排序的性能够不够好,而是对数据的处理够不够好。举个例子,有的时候在分数相同的前提下,要把数学成绩作为第二关键字来进行排序,如果能够做到这么一件事。我们就说这个排序是稳定的,反之就是不稳定的。
  下表是各种排序的稳定性

排序稳定性
冒泡排序稳定
插入排序稳定
希尔排序不稳定
直接选择不稳定
堆排序不稳定
快速排序不稳定
归并排序稳定
计数排序不稳定

最终测试排序稳定性:
  如果读者想要测试排序的性能,可以使用系统里的clock()函数,然后用作差的方式来求出排序消耗的时间,具体的测试代码如下:

void TestOP()
{
	const int N = 10000000;
	int* a1 = (int*)malloc(sizeof(int) * N);
	int* a2 = (int*)malloc(sizeof(int) * N);
	int* a3 = (int*)malloc(sizeof(int) * N);
	int* a4 = (int*)malloc(sizeof(int) * N);
	assert(a1);
	assert(a2);
	assert(a3);
	assert(a4);
	srand((unsigned)time(NULL));
	for (int i = 0; i < N; ++i)
	{
		a1[i] = rand();
		a2[i] = a1[i];
		a3[i] = a2[i];
		a4[i] = a3[i];
	}
	int begin1 = clock();
	QuickSort(a1, 0,N-1);
	int end1 = clock();
	printf("QuickSort: %d \n", end1 - begin1);
	int begin2 = clock();
	InsertSort(a2, N);
	int end2 = clock();
	printf("InsertSort: %d \n", end2 - begin2);
	int begin3 = clock();
	ShellSort(a3, N);
	int end3 = clock();
	printf("ShellSort: %d \n", end3 - begin3);
	int begin4= clock();
	HeapSort(a4, N);
	int end4 = clock();
	printf("HeapSort: %d \n", end4 - begin4);
	free(a1);
	free(a2);
	free(a3);
	free(a4);
}

以上就是文章所有的内容,如果有错误之处,还望读者可以指出,制作不易,希望各位百忙之中的客观能够抽空为作者的作品点一个赞,留下积极的评论。这就是你对作者最大的肯定。到这里,数据结构初阶就结束了,下一篇博客正式进入C++的大门。

``

  • 10
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 11
    评论
根据提供的引用内容,我们可以看出这是一个关于堆的实现和应用的问题。引用\[1\]提到了一种使用顺序表存储的方式来实现堆,但是这种方式存在空间浪费的问题。引用\[2\]列举了堆的接口函数和堆排序的过程。引用\[3\]介绍了一种常用且优化的表示方法,即左孩子右兄弟表示法。 根据问题描述,警告C6386是指在写入"popk"时发生了缓冲区溢出。根据提供的代码,问题出现在源文件的第64行。具体原因可能是在该行代码中,将数据写入了名为"popk"的缓冲区,但是该缓冲区的大小不足以容纳写入的数据,导致溢出。 为了解决这个问题,我们需要检查源文件中的相关代码,确保在写入缓冲区时不会超出其大小限制。可能需要调整缓冲区的大小或者使用更安全的写入方式来避免缓冲区溢出的问题。 #### 引用[.reference_title] - *1* *3* [二叉树第一弹之树和堆的概念和结构、基础堆接口函数的实现(编写思路加逻辑分析加代码实操,一应俱全的汇总...](https://blog.csdn.net/AMor_05/article/details/127175020)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^control,239^v3^insert_chatgpt"}} ] [.reference_item] - *2* [第九C语言数据结构与算法初阶之堆](https://blog.csdn.net/yanyongfu523/article/details/129582526)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^control,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值