数据结构:内部排序

news2024/9/22 1:54:20

文章目录

  • 1. 前言
    • 1.1 什么是排序?
    • 1.2 排序的稳定性
    • 1.3 排序的分类和比较
  • 2. 常见的排序算法
  • 3. 实现常见的排序算法
    • 3.1 直接插入排序
    • 3.2 希尔排序
    • 3.3 直接选择排序
    • 3.4 堆排序
    • 3.5 冒泡排序
    • 3.6 快速排序
      • 3.6.1 hoare思想
      • 3.6.2 挖坑法
      • 3.6.3 lomuto前后指针法
      • 3.6.4 非递归实现快速排序
      • 3.6.5 基准值的选取问题
      • 3.6.6 小区间优化
    • 3.7 归并排序
      • 3.7.1 递归版本
      • 3.7.2 非递归版本
    • 3.8 非比较排序:计数排序
  • 4.测试排序的性能对比
  • 5. 排序算法复杂度及稳定性分析


1. 前言

1.1 什么是排序?

所谓排序(sort),就是要整理初始排序表中的元素,使之按关键字递增或递减的有序序列。

1.2 排序的稳定性

假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。

1.3 排序的分类和比较

内排序:在排序过程中,若整个排序表都放在内存中处理,排序时不涉及数据的内,外存交换,则称之为内排序
外排序:若在排序过程中要进行数据的内,外存交换,则称之为外排序

内排序适用于元素个数不是很多的小表,外排序则适用于元素个数很多,不能一次将其全部元素放入内存的大表内排序是外排序的基础


2. 常见的排序算法

在这里插入图片描述

3. 实现常见的排序算法

3.1 直接插入排序

思路:当插入第 i(i>=1) 个元素时,前面的 array[0],array[1],…,array[i-1] 已经排好序,此时用 array[i] 与 array[i-1],array[i-2],… 进行比较,找到插入位置(找到第一个比array[i]小或相等的数的位置的下一个位置)即将 array[i] 插入,原来位置上的元素顺序后移(默认为升序)

在这里插入图片描述

void InsertSort(int* arr, int n)
{
	for (int i = 0; i < n ; i++)
	{
		int end = i - 1;//位置为0~i-1的元素已经有序,从位置为i-1的元素开始比较
		int tmp = arr[i];//将位置为i的元素进行插入
		while (end >= 0)
		{
			if (arr[end] > tmp)
			{
				arr[end + 1] = arr[end];//向后移一位
				end--;//下标减一,继续比较
			}
			else
			{
				break;//找到第一个比待插入元素tmp小于或相等的元素,跳出循环
			}
		}
		arr[end + 1] = tmp;//在第一个比待插入元素tmp小于或相等的元素的位置的下一个位置插入tmp
	}
}

直接插入排序特性总结:

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

3.2 希尔排序

思路:希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数(通常是gap = n/3+1,采用Knuth增量序列),把待排序文件所有记录分成各组,所有的距离相等的记录分在同一组内,并对每⼀组内的记录进行排序,然后gap=gap/3+1得到下一个整数,再将数组分成各组,进行插入排序,当gap=1时,就相当于直接插入排序

先进行预排序,根据gap的大小分为gap组进行排序,排完序更新gap
预排序完后进行直接插入排序(gap==1)

在这里插入图片描述

那么要分多少组较为合适?也就是选择步长为多少比较好?这影响着希尔排序整体的时间复杂度和排序效率

常用的增量序列
(1) 希尔建议的序列

希尔建议的增量序列并没有一个固定的公式或明确的通项表达式,它通常是根据经验或实验得出的。一个常见的希尔增量序列示例是:[1, 4, 13, 40, …] (gap=n/3),但这个序列并不是固定的(也有可能为gap=n/2),可以根据具体的数据集和排序需求进行调整。希尔增量序列的选取对于希尔排序的性能有较大影响。

(2) Hibbard序列

Hibbard增量序列的通项公式为:hi = 2^i - 1,或者递推公式为:h1 = 1,hi = 2 * h(i-1) + 1
Hibbard序列生成的是一个递增的奇数序列,如[1, 3, 7, 15, 31, 63, …]。这个序列的优点在于,它使得在排序的每一轮中,各个子序列的长度互质,这有助于减少数据交换的次数,并提高排序的效率。Hibbard序列的另一个特点是,它保证了在排序的最后阶段,增量为1,从而确保整个数组能够被完整地排序。
最坏时间复杂度:O(N^3/2)
猜想的平均时间复杂度:O(N^5/4)

(3) Sedgewick序列

一个常见的Sedgewick增量序列是基于幂次和乘除运算的,如9×4^i - 9×2^i + 1或4^i - 3×2^i + 1(注意,这些公式可能不是Sedgewick原始研究中使用的确切公式,但它们代表了Sedgewick增量序列的一种可能形式)。这些序列的设计目的是在减少增量的过程中,能够更平滑地过渡到全数组排序,从而减少排序的总时间。
例如:{28,1,5,19,41,109,…},hi = Max((9×4^i - 9×2^i + 1),4^i - 3×2^i + 1)
猜测的最坏时间复杂度:O(N^4/3)
猜测的平均时间复杂度:O(N^7/6)

