初阶数据结构--排序算法(全解析!!!)

排序

1. 排序的概念

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

2. 常见的排序算法

3. 实现常见的排序算法

以下排序算法均是以排升序为示例。

3.1 插入排序

基本思想:

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

在这里插入图片描述

这种思想类似于玩扑克牌的时候,将牌按一定顺序排好。

3.1.1 直接插入排序
3.1.1.1 代码示例
//直接插入排序(从大到小)n表示数组有效数据个数
void InsertSort(int* arr, int n)
{
	for (int i = 0; i < n - 1; i++)
	{
		
		int end = i;//从首元素开始记录有序序列的最后一个元素的下标
		int tmp = arr[end + 1];//待插入的元素

		//确保end在有序区间范围内,不能越界 
		while (end >= 0)
		{
			//比较有序区间数据,tmp数据小
			if (arr[end] > tmp)
			{
				//有序空间数据移动,寻找空间插入数据
				arr[end + 1] = arr[end];
				//继续向前寻找合适数据
				end--;
			}
			//比较有序区间数据,tmp数据大
			else
			{
				//直接跳出循环,将tmp数据插入到end+1下标处
				break;
			}
		}
		arr[end + 1] = tmp;
        //代码执行到此位置有两种情况:
		//1.待插入元素找到应插入位置(break跳出循环到此)。
		//2.待插入元素比当前有序序列中的所有元素都小(while循环结束后到此)。
	}
}

时间复杂度: O ( N 2 ) O ( N^2 ) O(N2)(最差情况,数组为降序)  空间复杂度: O ( 1 ) O ( 1 ) O(1)

3.1.1.2 代码详解
  • 函数参数:

    • int* arr:需要排序的数组的数组名(数组的首元素地址)
    • int n:数组中有效元素的个数
  • 外层for循环:

    • for (int i = 0; i < n - 1; i++)
    • 外层的for循环表示,控制end的下标需要遍历数组中的每个数据,每个数据均需要参与比较。
    • 这里的循环边界是tmp不能越界,所以tmp最大值为 n − 2 n-2 n2,所以 i < n − 1 i<n-1 i<n1为边界
  • 临时变量的创建:

    • int end:用于表示有序数组中的最后一位数据的下标,从首元素开始。
    • int tmp:将待插入元素拷贝一份。存在有序数组后移覆盖掉待插入元素的情况。
  • 内层while循环:

    • while (end >= 0)
    • 内层的while循环表示,要保证end在有序数组的范围内,不可以越界。
  • if判断语句

    • 如果此时以end为下标的数组数据 大于待插入数据,则将以end为下标的数组数据先后移(注意:这里是通过end+1的方式实现数据的后移,此时end的位置并没有改变),在将end--,在比较前面较小的有序数据。
    • 如果此时以end为下标的数组数据小于待插入数据,则需要在end后面一个位置插入此数据,并也不需要再与有序数组中的数据比较,故跳出循环。
  • 插入数据

    • arr[end + 1] = tmp
    • 代码执行到此位置有两种情况:
      • 待插入元素找到应插入位置(break跳出循环到此)。
      • 待插入元素比当前有序序列中的所有元素都小(while循环结束后到此)。
3.1.2 希尔排序
3.1.2.1 思路详解

相关介绍

希尔排序是按其设计者希尔的名字命名的,该算法由希尔1959年公布。他对普通直接插入排序的时间复杂度进行分析,得出了以下结论:

  • 直接插入排序的时间复杂度最坏情况下为 O ( N 2 ) O(N^2) O(N2),此时待排序列为逆序,或者说接近逆序。
  • 直接插入排序的时间复杂度最好情况下为 O ( N ) O(N) O(N),此时待排序列为升序,或者说接近升序。

是否可以有一种算法,可以规避掉直接插入排序的最坏的逆序情况,这样的话就可以大幅降低直接插入排序的复杂度了。

**规避掉逆序情况的方式:**先将待排序的数据进行预排序,使代排序列就接近有序,再对此序列进行一次直接插入排序,此时因为一定不是逆序序列,所以对于直接插入排序的复杂度就一定小于 O ( N 2 ) O(N^2) O(N2)

基本思想:

  • 希尔排序核心思想:先用较大间距进行局部排序,逐渐缩小间距提高整体有序性,最终用间距为1的插入排序完成,这样效率比普通插入排序高很多。

  • 因为希尔排序又被成为缩小增量排序

    1. 先选定一个小于N的整数gap(步长)作为第一增量,然后将所有距离为gap的元素分在同一组,并对每一组的元素进行直接插入排序。然后再取一个比第一增量小的整数作为第二增量,重复进行上述操作…
    2. 当增量的大小减到1时,就相当于整个序列被分到一组,进行一次直接插入排序,排序完成。

补充

  1. 问题1:为什么要让gap由小到大?

    因为当gap(步长)越大,数据间隔越大,数据移动的越快。前期让gap较大,可以让数据更快移动到自己对应位置附近,减少数据的挪动次数,减少复杂度。

  2. 问题2:gap的值应该应该如何选取?

    一般情况下,取序列的一半作为增量,然后依次减半,并确保最终增量可以减为1。

具体示例:

​ 首先对这10个乱序数组作为示例进行排序,用序列长度的一半作为第一次增量,也就是gap = 10/2 = 5,因此此时相隔距离为5的数据被分为一个**(共分了5组,每组有2个元素),然后对这5组在组内进行直接插入排序**,调整数据顺序。

​ 经过第一次调整之后,可以大致看出新的序列大体呈现左边为小数据,右边为大数据的规律。之后继续进行第二次调整,此时gap的值对半gap = 5/2 = 2,此时相隔距离为2的元素被分为一组**(共分了2组,每组有5个元素)**,然后再对这2组数据在组内进行直接插入排序,调整数据顺序。

​ 此时再将gap的值对半,gap = 2/2 = 1,此时gap减为1,即整个序列被分为一组,进行一个直接插入排序。

​ 这样就利用希尔排序完成了以上序列的排序方法,其中前两趟为希尔排序的预排序,最后一趟是希尔排序的直接排序。

