【数据结构---排序】庖丁解牛式剖析常见的排序算法

一、常见的排序算法

排序在我们生活中处处可见,所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。

常见的排序算法可以分为四大类:插入排序,选择排序,交换排序,归并排序;其中,插入排序分为直接插入排序和希尔排序;选择排序分为直接选择排序和堆排序;交换排序分为冒泡排序和快速排序;归并排序归为一大类;

在这里插入图片描述

下面我们逐一分析每一个排序的算法思路以及优缺点和稳定性;

二、常见排序算法的实现

1. 直接插入排序

直接插入排序是一种简单的插入排序法,其基本思想是:把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。

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

当插入第i(i>=1)个元素时,前面的 a[0],a[1],…,a[i-1] 已经排好序,即 0 到 end 的区间已经排好序,此时用 a[i] 的下标与 a[i-1] , a[i-2] ,…的下标从 end 开始往前进行比较,找到符合条件的插入位置即将 a[i] 插入,原来位置上的元素顺序后移;

如图,下标为 0 - end(0 - 0) 的区间上只有一个元素,即已经排好序,i 为 end 的后一个下标,然后 i 从 end 开始往前进行比较,遇到比自己大的就继续走,直到遇到比自己小的元素,就在这个位置插入;
在这里插入图片描述

原来这个位置上的元素往后移动;注意是先移动再插入,否则会覆盖数据;
在这里插入图片描述
第二轮插入:

在这里插入图片描述
在这里插入图片描述
如图所示这三个数就排好序了;

排序的动图如下:
在这里插入图片描述

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

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

2. 希尔排序

希尔排序法又称缩小增量法,希尔排序法的基本思想是:先选定一个整数 gap,把待排序文件中所有记录分成 gap 个组,所有距离为 gap 的记录分在同一组内(即间隔为 gap 分为一组,总计 gap 组),并对每一组内的记录进行排序。然后取重复上述分组和排序的工作。当 gap = 1时,所有记录在统一组内排好序。

希尔排序其实是对直接插入排序的优化,当 gap > 1 时都是预排序(对 gap 组数据分别进行插入排序),目的是让数组更接近于有序;当 gap == 1 时,即每一个元素都是独立的一组,也就变成了直接插入排序;

gap 的选值是不一定的,我们按照大概数组长度的三分之一来选值;例如{ 6,1,2,7,9,3,4,5,10,8,0 } 这个数组,我们按照 gap = gap / 3 + 1 来选取 gap 的值,数组被分成 gap == 4 组,每个组之间的元素间隔 gap == 4 个元素;如图所示,不同颜色的线段区间代表不同的 gap 组:
在这里插入图片描述
每个 gap 组排好序的数组如原数组的上方数据所示:
在这里插入图片描述

然后 gap 再按照上面的取值方式继续计算,gap 得到 2,按照 gap == 2 的分组分成以下组别,一共两组,每个组之间的元素间隔 gap == 2 个元素:
在这里插入图片描述

每个 gap 组排好序的数组如原数组的上方数据所示:

在这里插入图片描述
从目前的数组的排列可以看出,数组已经很接近有序了,此时我们只要继续按照 gap 的取值方式取 gap 值,会得到 gap == 1,即进行直接插入排序,这样我们就排好了一段数组;gap 按照 gap = gap / 3 + 1 的方式取值的原因是因为,最后的 + 1可以保证最后一次 gap 的取值一定是 1 ,即最后一次排序一定会执行直接插入排序;

实现的代码如下:

		//希尔排序
		void ShellSort(int* a, int n)
		{
			int gap = n;
			while (gap > 1)
			{
				// +1保证最后一次一定是1
				gap = gap / 3 + 1;
				
				// 多组并排
				// i < n - gap 保证与数组的长度有 gap 的距离,不会越界;并分成了 gap 组;
				for (int i = 0; i < n - gap; i++)
				{
					// 以 gap 为间距直接进行插入排序,多个组同时进行插入排序
					int end = i;
					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;
				}
			}
		}

