【初阶数据结构】——详解几个常见的经典排序算法

news2024/12/23 11:59:24

文章目录

    • 1. 排序的概念及其运用
      • 1.1 排序的概念
      • 1.2 排序的应用
      • 1.3 常见的排序算法
    • 2. 插入排序
      • 2.1 直接插入排序
        • 算法思想
        • 举例(升序)
        • 代码实现
        • 直接插入排序特性总结
      • 2.2 希尔排序( 缩小增量排序 )
        • 算法思想
        • 代码实现
        • 希尔排序特性总结
    • 3. 选择排序
      • 3.1 直接选择排序
        • 算法思想
        • 代码实现
        • 直接选择排序特性总结
      • 3.2 堆排序
    • 4. 交换排序
      • 4.1 冒泡排序
        • 算法思想
        • 代码实现
        • 冒泡排序特性总结
      • 4.2 快速排序(递归)
        • 1. hoare版本
          • 思路讲解
          • 代码实现
          • 优化1:三数取中法选key
          • 优化2:小区间优化
        • 2. 挖坑法
          • 思路讲解
          • 代码实现
        • 3. 前后指针法
          • 思路讲解
          • 代码实现
      • 4.3 快速排序(非递归)
        • 思路讲解
        • 代码实现
        • 快速排序特性总结
    • 5. 归并排序
      • 基本思想
      • 1. 递归版本
        • 思路讲解
        • 复杂度计算
        • 代码实现
      • 2. 非递归版本
        • 思路讲解
        • 代码实现
        • 发现问题
        • 解决问题
        • 最终代码
      • 归并排序特性总结
    • 6. 计数排序(非比较排序)
      • 算法思想
      • 思考
      • 思想优化
      • 代码实现
      • 计数排序特性总结
    • 7. 性能对比
    • 8. 排序算法复杂度及稳定性分析

这篇文章我们来学习排序。
在这里插入图片描述

1. 排序的概念及其运用

1.1 排序的概念

排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。

1.2 排序的应用

其实在我们生活中,很多地方都要用到排序。
比如:

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

1.3 常见的排序算法

在这里插入图片描述
接下来,我们就来讲解并实现一下常见的排序算法。

2. 插入排序

2.1 直接插入排序

首先我们来学习直接插入排序:

算法思想

直接插入排序是一种简单的插入排序法,其基本思想是:

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

实际中我们玩扑克牌时,就用了插入排序的思想
在这里插入图片描述
在这里插入图片描述

举例(升序)

排序我们一般是对一个数组进行操作:

当插入第i(i>=1)个元素时,前面的array[0],array[1],…,array[i-1]已经排好序,此时用array[i]的排序码与array[i-1],array[i-2],…的排序码顺序进行比较,找到插入位置即将array[i]插入,原来位置上的元素顺序后移

一趟直接插入排序:
在这里插入图片描述
所以一趟直接插入排序就是这样的:

end指向原有序数据的最后一个,那要插入的数据和end指向的数据进行对比,如果比end指向的数据大,那直接放在end后面,如果小,则把end指向的大的数据向后移动,end- - ,与前面的元素进行比较,直到遇到比要插入的元素小的数据,然后把要插入的数据放在其后面。
在这里插入图片描述
那如果前面的元素都比要插入的数据大呢?
那就一直比,直到比完第一个元素,然后end- -之后变成-1,还是放到end位置的后面,即让它成为新的第一个元素。

在这里插入图片描述

代码实现

那我们先来写一下一趟直接插入排序的代码:
在这里插入图片描述
这是一趟的,那现在有一个数组,我们如何使用直接插入排序对其进行排序呢?

我们是不是可以先把数组的第一个元素看成是有序的,让end指向第一个元素,然后把第二个元素当作即将要插入的数据,这样一趟之后,前两个就有序了,然后我们再插入第三个,依次循环往复,当数组最后一个元素进行完插入,整个数组就有序了。在这里插入图片描述

那其实在我们刚才写的一趟直接插入排序的基础上,外层加个循环控制end就行了。

//直接插入排序
void SInsertSort(int* arr, int len)
{
	for (int i = 0; i < len - 1; i++)
	{
		int end = i;
		int tmp = arr[end + 1];
		while (end >= 0)
		{
			if (tmp < arr[end])
			{
				arr[end + 1] = arr[end];
				end--;
			}
			else
			{
				break;
			}
		}
		arr[end + 1] = tmp;
	}
}

在这里插入图片描述
🆗,我们来测试一下:
在这里插入图片描述
这就是直接插入排序。