3.1.2.2 示例代码
//希尔排序
void ShellSort(int* arr, int n)
{
	//定义步长
	int gap = n;//6

	//控制排序次数,调整步长
	while (gap > 1)
	{
		gap = gap / 3 + 1;//保证最后一次gap一定为1

		//遍历被步长分隔的数组内部的数据
		for (int i = 0; i < n - gap; i++)
		{
			int end = i;//n-gap-1
			int tmp = arr[end + gap];

			//防止end越界
			while (end >= 0)
			{
				//进行元素的比较和交换
				if (arr[end] > tmp)
				{
					arr[end + gap] = arr[end];
					end -= gap;
				}
				else {
					break;
				}
			}
			arr[end + gap] = tmp;
			//代码执行到此位置有两种情况:
			//1.待插入元素在组中找到应插入位置(break跳出循环到此)。
			//2.待插入元素比当前组中的有序序列中的所有元素都小(while循环结束后到此)。		}
	}
}

平均时间复杂度大约为: O ( N 1.3 ) O(N^{1.3}) O(N1.3)  空间复杂度: O ( 1 ) O ( 1 ) O(1)

3.1.2.2 代码详解
  • 函数参数与步长定义

    • int* arr:需要调整的序列所属数组的数组名

    • int n:数组中有效元素的个数

    • int gap = n;:步长的定义,一般初始值即为n,在后续排序会先折半

  • 外层while循环

    • 先由gap = gap / 3 + 1;语句,对初始化的gap值进行调整,便于后续使用
    • 再有外层while循环控制整体排序的次数
  • 内层for循环

    • arr数组中的序列已经被gap切分之后,由for循环遍历各个组中的数据
    • 同时定义endtmp,含义与直接插入排序中一样
    • for循环的截止条件还是需要防止tmp的下标越界,所以为 i < n − g a p i < n - gap i<ngap
  • 算法核心实现

    • 插入算法的核心实现逻辑
    • 由外层while循环控制end遍历该组数据中的每个数据
    • if判断语句,实现数据比较与交换的功能

3.2 选择排序

基本思想

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

3.2.1 直接选择排序
3.2.1.1 代码示例
//直接选择排序
void SelectSort(int* arr, int n)
{
	//起始位置元素下标
	int begin = 0;
	//结尾位置元素下标
	int end = n - 1;

	//开始从数组两头遍历数组
	while (begin < end)
	{
		//最大最小值初始状态都定义在首元素处
		int mini = begin, maxi = begin;
		
		//在begin~end未排序区间之间,进行比较
		for (int i = begin; i <= end; i++)
		{
			if (arr[i] > arr[maxi]) 
			{
				//确定未排序区间中的最大数,并将maxi指向他
				maxi = i;
			}
			if (arr[i] < arr[maxi])
			{
				//确定未排序区间中的最小数,并将mini指向他
				mini = i;
			}
		}
		//mini和begin交换,maxi和end交换
		//避免maxi begin指向同一个位置,造成maxi mini互换两次,相当于没换
		if (maxi == begin)
		{
			maxi = mini;
		}
		Swap(&arr[mini], &arr[begin]);
		Swap(&arr[maxi], &arr[end]);

		//调整无序序列下标
		++begin;
		--end;
	}
}

时间复杂度: O ( N 2 ) O(N^2) O(N2)  空间复杂度: O ( 1 ) O(1) O(1)

3.2.2 堆排序

​ 这里的堆排序在前面堆的数据结构类型有过相关介绍。所以要想学习到堆排序,首先需要了解堆的向下调整算法,因为堆排序的核心就是先利用无序序列建堆,再利用堆的向下调整算法将堆构造成小根堆(以排升序为例),再将堆顶数据不断出堆即可。

3.2.2.1 思路详解

首先,拿到一个无序序列先将此序列依照堆的向下调整算法建堆。

向下调整算法的介绍:

在使用堆的向下调整算法的使用前提

  • 若想将其调整为小堆,那么根结点的左右子树必须都为小堆。(排降序用小堆)
  • 若想将其调整为大堆,那么根结点的左右子树必须都为大堆。(排升序用大堆)

堆的向下调整算法的基本思想

  • 从根结点处的数据开始操作,选出根结点左右孩子中值较大的那一个。
  • 让较大的孩子与其父亲进行比较
    • 若孩子结点比父亲结点大,则该孩子结点与父亲结点的位置进行交换,并将原来大孩子的位置作为新的父亲结点(根结点)继续遍历向下调整。
    • 若孩子结点比父亲结点小,则不需要处理,整个树已经是大堆了。
//堆的向下调整算法(以建大堆为例)
//end表示数组中最后一个有效数据的下表,也就是size-1
//该函数第一个参数表示要操作的数组,后两个表示数组有效数据的下标0~end(size - 1)
AdjustDown(HPDataType* arr, int parent, int end)
{
	int child = parent * 2 + 1;
	//当子节点移动到末尾结束循环
	while (child <= end)
	{	
		//补充:小堆找左右孩子最小的;大堆找左右孩子最大的
		//找左右孩子最大的,准备与父节点交换位置
		//注意越界问题,数组下标不可以大于最后一位数据的下标
		if (child + 1 >= end && arr[child] < arr[child + 1])
		{
			//右孩子更大,改变child的值
			child++;
		}
		//交换位置
		if (arr[child] > arr[parent])
		{
			Swap(&arr[child], &arr[parent]);
			//坐标变换继续调整数据
			parent = child;
			child = parent * 2 + 1;
		}
		//换到中间位置已经形成堆结构
		else
		{
			break;
		}
	}
}

问题1:上面的基本思想是建立在左右子树均为大堆或者小堆的基础上,那么如何将一个任意树调整成堆呢(建堆过程)?

​ 只需要从倒数第一个非叶子结点开始执行堆的向下调整算法,从后往前按下标,依次为根结点去向下调整即可。

//建堆(向下调整算法建(调整)堆)
//(size-1-1)/2指的是最后一个非叶子结点的数组下标,i--不断开始遍历每个节点进行调整
for(int i = (size-1-1)/2; i >= 0; i--)
{
	AdjustDown(arr, i, size - 1);
}

**问题2:**那么堆建好后,如何进行堆排序呢?

  • 将堆顶数据与堆的最后一个数据交换,然后对根结点位置进行一次堆的向下调整,但是调整时被交换到最后的那个最大的数不参与向下调整。
  • 完成步骤1后,这棵树除最后一个数之外,其余数又成一个大堆,然后又将堆顶数据与堆的最后一个数据交换,这样一来,第二大的数就被放到了倒数第二个位置上,然后该数又不参与堆的向下调整……反复执行下去,直到堆中只有一个数据时便结束。此时该序列就是一个升序。
//堆排序(大堆 --- 升序)
//因为如果需要实现堆排序,这需要先创建堆结构存放n个数据,空间复杂度为O(n)
//现在需要让堆排序空间复杂度为O(1),可以直接将对传来的数组进行操作
void HeapSort(int* arr, int size)
{
	//建堆(向下调整算法建(调整)堆)
	//(size-1-1)/2指的是最后一个非叶子结点的数组下标,i--不断开始遍历每个节点进行调整
	for(int i = (size-1-1)/2; i >= 0; i--)
	{
		AdjustDown(arr, i, size - 1);
	}


	//排升序 --- 大堆
	// 循环将堆顶数据跟最后位置(会发生变化,每交换一次位置-1)的数据交换
	int end = size - 1;//最后一个数据的下标

	//走到堆底则停止调整
	while (end > 0)
	{
		Swap(&arr[0], &arr[end]);//交换,此时堆顶元素为一开始数组中的最后一个数据
		//向下调整堆(从0调整到end)
		AdjustDown(arr, 0, end);
		//数组中最后一个已经是最小,故排出排序范围
		end--;
	}

	//排升序 --- 大堆 
	//排降序和升序逻辑一致,只需要更改AdjustDown和AdjustUp中的大小于号即可
}

时间复杂度: O ( N l o g N ) O(NlogN) O(NlogN)  空间复杂度: O ( 1 ) O(1) O(1)

补充:

  • 因为堆的底层结构是数组,然后无论是堆的向下调整算法还是堆排序,都是直接对乱序数组进行直接操作,所以所谓的建堆,其实不是把数组中的数据一个个插入堆中,而是将已经存在在堆中但是是乱序的数据,重新调整排列,使其满足大/小堆的结构。

3.3 交换排序

基本思想

所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置。

交换排序的特点是:将键值较大的向序列的尾部移动,键值较小的记录向序列的前部移动。

3.3.1 冒泡排序
3.3.1.1 示例代码
//冒泡排序
//时间复杂度:0(N^2)
void BubbleSort(int* arr, int n)
{
	//外层循环控制整个排序过程的遍历次数,确保每次排序(从乱序到有序算一次)数组中的每个数据都可以被遍历
	for (int i = 0; i < n; i++)
	{
		int exchange = 0;//记录该趟冒泡排序是否进行过交换
		//内层循环负责在当前轮次中进行相邻元素的比较和交换
		for (int j = 0; j < n - i - 1; j++)
		{
			//升序
			if (arr[j] < arr[j + 1])
			{
				exchange = 1;
				Swap(&arr[j], &arr[j + 1]);
			}
		}
		if (exchange == 0)//该趟冒泡排序没有进行过交换,已有序
		{
			break;
		}
	}
}

时间复杂度: O ( N 2 ) O(N^2) O(N2)  空间复杂度: O ( 1 ) O(1) O(1)

3.3.1.2 思路详解

利用两层for循环实现遍历次数的控制和每次遍历的大小比较、元素交换,从而实现冒泡排序。

  • 外层for循环:控制遍历次数,一次调整好一个数据故遍历n次。
  • 内存for循环:在此次遍历的乱序数组中,每两个数据都比较,将大的放后面,直到将乱序序列中的最大值挑选出并放到最后一个数据位置。完成一个完整的内部循环,回到外层循环,i++继续开始新的遍历。
3.3.2 快速排序(递归版)

快速排序是Hoare与1962年提出的一种二叉树结构的交换排序方法。

基本思想:

​ 任取待排序元素序列的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后左右子序列重复该过程,直到所有元素都排序在对应位置上为止。

如何按照基准值将待排序列分为两子序列,常见方法:

  1. Hoare版本
  2. 挖坑法
  3. lomuto前后指针法

以下不同版本的仅仅是利用不同的方式得到基准值下标并实现将基准值到达目标位置的子函数_QuickSort的编写方式的不同。

如上解释,因为快速排序核心思想就是利用递归的方法遍历整个乱序序列,所以其主函数框架在下面进行展示;至于子函数的三种实现方式,在下面会进行一一介绍。

QuickSort函数的实现

  • 函数功能:

    • 完成整个乱序序列的排序。
  • 函数示例:

    void QuickSort(int* arr, int left, int right)
    {
        //保证下标有效
    	if (left >= right)
    	{
    		return;
    	}
        
    	//[left,right]--->找基准值keyi
    	int keyi = _QuickSort(arr, left, right);
        
    	//左子序列:[left,keyi-1]
    	QuickSort(arr, left, keyi - 1);
    	//右子序列:[keyi+1,right]
    	QuickSort(arr, keyi + 1, right);
    }
    

    时间复杂度: O ( N l o g N ) O(NlogN) O(NlogN) 空间复杂度: O ( N l o g N ) O(NlogN) O(NlogN)

  • 逻辑补充:

    • 首先保证函数参数的leftright有意义。
    • 用函数_QuickSort,找到第一趟排序基准值,记录下来,根据基准值的位置,将序列分成两个子序列。
    • 循环递归调用QuickSort,遍历左右子序列,直到序列有序。
3.3.2.1 Hoare版本

代码思路:

  • _QuickSort函数的实现

    • 函数功能:

      • 用于实现单趟排序,也就是调用一次此函数即keyi可到达自己的目标位置。
    • 函数参数:

      • int arr*:待排序的数组
      • int left, int right:数组中的待排区间的左右下标标记,后续left从左向右走,right从右向左走
    • 函数逻辑:

      • 选取待排区间的最左侧,作为基准值(keyi),即int keyi = left;
      • left右移一位,基准值不需要遍历。即++left;
      • 开始遍历while (left <= right),因为一开始选择最左边的数据作为keyi,则需要right先走;若选择最右边的数据作为keyi,则需要left先走
      • 在走的过程中,若right遇到小于keyi的数,则停下,left开始走,直到left遇到一个大于keyi的数时,停止,将rightleft下标所指向的数据交换;然后right再次开始走,如此进行下去,直到leftright最终相遇,此时将相遇点的内容与keyi交换即可。
      • 同时此时交换后的基准值的位置,就是此数据在数组中应该所处的位置。
    • 函数示例:

      //QuickSort子函数,进行单趟的排序
      int _QuickSort(int* arr, int left, int right)
      {
          //定义基准点
      	int keyi = left;
      	++left;
      
      	while (left <= right)
      	{
              //right先走,找小于基准值的数
      		while (left <= right && arr[right] > arr[keyi])
      		{
      			right--;
      		}
      		//left后走,找大于基准值的数
      		while (left <= right && arr[left] < arr[keyi])
      		{
      			left++;
      		}
      		//right left
      		if (left <= right)
      		{
      			Swap(&arr[left++], &arr[right--]);
      		}
      	}
      	//right keyi交换
      	Swap(&arr[keyi], &arr[right]);
      
      	return right;
      }
      
    • 逻辑详解:

      • while (left <= right):包含=是因为left和right相遇的位置的值可能比基准值要大,如果此时跳出循环交换基准值和right指向的值,此时基准值的左子树中就存在一个大于基准值的值。

      • while (left <= right && arr[right] > arr[keyi])

        1. 这里介绍了right停下的两种原因:①检查到小于基准值的值;②遍历到left左边。只要不是这两种情况,right应该要不断左移。

        2. 循环前面的限制原因同补充1,第二个限制条件不加=,是为了解决在二分时,效率过低的问题。

          **eg:**待排序序列中全是同一个元素,这里若加上等号,right就会一直--直到与left重合,但是此时交换基准值,才会移动一格子。无法实现有效的数据分割。即遇到

      • while (left <= right && arr[left] < arr[keyi])

        1. 这里介绍了left停下的两种原因:①检查到大于基准值的值;②遍历到right右边。只要不是这两种情况,left应该要不断右移。

        2. 循环前面的限制原因同补充1,第二个限制条件不加=,是为了解决在二分时,效率过低的问题。

      • if (left <= right):经过上面两层循环,此时leftright都已经停下,这里的停下有两种情况:

        1. left和right没有相遇,正常的交换调整数据,然后继续移位遍历

        2. left和right相遇,先原地交换一下,然后在遍历,使得left > right,这样一方面可以跳出while循环,表示已经完成一趟排序,还可以将right重新指向一个小于基准值的值,要不然如果相遇值大于大于或者等于基准值就跳出while循环就会造成基准值的左子树中就存在一个大于基准值的值。

      • Swap(&arr[keyi], &arr[right]):将基准值keyi移到正确位置,此交换语句仅交换两个数组中的数据,此时的keyi仍是左边第一个数据的下标,right此时才是基准值数据的数组下标。

      • return right:函数的返回值是基准值所在下标。

3.3.2.2 挖坑版

代码思路:

  • _QuickSort函数的实现

    • 函数示例:

      //挖坑法
      int _QuickSort2(int* arr, int left, int right)
      {
          // 挖坑+记录基准值
      	int hole = left;
      	int key = arr[hole];
      
          //开始遍历
      	while (left < right)
      	{
              //right向左,找小
      		while (left < right && arr[right] > key)
      		{
      			--right;
      		}
      		arr[hole] = arr[right];
      		hole = right;
      
              //left向右,找大
      		while (left < right && arr[left] < key)
      		{
      			++left;
      		}
      		arr[hole] = arr[left];
      		hole = left;
      	}
          //left和right相遇
      	arr[hole] = key;//将基准值填入坑位
      	return hole;//返回此时基准值下标
      }
      
    • 逻辑详解:

      • int hole = left; int key = arr[hole];:首先在left位置挖坑并将坑中的数值记录下来,作为标准值。这里就像将left位置的土(数据),挖出装进key的车上(基准值)。
      • while (left < right):开始循环遍历序列,直到leftright相遇。
      • while (left < right && arr[right] > key)right循环遍历,跳出循环有两个情况:
        1. arr[right] <= key,检测到小于等于基准值的数据,此时right停下,此时将arr[right]中的土(数据),挖出填到arr[hole]的坑中,在将此时下标right标记为新的坑。
        2. left = right,此时leftright相遇,停止遍历,将相遇位置的土(数据),填到坑的位置上去,再将相遇位置标记为新的坑。
      • while (left < right && arr[left] < key)left循环遍历语句,和上述逻辑一致,不做过多赘述。
      • arr[hole] = key; return hole;leftright相遇时的调整语句,将原先存在装进key的车上的标准值的土,填入相遇位置的坑中。这个位置就是标准值的目标位置,最后将目标位置的返回回去。
3.3.2.3 lomuto前后指针法

基本步骤:

  • 将序列最左边数据作为基准值key
  • 创建两个指针,prev指向序列开头,cur指针指向prev+1
  • 若cur指向的内容小于key,则prev先移动一位并且不等于cur,然后交换prev和cur指针指向的内容,然后cur指针++;若cur指向的内容大于key,则cur指针直接++后移
  • 如此进行下去,直到cur指针越界,此时将key和prev指针指向的内容交换即可

函数示例:

//lomuto前后指针法
int _QuickSort3(int* arr, int left, int right)
{
    //前后指针和基准值的初始化
	int prev = left, cur = left + 1;
	int key = left;

    //开始遍历序列
	while (cur <= right)
	{
        //cur的内容小于基准值并且prev+1不等于cur交换数据
		if (arr[cur] < arr[key] && ++prev != cur)
		{
			Swap(&arr[cur], &arr[prev]);
		}
        //继续后移遍历
		cur++;
	}
    //将基准值移到目标位置
	Swap(&arr[key], &arr[prev]);

	return prev;
}
3.3.3 快速排序(非递归版)

​ 因为快速排序是根据二叉树结构的基础上进行排序,所以就涉及了函数的递归思想,但是因为涉及函数递归就需要函数栈帧的创建,每创建一个函数栈帧就相当于将一个数据移动到目标位置。但是如果序列中数据极大,没有那么大的空间创建足够的函数栈帧,可能就会出现栈溢出的情况

​ 所以下面会介绍快速排序的非递归版本的相关内容。而非递归版本的快速排序需要借助数据结构:

​ 因为上面已经将Hoare版本、挖坑法和前后指针法的单趟排序单独封装起来。此时只需要更改QuickSort函数的内部递归逻辑改为非递归逻辑,再在QuickSort函数内部调用单趟排序的函数即可。

基本步骤:

  • 先将待排序列的第一个元素的下标和最后一个元素的下标入栈。
  • 当栈不为空时,读取栈中的信息(一次读取两个:一个是begin,另一个是end),然后调用某一版本的单趟排序,排完后获得了基准值的下标,然后判断基准值的左序列和右序列是否还需要排序,若还需要排序**(左右序列区间有意义),就将相应序列的beginend入栈;若不需排序了(左右序列区间无意义或者左右区间相同)**,就不需要将该序列的信息入栈。
  • 反复执行步骤2,直到栈为空为止。

示例代码:

//非递归版本快排
//--借助数据结构--栈
void QuickSortNonR(int* arr, int left, int right)
{
	ST st;
	STInit(&st);
	StackPush(&st, right);
	StackPush(&st, left);

	while (!StackEmpty(&st))
	{
		//取栈顶元素---取两次
		int begin = StackTop(&st);
		StackPop(&st);

		int end = StackTop(&st);
		StackPop(&st);
        
		//[begin,end]---找基准值
		keyi = _QuickSort(arr, begin, end);
        
		//根据基准值划分左右区间
		//左区间:[begin,keyi-1]
		//右区间:[keyi+1,end]
		if (keyi + 1 < end)
		{
			StackPush(&st, end);
			StackPush(&st, keyi + 1);
		}
		if (keyi - 1 > begin)
		{
			StackPush(&st, keyi - 1);
			StackPush(&st, begin);
		}
	}

	STDestroy(&st);
}

代码详解:

由递归版本代码改为为非递归主要是对于左右序列的区间如何获取的方式不同。

  •   ST st;
      STInit(&st);
      
      STDestroy(&st);
    

    非递归版本需要借用数据结构–栈,创建栈、初始化栈、销毁栈为固定操作。

  •   StackPush(&st, right);
      StackPush(&st, left);
    

    将乱序区间的左右序列下标入栈。

  •   while (!StackEmpty(&st))
      {
          
      }
    

    利用循环检测栈里面有没有有效区间的方式,代替函数的递归。

  •   //取栈顶元素---取两次
      int begin = StackTop(&st);
      StackPop(&st);
      
      int end = StackTop(&st);
      StackPop(&st);
    

    取两次栈顶数据,作为单趟排序函数的左右区间参数。

  •   //[begin,end]---找基准值
      keyi = _QuickSort(arr, begin, end);
    

    参考上文三中单趟排序的子函数,任选一种单趟排序找基准值。

  •   //根据基准值划分左右区间
      //左区间:[begin,keyi-1]
      //右区间:[keyi+1,end]
      if (keyi + 1 < end)
      {
      	StackPush(&st, end);
      	StackPush(&st, keyi + 1);
      }
      if (keyi - 1 > begin)
      {
      	StackPush(&st, keyi - 1);
      	StackPush(&st, begin);
      }
    

    找到基准之后重新划分左右区间。但是在插入栈之前需要保证两个条件:

    • 左右区间为有效区间,起点位置不能大于终点位置。
    • 区间中数据必须大于一个,起点位置不能和终点位置相同。
3.3.4 快排的深入优化
3.3.4.1 三数取中选key

​ 在快速排序中的每趟排序中,都是key占整个序列的位置,是影响快速排序效率最大的因素,将key越接近中间位置,则越可以增强快速快速排序的效率。因为这里引入了一个三数取中的思想解决这个问题。

基本思想:

所谓三数取中,就是取头,中,尾三个元素,比较大小,选择排在中间的数据作为基准值key。这执行快速排序,这就确保了所选取的数不会是序列中的最大或是最小值,这种算法可以一定程度提升快速排序的效率。

  • 首先将此功能封装为一个函数GetMidIndex将待排数组和数组的最左边数据下标和最后边数组下标传入函数。
  • 再通过左右位置下标确定中间位置下标,之后利用if-else,对三个数据进行比较,最后将大小处于中间的数据作为key返回。
// 快速排序--优化1---三数取中选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
	{
		if (a[mid] > a[right])
		{
			return mid;
		}
		else if (a[right] > a[left])
		{
			return left;
		}
		else
		{
			return right;
		}
	}
}