希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,总体的时间复杂度在 O(NlogN)~O(N^2),最好的情况时间复杂度为 O(N^1.3);空间复杂度为 O(1),因为没有使用额外的空间;希尔排序的稳定性是不稳定的;

3. 直接选择排序

选择排序的思想是,每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。

动图如下。动图提供的思路是每次只选一个最小的元素放到数组的最左边,而我们的思路是同时选出最大元素和最小元素,将最大的元素放到最右边,最小的元素放到最左边,这算是一个小小的优化;
在这里插入图片描述

例如 { 5,3,4,1,2 } 这个数组,begin 和 end 记录数组的头和尾,maxi 和 mini 记录除了已排序好的元素之外的最大元素和最小元素的下标,如下图,此时 begin 和 end 维护这一段数组,目前这段数组是无序的,maxi 和 mini 都从 begin 开始遍历,分别寻找最大元素和最小元素的下标;然后先对 a[maxi] 和 a[end] 进行交换,将最大元素放到最后;然后交换 a[mini] 和 a[begin],将最小的元素换到前面去;最后 begin++, end- -,缩小数组范围;

在这里插入图片描述

进行第二次选择排序,此时 mini 和 end 重合,如果先交换 a[maxi] 和 a[end],原来的 a[mini] 是最小元素,交换后就会变成原来的 a[maxi],即现在的 a[end],即变成了最大的元素(因为 end 和 mini 重合),所以此时要进行判断, mini 和 end 如果重合则说明原来的 mini 现在已经被换到 maxi 的位置了,所以要进行 mini = maxi 操作;
在这里插入图片描述
交换后:在这里插入图片描述
改正后:
在这里插入图片描述
最后排序完:
在这里插入图片描述

以下是参考代码:

		void Swap(int* p1, int* p2)
		{
			int tmp = *p1;
			*p1 = *p2;
			*p2 = tmp;
		}
		
		//选择排序
		void SelectSort(int* a, int n)
		{
			int begin = 0, end = n - 1;
			while (begin < end)
			{
				int maxi = begin, mini = begin;
				for (int i = begin; i <= end; i++)
				{
					if (a[i] > a[maxi])
					{
						maxi = i;
					}
					if (a[i] < a[mini])
					{
						mini = i;
					}
				}
				
				Swap(&a[end], &a[maxi]);
		
				//end 和 mini 重合
				if (mini == end)
					mini = maxi;
				
				Swap(&a[begin], &a[mini]);
				begin++;
				end--;
			}
		}

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

  1. 直接选择排序思考好理解,但是效率不是很好,实际中很少使用
  2. 时间复杂度:O(N^2)
  3. 空间复杂度:O(1)
  4. 稳定性:不稳定

4. 堆排序

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

例如一个数组 { 5,2,1,3,7,6,4 },这个数组的树形结构如下图:
在这里插入图片描述

将它建成大堆,其中建堆的思路在这里不细说,详细请看往期博客链接 二叉树—堆,建成的大堆如下图:

在这里插入图片描述
堆排序的思路是,首先要建立一个堆,现在已经建立好大堆,升序要建大堆,因为大堆中,大的在前面,每次让堆顶的数据与堆尾的数据的值进行交换,交换完长度减一,相当于最大的放到后面就不动了,然后再从堆顶开始向下调整,次大的调到堆顶,然后和倒数第二的数据的值进行交换…直到长度减到0,就完成了排序;

例如上图的大堆中,7 和 4 交换后 size 减减,我们操作的堆,表面上逻辑结构是一个堆,实际上我们操作的是一个数组,所以交换后 7 交换到数组的最后,7 是最大的元素,所以将长度减一,就说明已经排序好了一个元素,排序完一个元素就继续从堆顶开始进行向下调整,因为除了堆顶的元素,它已经是一个堆,所以可以直接从堆顶开始进行向下调整算法继续建堆;

在这里插入图片描述

