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


目录

  • 一、排序的概念及其运用
    • 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;
}

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

整车测试中的UDS诊断

UDS&#xff08;Unified Diagnostic Services&#xff0c;统一的诊断服务&#xff09;诊断协议是在汽车电子ECU环境下的一种诊断通信协议。这种通信协议被用在几乎所有由OEM一级供应商所制造的新ECU上面。这些ECU控制车辆的各种功能&#xff0c;包括电控燃油喷射系统&#xff0…

WPF中DataGrid解析

效果如图&#xff1a; 代码如下&#xff1a; <DataGrid Grid.Row"1" x:Name"dataGrid" ItemsSource"{Binding DataList}" AutoGenerateColumns"False"SelectedItem"{Binding SelectedItem,UpdateSourceTriggerPropertyChange…

Redis 主库挂了,如何不间断服务?

目录 1、哨兵机制的基本流程 2、主观下线和客观下线 3、如何选定新的主库&#xff1f; 总结 // 你只管前行&#xff0c;剩下的交给时间 在 reids 主从库集群模式下&#xff0c;如果从库发生故障了&#xff0c;客户端可以继续向主库或其他从库发送请求&#xff0c;进行相关的…

Spring Cloud,注册中心,配置中心,原理详解

文章目录 Spring Cloud&#xff0c;注册中心&#xff0c;配置中心&#xff0c;原理详解谈谈我个人对 spring Cloud 的理解 注册中心Eureka&#xff1a;服务搭建小结 Ribbo - 负载均衡1. 负载均衡流程2. 负载均衡策略 nacos注册中心1. 配置集群1. 创建 namespace2. 配置命名空间…

Redis 的过期策略都有哪些?

思考:假如redis的key过期之后&#xff0c;会立即删除吗&#xff1f; Redis对数据设置数据的有效时间&#xff0c;数据过期以后&#xff0c;就需要将数据从内存中删除掉。可以按照不同的规则进行删除&#xff0c;这种删除规则就被称之为数据的删除策略&#xff08;数据过期策略…

如何运行C/C++程序

一、在线运行C/C 码曰 - 让代码在云端多飞一会&#xff1a;这是一个支持C/C&#xff0c;Java&#xff0c;Python等多种语言的在线编程&#xff0c;编译运行&#xff0c;粘贴分享的平台。你可以在这里输入你的代码&#xff0c;点击运行按钮&#xff0c;就可以看到输出结果。你也…

【shell】多行重定向与免交互expect与ssh、scp的结合使用

目录 一、多行重定向 举例1&#xff1a;使用read命令接收用户的输入值会有交互过程 举例2&#xff1a;设置变量的值 举例3&#xff1a;创建用户密码 举例4&#xff1a;使用多行重定向写入文件中&#xff08;以repo文件举例&#xff09; 举例5&#xff1a;变量设定 二、免…

微信小程序开发——开发账号注册与配置

版权声明 本文原创作者&#xff1a;谷哥的小弟作者博客地址&#xff1a;http://blog.csdn.net/lfdfhl 概述 本文的重点在于介绍注册微信小程序开发账号的步骤及其流程。 账号注册 请点击官方网站右上角的 https://mp.weixin.qq.com/ 立即注册&#xff0c;图示如下&#xf…

【C++ Primer Plus学习记录】while循环

while循环是没有初始化和更新部分的for循环&#xff0c;它只有测试条件和循环体&#xff1a; while(test-condition)dody 首先&#xff0c;程序计算圆括号内的测试条件表达式。如果该表达式为true&#xff0c;则执行循环体内的语句。与for循环一样&#xff0c;循环体也由一条…

RPC之grpc重试策略

1、grpc重试策略 RPC 调用失败可以分为三种情况&#xff1a; 1、RPC 请求还没有离开客户端&#xff1b; 2、RPC 请求到达服务器&#xff0c;但是服务器的应用逻辑还没有处理该请求&#xff1b; 3、服务器应用逻辑开始处理请求&#xff0c;并且处理失败&#xff1b; 最后一种…

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

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

Python入门学习篇(四)——if详解

if详解 1 单项分支 1.1 语法结构 if 条件:逻辑代码(条件为真时执行的代码) # 注: 如果条件不满足,那么则不执行if下面的逻辑代码1.2 示例代码 username input("请输入您的用户名: ") if username "admin":print("管理员登录成功")1.3 运行…

KMP算法【数据结构】

KMP算法 KMP算法是一种改进的字符串匹配算法 Next[j] k :一个用来存放子串返回位置的数组&#xff0c;回溯的位置用字母k来表示。其实就是从匹配失败位置&#xff0c;找到他前面的字符串的最大前后相等子串长度。默认第一个k值为-1(Next[0] -1),第二个k值为0(Next[1] 0),我…

蓝桥杯物联网竞赛_STM32L071_5_串口接收发送数据

理论&#xff1a; 串口采取异步通信&#xff0c;即不依赖时钟节拍来接收或发送数据&#xff0c;而是采用互相约定的波特率传输数据。 波特率与单位时间传输的比特数有关&#xff0c;波特率越大传输的数据越多 传输一个比特花费的时间T 1 / 比特率 接受和发送数据的时候需要…

MOM系统功能清单

什么是MOM系统&#xff1f; MOM系统是制造运营管理&#xff08;Manufacturing Operation Management&#xff09;的缩写。它是指通过协调管理企业的人员、设备、物料和能源等资源&#xff0c;把原材料或零件转化为产品的活动。MOM系统集成了生产计划、库存管理、生产调度、质量…

Java中关于ArrayList集合的练习题

目录 题目内容​编辑 完整源码 题目内容 根据下图所示数据&#xff0c;定义学生类Student&#xff0c;设置对应的字段并进行封装在Test中&#xff0c;定义ArrayList集合 ,将上述学生对象实例化&#xff0c;并放入集合&#xff0c;定义方法t1&#xff0c;参数为学生类集合&am…

基于SpringBoot的手机官方商城系统

基于SpringBoot的手机官方商城系统 摘要&#xff1a;随着电子商务的发展&#xff0c;网上购物已成为人们普遍的购物方式。与此同时&#xff0c;网上支付也得到了迅速的发展&#xff0c;大有赶超传统支付的趋势。在今天这个信息化程度高、生活节奏快的现代社会&#xff0c;传统…

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

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

PCF8591多通道数据读取异常问题

问题描述 PCF8591在循环读取两个通道时&#xff0c;两个通道数据出现交错问题。 例如我们想实现&#xff1a;第一次读取通道一、第二次读取通道二、第三次读取通道一、第四次读取通道二……依次循环 但实际数据&#xff1a;第一次读取的值为0x80、第二次读取的值为通道一的值、…

文件操作在 Python 中的基本用法

更多资料获取 &#x1f4da; 个人网站&#xff1a;ipengtao.com 文件操作是任何编程语言中都至关重要的一部分&#xff0c;Python 提供了简单而强大的文件操作功能&#xff0c;使得读取、写入和处理文件变得非常便捷。本文将详细介绍 Python 中文件操作的基本用法&#xff0c;…