【算法】七大经典排序(插入,选择,冒泡,希尔,堆,快速,归并)(含可视化算法动图,清晰易懂,零基础入门)

news2024/11/19 8:38:54


目录

  • 一、排序的概念及其运用
    • 1.1 排序的概念
    • 1.2 排序的应用
    • 1.3 常见的排序算法
  • 二、常见排序算法的实现
    • 2.1 插入排序
      • 2.1.1 直接插入排序
      • 2.1.2 希尔排序
      • 2.1.3 直接插入排序和希尔排序的性能对比
    • 2.2 选择排序
      • 2.2.1 直接选择排序
      • 2.2.2 堆排序
      • 2.2.3 直接选择排序和堆排序的性能对比(包括前面)
    • 2.3 交换排序
      • 2.3.1 冒泡排序
      • 2.3.2 快速排序
        • 2.3.2.1 递归实现
        • 2.3.2.2 非递归实现
      • 2.3.3 冒泡排序和快速排序的性能对比(包括前面)
      • 2.3.4 快速排序优化
    • 2.4 归并排序
      • 2.4.1 递归实现
      • 2.4.2 非递归实现
      • 2.4.3 归并排序优化
      • 2.4.4 归并排序的应用——外排序
  • 三、排序算法复杂度及稳定性分析
    • 3.1 稳定性的意义
    • 3.2 稳定性分析
    • 3.3 排序算法时间、空间复杂度和稳定性比较(表格)

个人专栏

《数据结构世界》

一、排序的概念及其运用

1.1 排序的概念

排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。

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

  • 内部排序:数据元素全部放在内存中的排序。

  • 外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。

1.2 排序的应用

Alt

1.3 常见的排序算法

Alt

二、常见排序算法的实现

2.1 插入排序

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

其实我们玩扑克牌时,就用到了插入排序的思想
Alt

2.1.1 直接插入排序

假设tmp为我们要插入的牌,我们往[0, end] 这个有序区间中比较插入 (这里排升序)

  1. 如果end位置的元素大于tmp,则将该元素向后挪动一位,end–
  2. 如果小于tmp,则break跳出循环,将tmp插入end+1的位置
  3. 循环的条件,end >= 0(如果end的元素一直大于tmp,那end最后就会减到-1)

代码实现:

上述是插入一次数据的过程,那如果我要对一个数组进行排序呢?很简单,先取数组的第二个元素进行插入排序,再取第三个……依此类推,循环上述过程,即可完成对数组的排序。


运行结果:

先简单写个打印数组函数

在这里插入图片描述

时间复杂度分析:

  • O(N2) —— (最坏)逆序
  • O(N) —— (最好)顺序

从上述分析可以看出,如果要排序的数据越接近有序,则使用直接插入排序的时间效率越高。

void PrintArray(int* a, int n)
{
	for (int i = 0; i < n; i++)
	{
		printf("%d ", a[i]);
	}
	printf("\n");
}

void InsertSort(int* a, int n)
{
	for (int i = 0; i < n - 1; i++)
	{
		int end = i;
		int tmp = a[i + 1];

		while (end >= 0)
		{
			if (a[end] > tmp)
			{
				a[end + 1] = a[end];
				end--;
			}
			else
			{
				break;
			}
		}

		a[end + 1] = tmp;
	}
}

2.1.2 希尔排序

上述分析给予我们启发,如果我让要排序的数据先接近有序,再用直接插入排序,那不就皆大欢喜了吗?
而就是一位叫希尔的大佬,付诸了行动,希尔排序主要分为两步:

  1. 预排序 —— 接近有序
  2. 直接插入排序

那么,什么叫做预排序呢?简单来说,分为两步:

  1. 对数据进行分组。间隔为gap的数据分为一组,总计gap组
  2. 分别对每组数据插入排序

举个例子
假设 gap=3 ,那么我们就可以分成红蓝绿3组,每组间距为3。那么我们对每组进行插入排序,就会变成下图的情况。这样做可以节省时间,比如9原本到后面要9次,现在只用3次即可,因为每次都跨越gap步

有了前面直接插入排序的经验,我们就很容易实现一组的插入排序(比如红色),只用将间距从1变成gap即可。

那么我们要实现对每一组都排序,只要在外层套一层循环即可。因为有gap组,所以循环gap次。

先测试一下预排序,运行结果:

预排序功能基本实现,但是,其实我们还可以简化一下代码,减少一层循环,只需要小小的改动一下。
大家可以仔细思考其中的差异。其实,思想转变很简单,原本是红绿蓝一组一组排,现在是多组并排,但是时间效率是相等的。

完成了预排序的实现,我们再来看看gap的取值

  1. gap = 1时,恰好就是直接插入排序
  2. gap > 1时,才是预排序

同时,我们发现gap越大,预排序速度越快,但越不接近有序;gap越小,预排序速度越慢,但越接近有序。
所以,gap的取值应该不断变化,n很大时,gap也大,预排序快速,而后面不断排序的过程中,为保证接近有序,gap应该逐步减小,最后gap为1,就是直接插入排序。

实现代码如下:

这里gap=gap/3 + 1中的+1,保证了最后一次gap一定为1,也就是循环中先预排序,最后一次直接插入排序。单个代码实现双重含义,简洁明了。

