速通数据结构与算法第七站 排序

系列文章目录

速通数据结构与算法系列

1 速通数据结构与算法第一站 复杂度 http://t.csdnimg.cn/sxEGF

2 速通数据结构与算法第二站 顺序表 http://t.csdnimg.cn/WVyDb

3 速通数据结构与算法第三站 单链表 http://t.csdnimg.cn/cDpcC

4 速通数据结构与算法第四站 双链表 http://t.csdnimg.cn/0VyDl

5 速通数据结构与算法第五站 栈&&队列 http://t.csdnimg.cn/MRU83

6 速通数据结构与算法第六站 树&&堆 速通数据结构与算法第六站 树&&堆-CSDN博客

感谢佬们支持!


目录

系列文章目录

  • 前言
  • 一、插入排序
  • 复杂度分析
  • 二、希尔排序
  • 复杂度分析
  • 三、选择排序
  • 复杂度分析
  • 四、堆排序
  • 复杂度分析
  • 五、冒泡排序
  • 复杂度分析
  • 六、快排
  • 1 hoare法
  • 2 挖坑法
  • 3 前后指针法(recommend)
  • 4 非递归
  • 5 优化之三数取中(随机选key)
  • 6 优化之小区间优化
  • 7 优化之三路划分
  • 8 SGI sort设计
  • 9 复杂度分析
  • 七、归并排序
  • 1 递归版本
  • 2 非递归但梭哈实现法
  • 3 非递归但不梭哈实现法
  • 4 复杂度分析
  • 八、其他排序的介绍
  • 1 计数排序
  • 复杂度分析
  • 2 桶排序
  • 复杂度分析
  • 3 基数排序
  • 九、排序总结
  • 总结

前言

这一节是速通数据结构的最后一节,我们要来学习排序。排序,看起来是个很简单的话题,实则一点也不简单。

举个例子,为了达到极致的效率,STL(SGI)算法库中的排序优化一大堆,首先要用快排,当数据个数小于16为了减少递归层数,改调插排,为了key的大小更适中搞了三数取中

一旦"划分恶化"改调堆排,防止时间复杂度恶化到O(n方)。所以排序还是很值得我们学习滴~

注:这篇文章收稿时达到了2w字!最用心的一集


一、插入排序

插入排序类似于我们整理扑克的过程

学排序首先要从单趟排序开始

假设现在有一个[2,4,7]的有序序列,我们要往其中插入一个数

1、假设我们插入的是1,2、4、7就都要往后移动
2、假设我们插入的是5,那需要挪的就是7
3、假设我们插入的是8,那就不需要挪,直接在最后插入即可

有了思路之后,我们就可以先来搞单趟排序了

单趟排序的逻辑是将tmp插入[0,end]的有序区间时

"我比你小,你挪;我比你大,插你后面"

cpp 复制代码
void InsertSort(int*a,int tmp)
{
    int end;
    int tmp;
    while(end>=0)
    {
        if(a[end]>tmp)
        {
            a[end+1]=a[end];
        }   
        else
        {
            break;
        }
        //我比你大||我比所有人都小,即end=-1
        a[end+1]=tmp;
    }
}

有了单趟排序,就能推出整个排序了,显然,第一个数不用排,最后一个数的位置是i-1

所以每次的end为i-1,tmp为a [i]

最后得出的代码就是这样的。


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


//SGI°æ²åÈëÅÅÐò

int main()
{
	int arr[] = { 3,5.2,9,8,10,2 };
	int size = sizeof(arr) / sizeof(arr[0]);
	InsertSort(arr, size);
}

打印结果:


复杂度分析

按最坏来看,如果是完全逆序的情况

时间复杂度就是1+2+......+n,最后就是O(n^2)

但是如果数据是完全有序/接近有序的情况,我们仅需一次遍历即可

也就是O(n)


空间复杂度是O(1)


二、希尔排序

希尔排序算是对插入的升级

希尔排序又称缩小增量法,希尔排序的基本增量是:先选定一个整数,将待排序中的所有记录分成个组,所有距离为选定整数的记录分在同一组内,并对每一组的记录做好排序

。然后将选定整数除以一个数,重复上述步骤,当这个整数到达1时,进行的最后一次排序

会得到一个有序的结果

看似逻辑很复杂,实际很简单,就是分组进行插入排序而已,利用了插入排序数据越有序

时间复杂度越小这一特性

如何分组?

我们通过选取一个gap来操作,让间隔为gap的数为一组

例:

gap=3

[9,1,2,5,7,4,8,6,3,5]

我们将其分为红、绿、蓝三组

现对红色一组进行插入排序

蓝色一组

绿色一组


我们先来写红色组排队的逻辑

只需一次让i+=gap即可