算法总结:

  • 在调用这个函数之后,就可以将中间值的下标返回给快速排序,之后再将此数据与起始的最左侧数据进行交换位置,之后进行正常的快速排序就可以了。此时的key就是GetMidIndex函数的返回值了

    // 快速排序--递归法
    void QuickSort(int* a, int begin, int end)
    {
    	if (begin >= end)
    	{
    		return;
    	}
     
    	int mid = GetMidIndex1(a, begin, end);
    	Swap(&a[begin], &a[mid]);        // 再交换中间值位置与最左边位置
     
    	int keyi = QuickSort(a, begin, end);
    	// [beign,keyi-1] keyi [keyi+1,end]
     
    	QuickSort(a, begin, keyi - 1);
    	QuickSort(a, keyi + 1, end);
    }
    
3.3.4.2 随机数选取key

​ 在选取key的时候已经有了上一个三数取中的思路,但是三数取中的思路可能有点死板,所取得值只能是固定位置,于是现在就有了基于三数取中的算法进行优化,即随机数算key法

基本思想:

随机数选key的和基本思想和三数取中一致,只是mid的值并不是由(left + right)/2得来的,而是由随机数生成而来,即 mid = left + (rand() % (right - left))

示例代码:

// 快速排序--优化2---随机数选key
int GetMidIndex2(int* a, int left, int right)
{
    //随机数获取中间值下标
	int mid = left + (rand() % (right - left));
	if (a[left] < a[mid])
	{
		if (a[mid] < a[right])
		{
			return mid;
		}
		else if (a[right] < a[left])
		{
			return left;
		}
		else
		{
			return right;
		}
	}
	else
	{
		if (a[mid] > a[right])
		{
			return mid;
		}
		else if (a[right] > a[left])
		{
			return left;
		}
		else
		{
			return right;
		}
	}
}
3.3.4.3 三路划分