运行结果:

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

void ShellSort(int* a, int n)
{
	int gap = n;

	while (gap > 1)
	{
		gap = gap / 3 + 1;

		for (int i = 0; i < n - gap; i++)
		{
			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;
		}
	}
}

2.1.3 直接插入排序和希尔排序的性能对比

既然说希尔排序是直接插入排序的升级版,那能比它快多少呢?那我们就来实际测试一下(release版本)。

  1. 先动态开辟两个数组
  2. 再随机生成10w个随机数分别存入其中

  1. 使用clock函数(clock函数可以给出系统开机到现在的时间),记录每个排序所用的时间
  2. 打印数据进行对比,并释放数组空间

运行结果:

这样看来,是不是希尔排序比起直接插入排序优化了非常多啊?


时间复杂度分析:

  • 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在很多书中给出的
    希尔排序的时间复杂度都不固定。

《数据结构(C语言版)》 — 严蔚敏

《数据结构-用面相对象方法与C++描述》— 殷人昆

因为我们的gap是按照Knuth提出的方式取值的,而且Knuth进行了大量的试验统计,我们暂时就按照:O(n1.25)到O(1.6 * n1.25)来算

2.2 选择排序

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

在这里插入图片描述

2.2.1 直接选择排序

这里我们对前面的思想进行一点小小的优化,一次分别选出最小和最大的元素,分别放在起始和末尾。

  1. 我们先定义开头和结尾的下标,并默认最小下标为开头和最大下标为结尾
  2. 循环遍历一遍,如果遇到比a[mini]更小的数,则下标更新,mini = i ;同样,如果遇到比a[maxi]更大的数,则 maxi = i
  3. 遍历结束后,进行交换,最小的数放在开头,最大的数放在结尾

这里因为频繁使用交换,所以独立写了一个交换函数。


上述只是一次遍历选择,而要排序完整个数组,只需在外层套上一层循环即可。

注意要把mini和maxi放进循环中,因为begin和end会不断更新

运行结果:

但是,我们居然发现,排序结果居然不对!这是为什么呢?

其实,有一种特殊情况我们并没有考虑到,请看下图。

如果maxi和begin重叠,那么就会出问题,因为在maxi交换前,它的值已经被替换了。那我们应该怎么改呢?只需要更新一下maxi即可。

正确的运行结果:

时间复杂度分析:

  • O(N2)
  • 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
void Swap(int* e1, int* e2)
{
	int tmp = *e1;
	*e1 = *e2;
	*e2 = tmp;
}

void SelectSort(int* a, int n)
{
	int begin = 0, end = n - 1;

	while (begin < end)
	{
		int mini = begin, maxi = end;
		for (int i = begin; i <= end; i++)
		{
			if (a[mini] > a[i])
			{
				mini = i;
			}

			if (a[maxi] < a[i])
			{
				maxi = i;
			}
		}

		Swap(&a[begin], &a[mini]);
		if (maxi == begin)
		{
			maxi = mini;
		}

		Swap(&a[end], &a[maxi]);

		begin++;
		end--;
	}
}

2.2.2 堆排序

关于堆排序,在往期的【数据结构】【版本2.0】【树形深渊】——二叉树入侵 有详细讲解,这里简单归类一下,不再讲具体实现。

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

运行结果:

时间复杂度分析:

  • O(N*log2N)
  • 堆排序使用堆来选数,效率就大大提高了
void AdjustDown(int* a, int n, int parent)
{
	int child = parent * 2 + 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 = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

void HeapSort(int* a, int n)
{
	//升序 -- 建大堆
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(a, n, i);
	}

	int end = n - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);
		AdjustDown(a, end, 0);
		end--;
	}
}

2.2.3 直接选择排序和堆排序的性能对比(包括前面)

既然堆排序是选择排序中的强者,希尔排序又是插入排序的精英,谁能更胜一筹呢?直接插入排序与直接选择排序相比,谁又能做第二把交椅呢?请看下面测试(release版本)。

数据量为10w时:

数据量为100w

经过上述代码测试,我们可以轻易发现希尔排序和堆排序在伯仲之间,而在O(N2)的排序算法中插入排序还是有不错的适应性的,但是选择排序就效率太低了,上不了台面。

2.3 交换排序

基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动键值较小的记录向序列的前部移动

2.3.1 冒泡排序

冒泡排序是最简单的排序,而且方便理解,具有教学意义,也是我们学的第一个排序算法。早在往期的指针章节 不允许你还不了解指针的那些事(二)已经详细讲解,这里也是简单归类一下。

冒泡排序的核心思想就是:两两相邻的元素进行比较

运行结果:

时间复杂度分析:

  • O(N2) —— (最坏)逆序
  • O(N) —— (最好)顺序
void BubbleSort(int* a, int n)
{
	for (int i = 0; i < n; i++)
	{
		bool exchange = false;

		for (int j = 0; j < n - i - 1; j++)
		{
			if (a[j] > a[j + 1])
			{
				int tmp = a[j];
				a[j] = a[j + 1];
				a[j + 1] = tmp;
				exchange = true;
			}
		}

		if (exchange == false)
		{
			break;
		}
	}
}

2.3.2 快速排序