cpp 复制代码
            
            int gap=3;
            for (int i = 0; i < n - gap; i+=gap)
		{

            int end=i;
			int tmp = a[end + gap];

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

下来如果要排三组,就要再加一层for循环

由于只有3组,所以我们给一个gap次的for循环即可

cpp 复制代码
            
            int gap=3;
    for(int j=0;j<gap;++j)
    {
         for (int i = j; i < n - gap; i+=gap)
		{

            int end=i;
			int tmp = a[end + gap];

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

或者用另一种方法,这样就可以在代码层面上减少一层循环(实际循环是没有减少的)

我们不让i一次+=gap了,而是一次加一个

cpp 复制代码
        int gap=3;
        for (int i = 0; i < n - gap; i++)
		{

            int end=i;
			int tmp = a[end + gap];

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

这样就从之前的一组一组插排变成了多组同时开始插排


在此基础上,我们要变化gap的值,最终让gap变为1,这样的话,最终gap就会等于1,最后一次排序的时候就会变成插排,实现有序

cpp 复制代码
#include<stdio.h>
#include<assert.h>
//ϣ

void ShellSort(int* a, int n)
{
	assert(a);
	
	int gap = n;
	while (gap > 1)//gap==1 
	{
		gap /= 2;
		for (int i = 0; i < n - gap; i++)
		{
			int end = i;
			int tmp = a[end + gap];

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



int main()
{
	int arr[] = { 3,5,2,9,8,10,2,2,9,8 };
	int size = sizeof(arr) / sizeof(arr[0]);
	
	ShellSort(arr, size);
}

打印结果


复杂度分析

希尔排序按我们的常理来看,其时间复杂度取决于gap的选择

所以其时间复杂度很难计算,这里给到两本权威书上的解释

由于咱们的gap是按照knuth的方式来提出的,所以时间复杂度就O(n^1.25)~1.6*O(n^1.25)来算

所以这个排序是个很玄学的排序,就像跳表中选取每次插入数据是否提高层数的概率p一样


空间复杂度依旧是O(1)


三、选择排序

先介绍一下选择排序的基本思想

每一次从待排序的数据元素中选出最大/最小的一个元素,存放在序列的起始位置,直到待排序的所有元素全部排完

我们首先容易想到的就是直接选择排序,每次遍历我们可以同时选出最大/最小的数,将其放在序列的最左/最右

单趟就很容易想了,就是每次遍历选出最小/最大即可

cpp 复制代码
int left = 0, right = n - 1;

int maxi = left, mini = left;

		for (int i = left; i <= right; ++i)//ұ
		{
			if (a[i] > a[maxi])
			{
				maxi = i;
			}
			if (a[i] < a[mini])
			{
				mini = i;
			}
			
		}
		swap(&a[left], &a[mini]);
		
		swap(&a[right], &a[maxi]);

剩下只要每次维护left,right即可

但是还有一个坑,如果某一波,maxi正好就在left的位置

那更新最小值的时候就把最大值换走了

所以我们加个特判即可

cpp 复制代码
#include<stdio.h.>

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

void SelectSort(int*a,int n)
{
	int left = 0, right = n - 1;
	while (left < right)
	{
		int maxi = left, mini = left;

		for (int i = left; i <= right; ++i)//ұ
		{
			if (a[i] > a[maxi])
			{
				maxi = i;
			}
			if (a[i] < a[mini])
			{
				mini = i;
			}
			
		}
		swap(&a[left], &a[mini]);
		if (maxi == left)
		{
			maxi = mini;
		}
		swap(&a[right], &a[maxi]);

		++left;
		--right;
	}
}
int main()
{
	int arr[] = { 3,5,2,9,8,10,2,2,9,8 };
	int size = sizeof(arr) / sizeof(arr[0]);

	SelectSort(arr, size);
	for (int i = 0; i < size; ++i)
	{
		printf("%d ", arr[i]);
	}

打印结果


复杂度分析

选择排序由于每次都需要遍历找最大/最小值,所以时间复杂度最好最坏情况都是O(n^2),确实是最fvv的排序


空间复杂度是O(1)


四、堆排序

堆排序就是从直接选择排序变成了用堆选数。

关于堆排序的逻辑我们在上篇博客已经介绍过了,这里就不再赘述了

直接上代码

cpp 复制代码
#include<stdio.h>

//swap
void swap(int* px, int* py)
{
	int tmp = *px;
	*px = *py;
	*py = tmp;
}

//向上调整
void AdjustUp(int* a, int child)
{
	int parent = (child - 1) / 2;
	while (child > 0)//最多调到根
	{
		if (a[child] > a[parent])
		{
			swap(&a[child], &a[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
			break;
		}
	}
}

//向下调整
void AdjustDown(int* a, const int n, int parent)
{
	//找左右孩子大的一个交换
	int child = parent * 2 + 1;//suppose左孩子大,经典玩法

	while (child < n)//如果孩子超出了数组范围,说明parent是叶子节点
	{
		if (child + 1 < n && a[child + 1] > a[child])//防止越界
		{
			child++;
		}
		if (a[child] > a[parent])
		{
			swap(&a[parent], &a[child]);

			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}


//排升序--建大堆(向上调整建堆)
void HeapSort1(int* a, int n)
{
	for (int i = 1; i < n; ++i)//O(lgN)
	{
		AdjustUp(a, i);
	}
	
	int end = n - 1;
	while(end>0)
	{
	swap(&a[0], &a[end]);
	AdjustDown(a, end, 0);
	end--;
	}
}

//排升序--建大堆(向下调整建堆)
void HeapSort2(int* a, int n)
{
	for (int i = (n - 1 - 1) / 2; i >= 0; --i)
	{
		AdjustDown(a, n, i);
	}
	int end = n - 1;
	while (end > 0)
	{
		swap(&a[0], &a[end]);
		AdjustDown(a, end, 0);
		end--;
	}
}

int main()
{
	int arr[] = { 2,4,5,6,7,2,6 };
	const int n = sizeof(arr) / sizeof(arr[0]);
	HeapSort2(arr, n);
	for (int i = 0; i < n; ++i)
	{
		printf("%d ", arr[i]);
	}
	printf("\n");
	return 0;
}

打印结果


复杂度分析

关于堆排序的时间复杂度我们也分析过了

如果是向下调整建堆,就是O(n*lgn)


由于是原地建堆,所以空间复杂度是O(1)


五、冒泡排序

冒泡排序属于交换排序

我们来介绍一下交换排序的思想

所谓交换,就是根据序列中的两个记录键值对的比较结果来swap这两个记录在序列的位置

交换排序的特点是:将键值较大的记录先后移动,将键值较小的记录先前移动

冒泡排序也是我们非常熟悉的排序了

这里直接上代码

cpp 复制代码
#include<stdio.h>
#include<stdbool.h>

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

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

int main()
{
	int arr[] = { 3,5,2,9,8,10,2,2,9,8 };
	int size = sizeof(arr) / sizeof(arr[0]);

	BubbleSort(arr, size);
	for (int i = 0; i < size; ++i)
	{
		printf("%d ", arr[i]);
	}
}

打印结果


复杂度分析

显然,如果序列本身有序,遍历一次即可排好序

所以时间复杂度最好情况为O(n)

如果是最坏情况,则是O(n^2)

需要注意的是,在相对有序的情况下,冒泡排序的时间复杂度还是不如插入排序的


空间复杂度是O(1)


六、快排

1 hoare法

单趟:选出一个基准值key,把他放到正确的位置(最终排好序要蹲的事)

例:

最终就会变成

我们有三种方法,第一种是这样的:

法一:

1、具体操作为左边找严格比key大的,右边找严格比key小的,然后swap

2、最后在最后停下的位置和key所在的位置交换一下

一波单趟排序就做好啦~

cpp 复制代码
//hoare法
int partition1(int* a, int left, int right)
{
	int mid = GetMidNumi(a, left, right);
	swap(&a[left], &a[mid]);

	int keyi = left;
	while (left < right)
	{
		//左边是key,右边先走
		//带上=,表示找严格大/严格小
		while (left < right && a[right] >= a[keyi])
		{
			right--;
		}
		while (left < right && a[left] <= a[keyi])
		{
			left++;
		}
		swap(&a[left], &a[right]);
	}
	//最后left和key换一下
	swap(&a[left], &a[keyi]);

	return keyi;
}

注:由于是循环套循环,所以做好边界判断
为什么要找严格大于/小于的?

如果某个用例为[2,2,2,2,2,2];那就完了,会一直死循环


2 挖坑法

由于hoare大佬的这个方法太拉拉了,又给出了第二种(感觉更复杂了,意思差不多)

法二:

1、先将key值存在一个变量里,就会形成一个坑位

2、依然是左边找大,右边找小

右边找小,放到坑里,更新坑的位置

左边再找大,放进坑里,更新坑的位置

3、当left和right相遇,将key填入坑中

按图来看,就是这样

cpp 复制代码
//挖坑法
int partition2(int* a, int left, int right)
{
	int mid = GetMidNumi(a, left, right);
	swap(&a[left], &a[mid]);

	int key = a[left];
	int hole = left;
	//此时 left为坑
	while (left < right)
	{
		//左边是key,右边先走
		//带上=,表示找严格大/严格小
		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;
	}
	//最后坑的位置放ke
	a[left] = key;

	return hole;
}

3 前后指针法(recommend)

这个方法还是很清爽很简洁的

法三

1、初始时,prev指向序列开头,cur指针指向prev指针后的一个位置,就像这样

2、cur所在位置比key小的,

如果找到了,就++prev,和cur交换

交换完以后,++cur

3 如果cur所在位置比key大

就++cur

在排序过程中,大概就是两种情况

1 prev紧跟着cur

2 prev和cur隔着比key大的一段数的区间

按图来看,就是这样

cpp 复制代码
//前后指针法
int partition3(int* a, int left, int right)
{
	int mid = GetMidNumi(a, left, right);
	swap(&a[left], &a[mid]);

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

	while (cur <= right)
	{
		if (a[cur] < a[keyi] && (++prev) < cur)//不要自己和自己换~
			swap(&a[cur], &a[prev]);

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

那么问题来了,我们已经学习了3种单趟排序的方法?

如何实现完整的排序呢?

我们知道,第一波单趟排序排好的是最终key的位置

所以下来的操作非常简单

1 我们对key的左右区间递归使用该函数即可

2 当区间只有一个数/区间不存在时,递归调用结束

有了上一节我们学到的递归的经验,这波函数体就会是这样(以第三种方法为例)

cpp 复制代码
void QuickSort(int* a, int left, int right)
{
	if (left >= right)
		return;

	int keyi = PartSort3(a, left, right);
	QuickSort(a, left, keyi - 1);
	QuickSort(a, keyi+1, right);
}

需要注意的是,三种单趟排序对同一序列的结果可能不同,如果有数据结构题目问

单趟排序的结果,我们需要考虑3种方式


4 非递归

非递归的思路也很简单,只要将递归的逻辑用栈代替即可

1、每次将left、right入栈;

2、每次取两次栈顶组成begin、end调用单趟排序

3、将单趟排序返回的keyi左右两端区间的左右顶点入栈

4、当栈为空时,循环结束

cpp 复制代码
//前后指针法
int partition3(int* a, int left, int right)
{

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

	while (cur <= right)
	{
		if (a[cur] < a[keyi] && (++prev) < cur)//不要自己和自己换~
			swap(&a[cur], &a[prev]);

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

void QuickSortNonR(int* a, int left, int right)
{
	stack<int> st;
	st.push(left);
	st.push(right);

	while (!st.empty())
	{
		int end = st.top();
		st.pop();
		
		int begin = st.top();
		st.pop();
		int keyi = partition3(a, begin, end);

		//begin,keyi-1 ;  keyi+1,end
		if (begin < keyi - 1)
		{
			st.push(begin);
			st.push(keyi-1);

		}

		if ( keyi + 1<end)
		{
			st.push(keyi+1);
			st.push(end);
		}
	}
	
}

int main()
{
	int arr[] = { 3,5,2,9,8,10,2,2,9,8 };
	int size = sizeof(arr) / sizeof(arr[0]);

	QuickSortNonR(arr, 0, size - 1);
	for (int i = 0; i < size; ++i)
	{
		printf("%d ", arr[i]);
	}
}

打印结果


5 优化之三数取中(随机选key)

三数取中其实很简单,由于我们固定选最左边的数为key会有不确定性,所以我们选取

左中右三个数中中间的那个为key

cpp 复制代码
//三数取中
int GetMidNumi(int* a, int left, int right)
{
	int mid = (right + left) / 2;
	if (a[left] < a[mid])
	{
		if (a[mid] < a[right])
		{
			return mid;
		}
		//说明mid是最大的
		else if (a[right] > a[left])
		{
			return right;
		}
		else
		{
			return left;
		}
	}
	else
	{
		if (a[mid] > a[right])
		{
			return mid;
		}
		//说明mid是最小的
		else if (a[right] > a[left])
		{
			return left;
		}
		else
		{
			return right;
		}
	}
}

6 优化之小区间优化

我们注意到,当递归到一定深度时,每次的区间长度不长

但仍需要递归,这就会导致递归次数太多,有栈溢出的风险

所以我们限制一个长度,在此长度以下,我们直接调插入排序

由于是接近有序,所以效率不会太拉还可以减少递归层数

cpp 复制代码
void QuickSort(int* a, int left, int right)
{
	if (left >= right)
		return;

	// 小区间优化--小区间直接使用插入排序
	if ((right - left + 1) > 10)
	{
		int keyi = PartSort3(a, left, right);
		QuickSort(a, left, keyi - 1);
		QuickSort(a, keyi + 1, right);
	}
	else
	{
		InsertSort(a+left, right - left + 1);
	}
}

7 优化之三路划分

当你有了以上优化之后,你会发现,你依然无法通过力扣的那道排序题

. - 力扣(LeetCode)

因为有个用例是大量重复的数

这对我们的快排是极为不利的

大量的重复意味着大量的递归+遍历,而每次单趟排序都不能做事

所以有人提出了所谓三路划分


之前我们通过选取key找到大于key/小于key的区间,算的上是一种双路划分

所以三路划分是这样的

单独划分出一个等于key的区间

核心思想是这样的

1、 跟key相等的值,往后推

2、比key小的在左边,比key大的在右边,和key相等的在中间

所以我们的双指针法就变成了三指针法了,

1、a[cur]<key,交换left和cur,left++,cur++

2、a[cur]>key,交换right和cur,right--,cur++

3、a[cur]==key,cur++(只动cur!)

搞定了单趟其他就简单了

我们和之前一样递归即可

简单实现一波

cpp 复制代码
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 mid=GetMidNumi(a,begin,end);
        swap(a[begin],a[mid]);
        int left=begin;
        int right=end;

		int key=a[begin];
        int cur=begin+1;
		while(cur<=right)
		{
			if(a[cur]<key)
			{
				left++;
				cur++;

			}
			else if(a[cur]>key)
			{
				swap(a[cur],a[right]);
				--right;
			}
			else
			{
				cur++;
			}
		}
	QuickSort(a,begin,left-1);
	QuickSort(a,right+1,end);
	}
}

有了这个优化,我们就可以通过力扣的排序题啦


8 SGI sort设计

(参考侯捷老师的《STL源码剖析》)

学习了上述优化,我们就可以来评鉴一下C++的算法库中的sort了

总述:STL的sort算法,数据量大时采用QuickSort,分段递归排序,一旦分段后的数据量小于某个门槛,为避免QuickSort的递归调用带来过大的额外负荷,就改用InsertSort

如果递归层数过深,还会改用HeapSort

InsertSort

SGI的InsertSort有两个版本,一种是递增,另一种是仿函数

版本二的可以先忽略,我们重点来看版本一的

这个是插入排序的外循环

cpp 复制代码
template<class RandomAccessIterator>
void __insertion_sort(RandomAccessIterator first,RandomAccessIterator last)
{
	if(first==last)
	return ;

	for(RandomAccessIterator i=first+1;i!=last;++i)//外循环
	{
		__liner_insert(first,i,value_type(first));
	}
}

__linear_insert是这么做的

cpp 复制代码
template<class RandomAccessIterator,class T>
inline void __liner_insert(RandomAccessIterator first,RandomAccessIterator last,T*)
{
	T value =*last;//记录尾元素
	//和我们写的不同的是,这里分了两种情况,是否尾比头还小(头为最小元素)
	if(value<*first)
	{
		copy_backward(first,last,last+1);//整体后移
		*first=value;
	}
	else
	{
		__unguarded_linear_insert(last,value); 
	}
}

这里的__unguarded的命名很讲究,表明这波不用判断是否超过边界(也就是我们所写代码中end>=0的逻辑),

因为我们的源码确保了最小值必然在内循环区间的最边缘

cpp 复制代码
void __unguarded_linear_insert( RandomAccessIterator last,T value)
{
	RandomAccessIterator next=last;
	
	while(value<*next)//内循环的逻辑
	{
		*last=*next;
		last=next;
		--next;
	}
	*last=value;
}

细节满满,看似只是省下了一个简单的判断,但是在大数据量下,影响还是很可观的


QuickSort

SGI的QuickSort提供了两种单趟排序(partioning)分别是我们的单趟1和单趟3

别的都差不多,没什么好说的


threshold

SGI sort还认为,对于一个小数据量序列,甚至简单的插入排序可能更快

鉴于这种情况,适度评估序列的大小,再决定选择插排还是快排,是值得采纳的优化措施

侯捷老师认为并无定论,5~20都有可能,因设备而定


final Insert Sort

SGI也采用了当序列小于一个值就调用插入排序的做法

(在源码中,这个值是16)

cpp 复制代码
const int __stl_threshold =16;

原文:如果我们令某个大小以下的序列滞留在"几近排序但尚未成功"的状态,最后再以

一次InsertSort将所有这些"几近排序但尚未成功"的子序列做一次完整的排序,其效率一般认为会比"将所有子序列彻底排好"更好,这是因为InsertSort在面对"几近排序"的序列时,有更好的表现


introsort

不当的轴承(就是key值)选择,可能导致QuickSort退化为O(n^2),所以David大佬提出了一种混合式排序,内省式排序,简称introsort。在分割行为有二次行为的倾向时,能自动检测,转而使用HeapSort,保住O(n*lgn)的下限

自我检测的函数为__lg,其实就是在找2^k<=n的最大值k

cpp 复制代码
template<class Size>
inline Size __lg(Size n)
{
	Size k;
	for(k=0;n>1;n>>=1)
	++k;

	return k;
}

所以,最终的sort本体是这样的~

cpp 复制代码
template<class RandomAccessIterator>
inline void sort(RandomAccessIterator first,RandomAccessIterator last)
{
	__introsort_loop(first,last,value_type(first),__lg(last-first)*2);
	__final_insertion_sort(first,last);
}
cpp 复制代码
template<class RandomAccessIterator,class T,class Size>
void __introsort_loop(RandomAccessIterator first, RandomAccessIterator last,T* ,Size depth_limit)
{
	while(last-first>__stl_threshold)
	{
		if(depth_limit==0)
		{
			//分裂恶化
			
			//调用堆排!
			partial_sort(first,last,last);

			return ;
		}
		--depth_limit;

		//下来就是单趟排序+三数取中的逻辑了,这里简化一下,就不写了
		RandomAccessIterator cut;

		//递归左右区间调用__introsort_loop,这里也简化一下
		__introsort_loop();
		__introsort_loop()
	}

当__introsort_loop()结束后,[first,last)会存在多个元素大于16的元素

此时回到主函数sort,再进行__final_insertion_sort(first,last);

cpp 复制代码
template<class RandomAccessIterator>
void __final_insertion_sort(RandomAccessIterator first,RandomAccessIterator last)
{
	if(last-first>__stl_threshold)
	{
		__insertion_sort(first,first+__stl_threshold);
		__unguarded_insertion_sort(first+__stl_threshold,last);//就是循环调用__unguarded_linear_insert; 
	}
	else
	{
		__insertion_sort(first,last);
	}
}

该函数首先判断元素个数是否大于16,如果为否,直接调用__insertion_sort

如果为是,就分割为一段长为16的区间和剩下的区间,分别调用__insertion_sort和------__unguarded__insertion_sort处理


这就是SGI STL sort的故事了,设计非常巧妙(最用心的一集)

9 复杂度分析

根据我们在SGI sort分析的那样,如果是普通快排,时间复杂度一般是O(n*lgn)

但是在极端情况下,会退化至O(n^2)。

但是SGI sort,我们可以保证一个O(n*lgn)的下限


空间复杂度的来源是栈帧的建立,为O(lgn)~O(n)


七、归并排序

归并排序是建立在归并操作上的一种有效的排序算法,采用了经典的分治法。

将已有序的子序列合并,得到完全有序的序列

归并的逻辑很简单,就是我们之前的合并两个有序数组的逻辑最后再拷回原数组即可

1 递归版本
cpp 复制代码
void _merge(int* a, int left, int right,int *tmp)
{
	//一个值时/区间不存在时结束
	if (left >= right)
	{
		return;
	}

	int mid = (left+right) / 2;
	
	//子区间递归排序
	_merge(a, left, mid, tmp);
	_merge(a, mid + 1, right, tmp);

	//归并
	int begin1 = left;
	int end1 = mid;
	int begin2 = mid + 1;
	int end2 = right;

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


	int i = left;
	while (begin1 <= end1 && begin2 <= end2)
	{
		if (a[begin1] < a[begin2])
		{
			tmp[i++] = a[begin1++];
		}
		else
		{
			tmp[i++] = a[begin2++];
		}
	}
	if (begin1 > end1)
	{
		while (begin2 <= end2)
		{
			tmp[i++] = a[begin2++];

		}
	}
	if (begin2 > end2)
	{
		while (begin1 <= end1)
		{
			tmp[i++] = a[begin1++];
		}
	}
	//记住加left!
	memcpy(a + left, tmp + left, sizeof(int) * (right - left + 1));
}

void MergeSort(int* a, int n)
{
	int* nums = (int*)malloc(sizeof(int) * n);

	_merge(a,0, n-1,nums);

	free(nums);
}

int main()
{
	int arr[] = { 3,5,2,9,8,10,2,2};
	int size = sizeof(arr) / sizeof(arr[0]);

	MergeSort(arr, size);
	for (int i = 0; i < size; ++i)
	{
		printf("%d ", arr[i]);
	}
}

2 非递归但梭哈实现法

非递归如何模拟模拟归并的过程呢?

归并的逻辑是先一一归并,再二二归并,在此基础上四四归并,他的逻辑更像一个后序遍历

而栈则对应的是前序遍历,所以这波不能用栈,而可以控制一个gap,实现一一、二二、四四......归并

gap==1,此时为一一归并

gap*=2,就变成了二二归并

......


光这样还不够,我们只考虑了元素个数是二的次方的情况

如果是9,20个数据,end2,begin2,begin1会有越界的风险

所以我们要修正边界


光修正边界还不够,我们要考虑拷回原数组的问题,这里分为一把拷(梭哈)和归一部分拷一部分(不梭哈)两种情况

我们可以打印一波边界来看看情况

我们试试9个元素

cpp 复制代码
//一把梭哈
void MergeSortNonRS(int* a, int n)
{
	int* nums = (int*)malloc(sizeof(int) * n);

	int gap = 1;
	while (gap < n)
	{
		for (int i = 0; i < n; i += 2 * gap)
		{
			//归并
			int begin1 = i;
			int end1 = i+gap-1;
			int begin2 = i+gap;
			int end2 = i+2*gap-1;

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

			

			int j = i;
			while (begin1 <= end1 && begin2 <= end2)
			{
				if (a[begin1] < a[begin2])
				{
					nums[j++] = a[begin1++];
				}
				else
				{
					nums[j++] = a[begin2++];
				}
			}
			if (begin1 > end1)
			{
				while (begin2 <= end2)
				{
					nums[j++] = a[begin2++];

				}
			}
			if (begin2 > end2)
			{
				while (begin1 <= end1)
				{
					nums[j++] = a[begin1++];
				}
			}
		}
		//记住加left!
		memcpy(a , nums, sizeof(int) *n);
		gap *= 2;
	}
	

	free(nums);
}

int main()
{
	int a[] = { 6,1,2,6,9,3,4,6,10};

	MergeSortNonRS(a, 9);
}

由于begin1为i,所以不可能越界

下来,越界有这么几种情况


1、end1越了,不归并了,但是是要拷贝的(因为只剩一个数了)

【8,11】【12,15】

由于我们要一次梭哈拷贝,所以这波我们要修正边界,才能不被覆盖


2、end1没越界,begin2越界了

同理,不用归并,也要修正边界

【8,8】【9,9】


3、只有end2越界了

他是需要归并的,修正end2

【0,7】【8,15】

不归并的修正边界很简单,我们只需修成一个不存在的区间,就不进循环了

而最后需要归并的end2,我们需要计算一下修正到的值,即n-1

cpp 复制代码
//一把梭哈
void MergeSortNonRS(int* a, int n)
{
	int* nums = (int*)malloc(sizeof(int) * n);

	int gap = 1;
	while (gap < n)
	{
		for (int i = 0; i < n; i += 2 * gap)
		{
			//归并
			int begin1 = i;
			int end1 = i+gap-1;
			int begin2 = i+gap;
			int end2 = i+2*gap-1;

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

			//修正
			if (end1 >= n)
			{
				end1 = n - 1;
				begin2 = n;
				end2 = n - 1;
			}
			else if (begin2 >= n)
			{
				///修成一个不存在的区间
				begin2 = n;
				end2 = n - 1;
			}
			else if (end2 >= n)
			{
				end2 = n - 1;
			}

			int j = i;
			while (begin1 <= end1 && begin2 <= end2)
			{
				if (a[begin1] < a[begin2])
				{
					nums[j++] = a[begin1++];
				}
				else
				{
					nums[j++] = a[begin2++];
				}
			}
			if (begin1 > end1)
			{
				while (begin2 <= end2)
				{
					nums[j++] = a[begin2++];

				}
			}
			if (begin2 > end2)
			{
				while (begin1 <= end1)
				{
					nums[j++] = a[begin1++];
				}
			}
		}
		//记住加left!
		memcpy(a , nums, sizeof(int) *n);
		gap *= 2;
	}
	

	free(nums);
}

3 非递归但不梭哈实现法

好消息是,不拷贝,前两个就可以不用修正边界了,直接break出去

只需修正第二个区间的右边界即可

cpp 复制代码
//归一部分拷一部分
void MergeSortNonR(int* a, int n)
{
	int* nums = (int*)malloc(sizeof(int) * n);

	int gap = 1;
	while (gap < n)
	{
		for (int i = 0; i < n; i += 2 * gap)
		{
			//归并
			int begin1 = i;
			int end1 = i + gap - 1;
			int begin2 = i + gap;
			int end2 = i + 2 * gap - 1;

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

			//修正
			if (end1 >= n || begin2 >= n)
			{
				break;
			}
			else if (end2 >= n)
			{
				end2 = n - 1;
			}

			int j = i;
			while (begin1 <= end1 && begin2 <= end2)
			{
				if (a[begin1] < a[begin2])
				{
					nums[j++] = a[begin1++];
				}
				else
				{
					nums[j++] = a[begin2++];
				}
			}
			if (begin1 > end1)
			{
				while (begin2 <= end2)
				{
					nums[j++] = a[begin2++];
				}
			}
			if (begin2 > end2)
			{
				while (begin1 <= end1)
				{
					nums[j++] = a[begin1++];
				}
			}
			memcpy(a +i , nums+i, sizeof(int) * (end2 - i + 1));
		}
		gap *= 2;
	}
free(nums);
}

复杂度分析

归并排序的时间复杂度也是标准的O(n*lgn)


空间复杂度来源于开辟的新数组,为O(n)


归并排序还可以用作外排序,大家感兴趣的话,可以再了解一下~


八、其他排序的介绍

计数排序

计数排序又称鸽巢原理,是对哈希直接定址法的应用

操作为:

1、统计相同元素个数

2、根据统计结果将序列回收回原来的序列

既然是模仿哈希,那么原理是这样的

思考一下元素的大致范围,开一个大的哈希表(数组),通过定址法将带牌序列的元素映射至

哈希表,最后再从哈希表提取元素即可

例:

{6,1,2,1,9,3,3,2,2,8}

我们直接开一个大小为10的数组就够了

然后遍历数组,直接定址,再对应下标值加1即可

遍历之后是这样的

下来就简单,遍历哈希表排序即可


看似很简单的计数排序,我们要考虑一些别的问题

如果序列是这样

{100,101,101,103,109,120},我们再从0开始定址就有些浪费了

所以我们可以统计最大,最小值,就能最大程度的节省空间了

需要注意的是,如果序列含有负数,我们的排序也可以解决

那代码是这样的~


cpp 复制代码
void CountSort(int* a, int n)
{
	int max = a[0], min = a[0];
	for (int i = 1; i < n; ++i)
	{
		if (a[i] > max)
		{
			max = a[i];
		}

		if (a[i] < min)
		{
			min = a[i];
		}
	}

	int range = max - min + 1;
	int* countA = (int*)malloc(sizeof(int) * range);
	if (countA == NULL)
	{
		perror("malloc fail\n");
		return;
	}
	memset(countA, 0, sizeof(int) * range);

	// 计数
	for (int i = 0; i < n; i++)
	{
		countA[a[i] - min]++;
	}

	// 排序
	int j = 0;
	for (int i = 0; i < range; i++)
	{
		while (countA[i]--)
		{
			a[j++] = i + min;
		}
	}

	free(countA);
}

int main()
{
	int arr[] = { 2,10,3,90,589,184,505.29,8,83 };

	for (auto e : arr)
	{
		std::cout << e << " ";
	}
	std::cout << std ::endl;
	int sz = sizeof(arr) / sizeof(arr[0]);

	CountSort(arr, 0, sz);
	for (auto e : arr)
	{
		std::cout << e << " ";
	}
	return 0;
}

复杂度分析

显然,如果序列内的值相差不太大,计数排序的时间复杂度能逼急O(n),非常逆天

其实是O(n+range)

但是由于我们用到了哈希表,那就意味着你得能哈希出一个值来才能排序

所以浮点数肯定是寄了,但是字符串可以(字符串哈希)。


空间复杂度是O(range)


桶排序

桶排序(Bucket Sort )是一种基于分布的排序算法,特别适用于数据分布比较均匀的情况。它的基本思想是将数据分成若干个桶(Bucket),然后分别对每个桶中的数据进行排序,最后再将各个桶中的数据按顺序合并,得到最终的有序数据。

桶排序的基本步骤:

  1. 创建桶
    • 根据数据范围创建一定数量的桶,每个桶代表一个区间范围。
  2. 将元素分配到桶中
    • 遍历输入数据,将每个数据根据其值分配到对应的桶中。
  3. 对每个桶内的数据排序
    • 对每个桶内的数据单独进行排序(可以使用任何排序算法,通常使用快速排序或插入排序)。
  4. 合并桶中的数据
    • 最后按顺序将各个桶中的数据合并成一个整体,即完成排序

复杂度分析

时间复杂度:

  1. 最好情况: O(n+k)O(n + k)O(n+k)

    • 如果输入数据均匀分布到桶中,且每个桶内的数据可以使用一个高效的排序算法(如插入排序)来进行排序,桶排序的时间复杂度接近线性,等于 O(n)O(n)O(n)。
    • 此外,每个桶的排序时间是与桶内元素数量有关的。如果使用插入排序,最好情况下每个桶的排序复杂度是 O(1)O(1)O(1),总的时间复杂度为 O(n+k)O(n + k)O(n+k),其中 nnn 是输入数据的数量,kkk 是桶的数量。
  2. 平均情况: O(n+k)O(n + k)O(n+k)

    • 当数据大致均匀分布到桶中时,桶排序的平均时间复杂度与最好情况类似,也是 O(n+k)O(n + k)O(n+k),因为分配数据到桶中的过程是线性的。
  3. 最坏情况: O(n2)O(n^2)O(n2)

    • 最坏情况下,所有数据都被分配到同一个桶中,此时桶排序退化为在单个桶中对 nnn 个元素进行排序。若该桶内使用插入排序或其他 O(n2)O(n^2)O(n2) 时间复杂度的排序算法,则总的时间复杂度为 O(n2)O(n^2)O(n2)。

空间复杂度:

  • 空间复杂度: O(n+k)

这种排序一般没什么用,大家了解一下即可


基数排序​​​​​​​

首先我们要知道,基数排序也是一个和之前不同,即不需要比较、移动的排序

是一种借助一种多关键字的排序对单关键字进行排序的方法

例:

我们的扑克牌就有两种关键字进行排序

一种是花色,♠️,♣️,♦️,♥️

另一种是数字大小,1,2,3,4,5,6,7,8,9,10,J,Q,K

再介绍两个概念

最高位优先(MSD)&最低位优先(LSD)

以扑克牌为例

MSD为:每个子序列花色相同但数字不同

LSD为:4个1,4个2,4个3......


给一个例子

我们以低位优先为例

由于是低位优先,所以我们先按个位排,

将其遍历,重组回原数组(注意,这波063先进,所以先出063)


下来再排十位


排好百位,这波就结束啦


原理还是很简单的,建一个挂队列的哈希表

然后进行数位次操作

每次操作先分发,再组合

写好的代码就是这样的~

cpp 复制代码
#define K 3
#define RADIX 10

std::queue<int> Q[RADIX];

int GetKey(int value,int k)
{
	int key = 0;
	while (k >= 0)
	{
		key = value % 10;
		value /= 10;
		k--;
	}
	return key;
}

void Distribute(int*arr,int left,int right,int k)
{
	for (int i = left; i < right; ++i)
	{
		int key = GetKey(arr[i],k);
		Q[key].push(arr[i]);
	}
}

void Collect(int*arr)
{
	int k = 0;

	for (int i = 0; i < RADIX; ++i)
	{
		while (!Q[i].empty())
		{
			arr[k++] = Q[i].front();
			Q[i].pop();
		}
	}
}

void RadixSort(int* arr, int left, int right)
{
	for (int i = 0; i < K; ++i)
	{
		//分发
		Distribute(arr,left,right,i);
		//组合
		Collect(arr);
	}
}



int main()
{
	int arr[] = { 278,10,63,930,589,184,505.269,8,83 };

	for (auto e : arr)
	{
		std::cout << e << " ";
	}
	std::cout << std ::endl;
	int sz = sizeof(arr) / sizeof(arr[0]);

	RadixSort(arr, 0, sz);
	for (auto e : arr)
	{
		std::cout << e << " ";
	}
	return 0;
}

复杂度分析

假设我们搞了r个队列

那我们的空间复杂度就是O(r)


假设我们要进行k次操作

每次分发和组合的时间复杂度就是(n+r)

所以总体就是O(k(n+r))


九、排序总结

这里我们再介绍一个所谓的稳定性的概念

假定在待排序的记录序列中,存在多个具有相同关键字的记录,若经过排序,这些记录的

相对次序保持不变。

即在原序列中,r[i]=r[j],且r[i]在r[j]之前,在排序之后,如果r[i]仍在r[j]之前,就说明这种排序是稳定的,否则就是不稳定的

说人话就是,假设某个序列有相同的数字,经过排序之后,这些相同的数的顺序不变,就是稳定的

有什么用呢?

假定考试时规定分数相同先交卷的人更牛逼,我们就用得着这个稳定性了

根据这些排序的原理,我们便可推出一个排序是否稳定了

|------|-------|----------------------------|
| 排序名 | 是否稳定? | 解释 |
| 插入排序 | 稳定 | 显然,如果规定了相等了不往后 挪即可 |
| 希尔排序 | 不稳定 | 相同的数可能被分到不同的组 |
| 选择排序 | 不稳定 | 每次选到的数不固定 |
| 堆排序 | 不稳定 | 每次作为堆顶的元素不确定,而堆顶之下可能会有重复元素 |
| 冒泡排序 | 稳定 | 相同不交换就可以做到稳定 |
| 快速排序 | 不稳定 | 每次的key值不确定 |
| 归并排序 | 稳定 | |
| 计数排序 | 稳定 | |
| 基数排序 | 稳定 | 辅助数组元素是队列,可以通过先进先出保证 |


总结

做总结,这篇博客我们学习了排序,这将是速通数据结构算法初阶的最后一集,接下来我们将迅速的投入沉淀C++的篇章中

水平有限,还请各位大佬指正。如果觉得对你有帮助的话,还请三连关注一波。希望大家都能拿到心仪的offer哦。

每日gitee侠:今天你交gitee了嘛

相关推荐
不惑_17 分钟前
List 集合安全操作指南:避免 ConcurrentModificationException 与提升性能
数据结构·安全·list
qq_4335545425 分钟前
C++ 面向对象编程:递增重载
开发语言·c++·算法
一只自律的鸡34 分钟前
C语言项目 天天酷跑(上篇)
c语言·开发语言
带多刺的玫瑰1 小时前
Leecode刷题C语言之切蛋糕的最小总开销①
java·数据结构·算法
巫师不要去魔法部乱说1 小时前
PyCharm专项训练5 最短路径算法
python·算法·pycharm
qystca1 小时前
洛谷 P11242 碧树 C语言
数据结构·算法
冠位观测者2 小时前
【Leetcode 热题 100】124. 二叉树中的最大路径和
数据结构·算法·leetcode
XWXnb62 小时前
数据结构:链表
数据结构·链表
悲伤小伞2 小时前
C++_数据结构_详解二叉搜索树
c语言·数据结构·c++·笔记·算法
m0_675988233 小时前
Leetcode3218. 切蛋糕的最小总开销 I
c++·算法·leetcode·职场和发展