【数据结构】——原来排序算法搞懂这些就行,轻松拿捏

前言:快速排序的实现最重要的是找基准值,下面让我们来了解如何实现找基准值

基准值的注释:在快排 的过程中,每一次我们要取一个元素作为枢纽值,以这个数字来将序列划分为两部分。 在此我们采用三数取中法,也就是取左端、中间、右端三个数,然后进行排序,将中间数作为枢纽值。

快速排序实现主框架:

cpp 复制代码
//快速排序 
void QuickSort(int* arr, int left, int right)
{
	if (left >= right)
	{
		return;
	}
    //keyi即是基准值
	int keyi = _QuickSort1(arr, left, right);//实现找基准值的方法

	QuickSort(arr, left, keyi - 1);

	QuickSort(arr, keyi + 1, right);

}

快速排序找基准值三种方法

Swap方法的实现 ,即交换两个数的值

cpp 复制代码
void Swap(int* x, int* y)
{
	int tmp = *x;
	*x = *y;
	*y = tmp;
}

hoare版本

算法思路:

1)创建左右指针,确定基准值

2)从右向左找出比基准值小的数据,从左向右找比基准值大的数据,左右指针数据交换,进入下次循环

问题1:为什么跳出循环后right位置的值⼀定不大于key?

当 left > right 时,即right⾛到left的左侧,而left扫描过的数据均不大于key,因此right此时指向的数据⼀定不大于key

问题2:为什么left 和 right指定的数据和key值相等时也要交换?

相等的值参与交换确实有⼀些额外消耗。实际还有各种复杂的场景,假设数组中的数据⼤量 重复时, 无法进行有效的分割排序。

cpp 复制代码
int _QuickSort1(int* arr, int left, int right)
{
	int keyi = left;
	++left;
	while (left <= right)
	{
		while (left <= right && arr[right] > arr[keyi])
		{
			right--;
		}
		while (left <= right && arr[left] < arr[keyi])
		{
			left++;
		}
		if (left <= right)
		{
			Swap(&arr[left++], &arr[right--]);
		}
	}
	Swap(&arr[keyi], &arr[right]);

	return right;
}

挖坑法

思路: 创建左右指针。首先从右向左找出比基准小的数据,找到后立即放入左边坑中,当前位置变为新的"坑",然后从左向右找出比基准大的数据,找到后立即放入右边坑中,当前位置变为新的"坑",结束循环后将最开始存储的分界值放入当前的"坑"中,返回当前"坑"下标(即分界值下标)

cpp 复制代码
int _QuickSort2(int* arr, int left, int right)
{
	int hole = left;//坑
	int key = arr[hole];//坑位数据

	while (left < right)
	{
		while (left < right && arr[left] >= key)
		{
			--right;
		}
		arr[hole] = arr[right];
		hole = right;

		while (left < right && arr[left] < key)
		{
			++left;
		}
		arr[hole] = arr[left];
		hole = left;
	}
	arr[hole] = arr[left];
	return hole;
}

lomuto前后指针

创建前后指针,从左往右找比基准值小的进行交换,使得小的都排在基准值的左边。

cpp 复制代码
int _QuickSort3(int* arr, int left, int right)
{
	int prev = left, cur = left + 1;
	int keyi = left;
	while (cur <= right)
	{
		if (arr[cur] < arr[keyi] && ++prev != cur)
		{
			Swap(&arr[cur], &arr[prev]);
		}
		++cur;
	}
	Swap(&arr[keyi], &arr[prev]);
	return prev;
}

以上就是快速排序当中查找基准值的三种方法

快速排序特性总结:

  1. 时间复杂度:O(nlogn)

  2. 空间复杂度:O(logn)

快速排序非递归版本,借助数据结构栈

cpp 复制代码
void QuickSortNonR(int* arr, int left, int right)
{
	ST st;
	STInit(&st);//栈的初始化
	STPush(&st, right);//压栈
	STPush(&st, left);//压栈

	while (!STEmpty(&st))
	{
		//取栈顶元素两次
		int begin = STTop(&st);
		STPop(&st);
		int end = STTop(&st);
		STPop(&st);
		//找基准值[begin,end]

		int prev = begin;
		int cur = begin + 1;
		int keyi = begin;

		while (cur <= end)
		{
			if (arr[cur] < arr[keyi] && ++prev != cur)
			{
				Swap(&arr[cur], &arr[prev]);
			}
			cur++;
		}
		Swap(&arr[keyi], &arr[prev]);

		keyi = prev;
		//根据基准值划分左右区间
		//左区间:[begin,keyi-1]
		//右区间:[keyi+1,end]

		if (keyi + 1 < end)
		{
			STPush(&st, end);
			STPush(&st,keyi + 1);
		}
		if (keyi - 1 > begin)
		{
			STPush(&st, keyi - 1);
			STPush(&st, begin);
		}
	}

	STDestroy(&st);//销毁
}

冒泡排序

冒泡排序核心思想是通过反复遍历待排序的序列,比较相邻的元素并交换它们的位置,使得每一趟遍历后,最大的元素逐渐"冒泡"到序列的末尾。