快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。


2.3.2.1 递归实现

这里我们可以用分治的思想,来递归实现。

  1. 返回条件:区间长度为0或者区间不存在,则return;
  2. 子问题:转换为 [ begin, keyi - 1 ] + keyi + [ keyi + 1, end ] 来进行分治,每次确定基准值,再递归进入被切割的左右子序列。
void QuickSort(int* a, int begin, int end)
{
	if (begin >= end)
	{
		return;
	}

	int keyi = PartSort3(a, begin, end);

	QuickSort(a, begin, keyi - 1);
	QuickSort(a, keyi + 1, end);
}

上述为快速排序递归实现的主框架,发现与二叉树前序遍历规则非常像,所以快速排序叫做二叉树结构的交换排序方法。


而实现按基准值切割左右子序列,这里有三种实现版本:

  1. hoare版本(左右指针法)

这个方法是最初hoare实现快速排序的版本。


主要思路:

  1. 先确定基准值key,一般选取最左边的元素作为基准值
  2. 先从右边开始找比key小的元素,再从左边开始找比key大的元素,二者交换
  3. 循环重复步骤2,直到 left 和 right 相遇,最后将基准值与相遇位置元素交换

这里有几个值得注意的点:

  • 如果确定最左边元素为key,则右边先找;如果确定最右边元素为key,则左边先找(如果不满足,大家可以对照动图,自行画图感受一下)
  • 大家可能会疑惑,为什么最后相遇的位置一定比key小呢?这就是值得探讨的核心,实际上,这就是由前面一点所决定的,左边做key,右边先走,就保障了相遇的值一定小于等于key。

相遇无非两种情况:L遇R,R遇L

  1. L遇R,也就是上述动图的情况。L走之前,R已经停下来了,那么R已经找到比key小的值,所以相遇的值比key小
  2. R遇L,R走之前,有两种情况。L还没走,就是key 或者 L在上一轮循环中,已经交换到比key小的值,所以相遇的值小于等于key

所以,这样就证明了左边做key,右边先走,就保障了相遇的值一定小于等于key。

  • 还有一个点,就是左右在寻找的过程中,有可能一直找不到,造成越界访问,所以里面循环也要加上left < right进行限制
  • 最后,还要注意,如果遇到的值等于key,可能会导致死循环,所以等于key时,左右指针也继续走(如下图)

//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;
}
  1. 挖坑法

但是由于hoare版本有很多需要考虑周全的地方,所以又流传出了一种简化的版本,比较便于理解。

主要思路:

  1. 同样以最左边为基准值key,但是我们创建临时变量将其存储起来,那么最左边就变成了“坑位”
  2. 那么就很自然地,因为左边为坑位,所以从右边开始找,找到比key小的值,填补到坑位里,再将自身变成坑位
  3. 那么坑位在右,我们就从左边开始找,找到比key大的值,填补到坑位里,再将自身变成坑位
  4. 最后,再将key填补到停止后的坑位中

有没有发现,挖坑法是hoare的改版,能避免我们去讨论一些细节,更加易于理解。

//挖坑法
int PartSort2(int* a, int left, int right)
{
	int key = a[left];
	int hole = left;
	while (left < right)
	{
		while (left < right && a[right] >= key)
		{
			right--;
		}

		a[hole] = a[right];
		hole = right;

		while (left < right && a[left] <= key)
		{
			left++;
		}

		a[hole] = a[left];
		hole = left;
	}

	a[hole] = key;

	return hole;

  1. 前后指针法

这种方法和前两种方法不同,是一种全新的思路,比较抽象,但也是最简洁的一种写法。

主要思路:

  1. 以最左边为基准值key,设置prev和cur两个指针,一前一后
  2. 如果cur指向的值小于key,则先prev++,再交换prev和cur指向的值,最后cur++
  3. 如果cur指向的值大于等于key,则cur++
  4. 最后,cur越界,循环停止,将key和prev指向的值交换

分析

  1. 最开始prev和cur是相邻的

  2. 当cur遇到比key大的值,则与prev分离开,中间间隔的都是比key大的值

  3. 当cur找到比key小的值,prev向后一格(指向比key大的值),再与cur交换,相当于翻滚式的将比key大的值向右推,同时把小的换到左边

  4. 最后,cur越界时,prev所在的值(经过前面的交换)肯定比key小,所以与key交换,则完成了左边全比key小,右边全比key大的子序列分割。

这里代码实现时,加上了额外判断,使prev和cur相等时不进行交换

//前后指针法
int PartSort3(int* a, int left, int right)
{
	int keyi = left;
	int prev = left;
	int cur = prev + 1;

	while (cur <= right)
	{
		if (a[cur] < a[keyi] && ++prev != cur)
		{
			Swap(&a[prev], &a[cur]);
		}
		cur++;
	}

	Swap(&a[keyi], &a[prev]);

	return prev;
}

上述三种思路只是实现方式的不同,但是实际快速排序的效率是相等的。

运行结果:

时间复杂度分析:

  • O(N*log2N) — 最好和平均(当每次选key为中位数时,则最好)

  • O(N2) — 最坏(如果数据有序,即每次选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;
   	}
   	else if (a[right] < a[left])
   	{
   		return left;
   	}
   	else
   	{
   		return right;
   	}
   }
   else//a[left] > a[mid]
   {
   	if (a[mid] > a[right])
   	{
   		return mid;
   	}
   	else if (a[right] > a[left])
   	{
   		return left;
   	}
   	else
   	{
   		return right;
   	}
   }
}
  • 再将其与最左边(key的位置)交换,尽量保证key不会是最大或者最小(这里用前后指针法举例子)

  • 空间复杂度 — O(log2N)
  • 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