​ 在递归版的快速排序中,当待排序列中含有多个相同元素的时候,单纯的使用普通的快速排序的优势就不是那么明显了。这时候使用三路划分就可以很好的解决这个问题。

图片理解:

在这里插入图片描述

基本思想:

​ 当面对有大量和key(基准值)相同的值的时候,三路划分的核心思想有点类似于hoare的左右指针和lomuto的前后指针的结合。核心思想是把数组分为三段比key小的值跟key相等的值比key大的值,所以叫做三路划分算法。结合下图具体阐述实现思想:

在这里插入图片描述

  1. key默认取left位置的值。
  2. left指向区间最左边,right指向区间最后面,cur指向left + 1的位置。
  3. 开始遍历比较,调整序列
    1. 情况①:cur遇到比key小的值后跟left位置交换,换到左边,然后left++,cur++
    2. 情况②:cur遇到比key大的值后跟right位置交换,换到右边,然后right–
    3. 情况③:cur遇到跟key相等的值之后,cur++
  4. 直到cur > right结束

示例代码:

// 快速排序--递归法---三路划分法
//begin:待排序列最左侧数据下标
//end:待排序列最右侧数据下标
void QuickSort(int* a, int begin, int end)
{
    //保证序列有意义
	if (begin >= end)
	{
		return;
	}
 
    //初始化
	int left = begin;
	int right = end;
	int key = a[left];
	int cur = left + 1;
 
    //开始遍历比较
	while (cur <= right)
	{
        //情况1:大的放到右边,由于交换过来的cur不确定,故cur不移动,进行下一轮检测
		if (a[cur] > key)
		{
			Swap(&a[cur], &a[right]);
			--right;
		}
        //情况2:小的放左边,此时的交换值是确定的,交换后left和cur均后移
		else if (a[cur] < key)
		{
			Swap(&a[left], &a[cur]);
			++left;
			++cur;
		}
        //情况3:与key相等的值推到中间,cur继续后移遍历
		else
		{
			++cur;
		}
	}
 
	// 此时的区间:[begin,left-1][left,right][righ+1,end]
 
    //递归左右乱序序列
	QuickSort(a, begin, left - 1);
	QuickSort(a, right + 1, end);
}