直接插入排序特性总结

  1. 元素集合越接近有序,直接插入排序算法的时间效率越高
    当数据完全有序时,直接插入排序就是O(N),只需比较N次,不需要交换。
  2. 时间复杂度:O(N^2)
    当数据完全逆序时,此时是最坏情况,时间复杂度是O(N^2)
  3. 空间复杂度:O(1),它是一种稳定的排序算法
    不需要额外开辟新空间。
  4. 稳定性:稳定。
    因为直接插入排序,如果有相同的数据,我们可以保证排序前后它们的相对位置不变。(只要能够做到这样我们就认为该排序是稳定的,无法做到的则是不稳定

2.2 希尔排序( 缩小增量排序 )

接下来我们来学习希尔排序:

希尔排序其实是对直接插入排序的优化。

算法思想

那希尔排序是如何对直接插入排序进行优化的,该算法的思想又是什么呢?
在这里插入图片描述
那如何进行预排序呢?
在这里插入图片描述
比如:
在这里插入图片描述

让gap等于1时,其实就是直接插入排序,但是在此之前已经进行了预排序,此时再进行直接插入排序就会比对原始数据直接进行直接插入排序快很多。

在这里插入图片描述

代码实现

那一趟预排序应该怎么实现呢?

其实很简单,我们说预排序是选定一个整数gap作为间隔,将要排序的数据进行分组,对分组后的数据进行直接插入排序。
那其实跟直接插入排序是一样的,只不过直接插入排序的gap是1罢了。

所以我们说当gap等于1时就是直接插入排序了。

所以,一趟预排序的实现,我们只需把直接插入排序中的1换成数据间隔gap就行了。
在这里插入图片描述
但是一趟过后还没完:

希尔排序的思想是我们选定一个gap之后,不断缩小gap,也就是说可能要进行多次预排序。
但是,要求gap最后一次取的值必须是1,因为预排序之后数据并不是已经有序了,而是相比原始数据更加接近有序了,所以最好还要进行一次直接插入排序(gap==1),这一趟过后,排序就完成了。

那gap的值要如何取呢?
在这里插入图片描述

常用的取法是:

  1. gap的初值取数据个数的一半,然后每次缩小2,这样不管数据个数n是奇数还是偶数,最后一次gap正好为1。
  2. gap初值取n的三分之一,但是要加个1,为什么要加个1呢?
    因为每次除3的情况下,gap最后一次取值不一定是1。
    比如对6个数据排序,n=6,6除3=2,2除3就是0了。
    所以加个1,保证最后一次gap取1。

那现在我们只需外层再嵌套一个循环来控制gap的值就行了。

//希尔排序
void ShellSort(int* arr, int len)
{
	int gap = len;
	// gap > 1 预排序
	// gap == 1 最后一次直接插入排序
	while (gap > 1)
	{
		//gap /= 2;
		gap = gap / 3 + 1;
		for (int i = 0; i < len - gap; i++)
		{
			int end = i;
			int tmp = arr[end + gap];
			while (end >= 0)
			{
				if (tmp < arr[end])
				{
					arr[end + gap] = arr[end];
					end -= gap;
				}
				else
				{
					break;
				}
			}
			arr[end + gap] = tmp;
		}
	}
}

我们测试一下:
在这里插入图片描述

希尔排序特性总结

  1. 希尔排序是对直接插入排序的优化。
  2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。
  3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些书中给出的希尔排序的时间复杂度都不固定:
    《数据结构(C语言版)》— 严蔚敏
    在这里插入图片描述
    《数据结构-用面相对象方法与C++描述》— 殷人昆

    在这里插入图片描述
    在这里插入图片描述4. 稳定性:不稳定
    因为在预排序的过程中可能会把相同的数据分到不同组里,这样排完之后它们的相对顺序与原来相比就可能改变了。

3. 选择排序

3.1 直接选择排序

我们先来看第一种选择排序,直接选择排序:

算法思想

先从待排序的数据元素中选出最大(或最小)的一个元素,存放在序列的起始位置,再选出次大的放到第二个位置,依次循环往复,直到全部待排序的数据元素排完 。

动图演示:
在这里插入图片描述

直接插入排序的思想呢非常简单,但是它的效率比较低,每遍历一次才选出一个数。

所以:

我们接下来实现一个优化一点的版本
怎么优化呢?
我们遍历一遍其实可以选出两个数,最大的最小的我们都可以选出来,第一次选出最小的和最大的放到首尾两个位置(假设用begin和end标识,第二次选出次大的和次小的再放到倒数第二和正数第二的位置(begin++,end- -)…,就这样循环往复,直到所有数据排完。

代码实现

//直接选择排序
void SelectSort(int* arr, int n)
{
	assert(arr);
	int begin = 0;
	int end = n - 1;
	while (begin < end)
	{
		int maxi = begin;
		int mini = begin;
		for (int i = begin+1; i <= end; i++)
		{
			if (arr[i] > arr[maxi])
			{
				maxi = i;
			}
			if (arr[i] < arr[mini])
			{
				mini = i;
			}
		}
		swap(&arr[mini], &arr[begin]);
		swap(&arr[maxi], &arr[end]);
		begin++;
		end--;
	}
}

🆗,我们来测试一下:
在这里插入图片描述
确实排好了。

我们换一组数据再测试一下:
在这里插入图片描述

欸,这一次怎么不对了啊。

为什么会这样?

🆗,其实我们刚才实现的还有一些bug,什么bug呢?
在这里插入图片描述
每一次循环我们找到最大的和最小的之后,我们首先把最小值交换到了下标begin的位置,那有一种可能:
就是maxi和begin是同一个位置,这样的话交换之后,maxi的位置就变了,所以我们要加一个判断,如果maxi等于begin,那么交换之后最大值就跑到mini位置了,那就要为maxi重新赋值了。
在这里插入图片描述

所以正确的代码应该是这样的:

//直接选择排序
void SelectSort(int* arr, int n)
{
	assert(arr);
	int begin = 0;
	int end = n - 1;
	while (begin < end)
	{
		int maxi = begin;
		int mini = begin;
		for (int i = begin+1; i <= end; i++)
		{
			if (arr[i] > arr[maxi])
			{
				maxi = i;
			}
			if (arr[i] < arr[mini])
			{
				mini = i;
			}
		}
		swap(&arr[mini], &arr[begin]);
		if (maxi == begin)
			maxi = mini;
		swap(&arr[maxi], &arr[end]);
		begin++;
		end--;
	}
}

这下我们再来测试:
在这里插入图片描述
这下就对了。

直接选择排序特性总结

  1. 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
  2. 时间复杂度:O(N^2)
  3. 空间复杂度:O(1)
  4. 稳定性:不稳定
    这个排序我们自己理解的话很容易认为它是稳定的,但其实它是不稳定的,举个反例:
    比如待排数据是这样的:8 9 8 5 5
    假如是升序,选最小的数,选到一个5之后我们可以控制后面有相等的数我们不更新这个最小值,但是最小值交换到第一个位置,两个8的相对位置就变化了。
    所以不稳定。

3.2 堆排序

堆排序呢,我们在之前二叉树的文章里已经进行了详细的讲解:
堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。
那在这里就不过多赘述了,大家有需要的话可以看我之前那篇讲解二叉树的文章。

这里再简单总结一下堆排序:

  1. 堆排序使用堆来选数,效率就高了很多。
  2. 时间复杂度:O(N*log2N)
  3. 空间复杂度:O(1)
  4. 稳定性:不稳定
    举个例子吧。
    比如这样一组数据:1 1 1 1 1
    建好堆之后最后一个元素和堆顶一交换,是不是就不行了。
    当然有些数据可能在向下调整建堆的过程中就不稳定了。

4. 交换排序

基本思想:

所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动(升序),降序则相反。

4.1 冒泡排序

算法思想

冒泡排序大家应该都比较熟悉,思想也很简单:

它重复地走访过要排序的元素列,依次比较两个相邻的元素,如果顺序错误就把他们交换过来。走访元素的工作是重复地进行,直到没有相邻元素需要交换,也就是说该元素列已经排序完成。
这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端(升序或降序排列),就如同碳酸饮料中二氧化碳的气泡最终会上浮到顶端一样,故名“冒泡排序”。
在这里插入图片描述
那相邻两个数据进行比较,如果有N个数,第一趟我们应该比较N-1次:
在这里插入图片描述
经过第一趟,最大值已经在最后一个位置了(升序),那第二趟我们就不用管最后一个数了,所以第二趟比较N-2次,第三趟比较N-3次…,
一趟搞定一个数,当只剩最后一个数时就有序了,总共N-1趟。

代码实现

//冒泡排序
void SelectSort(int* arr, int n)
{
	int i = 0;
	for (i = 0; i < n - 1; i++)
	{
		int j = 0;
		for (j = 0; j < n - 1 - i; j++)
		{
			if (arr[j] > arr[j + 1])
			{
				int tmp = arr[j];
				arr[j] = arr[j + 1];
				arr[j + 1] = tmp;
			}
		}
	}
}

我们测试一下:
在这里插入图片描述
🆗,没问题,不过我们还可以对它进行一下优化:

怎么优化呢?
每趟过后,我们都可以判断一下是否已经有序了,如果已经有序了,就不再继续循环比较了。

//冒泡排序
void BubbleSort(int* arr, int n)
{
	int i = 0;
	for (i = 0; i < n - 1; i++)
	{
		int j = 0;
		int flag = 0;
		for (j = 0; j < n - 1 - i; j++)
		{
			if (arr[j] > arr[j + 1])
			{
				swap(&arr[j], &arr[j + 1]);
				flag = 1;
			}
		}
		// 一趟冒泡过程中,没有发生交换,说明已经有序了,不需要再处理
		if (flag == 0)
			break;
	}
}

🆗,那冒泡排序就完成了。

冒泡排序特性总结

  1. 冒泡排序是一种非常容易理解的排序
  2. 时间复杂度:O(N^2)
  3. 空间复杂度:O(1)
  4. 稳定性:稳定

那接着我们来看另一种交换排序——快速排序。

4.2 快速排序(递归)

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

那么,将区间按照基准值划分为左右两半部分(即一趟快排)的常见方式有:

1. hoare版本

思路讲解

怎么划分呢?
在这里插入图片描述
对应着图,再给大家简单解释一下:

上图选择最右边的元素(即第一个,一般选第一个或最后一个)作为基准值Key,两个“指针”L和R分别指向最左边和最右边,R先出发向左找比Key小的值,找到就停下,然后L开始向右找大于Key的值,找到就停下,然后交换L和R位置的元素,接着重复上述操作,当L和R相遇时,再将Key对应的值与相遇位置的值交换,这时Key的左边都是比它小的值,右边都是比它大的值。
这就是一趟的过程。

对于上面的动图:

L和R在值为3处相遇,正好小于Key的值,所以和Key交换后左边都是小的,右边都是大的。
在这里插入图片描述
那如果相遇位置的值比Key大呢,这样的话交换之后是不是就不对了啊:
在这里插入图片描述

那我们现在提出一个问题:如何确保相遇位置的值比Key小?

🆗,其实很简单,这里是左边第一个做Key,我们只需要让R先走就能保证了。
R先走,相遇的情况其实只有两种:

  1. R停止,L在向右找大的过程中相遇。
    此时相遇点是R停止的位置,一定是小于Key的。
  2. L停止,R在向左找小的过程中相遇。
    此时相遇的位置是L停止的位置,虽然L是在值大于Key的位置停止,但是在R出发之前 R位置的大值已经和L位置的小值进行了交换,所以当L、R相遇时相遇位置的值还是小于Key的。

那么同理,如果我们选择右边的第一个值(最后一个)作为Key,就应该让L先走。
这时是需要大家注意的一点。

那一趟排序能达到一个什么样的目的呢?

  1. 当前的Key对应的值已经处在了最终正确的位置
    🆗,这样一趟过后,当前的Key对应的值是不是就已经处在了最终的位置了,是吧,因为此时它左边的值都是比它小的,右边的值都是比它大的。
  2. 以Key为分割线,分割出两个子区间。
    那如果再将这两个子区间变有序,是不是整体就有序了。
    那如何对子区间进行排序,是不是就是与原问题类似的规模较小的子问题啊,那就可以递归了。
代码实现

上面讲清楚了一趟快速排序的过程,那我们就先实现一下一趟的代码:

//一趟快速排序 [left,right]
int PartSort(int* arr, int left, int right)
{
	int keyi = left;
	while (left < right)
	{
		//R先走,向左找小
		while (left < right && arr[right] >= arr[keyi])
		{
			right--;
		}
		//L向右找大
		while (left < right && arr[left] <= arr[keyi])
		{
			left++;
		}
		if (left < right)
			swap(&arr[left], &arr[right]);
	}
	int meeti = left;
	swap(&arr[meeti], arr[keyi]);
	return meeti;
}

这里我们返回一下相遇位置的下标,该位置两侧的数据就是被分割的两个子区间。

那一趟的搞定了,接下来我们就能写完整的快速排序的代码了:

//快速排序
void QuickSort(int* arr, int begin, int end)
{
	if (begin >= end)
		return;
	int keyi = PartSort(arr, begin, end);
	//[begin,keyi-1] keyi [keyi+1,end]
	
	//排keyi左边
	QuickSort(arr, begin, keyi - 1);
	//排keyi右边
	QuickSort(arr, keyi + 1, end);
}

其实就是先对整体进行一趟快排,然后再去排分隔的左右两个区间。
在上面一开始快速排序的概念我们就提到,快速排序一种二叉树结构的交换排序方法,现在我们再看上面的代码会发现快速排序递归实现的主框架,与二叉树前序遍历规则非常像。
大家也可以自己画一下递归展开图帮助自己理解。

代码写好了,我们测试一下:
在这里插入图片描述
没毛病。

优化1:三数取中法选key

但是呢?

我们刚才这种固定选Key的方法(选第一个或最后一个元素),如果去排原本已经有序或者接近有序的数据,效率其实反而会变的比较慢。
为什么呢?
首先比较理想的状态,即我们的数据是比较随机的情况下,我们选取第一个或最后一个数作为Key值,最后交换之后Key可能正好处在比较中间的位置,正好从中间把数据分成两个部分,然后然后再去递归排两个子区间。
这种情况下效率其实还是很好的:
在这里插入图片描述
一趟排序,那就从两头向中间进行遍历再加几次交换,时间复杂度差不多是O(N),虽然每层递归排的数据一直在减少,但N比较大的时候,后面减的就可以忽略,那总层数其实就是一棵二叉树的高度log2N。
所以这种情况下时间复杂度可以认为是O(N*log2N)

而在有序或接近有序的情况下:

在这里插入图片描述
我们还选择两边的数据作为Key的话,这样会导致递归的深度或者说层次会变的多很多,那这种情况下,不仅效率会变慢,而且很容易可能就出现栈溢出了。
在这里插入图片描述
在VS上Debug版本下10000个数据,有序的情况下就溢出了,当然调到release版本下会好一点。

所以,我们要考虑进行一个优化:

怎么优化呢?
🆗,那既然固定选择Key不太好,那我们就改变一下选Key的方法。
针对如何去选K,也有人提出了好几个方法,其中比较优的一种是这样的,我们把它叫做三数取中
如何选Key呢,对一组待排序的数据,我们从第一个数,中间位置的数和最后一个数中选取中间值(即值的大小处在中间的那个数)。

接下来我们就写一个函数,返回这三个数的中间值的下标:

//三数取中
int GetMidIndex(int* arr, int left, int right)
{
	//int mid = (left + right) / 2;
	
	//防止left和right太大溢出
	int mid = left + (right - left) / 2;
	if (arr[mid] > arr[left])
	{
		if (arr[right] > arr[mid])
			return mid;
		else if (arr[left] > arr[right])
			return left;
		else
			return right;
	}
	//arr[mid] < arr[left]
	else	
	{
		if (arr[right] < arr[mid])
			return mid;
		else if (arr[left] > arr[right])
			return right;
		else
			return left;
	}
}

那我们的一趟快排Part Sort也应该修改一下:
在这里插入图片描述

//一趟快速排序 [left,right]
int PartSort(int* arr, int left, int right)
{
	int mid = GetMidIndex(arr, left, right);
	swap(&arr[mid], &arr[left]);
	int keyi = left;
	while (left < right)
	{
		//R先走,向左找小
		while (left < right && arr[right] >= arr[keyi])
		{
			right--;
		}
		//L向右找大
		while (left < right && arr[left] <= arr[keyi])
		{
			left++;
		}
		if (left < right)
			swap(&arr[left], &arr[right]);
	}
	int meeti = left;
	swap(&arr[meeti], &arr[keyi]);
	return meeti;
}

🆗,这样优化之后,我们的快排就能应对各种情况了,即使待排数据是有序的或接近有序,且数量比较大,也不会导致递归层次特别深,现在我们的程序就不会像上面那样轻易的就栈溢出了。

优化2:小区间优化

快速排序在上面的基础上呢,其实还可以再做一些小优化:

通过之前的学习,我们知道,一趟快排的作用其实就是让一个数能够去到它最终的位置,那如果待排区间比较小或者说待排数据比较少的时候(比如10个左右的时候),如果我们还像上面那样递归去排,递归一次建立一个栈帧。只排10几个数我们也需要递归调用好多次去搞定。
所以,我们可以再做一点小优化,就是在待排区间比较小的时候,我们可以直接用一个直接插入排序(相比与其它排序比较好一点)去单独排一下这几个数,从而达到一个优化的效果 与全部数据都用快排的方式相比。

那我们直接对前面写好的快排进行一些修改就行了:

//快速排序[begin,end]
void QuickSort(int* arr, int begin, int end)
{
	if (begin >= end)
		return;
	if (end - begin + 1 < 8)
	{
		// 小区间用直接插入排序,减少递归调用次数
		SInsertSort(arr + begin, end - begin + 1);
	}
	else
	{
		int keyi = PartSort(arr, begin, end);
		//[begin,keyi-1] keyi [keyi+1,end]

		//排keyi左边
		QuickSort(arr, begin, keyi - 1);
		//排keyi右边
		QuickSort(arr, keyi + 1, end);
	}
}

至于这个区间的大小,我们一般选的大小是在10左右,这里我们选了8。

这样我们快排的效率会更快一点点。

2. 挖坑法

我们上面提到将区间按照基准值划分为左右两半部分有好几种方法

上面我们讲的hoare版本是第一种,接下来我们再来看一种在hoare的基础上优化的版本——挖坑法。

思路讲解

挖坑法的思想是这样的:
在这里插入图片描述

首先选取一个位置作为初始的坑位(一般取第一个或最后一个),上面动图中还是选取最右边第一个作为坑位,先定义一个变量Key保存一下该坑位的值,然后还是两个“指针”L和R分别指向首尾,让R先走向左寻找比Key小的值,找到停下,把该位置的值填到坑的位置,然后让该位置成为新的坑,接着让L开始向右找大于Key的值,找到停下,将其放入坑中,再让该位置成为新的坑,依次循环往复,直到L和R相遇停止,然后把Key填到相遇的位置。
那这种方法呢,就会好一点,不像上面hoare版本需要注意那么多细节,比如要确保相遇位置的值一定要比Key大或比Key小。

代码实现

理清了思路,我们来实现一下:

//一趟快速排序 [left,right](挖坑法)
int PartSort2(int* arr, int left, int right)
{
	int mid = GetMidIndex(arr, left, right);
	swap(&arr[mid], &arr[left]);
	
	int key = arr[left];
	//变量pit标识坑的位置
	int pit = left;
	while (left < right)
	{
		//R向左找小于Key的值
		if (left < right && arr[right] >= key)
		{
			right--;
		}
		arr[pit] = arr[right];
		pit = right;//更新坑

		//L向右找大于Key的值
		if (left < right && arr[left] <= key)
		{
			left++;
		}
		arr[pit] = arr[left];
		pit = left;//更新坑
	}

	arr[pit] = key;
	return pit;
}

那我们来测试一下,替换一下之前代码里hoare版本的一趟快排就行了:
在这里插入图片描述
这里换成Part Sort2就行了:
在这里插入图片描述
也是没问题的。

3. 前后指针法

接下来我们再来学习将区间按照基准值划分为左右两半部分的第3种方法——前后指针法:

思路讲解

在这里插入图片描述
对照着图,再给大家解释一下:

还是选取第一个数作为基准值Key,然后有两个“指针” ,初始时,prev指向第一个元素,cur指向第二个:
在这里插入图片描述
然后判断cur指向的值是否小于Key对应的值,如果小,就让prev++,然后交换Prev和cur 位置的值(当然这种prev++之后和cur处在同一位置的情况交不交换都一样),再让cur++:
在这里插入图片描述
然后再次判断,如果cur指向的值是否小于Key对应的值,如果小,就让prev++,然后交换Prev和cur 位置的值,再让cur++:
在这里插入图片描述
然后继续判断,当cur指向的数大于Key时,只让cur++,prev不动。
在这里插入图片描述
cur继续走,直到再次遇到小于Key的值,停下,然后让prev++,交换Prev和cur 位置的值,再让cur++
在这里插入图片描述
在这里插入图片描述
然后继续往后走,比大小进行相应操作,当cur与所有数据比完停止,交换prev和Key对应的值。
在这里插入图片描述
在这里插入图片描述

代码实现

思路理清,我们来实现一下代码:

//一趟快速排序 [left,right](前后指针法)
int PartSort3(int* arr, int left, int right)
{
	int mid = GetMidIndex(arr, left, right);
	swap(&arr[mid], &arr[left]);

	int keyi = left;
	int prev = keyi;
	int cur = keyi + 1;
	while (cur <= right)
	{
		/*if (arr[cur] < arr[keyi])
		{
			prev++;
			swap(&arr[prev], &arr[cur]);
		}*/
		//或
		if (arr[cur] < arr[keyi] && ++prev != cur)
			swap(&arr[prev], &arr[cur]);
	
		cur++;
	}
	swap(&arr[prev], &arr[keyi]);
	return prev;
}

🆗,那我们先走把PartSort3替换到快排中再测试一下:
在这里插入图片描述
在这里插入图片描述
没问题。

4.3 快速排序(非递归)

我们上面学习了递归实现快速排序算法,虽然经过我们的不断改进,我们的快排在大多数情况下一般都不会再出现递归层次太深导致栈溢出了。
但是,不排除在某些极端情况下可能还是会溢出,因为栈区的空间毕竟还是没有特别大。

所以,我们接下来再来学习快排的非递归实现:

快排的非递归需要我们借助栈这种数据结构来实现,栈我们之前也已经学习过了,我们的栈使用的空间是在堆上开辟的,与栈区相比,堆区的空间就比较大了。一般不会出现什么问题。

思路讲解

那非递归实现的思路是什么呢?

大家思考一下,我们上面使用递归来实现快排,每次递归调用的区别是什么?
🆗,我们上面对一个数组进行排序的时候,每次递归传的数组是不是都是同一个,唯一不同的地方在哪,是不是就是每次传的区间不一样啊:

在这里插入图片描述
每一个不同的区间,我们都是先对整体进行划分,然后然处理它的左右两个子区间。
那我们现在非递归去实现,其实还是模拟递归的这个过程,上面提到要使用栈,其实栈的作用就是帮助我们去控制这个区间的。

那具体怎么做呢?接下来我们一起来走一遍:

首先,初始化一个栈,先把我们要排序数据的整个大区间的左右端点入栈:
在这里插入图片描述
然后,我们需要一个循环来模拟整个递归的过程,循环结束条件我们先不管。
进入循环,首先我们要进行第一次快排,原数据的区间端点我们已经存进栈里了,就可以直接拿到(拿出保存后我们从栈中删掉),进行第一趟排序了:

在这里插入图片描述
那我们现在拿到第一趟之后的基准值Keyi了,Keyi现在将整个区间分成两个子区间,那我们通过Keyi就可以拿到这两个子区间的端点值了,拿到之后,我们再将这些端点值存到栈里。
🆗,那到这里相信大家就猜出循环结束的条件了——只要栈不为空,就继续,说明此时还有未被排完的子区间,栈为空时,就排完了,结束循环。
当然每次划分出来的区间并不一定都是有效的,区间里的数据个数大于1个,才需要再排,所以我们可以加一个判断:

在这里插入图片描述

🆗,那我们非递归的快排就写完了,给大家展示一下完整代码:

代码实现

//快速排序(非递归)
void QuickSortNonR(int* arr, int begin, int end)
{
	ST st;
	StackInit(&st);

	StackPush(&st, begin);
	StackPush(&st, end);
	while (!StackEmpty(&st))
	{
		//栈是先进后出,我们取到的顺序是先右后左
		int right = StackTop(&st);
		StackPop(&st);

		int left = StackTop(&st);
		StackPop(&st);

		int keyi = PartSort3(arr, left, right);
		//[left,keyi-1] keyi [keyi+1,right]

		if (right > keyi + 1)
		{
			StackPush(&st, keyi + 1);
			StackPush(&st, right);
		}
		if (keyi - 1 > left)
		{
			StackPush(&st, left);
			StackPush(&st, keyi - 1);
		}
	}
	StackDestroy(&st);
}

那再来测试一下非递归:
在这里插入图片描述
没什么问题的。

快速排序特性总结

  1. 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
  2. 时间复杂度:O(N*logN)
    在这里插入图片描述
  3. 空间复杂度:O(logN)
  4. 稳定性:不稳定

5. 归并排序

接下来我们来学习归并排序:

其实归并排序的思想我们在之前做题的过程中也用到过,之前文章里我们有讲过一些顺序表和链表相关的习题,合并两个有序链表 还有 合并两个有序数组,解这两道题我们其实就用到了归并的思想。
就拿合并两个有序链表那个题来说,我们是怎么做的:
两个指针分别遍历两个链表,依次取小的尾插,最终就将两个链表合并成一个有序链表(升序)。
这其实就是归并的思想。

基本思想

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

1. 递归版本

思路讲解

那归并排序具体要怎么搞呢?(还是以升序举例)

我们上面提到的合并两个有序链表那种题目,虽然用的是归并的思想,但是是不是有一个前提啊,前提就是两个链表原本就是有序的,所以从前往后遍历才能依次取到从大到小的数据。

但是:

如果现在随便给我们一组数据,让我们对它进行归并排序,是不是没法直接搞啊?
我们可以从中间把它分成两个部分,但是这两组数据一定是有序的吗?
🆗,是不是不是有序的啊,就是需要我们排呢。

那应该怎么办?

我们是不是还可以考虑用递归来搞。
现在有一组数据,我们的思路是什么呢?
首先可以从中间把它分为两组,如果这两组数据都变成有序的话,我们是不是就可以对它们进行归并了,归并之后整体不就有序了嘛。
那现在问题来了,如何让它的左右两个区间变得有序
🆗,让它的两个左右区间有序,是不是又是与原问题类似但规模较小的子问题啊,那我们就可以递归了,递归的主要思想是啥,就是大事化小。
所以呢,我们对它的左右区间再划分,但是左右区间又各划分成两个子区间,是不是还是需要先把子区间变有序,然后才能归并啊。
所以我们可以要进行多次划分,不断分割成子问题,那啥时候结束呢?
当被划分出来的区间只有一个数时,只有一个数,那是不是就可以认为它是一个有序区间了,那我们就可以开始一层一层的往回合并了。
将所有的区间归并完,排序也就完成了。

举个栗子:
在这里插入图片描述
大家可以看一下这张图,这就是对一组数据进行归并排序的一个大致过程。

这里呢,也有一个动图大家可以看一下:
在这里插入图片描述

复杂度计算

该算法的基本思想我们理解了,我们来计算一下它的复杂度:

在这里插入图片描述
来看这张图:
我们对原始数据一直分解,直到分割成不可再分的子问题,大家看如果我们像上面那样一直从正中间分,最后分解完毕是不是可以看成是一棵满二叉树。
那它的高度(层数)我们可以认为是log2N(logN),那每一层我们都要进行合并:

在这里插入图片描述
合并其实就是遍历找小尾插。
注意这里我们尾插要放到一个新数组中,因为直接在原数组进行比较尾插有时候会覆盖掉有些有效数据。
所以要借助一个大小为N(数据个数)的数组,即归并排序的空间复杂度是O(N)
那我们对每一层进行合并,首先遍历找小尾插,那就是O(N),然后呢我们排完序还是将数据放到原始的数组中,所以还要将尾插到新数组的数据拷贝回原数组,那也可以认为是O(N),两个O(N)算时间复杂度就还是O(N)。
那每层O(N),一共logN层,所以该算法时间复杂度是O(N*logN)

代码实现

那我们接下来就一起来一步一步地去实现一下归并排序的代码:

首先经过上面的分析,我们需要一个额外的数组来辅助我们完成归并排序,所以我们先开辟一个数组:
在这里插入图片描述
那接下来我们就可以开始递归去排了,但是呢,这里我们通常回再搞一个子函数出来,因为直接在当前函数递归的话,每次递归是不是都会malloc一次啊,这样就不太行。
子函数的命名通常可以在原函数前面加一个_
在这里插入图片描述
🆗,那这个子函数呢就专门用来递归进行排序。
首先第一次我们应该把整体所有数据都传过来:
在这里插入图片描述
传给子函数_merger进行处理,那根据我们上面的分析,要先将全体数据分为两个区间,当这两个区间有序时,就可以进行归并了,那如何处理两个子区间,是不是直接递归就行了:
在这里插入图片描述
当左右两个子区间有序时,我们就可以进行归并了。
但是递归肯定得有结束条件啊,在这个递归分割得过程中,什么就该停止往回归并了啊,是不是区间只剩一个数的时候啊:
在这里插入图片描述
那当程序执行到352行,左右两个区间的数据就已经有序了,那我们是不是就剩最后一步,归并了。
接下来就来实现一下归并的代码:
那归并就好搞了,遍历两个区间数据,依次取小的尾插至tmp数组,然后再拷贝回原数组。
在这里插入图片描述
那归并就完成了。
那最终排好序的数据我们又放回到了原数组,那tmp数组就没用了,但是它是我们malloc开辟的,所以销毁一下:
在这里插入图片描述

那到这里,整个归并排序就完成了:

void _merger(int* arr, int begin, int end, int* tmp)
{
	if (begin >= end)
		return;

	int mid = (begin + end) / 2;
	//[begin,mid] [mid+1,end]

	_merger(arr, begin, mid, tmp);
	_merger(arr, mid + 1, end, tmp);

	//归并
	int begin1 = begin;
	int end1 = mid;
	int begin2 = mid + 1;
	int end2 = end;

	int i = begin;
	//归并,取小的尾插(升序)
	while (begin1 <= end1 && begin2 <= end2)
	{
		if (arr[begin1] <= arr[begin2])
		{
			tmp[i++] = arr[begin1++];
		}
		else
		{
			tmp[i++] = arr[begin2++];
		}
	}
	//循环结束时哪个区间还有剩余数据,就把剩余数据续到后面
	while (begin1 <= end1)
	{
		tmp[i++] = arr[begin1++];
	}
	while (begin2 <= end2)
	{
		tmp[i++] = arr[begin2++];
	}
	//最后把排好数据再拷回原数组(只拷贝当前归并的区间)
	memcpy(arr + begin, tmp + begin, (end - begin + 1) * sizeof(int));
}

//归并排序
void merger(int* arr, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc fail");
		return;
	}

	_merger(arr, 0, n - 1, tmp);

	free(tmp);
	tmp = NULL;
}

我们来测试一下:
在这里插入图片描述
没问题。

2. 非递归版本

接下来我们再来学习一下归并排序的非递归实现。

思路讲解

那归并排序不用递归,应该怎么实现呢?

首先呢数组还是需要的:
在这里插入图片描述
然后,归并排序非递归的实现呢我们也不需要像快排那样借助栈或者其它的什么数据结构,比较好的一种方法呢就是直接搞,怎么搞呢?
在这里插入图片描述
现在有这样一组数据,我们说归并的前提是两组数据如果是有序的,那我们就可以直接对它们归并了。
我们递归实现的思想是什么,就是对原始数据进行划分嘛,分割成子问题,一直分一直分,直到区间只剩一个数时,那就可以认为说有序的了,然后两两进行归并。

那现在不用递归,我们是不是可以反过来啊

先把原始数据一个一个分为一组,每组只有一个数据,那就可以认为是有序了,然后从前到后两两进行归并:
在这里插入图片描述
那这样一趟过后,再把每两个数看成一组,每组数据是不是也都是有序的了。
在这里插入图片描述
那再继续,两个看成一组,两两归并:
在这里插入图片描述
那现在每四个是不是可以看成一个有序区间了,那就四个一组再两两归并:
在这里插入图片描述
那对于当前这组数据来说,是不是就完成了啊。
所以,我们可以定义一个变量gap,来控制每组数据的个数,让gap依次取1,2,4…。
在这里插入图片描述

代码实现

那我们如何用代码控制着去走这个过程呢?

其实最不好搞的就是去控制好每次归并的区间的边界,那我们肯定还是搞一个循环,每次归并两组数据,每组数据的个数我们用gap控制,gap第一次是1
在这里插入图片描述
那归并的代码我们上面写过了,可以直接拷贝过来,然后控制边界就行了
在这里插入图片描述
那这个边界我们如何修改才是正确的呢?
在这里插入图片描述
应该是这样的,我们对照着例子来验证一下对不对:
在这里插入图片描述
是对的哦,大家可以自己走一遍。
然后我们是不是应该再加一层循环,让gap变化起来:
在这里插入图片描述
在这里插入图片描述
然后还需要注意的一点是,我们控制区间的边界改变了,拷贝的边界也应该修改一下:
在这里插入图片描述

那代码应该是这样的:

//归并排序(非递归)
void MergerSortNonR(int* arr, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc fail");
		return;
	}

	int gap = 1;
	while (gap < n)
	{
		for (int j = 0; j < n; j += 2 * gap)
		{
			//归并
			int begin1 = j;
			int end1 = j + gap - 1;
			int begin2 = j + gap;
			int end2 = j + 2 * gap - 1;

			int i = j;
			//归并,取小的尾插(升序)
			while (begin1 <= end1 && begin2 <= end2)
			{
				if (arr[begin1] <= arr[begin2])
				{
					tmp[i++] = arr[begin1++];
				}
				else
				{
					tmp[i++] = arr[begin2++];
				}
			}
			//循环结束时哪个区间还有剩余数据,就把剩余数据续到后面
			while (begin1 <= end1)
			{
				tmp[i++] = arr[begin1++];
			}
			while (begin2 <= end2)
			{
				tmp[i++] = arr[begin2++];
			}
			//最后把排好数据再拷回原数组(只拷贝当前归并的区间)
			memcpy(arr + j, tmp + j, (end2 - j + 1) * sizeof(int));
		}
		gap *= 2;
	}
	
	free(tmp);
	tmp = NULL;
}