参考代码如下:

		//向下调整算法
		void AdjustDown(int* a, int n, int parent)
		{
			int child = 2 * parent + 1;
		
			while (child < n)
			{
				if (child + 1 < n && a[child] < a[child + 1])
				{
					child++;
				}
		
				if (a[child] > a[parent])
				{
					Swap(&a[child], &a[parent]);
		
					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);
			}
		
			// 交换数据后调整堆顶的数据			
			while (n)
			{
				Swap(&a[0], &a[n - 1]);
				n--;
				AdjustDown(a, n, 0);
			}
		}

堆排序的特性总结:

  1. 堆排序使用堆来选数,效率就高了很多。
  2. 时间复杂度:O(N * logN),时间复杂度的消耗主要是在交换数据后堆顶要重新找到次大/次小的值;因为在交换数据后,除了最后一个元素和堆顶的元素,其他元素已经是堆,所以堆顶要找出次大/次小的元素,时间复杂度是O(logN),而一共有 N 个元素,所以总体的时间复杂度是 O(N*logN);
  3. 空间复杂度:O(1)
  4. 稳定性:不稳定

5. 冒泡排序

冒泡排序的思想是两两之间进行比较,将较大的元素放到后面去,直到遍历完数组,最大的元素就被放到最后面了;再进行第二趟比较,将次大的元素放到倒数第二个位置,假设有 n 个元素,那就一共要比较 n 趟,而每一趟里面又要对这 n 个元素进行两两比较,所以冒泡排序的时间复杂度是O(N^2);

冒泡排序的动图如下:
在这里插入图片描述
参考代码如下:

		//冒泡排序
		void BubbleSort(int* a, int n)
		{
			// 每一趟
			for (int i = 0; i < n; i++)
			{	
				// 每一趟的两两比较
				// flag 标记,如果这一趟没有进行交换,说明数组已经是有序的,提前跳出循环
				int flag = 1;
				for (int j = 1; j < n - i; j++)
				{
					if (a[j - 1] > a[j])
					{
						Swap(&a[j],&a[j - 1]);
		
						flag = 0;
		 			}
				}
		
				if (flag)
					break;
			}
		}

这里有一个小小的优化,是针对已经有序的数组,用 flag 标记为1,如果在这一趟中没有进行交换,说明数组已经有序,不用进行交换,也就没有比较下去的必要,所以直接提前跳出循环;

冒泡排序的特性总结:

  1. 冒泡排序是一种非常容易理解的排序,适合初学者理解,有教学意义;
  2. 时间复杂度:O(N^2)
  3. 空间复杂度:O(1)
  4. 稳定性:稳定

6. 快速排序

6.1 递归实现快速排序

快速排序的基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。

通俗易懂地讲,就是在数组中选取一个比较居中的值 key ,将比 key 小的元素放到 key 的左边,比 key 大的元素放到 key 的右边;而又在 key 左边的数组区间选取这段区间新的居中值(key) ,重复上面的操作,然后又在key 的右边重复操作,最终 key 的左右两边都有序了,这个数组自然就有序了;当然 key 的选值是有讲究的,下面我带大家逐一分析;

首先,我们先想办法选出每次 key 的值,并对选出的 key 的值进行分割,这里一共有三个思路供大家参考:

思路一、hoare 版本

我们先看一下 hoare 版本的动图思路:

在这里插入图片描述

很明显,思路就是每次定义 key 为最左边的元素,然后定义两个下标 L 和 R ,L 找比 key 大的元素, R 找比 key 小的元素,找到后交换下标为 L 和 R 的元素;那么通过这个思路,我们可以得出以下代码:

		// 快排排单趟 --- 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;
		}

那么大家肯定有一个疑问,怎么能保证最后一次交换的正确性呢?

首先我们是定义 key 为最左边的元素,其实也可以定义成最右边的元素,这要看大家的选择;我们定义 key 为最左边的元素,那么我们肯定是希望最后一次与 key 交换是比 key 小的元素,因为比 key 小的元素要放到左边;那么怎么保证 L 和 R相遇的位置一定比 key 小呢?

