C语言 | 数据结构与算法 | 八大排序的讲解

本文归纳数据结构中的七大排序,不说废话开始介绍排序

文章目录

  • 插入排序
    • 直接插入排序
    • 希尔排序
  • 选择排序
    • 选择排序
    • 堆排序
  • 交换排序
    • 冒泡排序
    • 快速排序
      • 1.hoare版本
      • 2.挖坑法
      • 3.前后指针法
      • 1.1hoare版本
      • 2.1挖坑法
      • 3.1前后指针法
      • 快排实现
      • 优化1:减少递归深度(效果不明显)
      • 优化2:三数取中(有效优化)
    • 快排非递归实现
  • 归并排序
    • 思想讲解
    • 代码实现
      • 注意点1
      • 注意点2
    • 非递归排序
  • 非比较排序
    • 计数排序

插入排序

直接插入排序

直插,将一个数插入到一个有序数组中。这样的操作是插入排序的单趟排序,单趟排序完成后,该数就到了数组中正确的位置。一个长度为n的数组,从第二个数开始到最后一个数,将每个数前面所有数看成一个有序数组,依次将第二个数到最后一个数插入到前面的数组中,就完成了插入排序。

那单趟排序的操作要怎么实现?假设将4插入[2,9]这个数组中,将4与数组中最后一个比较,4小于9,将9赋值到数组的后一个位置,数组变成[2,9,9],将4与2比较,4大于2,将4放到2的后面,数组变成[2,4,9],单趟排序完成。

将tmp插入一个有序的升序数组,从数组中的最后一个数开始比较,若最后一个数大于tmp,把最后一个数移动到后一个位置。将tmp与该数的前一个数比较,若该数小于tmp,将tmp赋值到该数的后一个位置,循环结束,单趟排序完成。就是说要插入的数从数组的最后开始,找到第一个比它小的数,放到第一个比它小的数后面。至此一趟单趟排序完成。

void InsertSort(int* data, int n)
{
	for (int i = 1; i < n; i++)
	{
		int tmp = data[i];
		int j = 0;
		for (j = i - 1; j >= 0; j--)
		{
			if (data[j] > tmp)// 当数大于tmp时,移动该数
				data[j + 1] = data[j];
			else
				break;// 当数小于tmp时,循环结束
		}
		data[j + 1] = tmp;
		// 循环跳出时,可能遍历完所有的数,可能在j位置处出现的数小于tmp
		// 将j + 1位置的数赋值成tmp,当数组中的所有数都大于tmp,此时遍历完所有数,j等于-1,j + 1就是数组第一个位置,将tmp放在该位置上
	}
}

外层循环控制循环次数的同时,保存要进行插入的数,内层循环负责遍历,找到第一个比要插入的数小的数,将数插入到它的后面

最后对时间复杂度进行分析,直接插入排序要对n-1个数进行插入,每次要进行比较的数都是要插入的数前面的数,数量成等差数列,在最坏的情况下,每次都要与前面的所有数进行比较,所以比较的次数是1,2,3… n-1,根据等差数列求和公式,得到要比较的次数是n * (n - 1) / 2,用大O表示法,时间复杂度是O(n ^ 2);

而直接插入排序是在原数组的基础上进行排序,所以空间复杂度为O(1)。

直接插入排序总结:

1.数组越接近有序,直接排序就越快(每一次单趟排序很快就结束)
2.时间复杂度O(n^2)
3.空间复杂度O(1)

希尔排序

若数组越接近有序,那么插入排序会越快(插入一个数,将其与它的前一个数进行比较,若数组接近有序,向前比较几个数后就能找到比它小(大)的数,插入完成,不用再向前比较,可以对下一个数进行排序了)。根据这个结论,希尔排序对数组进行预排序,每进行一次预排序,数组就越接近有序。

如何做到预排序?将数组分割成gap个子数组,对子数组进行直接插入排序,这时的数组就比之前更接近有序。再将数组分成更多的子数组,再进行插入排序,当gap为1时,就是对整个数组进行直接插入排序。举个例子
C语言 | 数据结构与算法 | 八大排序的讲解_第1张图片
相隔的距离为gap的数之间为一组,对这样的一组数进行直接插入排序。