2.3.2.2 非递归实现

当然,递归还有相应的缺陷,就是当数据量太大时,有栈溢出的风险。所以我们应该掌握一下非递归实现的方法——用栈模拟递归

因为栈是动态开辟在堆区的,而堆区空间远远比栈区大,基本没有溢出风险。

因为C语言没有对应的库,所以我们将之前写好的栈拷贝过来。

主要思路:

  1. 先创建栈并初始化,将end,begin依次压栈(因为栈是后进先出的,所以想与之前递归的方式相似,必须先把右边元素压栈,才能先取左边元素)
  2. 进入循环,依次取出最左边下标与最右边下标,分割子序列并获取基准值下标keyi
  3. 如果keyi + 1 < right,则右区间存在,将区间下标按先右后左的方式压入栈
  4. 如果left < keyi - 1,则左区间存在,将区间下标按先右后左的方式压入栈
  5. 这样不断循环,便模拟实现了递归的效果,实际非递归的快速排序

运行结果:

void QuickSortNonR(int* a, int begin, int end)
{
	ST st;
	STInit(&st);
	STPush(&st, end);
	STPush(&st, begin);

	while (!STEmpty(&st))
	{
		int left = STTop(&st);
		STPop(&st);
		int right = STTop(&st);
		STPop(&st);

		int keyi = PartSort3(a, left, right);
		if (keyi + 1 < right)
		{
			STPush(&st, right);
			STPush(&st, keyi + 1);
		}
		
		if (left < keyi - 1)
		{
			STPush(&st, keyi - 1);
			STPush(&st, left);
		}
	}

	STDestroy(&st);
}

2.3.3 冒泡排序和快速排序的性能对比(包括前面)

既然冒泡排序和插入排序时间复杂度最坏都为O(N2),最好都为O(N),谁能更胜一筹呢?还有刚刚实现的快速排序,比起希尔排序,堆排序,是否能力压群雄呢?请看下面测试(release版本)。

数据量为10w时:

  1. 数据随机无序

  1. 数据有序


我们可以发现,同为O(N2)量级的插入排序,选择排序和冒泡排序中,插入排序对数据适应性最强,综合最优;而冒泡排序虽然在数据随机无序比选择排序略低,但是在数据接近有序或有序的情况下,也有较强的适应性;但是选择排序却对数据没有任何适应性。

  • O(N2):插入排序 > 冒泡排序 >= 选择排序

数据量为100w时:

因为冒泡排序和选择排序太慢了,所以这里它们将退出舞台。

  1. 数据随机无序

  1. 数据有序


我们发现,在100w数据量下,插入排序已经非常吃力,在数据无序随机的情况下,快速排序确实更优一些;而在数据有序的情况下,插入排序以及其进阶的希尔排序,对数据有序或接近有序,有着天然的极强适应性(因为插入排序的思想,便是插入已经有序的数组)

数据量为1000w时:

这时插入排序也更不上时代的步伐了,必须退居二线。

数据量为1亿时:

让我们再疯狂一点吧!


这时,我们居然发现,快速排序效率居然急剧下降!!!这是,为什么呢?
其实,快速排序还有一个致命的弱点——无法快速处理含有大量相同元素的数据,而我们产生的随机数是有范围的(0—3w),所以总数据量非常大的情况下,数据中就会产生大量相同元素。那怎么解决呢?请看下面优化。

2.3.4 快速排序优化

分析:当数据中含有大量重复元素时,我们的三数取中法就会失效(因为取出的三数极有可能相等)。这样,我们又会面临最坏情况,快速排序将会退化成冒泡排序。

优化思路

  • 原本我们的思路是两路划分,小于等于key的放左边,大于等于key的放右边

  • 但是之前的思路,并没有对等于key的值,有很好的处理,所以才会留下这个“死穴”。
  • 那么,我们转换新思路——三路划分,将等于key的值放在中间,这样就完美解决了这个问题。


具体方法

  1. 设置left,cur,right三个指针
  2. 如果a[cur] < key,cur和left交换,left++,cur++
  3. 如果a[cur] > key,cur和right交换,right–
  4. 如果a[cur] == key,cur++

看图分析

  • 假设key=6,则left在最左边,cur在left右边一格,right在最右边

  • a[cur] < key,cur和left交换,left++,cur++

  • a[cur] == key,cur++

  • a[cur] > key,cur和right交换,right–

  • a[cur] > key,cur和right交换,right–
  • a[cur] == key,cur++

  • a[cur] > key,cur和right交换,right–

  • a[cur] < key,cur和left交换,left++,cur++
  • a[cur] == key,cur++

  • a[cur] < key,cur和left交换,left++,cur++

  • 循环条件:cur <= right