测试一下,就拿我们上面分析的那个例子:
在这里插入图片描述
哦豁,确实排好了。

我们再测一组,在原数组上再加一个数:
在这里插入图片描述

啥也没打印,其实我们的程序已经崩掉了
我们来调试一下:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
其实呢?
是出现了越界的情况。

发现问题

为什么会这样呢?

我们给定第一组数据非常完美,直接就排好了,我们分析过程也没发现什么问题:
在这里插入图片描述
但是第二次我们又增加了一个数据
在这里插入图片描述
我们再来分析一下:
在这里插入图片描述
第一趟是不是就出现越界了。
在这里插入图片描述
往原数组拷贝就拷贝了个随机值。
第一趟gap=1,当j等于8时,end2等于j + 2 * gap - 1=9,访问下标为9 的元素就发生越界了。
因为我们是两组两组进行归并的,但是最后到5的时候就剩它自己一组了。你再向后拿一个数跟它进行归并可不就越界了嘛。

解决问题

那接下来我们就分析一下哪些情况下会出现越界,然后进行相应的处理:

我们每次要归并的两组数据的区间是第一组【begin1,end1】和第二组【begin2,end2】。
首先begin1是肯定不会越界的,每次循环我们是j赋给begin1的,j小于n才进入循环的:
在这里插入图片描述