这个就与 L 和 R 谁先走有关系了,假设我们先让 L 先走,例如以上面动图的数组{6,1,2,7,9,3,4,5,10,8 },如下图,先让 L 先走:

在这里插入图片描述

从图中可以看出,最后 L 和 R 相遇的位置是 9,不是我们想要的比 key 小的值;而第一张动图中是 R 先走,R 先走最后的结果是满足我们的要求的;出现这种情况的原因是什么呢?

原因很简单,L 本质上是要找比 key 大的值,而 R 是要找比 key 小的值,如果是 L 先走,R 后走,等他们找到对应的值交换后,L 又开始新的一轮寻找,找比 key 大的值,而经过上一轮的交换,L 当前停留的元素是比 key 大的值,如果 L 在相遇 R 之前没有遇到比 key 大的值,那么 L 最终停留的位置一定是 R 所在的位置,又因为它们已经相遇了,所以 R 也动不了了,所以最终与 key 交换的值是比 key 大的值,不符合我们的期望;

相反,如果让 R 先走,L 后走,在经过一轮的交换后,L 停留的位置是比 key 小的值,R 停留的位置是比 key 大的值,新的一轮也是 R 先走,如果在相遇 L 之前没有遇到比 key 小的值,那么 R 和 L 的相遇点一定是比 key 小的值;即使 R 在 相遇 L 之前遇到比 key 小的值,随着 L 的移动,L 一定会相遇 R ,而他们的相遇点也一定是比 key 小的值,所以相遇点和 key 交换符合我们的期望;

以上就是 hoare 版本 的思路,下面我们介绍另外一种思路;

思路二、挖坑法

老规矩,我们先看动图的思路:
在这里插入图片描述
思路很简单,就是将最左边的元素看作是 key,而将 key 这个位置挖空,然后定义 L 和 R 两个下标,L 找比 key 大的元素,R 找比 key 小的元素,因为我们先挖空的是最左边元素,而我们期望左边放的都是比 key 小的元素,所以我们也是先让 R 先走,找到比 key 小的元素后放入坑中,自己形成新的坑,然后 L 走,找到比 key 大的元素后放入坑中,自己又形成新的坑,重复这个步骤,直到 L 和 R 相遇,相遇位置就是坑,将 key 放回坑中即可;参考代码如下:

		// 快排排单趟 --- 挖坑法
		int PartSort2(int* a, int left, int right)
		{
			int key = a[left];
			int hole = left;
		
			while (left < right)
			{
				// 右边找比 key 小的
				while (left < right && a[right] >= key)
				{
					right--;
				}
		
				a[hole] = a[right];
				hole = right;
		
				// 左边找比 key 大的
				while (left < right && a[left] <= key)
				{
					left++;
				}
		
				a[hole] = a[left];
				hole = left;
			}
		
			a[hole] = key;
			return hole;
		}

思路三、前后指针法

还有一个思路叫做前后指针法,我们先看动图思路:
在这里插入图片描述
从图中可以看出,前后指针法的思路也很好理解,定义两个指针 prev 和 cur,同样也是将最左边的元素看作 key,cur 找比 key 小的元素,与 prev 的后一个位置交换,这样 key + 1到 prev 的元素都是比 key 小的元素,prev + 1到 cur 都是比 key 大的元素;直到 cur 为空,prev 的位置肯定是比key 小的元素,最后 key 与 prev 的位置交换即可完成;

参考代码如下:

		// 快排排单趟 --- 前后指针法
		int PartSort3(int* a, int left, int right)
		{
			int keyi = left, cur = left + 1, prev = left;
			while (cur <= right)
			{
		
				if (a[cur] < a[keyi] && ++prev != cur)
				{
					Swap(&a[prev], &a[cur]);
				}
		
				cur++;
			}
			Swap(&a[prev], &a[keyi]);
		
			keyi = prev;
			return keyi;
		}