(4) Knuth 增量序列

h(i) = 1/2 * (3^i - 1)
递推公式:h(1) = 1,h(i) = 3 * h(i-1) + 1

算法实现

void ShellSort(int* arr, int n)
{
	int gap = n;
	while (gap > 1)
	{
		gap = gap / 3 + 1;//相对于gap / 2,gap = gap / 3 + 1可以加快排序的效率
		for (int i = 0; i < n - gap; i++)
		{
			int end = i;
			int tmp = arr[end + gap];
			while (end >= 0)
			{
				if (arr[end] > tmp)
				{
					arr[end + gap] = arr[end];//组内元素进行交换(移位)
					end -= gap;//减去步长即为同一组的前一个元素的下标,继续进行比较
				}
				else {
					break;
				}
			}
			arr[end + gap] = tmp;//end以及之前的同一组元素已经是有序,在end+gap的位置插入tmp即可
		}
	}
}

希尔排序的时间复杂度计算:
外层循环:外层循环的时间复杂度可以直接给出为: O(log2 n) 或者 O(log3 n) ,即 O(log n)
内层循环
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
因此,希尔排序在最初和最后的排序的次数都为n,即前一阶段排序次数是逐渐上升的状态,当到达某一顶点时,排序次数逐渐下降至n,而该顶点的计算暂时无法给出具体的计算过程。
希尔排序时间复杂度不好计算,因为 gap 的取值很多,导致很难去计算,因此很多书中给出的希尔排序的时间复杂度都不固定。《数据结构(C语言版)》— 严蔚敏书中给出的时间复杂度为:
在这里插入图片描述
希尔排序的特性总结

1.希尔排序是对直接插入排序的优化。
2.当 gap > 1 时都是预排序,目的是让数组更接近于有序。当 gap == 1 时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。
3.希尔排序的时间复杂度由步长gap决定,在实际应用中可以通过测试的方法来决定使用哪个增量序列较为合适
4.希尔排序的时间复杂度约为O(n^1.3),时间复杂度为O(1)
5.稳定性:不稳定

3.3 直接选择排序

思路:遍历数组并用begin和mimi指针先指向第一个位置,让mini指针向后遍历并记录最小数据的位置,遍历完后让begin和mini指向的数据交换(即让最小的数据排在最前面),一直遍历完数组和执行上述操作即可。
在这里插入图片描述

void SelectSort(int* arr, int n)
{
	for (int i = 0; i < n; i++)
	{
		int begin = i;
		int mini = i;
		//找最小的
		for (int j = i + 1; j < n; j++)
		{
			if (arr[j] < arr[mini])
			{
				mini = j;
			}
		}
		//找到了最小的值,i和mini位置数据进行交换
		Swap(&arr[begin], &arr[mini]);
	}
}

优化

既然可以向后找最小的数据然后与最前面的数据进行交换,那也可以向后找最大的数据然后与最后面的数据进行交换。

思路:定义两个指针begin(指向数组的开始位置),end(指向数组的末尾位置),再定义两个指针mini和maxi先指向begin指向的位置,然后向后遍历数组记录区间[begin,end]中最小数据和最大数据的位置,遍历完后让mini指向的数据与begin指向的数据进行交换,再让maxi指向的数据与end指向的数据进行交换,最后再让begin向后走一步(加一),end向前走一步(减一)。循环往复,当end<=begin时则退出循环

注意当maxi和begin指向同一个位置时会发生错误,因为是先将mini指向的数据与begin指向的数据进行交换,交换完后begin指向的数据是最小的数据,而maxi也指向begin指向的位置,在进行交换的话end指向的数据就会变成最小的数据。所以在进行数据交换前还得判断maxi是不是等于begin,如果是的话就要让maxi指向mini(mini和begin指向的数据交换后,mini指向的数据是最大的数据)

算法实现:

void SelectSort(int* arr, int n)
{
	int begin = 0;
	int end = n - 1;
	while (begin < end)
	{
		int mini = begin;
		int maxi = begin;
		for (int i = begin + 1; i <= end; i++)
		{
			if (arr[mini] > arr[i])
			{
				mini = i;
			}
			if (arr[maxi] < arr[i])
			{
				maxi = i;
			}
		}
		if (maxi == begin)
		{
			maxi = mini;
		}
		Swap(&arr[begin], &arr[mini]);
		Swap(&arr[end], &arr[maxi]);
		++begin;
		--end;
	}
}

直接选择排序特性总结:

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

3.4 堆排序

思路:堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。 对堆这个数据结构不熟悉的可以看一下我之前写的博客:数据结构:二叉树(堆)的顺序存储

void Swap(int* x, int* y)
{
	int tmp = *x;
	*x = *y;
	*y = tmp;
}
void AdjustDown(int* arr, int parent, int n)
{
	//第一种方法:递归
	int child = parent * 2 + 1;
	if (child + 1 < n && arr[child + 1] > arr[child])
	{
		child++;
	}
	if (arr[child] > arr[parent] && child < n)
	{
		Swap(&arr[child], &arr[parent]);
		AdjustDown(arr, child, n);
	}
	//第二种方法:循环
	/*
	int child = parent * 2 + 1;
	while (child < n)
	{
		if (child + 1 < n && arr[child] > arr[child + 1])
		{
			child++;
		}
		if (arr[child] < arr[parent])
		{
			Swap(&arr[child], &arr[parent]);
			parent = child;
			child = 2 * parent + 1;
		}
		else
		{
			break;
		}
	}
	*/
}