所以呢,出现越界无非就这几种情况:

  1. 第一组部分越界,即end1越界
    如果end1越界的话,哪那begin2,end2必定也都超过了数组的有效范围,即第二组是不存在的。
    那只有一组,也就没法进行归并了,所以这种情况直接break就行了。
  2. 第一组没有越界,第二组全部越界(即begin2越界了)
    那这种情况第二组也是不存在的,直接break
  3. 第一组未越界,第二组部分越界(即begin2没有越界但end2越界了)
    这时两组都是有数据的,只不过第二组数据少一些罢了,所以这种情况就不能直接break了,我们要休整一下end2的取值,然后对两组数据进行归并。
    那end2的取值应该怎么修改,n是数据个数,n-1就是最后一个元素下标,所以让end2等于n-1就行了。

那我们接下来处理一下这几种情况就行了:
在这里插入图片描述

最终代码

所以最终的代码是这样的:

//归并排序(非递归)
void MergerSortNonR(int* arr, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc fail");
		return;
	}

	int gap = 1;
	while (gap < n)
	{
		for (int j = 0; j < n; j += 2 * gap)
		{
			//归并
			int begin1 = j;
			int end1 = j + gap - 1;
			int begin2 = j + gap;
			int end2 = j + 2 * gap - 1;

			//判断越界的情况并进行处理
			//第一组部分越界
			if (end1 >= n)
				break;
			//第二组全部越界
			if (begin2 >= n)
				break;
			//第二组部分越界
			if (end2 >= n)
				end2 = n - 1;

			int i = j;
			//归并,取小的尾插(升序)
			while (begin1 <= end1 && begin2 <= end2)
			{
				if (arr[begin1] <= arr[begin2])
				{
					tmp[i++] = arr[begin1++];
				}
				else
				{
					tmp[i++] = arr[begin2++];
				}
			}
			//循环结束时哪个区间还有剩余数据,就把剩余数据续到后面
			while (begin1 <= end1)
			{
				tmp[i++] = arr[begin1++];
			}
			while (begin2 <= end2)
			{
				tmp[i++] = arr[begin2++];
			}
			//最后把排好数据再拷回原数组(只拷贝当前归并的区间)
			memcpy(arr + j, tmp + j, (end2 - j + 1) * sizeof(int));
		}
		gap *= 2;
	}
	
	free(tmp);
	tmp = NULL;
}

