八大排序详解:动图、代码、注释

目录

何为八大排序?

直接插入排序

排序过程解读

直接插入排序的特性总结:

希尔排序

希尔排序的特性总结:

直接选择排序

直接选择排序的特性总结:

堆排序

直接选择排序的特性总结:

冒泡排序

快速排序

1.Hoare版本

2.挖坑法

前后指针法

递归版本

非递归版本

快速排序的特性总结:

归并排序

递归版

非递归版

归并排序的特性总结:

计数排序

计数排序的特性总结:


何为八大排序?


八大排序算法通常指的是在计算机科学中广泛使用的八种内部排序算法,这些算法包括:

插入排序。这是一种简单的排序算法,通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

希尔排序。也称为缩小增量排序,这是一种基于插入排序的算法,通过比较相隔一定间隔的元素来工作,然后逐渐减少间隔,直至1。
简单选择排序。这种算法通过在未排序序列中找到最小(或最大)元素,将其放到已排序序列的末尾。
冒泡排序。这种算法重复地遍历要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。
快速排序。这是一种分而治之的策略,通过一个分割点将数组分成两部分,然后递归地对这两部分进行快速排序。
归并排序。这种算法采用分治法,将数组分成两部分,分别对这两部分进行归并排序,然后将结果合并。
计数排序。这是一种线性时间复杂度的排序算法,适用于一定范围内的整数排序,它通过统计不同值的数量来进行排序。

堆排序。利用建堆的思想去排序。

选择排序。先选择出合理的数据,再进行排序。

本文将通过排序算法的复杂度讲解、算法解析及稳定性讲解,带你深入了解八大排序。

解释何为稳定性

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

直接插入排序

直接插入排序是一种简单的插入排序法,其基本思想是:
把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。
实际中我们玩扑克牌时,就用了插入排序的思想

在摸牌排序时,我们总是把一个随机的牌,插入到已经有序的手牌中,但是已经有序的手牌是如何有序的呢?可以理解为,第一次摸牌时,只有一张手牌,可以认为一张就是有序

排序过程解读

当插入第i(i>=1)个元素时,前面的array[0],array[1],…,array[i-1]已经排好序,此时用array[i]的排序码与 array[i-1],array[i-2],…的排序码顺序进行比较,找到插入位置即将array[i]插入,原来位置上的元素顺序后移
在动图中可以看到步骤为
1.摸牌(tmp)
2.从已经有序数组的end位置开始往前遍历,后移
3.找到合适的位置之后,插入

//插入排序 :默认有序,插入无序
void InsertSort(int* a, int n)
{	
	for (int i = 0; i < n - 1; i++)		//默认第一个有序,一共 n - 1 趟
	{
		int end = i;		//end 是下标
		int tmp = a[i + 1];

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

		a[end + 1] = tmp;

	}

}

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

满足条件后移,因为已经是一个有序数组,到位置直接break

        a[end + 1] = tmp;然后再插入
 

直接插入排序的特性总结:

1. 元素集合越接近有序,直接插入排序算法的时间效率越高
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1),它是一种稳定的排序算法
4. 稳定性:稳定
需要注意的是,最然是N^2的时间复杂度,但是相对于冒泡排序,确是性能非常好的排序算法。

希尔排序