以上就是我们对 key 的分割的三个思路,那么我们应该如何实现快速排序呢?

由于分割的操作有点像我们前面学的二叉树中的前序遍历,key 就像根节点一样,所以我们可以用递归的思想实现;

		// 快排 --- 递归实现
		void QuickSort(int* a, int left, int right)
		{
			if (left >= right)
				return;
		
			int keyi = PartSort3(a, left, right - 1);
			
		
			QuickSort(a, left, keyi);
			QuickSort(a, keyi + 1, right);
		}

从代码可以看出,我们以前后指针法为例,先取出 key 的下标 keyi,再对其左区间和右区间进行选 key 的分割,即对其进行递归,最后当 left >= right 时停止递归。

这样我们的快速排序就实现了,但是对于这个快速排序还有一些缺陷:试想,我们的 key 每次都是按照最左边的值选取的,如果每次最左边的值都是这个数组中比较小的元素的时候,就会进行不必要的递归,效率也就慢了下来,所以针对这个问题,我们有了三数取中选key这个思路,这个思路的参考代码如下:

		//快排优化:三数取中
		int GetMidIndex(int* a, int left, int right)
		{
			int mid = (left + right) / 2;
		
			if (a[left] < a[mid])
			{
				if (a[mid] < a[right])
				{
					return mid;
				}
		
				if (a[left] < a[right])
				{
					return right;
				}
		
				else
				{
					return left;
				}
			}
		
			// a[left] > a[mid]
			else
			{
				if (a[mid] > a[right])
				{
					return mid;
				}
		
				if (a[left] > a[right])
				{
					return right;
				}
		
				else
				{
					return left;
				}
			}
		}

我们对下标 left 和 right 取中下标 mid,再两两比较这三个元素,返回处于中间大小的元素的下标,这样就大大增加了取 key 的随机性;

那么我们应该如何使用这个函数呢?
很简单,假设我们以前后指针法为例,只要在前后指针法函数内的开头加入这个函数即可;将下标 left 和 right 传入 GetMidIndex 函数,取到中间数的元素的下标 midi,再将下标为 left 和 midi 的元素交换即可;

		// 快排排单趟 --- 前后指针法
		int PartSort3(int* a, int left, int right)
		{
			int midi = GetMidIndex(a, left, right);
			Swap(&a[left], &a[midi]);
		
		
			int keyi = left, cur = left + 1, prev = left;
			while (cur <= right)
			{
		
				if (a[cur] < a[keyi] && ++prev != cur)
				{
					Swap(&a[prev], &a[cur]);
				}
		
				cur++;
			}
			Swap(&a[prev], &a[keyi]);
		
			keyi = prev;
			return keyi;
		}

以上这种递归实现的快速排序就相对比较完善了,但是对于一些特殊情况还没有得到相应的解决,如处理含有大量相同元素的时候,三数取中也很有可能会取到相同的元素,也会重复进行不必要的递归,大大降低了效率,这种问题的解决方案叫做三路划分,大家有兴趣的可以自行去了解。

快速排序的特性总结:

  1. 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
  2. 时间复杂度:O(N*logN)
  3. 空间复杂度:O(logN) (递归消耗了栈帧的空间)
  4. 稳定性:不稳定

6.2 非递归实现快速排序

非递归实现快速排序的基本思路是:用栈模拟实现递归的操作,严格来说并不是模拟递归的实现,只是用栈实现比较像递归的操作;

例如数组 {6,1,2,7,9,3,4,5,10,8 },假设我们每次以最左边的为 key,如下图操作,下图只执行到第二次取 keyi 的值:
在这里插入图片描述
如上图,到第二次取 keyi 的值的时候,其实也重复了上图刚开始的操作,继续将其左右区间入栈,按照栈的特性,后进的先出,栈会先处理后进的元素下标,我们上面模拟的是后进 keyi 的左区间,所以栈会先处理 keyi 的左区间,最后再处理 keyi 的右区间;