这下我们再来测试刚才的那组数据:
在这里插入图片描述
就没问题了。

归并排序特性总结

  1. 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
  2. 时间复杂度:O(N*logN)
  3. 空间复杂度:O(N)
  4. 稳定性:稳定

6. 计数排序(非比较排序)

那到现在为止:

前面我们学的所有的排序算法,它们都是一大类的,即比较排序,就是我们在排序的过程中都对数据进行了大小的比较。
那接下来我们还要学习一个非比较排序——计数排序。
当然非比较排序肯定不止这一种,但我们这里重点给大家讲一下这个计数排序,因为计数排序在生活和工作中还是可能应用会比较多一点的,而且在找工作的时候也有可能会被考到。

接下来我们就一起来学习一下:

计数排序是一个非基于比较的排序算法,该算法于1954年由 Harold H. Seward 提出。它的优势在于在对一定范围内的整数排序时,它的复杂度为Ο(n+k)(其中k是整数的范围),快于任何比较排序算法。

算法思想

计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用

那具体的操作步骤是怎么样的呢?举个例子一起来看一下:

计数排序是这样搞的:
假如现在有这样一个数组待排序:
在这里插入图片描述
一共8个数。
对于一组待排序的数据,我们首先要做的是:
统计每个元素出现的次数保存到另一个数组中
那具体要怎么做?
拿这组数据来说,我们可以先找出它的最大值,这里是5,那我们就开辟一个大小为5+1的数组C,这样最后一个下标值正好是5 ,该数组全部初始化为0。
然后,怎么做呢?
遍历原数组,原数组的值是几,就让数组C下标为几的元素(初始为0)++,这样遍历一遍过后,数组C存的就是原数组每个元素出现的个数,下标的值就是对应的原数组中的数据。

