排序专题(插入,)

1.插入排序

1.1 核心思想:

把数组分成有序区间+无需区间两个部分,

假设位置0,end的值都是按升序排列的,把end+1位置的值保存到变量tmp里,end位置的值大于tmp就往后挪一位,end也走到end-1,一直比较直到成功插入该数,然后进行下一轮比较。

先写单趟排序的过程,再写整体。

1.2 代码:

1.3 复杂度

**·**时间复杂度:O(N^2)
逆序的情况下最坏:O(N^2)
顺序的情况下最好:O(N))
**·**空间复杂度:O(1),原地排序,只开临时变量,不额外开辟数组

2.冒泡排序

2.1 核心思想:

重复遍历数组相邻两个元素,逆序则交换;每一轮外层循环,会把当前无序区间里最大的数"冒泡"到区间末尾。

**优化点:**设置flag标记,如果一整轮遍历没有发生任何交换,代表数组已经完全有序,直接提前跳出循环,不用继续遍历。

2.2 代码:

2.3 复杂度

**·**时间复杂度:O(N^2)

**·**空间复杂度:O(1)

3.希尔排序

3.1 核心思想:

希尔排序是插入排序的优化版本,分为两个阶段:

**1.预排序:**设置间隔gap,将数组分为gap组,每组内部做插入排序,让数组接近有序。

**2.插入排序:**间隔缩小为1时,等价于标准插入排序

示例:

先只排红色这一组:

核心疑问:i 的范围为什么在0到n-gap之间?

如上图,数组下标最大为n-1,若 i >= n-gap,i + gap >= n,ai+gap就越界了。

接下来把绿色与紫色组的考虑进去,

写法一:分组依次遍历

写法二:多组依次遍历


gap缩小公式说明:gap = gap/3 + 1

  1. 除以3:快速缩小间隔

  2. +1兜底,防止gap变为0


3.2 代码:

3.3 复杂度

**·**时间复杂度:O(N^1.3)

4.双向选择排序

4.1 核心思想:

双向选择排序每一趟同时找出区间begin, end的最小值、最大值,分别交换到区间左右两端:

  1. 遍历区间,记录最小值下标mini、最大值下标maxi

  2. 最小值交换到begin位置,最大值交换到end位置

  3. begin++、end--,缩小区间,循环直到begin >= end

写出代码如下:

但是经过测试,我们发现:

并没有按照我们预想的成功排好序。

出错场景:最大值刚好在begin下标

第一轮begin=0,区间最小值下标mini=1,最大值下标maxi=0

1.执行Swap(&a0,&a1)后,下标为0的最大值9,被换到了mini=1的位置,但maxi仍然为0。

2.再执行Swap(&aend,&amaxi),把1交换到了末尾,而最大值9遗留在中间,排序出现错乱。

4.2 正确代码如下:

4.3 复杂度

**·**时间复杂度:O(N^2)

5.快排

5.1 核心思想:

分治二分

快速排序是经典分治类排序算法,核心逻辑:

  1. 在区间内选定一个key,一趟遍历将数组分割为两部分:左侧全部 ≤ key,右侧全部 ≥ key;

  2. 递归处理左子区间**left, keyi-1** 、右子区间**keyi+1, right**;

  3. 当区间长度≤1时天然有序,递归终止。

5.2 Hoare分区法(基础原生快排)

5.2.1 分区原理

我们默认把区间最左元素设为key,左指针L从左向右找大于key的数,右指针R从右向左找小于key的数:

• 行走规则:R先动,L后动,两指针相遇时停下

• 相遇位置性质保证:相遇处的值一定 ≤ key

  1. 场景1:R主动停下------R找到小于key的值,L全程未找到大数,直接和R相遇,R停留值天然小于key;

  2. 场景2:R走完整个区间无小数,直接与L碰头------上一轮交换已经把小于key的元素换到L侧。

• 收尾操作:两指针相遇后,交换key与相遇位置的值,完成区间分割。

补充:将key设为最右侧,需要改为L先走,R后走,相遇位置的值会恒大于key

代码如下:

易错点:内层循环必须重复添加"begin<end"判断:外层循环的初始检查,替代不了循环中持续变化的变量校验,如果漏写,begin与end可能会一直走,造成越界。

接下来只需要让key的左区间与右区间有序即可。我们可以用递归调用来实现,具体过程如下图:

5.2.2 代码:

时间复杂度:O(N)=N*logN

5.3 原生快排的缺陷与优化

5.3.1 两大风险

1.当数组本身有序时,递归深度极大有可能会出现栈溢出的情况。

2.当数组有序时,这个算法就会退化成O(N^2)的算法,如下图:

5.3.2 优化1:三数取中