与直接插入排序一样,将要插入的数保存。例,要把5插入到9这个数组中时,将5保存,比较5与9(往前gap位置的数)的大小,9比5大,将9赋值到5的位置上(升序)
C语言 | 数据结构与算法 | 八大排序的讲解_第2张图片

此时数组没有其他数,不能再往前比较,比较结束,将5放到原来是9的位置上。
C语言 | 数据结构与算法 | 八大排序的讲解_第3张图片
之后要找的数不是7,而是和5间隔距离为3的数,将8插入[5,9]这个数组。所以循环变量一次要加gap,不是加1

// 单趟排序
void _ShellSort(int* data, int n)
{
	int gap = 3;
	for (int j = 0; j < gap; j++)
	{
		for (int i = 0; i < n - gap; i += gap)//  比较完,i要到更新到往后距离为gap的数,所以加gap
		{
			int tmp = data[i + gap];// 先将i + gap位置上的数保存,为防止越界,for循环的判断条件是i要小于n - gap
			int pos = i;// pos是要插入的数的前一个数。当pos大于等于0,即数组中还有数要比较时,循环继续
			while (pos >= 0)
			[
				if (tmp < data[pos])
				{
					data[pos + gap] = data[pos];// 升序,若tmp比进行比较的数小,则将该数向后gap距离的位置赋值
					pos -= gap;// 要比较的位置向前走gap距离
				}
				else
					break;
			]
			data[pos + gap] = tmp;
		}
	}
}

单趟排序中有三层循环,看上面的图,最外层的循环控制的是不同颜色数组的更新;中间一层和最里面的一层循环控制同一颜色数字的排序;

可以将最外层的for和中间一层的for循环进行合并,但时间复杂度一样,至少写的更简略了。反正数组的所有数都要被遍历一次,那为什么要用两个变量遍历,所以改进的代码只用了一个变量来遍历数组。

void _ShellSort(int* data, int n)
{
	int gap = 3;
	for (int i = 0; i < n - gap; i++)
	{
		int pos = i;// pos是要进行插入的数的前一个数
		int tmp = data[pos + gap];// 先将要插入的数保存
		while (pos >= 0)
		{
			if (data[pos] > tmp)
			{
				data[pos + gap] = data[pos];// 若前面的数大,将其移动到后gap的位置上
				pos -= gap;// pos更新
			}
			else
				break;
		}
		data[pos + gap] = tmp;
	}
}

而单趟排序只是将数组中间隔为gap的数进行排序,gap是3,排完序后数组中间隔为3的数之间就是有序的,但间隔为2的数之间不是有序,所以要减小gap将数组分成更多的子数组进行单趟排序,直到gap为1,即对相邻的数之间进行插入排序,相当于直接插入排序,此后数组完全有序。

所以希尔排序的思想就是:

1.gap > 1,对数组进行预排序,使数组接近有序,越有序的数组用直接插入排序就越快
2.gap == 1,此时的排序就是直接插入排序

所以希尔排序是三层循环,最外层控制将数组分割成多少个子数组,中间一层控制子数组的更新,最内层控制子数组的插入排序。

void ShellSort(int* data, int n)
{
	int gap = n;
	while (gap > 1)
	{
		gap  = gap / 3 + 1;// 每次预排序后gap都要进行更新,所以将gap除一个数,而为了保证gap最后是1,除一个数后还加了1
		for (int i = 0; i < n - gap; i++)
		{
			int pos = i;
			int tmp = data[pos + gap];
			while (pos >= 0)
			{
				if (data[pos] > tmp)
				{
					data[pos + gap] = data[pos];
					pos -= gap;
				}
				else
					break;
			}
			data[pos + gap] = tmp;
		}
	}
}

选择排序

选择排序

对数组进行遍历,每遍历一遍都找出一个最小值,记录最小值的下标,将最小值与数组的第一个数交换。此时第一个数满足升序的性质,将除了第一个数之外的数看成数组,再从其中找出最小值的下标,将最小值与数组的第一个数交换,再把除了第一个数之外的数看成数组…直到数组的长度为1,排序完成

void SelectSort(int* data, int n)// n为数组长度
{
	for (int i = 0; i < n - 1; i++)
	{
		int mini = i;// 默认将第一个数看成最小值
		for (int j = i + 1; j < n; j++)
		{
			if (data[j] < data[mini])// 若数组中有数比以mini为下标的数小,更新mini
				mini = j;
		}
		Swap(&data[mini], &data[i]);// 找出mini后,交换两数
	}
}

