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

news2024/11/27 16:39:19

排序算法

  • 一、常见的排序算法
  • 二、常见排序算法的实现
    • 1. 直接插入排序
    • 2. 希尔排序
    • 3. 直接选择排序
    • 4. 堆排序
    • 5. 冒泡排序
    • 6. 快速排序
      • 6.1 递归实现快速排序
        • 思路一、hoare 版本
        • 思路二、挖坑法
        • 思路三、前后指针法
      • 6.2 非递归实现快速排序
    • 7. 归并排序
      • 7.1 归并排序的递归实现
      • 7.2 归并排序的非递归实现
    • *8. 计数排序
  • 三、各种排序的复杂度和稳定性

一、常见的排序算法

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

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

在这里插入图片描述

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

二、常见排序算法的实现

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] 之前,则称这种排序算法是稳定的;否则称为不稳定的。

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/753157.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Word2Vec实现文本识别分类

深度学习训练营之使用Word2Vec实现文本识别分类 原文链接环境介绍前言前置工作设置GPU数据查看构建数据迭代器 Word2Vec的调用生成数据批次和迭代器模型训练初始化拆分数据集并进行训练 预测 原文链接 &#x1f368; 本文为&#x1f517;365天深度学习训练营 中的学习记录博客&…

Apache Phoenix(2):安装Phoenix

1 下载 大家可以从官网上下载与HBase版本对应的Phoenix版本。 http://phoenix.apache.org/download.html 2 安装 &#xff08;1&#xff09;上传安装包到Linux系统&#xff0c;并解压 cd /opt/ tar -xvzf phoenix-hbase-2.5-5.1.3-bin.tar.gz &#xff08;2&#xff09;将p…

软件测试真实企业测试流程

最近收到不少准备转行软件测试的小伙伴私信问真实企业里面软件测试流程是什么样子的&#xff1f; 对于这个问题&#xff0c;在面试的时候也是经常会被问到。 关于测试流程&#xff0c;100家公司可能有100套测试流程&#xff0c;但是基本上都是大同小异&#xff0c;完全可以将测…

行业追踪,2023-07-14,汽车零部件在反弹时已清仓,耐心等待第二波买点重现

自动复盘 2023-07-14 凡所有相&#xff0c;皆是虚妄。若见诸相非相&#xff0c;即见如来。 k 线图是最好的老师&#xff0c;每天持续发布板块的rps排名&#xff0c;追踪板块&#xff0c;板块来开仓&#xff0c;板块去清仓&#xff0c;丢弃自以为是的想法&#xff0c;板块去留让…

ELK-日志服务【kafka-配置使用】

kafka-01 10.0.0.21 kafka-02 10.0.0.22 kafka-03 10.0.0.23 【1】安装zk集群、配置 [rootes-01 ~]# yum -y install java maven [rootes-01 ~]# tar xf apache-zookeeper-3.5.9-bin.tar.gz -C /opt/[rootes-01 ~]# cd /opt/apache-zookeeper-3.5.9-bin/conf/ [rootes-…

MySQL结构以及数据管理(增删改查)

目录 1.数据库的简介 2.数据库分类 2.1关系型数据库 2.2 非关系型数据库 3.mysql的数据类型 3.1 常用的数据库类型 4.mysql的数据库结构 4.1 查看库信息 4.2 查看表信息 5.SQL 语句 5.1 SQL语言分类&#xff1a; 1.数据库的简介 数据库&#xff08;database&#…

基于LoRa技术的网络终端无线程序升级系统研究(学习)

摘要 设计了一种基于LoRa技术的STM32F4无线程序升级系统。此系统由PC及相关STM32软件开发环境、LoRa通信模块及控制器和STM32F4终端三部分组成。 本系统采用LoRa技术将程序数据无线发送到终端&#xff0c;终端通过IAP技术实现远程无线程序自动升级。测试结果表明&#xff0c;…

基于Springboot+Vue的宠物店猫咖管理系统(源代码+数据库)088

基于SpringbootVue的宠物店猫咖管理系统(源代码数据库)088 一、系统介绍 本系统分为管理员、店长、用户三种角色 用户角色包含以下功能&#xff1a; 登录、注册、我的宠物、我的信息、文件管理、我的预约、门店详情、打工喵、本地喵、外来喵、宠物领养、个人中心、密码修改…

0基础学习VR全景平台篇 第61篇:基本功能-如何发布VR视频

戳我先了解“全景视频上传规范” 1、点击【上传】按钮&#xff0c;打开本地文件夹&#xff0c;上传符合要求的全景视频素材&#xff0c;可以选择单个或多个视频同时上传。 2、视频上传成功以后&#xff0c;需要处理一段时间&#xff0c;请耐心等待。 视频处理好以后&#xff0…