void HeapSort(int* arr, int n)
{
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(arr, i, n);
	}
	int end = n - 1;
	while (end > 0)
	{
		Swap(&arr[0], &arr[end]);
		AdjustDown(arr, 0, end);
		end--;
	}
}

堆排序特性总结

  1. 堆排序适用于数据量较大的情况,因为它的时间复杂度相对较低,能够在合理的时间内完成排序任务。
  2. 对于内存有限的环境也非常适用,由于其空间复杂度低,不需要额外的大量存储空间。
  3. 时间复杂度:O(N*logN),空间复杂度:O(1)
  4. 稳定性:不稳定

3.5 冒泡排序

思路:冒泡排序通过重复地遍历要排序的数列,依次比较相邻的两个元素,如果它们的顺序不符合要求就进行交换,就像水中的气泡逐步上升一样,每次把较大(或较小)的元素逐渐 “浮” 到数列的一端,经过多次遍历后实现整个数列的有序排列。

在这里插入图片描述

算法实现:

void BubbleSort(int* arr, int n)
{
	for (int i = 0; i < n; i++)
	{
		int exchange = 0;
		for (int j = 0; j < n - i - 1; j++)
		{
			if (arr[j] > arr[j + 1])
			{
				exchange = 1;
				Swap(&arr[j], &arr[j + 1]);
			}
		}
		if (exchange == 0)
		{
			break;
		}
	}
}

冒泡排序特性总结:

1.简单易懂,容易实现。
2.时间复杂度:O(N^2)
3.空间复杂度:O(1)
4.稳定性:稳定

3.6 快速排序

其基本思想为:任取待排序元素序列中的某元素作为基准值(通常选择第一个元素,最后一个元素或者序列中间的元素),按照该排序码(升序或者降序)将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。

快速排序是一种经典的分治排序算法,不断的将原数组分割成两个部分,再递归和排序两个左右子序列,直到原数组有序

3.6.1 hoare思想

  • 有两个指针left和right分别指向数组的第一个和最后一个元素
  • 随机选取一个元素作为基准值,这里选择第一个元素left
  • left从基准值后面开始从左向右找比基准值要大的元素
  • right从后往前找比基准值要小的元素
  • 找到后且left<=right,则交换left和right指向的元素
  • 循环往复,当left>right时则退出循环。此时left之前的元素都是小于基准值的(left指向的元素大于基准值),而right指向的元素也是小于基准值的,所以基准值要和right指向的元素进行交换(这就实现了比基准值小的元素都在基准值的前面,比基准值大的元素都在基准值的后面),最后再返回基准值的下标即可。
//hoare版本
int _QuickSort1(int* arr, int left, int right)
{
	int keyi = left;//选取序列的第一个元素作为基准值
	++left;//从基准值后面的元素开始查找
	while (left <= right)//left和right相遇的值比基准值要大
	{
		while (left <= right && arr[right] > arr[keyi])
		{
			right--;
		}
		while (left <= right && arr[left] < arr[keyi])
		{
			left++;
		}
		if (left <= right)
		{
			//left指向的元素大于基准值,right指向的元素小于基准值,所以要进行交换,交换不要忘了left++,right--
			Swap(&arr[left++], &arr[right--]);
		}
	}
	//最后要和right指向的元素进行交换
	Swap(&arr[keyi], &arr[right]);
	//返回基准值下标
	return right;
}

再进行分治操作,递归左右子序列

void QuickSort(int* arr, int left, int right)
{
	if (left >= right)
	{
		return;
	}
	//[left,right]-->找基准值mid
	int mid = _QuickSort1(arr, left, right);
	//左子序列:[left,mid-1]
	QuickSort(arr, left, mid - 1);
	//右子序列:[mid+1,right]
	QuickSort(arr, mid + 1, right);
}

当选取第一个元素作为基准值的hoare思想法的平均时间复杂度为:O(N*logN),当面对有序数组时,时间复杂度最差可能变为O(N^2)。在平均情况下,空间复杂度为O(logN),但是当面对最坏情况下(有序数组),空间复杂度最差为O(N)

3.6.2 挖坑法

在这里插入图片描述

思路:创建左右指针。首先从右向左找出比基准小的数据,找到后立即放入左边坑中,当前位置变为新的"坑",然后从左向右找出比基准大的数据,找到后立即放入右边坑中,当前位置变为新的"坑",结束循环后将最开始存储的分界值放入当前的"坑"中,返回当前"坑"下标(即分界值下标)

  • 选取第一个元素作为坑位,用key存储坑位的元素
  • right指针负责从右向左找比坑位要小的元素的下标,找到后将该元素放入坑位hole中,此时right指向元素的位置就是新的坑位
  • left指针负责从左向右找比坑位要大的元素的下标,找到后将该元素放入坑位hole中,此时left指向元素的位置就是新的坑位
  • 循环往复,当left>=right时则退出循环,最后将key放入到坑位hole中,返回hole即可