算法总结:

​ 三路划分的算法思想在解决序列中包含多个重复数据的时候效率很好,其他场景就比较一般。但是三路划分思路还是很有价值的,有些快排思想变形体,要用划分去选数,他能保证和key相等的数都排到中间去,三路划分的价值就可以提现出来了。

3.3.4.3 introsort的快速排序

​ 快排是会受到数据样本的分布和形态的影响的,所以经常有的时候遇到一些特殊数据的数组,如大量重复数据的数组,因为快排需要选key,每次key都选到重复数据就会导致key将序列分割的很偏,就会出现效率退化的问题。

​ 无论是hoare、lomuto还是三路划分他们的思想在特殊场景下效率会退化,比如在选key的时候大多数都选择了接近最小或者最大值,导致划分不均衡,效率退化。

​ 因为以上情况的出现,所以introsort诞生了,introsort是由David Musser在1997年设计的排序算法,C++sgi STLsort中就是用的 introspectivesort (内省排序) 思想实现的。内省排序可以认为不受数据分布的影响,无论什么原因划分不均匀,导致递归深度太深,他就是转换堆排了,堆排不受数据分布影响。

基本思路:

  • 如果快排递归深度太深**(sgi stl 中使用的是深度为2倍元素数量的对数值)**,也就是depth大于 2 ∗ l o g n 2*logn 2logn ,则说明在这种数据序列下,选key出现了问题,性能在快速退化,就不再进行快排分割递归了,改为堆排序。