选择排序还能一次选出最大与最小值,将时间减半

void SelectSort(int* data, int n)
{	
	int left = 0;
	int right = n - 1;
	while (left < right)
	{
		int maxi = left;
		int mini = left;
		for (int i = left; i <= right; i++)
		{
			if (data[i] > data[maxi])
				maxi = i;
			if (data[i] < data[mini])
				mini = i;
		}
		Swap(&data[mini], &data[left]);
		if (maxi == left)
		// 若最大值下标与left相等,因为left与最小值交换过,此时的最大值不在left上,而换到了mini的位置,所以将maxi更新为mini
			maxi = mini;
		Swap(&data[maxi], &data[right]);

		left++;
		right--;
	}
}

选择排序很好理解,遍历一遍数组选出一个最小数,放到数组第一个位置。再将其他数看成一个数组,选最小。所以数组长度每次会减1,要遍历的次数是n,n-1,n-2… 2。一个等差数列的时间复杂度为O(n^2)。

堆排序

之前写过,这里就不写了

交换排序

冒泡排序

一趟单趟排序比较相邻的两个数,将一个最大的数排到最后,一次只能使一个数有序,所以冒泡排序要进行n - 1次单趟排序,n为数组长度。

所以冒泡由两层循环控制,外层控制单趟排序的次数,内层控制单趟排序。要注意的是,将最大的数排到数组的最后,再进行排序时的数组,应该是除了刚刚排完序的数之外的数组成的。因此单趟排序的数组长度要注意控制。

void BubbleSort(int* data, int n)
{
	for (int i = 0; i < n - 1; i++)// 一共要进行n - 1次单趟排序
	{
		for (int j = 0; j < n - i - 1; j++)// 注意判断条件对数组长度的控制
		{
			if (data[j] > data[j + 1])
				Swap(&data[j], &data[j + 1]);// 若前一个数大于后一个数,交换
		}
	}
}

若在进行单趟排序时,没有数发生交换,即相邻的数之间都满足前一个数小于后一个数,此时的数组为有序数组,故不用再进行排序,所以能对冒牌排序进行优化。

void BubbleSort(int* data, int n)
{
	for (int i = 0; i < n - 1; i++)// 一共要进行n - 1次单趟排序
	{
		int flat = 0;// flat用来标识单趟排序中是否发生交换
		for (int j = 0; j < n - i - 1; j++)// 注意判断条件对数组长度的控制
		{
			if (data[j] > data[j + 1])
			{
				Swap(&data[j], &data[j + 1]);// 若前一个数大于后一个数,交换	
				flat = 1;
			}
		}
		if (flat == 0)// 单趟排序完,若flat的值没有变化,则数组是有序的,不用再进行排序
			break;
	}
}

快速排序

先理解快速排序的单趟排序:从数组中选出一个key,一般是数组中的第一个数,要求排完序key的左边都是小于它的数,右边都是大于它的数。

单趟排序一共有三种实现方式:

1.hoare版本

一个left,一个right分别指向数组第一个数与最后一个数,若将数组第一个数当作key,right先判断指向的数是否小于key,若小于key,right不动,若大于key,right向左走一步。直到遇到比key小的数或者left停下来。right停下之后,判断left指向的数是否比key大,若比key大,left不动,比key小,left向右走一步,重复这样的操作,直到遇到比key大的数或者遇到right停下。两者停下后,交换两数。
C语言 | 数据结构与算法 | 八大排序的讲解_第4张图片
交换后C语言 | 数据结构与算法 | 八大排序的讲解_第5张图片
接着left继续找大,遇到9停下,right继续找小,遇到4停下,交换这两个数C语言 | 数据结构与算法 | 八大排序的讲解_第6张图片然后right找小,遇到3停下,left找大,先判断4是比key小的,left向右走遇到right,此时停下,循环结束。C语言 | 数据结构与算法 | 八大排序的讲解_第7张图片
将停下的位置与key交换。6作为key的单趟排序完成,6的左边都是比它小的数,右边是比6大的数。

C语言 | 数据结构与算法 | 八大排序的讲解_第8张图片