//挖坑法
int _QuickSort2(int* arr, int left, int right)
{
	int hole = left;//设置第一个元素作为坑位
	int key = arr[hole];//用key存储坑位的元素
	while (left < right)
	{
		while (left < right && arr[right] > key)
		{
			right--;
		}
		//找到比坑位小的元素,将它放入坑中,此时right指向的就是新的坑位
		arr[hole] = arr[right];
		hole = right;
		while (left < right && arr[left] < key)
		{
			left++;
		}
		//找到比坑位大的元素,将它放入坑中,此时left指向的就是新的坑位
		arr[hole] = arr[left];
		hole = left;
	}
	//最后将key放入坑中,返回坑位hole
	arr[hole] = key;
	return hole;
}

3.6.3 lomuto前后指针法

在这里插入图片描述

  • prev指针指向第一个元素的位置,cur指向prev下一个的位置,keyi存储第一个元素的位置
  • cur负责从前向右后走,一直找到比keyi指向的元素小的元素位置,找到后prev向后走一步,如果prev不等于cur则交换prev和cur指向的元素(如果prev等于cur就没有交换的必要),cur再++
  • 循环往复,一直到cur>right则退出循环
  • 此时prev指向的元素以及prev之前的元素都是小于keyi指向的元素的,所以再将prev和keyi指向的元素进行交换,最后返回prev即可
//lomuto前后指针法
int _QuickSort3(int* arr, int left, int right)
{
	int keyi = left;
	int prev = keyi;
	int cur = prev + 1;
	while (cur <= right)
	{
		if (arr[cur] < arr[keyi] && ++prev != cur)//避免prev和cur相等时再进行交换操作
		{
			Swap(&arr[cur], &arr[prev]);
		}
		cur++;
	}
	Swap(&arr[keyi], &arr[prev]);//交换后prev之前的元素都是小于prev所指向的元素,则prev所指向的元素即为基准值
	return prev;//最后返回基准值下标即可
}

3.6.4 非递归实现快速排序

上述采用递归的方法容易导致栈溢出(递归的深度太大),所以这里借助栈这个数据结构对待排序区间的下标进行压栈和出栈的操作来实现快速排序。

不了解栈这个数据结构的可以看一下我之前的博客:数据结构:栈

void QuickSortNonR(int* arr, int left, int right)
{
	Stack st;
	stackInit(&st);
	//由于栈是先进后出,所以先将右区间入栈,再将左区间入栈
	stackPush(&st, right);
	stackPush(&st, left);
	while (!stackIsEmpty(&st))
	{
		int begin = stackTop(&st);
		stackPop(&st);
		int end = stackTop(&st);
		stackPop(&st);
		int prev = begin;
		int cur = prev + 1;
		int keyi = begin;
		while (cur <= end)
		{
			if (arr[cur] < arr[keyi] && ++prev != cur)//避免prev和cur相等时再进行交换操作
			{
				Swap(&arr[cur], &arr[prev]);
			}
			cur++;
		}
		Swap(&arr[keyi], &arr[prev]);//交换后prev之前的元素都是小于prev所指向的元素,则prev所指向的元素即为基准值
		keyi = prev;//基准值为prev
		//根据基准值划分左右区间
		//左区间:[begin,keyi-1]
		//右区间:[keyi+1,end]
		if (keyi + 1 < end)
		{
			stackPush(&st, end);
			stackPush(&st, keyi + 1);
		}
		if (begin < keyi - 1)
		{
			stackPush(&st, keyi-1);
			stackPush(&st, begin);
		}
	}
	satckDestroy(&st);
}

非递归实现快速排序时,平均情况下的时间复杂度为:O(N*logN)。最坏情况发生在每次划分操作都选择到了数组中的最小(或最大)元素作为基准(pivot),导致每次划分后,一个子数组为空,另一个子数组仍然是原数组(除了基准元素)。这样,快速排序就退化成了冒泡排序,时间复杂度为 O(N^2)。

3.6.5 基准值的选取问题

(1) 比较不推荐的选取方法

通常选取第一个元素或者最后一个元素作为基准值,但是当数组是升序或者降序的时候就会导致一个子数组为空,另一个子数组仍然是原数组(除了基准元素)。在这种极端的情况下,快速排序的时间复杂度可能会达到O(N^2),性能严重下降

(2) 随机选取基准值

int SelectPivotRandom(int arr[], int low, int high)
{
	srand((unsigned)time(NULL));
	int pivotPos = rand() % (high - low) + low;
  
	Swap(&arr[pivotPos], &arr[low]);
	return arr[low];
}

优点:

1.避免分区不均匀:随机选取基准值能够显著减少因为固定选取基准值(如数组的第一个、最后一个或中间元素)而可能导致的分区不均匀问题。分区不均匀是快速排序效率下降的主要原因之一,因为它可能导致递归深度增加,甚至在最坏情况下退化为冒泡排序的性能O(N^2) )
2.提高排序效率:通过随机选取基准值,可以更加均匀地分割数据集,使得每次分区后,两个子数组的大小更加接近,从而减少了递归调用的次数和比较的次数,提高了整体排序效率。