核心步骤如下:

  1. 比较相邻的元素:从第一个元素开始,依次比较相邻的两个元素。

    • 如果前一个元素大于后一个元素,则交换它们的位置。
  2. 继续遍历序列:一趟遍历后,最大的元素会被"冒泡"到序列的末尾。

  3. 重复遍历:从头开始再进行遍历,对剩下的元素重复比较和交换操作,直到所有元素都按顺序排列。

  4. 优化(可选):如果在某一趟遍历中没有发生任何交换,说明序列已经有序,可以提前终止排序。

cpp 复制代码
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²),由于每次都要遍历未排序的部分,并且重复多次比较操作,因此效率较低。

直接插入排序

直接插入排序(Direct Insertion Sort)的原理是通过逐步将待排序数组中的元素插入到已排序部分的正确位置,最终实现排序。它是一种简单且直观的排序算法,尤其适用于小规模或近乎有序的数据集。

原理步骤:

  1. 初始化已排序序列:假设数组的第一个元素已经是有序的,直接跳过。

  2. 逐个插入元素:从第二个元素开始,逐个将每个元素插入到前面已经排好序的部分中。

    • 将当前元素与前面已经排序好的元素依次比较,找到它的正确位置。
    • 如果当前元素比已排序部分的某个元素小,则将已排序部分的元素向后移动,空出位置给当前元素。
  3. 插入完成:重复这个过程,直到所有元素都被插入正确位置,排序完成。

举例说明:

假设有一个数组 [5, 2, 9, 1, 5, 6] 需要排序,步骤如下:

  • 初始数组:[5, 2, 9, 1, 5, 6]
  • 第一轮(从第二个元素开始,即 2):比较 2 和 5,2 小于 5,将 5 向后移,插入 2。得到:[2, 5, 9, 1, 5, 6]
  • 第二轮(9):9 比 5 大,直接进入下一个。得到:[2, 5, 9, 1, 5, 6]
  • 第三轮(1):1 小于 9、5 和 2,将它们都向后移,插入 1。得到:[1, 2, 5, 9, 5, 6]
  • 第四轮(5):5 小于 9,移位插入 5。得到:[1, 2, 5, 5, 9, 6]
  • 第五轮(6):6 小于 9,移位插入 6。得到:[1, 2, 5, 5, 6, 9]
cpp 复制代码
void InsertSort(int* arr, int n)
{
	for (int i = 0; i < n - 1; i++)
	{
		int end = i;
		int tmp = arr[end + 1];
		while (end >= 0)
		{
			if (arr[end] > tmp)
			{
				arr[end + 1] = arr[end];
				end--;
			}
			else
			{
				break;
			}
		}
		arr[end + 1] = tmp;
	}
}

希尔排序

希尔排序(Shell Sort)是插入排序的改进版,它通过比较和交换不相邻的元素来减少数据的移动次数,以加速排序过程。希尔排序的核心思想是将待排序的序列按一定间隔分组,分别对每一组进行插入排序,逐步缩小间隔,直到间隔为 1 时,完成最终的插入排序。

原理步骤:

  1. 确定间隔序列:首先选择一个间隔(也称"增量"),将整个序列按照这个间隔进行分组。例如,间隔为 5 时,第 1 个元素与第 6 个元素、第二个元素与第 7 个元素形成一组,依次类推。

  2. 组内排序:对每一组进行插入排序。在每个组内的元素之间的距离由间隔确定,插入排序的操作类似于直接插入排序,但由于间隔较大,能使元素迅速向正确的位置移动,减少了总的移动次数。

  3. 缩小间隔:缩小间隔并重复步骤 2。常见的缩小方式是将间隔减半,直到间隔为 1。

  4. 完成排序:当间隔为 1 时,整个序列已经近乎有序,最后一次插入排序将序列排好。

举例说明:

假设对数组 [23, 29, 15, 19, 31, 7, 9, 5, 2] 进行希尔排序,初始数组如下:

  • 原数组:[23, 29, 15, 19, 31, 7, 9, 5, 2]

第一步:假设初始间隔为 4,将数组分组进行插入排序:

  • 对于第 1, 5, 9 号元素:[23, 31, 2] 进行排序,结果为 [2, 23, 31]。
  • 对于第 2, 6 号元素:[29, 7] 进行排序,结果为 [7, 29]。
  • 对于第 3, 7 号元素:[15, 9] 进行排序,结果为 [9, 15]。
  • 对于第 4, 8 号元素:[19, 5] 进行排序,结果为 [5, 19]。

结果数组:[2, 7, 9, 5, 23, 29, 15, 19, 31]

第二步:缩小间隔到 2,继续分组排序:

  • 对于第 1, 3, 5, 7 号元素:[2, 9, 23, 15] 进行排序,结果为 [2, 9, 15, 23]。
  • 对于第 2, 4, 6, 8 号元素:[7, 5, 29, 19] 进行排序,结果为 [5, 7, 19, 29]。

结果数组:[2, 5, 9, 7, 15, 19, 23, 29, 31]

第三步:间隔为 1 时,进行最后一次插入排序,得到最终排序结果:[2, 5, 7, 9, 15, 19, 23, 29, 31]