所以为了避免有序情况下造成的效率退化,我们可以用到以下两种方法:

1.随机取key

2.三数取中(重点讲解)

三数取中即在一组数最左边,最右边以及中间的数中取一个不大不小的中间值作key。代码如下:

使用方法:分区函数开头获取中位数下标,交换至left位置,再执行Hoare逻辑

5.3.3 优化2:小区间插入排序

由于递归调用本身存在不小的性能损耗,我们可以对长度较短的小区间放弃递归,改用插入排序提升整体效率。

原理:假设快排每次分割都是二分,整个递归过程可以等效为满二叉树;二叉树最后一层的递归调用次数占总次数50%,倒数第二层占25%。直接去掉底层小规模区间的递归,能明显降低开销。

小区间阈值通常取长度10,代码示例:

5.4前后指针分区法

5.4.1 原理

设定最左侧为key,初始状态下prev与cur指向同一起点,cur向后遍历,找到小于key的值时prev就++,然后交换prev处与cur处的值,循环这个过程直到cur遍历到最后,交换prev处与key的值。

5.4.2 代码:

5.5 整合优化版递归快排代码

cs 复制代码
void QuickSort(int* a,int left,int right)
{
	if (left >= right)
		return;
	
		int keyi=PartSort2(a, left, right);
		QuickSort(a, left, keyi - 1);
		QuickSort(a, keyi + 1, right);
}
int PartSort1(int* a, int left, int right)
{
	//小区间优化
	if (right - left + 1 < 10)
	{
		InsertSort(a + left, right - left + 1);
	}
	else
	{
		//三数取中
		int mid = GetMidi(a, left, right);
		Swap(&mid, &a[left]);
		int begin = left, end = right;
		int keyi = left;
		while (begin < end)
		{
			while (begin < end && a[begin] <= a[keyi])
			{
				++begin;
			}
			while (begin < end && a[end] >= a[keyi])
			{
				--end;
			}
			Swap(&a[begin], &a[end]);
		}
		Swap(&a[begin], &a[keyi]);
		return begin;
	}
}
int	PartSort2(int* a, int left, int right)
{ 
		//三数取中
	int mid = GetMidi(a, left, right);
	Swap(&mid, &a[left]);
	int prev = left;
	int cur = left+1;
	int keyi = left;
	while (cur <= right)
	{
		if (a[cur] < a[keyi])
		{
			prev++;
			Swap(&a[cur], &a[prev]);
      	}
		cur++;
	}
	    Swap(&a[keyi], &a[prev]);
		return prev;
}

5.6 非递归快排(栈模拟递归,解决栈溢出)

5.6.1 实现思路

递归快排依赖程序调用栈,有序大数据易栈溢出;使用自定义栈数据结构存储待排序区间下标,手动模拟递归二分流程。

优势:自定义栈在堆区申请内存,空间上限远大于程序栈,不会栈溢出。

如上图,右左子区间入栈,每次取栈顶区间,单趟排序。循环一次,相当于一次递归。

5.6.2 代码如下:

6.归并排序

6.1 核心思想:

分治思想,整体分为两个阶段:

1.分:递归拆分

不断将数组区间对半切割,直到每个子区间仅仅存在单个元素(单个元素天然有序)

2.治:有序归并

将两段已经排好序的子数组,借助临时数组tmp,按从小到大的顺序合并为一段完整有序区间,最后拷贝回原数组。

如上图,不断对比abegin1和abegin2,把更小的值存入临时数组tmp,对应下标向后移动,直至其中一段全部遍历完毕。

若某一段提前遍历完成,直接把另一段剩余元素全部追加至tmp尾部,最后将tmp内当前区间有序数据覆盖写回原数组。

6.2 代码:

cs 复制代码
void _MergeSort(int* a, int* tmp, int begin, int end)
{
	if (begin == end)
		return;
	int mid = (begin + end) / 2;
	//[begin,mid][mid+1,end]
	_MergeSort(a, tmp, begin, mid);
	_MergeSort(a, tmp, mid+1, end);
	//归并
	int begin1 = begin, end1 = mid;
	int begin2 = mid + 1, end2 = end;
	int i = begin;
	while (begin1 <= end1 && begin2 <= end)
	{
		if (a[begin1] < a[begin2])
		{
			tmp[i++] = a[begin1++];
		}
		else
		{
			tmp[i++] = a[begin2++];
		}
	}
	while (begin1 <= end1)
	{
		tmp[i++] = a[begin1++];
	}
	while (begin2 <= end2)
	{
		tmp[i++] = a[begin2++];
	}
	memcpy(a + begin, tmp + begin, (end - begin + 1) * sizeof(int));
}
void MergeSort(int* a, int n)
{
	int* tmp = malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc");
		return;
	}
	_MergeSort(a, tmp, 0, n-1);

	free(tmp);
	tmp = NULL;
}