希尔排序(Shell's Sort)是插入排序的一种,又称“缩小增量排序”(Diminishing Increment Sort),是直接插入排序算法的一种更高效的改进版本。该方法因D.L.Shell于1959年提出而得名。

其核心要点是 预排序与直接插入排序的结合。假定一个增量gap,让数组分为gap组,先对每组进行排序(预排序),使原数组接近于有序,再对整体排序。
基本思想是:
先选定一个整数gap,把待排序文件中所有记录分成个组,所有距离为的记录分在同一组内,并对每一组内的记录进行排序。然后,取,重复上述分组和排序的工作。当到达=1时,所有记录在统一组内排好序。

void ShellSort(int* a, int n)		//希尔排序(缩小增量排序)
{
	int gap = n;

	while (gap > 1) //最后一次循环结束,gap等于1
	{
		gap = gap / 3 + 1;

		for (int i = 0; i < n - gap; i++)			//类比直接插入排序
		{
			int end = i;
			int tmp = a[i + gap];

			while (end >= 0)		//挪动
			{
				if (a[end] > tmp)
				{
					a[end + gap] = a[end];
					end -= gap;
				}
				else
					break;
			}

			a[end + gap] = tmp;			//挪完,插入

		}


	}

}

gap先被初始化为n,在第一次进行预排序时gap = gap / 3 + 1,此后每一次排序,gap都在不断缩小。

对于gap的取值,有对应的结论:当gap越大时,排序的时间短,但是越接近无需;gap越小时,排序时间长,但是越接近有序。

设置循环的控制条件时,i < n - gap是因为防止tmp = a[i + gap]发生越界。

a[end + gap] = a[end];        //此句体现后移

类比插入排序,便可以发现,只是把插入排序中的“1”换成了gap

需要注意的是,当gap = gap/ 3 + 1时,可以使最后的gap一定为1

希尔排序的特性总结:


1. 希尔排序是对直接插入排序的优化。
2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些树中给出的
希尔排序的时间复杂度都不固定

笔者也去查阅了许多资料,目前市面上也是众说纷纭,一般认为是O(N^1.3)

直接选择排序

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

排序过程:

在元素集合array[i]--array[n-1]中选择关键码最大(小)的数据元素若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换在剩余的array[i]--array[n-2](array[i+1]--array[n-1])集合中,重复上述步骤,直到集合剩余1个元素。

可以从图中看到,需要进行多趟遍历,不断选出最大最小值,进行排序。


void SelectSort(int* a, int n)
{
	int begin = 0;		//通常用下标记录数据,以便随机访问
	int end = n - 1;

	while (begin < end)
	{
		int maxi = begin;		//每次都需要重建maxi 与 mini,需要封装在while内部	//不能初始化为 0 ,每次都要从begin处开始
		int mini = begin;

		for (int i = begin; i <= end; i++)		//再begin 和 end(包含end,是 <= )这个区间查找
		{
			if (a[i] > a[maxi])
				maxi = i;

			if (a[i] < a[mini])
				mini = i;
		}

		Swap(&a[begin], &a[mini]);

		if (begin == maxi)		// == 不是 =
			maxi = mini;

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

		begin++;
		end--;
	}
}

        需要定义一段区间,区间从两侧开始慢慢有序,不断向中间逼近。

    for (int i = begin; i <= end; i++)        //再begin 和 end(包含end,是 <= )这个区间查找
    {
        if (a[i] > a[maxi])
            maxi = i;

        if (a[i] < a[mini])
            mini = i;
    }

此段代码完成maxi与mini的查找,找到之后,再完成头尾与最大、最小的位置交换。

为了防止出现头就是最大值的情况,需要加一句判断。

        if (begin == maxi)       
            maxi = mini;

循环结束之后,两端向中间逼近。
        begin++;
        end--;

由于这种算法既不稳定效率低下,因此极少使用。

直接选择排序的特性总结:


1. 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1)
4. 稳定性:不稳定

堆排序

堆排序是利用堆的特性进行排序,是堆的重要应用。其中,向下调整算法是建堆及排序的关键。

堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是 通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。
首先介绍什么是向下调整算法:
向下调整算法主要用于在删除堆的根节点或在根节点插入新值后,重新恢复堆的顺序性质。

向下调整算法的基本步骤如下:

  1. 确定调整的位置:当删除或替换堆顶元素后,需要调整的节点通常是新的根节点。

  2. 比较与子节点的值:在最小堆中,比较该节点与其子节点(如果存在)的值,确定哪个子节点的值最小;在最大堆中,则确定哪个子节点的值最大。

  3. 交换:如果父节点的值大于(在最小堆中)或小于(在最大堆中)子节点的值,则与该子节点交换位置。

  4. 重复:将交换后的子节点作为当前节点,重复步骤2和3,直到当前节点没有子节点,或者它的值已经符合堆的性质。


void AdjustDown(int* a, int n, int parent)			//升序建大堆,找大孩子
{
	int child = parent * 2 + 1;			//假设左孩子大

	while (child < n)		//child是下标,最后一次是 n - 1,可以参与调整
	{
		if (child + 1 < n
			&& a[child + 1] > a[child])
			child = child + 1;

		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);

			parent = child;		//交换之后,不断迭代
			child = parent * 2 + 1;
		}

		else	//前提是,其余元素已经是个堆结构
			break;

	}


}