缺点:

1.随机性带来的不确定性:尽管随机选取基准值在大多数情况下能提高排序效率,但其随机性也带来了一定的不确定性。在某些极端情况下,即使随机选取基准值,也可能出现分区不均匀的情况,尽管这种情况相对较少。
2.实现复杂度增加和可能增加额外开销:相比于固定选取基准值的方法,随机选取基准值需要额外的步骤来生成随机索引并与数组中的某个元素交换,这增加了算法的实现复杂度,随机选取基准值可能带来的额外开销会相对较大,从而影响排序的整体性能。
3.对随机数生成器的依赖:随机选取基准值的方法依赖于随机数生成器的质量。如果随机数生成器不够随机或存在缺陷,那么随机选取基准值的效果可能会受到影响,甚至可能不如固定选取基准值的方法。

(3) 三数取中法
取第一个元素,最后一个元素和中间元素的中位数的下标,将该中位数与数组的首元素或尾元素进行交换即可。

int getmidi(int* arr, int left, int right)
{
	//int midi = (left + right) / 2;
	int midi = left + right >> 1;
	if (arr[left] < arr[right])
	{
		if (arr[left] < arr[midi])//left最小
		{
			if (arr[midi] > arr[right])
				return right;
			else
				return midi;
		}
		else
			return left;
	}
	else
	{
		if (arr[right] < arr[midi])//right最小
		{
			if (arr[left] < arr[midi])
				return left;
			else
				return midi;
		}
		else
			return right;
	}
}

具体使用时,只需要将下面的代码放到每次划分左右子序列代码的开头即可。

int key = getmidi(arr, left, right);
Swap(&arr[key], &arr[left]);

这样每次就可以将left指向的元素作为基准值,防止出现有序的情况和便于划分操作

(4) 三路划分法

int _QuickSort4(int* arr, int left, int right)
{
	int keyi = getmidi(arr, left, right);
	Swap(&arr[keyi], &arr[left]);
	int key = arr[left];
	int prev = left;
	int cur = prev + 1;
	int r = right;
	while (cur <= r)
	{
		if (arr[cur] > key)
		{
			Swap(&arr[cur], &arr[r]);
			r--;
		}
		else if (arr[cur] < key)
		{
			Swap(&arr[cur], &arr[prev]);
			cur++;
			prev++;
		}
		else {
			cur++;
		}
	}
	return prev;//最后返回基准值下标即可
}

3.6.6 小区间优化

当待排序序列的长度划分到一定大小的时候(N<=20),使用直接插入排序。

原因:对于很小和部分有序的数组,快速排序不如直接插入排序好。当待排序序列的长度划分到一定大小后,继续划分的效率比直接插入排序要差(递归调用会消耗一定的栈空间),此时可以使用直接插入排序而不是快速排序。

void InsertSort(int* arr, int left , int right) {  
    for (int i = left + 1; i <= right; i++) {  
        int end = i - 1;  
        int tmp = arr[i];  
        while (end >= left && arr[end] > tmp) {  
            arr[end + 1] = arr[end];  
            end--;  
        }  
        arr[end + 1] = tmp;  
    }  
}
void QuickSort(int* arr, int left, int right)
{
	if (left >= right)
	{
		return;
	}
	if (right - left + 1 <= 20)
	{
		InsertSort(arr, left, right);
		return;
	}
	//[left,right]-->找基准值mid
	int mid = _QuickSort4(arr, left, right);
	//左子序列:[left,mid-1]
	QuickSort(arr, left, mid - 1);
	//右子序列:[mid+1,right]
	QuickSort(arr, mid + 1, right);
}

3.7 归并排序

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

3.7.1 递归版本

归并排序(Merge Sort)是一种高效的排序算法,采用分治法(Divide and Conquer)的一个非常典型的应用。归并排序的核心思想是将数组分成两半,分别对它们进行排序,然后将排序好的两半合并在一起。这个过程一直递归进行,直到分割的子数组只包含一个元素(此时认为它是排序好的)。

在这里插入图片描述

  • 分解过程:找到数组的中间结点,将数组分成两个子数组,再分别对左子数组和右子数组进行递归调用,一直递归到子数组只有一个元素的时候就停止递归调用。
  • 合并过程:核心是将两个有序的子数组合并成一个有序的数组:创建一个数组tmp用来存放合并后的有序数组,创建两个指针begin1和end1用来指向一个子数组的首尾位置,再创建两个指针begin2和end2用来指向另一个子数组的首尾位置,再创建一个index指针指向begin1(表示在tmp数组存放第一个元素的位置)。比较begin1和begin2指向的元素,将较小或者较大的元素放入tmp中,再将指向该元素的指针和index向后走一步,一直到其中一个子数组里的元素全部存放在tmp数组中,再将另一个子数组里的剩余所有元素放入tmp数组中即可。