其次用栈模拟实现我们需要先有一个栈,根据前期回顾我们直接用以前实现过的栈,详细请看链接 栈和队列

参考代码如下:

		// 快排 --- 非递归
		void QuickSortNonR(int* a, int begin, int end)
		{
			ST st;
			STInit(&st);
			
		    // 一开始先将两边的元素入栈
			STPushTop(&st, end - 1);
			STPushTop(&st, begin);
		
			// 栈不为空就继续
			while (!STIsEmpty(&st))
			{
				// 取一次,出一次栈
				int left = STTop(&st);
				STPopTop(&st);
		
				// 取一次,出一次栈
				int right = STTop(&st);
				STPopTop(&st);
		
				// 取出 keyi 的值
				int keyi = PartSort3(a, left, right);
		
				// 在符合的区间内就继续将其左右区间入栈
				if (keyi + 1 < right)
				{
					STPushTop(&st, right);
					STPushTop(&st, keyi + 1);
				}
		
				if (left < keyi - 1)
				{
					STPushTop(&st, keyi - 1);
					STPushTop(&st, left);
				}
			}
		
			STDestroy(&st);
		}

7. 归并排序

7.1 归并排序的递归实现

基本思想:归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。

下面观察动图的思路:

在这里插入图片描述
例如数组{ 10,6,7,1,3,9,2,4 },观察更直观的动图:在这里插入图片描述
根据上面的思路,我们首先想到,它的思路有点像二叉树中的后序遍历,先将它的子序列排成有序的,最后再将两个相对有序的子序列进行归并,所以我们这里也可以用递归的思路实现类似后序遍历的操作;

我们首先需要一个子函数对子序列进行划分并排序的函数:

		// 归并的区间划分
		void PartOfMergeSort(int* a, int begin, int end, int* tmp)
		{
			if (begin == end)
				return;
		
			// 小区间优化
			if (end - begin + 1 < 10)
			{
				InsertSort(a + begin, end - begin + 1);
				return;
			}
		
			int mid = (begin + end) / 2;
		
			// 划分的区间为:
			// [begin,mid] [mid + 1,end]
			PartOfMergeSort(a, begin, mid, tmp);
			PartOfMergeSort(a, mid + 1, end, tmp);
		
			// 对每个区间进行归并排序
			int begin1 = begin, end1 = mid;
			int begin2 = mid + 1, end2 = end;
			int pos = begin;
		
			while (begin1 <= end1 && begin2 <= end2)
			{
				if (a[begin1] <= a[begin2])
					tmp[pos++] = a[begin1++];
				
				else
					tmp[pos++] = a[begin2++];
			}
		
			while (begin1 <= end1)
				tmp[pos++] = a[begin1++];
			
		
			while (begin2 <= end2)
				tmp[pos++] = a[begin2++];
				
			// 将这段已经排序好的空间拷贝回原数组
			memcpy(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));
		}

上面的函数中,每次进入函数,都会取中间的下标,对区域进行划分,并递归它的左右子区间,到最后停止递归的条件是 begin == end,然后返回上一层递归对上一层的子序列进行归并排序,每排序完一段子序列就拷贝回原数组,然后继续返回上一层排序上一层的子序列,直到回到第一层,回到第一层,左右子序列已经排序好了,进行最后一次归并排序即可;

其次,我们可以看到,在上面的函数中我们加了一个小优化,就是当区间的元素小于 10 个时,我们选择直接插入排序,原因是因为当区间元素小于 10 个时,继续递归会消耗更多的空间和效率,这种不必要的递归用直接插入排序替换更优;

		// 归并 --- 递归
		void MergeSort(int* a, int n)
		{
			// 需要一段空间进行临时拷贝
			int* tmp = (int*)malloc(sizeof(int) * n);
			PartOfMergeSort(a, 0, n - 1, tmp);
			free(tmp);
		}

7.2 归并排序的非递归实现