在代码中,假设左孩子大,当右孩子存在时,若大于左孩子,则更新孩子的下标。


        if (a[child] > a[parent])
        {
            Swap(&a[child], &a[parent]);

            parent = child;        //交换之后,不断迭代
            child = parent * 2 + 1;
        }

如果父子逆序,只需要交换父子。需要注意的是:完成一次交换之后,需要更新父子的下标,完成迭代。

else    //前提是,其余元素已经是个堆结构
    break;

若不需要交换,可以直接跳出堆结构。

简而言之,向下调整算法就是:先找孩子,逆序交换,顺序跳出。

介绍完调整算法,便可以介绍排序算法。

进行堆排序时,需要两个步骤:1.向下调整算法建堆 2.向下调整算法排序。

1.建堆过程

  观察向下调整算法,其参数是void AdjustDown(int* a, int n, int parent)。我们需要传入①数组②数组大小③父节点的下标

在使用向下调整算法时,需要保证其余的结构已经满足大堆或者小堆的结构,因此,建堆可以进行倒着建堆。

观察这个小堆结构,在建堆时,可以认为第三层的数据本身就是堆结构,因此只需要传第三层的父节点,不断向下调整建堆,父节点坐标--,遍历完所有父节点,便可完成建堆。

    for (int i = (n - 1 - 1) / 2; i >= 0; i--)        //i从倒数第一个父亲开始,不断--,不断建好堆的每一部分
    {
        AdjustDown(a, n, i);
    }

这段代码便是建堆的过程。

(n - 1 - 1) / 2 这种书写方式是因为n  - 1是最后一个孩子节点的下标,( (n - 1) - 1) / 2是

(孩子 - 1)/ 2才是父节点的下标。i--可以让父节点完成遍历。

循环的内容是向下调整算法,其中参数n控制向下调整算法的结束。(此处需要了解传参时个参数的作用)

2.排序过程

向下调整算法排序: 不断筛选最大值,放到数组末尾。

利用堆的特性,堆的根一定是最大值或最小值,取根,与数组的末尾的数据交换顺序,交换完之后,再去对新数组(忽略交换的最后的数据)调整建堆。

int end = n - 1;

while (end > 0)
{
	Swap(&a[end], &a[0]);
	AdjustDown(a, end, 0);		//从根开始向下调整, 新数组大小是end
	end--;	
}

让end是最后数据的下标,end不断--,完成数组的排序。

因此,完整的堆排序为


void HeapSort(int* a, int n)
{
	//1.向下调整算法建堆 :传父亲下标

	for (int i = (n - 1 - 1) / 2; i >= 0; i--)		//i从倒数第一个父亲开始,不断--,不断建好堆的每一部分
	{
		AdjustDown(a, n, i);
	}

	//2.向下调整算法排序: 不断筛选最大值,放到数组末尾

	int end = n - 1;

	while (end > 0)
	{
		Swap(&a[end], &a[0]);
		AdjustDown(a, end, 0);		//从根开始向下调整, 新数组大小是end
		end--;	
	}

}

直接选择排序的特性总结:


1. 堆排序使用堆来选数,效率就高了很多。
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(1)        //利用自身建堆
4. 稳定性:不稳定

冒泡排序

冒泡排序(Bubble Sort)是一种简单的排序算法。它重复地遍历要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。遍历数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。

这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。

注意:冒泡排序效率十分低下,但是其形象的特点,教学意义重大。

冒泡排序算法的步骤如下:

  1. 比较相邻的元素:如果第一个比第二个大(为实现升序排序),就交换它们两个。

  2. 对每一对相邻元素做同样的工作:从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。

  3. 针对所有的元素重复以上的步骤,除了最后已经排序好的元素。

  4. 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。

void BubbleSort(int* a, int n)
{
	for (int i = 0; i < n - 1; i++)		//趟数(类比插入排序的趟数)
	{
		bool exchange = false;

		for (int j = 0; j < n - 1 - i; j++)
		{
			if (a[j] > a[j + 1])		//每两个元素,一一比较
			{
				Swap(&a[j], &a[j + 1]);
				exchange = true;
			}
		}

		if (exchange == false)		//当某一趟没有发生交换,说明已经有序,则直接跳出循环
			break;
	}

}