2023年7月最新ESI 中国 国内高校排名(附上下载链接),

东南大学的排名 多年来 一直是22, 21 &#xff0c;还需要进一步提高&#xff0c;进入前10. 下载连接 (375条消息) 2023年7月最新ESI数据国内高校排名表格-行业报告文档类资源-CSDN文库

Vue3 组件之间跨级通信

文章目录 Vue3 组件之间跨级通信概述选项式API简单使用支持响应式调用顶层方法 组合式API简单使用支持响应式底层调顶层方法 Vue3 组件之间跨级通信 概述 通常情况下&#xff0c;当我们需要从父组件向子组件传递数据时&#xff0c;会使用props。 但是一些多层级嵌套的组件&a…

【Vue】给 elementUI 中的 this.$confirm、this.$alert、 this.$prompt添加按钮的加载效果

文章目录 主要使用 beforeClose 方法实现 loading 的效果beforeClose MessageBox 关闭前的回调&#xff0c;会暂停实例的关闭 function(action, instance, done)1. action 的值为confirm, cancel或close。 2. instance 为 MessageBox 实例&#xff0c;可以通过它访问实例上的属…

不外传秘诀| docker 快速搭建常用的服务环境

本文主要给大家介绍如何使用 docker 搭建常用的服务环境&#xff0c; 包括mysql,reedis,nginx,jenkins 等常用的环境&#xff0c;下面直接进入主题。 1、MySQL 部署 ①搜索 MySQL 镜像 docker search mysql ②拉取 MySQL 镜像 docker pull mysql:5.7 ③创建容器&#xf…

嵌入式LinuxLED驱动开发实验

目录&#xff1a; 1. Linux下LED灯的驱动原理1.1. 地址映射1.1.1. 实际物理地址映射到虚拟地址的函数1.1.2. 内存访问函数 2.硬件原理图分析3. 实验程序编写3.1. 驱动程序编写3.2. 应用程序编写 4. 运行测试 1. Linux下LED灯的驱动原理 我们在裸机实验的时候&#xff0c;都是通…

2023年最具威胁的25种安全漏洞(CWE TOP 25)

1. CWE 4.12发布 最近几年&#xff0c;每年6月CWE发布的版本都成为一年中最重要的版本&#xff0c;因为里面包含了新的CWE TOP 25 视图&#xff0c;也就是我们常说的&#xff1a;CWE最具威胁的25种缺陷。 CWE 4.12 在6月29号发布&#xff0c;里面包含了重要的2023年TOP25视图…

Promise分享

手写promise之前需要知道 宏任务 & 微任务 我们都知道 Js 是单线程的&#xff0c;但是一些高耗时操作就带来了进程阻塞问题。为了解决这个问题&#xff0c;Js 有两种任务的执行模式&#xff1a;同步模式&#xff08;Synchronous&#xff09;和异步模式&#xff08;Asynchr…

2023年先进封装行业研究报告

第一章 行业概况 1.1 概述 封装是半导体制造过程中的一个重要步骤。在这个步骤中&#xff0c;半导体芯片&#xff08;或称为集成电路&#xff09;被包裹在一个保护性的外壳中。这个外壳的主要功能是保护芯片免受物理和化学损害&#xff0c;例如防止芯片受到潮湿、尘埃、温度变…

git clone 或者是vscode clone 时遇到the remote end hung up unexpectedly

fatal: the remote end hung up unexpectedly fatal: early EOF fatal: index-pack failed使用git clone总是报错 查看原因有三种可能&#xff1a;要么是缓存不够&#xff0c;要么是网络不行&#xff0c;要么墙的原因。 如果是网络不行&#xff0c;可以配置git的最低速度和最…

pdf.js移动端展示预览打开pdf-pdfh5.js

有问题可以加Q群咨询&#xff0c;技术交流群&#xff0c;也可以探讨技术&#xff0c;另有微信群可以问群主拉入微信群 QQ群521681398 pdfh5博客主页 pdfh5项目GitHub地址 pdfh5项目gitee地址 react、vue均可使用 example/test是vue使用示例 example/vue3demo是vue3使用示…

第三章系统控制(Cortex-M7 Processor)

第三章系统控制 目录 第三章系统控制 3.1关于系统控制 3.2寄存器汇总 3.3寄存器描述 3.3.1辅助控制寄存器 3.3.2cpu基寄存器 3.3.3 Cache Level ID寄存器 3.3.4缓存大小ID寄存器 3.3.5缓存大小选择寄存器 3.3.6指令和数据紧密耦合的存储器控制寄存器 3.3.7 AHBP控制寄存器 3…