在这里插入图片描述

那统计好次数,下一步怎么做呢?

下面就可以进行排序了,怎么排?
去遍历数组C,下标为0的的值是2,就说明0出现了两次,那就向原数组中(也是从下标为0位置开始),放两个0进去,继续遍历C,下标1位置值是0,就说明原数据没有1,继续向后,下标2的位置值是2,就接着向原数组放两个2…
依次类推,直到遍历完C数组。这时原数组的数据就是排好序之后的:

在这里插入图片描述

思考

刚才的例子我们很顺利就排好了,但是呢,还有一些问题值得我们思考一下:

如果待排序的数据大小差别非常大呢?
比如说最小的数据是5,最大的是50000,那我们如果还按上面的思路进行排序的话是不是也得创建一个非常大的数组啊。
那这样空间的耗费是不是就太严重了啊。
所以呢?
计数排序一般适用于范围相对集中的数据

那除此之外,还有一个问题:

就算数据处的范围比较集中,但是数据都比较大怎么办:
在这里插入图片描述
比如是这样的,那我们也要开辟一个大小一百多的数组吗?
是不是也有点不太合适。

思想优化

那这时我们就要想办法优化一下我们的思想了:

怎么优化呢?
刚才我们统计次数其实是用的待排数据的一个绝对映射,什么意思呢?
即待排的数据是几,它的次数就保存在了另一个数组下标为几的位置上。
那现在要优化,我们可以考虑使用数据的相对映射来搞:
什么意思呢?
在这里插入图片描述
就拿这组数据来说:
如果还像上面那样的话,我们就得开一个大小为121的数组,因为最大值是120嘛,0到120 就是121个数嘛。
那这样前100个空间是不是都没用上啊。