归并排序的非递归实现,基本思路是控制 gap 的值,把 2*gap 看作一个子序列,在这一轮的 gap 的子序列排完序后,gap *= 2,再归并下一个子序列,直到 gap 的值大于数组长度就结束;

例如数组{ 10,6,7,1,3,9,4,2 },
当 gap == 1:
在这里插入图片描述
当 gap == 2:
在这里插入图片描述
当 gap == 4:
在这里插入图片描述
如上图,当 gap == 4 时,数组已经排序好了,这时候将数组拷贝回原数组即可;参考代码如下:

		// 归并 --- 非递归
		void MergeSortNonR(int* a, int n)
		{
			int* tmp = (int*)malloc(sizeof(int) * n);
			assert(tmp);
		
			int gap = 1;
			while (gap < n)
			{
				int pos = 0;
				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;
		
					// 有一个区间结束就结束
					while (begin1 <= end1 && begin2 <= end2)
					{
						if (a[begin1] <= a[begin2])
						{
							tmp[pos++] = a[begin1++];
						}
		
						else
						{
							tmp[pos++] = a[begin2++];
						}
					}
					
					// 判断两个区间是否都结束了
					while (begin1 <= end1)
					{
						tmp[pos++] = a[begin1++];
					}
		
					while (begin2 <= end2)
					{
						tmp[pos++] = a[begin2++];
					}
		
				}
			
				// 更新 gap
				gap *= 2;
			}
		}

这时候我们不得不面临一个问题,当我们增加 1 到 2 数据的时候,结果还会一样吗?我们画一下图就可以看出来了,当数组为{ 10,6,7,1,3,9,4,2 ,0}时,即上面的数组增加了一个 0 ,作图如下:
在这里插入图片描述
从图中可以看出,当 gap == 1 时,问题就已经出现了,end1、begin2、end2 都越界了;

有人认为是奇数个元素就不行,而当数组为{ 10,6,7,1,3,9,4,2 ,0,5},即在上面的数组基础上又增加了一个元素,此时是 10 个元素,作图如下:
在这里插入图片描述
当元素为偶数个的时候,依然越界了,此时我们不得不面临一个问题,就是在给区间划分范围的时候边界的区间可能会面临越界的问题,此时我们需要修正边界的范围,这里有两种修正方案:

方案一:因为 begin1 == i,而 i 是不可能越界的,所以 begin1 不可能会越界,而 end1、begin2、end2 都有可能越界,此时我们可以做出以下修正:

			// 修正边界值(方法一:适用归并一组拷贝一组)
			if (end1 >= n || begin2 >= n)
			{
				break;
			}

			if (end2 >= n)
			{
				end2 = n - 1;
			}

加在函数中如下:

		// 归并 --- 非递归
		void MergeSortNonR(int* a, int n)
		{
			int* tmp = (int*)malloc(sizeof(int) * n);
			assert(tmp);
		
			int gap = 1;
			while (gap < n)
			{
				int pos = 0;
				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;
		
					// 修正边界值(方法一:适用归并一组拷贝一组)
					if (end1 >= n || begin2 >= n)
					{
						break;
					}
		
					if (end2 >= n)
					{
						end2 = n - 1;
					}
		
					while (begin1 <= end1 && begin2 <= end2)
					{
						if (a[begin1] <= a[begin2])
						{
							tmp[pos++] = a[begin1++];
						}
		
						else
						{
							tmp[pos++] = a[begin2++];
						}
					}
		
					while (begin1 <= end1)
					{
						tmp[pos++] = a[begin1++];
					}
		
					while (begin2 <= end2)
					{
						tmp[pos++] = a[begin2++];
					}
		
					// 归并一组,拷贝一组
					memcpy(a + i, tmp + i, sizeof(int) * (end2 - i + 1));
				}
				gap *= 2;
			}
		}

注意方案一需要归并一组,拷贝一组,它的解决方案是当 begin2 或 end1 越界时直接跳出循环,这段区间就在原数组中不用动了;