可以观察到上述的冒泡排序进行了一定的优化,当某一趟不需要进行交换时,表明数组已经有序,便可以跳出循环,排序结束。

示例:

假设我们有以下已排序的数组进行排序:[1, 2, 3, 4, 5]。使用优化后的冒泡排序,第一趟排序后因为没有发生任何交换,算法会立即结束,不需要进行剩余的排序操作。

这个优化不会降低冒泡排序的时间复杂度,它仍然是O(n^2),但是它可以显著减少最好情况下的时间复杂度到O(n),即当输入数组已经是有序的情况下。

快速排序

快速排序,堪称是所有排序的老大哥,性能极其优秀。

快速排序(Quick Sort)是由英国计算机科学家东尼·霍尔(Tony Hoare)发现的,他在1960年发明了这种算法。东尼·霍尔是在研究一个文件排序的问题时,发明了快速排序算法。

这位大佬也是既低调又高调,低调是不用自己的名字命名,高调则是直接起名“快速”。

目前关于快速排序也是有多个子版本,这里着重介绍如下几个。

基本思想:

任取待排序元素序列中 的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
可以观察到,其思想是先利用单趟排序,使得单趟有序,再分别对子区间排序,因此便衍生出了递归版本和非递归实现的版本。

/*
每个子排序,都需要一个keyi来分割区间
*/

1.Hoare版本

顾名思义,这也是霍尔大佬发现的最初版本。

//Hoare法
int PartSort1(int* a, int left, int right)
{


	int keyi = left;

	while (left < right)	//右找小;左找大
	{
		while (left < right && a[right] >= a[keyi])			//左边是key,右边先走
			right--;		

		while (left < right && a[left] <= a[keyi])		//防止越界 与 死循环
			left++;
                //到此处时,一定是找到了大和小
		Swap(&a[left], &a[right]);		//体现交换排序

	}

	//相遇

	Swap(&a[left], &a[keyi]);	//最后相遇,让数据key和相遇位置的数据(比key小)交换

	keyi = left;

	return keyi;


}

可以观察到右边先找小,左边再找大(原因下方讲述)

上述循环既左边找小,右边找大,符合条件交换,相遇之后,再交换keyi处和相遇位置处的值。

从而保证单趟结束之后,keyi左边都比a[keyi]小,右边都比它大。

left < right && a[right] >= a[keyi] 此句代码则能够防止 1.出现死循环 >= 2.出现越界 left < right

易错讲解

当升序排序时

无论升降序,都是让对方先走。

最后都要和keyi处的数据换。

如此保证,右边比key大;左边比key小

左边做K分析

情况一;R停下来时,一定是找到了  --- 小
情况二:L停下时,一定是刚交换完的之后 ----  小
算法优化
此算法的时间复杂度目前代码是n^2,主要原因是key的取值,如果keyi每次取最左边,如果原数组有序,则会形成等差数列的复杂度,造成n^2。

分析:

//如果已经有序,每次取得是最左边的数,则左边是一个不存在的区间,右边则会形成等差数列一共进行n - 1次调用(每次深度 - 1),每一次调用的循环规模是n - 1, n - 2, n - 3.

//则时间复杂度是所有规模之和,n + n - 1 + n - 2 + .....

优化后:

了解到主要原因是处在key的取值后,那便尽量让key的取值接近中位数。此时便出现三数取中。

三数取中

比较头、中、尾三个元素的大小,取中间值作为key

以下是三位取中的实现



//优化版:三数取中法(两两比较)

int GetMidIndex(int* a, int left, int right)			//取下标
{
	int mid = (left + right) >> 1;		//÷2

	if (a[left] < a[mid])
	{
		if (a[mid] <= a[right])
			return mid;

		else if (a[left] <= a[right])		//走到此处,前提条件一定是 a[left] >= a[right]
			return right;

		else
			return left;

	}

	else	//一定是a[left] >= a[mid]
	{
		if (a[mid] >= a[right])
			return mid;

		else if (a[left] <= a[right])	//此时一定是a[mid] < a[right]
			return left;

		else
			return right;

	}


}

得到中间值的下标之后,只需要让中间值与左边的值交换,便可以仍然让左边的值作为key

int midi = GetMidIndex(a, left, right);    //三数取中,记录Index
Swap(&a[midi], &a[left]);        //始终让最左边的数是“中”