void PartSort1(int* data, int left, int right)
{
	int keyi = left;// 记录key的下标
	while (left < right)// 当left与right相遇时,循环停止
	{
		while (left < right && data[right] >= data[keyi])
			right--;// right找小
			
		while (left < right && data[left] <= data[keyi])
			left++;// left找大

		Swap(&data[left], &data[right]);// 交换找到的数
	}
	Swap(&data[left], &data[keyi]);// left与right相遇,将相遇位置的数与key交换
}

2.挖坑法

将数组第一个数选为key后,记录该位置为一个坑位,right先找小,找到后将找到的数填到坑位上,将此时right更新为新的坑位,left找大,找到后将该数填到坑位上,将此时的left更新为新的坑位。最后left与right相遇时,将key填到坑位上。

刚开始的数组
C语言 | 数据结构与算法 | 八大排序的讲解_第9张图片

right找小
C语言 | 数据结构与算法 | 八大排序的讲解_第10张图片
找到之后填到坑中,更新pit
C语言 | 数据结构与算法 | 八大排序的讲解_第11张图片
left找大,将找到的数填到坑中,更新pit
C语言 | 数据结构与算法 | 八大排序的讲解_第12张图片
最后排完序的数组
C语言 | 数据结构与算法 | 八大排序的讲解_第13张图片

void PartSort2(int* data, int left, int right)
{	
	int keyi = left;
	int key = data[keyi];
	int pit = keyi;
		
	while (left < right)// 当left与right相遇,两者相等循环结束
	{
		while (left < right && data[right] >= key)
			right--;
		data[pit] = data[right];// 找小后将right填到坑中
		pit = right;// 更新pit
		
		while (left < right && data[left] <= key)
			left++;
		data[pit] = data[left];// 找大后将left填到坑中
		pit = left;// 更新pit
	}
	data[pit] = key;// 两者相遇将key填到相遇位置
}

3.前后指针法

一个指针prev指向cur指针的前一个位置,cur指针在开始时指向数组的第一个数。同时将数组的第一个数视为key。
C语言 | 数据结构与算法 | 八大排序的讲解_第14张图片
判断prev是否小于key,若prev小于key,将cur向前走一步,判断cur与prev是否相等,若不相等,交换两数。将prev向右走一步,判断prev是否小于key来确定cur是否要往右走…当prev遍历完数组时,将key与cur交换

第一步,prev为1,比6小,将cur向右走一步
C语言 | 数据结构与算法 | 八大排序的讲解_第15张图片
此时1等于1,两者不交换。prev向右走一步,而2小于6,cur也向右走一步,两者还是相等,不交换。
C语言 | 数据结构与算法 | 八大排序的讲解_第16张图片
prev向右走,7大于6,cur不动,prev再走,9大于6,cur还是不动,prev再走,3小于6,cur向右走,7不等于3,交换两数
C语言 | 数据结构与算法 | 八大排序的讲解_第17张图片
prev向右走,4小于6,cur也走,不相等,交换两数…
C语言 | 数据结构与算法 | 八大排序的讲解_第18张图片
当prev为7时,向右走一步后,10大于6,cur不动,prev再走,8还是大于6,再走遍历完整个数组,循环结束,将key6与cur5交换

void PartSort3(int* data, int left, int right)
{
	int keyi = left;
	int cur = left;
	int prev = cur + 1;
	
	while (prev <= right)// right为数组最后一个数的下标,超过这个下标表示遍历完数组循环结束
	{
		if (data[prev] < data[keyi])// prev小于key,cur++
			cur++;
		if (data[prev] != data[cur])// prev不等于cur,两数交换
			Swap(&data[prev], &data[cur]);	
		
		prev++;// 不管怎样,prev都要++
	}
	Swap(&data[keyi], &data[cur]);// 交换key与cur
}

代码还能简化

void PartSort3(int* data, int left, int right)
{
	int keyi = left;
	int cur = left;
	int prev = cur + 1;
	
	while (prev <= right)// right为数组最后一个数的下标,超过这个下标表示遍历完数组循环结束
	{
		// 利用逻辑短路,prev大于key值,后面的表达式不计算,cur就不会++
		// 只有prev大于key值,cur才会++,++后再进行交换
		if (data[prev] < data[keyi] && data[++cur] != data[prev])
			Swap(&data[cur], &data[prev]);

		prev++;
	}
	Swap(&data[keyi], &data[cur]);// 交换key与cur
}

