【数据结构的排序算法3】冒泡排序与快速排序详解

文章详细介绍了冒泡排序的基本思想、代码实现、性能优化及其时间复杂度分析。接着深入探讨了快速排序,包括基本思想、递归实现的代码框架,重点讲解了Hoare方法、挖坑法和前后指针法,并讨论了时间复杂度和优化策略,如三数取中和小区间优化。
摘要由CSDN通过智能技术生成

🙊 冒泡排序🙊

💖 基本思想

冒泡排序属于交换排序,所谓交换排序就是就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。说直白点就是从左向右走,相邻的两个数进行比较,如果满足条件就交换。

在这里插入图片描述

💖 代码实现

要先写单趟,然后再控制整体,因为一次排序需要将最大的数据拿到数组最后,然后排序的个数-1,所以最外层的 for 循环 i 是 小于 n,因为里面的 for 循环 j 是从 0 开始,最后避免越界结束条件是是小于 n - i - 1 。代码如下:

void BubbleSort(int* a, int n)
{
	for (int i = 0; i < n; ++i)
	{
		for (int j = 0; j < n - i - 1; j++)
		{
			if (a[j] > a[j + 1])
			{
				swap(&a[j], &a[j + 1]);
			}
		}
	}
}

💖 冒泡排序的性能优化

考虑这样一种情况:如果在冒泡排序的过程中,如果单趟排序没有发生交换,说明数组中每一个前位置的数都小于后位置的数,说明数组已经有序,不用再继续冒泡了。所以对代码做出以下修改:

void BubbleSort(int* a, int n)
{
	for (int i = 0; i < n; ++i)
	{
		int change = 0;
		for (int j = 0; j < n - i - 1; j++)
		{
			if (a[j] > a[j + 1])
			{
				swap(&a[j], &a[j + 1]);
				change = 1;
			}
		}
		//如果change没有变,说明一趟排序中没有交换数据,证明已经有序,不需要处理了
		if (change == 0)
		{
			break;
		}
	}
}

注意:

在乱序的情况下,这种优化效果就不明显,基本不起作用,只有前面某一部分有序或者整体有序,这种优化才起到效果。

💖 时间复杂度分析

冒泡排序的总执行次数是一个公差为 − 1 的等差数列: ( n − 1 ) + ( n − 2 ) + . . . + 1,根据等差数列求和公式得最终的时间复杂度为:O(N2) ​ ,而通过上面的分析知改进后的冒泡排序有一定的局限性,对时间复杂度并没有很好的适应性提升,所以即使经过改进,冒泡排序的时间复杂度仍然为O(N2)

🙊快速排序🙊

💖 基本思想

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

💖 递归方式实现快速排序的代码框架

1、快排为交换排序的一种。快排在开始时,会选择一个 key 做基准值,然后进行单趟排序,单趟排序后,会把 key 的索引或 key 值本身与边界某一值交换,形成区间划分。

2、区间划分通常为 key 左边的值都小于 keykey 右边的值都大于 key ,这样就使得区间被划分了。中间的 key 值不用管,当前 key 值已经到了正确的位置。那么现在排序就变为:对左区间和右区间的排序

3、每次排序后都会确定一个元素的位置,确定位置后继续划分子区间…这样的过程实际上就是递归,通过递归,对设定的基准值分割开的左右区间完成排序。

4、递归的返回条件为 左区间索引 右区间索引 ,说明区间只有 1 个元素或无元素就返回。

代码框架如下图所示:

void QuickSort(int* a, int begin, int end)
{
	if (begin >= end)
	{
		return;
	}
	// 对左右区间进行划分
    int key = partion(a, begin, end);
    // 递归左右区间
    QuickSort(a, begin, key - 1);
    QuickSort(a, key + 1, end);
}

上述为快速排序递归实现的主框架,我们可以发现与这个框架与二叉树前序遍历非常像,最后我们只要分析如何按照 基准值 来对区间中数据进行划分即可。

而按照基准值划分区间的方法有三种,分别为 hoare 版本、挖坑法双指针版本 ,接下来我们一一讲解。