这样,经过上图的分析,是不是很清晰地理解了该优化算法的运作?其实,我们可以发现,该方法是hoare版本(左右指针法)和前后指针法的结合体,取二者之精华。

进一步理解,left 和 right 最后锁定了值全为key的区间。

递归的子区间划分:[begin, left - 1] [left, right] [right + 1, end]

void QuickSort(int* a, int begin, int end)
{
	if (begin >= end)
	{
		return;
	}

	int left = begin;
	int right = end;
	int cur = left + 1;

	int mid = GetMidIndex(a, left, right);
	Swap(&a[left], &a[mid]);
	int key = a[left];

	while (cur <= right)
	{
		if (a[cur] < key)
		{
			Swap(&a[cur++], &a[left++]);
		}
		else if (a[cur] > key)
		{
			Swap(&a[cur], &a[right--]);
		}
		else
		{
			cur++;
		}
	}

	QuickSort(a, begin, left - 1);
	QuickSort(a, right + 1, end);
}

最后,我们再更改一下三数取中法,让其不再固定取中,而是随机取中

再来测试1亿的数据量:

我们发现,快速排序又遥遥领先啦!

  • 优化后的三路划分的快速排序,效率有略微地下降(因为判断的次数变多),但是使用的场景更加普遍和广泛

2.4 归并排序

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


2.4.1 递归实现

归并排序首先需要动态开辟n个元素大小的tmp空间,因为空间只用开一次,所以不能在本函数内进行递归,那么就写一个子函数进行递归调用。

void MergeSort(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);

	_MergeSort(a, 0, n - 1, tmp);

	free(tmp);
}

归并排序递归的思路,和二叉树后序遍历规则十分相似,也是利用二叉树结构的排序。


  • 返回条件:当区间长度为0时,则返回(为0代表一个元素,同时区间不可能不存在)
  • 子问题:转换为 [begin, mid] ,[mid + 1, end] 两个区间进行归并(因为归并要两边为有序区间,所以一直递归到一个元素时,便两两归并)
  • 归并逻辑:两有序区间从头比较,取小的尾插tmp数组。如果一边区间取完,则代表另一区间中剩余的数均大于之前的数,则直接追加到tmp后面即可。最后,将tmp数组中的归并好的元素拷贝回原数组对应位置

运行结果:

  • 时间复杂度:O(N*log2N)
  • 空间复杂度:O(N)
void _MergeSort(int* a, int begin, int end, int* tmp)
{
	if (begin == end)
	{
		return;
	}

	int mid = (begin + end) / 2;
	int begin1 = begin, end1 = mid;
	int begin2 = mid + 1, end2 = end;

	_MergeSort(a, begin1, end1, tmp);
	_MergeSort(a, begin2, end2, tmp);

	int i = 0;
	while (begin1 <= end1 && begin2 <= end2)
	{
		if (a[begin1] <= a[begin2])
		{
			tmp[i++] = a[begin1++];
		}
		else
		{
			tmp[i++] = a[begin2++];
		}
	}

	while (begin1 <= end1)
	{
		tmp[i++] = a[begin1++];
	}

	while (begin2 <= end2)
	{
		tmp[i++] = a[begin2++];
	}

	memcpy(a + begin, tmp, sizeof(int) * i);
}

2.4.2 非递归实现

大体思路:

  1. 将数据分组归并,总共gap组
  2. gap初始为1,每一轮循环,gap *= 2
  3. 循环内,每组进行归并排序,并拷贝回原数组

gap=1时


一组归并拷贝完,则起始位置+=gap,进行第二组……直到将所有元素,两两归并,拷贝回原数组


gap=2时

gap=4时

void MergeSortNonR(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);

	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 j = 0;
			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 + i, tmp, sizeof(int) * j);
		}

		gap *= 2;
	}

	free(tmp);
}

但是我们发现,上述代码只有在2的次方数个数据才有用,其他个数时(比如10个)就会程序崩溃或者随机值,这是为什么呢?其实这是因为数组访问越界,让我们打印出边界值来分析一下。

经过分析,这里一共有三种情况的越界

  • 情况一:end1,begin2,end2全部越界

  • 情况二:begin2,end2越界

  • 情况三:end2越界

所以,针对以上情况,我们要进行修正。

  • 对于情况一和情况二,我们直接break跳出循环,不用对单个区间归并(因为本身已经有序)
  • 对于情况三,我们将end2边界值修正到n-1即可

正确的运行结果:

void MergeSortNonR(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);

	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;

			if (end1 >= n || begin2 >= n)
			{
				break;
			}

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

			int j = 0;
			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 + i, tmp, sizeof(int) * j);
		}

		gap *= 2;
	}

	free(tmp);
}

2.4.3 归并排序优化

其实我们发现,当区间长度比较小时,可以不用归并排序,而用其他排序则能更快完成数据的排序。而从基本排序里选择,插入排序便是我们的首选。这种优化方式称为小区间优化

  • 当区间长度小于10时,我们便使用插入排序,辅助完成归并。

有人可能会疑惑,觉得区间长度怎么才小于10,如果再大一点,不是排序的更快速吗?