单趟排序会让一个key排到正确的位置,左边都是小于它的数,右边是大于它的数。当出现以下情况,则表示数组有序

1.key的左右两边都只有一个数
2.key的左右两边有一边没有数,一边有数

所以单趟排序后应该返回key在数组中的位置,以此判断这个数组是否有序。若数组无序,则将key左边的数视为一个数组,右边也视为一个数组,对这两个数组再进行单趟排序。

所以要对这三个快排的单趟排序进行修改

1.1hoare版本

int PartSort1(int* data, int left, int right)
{
	int keyi = left;// 记录key的下标
	while (left < right)// 当left与right相遇时,循环停止
	{
		while (left < right && data[right] >= data[keyi])
			right--;// right找小
			
		while (left < right && data[left] <= data[keyi])
			left++;// left找大

		Swap(&data[left], &data[right]);// 交换找到的数
	}
	Swap(&data[left], &data[keyi]);// left与right相遇,将相遇位置的数与key交换
	return left;// left与right相遇后,将key交换到相遇的位置,所以返回left与right其中的一个
}

2.1挖坑法

int PartSort2(int* data, int left, int right)
{	
	int keyi = left;
	int key = data[keyi];
	int pit = keyi;
		
	while (left < right)// 当left与right相遇,两者相等循环结束
	{
		while (left < right && data[right] >= key)
			right--;
		data[pit] = data[right];// 找小后将right填到坑中
		pit = right;// 更新pit
		
		while (left < right && data[left] <= key)
			left++;
		data[pit] = data[left];// 找大后将left填到坑中
		pit = left;// 更新pit
	}
	data[pit] = key;// 两者相遇将key填到相遇位置
	
	return pit;// left与right相遇后将key填到相遇的位置上,所以pit为key现在的位置
}

3.1前后指针法

int PartSort3(int* data, int left, int right)
{
	int keyi = left;
	int cur = left;
	int prev = cur + 1;
	
	while (prev <= right)// right为数组最后一个数的下标,超过这个下标表示遍历完数组循环结束
	{
		if (data[prev] < data[keyi] && data[++cur] != data[prev])
			Swap(&data[cur], &data[prev]);

		prev++;
	}
	Swap(&data[keyi], &data[cur]);// 交换key与cur

	return cur;// prev遍历完数组后,将cur与key交换,所以cur为key现在的位置
}

可以将快排排序拆分成有关单趟排序的子问题,如果单趟排序后key的左边数组有序,右边数组有序,则该数组有序,若左右两边的数组无序,则将它们排成有序,继续对这两个数组进行单趟排序。

快排实现

void QuickSort(int* data, int begin, int end)
{
	int keyi = PartSort2(data, begin, end);

	if (begin < keyi - 1)// 当key左边数组有2个及以上的数时,递归调用
		QuickSort(data, begin, keyi - 1);
	if (keyi + 1 < end)// 同理,key右边有2个及以上的数时,递归调用
		QuickSort(data, keyi + 1, end);
}

(非递归实现,手动创建栈,用循环模拟递归实现)

void QucikSortNonR(int* data, int begin, int end)
{
	Stack st;// Stack是一个栈类型
	StackInit(&st);// 将栈初始化

	StackPush(&st, being);
	StackPush(&st, end);// 将要排序的区间入栈
	
	while (!StackEmpty(&st))// 当栈不为空时,循环继续
	{
		int right = StackTop(&st);// 取出栈顶元素
		StackPop(&st);

		int left = StackTop(&st);
		StackPop(&st);

		int keyi = PartSort1(data, left, right);
		if (left < keyi - 1);// 当数组至少有两个数时,要继续排序,入栈
		{
			StackPush(&st, left);
			StackPush(&st, keyi - 1);
		}
		if (keyi + 1 < right)
		{
			StackPush(&st, keyi + 1);
			StackPush(&st, right);
		}
	}
}

优化1:减少递归深度(效果不明显)

假设每次单趟排序后key的位置都在数组的中间,那么快排的单趟调用就像一颗满二叉树
C语言 | 数据结构与算法 | 八大排序的讲解_第19张图片
而函数的递归调用是需要时间的,由于这样的递归到最后要创建很多的栈帧,所以当要进行排序的数组长度足够小时,可以用其他排序来减少栈帧创建的时间。