void _MergeSort(int* arr, int left, int right,int* tmp)
{
	if (left >= right)
	{
		return;
	}
	int mid = (left + right) / 2;
	//[left,mid] [mid+1,right]
	_MergeSort(arr, left, mid, tmp);
	_MergeSort(arr, mid + 1, right, tmp);

	//合并
	//[left,mid] [mid+1,right]
	int begin1 = left, end1 = mid;
	int begin2 = mid + 1, end2 = right;
	int index = begin1;
	while (begin1 <= end1 && begin2 <= end2)
	{
		if (arr[begin1] < arr[begin2])
		{
			tmp[index++] = arr[begin1++];
		}
		else
		{
			tmp[index++] = arr[begin2++];
		}
	}
	//要么begin1越界,要么begin2越界
	while (begin1 <= end1)
	{
		tmp[index++] = arr[begin1++];
	}
	while (begin2 <= end2)
	{
		tmp[index++] = arr[begin2++];
	}
	//[left,mid] [mid+1,right]
	//把tmp中的数据拷贝回arr中
	for (int i = left; i <= right; i++)
	{
		arr[i] = tmp[i];
	}
}
//归并排序
void MergeSort(int* arr, int n)
{
	//创建新数组tmp
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc fail!");
		exit(1);
	}
	_MergeSort(arr, 0, n - 1, tmp);
	//销毁空间
	free(tmp);
	tmp = NULL;
}

归并排序特性总结(递归):

时间复杂度:O(N*logN)
空间复杂度:O(N)
稳定性:稳定

3.7.2 非递归版本

思路:首先开辟一个新数组tmp,再设置变量gap来进行分组,分别记录两个子数组的左右区间,将两个子数组进行比较,小的元素插入数组tmp中,直到一个子数组的全部元素插入到tmp数组后再将另一个子数组的剩下所有元素插入到tmp数组中。完成一趟排序后就将排序完的数据拷贝到原数组arr中,再将gap*2,继续完成剩余的排序,直到gap>=n时则完成了全部的排序。

void MergerSortNonR(int* a, int n)
{
	//创建数组
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("tmp");
		exit(-1);
	}
	//划分组数
	int gap = 1;
 
	while (gap < n)
	{
		int j = 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[j++] = a[begin1++];
				}
				else
				{
					tmp[j++] = a[begin2++];
				}
			}
 
			//再将剩余数据依次尾插
			while (begin1 <= end1)
			{
				tmp[j++] = a[begin1++];
			}
			while (begin2 <= end2)
			{
				tmp[j++] = a[begin2++];
			}
		}
 
		//将数据重新拷贝至原数组
		memcpy(a, tmp, sizeof(int) * n);
		//更新gap
		gap *= 2;
	}
 
	//释放
	free(tmp);
}

在这里插入图片描述
可以看出这个排序结果是错误的,为什么会错误呢?因为随着gap的增大,当gap=8时就会发生越界问题,所以导致排序结果错误,下面就来分析一下

注意要注意处理边界问题

一个子数组的左右区间为begin1和end1,另一个子数组的左右区间为begin2和end2,则数组越界有三种情况:

    1. end1,begin2,end2越界
    1. begin2,end2越界
    1. end2越界

改正方式有两种:

第一种:
1.首先不能将子数组全部排完序就将数据整体拷贝,需要排完一组,拷贝一组。
2.当发生越界中的第一、二种时可以直接break。
3.当发生越界中的第三种时可以将边界进行修正。

void MergeSortNonR1(int* arr, int n)
{
	//创建新数组tmp
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc fail!");
		exit(-1);
	}
	int gap = 1;//根据gap进行分组
	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 j = i;
			//end1和begin2越界直接跳出循环
			if (end1 >= n || begin2 >= n)
			{
				break;
			}
			//end2越界可以修正
			if (end2 >= n)
			{
				end2 = n - 1;
			}
			while (begin1 <= end1 && begin2 <= end2)
			{
				if (arr[begin1] < arr[begin2])
				{
					tmp[j++] = arr[begin1++];
				}
				else
				{
					tmp[j++] = arr[begin2++];
				}
			}
			while (begin1 <= end1)
			{
				tmp[j++] = arr[begin1++];
			}
			while (begin2 <= end2)
			{
				tmp[j++] = arr[begin2++];
			}
			//归并一组,拷贝一组
			memcpy(arr + i, tmp + i, sizeof(int) * (end2 - i + 1));
		}
		gap *= 2;
	}
	free(tmp);
	tmp = NULL;
}

第二种:
只需要将越界的区间改为不存在的区间即可

void MergeSortNonR2(int* arr, int n)
{
	//创建新数组tmp
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc fail!");
		exit(-1);
	}
	int gap = 1;//根据gap进行分组
	while (gap < n)
	{
		int j = 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)
			{
				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 (arr[begin1] < arr[begin2])
				{
					tmp[j++] = arr[begin1++];
				}
				else
				{
					tmp[j++] = arr[begin2++];
				}
			}
			while (begin1 <= end1)
			{
				tmp[j++] = arr[begin1++];
			}
			while (begin2 <= end2)
			{
				tmp[j++] = arr[begin2++];
			}
		}
		//整体拷贝
		memcpy(arr, tmp, sizeof(int) * n);
		gap *= 2;
	}
	free(tmp);
	tmp = NULL;
}

归并排序(非递归)的时间复杂度为:O(N*logN),空间复杂度为:O(N)

3.8 非比较排序:计数排序