2.挖坑法

其思想仍然是让左边小于key,右边大于key,分割出两段区间。


//挖坑
int PartSort2(int* a, int left, int right)
{
    int midi = GetMidIndex(a, left, right);    //三数取中,记录Index
    Swap(&a[midi], &a[left]);

    int key = a[left];    //key
    int hole = left;    //假设最开始left是坑的位置

    while (left < right)    //左找大,右找小
    {
        while (left < right && a[right] >= key)    //找数据
            right--;

        //交换数据,填洞
        Swap(&a[right], &a[hole]);        //交换数据,拿当前数据填坑
        hole = right;                //自己形成新的坑

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

        Swap(&a[left], &a[hole]);
        hole = left;

    }

    //最后相遇,相遇在坑处(其中一个一定是坑,那么相遇一定是相遇在坑处)

    a[hole] = key;        //形成三个区间,左边比key小,右边比key大,最后相遇被key填上

    return hole;    //返回最后一个坑的位置

} 

仍然是右边先找小,再左边找大,只不过区别是,每次找到之后,都要去填坑,形成新的坑。

最后key填到相遇的位置(hole中)。

前后指针法

可以说是最简单的一种版本了,至于要定义两个下标 prev 和 cur(是下标,不是数据),遍历数组就好。

过程:

通过此操作,使得大于key的值,不断翻滚向前,prev与cur夹杂的数据就是大于key的数据。

        


//前后指针法
int PartSort3(int* a, int left, int right)
{
	int cur = left + 1;
	int prev = left;		//不能是 0, 应该是left端点 

	int midi = GetMidIndex(a, left, right);	//三数取中,记录Index
	Swap(&a[midi], &a[left]);

	int keyi = left;

	while (cur <= right)		//闭区间
	{
		if (a[cur] < a[keyi] && ++prev != cur)			//找到小,prev++;再交换
			Swap(&a[cur], &a[prev]);

		cur++;
	}

	Swap(&a[prev], &a[keyi]);		//交换数据,挪动keyi的位置,形成三个子区间
	keyi = prev;

	return keyi;


}

找完之后,让再交换keyi与prev处的值,让keyi到prev处,从而分割出两段区间。


/*


三种子排序,都是形成了 左 keyi 右 三个区间


快排一般用前后指针法

cur找小,prev++,交换  (cur 与 prev最开始相邻)

最后交换a[prev] 与 a[keyi]

keyi = prev;
return keyi;   

*/
 

讲解完三种子排序,便可以讲解快排。

递归版本



//类似二叉树的展开,左边完成之后,再走右边
void QuickSort(int* a, int begin, int end)		//三种子排序,最终思想都是左边比key小,右边比key大,中间是keyi
{

	if (begin >= end)
		return;

	int keyi = PartSort3(a, begin, end);		//单趟排序
	//[left, keyi - 1] keyi [keyi + 1, end]


	QuickSort(a, begin, keyi - 1);	//利用单趟排序的返回值,左区间重复
	QuickSort(a, keyi + 1, end);	//右边重复
}

每次子排序,都会形成[left, keyi - 1] keyi [keyi + 1, end]两段区间,再利用这两段区间,分别进行子排序。(类似于二叉树的先序遍历)

非递归版本

由于其递归过程形似二叉树的展开过程,因此非递归借助栈来实现。

基本思想:1.利用栈的后进先出 2.当大区间完成排序之后,带入子区间进入栈

注意点:
对于借助栈的循环而言,一般都是利用栈的后进先出。其循环格式一般为
1.判空
2.出数据,使用数据(注意后入先出)
3.入数据
//循环:入  +  出 + 功能操作
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);
		//[left, keyi - 1] keyi [keyi + 1, 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);


}


在此段代码中,先押入右再压入左,则先取左,再取右

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

每次调用单趟排序之后,都会形成两段区间。

    STDestroy(&st);用完之后,将栈空间销毁。

快速排序的特性总结:


1. 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(logN)        //递归调用深度
4. 稳定性:不稳定

5.三种方法都无法解决有大量重复元素的问题。

解决:三路划分法 + 随机数取中

三路划分更全面,但是右路的原因,造成效率下降(日常写快排还是hoare和前后指针)

1.随机数取中

让mid取[left, end]区间的任意一个数