void QuickSort(int* data, int begin, int end)
{
	if (end - begin + 1 < 8)// 当数组长度小于8时,调用其他排序
		InsertSort(data + begin, end - begin + 1);
		// 子数组可能是原来数组的中间部分,不是从头开始的,所以要排序的数组要加上begin
	else
	{
		int keyi = PartSort(data, begin, end);
		if (begin < keyi - 1)
			QuickSort(data, begin, keyi - 1);
		if (keyi + 1 < end)
			QuickSort(data, keyi + 1, end);
	}
}

优化2:三数取中(有效优化)

每次选的key都默认是数组的第一个数,假设一个极端的情况,当数组是有序且顺序是升序的,并且数组长度较大。这时每次递归调用数组只会调用key右边的数组,每次的数组长度只减少1。前面说数组长度较长,在这样的情况下,有几个数,就会递归的创建几个函数栈帧,而一直开辟栈帧会导致栈溢出。所以针对这种最坏的情况,需要对如何选key进行优化,至少不能选到最小或是最大的那个数。

left为数组第一个数的位置,right为数组最后一个数的位置,选一个mid,为left + right再除以2,将三个数进行比较,得到大小为中数的那个数。选择中数为key,保证了key不是数组中最大或最小的一个数,防止栈溢出的问题

int GetMidIndex(int* data, int left, int right)
{
	int mid = (left + right) / 2;

	if (data[left] > data[mid])
	{
		if (data[mid] > data[right])// left > mid,mid > right,mid为中数
			return mid;
		else// left > mid, mid < right,mid为最小,比较left和right大小,较小的为中间数
		{
			if (data[left] < data[right])
				return left;
			else
				return right;
		}
	}
	
	else// mid < left
	{
		if (data[left] < data[right])
			return left; mid < left < right
		else // left > right,left > mid,left最大,比较mid和right大小,较大的为中间数
		{
			if (data[left] > data[right])
				return left;
			else
				return right;
		}
	}
}

在单趟排序选key时,将该函数的返回值作为key值,就能避免爆栈的出现

快排非递归实现

只要是递归就有爆栈的风险(在release的优化下,这种风险很小),可以用非递归来模式实现递归调用的过程,避免爆栈。

先分析快排的递归过程:首先是对整个数组调用了单趟排序,得到一个keyi值,keyi左边的数都小于keyi上的数,右边的数都大于keyi上的数。再将keyi的左边和右边调用单趟排序直到keyi的左边只有一个数或者没有数时,排序结束。

而单趟排序的关键是要知道排序数组的区间:第一次的区间是整个数组,第二次是第一次单趟排序完key左边的数组,第三次是key右边的数组…

所以模拟一个栈空间,每次将要排序的数组入栈,排完序出栈,并且判断key左边和右边的数组还需不需要排序,若需要排序将数组的区间入栈。直到栈空间为空时,说明栈中没有需要排序的空间,循环结束。

有关栈空间的实现,可以看之前写的这篇

void QuickSortNonR(int* data, int n)
{
	Stack st;
	StackInit(&st); // 创建一个栈,并将栈初始化
	
	int begin = 0;
	int end = n - 1;

	StackPush(&st, begin);
	StackPush(&st, end);// 将要排序的数组区间入栈

	while (!StackEmpty(&st))// 当栈不为空时,说明栈中有要排序的数组,循环继续
	{
		int right = StackTop(&st);// 将栈顶元素的值赋值给right
		StackPop(&st);// 将栈顶元素删除
		int left = StackTop(&st);// 同上
		StackPop(&st);

		int keyi = PartSort1(data, left, right);// 调用单趟排序的其中一个

		if (left < keyi - 1)// 当keyi与left不重合时(数组有两个及以上的数),数组要排序
		{
			StackPush(&st, left);
			StackPush(&st, keyi - 1);
		}
		if (keyi + 1 < right)
		{
			StackPush(&st, keyi + 1);
			StackPush(&st, right);
		}
	}
	
}

归并排序

思想讲解

将一个数组分成两个数组,假设这两个数组有序,用两个变量begin1和begin2遍历这两个数组,开始时都指向数组第一个数,谁指向的数小,谁就放到tmp(创建的一个临时数组)中,然后指向下一个数,直到一个数组遍历完,将另一个数组的数全接到tmp数组后面,再将tmp拷贝回原数组。