示例代码:

/**
 * Note: The returned array must be malloced, assume caller calls free().
 */

//堆排序的实现
//交换函数
void Swap(int* x, int* y)
{
	int tmp = *x;
	*x = *y;
 	*y = tmp;
}

//堆的向下调整算法
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[child], &a[parent]);
 			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[end], &a[0]);
 		AdjustDown(a, end, 0);
 		--end;
 	}
}

//直接插入算法
void InsertSort(int* a, int n)
{
 	for (int i = 1; i < n; i++)
 	{
 		int end = i-1;
 		int tmp = a[i];
 		// 将tmp插⼊到[0,end]区间中,保持有序 
 		while (end >= 0)
 		{
 			if (tmp < a[end])
 			{
 				a[end + 1] = a[end];
 				--end;
 			}
 			else
 			{
 				break;
 			}
 		}
 		a[end + 1] = tmp;
 	}
}

//Introsort排序
void IntroSort(int* a, int left, int right, int depth, int defaultDepth)
{
    //保证序列有效
 	if (left >= right)
 	return;
 
 	// 数组长度⼩于16的⼩数组,换为插⼊排序,简单递归次数 
 	if(right - left + 1 < 16)
 	{
 		InsertSort(a+left, right-left+1);
 		return; 
 	}
    
 	// 当深度超过2*logN时改用堆排序 
 	if(depth > defaultDepth)
 	{
 		HeapSort(a+left, right-left+1);
 		return;
 	}
 	depth++;
    
    //常规情况使用lomuto快速排序
    //下标初始化
 	int begin = left;
 	int end = right;
    
 	// 随机选key 
 	int randi = left + (rand() % (right-left + 1));
 	Swap(&a[left], &a[randi]);
    
    //参量初始化
 	int prev = left;
 	int cur = prev + 1;
 	int keyi = left;
 	while (cur <= right)
 	{
 		if (a[cur] < a[keyi] && ++prev != cur)
 		{
 			Swap(&a[prev], &a[cur]);
 		}
 		++cur;
 	}
 	Swap(&a[prev], &a[keyi]);
 	keyi = prev;
    
    //遍历左右乱序序列
	// [begin, keyi-1] keyi [keyi+1, end]
 	IntroSort(a, begin, keyi - 1, depth, defaultDepth);
 	IntroSort(a, keyi+1, end, depth, defaultDepth);
}

//快速排序入口函数
void QuickSort(int* a, int left, int right)
{
    //初始化参数
 	int depth = 0;		//参数
 	int logn = 0;		//数组大小 N 的对数(基数为2)
 	int N = right-left+1;//数组大小
    
    //求数组长度N的是2的多少次方,求将数组放入二叉树的层数
 	for(int i = 1; i < N; i *= 2)
 	{
 		logn++;
 	}

 	// introspective sort -- ⾃省排序 
 	IntroSort(a, left, right, depth, logn*2);
}

//对整数数组 nums 进行排序,并返回排序后的数组
int* sortArray(int* nums, int numsSize, int* returnSize)
{
    //随机数取key
 	srand(time(0));
 	QuickSort(nums, 0, numsSize-1);
 	*returnSize = numsSize;
 	return nums;
}

算法总结:

  • 本质上就是根据数据的实时情况,选择不同的排序算法,增强程序的效率和适应性。

3.4 归并排序

基本思想:

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

3.4.1 思路详解

基本思想的核心总结:将已有序的子序列合并,从而得到完全有序的序列,即先让每个子序列有序,再使子序列段间有序。

  • 分解:

    先将乱序序列通过 m i d = ( r i g h t + l e f t ) / 2 mid = (right + left)/2 mid=(right+left)/2 获取中间位置的下标,将序列从中间mid开始分为两个序列,再不断递归循环,直到将数据分为一个个零散的数据(此时这个数据可以看做一个left等于right的序列)

  • 合并:

    每次合并都是两个有序序列进行合并。

    对于要合并的两个序列用begin1end1begin2end2分别来表示这两个序列的首位数据。之后再利用两个序列的begin下标不断遍历序列按顺序循环取出较小的数据,放入临时数组tmp中,最后利用end作为截止循环的条件,最后将完整的有序数组tmp赋给arr

3.4.2 示例代码
//归并排序
//子函数
void _MergeSort(int* arr,int left,int right,int* tmp)
{
    //非有效区间
	if (left >= right)
	{
		return;
	}
    //找中间下标
	int mid = (left + right) / 2;
    
    //递归分割序列
	//左序列:[left,mid] 右序列:[mid+1,right]
	_MergeSort(arr, left, mid, tmp);
	_MergeSort(arr, mid + 1, right, tmp);


	//合并序列
	//左序列:[left,mid] 右序列:[mid+1,right]
	int begin1 = left, end1 = mid;
	int begin2 = mid + 1, end2 = right;
    
    //用于指示tmp数组数据下标
	int index = left;

    //合并两个有序序列
	while (begin1 <= end1 && begin2 <= end2)
	{
		if (arr[begin1] < arr[begin2])
		{
			tmp[index++] = arr[begin1++];
		}
		else {
			tmp[index++] = arr[begin2++];
		}
	}
	//要么begin1越界  要么begin2越界
	while (begin1 <= end1)
	{
		tmp[index++] = arr[begin1++];
	}
	while (begin2 <= end2)
	{
		tmp[index++] = arr[begin2++];
	}

	//把tmp中的数据拷贝回arr中
	for (int i = left; i < right; i++)
	{
		arr[i] = tmp[i];
	}
}