其实,小于10的不采用归并递归,减少了最后三层的递归调用。(如下图)

  • 而根据二叉树的性质,我们知道最后三层加起来,便占用了 87.5%的递归调用次数,所以小区间优化实际上已经减少了绝大部分递归次数(区间长度继续扩大,减少次数却微乎其微了)

  • 实际上,小区间优化也能用在快速排序上,不过值得注意的是,小区间优化只是锦上添花,并不能起决定性的改变作用。

测试1亿的数据量:

归并排序是不是快了不少?

2.4.4 归并排序的应用——外排序

归并排序主要思考的是解决硬盘中的外排序问题。

之前我们讲的所有排序都可以解决在内存中的排序问题,这种称为内排序。而归并排序不仅能用于内排序,还能用于外排序。

那么,为什么要使用外排序呢?让我们来思考一个问题:如果要对100亿个整数进行排序,应该怎么办?(要知道100亿整数,就是400亿字节,换算过来大约是40G,而你的内存显然没有那么大)

这个时候,我们就只能在硬盘中进行数据排序。而在硬盘中有一个致命的缺陷,那就是文件只能顺序读取,不支持下标随机访问。这才导致之前的种种排序算法都无能为力,而只有归并排序的非递归可以做到(按顺序从小到大依次归并)。

  • 这里简单介绍一下外排序,后期会专门讲解归并排序怎样对文件进行外排序

三、排序算法复杂度及稳定性分析

3.1 稳定性的意义

简单来说,稳定性,是指排序完后,相同元素相对位置不变。

那么稳定性有什么意义呢?

假设考试要选前三名,但是有4个分数相同的,那么先交卷的排名就高。所以,这时候就需要稳定的排序算法来完成分数的排序,保证排序完,先后交卷的顺序不变。

3.2 稳定性分析

  • 直接插入排序:稳定
  • 希尔排序:不稳定(因为相同元素可能分在不同的组)
  • 直接选择排序:不稳定
  • 堆排序:不稳定
  • 冒泡排序:稳定
  • 快速排序:不稳定
  • 归并排序:稳定

3.3 排序算法时间、空间复杂度和稳定性比较(表格)

排序方法平均情况最好情况最坏情况辅助空间稳定性
冒泡排序O(N2)O(N)O(N2)O(1)稳定
直接选择排序O(N2)O(N2)O(N2)O(1)不稳定
直接插入排序O(N2)O(N)O(N2)O(1)稳定
希尔排序O(N) ~ O(N*log2N)O(N1.3)O(N2)O(1)不稳定
堆排序O(N*log2N)O(N*log2N)O(N*log2N)O(1)不稳定
归并排序O(N*log2N)O(N*log2N)O(N*log2N)O(N)稳定
快速排序O(N*log2N)O(N*log2N)O(N2)O(log2N) ~ O(N)不稳定

看到这里了还不给博主扣个:
⛳️ 点赞☀️收藏 ⭐️ 关注!
💛 💙 💜 ❤️ 💚💓 💗 💕 💞 💘 💖
拜托拜托这个真的很重要!
你们的点赞就是博主更新最大的动力!
有问题可以评论或者私信呢秒回哦。

test.c

#define _CRT_SECURE_NO_WARNINGS 1
#include"sort.h"

void TestInsertSort()
{
	int a[] = { 5,9,8,4,6,3,1,5,6,4,8 };
	int sz = sizeof(a) / sizeof(a[0]);

	PrintArray(a, sz);
	InsertSort(a, sz);
	PrintArray(a, sz);
}

void TestShellSort()
{
	int a[] = { 5,9,8,4,6,3,1,5,6,4,8 };
	int sz = sizeof(a) / sizeof(a[0]);

	PrintArray(a, sz);
	ShellSort(a, sz);
	PrintArray(a, sz);
}

void TestSelectSort()
{
	int a[] = { 5,9,8,4,6,3,1,5,6,4,8 };
	int sz = sizeof(a) / sizeof(a[0]);

	PrintArray(a, sz);
	SelectSort(a, sz);
	PrintArray(a, sz);
}

void TestHeapSort()
{
	int a[] = { 5,9,8,4,6,3,1,5,6,4,8 };
	int sz = sizeof(a) / sizeof(a[0]);

	PrintArray(a, sz);
	HeapSort(a, sz);
	PrintArray(a, sz);
}

void TestBubbleSort()
{
	int a[] = { 5,9,8,4,6,3,1,5,6,4,8 };
	int sz = sizeof(a) / sizeof(a[0]);

	PrintArray(a, sz);
	BubbleSort(a, sz);
	PrintArray(a, sz);
}

void TestQuickSort()
{
	int a[] = { 5,9,8,4,6,3,1,5,6,4,8 };
	int sz = sizeof(a) / sizeof(a[0]);

	PrintArray(a, sz);
	QuickSort(a, 0, sz-1);
	//QuickSortNonR(a, 0, sz - 1);
	PrintArray(a, sz);
}

void TestMergeSort()
{
	int a[] = { 5,9,8,4,6,3,1,5,6,4,8 };
	int sz = sizeof(a) / sizeof(a[0]);

	PrintArray(a, sz);
	MergeSort(a, sz);
	//MergeSortNonR(a, sz);
	PrintArray(a, sz);
}