计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用
计数排序适用于数据在一定的范围内较为集中的情景,其时间复杂度较低,但空间需求较大(空间换时间)

思路:先统计相同元素出现次数,再根据统计的结果将序列回收到原来的序列中

//计数排序
void CountSort(int* arr, int n)
{
	//根据最大值和最小值确定数组大小
	int max = arr[0], min = arr[0];
	for (int i = 1; i < n; i++)
	{
		if (arr[i] > max)
		{
			max = arr[i];
		}
		if (arr[i] < min)
		{
			min = arr[i];
		}
	}
	int range = max - min + 1;
	int* count = (int*)malloc(sizeof(int) * range);
	if (count == NULL)
	{
		perror("malloc fail!");
		exit(1);
	}
	//初始化count数组中所有的数据为0
	memset(count, 0, sizeof(int) * range);
	//统计数组中每个数据出现的次数
	for (int i = 0; i < n; i++)
	{
		count[arr[i] - min]++;
	}
	//取count中的数据,往arr中放
	int index = 0;
	for (int i = 0; i < range; i++)
	{
		while (count[i]--)
		{
			arr[index++] = i + min;
		}
	}
}

计数排序的局限性:

1.数据范围限制:如果数据的范围非常大,那么就需要一个同样大的数组来计数,这将导致内存空间的极大浪费,甚至可能超出系统的内存限制。因此,计数排序通常只适用于数据范围相对较小的场景。
2.数据类型限制:计数排序通常只适用于整数排序,对于浮点数、字符串等类型的数据,计数排序则难以直接应用
3.内存使用:计数排序需要额外的内存空间来存储计数数组。在最坏的情况下,如果数据的范围与数据的数量接近,那么计数数组的大小可能会与原始数据数组的大小相当,从而占用大量的内存空间。

计数排序特性总结:

1.计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
2.时间复杂度:O(N+range)
3.空间复杂度:O(range)
4.稳定性:稳定

4.测试排序的性能对比

排10万个数据,各个排序算法时间的比较(单位:ms):

在这里插入图片描述

5. 排序算法复杂度及稳定性分析

在这里插入图片描述
在这里插入图片描述

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

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

相关文章

软考(中级-软件设计师)计算机系统篇(0921)

I 计算机系统知识 一、考纲要求 数值及其转换 二进制、十进制和十六进制等常用数制及其相互转换 计算机内数据的表示 数值的表示&#xff08;原码、反码、补码、移码表示&#xff0c;整数和实数的机内表示&#xff0c;精度和溢出&#xff09;非数值表示&#xff08;字符和汉字…

AI直播新浪潮:无人视频自动直播,出圈再造辉煌,创业者首选!

AI直播新浪潮:无人视频自动直播&#xff0c;出圈再造辉煌&#xff0c;创业者首选&#xff01; 在数字化浪潮的汹涌澎湃中&#xff0c;AI技术正以前所未有的速度重塑着各行各业的边界&#xff0c;而直播行业作为数字内容消费的前沿阵地&#xff0c;正迎来一场由AI驱动的深刻变革…

MQ(RabbitMQ)笔记

初识MQ 同步调用优缺点 异步调用优缺点 总结&#xff1a; 时效性要求高&#xff0c;需要立刻得到结果进行处理--->同步调用 对调用结果不关心&#xff0c;对性能要求高&#xff0c;响应时间短--->异步调用

2024年华为杯-研赛F题论文问题一二讲解+代码分享

X射线脉冲星光子到达时间建模 摘要 脉冲星是一类高速自转的中子星&#xff0c;其自转形成规律性脉冲信号&#xff0c;类似于“宇宙中的灯塔”&#xff0c;因此被认为是极为精确的时钟。X射线脉冲星导航利用脉冲星信号为航天器提供时间和空间参考。通过比较脉冲信号到达航天器…

查找算法 01分块查找

自己设计一个分块查找的例子&#xff0c;不少于15个数据元素&#xff0c;并建立分块查找的索引 基于上述例子&#xff0c;计算查找成功的ASL、查找失败的ASL 拓展&#xff1a; ‌‌分块查找的平均查找长度&#xff08;‌ASL&#xff09;的计算公式如下‌&#xff1a;‌ ‌顺序…

Camunda流程引擎并发性能优化

文章目录 Camunda流程引擎一、JobExecutor1、工作流程2、主要作用 二、性能问题1、实际场景&#xff1a;2、性能问题描述3、总结 三、优化方案方案一&#xff1a;修改 Camunda JobExecutor 源码以实现租户 ID 隔离方案二&#xff1a;使用 max-jobs-per-acquisition 参数控制上锁…

ThreadLocal与AsyncLocal

简介 ThreadLocal 用于在多线程环境中创建线程局部变量&#xff0c;可以让每个线程独立地访问自己的变量副本&#xff0c;互不影响。 而 AsyncLocal 是 ThreadLocal 的异步版本&#xff0c;专门用于异步编程场景&#xff0c;在异步操作中它可以正确处理上下文切换。 ThreadLo…

ftp服务的管理及安全优化