//主函数
void MergeSort(int* arr, int n)
{
    //动态创建一个临时数组,用于排序
	int* tmp = (int*)malloc(sizeof(int) * n);

    //执行子函数
	_MergeSort(arr, 0, n - 1, tmp);

	free(tmp);
}

时间复杂度: O ( N l o g N ) O(NlogN) O(NlogN)  空间复杂度: O ( N ) O(N) O(N)

3.4.4 知识补充:文件排序

​ 以上介绍了很多种排序算法,但是基本都是基于内排序,均是基于内存中的进行的,但是在排序中面对数量极多的序列,上述的排序算法都是无法实现的,但是归并排序是可以实习的。

下面先简单介绍一下内排序外排序

  • **内排序:**数据量相对少一些,可以放到内存中进行排序。
  • **外排序:**数据量较大,内存中放不下,数据只能放到磁盘文件中,需要排序。

基本思路:

假设现在有10亿个整数(4GB)存放在文件A中,需要我们进行排序,而内存一次只能提供512MB空间,归并排序解决该问题的基本思路如下:

合并思路1:

  1. 每次从文件A中读取八分之一,即512MB到内存中进行排序(内排序),并将排序结果写入到一个文件中,然后再读取八分之一,重复这个过程。那么最终会生成8个各自有序的小文件(A1~A8)。
  2. 对生成的8个小文件进行11合并,最终8个文件被合成为4个,然后再11合并,就变成2个文件了,最后再进行一次11合并,就变成1个有序文件了。

合并思路2:

  1. 每次从文件A中读取八分之一,即512MB到内存中进行排序(内排序),并将排序结果写入到一个文件中,然后再读取八分之一,重复这个过程。那么最终会生成8个各自有序的小文件(A1~A8)。
  2. 对生成的8小文件,按顺序第一次先归并排序前两个小文件,形成一个新的文件,再将新的文件和第三个小文件进行归并排序,不断合并,最后变成一个有序文件。

在这里插入图片描述

示例代码:

下面的示例代码,是按照合并思路2编写:

#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include<time.h>
#include<stdlib.h>

// 创建N个随机数,写到文件中
void CreateNDate()
{
    // 创造一千万个数据
    int n = 10000000;
    srand(time(0));
    const char* file = "data.txt";
    FILE* fin = fopen(file, "w");
    if (fin == NULL)
    {
        perror("fopen error");
        return;
    }

    //将生成的一千万个数据写进“data.txt”
    for (int i = 0; i < n; ++i)
    {
        int x = rand() + i;
        fprintf(fin, "%d\n", x);
    }

    fclose(fin);
}

//作为qsort()函数的比较回调函数,用于控制qsort是排升序还是降序
int compare(const void* a, const void* b)
{
    return (*(int*)a - *(int*)b);
}

//函数功能:从一个输入文件读取整数数据,将它们排序后写入另一个文件
//函数参数:FILE* fout:输入文件指针(从这里读取数据)
//        const char* file1:输出文件名(排序后的数据将写入此文件)
// 函数返回值:返回实际读到的数据个数,没有数据了,返回0
int ReadNDataSortToFile(FILE* fout, int n, const char* file1)
{
    int x = 0;
    //用于在内存中存放文件中的数据
    int* a = (int*)malloc(sizeof(int) * n);
    if (a == NULL)
    {
        perror("malloc error");
        return 0;
    }

    // 想读取n个数据,如果遇到文件结束(最后读取的个数不满n个),应该读到j个
    int j = 0;
    for (int i = 0; i < n; i++)
    {
        //将文件数据读取到内存,并在内存申请的空间中进行排序
        if (fscanf(fout, "%d", &x) == EOF)
            break;

        a[j++] = x;
    }
    if (j == 0)
    {
        free(a);
        return 0;
    }

    // 对内存中的数据进行排序,利用C语言内置qsort函数
    qsort(a, j, sizeof(int), compare);

    FILE* fin = fopen(file1, "w");
    if (fin == NULL)
    {
        free(a);
        perror("fopen error");
        return 0;
    }

    // 写回file1文件
    for (int i = 0; i < j; i++)
    {
        fprintf(fin, "%d\n", a[i]);
    }

    free(a);
    fclose(fin);

    return j;
}

//对文件进行排序归并,将file1和file2归并到mfile中
void MergeFile(const char* file1, const char* file2, const char* mfile)
{
    FILE* fout1 = fopen(file1, "r");
    if (fout1 == NULL)
    {
        perror("fopen error");
        return;
    }

    FILE* fout2 = fopen(file2, "r");
    if (fout2 == NULL)
    {
        perror("fopen error");
        return;
    }

    FILE* mfin = fopen(mfile, "w");
    if (mfin == NULL)
    {
        perror("fopen error");
        return;
    }

    // 归并逻辑
    int x1 = 0;
    int x2 = 0;
	//从两个文件中不断读取数据
    int ret1 = fscanf(fout1, "%d", &x1);
    int ret2 = fscanf(fout2, "%d", &x2);
    //当两个文件中有一个文件遇到文件末尾则跳出循环
    while (ret1 != EOF && ret2 != EOF)
    {
        if (x1 < x2)
        {
            fprintf(mfin, "%d\n", x1);
            ret1 = fscanf(fout1, "%d", &x1);
        }
        else
        {
            fprintf(mfin, "%d\n", x2);
            ret2 = fscanf(fout2, "%d", &x2);
        }
    }

    //文件2遇到文件末尾
    while (ret1 != EOF)
    {
        fprintf(mfin, "%d\n", x1);
        ret1 = fscanf(fout1, "%d", &x1);
    }

    //文件1遇到文件末尾
    while (ret2 != EOF)
    {
        fprintf(mfin, "%d\n", x2);
        ret2 = fscanf(fout2, "%d", &x2);
    }

    fclose(fout1);
    fclose(fout2);
    fclose(mfin);
}