void TestOP()
{
	srand((unsigned int)time(NULL));
	const int N = 100000000;
	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);
	int* a5 = (int*)malloc(sizeof(int) * N);
	int* a6 = (int*)malloc(sizeof(int) * N);
	int* a7 = (int*)malloc(sizeof(int) * N);

	for (int i = 0; i < N; i++)
	{
		a1[i] = rand();
		a2[i] = a1[i];
		a3[i] = a1[i];
		a4[i] = a1[i];
		a5[i] = a1[i];
		a6[i] = a1[i];
		a7[i] = a1[i];
	}

	int begin1 = clock();
	//InsertSort(a1, N);
	int end1 = clock();

	int begin2 = clock();
	ShellSort(a2, N);
	int end2 = clock();

	int begin3 = clock();
	//SelectSort(a3, N);
	int end3 = clock();

	int begin4 = clock();
	HeapSort(a4, N);
	int end4 = clock();

	int begin5 = clock();
	//BubbleSort(a5, N);
	int end5 = clock();

	int begin6 = clock();
	QuickSort(a6, 0, N-1);
	int end6 = clock();

	int begin7 = clock();
	MergeSort(a7, N);
	int end7 = clock();

	printf("InsertSort:%d\n", end1 - begin1);
	printf("ShellSort:%d\n", end2 - begin2);
	printf("SelectSort:%d\n", end3 - begin3);
	printf("HeapSort:%d\n", end4 - begin4);
	printf("BubbleSort:%d\n", end5 - begin5);
	printf("QuickSort:%d\n", end6 - begin6);
	printf("MergeSort:%d\n", end7 - begin7);

	free(a1);
	free(a2);
	free(a3);
	free(a4);
	free(a5);
	free(a6);
	free(a7);
}

int main()
{
	srand((unsigned int)time(NULL));
	//TestInsertSort();
	//TestShellSort();
	//TestSelectSort();
	//TestHeapSort();
	//TestBubbleSort();
	//TestQuickSort();
	//TestMergeSort();
	TestOP();
	return 0;
}

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

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

相关文章

会声会影2024旗舰版系统配置要求及格式支持

会声会影2024旗舰版是一款广受欢迎的视频编辑软件&#xff0c;它的最新版本&#xff0c;会声会影2023&#xff0c;已经发布。在这篇文章中&#xff0c;我们将探讨会声会影2024旗舰版系统配置要求及格式支持 会声会影2024是一款专业的视频剪辑软件&#xff0c;能够帮助用户制作高…

写一个宏,交换整数二进制位的奇数位和偶数位,并打印这个新的数

我们已经学过了C语言的宏&#xff0c;我们今天应用宏来交换一个整数的二进制的奇数位和偶数位&#xff0c;并得到这个被交换过的数&#xff0c;我们开始吧&#xff01; 1.问题分析 解题方法&#xff1a;& 和<< 和>>操作符 问题解析&#xff1a;我们这里假设一个…

医保移动支付程序开发

作为公司最苦命的开发&#xff0c;年初接到任务开发医保移动支付程序&#xff08;微信小程序和支付宝小程序&#xff09;&#xff0c;为医疗机构提供线上医保结算。好家伙&#xff0c;我一看解压后资料大于一个G&#xff0c;内心无比的惊慌。 一、技术流程图 图太大了显示不全需…

入门必读:Figma软件的功能和用途一览!

1、Figma软件是做什么的 Figma软件奠定了在线设计工具的形式&#xff0c;产品UI设计功能非常强大&#xff0c;Figma软件基于web操作&#xff0c;无论是macos、windows可以使用这个软件&#xff0c;即使有电脑、浏览器、网络&#xff0c;甚至软件也不需要下载&#xff0c;节省了…

C语言枚举的作用是什么?

我在知乎上看到这个问题&#xff0c;一开始&#xff0c;也有一些疑惑&#xff0c;后面查了一些资料&#xff0c;对于这个问题&#xff0c;简单的说一下我的看法。 枚举有多大 枚举类型到底有多大&#xff0c;占多少空间呢&#xff1f;这个要具体情况具体分析&#xff0c;编译器…

基于ssm框架的公寓租房系统设计与实现

基于ssm框架的公寓租房系统的设计与实现 摘要&#xff1a;在互联网技术的不断发展壮大的背景下,人们生活水平及经济水平也随之得到提上&#xff0c;许多商家都纷纷吧自己的业务重心偏移到网络这个大蛋糕上&#xff0c;为了迎合时代的发展&#xff0c;房屋的出租业务也应该将重…

将测试速度提升数倍!RunnerGo测试平台——您的UI自动化测试利器!

RunnerGo提供从API管理到API性能再到可视化的API自动化、UI自动化测试功能模块&#xff0c;覆盖了整个产品测试周期。 RunnerGo UI自动化基于Selenium浏览器自动化方案构建&#xff0c;内嵌高度可复用的测试脚本&#xff0c;测试团队无需复杂的代码编写即可开展低代码的自动化…

Windows下Linkis1.5DSS1.1.2本地调试

1 Linkis: 参考&#xff1a; 单机部署 | Apache Linkis技术分享 | 在本地开发调试Linkis的源码 (qq.com)DataSphere Studio1.0本地调试开发指南 - 掘金 (juejin.cn) 1.1 后端编译 参考【后端编译 | Apache Linkis】】 修改linkis模块下pom.xml,将mysql.connetor.scope修改…