cpp 复制代码
void ShellSort(int* arr, int n)
{
	int gap = n;
	while (gap > 1)
	{
		gap = gap / 3;
		for (int i = 0; i < n - gap; i++)
		{
			int end = i;
			int tmp = arr[end + gap];
			while (end >= 0)
			{
				if (arr[end] > tmp)
				{
					arr[end + gap] = arr[end];
					end -= gap;
				}
				else
				{
					break;
				}
			}
			arr[end + gap] = tmp;

		}
	}
}

归并排序

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

cpp 复制代码
void _MergeSort(int* arr, int left, int right, int* tmp)
{
	//分开
	if (left >= right)
	{
		return;
	}
	int mid = (left + right) / 2;

	_MergeSort(arr, left, mid, tmp);
	_MergeSort(arr, mid + 1, right, tmp);

	//合并
	int begin1 = left, end1 = mid;
	int begin2 = mid + 1, end2 = right;
	int index = begin1;

	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);
}

1. 时间复杂度: O(nlogn)

2. 空间复杂度: O(n)

计数排序

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

操作步骤:

1)统计相同元素出现次数

2)根据统计的结果将序列回收到原来的序列中

cpp 复制代码
void CountSort(int* arr, int n)
{
	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!");
		exit(1);
	}
	//初始化range中的数据为0
	memset(count, 0, range * sizeof(int));

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

这五种排序的优缺点

1. 快速排序:

  • 优点 :
    • 平均时间复杂度为 O(nlog⁡n)O(n \log n)O(nlogn),非常高效。
    • 原地排序,不需要额外的存储空间(递归调用栈除外)。
    • 在实际应用中表现出色,尤其对大数据集。
  • 缺点 :
    • 最坏情况下时间复杂度为 O(n2)O(n^2)O(n2),特别是在数据有序时。
    • 不稳定,可能改变相同元素的相对顺序。
    • 对递归栈空间有要求,可能导致栈溢出。

2. 希尔排序:

  • 优点 :
    • 改进了插入排序,通过使用间隔的分组排序减少了移动次数。
    • 时间复杂度一般为 O(n1.3−n2)O(n^{1.3} - n^{2})O(n1.3−n2),对于中等规模的数据表现良好。
    • 原地排序,不需要额外空间。
  • 缺点 :
    • 不是稳定排序,相同元素可能打乱顺序。
    • 性能依赖于选取的增量序列,难以分析其最优时间复杂度。
    • 实现相对复杂。

3. 直接插入排序:

  • 优点 :
    • 实现简单,适合少量元素时的排序。
    • 稳定排序,保持相同元素的相对顺序。
    • 对于几乎有序的数组非常高效(接近 O(n)O(n)O(n))。
  • 缺点 :
    • 时间复杂度为 O(n2)O(n^2)O(n2),对大规模数据效率低。
    • 需要频繁的元素移动,尤其是当数据无序时。

4. 归并排序:

  • 优点 :
    • 时间复杂度为 O(nlog⁡n)O(n \log n)O(nlogn),在最坏情况下仍能保持高效。
    • 稳定排序,保持相同元素的相对顺序。
    • 可用于链表等不连续存储的数据结构。
    • 非原地排序,但可以用外部排序实现超大数据集的排序。
  • 缺点 :
    • 需要额外的空间 O(n)O(n)O(n),对于内存敏感的应用不是很理想。
    • 实现相对复杂。

5. 计数排序:

  • 优点 :
    • 时间复杂度为 O(n+k)O(n+k)O(n+k),对数据范围 kkk 相对较小的整数数据集非常高效。
    • 稳定排序,保持相同元素的相对顺序。
    • 不涉及比较,适用于一些特殊的场景,如成绩排名等。
  • 缺点 :
    • 需要额外的存储空间 O(k)O(k)O(k),当数据范围大时,空间消耗可能过高。
    • 只能处理整数或离散类型数据,无法处理浮点数或复杂类型数据。
    • 对于数据范围远大于数据量的情况,效率不高。
相关推荐
Chris _data17 分钟前
二叉树oj题解析
java·数据结构
დ旧言~29 分钟前
【高阶数据结构】图论
算法·深度优先·广度优先·宽度优先·推荐算法
张彦峰ZYF34 分钟前
投资策略规划最优决策分析
分布式·算法·金融
The_Ticker1 小时前
CFD平台如何接入实时行情源
java·大数据·数据库·人工智能·算法·区块链·软件工程
Lenyiin1 小时前
02.06、回文链表
数据结构·leetcode·链表
爪哇学长1 小时前
双指针算法详解:原理、应用场景及代码示例
java·数据结构·算法
爱摸鱼的孔乙己1 小时前
【数据结构】链表(leetcode)
c语言·数据结构·c++·链表·csdn
Dola_Pan1 小时前
C语言:数组转换指针的时机
c语言·开发语言·算法
繁依Fanyi2 小时前
简易安卓句分器实现
java·服务器·开发语言·算法·eclipse
烦躁的大鼻嘎2 小时前
模拟算法实例讲解:从理论到实践的编程之旅
数据结构·c++·算法·leetcode