那现在我们改变一下:

上面这组数据最大值max是120,最小值min是100,所以我们只需开一个大小为(max-min+1)的数组就够了
那该数组的下标范围就是【0,20】了,如果和上面一样的话100就应该映射到下标为100的位置,那按我们现在的思路100是最小值,就映射到下标为0的位置。
即对于待排数组中的数组arr[i],就映射到下标为arr[i]-min的位置就行了。

大家再思考一下:

用计数排序如果待排数组中有负数可以吗?
如果用绝对映射是不是不行,假如有个-5,绝对映射的话-5的次数应该存在下标为-5的位置,这不就越界了,数组下标是不能为负的。
但我们现在用相对映射是不是就可以了。
假如有个负数-5,是最小的一个,那它映射到哪个位置,-5-min即-5-(-5)=0,🆗,下标为0的位置,是不是可以啊。
那剩下其它的步骤就还和原来一样了。

那其实这个优化的作用呢就是能少开一些空间。

代码实现

那我们接下来就来实现一下计数排序的代码:

首先第一步是什么:

  1. 找最大值最小值,确定我们要开的数组大小
    大小是max-min+1,我们上面分析过了:
    在这里插入图片描述
  2. 创建用来计数的数组并将全部元素初始化为0
    在这里插入图片描述
    当然这个地方不想手动初始化的话,也可以用calloc,它与malloc的区别就是可以自动将开好的空间初始化为0。
    在这里插入图片描述
  3. 统计个数存入count数组
    在这里插入图片描述
  4. 排序(遍历count数组按个数往原数组放数据)
    在这里插入图片描述
  5. 释放malloc的数组
    在这里插入图片描述

就写完了:

//计数排序
void CountSort(int* arr, int n)
{
	assert(arr);
	//找最值
	int max = arr[0];
	int 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");
		return;
	}
	memset(count, 0, sizeof(int) * range);
	/*int* count = (int*)calloc(range, sizeof(int));
	if (count == NULL)
	{
		perror("malloc fail");
		return;
	}*/

	//统计个数
	for (int i = 0; i < n; i++)
	{
		count[arr[i] - min]++;
	}

	//排序(遍历count数组按个数往原数组放数据)
	int j = 0;
	for (int i = 0; i < range; i++)
	{
		//count[i]就是每个数据出现的次数
		while (count[i]--)
		{
			arr[j] = i + min;
			j++;
		}
	}

	//释放malloc的数组
	free(count);
	count = NULL;
}

来测试一下:
在这里插入图片描述
来测一组带负数的:
在这里插入图片描述
没问题,🆗,那我们的计数排序就完成了。

计数排序特性总结

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

7. 性能对比

其实上面讲的这几个算法,我们通过对他们的理解以及时间复杂度就能分辨出来那些算法效率更高。

接下我给出一个程序,让它们对一组大量的相同的数组进行排序,我们打印出它们执行的时间,给大家展示一下(程序的具体实现大家可以不用关心,直接拿去用):