方案二:直接加在函数中如下:

		// 归并 --- 非递归
		void MergeSortNonR(int* a, int n)
		{
			int* tmp = (int*)malloc(sizeof(int) * n);
			assert(tmp);
		
			int gap = 1;
			while (gap < n)
			{
				int pos = 0;
				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;
		
					// 修正边界值(方法二:适用归并完当前 gap 再拷贝)
					if (end1 >= n)
					{
						end1 = n - 1;
		
						// 将第二个区间变成不存在的区间
						begin2 = n;
						end2 = n - 1;
					}
		
		
					else if (begin2 >= n)
					{
						// 变成不存在的区间
						begin2 = n;
						end2 = n - 1;
					}
		
					else if (end2 >= n)
					{
						end2 = n - 1;
					}
		
					// 有一个区间结束就结束
					while (begin1 <= end1 && begin2 <= end2)
					{
						if (a[begin1] <= a[begin2])
						{
							tmp[pos++] = a[begin1++];
						}
		
						else
						{
							tmp[pos++] = a[begin2++];
						}
					}
		
					// 判断两个区间是否都结束了	
					while (begin1 <= end1)
					{
						tmp[pos++] = a[begin1++];
					}
		
					while (begin2 <= end2)
					{
						tmp[pos++] = a[begin2++];
					}
				}
		
				// 归并完当前 gap 全部拷贝
				memcpy(a, tmp, sizeof(int) * n);
				gap *= 2;
			}
		}

方案二的思路是将所有越界的边界值都进行修改,只需要修改成 begin2 > end2 就可以了;这个修正方案可以直接不用归并一组,拷贝一组,而是可以将当前的 gap 分组归并完后,一次性拷贝回原数组;

以上就是归并排序的思路分析,归并排序的特性总结:

  1. 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
  2. 时间复杂度:O(N*logN)
  3. 空间复杂度:O(N)
  4. 稳定性:稳定

*8. 计数排序

计数排序是一种非比较排序,是利用另外一个数组 hash 记录需要排序的数组中的元素出现的次数,然后遍历一次 hash 数组,按顺序将出现的元素依次放到数组中,放一次就自减一次,直到出现过的元素出现次数减到 0 ,这样就相当于排序了;

这种排序算法只需要了解即可,因为它的局限性大,它有两大缺陷:
缺陷1:依赖数据范围,适用于范围集中的数组;
缺陷2:只能用于整形;

所以在这里我不作过多的分析,有兴趣的伙伴可以自行去了解;
参考代码如下:

		// 计数排序
		void CountSort(int* a, int n)
		{
			// 找出最大的元素和最小的元素
			int max = a[0], min = a[0];
			for (int i = 0; i < n; i++)
			{
				if (a[i] > max)
				{
					max = a[i];
				}
		
				if (a[i] < min)
				{
					min = a[i];
				}
			}
		
			// 计算这个数组的最大值和最小值的范围
			// 计算相对范围
			int range = max - min + 1;
		
			// 开辟空间,长度就是相对的范围
			int* hash = (int*)malloc(sizeof(int) * range);
			assert(hash);
		
			// 将空间初始化为 0 
			memset(hash, 0, sizeof(int) * range);
		
			// 统计某个元素在相对位置出现的次数
			for (int i = 0; i < n; i++)
			{
				hash[a[i] - min]++;
			}
		
			// 遍历相对范围,如果相对位置不为 0,说明出现过,就将这个元素的相对值放入元素中覆盖即可,然后出现的次数自减
			int pos = 0;
			for (int i = 0; i < range; i++)
			{
				while (hash[i] != 0)
				{
					a[pos++] = i + min;
					hash[i]--;
				}
			}
		}

三、各种排序的复杂度和稳定性

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

所以经过分析,我们得出各种排序算法的时间复杂度和空间复杂度以及稳定性如下表:
在这里插入图片描述
以上就是我对常见的各种排序的思路的分享,如有不正确或可以修改的地方,感谢指出!

猜你喜欢

转载自blog.csdn.net/YoungMLet/article/details/131710968