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

news/2024/7/23 13:36:08 标签: 算法, 排序算法, 数据结构, c++, c语言


目录

  • 一、排序的概念及其运用
    • 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.niftyadmin.cn/n/5221295.html

相关文章

数据结构重点知识点复习——第五章 树

目录 一、树的概念 树的概念 二、树与二叉树转换 三、树与二叉树性质​编辑 四、二叉树遍历 二叉树的遍历 先序遍历 中序遍历 后序遍历 层序遍历 五、哈夫曼树与哈夫曼编码 算法的描述如下&#xff1a; 六、线索二叉树 一、树的概念 树的概念 二、树与二叉树转换 …

Windows全系列 本地密码暴力破解

首先 咱们要准备两个工具&#xff1a; 第一个是 pwdump-master 第二个是 saminside_softradar-com.exe这两个工具 我会一并上传 需要的同学 可以自取本文章操作思路是&#xff1a; 第一步 首先把我刚刚提到的两个软件 以某种手段放置于机器中 如果是真实机 就用U盘 拷贝到真实机…

Armv8.5-A Memory Tagging Extension内存标记扩展

目录 一、MTE简介 二、威胁模型 三、MTE下的内存安全性 四、架构细节 五、MTE的规模部署

浅谈C++中如何重载前置++/--与后置++/--

前置/–与后置/– C中的和–操作符存在前置式与后置式&#xff0c;最基本的&#xff0c;作为一名程序员&#xff0c;你应当了解它们实现的不同&#xff1a; i; i;以上两行代码如果单独使用&#xff0c;在功能上是一致的&#xff0c;它们都实现了让变量i加1的操作&#xff0c;…

4.Spring源码解析-loadBeanDefinitions(XmlBeanDefinitionReader)

第一个点进去 发现是空 肯定走的第二个逻辑了 这里在这里已经给属性设置了值&#xff0c;所以肯定不是空能拿到。 1.ClassPathXmlApplicationContext 总结&#xff1a;该loadBeanDefinitions是XmlBeanDefinitionReader设置xml文件在哪。

通过预定义颜色查找表上色_vtkLookupTable_vtkColorTransferFunction

开发环境&#xff1a; Windows 11 家庭中文版Microsoft Visual Studio Community 2019VTK-9.3.0.rc0vtk-example demo解决问题&#xff1a;通过颜色查找表给vtkPlaneSource上色 第一种技术是使用预定义颜色的查找表vtkLookupTable。这包括创建一个查找表并为其分配一组已命名的…

filebeat配置解析

目录 filebeat概览filebeat是如何工作的工作原理采集日志注册表发送日志 容器日志采集的三种方式方式一&#xff1a;Filebeat 与 应用运行在同一容器&#xff08;不推荐&#xff09;方式二&#xff1a;Filebeat 与 应用运行不在同一容器方式三&#xff1a;通过 Kubernetes File…

CCC联盟数字钥匙(一)——UWB MAC概述

本文在前面已经介绍了相关UWB的PHY之后&#xff0c;重点介绍数字钥匙&#xff08;Digital Key&#xff09;中关于MAC层的相关实现规范。由于MAC层相应涉及内容比较多&#xff0c;本文首先从介绍UWB MAC的整体框架&#xff0c;后续陆续介绍相关的网络、协议等内容。 1、UWB MAC架…