int main()
{
    //创造待排数据
    CreateNDate();

    //设置
    const char* file1 = "file1.txt";
    const char* file2 = "file2.txt";
    const char* mfile = "mfile.txt";

    //"data.txt"用于存储创造了一千万个数据
    FILE* fout = fopen("data.txt", "r");
    if (fout == NULL)
    {
        perror("fopen error");
        return;
    }
    
    //从data.txt中分别读取一百万个数据放进file1和file2中
    int m = 1000000;
    ReadNDataSortToFile(fout, m, file1);
    ReadNDataSortToFile(fout, m, file2);

    //开始循环归并文件
    while (1)
    {
        //将file1和file2归并到mfile中
        MergeFile(file1, file2, mfile);

        // 删除file1和file2
        remove(file1);
        remove(file2);

        // 重命名mfile为file1,将mfile当做新的file1继续循环
        rename(mfile, file1);

        // 当再去读取数据,一个都读不到,说明已经没有数据了,说明已经归并完成,归并结果在file1
        int n = 0;
        //此语句的功能1:从原始数据文件data.txt中读取新的数据块创建新的file2,继续与新的file1归并
        //此语句的功能2:用来检测是否已经到data.txt的数据末尾,看是否需要结束循环
        if ((n = ReadNDataSortToFile(fout, m, file2)) == 0)
            break;

        //调试语句,可以此处打断点观察最后一次不满n个数据时的排序结果
        /*if (n < 100)
        {
            int x = 0;
        }*/
    }

	return 0;
}

3.5 非比较排序

3.5.1 计数排序

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

  1. 统计相同元素出现次数
  2. 根据统计的结果将序列回收到原来的序列中
3.5.1.1 图片理解

​ 上图这种映射关系是绝对映射,即arr数组中的元素是几就在count数组中下标为几的位置++,但这样会造成空间浪费。例如,要将数组:1020,1021,1018,进行排序,难道需要开辟1022个整型空间吗?

​ 所以在计数排序中,一般使用相对映射。即**数组中的最小值就相对于count数组中的0下标,数组中的最大值就相对于count数组中的最后一个下标。**这样,对于数组:1020,1021,1018,就只需要开辟用于储存4个整型的空间大小了,此时count数组中下标为i的位置记录的实际上是1018+i这个数出现的次数。

综上所述:

  • 绝对映射:count数组中下标为i的位置记录的是arr数组中数字i出现的次数。
  • 相对映射:count数组中下标为i的位置记录的是arr数组中数字min+i出现的次数。
3.5.1.2 代码示例
//计数排序
void CountSort(int* arr, int n)
{
	//根据最大值最小值确定数组大小(相对映射)
	int max = arr[0], min = arr[0];
    //打擂台,遍历数组确定最大值与最小值
	for (int i = 1; i < n; i++)
	{
		if (arr[i] > max)
		{
			max = arr[i];
		}
		if (arr[i] < min)
		{
			min = arr[i];
		}
	}
    //确定数组大小,并申请对应空间
	int range = max - min + 1;
	int* count = (int*)malloc(sizeof(int) * range);
	if (count == NULL)
	{
		perror("malloc fail!");
		exit(1);
	}
	//初始化range数组中所有的数据为0
	memset(count, 0, range * sizeof(int));

	//统计数组中每个数据出现的次数
	for (int i = 0; i < n; i++)
	{
		count[arr[i] - min]++;
	}
	//取count中的数据,往arr中放
	int index = 0;
	for (int i = 0; i < range; i++)
	{
		while (count[i]--)
		{
			arr[index++] = i + min;
		}
	}
}

时间复杂度: O ( N + r a n g e ) O(N+range) O(N+range)  空间复杂度: O ( r a n g e ) O(range) O(range)

3.6 测试代码:排序性性能的对比

为了更加直观地显示各个排序的性能和排序速度,下面的代码通过生成十万个随机数,并利用上述排序算法对这个十万个随机数的乱序序列进行排序,并记录下时间,打印在屏幕上。

// 测试排序的性能对⽐  
void TestOP() 
{ 
    //生成十万个随机数
 	srand(time(0)); 
 	const int N = 100000; 

    //开辟承载十万个随机数的数组
 	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(); 
 	QuickSort(a5, 0, N-1); 
 	int end5 = clock(); 
 
    //归并排序
 	int begin6 = clock(); 
 	MergeSort(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("QuickSort:%d\n", end5 - begin5); 
 	printf("MergeSort:%d\n", end6 - begin6); 
 	printf("BubbleSort:%d\n", end7 - begin7); 
 
    //释放申请的空间
 	free(a1); 
 	free(a2); 
 	free(a3); 
 	free(a4); 
 	free(a5); 
 	free(a6); 
 	free(a7); 
} 

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

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

排序方法平均情况最好情况最坏情况辅助空间稳定性
冒泡排序 O ( n 2 ) O(n²) O(n2) O ( n ) O(n) O(n) O ( n 2 ) O(n²) O(n2) O ( 1 ) O(1) O(1)稳定
直接选择排序 O ( n 2 ) O(n²) O(n2) O ( n 2 ) O(n²) O(n2) O ( n 2 ) O(n²) O(n2) O ( 1 ) O(1) O(1)不稳定
直接插入排序 O ( n 2 ) O(n²) O(n2) O ( n ) O(n) O(n) O ( n 2 ) O(n²) O(n2) O ( 1 ) O(1) O(1)稳定
希尔排序 O ( n l o g n ) ∼ O ( n 2 ) O(n log n) \sim O(n²) O(nlogn)O(n2) O ( n l o g n ) ∼ O ( n 2 ) O(n log n) \sim O(n²) O(nlogn)O(n2) O ( n 2 ) O(n²) O(n2) O ( 1 ) O(1) O(1)不稳定
堆排序 O ( n l o g n ) O(n log n) O(nlogn) O ( n l o g n ) O(n log n) O(nlogn) O ( n l o g n ) O(n log n) O(nlogn) O ( 1 ) O(1) O(1)不稳定
归并排序 O ( n l o g n ) O(n log n) O(nlogn) O ( n l o g n ) O(n log n) O(nlogn) O ( n l o g n ) O(n log n) O(nlogn) O ( n ) O(n) O(n)稳定
快速排序 O ( n l o g n ) O(n log n) O(nlogn) O ( n l o g n ) O(n log n) O(nlogn) O ( n 2 ) O(n²) O(n2) O ( l o g n ) ∼ O ( n ) O(log n) \sim O(n) O(logn)O(n)不稳定

本文中有多个插图取自前辈2021dragon爱写代码的捣蛋鬼
感谢前辈的知识分享,能让我们这些后来者可以站在巨人的肩膀上不断向前!!感谢阅读!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值