常用的几种排序算法小结

目录

1.冒泡排序

2.堆排序

2.1堆的基础知识和特性

2.2向上调整算法和向下调整算法

2.3堆排序实现

3.插入排序

4.希尔排序

5.选择排序

5.1选择排序递归版

5.2选择排序非递归版

6.快速排序

[6.1 Hoare版本之递归](#6.1 Hoare版本之递归)

6.1.1普通版

6.1.2随机数版

6.1.3三数取中版

6.1.4小区间优化

6.2挖坑法

6.3前后指针法

6.4非递归实现快速排序

7.归并排序

7.1归并排序递归实现

7.2归并排序非递归实现


我们在面对一些杂乱无章的元素的时候,就需要使用排序把它们排列为有顺序的一个数组,那么这个时候我们就要使用到排序算法。

排序算法是一个很神奇的东西,同样的都是把一个无序数组转换成有序数组,却有着许许多多不同的算法去实现它,并且背后的思想都是极致的数学思想。优秀的算法都是能够把空间复杂度和时间复杂度变为尽量小。

1.冒泡排序

被称为最经典的教学排序,也是大部分初学者第一个认识的排序。冒泡排序使用多次遍历数组,每次遍历都能够把最大(或者最小,由需要实现的顺序决定)排到最后,然后把范围缩减1,继续排列剩下的几个数据,直到数组变为有序为止。

冒泡排序的本质思想其实很简单,就是每次把数据中最大\最小的一个数据选出,放到数组最后面。

现在有一个数组假设数据有n个数,需要我们排序成升序,那么我们一次遍历就需要让i从1历到n-1,每次让arr[i]和arr[i-1]比较,如果arr[i-1] > arr[i]则说明需要把arr[i-1]和arr[i]交换位置,一次遍历之后,最大的数据就交换到最后面了,然后把范围-1,进入下一个循环。范围的最大是n,最小是多少呢?最小就是当范围走到只有一个数据的时候,这个时候就是整个数据都排序玩成的情况。

下面看一下代码实现:

cpp 复制代码
void BubbleSort(int* arr, int n)
{
	//冒泡的一趟排序
	//for (int i = 1; i < n; i++)
	//{
	//	if (arr[i - 1] > arr[i])
	//	{
	//		Swap(&arr[i - 1], &arr[i]);
	//	}
	//}
	for (int j = 0; j < n; j++)
	{
		for (int i = 1; i < n - j; i++)
		{
			if (arr[i - 1] > arr[i])
			{
				Swap(&arr[i - 1], &arr[i]);
			}
		}
	}
}

测试一下效果:

很完美,这样我们就写完了冒泡排序算法。冒泡排序算法时间复杂度是十分接近O(N)的,所以平常项目中,一般是不会使用冒泡排序去排列数组,一位因为它的效率是十分低下的,但是冒泡排序又有着简单易懂的思想,所以我们就可以把它作为一个经典的入门排序算法用于教学。

2.堆排序

堆排序是基础阶段的一大真神,以其空间复杂度和时间复杂度兼得而被纳入教材,说它是真神也不为过。

当然,使用堆排序一般又两种算法,堆排序是基于数据结构------堆实现的,而堆正好又是一个完全二叉树,所以我们一般不会使用建堆这样的空间复杂度极大的方式去完成堆排序,我们一般就使用原数组进行建堆模拟堆完成排序。

2.1堆的基础知识和特性

这里就要讲到一些关于堆和二叉树的基础知识了:

这里有两张图,第一张是完全二叉树并且是满二叉树,第二张是完全二叉树但不是满二叉树,满二叉树的定义就是每一层从左往右数都是一些不会间断的一批数据,这里的"间断"指的是在数到最后一层最后一个非空节点之前不会遇到空节点。如果每层都是满的,那么我们就说这样的二叉树结构不仅是完全二叉树,还是满二叉树。完全二叉树构成了一种数据结构------堆,它不仅可以用一个个结构体连接并且存储,还可以使用一个数组存储,顺序就是层序遍历(也就是从第一层到最后一层都是从左到右遍历并存储到一个数组中)

二叉树每个节点其实都有两个向外连接的两个"枝条",对应的节点就是它们的"子节点",如果没有看到"子节点",则说明它的这个子节点对应的是空节点。而每个子节点往上一层连接的节点就是父节点,每个节点至多有一个父节点。

而堆和二叉树的区别就是:堆的子节点和父节点之间有一定的大小关系。堆分为大堆和小堆,大堆的结构就注定了父节点一定比子节点大,而小堆正好相反,小堆的父节点比子节点小,然而有一点要注意,那就是一个父节点的下面两个子节点之间没有任何大小关系,只有父节点和子节点之间有大小关系。

用层序遍历就可以把一个二叉树排序进入一个数组中,比如我们把这里第二个堆转化成一个用数组存储的形式。

这里使用了不同的颜色去标记每一层的数据。而这里我们有一个规律:那就是:父节点下标*2+1= 左子树下标,父节点下标*2+2 = 右子树下标 ,同样的,反过来就是(子节点下标 - 1)/2 = 父节点下标,那么我们就知道了堆用数组存储的最精髓的部分。

2.2向上调整算法和向下调整算法

在使用数组建堆模拟的时候,我们就要认识两种算法:向上调整算法和向下调整算法。

向上调整算法一般针对尾插入的数据,通过给的子树数据然后向上找到父节点,然后再比较大小,直到找到这个数据应该处于的位置。而向下调整算法就一般使用在删除头节点操作中,删除头节点操作一般不能破坏堆的结构,所以一般就会把头节点和最后一个节点交换位置,然后再进行删除尾节点,再把头节点进行向下调整算法,直到头节点找到属于它自己的位置。

这里我们就可以把两种算法先实现一下(这里假设建大堆):

cpp 复制代码
//假设建大堆
//向上调整算法
void AdjustUp(int* arr, int child)//child为字节点的下标
{
	int father = (child - 1) / 2;
	while (father >= 0)
	{
		if (arr[child] > arr[father])
		{
			Swap(&arr[child], &arr[father]);
			//更新下标
			child = father;
			father = (child - 1) / 2;
		}
		else
		{
			break;
		}
	}
}
//向下调整算法(建立大堆)
void AdjustDown(int* arr, int father,int size)//father为父节点的下标
{
	int child = father * 2 + 1;//假设左节点更大
	while (father >= 0)//确保下标合法不越界
	{
		if (child + 1 < size && arr[child] < arr[child + 1])
		{
			++child;
		}
		if (child < size && arr[child] > arr[father])
		{
			Swap(&arr[child], &arr[father]);
			//更新
			father = child;
			child = father * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

2.3堆排序实现

在使用堆排序前需要先建立堆,可以使用原数组建堆,建堆只需要用到向下调整算法,原本的思路应该是:先遍历数组,使用向下调整算法建堆。但是这里有一点,那就是有一些节点是没有子节点的,所以对它们使用向下调整算法是没有任何意义的,那么我们就要找到最后一个有子节点的元素下标,然后使用向下调整算法,再继续向上遍历,这样就可以保证遍历到下标为0的时候就能建好堆。

那么"最后一个有子节点的元素下标"是哪个呢?很简单,我们只要找到数组末尾节点就好了,它的父节点一定就是"有子节点的最后一个元素"。那么我们就可以写出下面的代码:

cpp 复制代码
void HeapSort(int* arr, int n)
{
	int father = (n - 1 - 1) / 2;//找到"最后一个有子节点的元素下标"
	//用原数组建堆
	for (int i = father; i >= 0; i--)
	{
		AdjustDown(arr, i, n);
	}
}

在这里解释一下为什么要使用大堆而不是小堆:因为我们需要得到的是一个升序的数组,而堆的特性只能够确保这个堆的堆顶是整个堆中的最大/最小值(大堆堆顶为最大值,小堆堆顶为最小值)。假设我们使用了建小堆的话,我们的确可以得到一个堆顶是最小值的堆,但是之后怎么办呢?把剩下的数再重新建一个堆?重新建堆的时间复杂度可大了,这样的话堆排序还不如冒泡排序。

我们想到一个办法:把首元素和尾元素交换,再把范围 -1 ,然后就可以把最值放在最后面,然后通过向下调整算法再选出下一个最值,这样我们既不需要重新建一个堆,一趟下来我们整个数组也排好了序,两全其美。同时,我们也要注意到我们使用小堆的话,我们最后得到的就是从大到小的数组,就是一个降序的数组了,我们还需要再颠倒顺序,这无疑是增加了时间复杂度。所以我们建大堆就可以一步到位直接得到一个升序的数组,假如需要降序的数组就建小堆。

这就是堆排序的核心思路了,下面就是代码实现:

cpp 复制代码
//堆排序
void HeapSort(int* arr, int n)
{
	int father = (n - 1 - 1) / 2;//找到"最后一个有子节点的元素下标"
	//用数组建堆
	for (int i = father; i >= 0; i--)
	{
		AdjustDown(arr, i, n);
	}
	for (int i = 0; i < n - 1; i++)//范围内元素个数为 n-i ,范围最小为 2 ,n - i >= 2 则i <= n - 2 ,即i < n - 1
	{
		Swap(&arr[0], &arr[n - i - 1]);//交换范围内的首尾
		AdjustDown(arr, 0, n - i - 1);//除去尾部的范围缩小1,向下调整
	}
}

3.插入排序

插入排序就是在一个已经有序的数组中插入一个数据,让这个插入后的数组仍然保持有序。

好比你有一手有序的扑克牌,这个时候又发了一张牌过来,你就可以这样把这张牌插入这些扑克牌中的对应的位置,让整副扑克牌仍然有序。

这就是插入排序的核心思想。比较简单,在代码中就是下面的这种实现方法:

cpp 复制代码
void InsertSort(int* arr, int n)
{
	for (int i = 1; i < n; i++)
	{
		int tmp = arr[i];
		int end = i - 1;
		while (end >= 0 && arr[end] > tmp)
		{
			arr[end + 1] = arr[end];//向后覆盖
			end--;
		}
		arr[end + 1] = tmp;
	}
}

这就是插入排序,一个比较简单的算法,基于这个算法,希尔提出了不一样的优化方案:那就是希尔排序

4.希尔排序

插入排序在一种情况下时间复杂度是最高的:那就是数组正好和预期顺序相反的时候,这样每次排序都需要遍历到最边边,时间复杂度达到最大。但是顺序相同的时候就正好时间复杂度最小,那么要怎么解决这样的问题呢?希尔排序做出了下面的优化:

先进行预排序,再进行插入排序。

预排序就是把整个数组以相同间距gap分为若干部分,把每个部分都进行插入排序,以让每部分都是有序的数据,然后再最后进行插入排序,这样就完成了希尔排序。

上面就是一个间距 gap == 3 的一个分割,我们把每个部分的数据都进行一次排序,最后再进行一次总排序,那么我们就可以得到一个排列整齐的数组。

cpp 复制代码
	int gap = 3;
	for (int j = i + gap; j < n; j += gap)
	{
	    int tmp = arr[j];
	    int end = j - gap;
	    while (end >= 0 && arr[end] > tmp)
	    {
	        //向后覆盖
	        arr[end +gap] = arr[end];
	        end -= gap;
	    }
	    arr[end + gap] = tmp;
	}

这就是一趟gap为3的预排序。不难看出,其实插入排序就是gap == 1 的预排序,那么我们最后需要进行一次gap==1的预排序就可以了。

我们依照这样的思路,可以建立下面这一种更新:

cpp 复制代码
void ShellSort(int* arr, int n)
{
	int gap = n;
	while (gap > 1)
	{
		 gap/= 2;
		for (int i = 0; i < gap; i++)
		{
			for (int j = i + gap; j < n; j += gap)
			{
				int tmp = arr[j];
				int end = j - gap;
				while (end >= 0 && arr[end] > tmp)
				{
					//向后覆盖
					arr[end +gap] = arr[end];
					end -= gap;
				}
				arr[end + gap] = tmp;
			}
		}
	}
}

不难看出,其实每次gap都除以2,最后gap一定会是1,为什么?gap倒数第二次如果为2,则最后一次为1,如果倒数第一次为3,则最后一次为1。倒数第二次gap为4?4的下一次还是2,所以算不上倒数第二次,总之,最后一次gap为1就对了。

有人会质疑希尔排序的时间复杂度和效率,事实上,希尔排序的效率是有着明显提升的,这里使用一个clock函数来测试一下效率:

cpp 复制代码
void SortOP()
{
	const int N = 100000;
	int* arr1 = (int*)malloc(sizeof(int) * N);
	int* arr2 = (int*)malloc(sizeof(int) * N);

	for (int i = 0; i < N; i++)
	{
		arr1[i] = rand();
		arr2[i] = rand();
	}

	int begin1 = clock();
	InsertSort(arr1, N);
	int end1 = clock();

	int begin2 = clock();
	ShellSort(arr2, N);
	int end2 = clock();

	printf("InsertSort: %d \n", end1 - begin1);
	printf("ShellSort: %d \n", end2 - begin2);

	free(arr1);
	free(arr2);
}

看一下效果

可以看到,在10万个数据面前,插入排序需要2527ms而希尔排序只需要15ms,所以希尔排序确确实实大幅提升了插入排序效率。

当然,这里还有一个更加高效的变换方法,那就是n = n / 3 +1,这里就不测试了,变化方法又很多种,提升效率的办法也有很多,总之都证明了希尔排序是一个效率十分高并且潜力很大的排序算法。

5.选择排序

选择排序是一个有点神似快排与冒泡的一个排序,就是有点像二者的结合体,介于中间,这里我们也可以借助它来完成我们从冒泡到快排的一个过渡。

5.1选择排序递归版

选择排序的特点就是进行多次遍历,每次遍历选出最小的一个数的下标和最大的一个数的下标,然后再把它们和数组边界交换位置,然后再把边界缩小再遍历,直至整个数组都变成一个有序的数组。

这样的话,我们的选择排序就可以使用递归先演示一遍。

cpp 复制代码
void _SelectSort(int* arr, int left, int right)
{
	if (left >= right)
		return;
	int maxid = left;
	int minid = left;
	for (int i = left; i <= right; i++)
	{
		if (arr[i] > arr[maxid])
		{
			maxid = i;
		}
		if (arr[i] < arr[minid])
		{
			minid = i;
		}
	}
	Swap(&arr[left], &arr[minid]);
	if (maxid == left)//假设maxid为最左的话,那么经过一次转换之后,它就已经被换到了minid上
	{
		maxid = minid;//更新之后的最大值位置在minid上
	}
	Swap(&arr[right], &arr[maxid]);
	_SelectSort(arr, left + 1 , right - 1);
}
void SelectSort(int* arr, int n)
{
	_SelectSort(arr, 0, n - 1);
}

这里需要注意的一个点就是如果遍历之后开始交换了minid和left,这个时候就要先判断原来的maxid是不是就在left上,如果原来的maxid就在left上的话,那么我们就要更新maxid的位置,因为它只是一个下标,left和minid交换之后最大值就跑到了minid的位置上,我们在这个时候就要及时更新maxid的下标位置,然后再进行交换最大值操作。

这样我们就使用套壳就完成了整个数组排序,思路还是很简单的。

但是我们不应该止步于此,递归是为了我们理解整个函数的运作,然而递归的函数层层调用创建了很多额外的空间,所以非递归的实现才是这个排序算法的最优秀的做法

5.2选择排序非递归版

非递归主要通过循环实现,这个也比较简单,把原本的递归稍微换一下就好了,并没有什么比较难的地方,所以我们可以直接实现:

cpp 复制代码
void SelectSort(int* arr, int n)
{
	int left = 0;
	int right = n - 1;
	while (left < right)
	{
		int maxid = left;
		int minid = left;
		for (int i = left; i <= right; i++)
		{
			if (arr[i] > arr[maxid])
			{
				maxid = i;
			}
			if (arr[i] < arr[minid])
			{
				minid = i;
			}
		}
		Swap(&arr[left], &arr[minid]);
		if (maxid == left)
		{
			maxid = minid;
		}
		Swap(&arr[right], &arr[maxid]);
		left++;
		right--;
	}
}

这样我们就完成了整个数组的排序

6.快速排序

快速排序(一般简称快排)是一个极具实践意义的一个排序算法,而且性能也是十分厉害,现在也出了很多个版本,这里先介绍一下创始人hoare的版本

6.1 Hoare版本之递归

6.1.1普通版

hoare是快排的创始人,而之后所有快排的优化都基于这个hoare的初始版本思想。

hoare版主要思想就是找到一个基准值,然后再定义两个变量,一个先从最右边开始走,一个从最左边开始走(为什么右边先走后面再解释),如果右边找到比基准值小的就停下来,然后左边走,左边找到比基准值大的就停下来,左边停下来之后就把左右两个值交换,然后继续走,直到二者相遇,就把这个相遇点的值和基准值交换。这个时候神奇的事情就出现了:基准值到了它最后应该出现的地方,也就是基准值找到了属于它的位置,因为基准值左边都是比基准,值小的数,基准值右边都是比基准值大的数,所以之后无论怎么变,基准值的位置都会是交换后的位置,因为它其实已经排好序了。

所以后面的操作就是把基准值左边的数排序,基准值右边的数也排序,这样就有点像二叉树的分衍问题,把大问题化成小问题,小问题再化成小问题,直到递归到了最小子问题,我们就排好了顺序。

按照上面的思路,我们把这样的快排传入一个左值和一个右值,然后递归。

cpp 复制代码
void QuickSortRe(int* arr, int left, int right)
{
	//范围[left,right]
	if (left >= right)//分衍子问题最小
	{
		return;
	}
	int keyi = left;//选取最左边数为基准数
	int i = left;
	int j = right;
	while (i < j)
	{
		while (i < j && arr[j] >= arr[keyi])//右边先走
		{
			j--;
		}
		while (i < j && arr[i] <= arr[keyi])
		{
			i++;
		}
		Swap(&arr[i], &arr[j]);
	}
	
	//此时i和j均为相遇点
	Swap(&arr[keyi], &arr[i]);

	//[left,i - 1]
	QuickSortRe(arr, left, i - 1);
	//[i + 1,right]
	QuickSortRe(arr, i + 1, right);
}

void QuickSort(int* arr, int n)
{
	QuickSortRe(arr, 0, n - 1);
}

这里解释一下我们为什么右边的下标先走:首先我们左右停下来都有一个条件:左右相遇或者是左边遇到了比基准值大的右边遇到了比基准值小的,那么我们初始定义的基准值都是使用的最左边的,所以我们一定要保证相遇点就是比基准值小的,然后交换,交换之后基准值换到相遇点的同时,相遇点的值要换到最左边去,而我们排序的初衷就是要把比基准值小的数排在它的左边,大的排在它的右边,假设左边先走,就有可能出现相遇点的值比基准值大的情况,除非把基准值取在最右边或者是排列降序的数列,不然都是需要先走右边的下标点。

6.1.2随机数版

我们会发现前面写的快排有一个非常致命的缺陷,就是当每次基准值最终归属地在边缘的时候,另外一个就需要遍历整个数组的长度,比方说我们的数组是降序的,那么我们要把它变成升序的数组就要走很多"弯路",比如说我们的左边的就要遍历整个数组的长度,直到相遇,这样的话我们的时间复杂度就会直线上升,大概就是N的阶乘,在O(N^2)的水平。我们需要避免这样的事情发生,我们就可以稍微更改一下:把基准值keyi改成随机的一个值,这样我们最后的数组经过一次次递归之后,还是会排好序,并且这个也不会由于数组原本降序而导致我们的时间复杂度直线上升:

那么还是之前的那种写法,我们就可以得到下面的代码:

cpp 复制代码
void QuickSortRe(int* arr, int left, int right)
{
	srand((unsigned int)time(NULL));
	//范围[left,right]
	if (left >= right)//分衍子问题最小
	{
		return;
	}
	int keyi = rand()%(right - left + 1) + left;//保证基准值在范围内
	Swap(&arr[keyi], &arr[left]);//换到左边继续操作
	keyi = left;//更新keyi
	int i = left;
	int j = right;
	while (i < j)
	{
		while (i < j && arr[j] >= arr[keyi])//右边先走
		{
			j--;
		}
		while (i < j && arr[i] <= arr[keyi])
		{
			i++;
		}
		Swap(&arr[i], &arr[j]);
	}

	//此时i和j均为相遇点
	Swap(&arr[keyi], &arr[i]);
	//[left,i - 1]
	QuickSortRe(arr, left, i - 1);
	//[i + 1,right]
	QuickSortRe(arr, i + 1, right);
}

这里选择随机数就使得时间复杂度极大的情况大大减小,这里还有一个重要的点:那就是我们选择到了随机数之后我们要把它换到最左边之后才能继续前面的操作,因为我们后面代码的分析都是基于基准数keyi在最左边实现的,如果keyi不在最左边,就会出现很多不同的情况要分类讨论,比如:keyi在右边,我们先开始的就是左边的下标......为了简化分析和代码,我们就干脆把选出的随机数再放到左边,然后套用前面的模式,这样就好了。

6.1.3三数取中版

基于上面随机数的优化,我们又有一个疑问:就是我们为什么要把这个优化全盘交给随机数,又或者说:交给你的"运气"呢?所以,这里提出了另外一个更加优化的方案:三数取中法。

快排时间复杂度大的根本原因是取到的基准值keyi是整个数组中的最值,这样的话我们递归子问题的时候得到的就是一个栈深度极大,没有很好利用快排优势的一种算法,所以我们前面的随机数法还是有一点点的牵强:万一运气不好,正好每次选到的都是"最值"呢?

那么我们就要好好为这个疑问选择一个折中的方法------三数取中法就是二者的结合。

我们选择的"中"不是指位置上的"中",而是大小上的"中"。只有基准值的大小接近整个范围内的中位数,这样时间复杂度才能很好的被降低,三数取中就是有希望实现这种愿景的一种算法。

类似于随机数,这里的三数取中其实就是得到一个中间值,然后再继续执行相同的操作,至于整个中间值怎么取,可以使用随机值,也可以直接使用范围中间的值,然后再选出处于中间的那个数。

cpp 复制代码
int GetMid(int* arr, int left, int right)
{
	int mid = (left + right) / 2;
	if (arr[mid]>arr[left])
	{
		if (arr[mid]>arr[right])
		{
			if (arr[right] > arr[left])
			{
				return right;
			}
			else
			{
				return left;
			}
		}
	}
	else
	{
		if (arr[mid] > arr[right])
		{
			return mid;
		}
		else
		{
			if (arr[left] > arr[right])
			{
				return right;
			}
			else
			{
				return right;
			}
		}
	}
}
void QuickSortRe(int* arr, int left, int right)
{
	srand((unsigned int)time(NULL));
	//范围[left,right]
	if (left >= right)//分衍子问题最小
	{
		return;
	}
	int keyi = GetMid(arr, left, right);
	Swap(&arr[keyi], &arr[left]);//换到左边继续操作
	keyi = left;//更新keyi
	int i = left;
	int j = right;
	while (i < j)
	{
		while (i < j && arr[j] >= arr[keyi])//右边先走
		{
			j--;
		}
		while (i < j && arr[i] <= arr[keyi])
		{
			i++;
		}
		Swap(&arr[i], &arr[j]);
	}

	//此时i和j均为相遇点
	Swap(&arr[keyi], &arr[i]);
	//[left,i - 1]
	QuickSortRe(arr, left, i - 1);
	//[i + 1,right]
	QuickSortRe(arr, i + 1, right);
}

void QuickSort(int* arr, int n)
{
	QuickSortRe(arr, 0, n - 1);
}

6.1.4小区间优化

递归的时候,我们得到的函数栈帧就是一个类二叉树,假设我们设整个二叉树一共有h层,那么总共的函数调用的次数就是2^0+2^1+2^2+......+2^(h-1) = 2^h - 1次,而最后一层却有大概有2^(h-1)次,这就占了总递归次数的50%,倒数第二层就占了25%,倒数第三层12.5%......这样来说的话,最后三层占总递归次数的80%-90%,十分的不合理,而且最后的基层数据范围都很小,也就是说短短的几个数都要调用函数占用时间,在针对数据量极大的情况下,这样的情况是应该被优化的,我们这个时候就可以选择一个简便,占用小,又简单高效的排序方法。

一般情况下我们都使用稳定又简单的插入排序,只需要在进入快排之前先判断一下这个数据是不是小于某个值,比如我们在排序一亿个数的时候,我们就可以对区间长度小于10甚至是15的部分都进行插入排序,这样的话,我们就可以得到一个更加高效的优化版本递归式快排。这里就提一下思路,不写代码了。

6.2挖坑法

挖坑法和Hoare的原来的方式大同小异,就是把交换这个动作变成了形象的挖坑。

首先我们就要用某种方式把基准数keyi取出,那么keyi处就"留下了一个坑",所以我们就可以再次进行右边下标走动,然后左边下标走动,不同的是,右边下标停下的时候(遇到小于基准值),就立即把这个停下的位置的数转移到"坑"里面,然后原来的位置形成了"新的坑",再左边走,左边同样的操作,填坑挖坑,直到二者相遇,最后一次挖完坑之后,最后的坑就是两个左右下标的相遇点,把keyi放进这个坑里面,排序就完成了。

挖坑法其实就是形象版本的Hoare法,而且对于keyi的取值和其他的优化都可以参照上面的随机数法,三数取中,小区间优化,都是适用的。

6.3前后指针法

前后指针是基于前面所有的思想中,最为简洁的一种优化。

前后指针,也可以叫双指针或者是快慢指针,简直就是神之一笔。

首先,定义两个指针,一个指针prev指向首元素地址,而第二个指针cur指向首元素地址的下一个地址,遍历判断,先判断cur指向的值是不是小于基准值,如果是,则prev++,然后prev和cur中的值交换,cur再++,如果不是,prev不动,cur++,直到遍历完数组,也就是cur指向为空,即cur越界的时候,就可以把prev和基准值交换位置,然后就得到了基准值应该处于的中间位置。

这个思想有点类似单链表中的找中值,这里运用在数组真的是神之一笔,然后再进行前面的递归,最后就得到了排列整齐的数据。这样的方式同时也是一种十分推荐的方式

cpp 复制代码
void QuickSortRe(int* arr, int left, int right)
{
	if (left >= right)
	{
		return;
	}
	int keyi = left;
	int prev = left;
	int cur = prev + 1;
	while (cur <= right)
	{
		if (arr[cur] < arr[keyi])
		{
			prev++;
			Swap(&arr[cur], &arr[prev]);
		}
		cur++;
	}
    Swap(&arr[prev], &arr[keyi]);
	//[left,i - 1]
	QuickSortRe(arr, left, prev - 1);
	//[i + 1,right]
	QuickSortRe(arr, prev + 1, right);
}

void QuickSort(int* arr, int n)
{
	QuickSortRe(arr, 0, n - 1);
}

这里没有使用指针的原因是没有办法判断cur指针越界,所以还是使用下标的方式模拟使用指针,所以叫做"指针法"

6.4非递归实现快速排序

非递归实现快速排序,我们就需要使用其他的数据结构储存我们前面的分隔值然后再进行两边不同的调用,不使用递归而实现这样的功能,我们就要使用一种数据结构------栈

大概的实现思路就是把我们本来需要递归的区间下标存储在一个栈里面,用数据的出栈和入栈来代替递归的过程。

例如我们原本的大区间是[0 - n-1],第一次循环结束之后,我们得到交点下标为m,所以我们按照递归的思路就是再套函数实现[0,m-1]和[m+1 ,n-1]的区间排序。不过我们把这样的动作换成入栈出栈,就是我们先把0和n-1这两个区间下标按照顺序入栈,然后定义一个循环出栈并带入下一层的区间下标,就是把[0,n-1]取出,进行一轮排序之后得到交点m,那么我们就还需要处理[0,m-1]和[m+1,n-1]这两个小区间,我们把它们再按照一定顺序入栈,进入下一层循环的时候再取出使用.以此往复,直到我们把栈里面的所有数据都取出,我们的排序就算完成了,

这就是我们使用栈实现快排非递归的方法。当然,前面的各种后续优化都是可以使用的,只是没有了类似二叉树的函数栈帧建立,取而代之的是我们自己定义的栈。下面是手搓的栈和快排实现代码:

cpp 复制代码
typedef struct Stack
{
	int* a;
	int size;
	int capacity;
}Stack;
//初始化
void StInit(Stack* st)
{
	st->a = NULL;
	st->size = st->capacity = 0;
}
//判断是否为空
bool StEmpty(Stack* st)
{
	return st->size == 0;
}
//入栈
void StPush(Stack* st,int x)
{
	if (st->size == st->capacity)
	{
		int newcapacity = st->capacity == 0 ? 4 : 2 * st->capacity;
		int*tmp = (int*)realloc(st->a,sizeof(int) * newcapacity);
		if (tmp == NULL)
		{
			perror("realloc fail!");
			return;
		}
		st->a = tmp;
		st->capacity = newcapacity;
	}
	st->a[st->size++] = x;
}
//出栈
void StPop(Stack* st)
{
	if (StEmpty(st))
	{
		perror("Pop fail!");
		return;
	}
	st->size--;
}
//取栈顶数据
int StTop(Stack* st)
{
	return st->a[st->size - 1];
}
//栈的销毁
void StDestory(Stack* st)
{
	st->capacity = st->size = 0;
	st->a = NULL;
}
//交换
void Swap(int* s1, int* s2)
{
	int tmp = *s1;
	*s1 = *s2;
	*s2 = tmp;
}
//快排的非递归实现
void QuickSortNoR(int* arr, int n)
{
	Stack st;
	StInit(&st);
	int left = 0;
	int right = n - 1;
	StPush(&st, left);
	StPush(&st, right);
	while (!StEmpty(&st))
	{
		right = StTop(&st);
		StPop(&st);
		left = StTop(&st);
		StPop(&st);
		if (left >= right)
		{
			continue;//跳出当前循环
		}
		int i = left;
		int j = right;
		int keyi = left;
		while (i < j)
		{
			while (i < j && arr[j] >= arr[keyi])
			{
				j--;
			}
			while (i < j && arr[i] <= arr[keyi])
			{
				i++;
			}
			Swap(&arr[i], &arr[j]);
		}
		Swap(&arr[i], &arr[keyi]);
		keyi = i;
		//用栈存储左右子区间下标
		//左
		StPush(&st, left);
		StPush(&st, keyi - 1);
		//右
		StPush(&st, keyi + 1);
		StPush(&st, right);
	}
	StDestory(&st);
}

7.归并排序

归并排序也是一大经典排序。

归并排序的核心逻辑其实和二叉树的逻辑类似,不过归并排序是运用了分衍的思想,二叉树和快排都是用了递归,其实比较类似,只是快排两边的递归的深度是不确定的,有可能会出现一边过长一边过短的情况,而归并用的是平均分的思想,把大问题分成两个几乎相等的小问题,再把每个小问题平均分成两份,直到得到最小子问题,就可以把它完美的解决并且几乎不会出现两边严重不平衡的问题。

7.1归并排序递归实现

首先,我们使用一个图来形象的理解一下归并的过程:

其实我们需要把一个数组排序,我们就只要把这个数组分成两半,然后把每一半都排序,这两部分的排序其实又可以分为更小的子问题,直到出现最小子问题------我们把这些数据都分成了单个的数据,单个的数据看作是一个数组的话,这个数组必定是有序的,这个时候我们就可以开始归并操作了。

归并的话就要把每一个有序的子问题都组合起来,把它们也组合成一个个的有序数组,直到我们组合成为一个完整的大数组,就说明我们完成了排序。

cpp 复制代码
void _MergeSort(int* arr, int left,int right,int* tmp)
{
	//1.分治

	//达到最小子问题------数组分到只有一个
	if (left == right)
	{
		return;
	}
	//得到一个范围为[left,right]的数组
	//取得中位数,分割数组,进行分治
	int mid = (left + right) / 2;
	//printf("left:%d mid:%d right:%d\n", left, mid, right);
	//printf("arr[left]:%d arr[mid]:%d arr[right]:%d\n", arr[left], arr[mid] , arr[right] );
	//[left,mid]
	_MergeSort(arr, left, mid ,tmp);
	//[mid+1,right]
	_MergeSort(arr, mid+1, right,tmp);

	//2.归并
	
	//到达此处则说明分治已经到了最小子问题,开始回归
	//两个区间的始末位置
	int begin1 = left, end1 = mid;
	int begin2 = mid + 1, end2 = right;
	int i = left;
	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+left, tmp+left, sizeof(int) *(right-left+1));
}

void MergeSort(int* arr, int n)
{
	//定义一个储存有序数组的数组
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc fail");
		return;
	}
	_MergeSort(arr, 0, n - 1, tmp);
}

这里就是大概的代码

7.2归并排序非递归实现

同快排的非递归实现,借助一个栈存储区间的下标,然后继续同样的操作。

(写累了,上代码吧)

cpp 复制代码
// 定义一个辅助函数,用于合并两个已排序的子数组
void merge(int arr[], int left, int mid, int right) {
    int i, j, k;
    int n1 = mid - left + 1;
    int n2 = right - mid;
    
    // 创建临时数组
    int L[n1], R[n2];
    
    // 将数据复制到临时数组
    for (i = 0; i < n1; i++)
        L[i] = arr[left + i];
    for (j = 0; j < n2; j++)
        R[j] = arr[mid + 1 + j];
    
    // 合并临时数组到原数组
    i = 0;
    j = 0;
    k = left;
    while (i < n1 && j < n2) {
        if (L[i] <= R[j]) {
            arr[k] = L[i];
            i++;
        } else {
            arr[k] = R[j];
            j++;
        }
        k++;
    }
    
    // 复制剩余的元素
    while (i < n1) {
        arr[k] = L[i];
        i++;
        k++;
    }
    while (j < n2) {
        arr[k] = R[j];
        j++;
        k++;
    }
}

// 非递归归并排序函数
void mergeSort(int arr[], int n) {
    int current_size;  // 当前子数组的大小
    int left_start;    // 左子数组的开始索引
    
    // 从单个元素开始,逐渐增加子数组的大小
    for (current_size = 1; current_size <= n - 1; current_size *= 2) {
        // 选取数组中的两个相邻的子数组并进行合并
        for (left_start = 0; left_start < n - 1; left_start += 2 * current_size) {
            int mid = left_start + current_size - 1;
            int right_end = left_start + 2 * current_size - 1;
            
            // 防止越界
            if (right_end > n - 1)
                right_end = n - 1;
            
            // 合并子数组
            merge(arr, left_start, mid, right_end);
        }
    }
}
相关推荐
南宫生38 分钟前
贪心算法习题其四【力扣】【算法学习day.21】
学习·算法·leetcode·链表·贪心算法
懒惰才能让科技进步1 小时前
从零学习大模型(十二)-----基于梯度的重要性剪枝(Gradient-based Pruning)
人工智能·深度学习·学习·算法·chatgpt·transformer·剪枝
Ni-Guvara2 小时前
函数对象笔记
c++·算法
泉崎2 小时前
11.7比赛总结
数据结构·算法
你好helloworld2 小时前
滑动窗口最大值
数据结构·算法·leetcode
AI街潜水的八角3 小时前
基于C++的决策树C4.5机器学习算法(不调包)
c++·算法·决策树·机器学习
白榆maple3 小时前
(蓝桥杯C/C++)——基础算法(下)
算法
JSU_曾是此间年少3 小时前
数据结构——线性表与链表
数据结构·c++·算法
sjsjs113 小时前
【数据结构-合法括号字符串】【hard】【拼多多面试题】力扣32. 最长有效括号
数据结构·leetcode
此生只爱蛋4 小时前
【手撕排序2】快速排序
c语言·c++·算法·排序算法