易错点:

1.memcpy容易漏掉+begin

2.为什么分割区间不这样分割:begin,mid-1mid,end

根据上图分析可得:这样分割区间会造成区间重复递归,死循环

6.3 复杂度

**·**时间复杂度:O(N*logN)

**·**空间复杂度:O(N)

6.4 非递归实现归并排序

递归版归并排序需要借助函数调用栈完成区间拆分与回溯,栈操作逻辑抽象、不易理解;因此我们改用循环迭代的方式实现归并排序,思路直观清晰。


gap:代表每组归并区间的数据个数

• 初始 gap = 1:单个元素天然有序,执行11归并;

• 每轮循环结束执行 gap *= 2,子区间长度翻倍;

• 循环条件:gap < n,当子区间长度超过数组总长度时整体数组完成有序合并。

分组遍历逻辑:

for(int i=0;i<n;i+=2*gap)

i 代表每组归并区间的起始位置,每组包含两段长度为gap的有序空间,总长度2*gap。

区间划分公式:


基础代码框架如下:

cs 复制代码
void MergeNonRSort(int* a,int n)
{
	int* tmp = malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc");
		return;
	}
	int gap = 1;
	while (gap < n)
	{
		for (int i = 0; i < n; i += 2 * gap)
		{
			int begin1 = i, end1 = i + gap - 1;
			int begin2 = i + gap, end2 = i + 2 * gap - 1;
			int j = i;
			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));
		}
		gap *= 2;
	}

	free(tmp);
	tmp = NULL;

}

缺陷:上述代码在数组长度不能被2*gap整除时,会产生下标越界访问的问题。

我们添加打印语句,输出每一组区间的下标,直观观察越界场景:

测试数组:{6,1,2,7,9,3,4,5,10,8},数组长度n=10,下标范围0-9

输出结果如下:

红色标注的区间即为越界下标,一共分为两类异常场景,针对两种越界场景,我们在计算完区间下标后立刻添加两段边界校验:

7. 计数排序

7.1 核心思想:

开辟一块统计数组count,下标对应数组元素,count数值存储该数字出现次数,最后遍历count数组,按下标顺序还原有序序列。

示例演算:

**基础版缺陷:若数组数值跨度极大,最小值远大于0,会造成大量内存浪费。**例:


优化:偏移映射

1.遍历数组,找到最小值,最大值

2.实际开辟长度range=max-min+1的count数组

3.counta\[i-min]++,把原始数字减去最小值,映射从0开始的连续下标

4.读取还原:aj++=i+min,count下标i加上偏移量min,还原原始数值


7.2 代码:

cs 复制代码
void CountSort(int* a, int n)
{
	int min = a[0], max = a[0];
	for (int i = 0; i < n; i++)
	{
		if (a[i] < min)
			min = a[i];
		if (a[i] > max)
			max = a[i];
	}
	int range = max - min + 1;
	int* count = calloc(range, sizeof(int));
	if (count == NULL)
	{
		perror("calloc");
		return;
	}
	for (int i = 0; i < n; i++)
	{
		count[a[i] - min]++;
	}
	     int j = 0;
	for (int i = 0; i < range; i++)
	{
		while (count[i]--)
		{
			a[j++] = i + min;
		}
	}
	free(count);
}

7.3 复杂度:

**·**时间复杂度:O(N+range)

**·**空间复杂度:O(range)

适用限制:

1.仅支持整数数组,浮点、字符串无法使用

2.仅适合数值分布集中,数值跨度小的数组

相关推荐
初学者,亦行者1 小时前
算法设计与分析3:贪心法 - 求解最短路径问题(TSP)
算法·代理模式
Starmoon_dhw1 小时前
题解:P16108 「o.OI R-1」基础博弈练习题
c++·算法·图论
大鱼>2 小时前
AI+快递分拣:视觉识别+自动分拣+异常检测
人工智能·深度学习·算法·机器学习
想要成为糕糕手2 小时前
238. 除了自身以外数组的乘积 — 面试向深度解析
javascript·算法·面试
浩瀚地学2 小时前
【面试算法笔记】0105-数组-螺旋矩阵
java·开发语言·笔记·算法·面试
Hesionberger2 小时前
动态规划与二分法破解最长递增子序列
java·数据结构·python·算法·leetcode
imuliuliang3 小时前
关于Kruskal 算法在图优化问题中的扩展应用7
算法
老约家的可汗3 小时前
Linux中基础IO
linux·服务器·算法
满怀冰雪3 小时前
第29篇-状态压缩DP-当状态很多时如何降维优化
java·算法·动态规划