(left  + rand() / (right - left))此时取出的mid值,保证是区间内的任意值

rand % x ,保证结果小于x 因此 left + x < right;

2.三路划分法

当有大量重复元素时,每次让左边的值为key,则剩下的递归区间:左区间没有,有区间为n - i (i为递归次数),此时变成最坏情况

解决本质:

小的放在左边,大的放到右边,等于key的集中中间

将等于key的数据,放到中间,每次只对小于的部分和大于的部分进行划分

在过程中,left与right不断靠近中间

既有hoare的思想,也有前后指针的思想。

1.小的往左甩,大的往右甩right--

2.把等于key的值,往中间推

往右甩不会影响,所以不需要++c。往右甩有三种情况,大于等于 相等

在++Cur的过程中,始终保持left位置处的值 == key

最终left 与  right区间中的数据就是与key相等数据

[begin,left - 1]      [left, right]         [right + 1 ,end] 最终可形成这三段区间。

核心代码:


void QuickSort(int* nums, int begin, int end)
{

    if (begin >= end)
        return;

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

    int midi = MidIndex(nums, begin, end);
    Swap(&nums[midi], &nums[left]);
    int key = nums[left];

    while (cur <= right)
    {
        if (nums[cur] < key)    //cur是下标,不是具体元素
        {
            Swap(&nums[cur], &nums[left]);      //分别和;;left和right处的值换,让left与right再不断移动,靠近中间 
            left++; 
            cur++;
        }
        
        else if (nums[cur] > key)
        {
            Swap(&nums[cur], &nums[right]);
            right--;
        }

        else
            cur++;

    }



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

最终left,right区间夹杂的数据就是等于key的值。

例子:

给你一个整数数组 nums,请你将该数组升序排列。(需要解决三种方法都无法解决有大量重复元素的问题。)

示例 1:

输入:nums = [5,2,3,1]
输出:[1,2,3,5]

示例 2:

输入:nums = [5,1,1,2,0,0]
输出:[0,0,1,1,2,5]

提示:

  • 1 <= nums.length <= 5 * 104
  • -5 * 104 <= nums[i] <= 5 * 104

解决代码:


int MidIndex(int* nums, int left, int right)
{
    int mid = left + (rand() % (right - left));     //中间值变化
        //rand % x ,保证结果小于x 因此 left + x < right;

    if (nums[left] < nums[mid])
    {
        if (nums[mid] <= nums[right])
            return mid;

        if (nums[left] <= nums[right])
            return right;
        
        else
            return left;
    }
    
    else
    {
        if (nums[left] <= nums[right])
            return left;
        
        if (nums[mid] <= nums[right])
            return right;
        
        else
            return mid;

    }

}

void Swap(int* a, int* b)
{
    int tmp = *a;
    *a = *b;
    *b = tmp;
}


//优化:三路划分法 + 随机数取中

void QuickSort(int* nums, int begin, int end)
{

    if (begin >= end)
        return;

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

    int midi = MidIndex(nums, begin, end);
    Swap(&nums[midi], &nums[left]);
    int key = nums[left];

    while (cur <= right)
    {
        if (nums[cur] < key)    //cur是下标,不是具体元素
        {
            Swap(&nums[cur], &nums[left]);      //分别和;;left和right处的值换,让left与right再不断移动,靠近中间 
            left++; 
            cur++;
        }
        
        else if (nums[cur] > key)
        {
            Swap(&nums[cur], &nums[right]);
            right--;
        }

        else
            cur++;

    }



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

int* sortArray(int* nums, int numsSize, int* returnSize) {

    srand(time(0));
    int begin = 0;
    int end = numsSize - 1;

    QuickSort(nums, begin, end);
    *returnSize = numsSize;     //传入的returnSize是一个指针,需要人为赋值

    return nums;
}

归并排序

顾名思义,是通过两个数组的归并完成的排序。

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

其具体思想是:将大区间拆分成一个个小区间,再两两归并,再拷贝回原来的区间。

递归版



//1.拆分  2.归并
void _MergeSort(int* a, int begin, int end, int* tmp)
{
	if (begin == end)
		return;
	int mid = (begin + end) / 2;

	//拆分 [begin, mid] [mid + 1, end]
	_MergeSort(a, begin, mid, tmp);
	_MergeSort(a, mid + 1, end, tmp);

	//归并
	int begin1 = begin;   int end1 = mid;
	int begin2 = mid + 1; int end2 = end;
	
	int i = begin;	// i 丛begin 开始,而不是从 0 开始,  tmp数组随a从begin位置开始拷贝
	while (begin1 <= end1 && begin2 <= end2)	//尾插
	{
		if (a[begin1] <= a[begin2])
			tmp[i++] = a[begin1++];		//begin1 和 begin2 不能同时++,只有用完之后才能++

		else
			tmp[i++] = a[begin2++];
	}

	while (begin1 <= end1)	//到此处一定有一个结束
	{
		tmp[i++] = a[begin1++];
	}

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

	memcpy(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));	//不是只拷贝前几个字节,而是拷贝完成内容修改的部分
}

//需要注意的是,尾插从begin处开始,拷贝回去也是从begin处开始。
void MergeSort(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);

	_MergeSort(a, 0, n - 1, tmp);		//n是数组大小,n - 1才是下标
	free(tmp);
	tmp = NULL;
}