LeetCode Hot100 84.柱状图中最大的矩形

题目&#xff1a; 给定 n 个非负整数&#xff0c;用来表示柱状图中各个柱子的高度。每个柱子彼此相邻&#xff0c;且宽度为 1 。 求在该柱状图中&#xff0c;能够勾勒出来的矩形的最大面积。 方法&#xff1a; 代码&#xff1a; class Solution {public int largestRectang…

vue项目中通过vuex管理数据

目录 1.前言&#xff1a; 2.vuex的基础用法&#xff1a; 1.构建与挂载vue 基础模板渲染 构建仓库 2.mutations的使用 1.介绍 ​编辑 2.案列&#xff1a; 3.传参 4.辅助函数mapMutations&#xff1a; 3.module分对象的写法 介绍 建立模块&#xff1a; 访问数据的方…

SPSS生存分析:寿命表分析

前言&#xff1a; 本专栏参考教材为《SPSS22.0从入门到精通》&#xff0c;由于软件版本原因&#xff0c;部分内容有所改变&#xff0c;为适应软件版本的变化&#xff0c;特此创作此专栏便于大家学习。本专栏使用软件为&#xff1a;SPSS25.0 本专栏所有的数据文件请点击此链接下…

PostgreSQL | EXTRACT | 获取时间的年月日字串

EXTRACT EXTRACT 函数是 PostgreSQL 中用于从日期和时间类型中提取特定部分&#xff08;如年、月、日、小时等&#xff09;的函数。 格式 EXTRACT(field FROM source) -- field 参数是要提取的部分&#xff0c;例如 YEAR、MONTH、DAY、HOUR 等。 -- source 参数是包含日期或…

计算机网络高频面试八股文

目录&#xff1a; 网络分层结构三次握手两次握手可以吗&#xff1f;四次挥手第四次挥手为什么要等待2MSL&#xff1f;为什么是四次挥手&#xff1f;TCP有哪些特点&#xff1f;说说TCP报文首部有哪些字段&#xff0c;其作用又分别是什么&#xff1f;TCP和UDP的区别&#xff1f;…

Unity 关于生命周期函数的一些认识

Unity 生命周期函数主要有以下一些&#xff1a; Awake(): 在脚本被加载时调用。用于初始化对象的状态和引用。 OnEnable(): 在脚本组件被启用时调用。在脚本组件被激活时执行一次&#xff0c;以及在脚本组件被重新激活时执行。 Reset(): 在脚本组件被重置时调用。用于重置脚本…

11.27二叉查找树,遍历二叉树,层序(判断是不是完全二叉树),根据遍历序列重构二叉树,递归输入建树(树的定义,结构体细节,typedef)

如果left<right&#xff0c;就表明其之间还有元素&#xff0c;即左右指针重合&#xff0c;区间只有一个元素也被包含其中&#xff1b; left<right,就表明递归过程中&#xff0c;只允许区间有两个及以上的元素&#xff0c;不允许区间只有一个元素&#xff0c;那么对应地&…

Python数据预处理详解

更多资料获取 &#x1f4da; 个人网站&#xff1a;ipengtao.com 数据预处理是数据科学中至关重要的步骤&#xff0c;它包括清洗、转换、归一化等操作&#xff0c;以使数据适合于机器学习模型的使用。Python提供了多种强大的库和工具&#xff0c;能够帮助进行数据预处理。本文将…

中间件安全:JBoss 反序列化命令执行漏洞.(CVE-2017-7504)

中间件安全&#xff1a;JBoss 反序列化命令执行漏洞.&#xff08;CVE-2017-7504&#xff09; JBoss 反序列化漏洞&#xff0c;该漏洞位于 JBoss 的 HttpInvoker 组件中的 ReadOnlyAccessFilter 过滤器中&#xff0c;其 doFilter 方法在没有进行任何安全检查和限制的情况下尝试…

小程序如何进行一键修复

在使用小程序过程中&#xff0c;难免会遇到一些问题&#xff0c;比如程序崩溃、功能异常等等。这时&#xff0c;版本一键修复就显得尤为重要了。下面&#xff0c;我们就来介绍一下小程序如何进行版本一键修复。 一、什么是版本一键修复&#xff1f; 版本一键修复是指在小程序…

YOLOv5算法进阶改进(5)— 主干网络中引入SCConv | 即插即用的空间和通道维度重构卷积

前言:Hello大家好,我是小哥谈。SCConv是一种用于减少特征冗余的卷积神经网络模块。相对于其他流行的SOTA方法,SCConv可以以更低的计算成本获得更高的准确率。它通过在空间和通道维度上进行重构,从而减少了特征图中的冗余信息。这种模块的设计可以提高卷积神经网络的性能。�…

从三个角度分析共享办公对工作效率的影响

在现代社会&#xff0c;共享办公的概念越来越受到人们的关注和欢迎。共享办公&#xff0c;顾名思义&#xff0c;就是多个公司或个体在同一办公空间内进行工作。那么&#xff0c;共享办公是否能提高工作效率呢&#xff1f;这个问题并没有一个确定的答案&#xff0c;因为它取决于…