💖 递归方式实现快速排序 — hoare 方法

💖 单趟排序

1、单趟排序基本思想就是一般选出数组中最左边的数或最右边的数为 key,本文以最左边做 key 为例,默认升序排列,选最左边为 key,让右边先走找比 key 小的数。

在这里插入图片描述

2、右边找到后就停下

在这里插入图片描述

3、此时左边开始找比 key 大的数。

在这里插入图片描述

4、找到后最小和最大进行交换,

在这里插入图片描述

5、继续按照此规则寻找

在这里插入图片描述

6、直到相遇就停止

在这里插入图片描述

7、将相遇位置与 key 进行交换,保证了左边比 key 要小,右边比 key 要大。

![在这里插入图片描述](https://img-blog.csdnimg.cn/30fe07ae0a5b491ab54a4e784cf5c844.png

单趟排序的意义:
1、是分割出了左右区间,左区间比 key 要小,右区间比 key 要大
2、key 已经落到了正确的位置(排序后的最终位置)

剩下的问题:

如果左区间有序、右区间有序,那么整体就完成了排序,但是左区间和右区间无序,就可以利用递归的思想,进行子问题转换。 图示如下:

在这里插入图片描述

总结:

1、左边做 key 让右边先走,让右边做 key,左边先走,因为只有这样才能保证相遇位置比 key

2、相遇时 r 停住了,l 遇到 r,相遇的位置就是 r 停住的位置

3、相遇时 l 停住了,r 遇到 l,相遇的位置就是 l 停住的位置

4、以上不管谁与谁相遇,相遇的位置都比 key 要小

💖 动态图示

在这里插入图片描述

💖 单趟排序代码实现及注意事项

根据上面介绍的思想,写出如下代码:

int PartSort1(int* a, int n)
{
	int left = 0, right = n - 1;
	//选左边做key
	int key = a[left];
	//相遇就结束
	while (left < right)
	{
		while (a[right] > key)
		{
			--right;
		}
		//左边再走找大
		while (a[left] < key)
		{
			++left;
		}
		//此时左右两边都找到了就交换
		swap(&a[right], &a[left]);
	}
	//相遇以后就和key交换
	swap(&key, &a[left]);
}

此时需要注意,此段代码里面有很多的 bug,导致某些特殊情况下会出现问题:

情形一:

key 右边的值都比 key 大,此时 right 一直到 key 的位置才停下,左边的值一直不动,这时情况正常。

在这里插入图片描述

情形二:

如果中间有和 key 相等的值的时候,右边走到和 key 相等值的位置就停下,左边一直不动,然后二者进行交换并进入下一次 while 循环,导致程序陷入死循环。

在这里插入图片描述

所以此时的程序需要进行如下更改,将内层的两个 while 循环的条件进行更改。

代码如下:

int PartSort1(int* a, int n)
{
	int left = 0, right = n - 1;
	//选左边做key
	int key = a[left];
	//相遇就结束
	while (left < right)
	{
		while (a[right] >= key)
		{
			--right;
		}
		//左边再走找大
		while (a[left] <= key)
		{
			++left;
		}
		//此时左右两边都找到了就交换
		swap(&a[right], &a[left]);
	}
	//相遇以后就和key交换
	swap(&key, &a[left]);
}

但是更改之后又会出现一个问题,即情形一会出现越界的情况:

在这里插入图片描述

此时还需要进行代码的更改:

1、就是在内层 while 循环中加入 left < right 的判断,因为外层 while 循环是进行初始的判断,而里面的两层循环在满足初始条件后,会更新 leftright 的位置,所以还需要加上 left < right 的判断。

2、最后的 swap 函数也有一些问题,最后是需要跟数组的最左边的元素进行交换,而 key 是一个局部变量,所以不能跟 key 交换,这里的代码需要做出更改,即将 key 看作是下标,让其等于区间左端点的位置即 left,这样在最后的交换函数中,才是区间左端点与相遇位置的值进行交换。

3、函数的传参也需要做出相应的改变,需要传递的是左右区间的位置。

4、单趟排序完成后,被分成三段区间:[ begin , key - 1][ key ][ key + 1, end ]

至此 hoare 版本的快速排序单趟排序就实现完成,注意避开提到过的这几个坑,单趟排序代码实现如下:

int PartSort1(int* a, int n)
{
	int left = 0, right = n - 1;
	//选左边做key
	int key = left;
	//相遇就结束
	while (left < right)
	{
		//让右边先走,找小,如果右边大于key,就--right继续找,找到就退出while
		//注意这里的条件是 >= 而不是 > ,因为如果是大于当遇到和key相等的值时就会陷入死循环
		//而如果右边都比key要大,直到走到key的位置,此时a[right]仍然满足while条件,再--right就会越界
		//所以此时需要加上一个判断条件 left<right
		while (left < right && a[right] >= a[key])
		{
			--right;
		}
		//左边再走找大
		while (left < right && a[left] <= a[key])
		{
			++left;
		}
		//此时左右两边都找到了就交换
		swap(&a[right], &a[left]);
	}
	//相遇以后就和key交换
	swap(&a[key], &a[left]);
}

💖 快速排序 hoare 方法实现总代码

如上可知,单趟排序完成后被分成了 [ begin , key - 1][ key ][ key + 1, end ] 三个区间,此时需要进行子区间的递归,将左右两个区间再进行排序。当区间不存在或者只有一个元素的时候,说明已经排好当前区间,就进行返回。

代码如下:

void QuickSort(int* a, int begin,int end)
{
	if (begin >= end)
	{
		return;
	}

	int left = begin, right = end;
	//选左边做key
	int key = left;
	//相遇就结束
	while (left < right)
	{
		//让右边先走,找小,如果右边大于key,就--right继续找,找到就退出while
		//注意这里的条件是 >= 而不是 > ,因为如果是大于当遇到和key相等的值时就会陷入死循环
		//而如果右边都比key要大,直到走到key的位置,此时a[right]仍然满足while条件,再--right就会越界
		//所以此时需要加上一个判断条件 left<right
		while (left < right && a[right] >= a[key])
		{
			--right;
		}
		//左边再走找大
		while (left < right && a[left] <= a[key])
		{
			++left;
		}
		//此时左右两边都找到了就交换
		swap(&a[right], &a[left]);
	}
	//相遇以后就和key交换
	swap(&a[key], &a[left]);
	key = left;

	QuickSort(a, begin, key - 1);
	QuickSort(a, key + 1, end);

}

💖 递归展开图

程序的递归展开图如下:

在这里插入图片描述

💖 时间复杂度分析

快速排序理想的状态:单趟排序每次的 key 都选到中间的位置, 每次递归都处理一个数,剩下的就进行递归处理,假设一共有 N 个值,每次递归处理一个数,根据学过的二叉树知识,其高度为 logN,每一层处理的节点个数都少一个。这种情况的时间复杂度为 N * logN

在这里插入图片描述

但是这种情况是理想的情况,即每次都在中间找到 key,那么最坏的情况是每次 key 都到最左边或者最右边,那么每次选 key 都是最大或者最小,那么其时间复杂度就是 N + N-1 + N-2 + …,最后就是 O(N^2)

在这里插入图片描述

💖 快速排序改进–三数取中

针对以上问题,提出一种三数取中的方式对快速排序进行一些优化。如果只选左边的数或者右边的数做 key,如果是最坏的情况,时间复杂度就会显著提升,由于快速排序只规定了单趟排序后左边比 key 小而右边比 key 大就可以,并没有规定如何排序,所以可以取最左边、最右边和最中间三个数中不是最小也不是最大的值与最左边的值进行交换,还是让最左边的值做 key ,换完以后再走单趟排序。
三数取中思想是两两比较,选出中间值的位置,将中间值与最左边的值进行交换,仍然让最左侧做 key,代码如下:

//三数取中,不用考虑将 == 的情况放到else里面判断,只需要判断 > 和 < 的情况。
int GetMidIndex(int* a, int begin, int end)
{
	int mid = (begin + end) / 2;
	if (a[begin] < a[mid])
	{
		if (a[mid] < a[end])
		{
			return mid;
		}
		else if (a[begin] > a[end])
		{
			return begin;
		}
		else
		{
			return end;
		}
	}
	else
	{
		if (a[mid] > a[end])
		{
			return mid;
		}
		else if (a[begin] < a[end])
		{
			return begin;
		}
		else
		{
			return end;
		}
	}
}

💖 三数取中时间复杂度分析

三数取中以后,快速排序就从最坏的情况变成最好的情况,不会出现最坏的情况,所以其时间复杂度可以认为是 O(N * logN)

💖 小区间优化

试想一下,快速排序不断向下递归时,到后面几层只有很少的几个数,假设递归到后面某层只有 10 个数,10 个数用递归排序需要建立函数栈帧,是不是付出了很大的性能代价,所以针对这点,可以进行小区间优化,就是只有 10 个数据时,使用直接插入排序将这 10 个数排好顺序,这样就避免了大部分的递归。
在这里插入图片描述由于还要介绍几种快速排序的方法,所以这里先将单趟排序的代码提取出来,然后其他过程都不变,只需要改变单趟排序调用的函数就可以。

	//三数取中,不用考虑将 == 的情况放到else里面判断,只需要判断 > 和 < 的情况。
int GetMidIndex(int* a, int begin, int end)
{
	int mid = (begin + end) / 2;
	if (a[begin] < a[mid])
	{
		if (a[mid] < a[end])
		{
			return mid;
		}
		else if (a[begin] > a[end])
		{
			return begin;
		}
		else
		{
			return end;
		}
	}
	else
	{
		if (a[mid] > a[end])
		{
			return mid;
		}
		else if (a[begin] < a[end])
		{
			return begin;
		}
		else
		{
			return end;
		}
	}
}

// Hoare
int PartSort1(int* a, int begin, int end)
{
	int mid = GetMidIndex(a, begin, end);
	swap(&a[begin], &a[mid]);

	int left = begin, right = end;
	int keyi = left;
	while (left < right)
	{
		// 右边先走,找小
		while (left < right && a[right] >= a[keyi])
		{
			--right;
		}

		// 左边再走,找大
		while (left < right && a[left] <= a[keyi])
		{
			++left;
		}

		swap(&a[left], &a[right]);
	}

	swap(&a[left], &a[keyi]);
	keyi = left;

	return keyi;
}

void QuickSort(int* a, int begin, int end)
{
	if (begin >= end)
	{
		return;
	}

	if ((end - begin + 1) < 15)
	{
		// 小区间用直接插入替代,减少递归调用次数
		InsertSort(a + begin, end - begin + 1);
	}
	else
	{
		int keyi = PartSort3(a, begin, end);

		// [begin, keyi-1]  keyi [keyi+1, end]
		QuickSort(a, begin, keyi - 1);
		QuickSort(a, keyi + 1, end);
	}
}

💖 递归方式实现快速排序—挖坑法

💖 挖坑法思想

1、先留出一个坑位,将坑位的值给 key 变量进行保存,左边是坑先让右边先走找小。

在这里插入图片描述
2、右边找到小之后,将找到的数放到坑里面,然后形成新的坑位。

在这里插入图片描述
3、此时左边再找比 key 大的值,找到以后填坑,并形成新的坑位。

在这里插入图片描述
4、此时右边继续找小,找到后填坑并形成新的坑位

在这里插入图片描述
5、左边继续找大,找到后填坑并形成新的坑位。

在这里插入图片描述
6、右边继续找小,找到后填坑并形成新的坑位

在这里插入图片描述
7、此时左边再继续走二者相遇就停下,将 key 放到坑里面。

在这里插入图片描述

💖 挖坑法动态图示

在这里插入图片描述

💖 挖坑法代码

// 挖坑法
int PartSort2(int* a, int begin, int end)
{
	int mid = GetMidIndex(a, begin, end);
	swap(&a[begin], &a[mid]);

	int left = begin, right = end;
	int key = a[left];
	int hole = left;
	while (left < right)
	{
		// 右边找小,填到左边坑里面
		while (left < right && a[right] >= key)
		{
			--right;
		}
		//将右边的数填到坑里面
		a[hole] = a[right];
		//自己变成坑
		hole = right;

		// 左边找大,填到右边坑里面
		while (left < right && a[left] <= key)
		{
			++left;
		}
		//将左边的数填到坑里面
		a[hole] = a[left];
		//自己变成坑
		hole = left;
	}
	//相遇就将key填到坑里面
	a[hole] = key;
	//坑的位置就是key的位置,将其返回
	return hole;
}

💖 递归方式实现快速排序—前后指针法

💖 基本思想

选左边为 key 或者右边为 key,定义一前一后两个指针 prevcurcur 指针向右找比 key 小的值,找到后停下来。++prev 并交换 prevcur 的位置。

在这里插入图片描述
1、cur 找到小后,prev++ 并进行交换

在这里插入图片描述
2、cur 继续找小,找到后 ++prev 并进行交换

在这里插入图片描述

3、cur 继续找小,找到后注意此时 prev 已经与 cur 拉开了差距

在这里插入图片描述
4、 ++prev 并进行交换,注意此时已经拉开差距并改变了数组

在这里插入图片描述
5、cur 再继续往后走,遇到比 key 小的停下,++prev 并进行交换

在这里插入图片描述
6、cur 再继续往后走,遇到比 key 小的停下,++prev 并进行交换

在这里插入图片描述
7、直到 cur 找不到小就结束,结束时 prev 是比 key 大的前一个位置

在这里插入图片描述
8、将 key 位置的值和 prev 位置的值进行交换,更新 key 位置

在这里插入图片描述

总结:

1、如果前面遇到的值都比 key 小,prev 就紧跟着 cur,当遇到比 key 大的,curprev 就拉开了差距,没有发生交换之前中间间隔的是比 key 大的数据

2、最后 prev 位置的值与 key 位置的值交换,最后 prevkey 同一个位置

💖 动态图示

在这里插入图片描述

💖 前后指针代码

int PartSort3(int* a, int begin, int end)
{
	int mid = GetMidIndex(a, begin, end);
	swap(&a[begin], &a[mid]);

	int keyi = begin;
	int prev = begin, cur = begin + 1;
	while (cur <= end)
	{
		// 找到比key小的值时,跟++prev位置交换,小的往前翻,大的往后翻
		//为了避免cur和prev为同一位置的情况没有必要交换的问题,这里使用++prev != cur判断
		if (a[cur] < a[keyi] && ++prev != cur)
			swap(&a[prev], &a[cur]);
		//无论什么情况cur都要往后走
		++cur;
	}

	swap(&a[prev], &a[keyi]);
	keyi = prev;
	return keyi;
}

💖 非递归方式实现快速排序

快速排序的非递归需要借助栈来实现,因为区间存在栈帧里面,递归的目的是继续对区间进行划分,由于栈帧里存的是区间,现在要用栈来模拟这个过程。

在这里插入图片描述
1、最开始先将 09 进栈


在这里插入图片描述
2、栈不为空取出 09 进行单趟排序,排完以后第一个 key 的位置就定了。然后此时按照递归的思想被分成了两段区间,如果先排左边再排右边,需要先将右边的区间进行压栈,再将左边的区间进行压栈。

在这里插入图片描述
3、此时先出的是左区间的右边界,进行单趟排序。 进行单趟排序后又分成了左右区间。

在这里插入图片描述

4、再入右区间,再入左区间, 由于此时栈里面还有 96 所以继续压栈,由于被分成的右子区间只有一个值,所以不进行压栈,只需将左区间进行压栈即可。

在这里插入图片描述

5、依照此方式依次进行压栈、出栈和排序,直到完成整个排序即可。

💖 代码实现

void QuickSortNonR(int* a, int begin, int end)
{
	ST st;
	StackInit(&st);
	//将区间左端点进行压栈
	StackPush(&st, begin);
	//将区间右端点进行压栈
	StackPush(&st, end);

	//当栈不为空就执行里面的操作
	while (!StackEmpty(&st))
	{
		//right保存栈顶数据,也就是压进去的右端点值
		int right = StackTop(&st);
		//将区间右端点出栈
		StackPop(&st);
		//left保存栈顶数据,也就是压进去的左端点的值
		int left = StackTop(&st);
		//将区间左端点出栈
		StackPop(&st);

		//排出本次左右区间的单趟排序
		int keyi = PartSort3(a, left, right);
		// 排序完后本分成三段区间:[left, keyi-1] keyi [keyi+1, right]
		
		//判断如果左右区间存在且有两个以上的元素继续进行压栈
		//继续先压右区间 
		if (keyi+1 < right)
		{
			StackPush(&st, keyi + 1);
			StackPush(&st, right);
		}
		//再压左区间
		if (left < keyi-1)
		{
			StackPush(&st, left);
			StackPush(&st, keyi - 1);
		}
	}

	StackDestroy(&st);
}

💖 快速排序补充说明

当有大量重复的数据,如果 key 是重复数据时,就会有性能下降的问题,以前是以 key 为分割点,将其分为比 key 小和比 key 大的两部分,现在可以进行一下改进,将其分为比 key 小、等于 key 和比 key 大三段。

在这里插入图片描述
如下图 leftcurright 初始位置和 key 值都已初始化完成,主要看 cur,分如下三种情况:

1、如果其 cur 位置小于 key ,交换 leftcur 的位置的值,left++cur++

2、如果其 cur 位置的值等于 key,只将 cur++

3、如果 cur 位置的值大于 key,交换 curright 位置的值,将 right–。

初始情况如下图所示:

在这里插入图片描述

此时 cur 位置的值小于 key,交换 cur 位置与 left 位置的值,cur++left++

在这里插入图片描述
此时 cur 位置的值等于 key,将 cur++

在这里插入图片描述
此时 cur 位置的值大于 key。交换 cur 位置的值与 right 位置的值,然后 right–

在这里插入图片描述
此时 cur 位置的值仍然小于 right 位置的值,继续重复上过程。

在这里插入图片描述
此时 cur 位置的值等于 key 时,就将 cur++,由于中间一直重复此过程,所以直接跳到此步。

在这里插入图片描述
此时 cur 位置小于 key ,交换 leftcur 的位置的值,left++cur++

在这里插入图片描述
此时 cur 位置的值小于 key,交换 cur 位置与 left 位置值,cur++left++

在这里插入图片描述
此时 cur 位置超过 right 位置,且区间被分成三份,整个过程结束。

核心思想总结:

1、还是三数取中最左边或最右边做 key

2、将与 key 相等的值往中间推

3、比 key 小的值甩到左边

4、比 key 大的值甩到右边

5、跟 key 相等的就在中间。

代码实现:

void QuickSort(int* a, int begin, int end)
{
	if (begin >= end)
	{
		return;
	}
	if ((end - begin + 1) < 15)
	{
		// 小区间用直接插入替代,减少递归调用次数
		InsertSort(a+begin, end - begin + 1);
	}
	else
	{
		//三数取中选最左边的做key
		int mid = GetMidIndex(a, begin, end);
		Swap(&a[begin], &a[mid]);
		int left = begin, right = end;
		int key = a[left];
		//定义cur
		int cur = begin + 1;
		//当cur比right大就结束循环
		while (cur <= right)
		{
			//情形一
			if (a[cur] < key)
			{
				Swap(&a[cur], &a[left]);
				cur++;
				left++;
			}
			//情形二
			else if (a[cur] > key)
			{
				Swap(&a[cur], &a[right]);
				--right;
			}
			//情形三
			else // a[cur] == key
			{
				cur++;
			}
		}

		//最后被分成了三段区间
		// [begin, left-1][left, right][right+1,end]
		//递归处理左区间
		QuickSort(a, begin, left-1);
		//递归处理右区间
		QuickSort(a,right+1, end);
	}
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值