1.ftp介绍 ftp : file transfer proto 互联中最老牌的文件传输协议 2.vsftpd安装及启用 环境 #server 主机 &#xff1a; # R3 # 192.168.10.130 # selinux 关闭 # 火墙开启 # dnf 安装设定完成 # #client 主机 &#xff1a; # R4 # 192.168.10.131 # selinux 关闭 …

C++之职工管理系统(细节Q)

指针初始化类 && 普通变量初始化类 抽象基类worker&#xff0c;只需编写 .h &#xff0c;无需 .cpp 底层实现 类 记得声明权限public&#xff01;&#xff01;&#xff01;不然默认private&#xff0c;主函数访问不了 记得继承父类 Worker * worker&#xff1a;指向Wo…

source insight学习笔记

目录 目的 基础配置 1、护眼的保护色 2、行号显示 基础操作 目的 记录一下使用source insight中遇到的问题。比如常见好用的基础配置&#xff0c;常用的基础操作等。主要是为了自己以后忘记了好找。自己写的东西总归看起来更舒服。 PS&#xff1a;目前是第一个版本&#…

Linux相关概念和重要知识点(5)(权限的修改、时间属性)

1.权限的修改 &#xff08;1&#xff09;提权行为 普通用户是会受到权限的限制&#xff0c;但root账户无视一切权限限制&#xff0c;因此当我们要获取更高的权限&#xff0c;有一种办法就是将自己变成root或者短暂拥有和root一样的权力。 普通用户 -> root &#xff1a;s…

NoSql数据库Redis知识点

数据库的分类 关系型数据库 &#xff0c;是建立在关系模型基础上的数据库&#xff0c;其借助于集合代数等数学概念和方法来处理数据库 中的数据主流的 MySQL 、 Oracle 、 MS SQL Server 和 DB2 都属于这类传统数据库。 NoSQL 数据库 &#xff0c;全称为 Not Only SQL &a…

网络丢包定位记录(二)

网卡驱动丢包 查看&#xff1a;ifconfig eth1/eth0 等接口 1.RX errors: 表示总的收包的错误数量&#xff0c;还包括too-long-frames错误&#xff0c;Ring Buffer 溢出错误&#xff0c;crc 校验错误&#xff0c;帧同步错误&#xff0c;fifo overruns 以及 missed pkg 等等。 …

K8S介绍+集群部署

Kubernetes介绍 官网&#xff1a;https://kubernetes.io/ 一、应用部署方式演变 1、传统部署&#xff1a;互联网早期&#xff0c;会直接将应用程序部署在物理机上 优点&#xff1a;简单&#xff0c;不需要其他技术的参与 缺点&#xff1a;不能为应用程序定义资源使用边界&a…

WAN广域网技术--PPP和PPPoE

广域网基础概述 广域网&#xff08;Wide Area Network&#xff0c;WAN&#xff09;是一种覆盖广泛地区的计算机网络&#xff0c;它连接不同地理位置的计算机、服务器和设备。广域网通常用于连接不同城市、州或国家之间的网络&#xff0c;它通过互联网服务提供商&#xff08;ISP…

九芯电子革新健康检测!语音播报血压计ic芯片解决方案

血压计&#xff0c;可测量血压并将读数显示在屏幕上。为了提高老年人和视障人士的可用性&#xff0c;现代电子语音血压计已经开发出来&#xff0c;可提供当前血压读数的听觉反馈。这是通过集成语音芯片来实现的&#xff0c;该芯片将测量结果发声给用户。 &#xff08;一&#x…

Java免税购物商城:Spring Boot技术实现

第二章 系统开发关键技术 2.1 JAVA技术 Java主要采用CORBA技术和安全模型&#xff0c;可以在互联网应用的数据保护。它还提供了对EJB&#xff08;Enterrise JavaBeans&#xff09;的全面支持&#xff0c;java servlet AI&#xff0c;JS&#xff08;java server ages&#xff09…

《使用 LangChain 进行大模型应用开发》学习笔记(四)

前言 本文是 Harrison Chase &#xff08;LangChain 创建者&#xff09;和吴恩达&#xff08;Andrew Ng&#xff09;的视频课程《LangChain for LLM Application Development》&#xff08;使用 LangChain 进行大模型应用开发&#xff09;的学习笔记。由于原课程为全英文视频课…

银河麒麟桌面操作系统V10(SP1)离线升级SSH(OpenSSH)服务

目录 前言 准备工作 准备与目标服务器相同版本的操作系统 准备编译依赖包 下载OpenSSL源码包 下载OpenSSH源码包 升级OpenSSH服务 查看当前版本信息 安装编译依赖包 安装OpenSSL 安装OpenSSH 前言 OpenSSH是一个广泛使用的开源SSH(安全壳)协议的实现,它提供了安…

01-ZYNQ linux开发环境安装,基于Petalinux2023.2和Vitis2023.2

TFTP 服务器配置 安装安装 tftp-hpa 和 tftpd-hpa &#xff1b;tftp-hpa 客户端&#xff0c;tftpd-hpa 为服务端 #安装 tftp-hpa 和 tftpd-hpa sudo apt-get install tftp-hpa tftpd-hpa配置服务器 #创建路径 mkdir -p ~/workspace/tftp-boot chmod 777 ~/workspace/tftp-b…