void TestOP()
{
	srand(time(0));
	const int N = 10000;
	int* a1 = (int*)malloc(sizeof(int) * N);
	if (a1 == NULL)
		exit(-1);
	int* a2 = (int*)malloc(sizeof(int) * N);
	if (a2 == NULL)
		exit(-1);
	int* a3 = (int*)malloc(sizeof(int) * N);
	if (a3 == NULL)
		exit(-1);
	int* a4 = (int*)malloc(sizeof(int) * N);
	if (a4 == NULL)
		exit(-1);
	int* a5 = (int*)malloc(sizeof(int) * N);
	if (a5 == NULL)
		exit(-1);
	int* a6 = (int*)malloc(sizeof(int) * N);
	if (a6 == NULL)
		exit(-1);
	int* a7 = (int*)malloc(sizeof(int) * N);
	if (a7 == NULL)
		exit(-1);

	for (int i = 0; i < N; ++i)
	{
		a1[i] = rand();
		//a1[i] = rand() + i;
		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();
	SInsertSort(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();
	QuickSort(a5, 0, N - 1);
	int end5 = clock();

	int begin6 = clock();
	MergerSort(a6, N);
	int end6 = clock();

	int begin7 = clock();
	BubbleSort(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", end7 - begin7);
	printf("QuickSort:%d\n", end5 - begin5);
	printf("MergeSort:%d\n", end6 - begin6);

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

我们来测试一下:

首先第一次10000个数据:
在这里插入图片描述
然后我们会分别获取一下,程序运行开始和结束的时间:
在这里插入图片描述
最后相减就能得到运行时间,单位是毫秒:
在这里插入图片描述
我们来运行一下:
在这里插入图片描述
看这个差别就出来了。

再多一点,来10万个:

在这里插入图片描述
这次我等的时间就挺长的:
在这里插入图片描述
还是10万个,刚才是debug版本下,我们可以换到release下(会对我们的程序进行优化),会快一点
在这里插入图片描述
再增加一点,release下100万个数据,但是像冒泡和直接插入这些比较慢的我们可以把它先屏蔽掉,否则太慢了:
在这里插入图片描述
注意:0的那三个屏蔽掉了
🆗,数据越多它们的差距就越明显了。
500万个:
在这里插入图片描述
在这里插入图片描述
1000万:
在这里插入图片描述
快排敢称为“快速排序”确实还是很快的啊。

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

在这里插入图片描述
以上就是对一些常见排序算法的讲解,欢迎大家指正!!!
在这里插入图片描述

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

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

相关文章

Hadoop基础之《(7)—Hadoop三种运行模式》

一、hadoop有三种运行模式 1、本地模式 数据存储在linux本地&#xff0c;不用 2、伪分布式集群 数据存储在HDFS&#xff0c;测试用 3、完全分布式集群 数据存储在HDFS&#xff0c;同时多台服务器工作。企业大量使用 二、单机运行 单机运行就是直接执行hadoop命令 1、例子-…

AntV结合Vue实现导出图片功能

一、业务场景&#xff1a; AntV 组织图操作完毕以后&#xff0c;需要点击按钮将画布以图片的形式导出 二、问题描述&#xff1a; 官网上有4个方法&#xff0c;我用的是 graph.toFullDataURL(callback, type, imageConfig) 三、具体实现步骤&#xff1a; &#xff08;1&#x…

Three.js纹理投影简明教程

纹理投影是一种将纹理映射到 3D 对象并使其看起来像是从单个点投影的方法。 把它想象成投射到云上的蝙蝠侠符号&#xff0c;云是我们的对象&#xff0c;蝙蝠侠符号是我们的纹理。 它用于游戏和视觉效果&#xff0c;以及创意世界的更多部分。 工具&#xff1a;使用 NSDT场景编辑…

Linux 入门教程||Linux 简介||Linux 安装

Linux 简介 Linux内核最初只是由芬兰人李纳斯托瓦兹&#xff08;Linus Torvalds&#xff09;在赫尔辛基大学上学时出于个人爱好而编写的。 Linux是一套免费使用和自由传播的类Unix操作系统&#xff0c;是一个基于POSIX和UNIX的多用户、多任务、支持多线程和多CPU的操作系统。…

pdf文件怎么压缩?pdf文件变小的简单方法

工作中&#xff0c;pdf文件的使用是非常广泛的&#xff0c;一些特殊的场景下对于pdf文件的大小是有着严格规定的&#xff0c;所以pdf文件压缩成了必备的一项技能&#xff0c;那么怎么将pdf压缩&#xff08;https://www.yasuotu.com/pdfyasuo&#xff09;呢&#xff1f;下面介绍…

一个完整的渗透学习路线是怎样的?如何成为安全渗透工程师?

前言 1/我是如何学习黑客和渗透&#xff1f; 我是如何学习黑客和渗透测试的&#xff0c;在这里&#xff0c;我就把我的学习路线写一下&#xff0c;让新手和小白们不再迷茫&#xff0c;少走弯路&#xff0c;拒绝时间上的浪费&#xff01; 2/学习常见渗透工具的使用 注意&…

2023年江苏建筑安全员精选真题题库及答案

百分百题库提供建筑安全员考试试题、安全员证考试真题、安全员证考试题库等,提供在线做题刷题&#xff0c;在线模拟考试&#xff0c;助你考试轻松过关。 250.施工升降机防坠安全器在装机使用时,应按吊笼额定载重量进行坠落试验,以后至少()个月应进行一次额定载重量的坠落试验 …

辨析Web Service, SOAP, REST, OData之间的关系与区别

最近发现&#xff0c;对于刚刚接触HTTP服务的同学&#xff0c;在一些基础概念上容易混乱。很多同学搞不清楚Web Service&#xff0c;SOAP&#xff0c;REST以及OData这些技术之间的关系与区别。文本会尽量用最简洁的方式&#xff0c;解释这几个概念&#xff0c;并附上一些资料的…

第一章:在Mac OS上安装Go语言开发包

各位朋友们大家好&#xff01; 本节主要为大家讲解如何在Mac OS上安装Go语言开发包&#xff0c;大家可以在Go语言官网下载对应版本的的安装包&#xff0c;如下图所示。 安装Go语言开发包 Mac OS 的Go语言开发包是 .pkg 格式的&#xff0c;双击我们下载的安装包即可开始安装。…

I.MX6ULL内核开发1:内核模块实验

目录 一、实验环境 二、编译4.19.35版内核 1、下载linux内核源码 2、安装必要的环境工具库 3、一键编译内核 4、获取编译出来的内核相关文件&#xff08;与makefile文件一致&#xff09; 三、内核模块代码分析 1、内核模块头文件 2、内核模块打印函数 3、文中语法分析…

filter滤镜实现网页置灰(纪念日)效果

目录 前言关键代码兼容ie的做法定位错乱的原因 前言 一些特殊纪念日的时候&#xff0c;很多网站的首页进行置灰处理。这种效果实际上是用滤镜filter实现的&#xff0c;几行css就可以实现。 在实现整个页面置灰的过程中&#xff0c;要注意页面中有定位的元素&#xff0c;就需…

java中 == 和 equels

1、 和 equals的区别 是操作符 操作符专门用来比较变量的值是否相同。对于基本类型变量来说&#xff0c;只能使用 &#xff0c;因为基本类型的变量没有方法。使用比较是值比较。对于引用类型的变量来说&#xff0c;比较的两个引用对象的地址是否相等。 equals 是方法 equals方…

Linux kdump配置步骤和注意事项(基于debian、OpenEuler和自定义编译内核的Linux)

1、kdump简单描述 kdump是Linux中的一个内核转储机制&#xff0c;主要用于当Linux内核发生崩溃时&#xff0c;将该内核相关的信息和崩溃原因通过转储的形式保留下来&#xff0c;在debian系统中&#xff0c;相关信息会存储在dump文件中&#xff0c;在OpenEuler和CentOS等系统中…

utils:crypto-js的基本使用和(加密/解密)功能封装

目录一、基本使用1. 资源下载2. 目录结构3. 代码二、功能封装1. 封装代码2. 使用说明(1) 引入utils工具(2) 使用工具一、基本使用 1. 资源下载 crypto-js 2. 目录结构 3. 代码 <html xmlns"http://www.w3.org/1999/xhtml"> <head> <meta http-equ…

【Linux】磁盘结构/文件系统/软硬链接/动静态库

文章目录前言一、磁盘结构1、磁盘的物理结构2、磁盘的存储结构3、磁盘的逻辑结构二、文件系统1、对 IO 单位的优化2、磁盘分区与分组3、对分组的具体管理方法4、文件操作三、软硬链接1、理解硬链接2、理解软链接3、理解 . 和 ..四、静动态库1、什么是动静态库2、动静态库的制作…

JavaScript 中的字符串

JavaScript 字符串 什么是字符串&#xff1f; 字符串是用来存储和处理文本数据的 比如&#xff1a;一句话、a等 字符串的创建方式 字面量方式进行创建 <!DOCTYPE html> <html lang"en"><head><meta charset"UTF-8" /><meta…

DW 2023年1月Free Excel 第十次打卡 Excel看板

第十章Excel看板 数据下载地址与参考链接&#xff1a;https://d9ty988ekq.feishu.cn/docx/Wdqld1mVroyTJmxicTTcrfXYnDd 数据看板作为数据动态展示的一种重要方式&#xff0c;被广泛的应用于各个领域&#xff0c;因此本节根据1个案例讲解使用Excel制作数据看板的过程&#xff…

大厂年薪43w测试开发手把手教你搭建Web自动化测试框架,超详细

测试框架的设计有两种思路&#xff0c;一种是自底向上&#xff0c;从脚本逐步演变完善成框架&#xff0c;这种适合新手了解框架的演变过程。另一种则是自顶向下&#xff0c;直接设计框架结构和选取各种问题的解决方案&#xff0c;这种适合有较多框架事件经验的人。本章和下一张…

使用Python读取和处理安卓传感器数据与CSV读取

多年来&#xff0c;数据一直是世界运作的重要组成部分。这些数据可以从GDP到血样&#xff0c;再到世界的各个方面。随着我们数据的增长&#xff0c;统计学找到了从它们中提取更多意义的方法。 这些方法之一被称为方差分析&#xff08;ANOVA&#xff09;。方差分析是一套统计模…

python进阶——人工智能视觉识别

大家好&#xff0c;我是csdn的博主&#xff1a;lqj_本人 这是我的个人博客主页&#xff1a;lqj_本人的博客_CSDN博客-微信小程序,前端,vue领域博主lqj_本人擅长微信小程序,前端,vue,等方面的知识https://blog.csdn.net/lbcyllqj?spm1000.2115.3001.5343 哔哩哔哩欢迎关注&…