以下体现的是拆分

    //拆分 [begin, mid] [mid + 1, end]
    _MergeSort(a, begin, mid, tmp);
    _MergeSort(a, mid + 1, end, tmp);

以下体现的是归并

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

int i = begin;    // i 丛begin 开始,而不是从 0 开始,  tmp数组随a从begin位置开始拷贝
while (begin1 <= end1 && begin2 <= end2)    //尾插    //只有一个元素时,begin == end
{
    if (a[begin1] <= a[begin2])
        tmp[i++] = a[begin1++];        //begin1 和 begin2 不能同时++,只有用完之后才能++

    else
        tmp[i++] = a[begin2++];
}

while (begin1 <= end1)    //到此处一定有一个结束
{
    tmp[i++] = a[begin1++];
}

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

最后需要拷贝回去。

需要注意的是,并归到tmp数组时,需要从begin处开始并归,因此拷贝回去,也是从此处拷贝回去。

非递归版

对于递归,可以完成层层拆分;对于非递归,则需要借助gap来完成小组之间的递归

大体可以看作两步

1.三层循环嵌套 2.进行参数调整,防止出现越界。


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

	int gap = 1;	//表示两个begin1 之间的元素个数
	while (gap < n)
	{	
		int j = 0;
		for (int i = 0; i < n; i += 2 * gap)
		{
			int begin1 = i; int end1 = i + gap - 1;	//end下标是  begin下标 + 间隔元素个数 - 1 
			int begin2 = end1 + 1; int end2 = begin2 + gap - 1;

			//三种越界

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

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

			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 + i, sizeof(int) * (end2 - i + 1));	//闭区间元素个数需要 + 1
		}

		gap *= 2;
	}

	free(tmp);
	tmp = NULL;

}

1.三层嵌套讲解

不同于快排的非递归实现,快排的非递归实现借助的是栈,这是因为其区间变化是从大到小,类比二叉树。

并归排序的非递归采用的是从前到后分割区间,因此可以用gap来分割。

首先是gap  < n,保证了循环过程中,不能出现数组越界。

再者是        for (int i = 0; i < n; i += 2 * gap) 通过 i 来生成两两归并的起点

最后是        while (begin1 <= end1 && begin2 <= end2) 来完成归并。

简而言之就是:1.控gap 2.控起点 3.完归并

2.越界讲解。

此处越界,可分为三种情况,

三种越界图示:

对于第一第二种情况,只需要让循环终止即可,通过归并一组,拷贝一组,只需要让归并的部分拷贝到原数组即可

对于第三种情况,只需要让end2修改成n - 1即可(没必要让进行两个归并的数组大小非得相同)。

需要注意的是:

 ①   int begin1 = i; int end1 = i + gap - 1;    //end下标是  begin下标 + 间隔元素个数 - 1 
    int begin2 = end1 + 1; int end2 = begin2 + gap - 1;

对于bg1 和 end1,最开始只指向一个元素,两者重合。同时end是下标,其值为 begin的下标 + 间隔元素个数 - 1。

② if (a[begin1] <= a[begin2])
                tmp[j++] = a[begin1++];

else
                tmp[j++] = a[begin2++];

在归并时,不能同时让begin1 和 begin2同时++,只有用到时,才可以++