怎么确定由一个数组分成的两个数组是有序的?如果一个数组只有两个数,分成两个数组,分成的数组中只有一个数,那么就能将一个数看成是有序的。

所以归并排序对一个数组不断的进行二分,直到分成的数组只有一个数,再进行排序C语言 | 数据结构与算法 | 八大排序的讲解_第20张图片

红线以上将数组分割成一个数的过程称作归,将一个数不断的合并,直到数组有序的过程叫做并,这就是归并排序的大概思想。

那如何分割数组成了首要的问题,知道了数组的第一个数与最后一个数的下标,比如上面的数组,第一个数下标是0,最后一个数下标是7,将两者相加/2,得到3。

那么可以将0 ~ 3,4 ~ 7看成两个数组。所以将数组第一个数下标begin与最后一个数下标end相加除2,得到mid,将[begin, mid], [mid + 1, end]看成两个数组。begin和mid相等或者mid + 1与end相等,说明这两个数组只有一个数,接下来就不用分割了。因此分割的结束条件是begin > mid, mid + 1 > end。

代码实现

// 归并排序的子函数,调用子函数能使数组有序
void _MergeSort(int* data, int begin, int end, int* tmp)
{
	if (begin >= end)// 分割(递归)的结束条件,数组中没有数时不分割
		return;

	int mid = (begin + end) / 2;

	int begin1 = begin;// 定义两数组的起点与终点
	int end1 = mid;

	int begin2 = mid + 1;
	int end2 = end;

	int index = begin;// index作为tmp数组开始存储数据的起始位置
	//假设要排序的数组下标范围在[5,6]那么在tmp中排序后的数据范围也要在[5,6]下标,做到统一
	
	_MergeSort(data, begin1, end1, tmp);// 递归调用子函数使数组分割的两个数组有序
	_MergeSort(data, begin2, end2, tmp);

	// 调用完子函数,分割的两个数组是有序的,将两数组合并
	while (begin1 <= end1 && begin2 <= end2)
	{
		if (data[begin1] < data[begin2])
			tmp[index++] = data[begin1++];//将小的数存到tmp中,index与begin1向后走
		else
			tmp[index++] = data[begin2++];
	}

	// 遍历完一个数组,上面的循环结束,还有一个数组的数没有遍历完,将每遍历完的数组的数接到tmp中
	while (begin1 <= end1)
		tmp[index++] = data[begin1++];
	while (begin2 <= end2)
		tmp[index++] = data[begin2++];

	memcpy(data + begin, tmp + begin, sizeof(int) * (end - begin + 1));// 将tmp中的所有数拷贝到原数组中
}

void MergeSort(int* data, int n)
{
	int begin = 0;
	int end = n - 1;

	int* tmp = (int*)malloc(sizeof(int) * n);//开辟临时数组tmp

	_MergeSort(data, begin, end, tmp);// 调用子函数使数组有序

	free(tmp);
}

注意点1

要注意两个点,1.不能在MergeSort函数调用_MergeSort子函数后再将tmp的数据拷贝到原数组中。
C语言 | 数据结构与算法 | 八大排序的讲解_第21张图片
比如将_MergeSort子函数将[10,6]归并成[6,10]之后,要将[6,10]正确的顺序拷贝到原数组中,否则在进行[6,10]与[1,7]这两组数的归并时,[6,10]变成了[10,6],[1,7]成了[7,1]和没有排序前一样,这时再进行归并排序得到的数组顺序是错误的。所以在tmp数组中归并得到正确的数时,要将正确的数据拷贝回原数组,不能在最后拷贝。

注意点2

2.拷贝数组时,原数组和目标数组的起始位置要格外注意。

之前写归并出现了错误,在调试时发现我将memcpy写错了
在这里插入图片描述
memcpy是将原数组的n个字节数的数据拷贝到目标数组。这样的描述太简单了,举上面的例子,数组名tmp作为一个数组的首元素地址,data同样也是,memcpy将你给的原地址tmp处向后的n个字节数的数据依次拷贝到以data为起始地址往后的n个字节数地址处

所以这样写的memcpy每次只拷贝了tmp数组的前几个数到data数组中。而有时归并排序排的数是data数组中间或者最后的数,不能简单的将数拷贝到data的后面,要使数据满足一一对应的关系。

非递归排序

由于知道归并排序的最小子问题是将两个数合并(合并两个长度为1的数组),用非递归模拟实现归并排序的递归时,就能从最小子问题入手。将相邻两个合并成有序数组后,再将有序的两数组继续合并。

void MergeSortNonR(int* data, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);// 开辟临时数组存储合并的数据
	int gap = 1;// gap指每次合并的数组长度,先从最小的长度1开始

	while (gap < n)// 控制合并数组的长度gap
	{
		for (int i = 0; i < n; i += gap * 2)// 遍历所有数,合并长度为gap的两两数组
		{
			int begin1 = i;
			int end1 = i + gap - 1;// gap是数组长度,i + gap是第二个数组的起点
			if (end1 >= n)// 当end1越界时,修正
				end1 = n - 1;

			int begin2 = i + gap;
			if (begin2 >= n)// 当begin2越界时,说明该区间不存在,修正成一个不存在的区间
			{
				begin2 = 1;
				end2 = 0;
			}
			int end2 = i + 2 * gap - 1;
			if (end2 >= n)
				end2 = n - 1;
				
			int index = i;// index是tmp数组保存数据的起始位置
			while (begin1 <= end1 && begin2 <= end2)
			{
				if (data[begin1] < data[begin2])
					tmp[index++] = data[begin1++];
				else
					tmp[index++] = data[begin2++];
			}
			// 当一组数组遍历完,将另一组数拷贝到tmp中
			while (begin1 <= end1)
				tmp[index++] = data[begin1++];
			while (begin2 <= end2)
				tmp[index++] = data[begin2++];
		}// end of for
		//for循环后,数组中长度为gap的子数组有序,将tmp拷贝到原数组中,再次归并
		memcpy(data, tmp, sizeof(int) * n);

		gap *= 2;// gap为合并数组的长度,乘2更新gap
	}// end of while
}

非比较排序

计数排序

以上的排序都是比较排序,即通过两个数之间的比大小得到一个有序的数列。但有的排序却不需要通过比较。

计数排序:通过哈希定址法进行排序。计数排序的时间复杂度小,但其实是一组用空间换时间的排序

假设有一个数组data,先遍历一遍data,得到data数组的最大值与最小值,知道了数组的范围再开辟一个count计数数组,将数组全初始化为0。将data数组中的数作为count数组的下标,将该位置的数加1。所以在count数组中的数表示以该下标为值的数在data数组中出现的次数,而数组下标是天然有序的,所以遍历count数组,就能得到data数组排序的结果。

void CountSort(int* data, int n)
{
	int max = data[0];
	int min = data[0];// 先将数组的第一个数假设成数组中的最大值和最小值
	for (int i = 0; i < n; i++)
	{	
		if (data[i] > max)
			max = data[i];
		if (data[i] < min)
			min = data[i];
	}

	int range = max - min + 1;// 得到数组的范围,记得要+1
	// 假设要排序的数据范围在0~9,得到的range是9,但0~9有10个数,所以range要+1
	int* count = (int*)malloc(sizeof(int) * range);// 开辟计数数组
	memset(count, 0, sizeof(int) * range);// 数组初始化为0
	
	for (int i = 0; i < n; i++)
	{
		count[data[i] - min]++;// 记录data数组中数的出现次数
	}
	
	int index = 0; // index用来遍历count数组
	for (int i = 0; i < range; i++)
	{
		while (count[i]--)// 当count数组中的数不为0,说明该数的下标在data中出现
			data[index++] = i + min;// 将下标拷贝到原数组data中,index在拷贝后++
	}
}

关于count[data[i] - min]++这行代码:为了减小空间的浪费,在开辟count数组时,不将data数组的最大数作为range开辟;如果数据范围在[5000, 10000]时,不开辟10000个int大小的数组,若开辟10000个int大小的数组将浪费前5000个int的空间。所以采用相对映射的方式,在得到data数组的范围range后,只开辟range个int大小的数组。这种方式使得数据映射时要减去最小值,假设现在有一个数5002,要映射到count数组中,将5002-5000得到2,将count下标为2的位置++,说明2出现了一次,但由于时相对映射,所以在拷贝回原数组data时,i下标要加上减去的min,所以要写data[index++] = i + min,而不是data[index++] = i。

你可能感兴趣的:(数据结构与算法,数据结构,c语言,排序算法)