③若把j定义在次循环外部,则 j 可以定义为0,没完成一趟遍历(归并完一整组,j会重新变成0,重新用尾插的方式,归并到tmp数组

④同理,从begin位置开始归并,归并一组,拷贝一组,因此拷贝也要从begin处进行

            memcpy(a + i, tmp + i, sizeof(int) * (end2 - i + 1));    //闭区间元素个数需要 + 1
每次拷贝的元素个数是end2 - i + 1(下标 + 1 == 元素个数; 元素个数 - 1 == 下标)

⑤i += 2*gap (两个begin1之间间隔2*gap个元素)

⑥完成一大趟并归之后,gap *= 2 (先是 一一归并,再是两两归并……)

    free(tmp);
    tmp = NULL;
归并完成之后,释放空间。

归并排序的特性总结:


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

(另外多提一嘴,归并排序和快排是在工程项目中最常用的两种排序,因为两者快、高效且一个稳定,一个不稳定,可以达到效果互补)

计数排序

计数排序是一个比较有趣的排序方法,体现在两点:1.不需要进行数据的比较 2.效率忽高忽低

就拿吃席举例子,那么小孩桌坐的是:冒泡排序、插入排序、直接基数排序

大人桌坐的是:快排、并归、希尔、堆排序

计数排序比较特殊,如果去大人桌,那么他喝酒比大人还牛;但如果去小孩桌,那他喝饮料都和不过小孩。

非比较排序
思想:计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。 操作步骤:
1. 统计相同元素出现次数
2. 根据统计的结果将序列回收到原来的序列中

简单来说就是 :

1.建立一个count(tmp)数组,来统计原数组种数据出现的个数

2.tmp数组中的数据,按照顺序映射回原数组。

表达式: tmp数组下标 == 数据值 - min

void CountSort(int* a, int n)
{
	int max = a[0];
	int min = a[0];
	
	//求max 与 min
	for (int i = 0; i < n; i++)
	{
		if (a[i] > max)
			max = a[i];
		
		if (a[i] < min)
			min = a[i];
	}

	int range = max - min + 1;
	int* tmp = (int*)malloc(sizeof(int) * range);
	memset(tmp, 0, sizeof(int) * range);	//每个字节都被初始化为数字0。 不能是字符 '0' (字符‘0’存储的ASC II值不是数字0)

	if (tmp == NULL)
	{
		perror("malloc fail");
		return;
	}

	//相对映射
	for (int i = 0; i < n; i++)
	{
		tmp[a[i] - min]++;
	}


	//映射到原数组
	int j = 0;
	for (int i = 0; i < range; i++)		//遍历tmp计数数组时,开辟的空间大小是range,而不是n
	{
		while (tmp[i]--)	//1.不为0  2.看次数(count)
		{
			a[j++] = i + min;
		}

	}

	free(tmp);
	tmp = NULL;
}

观察代码不难发现,此处需要三个循环。

循环一:遍历原数组,找max与min

循环二:遍历原数组,将原数组的数据,映射到tmp数组,当出现相同的元素时,对应tmp数组的元素++

循环三:遍历tmp数组(大小是range),将元素拷贝回原数组。

需要注意的是

① 

tmp[a[i] - min]++;

a[j++] = i + min;

这两段代码,便体现了tmp数组的下标 == 有效数据大小 - min的特点。

完成之后,释放空间。

memset(tmp, 0, sizeof(int) * range);

需要对tmp数组所有数据初始化为数字0。

for (int i = 0; i < range; i++)        //遍历tmp计数数组时,开辟的空间大小是range,而不是n
{
    while (tmp[i]--)    //1.不为0  2.看次数(count)
    {
        a[j++] = i + min;
    }

}

第三次遍历tmp数组时,由于tmp数组元素被初始化为0,因此可以直接while(tmp[i]--)

既可以保证不为0,同时不为0时,还可以根据被统计的次数不断--,通过a[j++] = i  +  min;给数组a排序。

计数排序的特性总结:


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

由此便可以知道为什么说计数排序是一个有趣的排序,观察空间复杂度,当数据比较集中时,range很小,空间复杂度极低;同时时间按复杂度变为O(N) 比快排还要优秀许多倍!!!同时稳定性也良好!

但是数据过分分散时,空间复杂度及时间复杂度就变得很差。

以上便是八大内排序的常用算法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值