十大排序算法总结

排序算法属于数据结构中最基础的一部分知识点了,以前总看了就忘,还是有必要自己写个博客记录一番

1 冒泡排序

冒泡排序思路比较简单直观,首先需要明白一次冒泡的过程。假设需要将一个数组 a r r arr arr 从小到大排序,并且我们从左边往右边开始冒泡,那么每次冒泡都是从起点 0 0 0 开始,逐个比较当前数和右相邻数的大小,如果当前的数比右相邻数大,则需要将这两个数交换,然后接着从下一个位置继续这个过程,可以明显的看见,如果一个数非常大,那么这个数在比较的过程中会一直往后移动,直到遇见一个比它更大数,才会停留在原位置,因此第一次冒泡的结束,会把整个数组的最大数排到数组的尾部。在下一轮冒泡中,因为有一个数已经排序完成,因此这一轮冒泡只需检测到倒数第三个数,这一轮结束,整个数组第二大的数会被放置到数组倒数第二个位置。重复以上过程,直到只剩一个数没有冒泡,此时整个冒泡排序完成。

void exch(vector<int> &vec,int a,int b)
{
	int tmp = vec[a];
	vec[a] = vec[b];
	vec[b] = tmp;
}

void bubbleSort(vector<int> &nums)
{
	int len = nums.size();
	for (int i = 0; i < len; i++)
	{
		bool hasExch = false;//交换标记
		for (int j = 0; j < len - 1 - i; j++)
		{
			if (nums[j] > nums[j + 1])
			{
				exch(nums, j, j + 1);
				hasExch = true;//标记这一轮有数据交换
			}
		}
		if (!hasExch)
			break;
	}
}

在冒泡排序的代码中,还进行了一个优化工作。主要是因为在某次冒泡的过程中,没有进行任何一次数据的交换,说明数组中任何一个数都小于其相邻的右边的数,这就表明数组此时已经有序了。因此我们设置了一个检测某一轮冒泡中,是否产生了数据交换过程的标记,以便能够及时跳出循环。冒泡排序的时间复杂度是 o ( n 2 ) o(n^{2}) o(n2),空间复杂度是 o ( 1 ) o(1) o(1),并且排序是稳定的排序。

2 选择排序

选择排序的思路比较简单,从第一个位置开始,每次从排序的位置开始遍历到数组尾部,选择一个最小的数放到这个位置上,准确点说是把那个选到的最小数与这个位置原本的数进行交换。一直进行到最后一次就完成了排序。这种排序比较简单粗暴,无论原始数据的排列情况如何,时间复杂度都是 o ( n 2 ) o(n^{2}) o(n2) ,空间复杂度为 o ( 1 ) o(1) o(1) , 并且是不稳定的排序方式。代码如下:

void selectSort(vector<int> &nums)
{
	int len = nums.size();
	for(int i=0;i<len;++i)
	{
		int min = i;
		for(int j=i;j<len;++j)
		{
			if(nums[j] < nums[min])
				min = j;
		}
		exch(nums,i,min);
	}
}
3 插入排序

插入排序的过程可以类比我们抓扑克牌的过程,我们手里拿的已经是排好序的一堆牌,每抓一张新的牌,我们就会将这张牌同手上的牌进行比较,从最大那张牌开始往前面扫描,直至找到一张比新牌更小的一张牌,就把新牌放在这张牌之后;或者全是比新牌更大的牌面值,就把新牌放在最前面,以上就完成了一次插入排序的一次过程 ,重复以上过程直到最后一张牌,就完成了所有的排序。插入排序时间复杂度为 o ( n 2 ) o(n^{2}) o(n2) , 空间复杂度为 o ( 1 ) o(1) o(1),插入元素遇到与自己值相等的元素并不会继续往前,因此插入排序是稳定的算法。插入排序代码如下:

void insertSort(vector<int> &nums)
{
	int len = nums.size();
	for(int i=1;i<len;i++)	//从第二个元素开始进行插入排序
		for(int j=i;j>0 && nums[j]<nums[j-1];j--)//只要比插入元素小就一直往前扫描,直到头部
			exch(nums,j-1,j);
}
4 希尔排序

有了插入排序的基础,理解希尔排序就很简单了,希尔排序可以看作是插入排序的升级版本,升级的主要有两个点,第一是把整个排序数组进行了 k k k 间距划分,比如数组有十个元素,以 k = 4 k=4 k=4 为划分的间距分组,则下标为 {0,4,8},{1,4,9},{2,6},{3,7} 一共形成了四个子数组,对这四个子数粗进行插入排序;第二点就是对子数组的插入排序并不是完成第一组后进行第二组,而是对所有的组轮流进行插入排序。

继续回到希尔排序本身,进行了一次k间距划分并且排序一次之后,每个子数组都是已经有序的,接下来就要把间距继续缩小,取k为2,{0,2,4,6,8},{1,3,5,6,7,9} 一共构成了两个子数组,同样进行子数组的插入排序,并且还是轮流进行排序。
重复以上的过程,直到间距取1,那么这时候再进行插入排序就完成了对整个数组的排序,整个过程就是希尔排序的思想。先上代码,再慢慢陈述细节:

void shellSort(vector<int> &nums)
{
	int len=nums.size();
	int gap=1;
	// 增大gap值
	while(gap < len/3) gap=gap*3+1;
	while(gap>=1)
	{
		for(int i=gap;i<len;i++)
			for(int j=i;j>=gap && nums[j]<nums[j-gap];j=j-gap)
				exch(nums,j-gap,j);
		gap = gap/3;
	}
}

第一次 g a p gap gap 的距离会稍微取大一点,然后再慢慢减小,我们主要看第二个 while 循环内部的两层 for 循环。上面我们说轮流对子数组进行插入排序,那么如何理解这个轮流排序呢。第一层for循环是从下标为gap的元素开始,直到数组的最后一个元素,这里其实就是对【 g a p gap gap l e n − 1 len-1 len1】之间的元素进行插入排序,因为 g a p gap gap 之前的每一个元素都是某一个子数组的首元素,插入排序中我们知道首元素是默认有序的,因此应从第二个元素开始插入排序, g a p gap gap 就是第一个子数组的第二个元素的下标, g a p + 1 gap+1 gap+1 就是第二个子数组的第二个元素,以此类推,就可以把所有的子数组,从第二个元素开始到这个子数组最后一个元素全部进行插入排序,并且逐轮次进行。
值得一提的是,希尔排序根据gap的取值序列的不同,最终的得到的整个排序时间复杂度也不同,认为其平均时间复杂度为 o ( n l o g n ) o(nlogn) o(nlogn) ,如果取其他的序列可能复杂度会更小,可查阅相关资料。空间复杂度是 o ( 1 ) o(1) o(1) , 并且希尔排序要和普通插入排序区分,希尔排序是不稳定的排序方式

5 归并排序

归并排序的是分治思想的典型应用。把待排序的序列分成两个规模较小的子数组,对这两个子数组进行排序,排完序之后对这两个已经有序的子数组进行归并,就完成了整个排序的过程。那么现在的问题就是对子数组排序了,同样,这个子数组可以进一步拆分成两个更小的子数组,这个过程很明显了,就是一个递归的过程。

//合并两个已经有序的子数组
void myMerge(vector<int>& nums,int start,int mid,int end,vector<int>& tmp)
{
	int i=start,j=mid+1;
	int k=0;
	while(i<=mid && j<=end)
	{
		if(nums[i] <= nums[j])
			tmp[k++] = nums[i++];
		else 
			tmp[k++] = nums[j++];
	}
	while(i<=mid)
		tmp[k++] = nums[i++];
	while(j<=end)
		tmp[k++] = nums[j++];
	for(int i=0;i<k;i++)
		nums[start+i] = tmp[i];
}

//把排序数组不断拆分成子数组,
void mySort(vector<int>& nums,int start,int end,vector<int>& tmp)
{
	if(end <= start) return;//只剩一个元素直接返回
	int mid = start + (end - start)/2;
	mySort(nums,start,mid,tmp);
	mySort(nums,mid+1,end,tmp);
	myMerge(nums,start,mid,end,tmp);
}

void mergeSort(vector<int>& nums)
{
	vector<int> tmp(nums.size());//用于进行归并的辅助数组
	mySort(nums,0,nums.size()-1,tmp);
} 

归并排序划分子数组的时间复杂度为 o ( l o g n ) o(logn) o(logn),而每次对数组的进行归并复杂度为 o ( n ) o(n) o(n),因此整个归并排序的时间复杂度为 o ( n l o g n ) o(nlogn) o(nlogn)。由于归并排序用到了一个辅助数组,并且有递归压栈,因此空间复杂度为 n + l o g n n+logn n+logn,因此空间复杂度就是 o ( n ) o(n) o(n)归并排序是稳定的排序算法。

6 快速排序

快速排序是一种非常经典的排序方式,其基本思想也是基于元素之间的比较。大概思路是每次随机选出一个元素,将其值作为基准值,设置两个遍历指针low和high分别指向序列的首和尾。high先从尾部向前扫描,如果元素值大于等于基准值就继续往前,如果这个元素值小于基准值,就把这个值赋给low所指的地方。接着从low所指的地方往后扫描,当扫描的值大于基准值的时候,把这个值赋给high所指的位置,然后再换到high的位置,继续往前扫描,重复这个过程。这样当high和low相遇的时候,high之后的元素一路扫描过来都是比基准值大的元素,low之前的元素就是比基准值小的元素,那么说明这个位置就是基准值最终的值。将基准值赋值给这个位置,就完成了一次快速排序的几个最基本的过程。接下来就是递归排序基准值前后两个部分的子序列。

int partition(vector<int> &nums,int low,int high)
{
	int pivot = nums[low];//基准数
	while (low < high)
	{
		while (low< high && nums[high]>=pivot)
			high--;//从后面往前找到小于基准数的数
		nums[low] = nums[high];
		while (low < high && nums[low]<=pivot)
			low++;//从前往后找到大于基准数的数
		nums[high] = nums[low];	
	}
	nums[high] = pivot;//基准数归位
	return high;
}

void quickSort(vector<int> &nums, int start, int end)
{
	if (end <= start)
		return;
	int j = partition(nums, start, end);
	quickSort(nums, start, j-1);
	quickSort(nums, j + 1, end);
}

理解了快排的思路,代码还是比较好写的。high和low在相遇之前,一旦遇到不符合要求的值,就会停下来把这个值赋给另一个指针所指的位置。high会把小于基准值的值赋给low所指的位置,然后high停留在这个位置。在这个时候,high和low所指的值是一模一样的,也就是说这个不符合要求的值在整个序列里其实保存了两份,这是因为我们把基准值一开始就做了一个备份,因此空出来了一个位置。

现在反过来想,low的值被high的值覆盖了,那low之前的值哪里去了。其实low之前的值,也就是low在上一次遇到了一个比基准值大的值而停下来的地方,同样把这个比基准值大的值赋给了high,然后换high扫描的时候,high从那个low刚赋值的地方开始往前扫描,也就是说被覆盖的值其实保留了一份。当low和high相遇,假设是high在移动扫描,那么与low相遇的时候,这个值就是high这次开始移动的时候的起点值,因此在相遇的位置,赋值为基准值。

快速排序的时间复杂度为 o ( n l o g n ) o(nlogn) o(nlogn),这是平均时间复杂度,最坏的时间复杂度为 o ( n 2 ) o(n^{2}) o(n2),这取决于基准值选取的方式和初始序列的排序情况。在代码中我们采取的是选用第一个值作为基准值,这并不是最好的选取方式,因为这种方式一旦初始序列已经大部分有序的时候,基准值最终的位置很可能比较靠前,这样快排划分就会出现划分不平衡的现象,如果每次划分都出现这种现象,快排的性能就会严重下降。最佳的划分就是基准值的位置可以将序列划分成较为平衡额两个部分,这样快排就可以达到较好的时性能。由于要递归调用排序,其空间复杂度为 o ( l o g n ) o(logn) o(logn),并且是不稳定的排序方式。

7 堆排序

在理解堆排序之前,需要明白堆这种结构,因为堆排序就是建立建立了堆结构之后才能进行的。先抛结论,堆是一种特殊的完全二叉树,特殊的地方在于二叉树的所有的节点的值总是大于或者等于左右孩子节点的值,这种堆就叫做大顶堆,同理,也有小顶堆。在进行堆排序之前,首先要做的就是构造堆,而构造堆的最核心步骤就是调整堆中的不符合要求的元素。例如构造大顶堆的过程中,对于节点值小于孩子节点的父节点,就要对这个父节点的位置进行相应的调整。

void heapify(vector<int> &nums,int i,int len)
{//把序号为i的节点往下调整,len不是nums的总长度,而是目前堆中的节点个数
 //随着排序的进行,堆的根节点每次出堆,整个堆的大小都会不断减小

	int left = 2*i+1;//左孩子
	int right = 2*i+2;//右孩子
	int largest = i;
	//找出父节点和孩子节点之间的最大节点
	if(left<len && nums[largest]<nums[left])
		largest = left;
	if(right<len && nums[largest]<nums[right])
		largest = right;
	if(largest != i)//如果最大值是孩子节点
	{
		exch(nums,i,largest);//与最大的孩子交换
		heapify(nums,largest,len);//继续往下调整
	}
}

//构造大顶堆
void buildMadHeap(vector<int>& nums)
{
	int len = nums.size();
	for (int i = len / 2; i >= 0; i--)//从最后一个非叶子节点开始
		heapify(nums, i, len);
}

对于一棵具有 n n n 个节点的完全二叉树,当 n n n 为偶数的时候,叶子节点共有 n / 2 n/2 n/2 个,当 n n n 为奇数的时候,叶子节点共有 ( n + 1 ) / 2 (n+1)/2 (n+1)/2 个,因此构建堆的时候,应该从序号最大的那个非叶子节点开始,逐个往前进行堆调整

堆构造完毕,下面就是进行堆排序了。虽然堆是二叉树,但是由于完全二叉树序号是从小到大依次排列存放在数组中的,大顶堆的根节点是整个堆中的最大值,也就是数组的第一个值。堆排序基本步骤就是每次把堆中的最大值和最后一个叶子节点交换,也就是数组的头元素和尾元素进行交换。交换完之后,由于最后一个叶子节点现在处于根节点的位置,需要对这个根节点进行堆调整,注意此时堆中的元素个数减少了一个。

重复上面这个过程,知道堆中只剩一个元素,堆排序就完成了。堆排序的代码如下:

void heapSort(vector<int> &nums)
{
	int len = nums.size();
	buildMaxHeap(nums);//先建立大根堆,最大值现在是根节点
	for(int i=len-1; i>0; i--)
	{
		exch(nums,0,i);//把目前的大根堆的根节点和尾节点互换
		len--;//最大值已经出堆,整个堆的大小减一
		heapify(nums,0,len);//调整此时的根节点到正确位置
	}
}

初始化建堆的时间复杂度为 O ( n ) O(n) O(n),排序重建堆的时间复杂度为 o ( n l o g n ) o(nlogn) o(nlogn),所以总的时间复杂度为 O ( n + n l o g n ) = O ( n l o g n ) O(n+nlogn)=O(nlogn) O(n+nlogn)=O(nlogn)。另外堆排序的比较次数和序列的初始状态有关,但只是在序列初始状态为堆的情况下比较次数显著减少,在序列有序或逆序的情况下比较次数不会发生明显变化。空间复杂度为 o ( 1 ) o(1) o(1)堆排序是不稳定的排序

8 计数排序

前面介绍了那么多的排序算法,算法的核心都还是基于元素之间的比较,因此最优时间复杂度大都卡在 o ( n l o g n ) o(nlogn) o(nlogn)。计数排序并不是一个基于元素之间比较的算法,而是一种线性的算法,其时间复杂度为 o ( n ) o(n) o(n)。当然,这是一种以空间换时间的做法。计数排序的核心思想是对每个元素值 x x x,确定排序序列中小于 x x x 的元素个数,比如有4个元素的值比 x x x 要大,那么 x x x 就自然得排在第五个位置了。理解了这一点,计数排序的代码还是不难写的。

vector<int> countSort(vector<int>& nums)
{
	vector<int> res(nums.size());
	int maxVal = nums[0];
	for(int i=0;i<nums.size();i++)
	{
		if(nums[i] > maxVal)
			maxVal = nums[i];
	}
	vector<int> countNums(maxVal+1,0);
	//countNums先统计所有值出现的次数
	for(int i=0;i<nums.size();i++)
		countNums[nums[i]]++;
	//countNums[i]表示等于或者小于i的元素个数
	for(int i=1;i<countNums.size();i++)
		countNums[i] += countNums[i-1];
	//把元素放到正确的位置上
	for(int i=nums.size()-1;i>=0;i--)//从后往前,为了保持稳定性
	{
		res[countNums[nums[i]]-1] = nums[i];
		countNums[nums[i]]--;
	}
	return res;
}

以上代码需要注意的地方就是第三和第四个for循环,前面说了计数排序要知道的东西是有多少个元素小于 x x x,在得到数组中各个值的个数之后,在这个基础上就可以统计小于或者等于 x x x 的元素个数,存放在 c o u n t N u m s countNums countNums 数组中。然后第四个for循环中,我们是从原数组的最后一个元素开始从后往前把元素放置正确的结果中,之所以这么做的原因就是为了保持稳定性。假设有多个相同的 x x x ,那么靠后的 x x x 会先被找出来,放在正确的位置上,然后 c o u n t N u m s [ x ] countNums[x] countNums[x] 的值会减一,那么下一次再找到 x x x 的时候,就会放到前面去了,不会破坏数组的稳定性。

最后谈一谈计数排序的复杂度的问题,计数排序具有明显的局限性,就是数组的最大值和最小值之间的差值不能太大。因为我们开了一个 c o u n t N u m s countNums countNums 数组进行计数,这个数组的大小是取决于最大值和最小值之间的差值,例如 类似 {8,1,2,10000000} 这种情况,就会造成我们的 c o u n t N u m s countNums countNums 数组开的很大,但是绝大部分都没有用到,造成了巨大的浪费。假设最大值和最小值之间的差值为 k k k ,数组一共有 n n n 个元素,那么计数排序的时间复杂度应该是 o ( n + k ) o(n+k) o(n+k),所以当 k = o ( n ) k=o(n) k=o(n) 的时候,计数排序的时间复杂度为 o ( n ) o(n) o(n),可以达到比较好的效果。空间复杂度就是 o ( n ) o(n) o(n)并且是稳定的排序

9 基数排序

我们排序的数一般都是十进制数,我们就以十进制数的排序为例。十进制数的每一位有 0~9 十种出现的可能。假设一个 d d d 位数,那么这个数就有 d d d 列,基数排序对所有的数字,从 d d d 列中的最后一列也就是个位开始,同一对所有的数进行排序,然后在根据倒数第二列的数字也就是十位数,对所有的数字排序,直到最高位那列的数字排序完毕,整个排序过程就结束。代码如下:

int getMax(vector<int>& nums)
{//取数组中的最大值
	int maxVal = nums[0];
	for(int i=0;i<nums.size();i++)
		if(nums[i] > maxVal)
			maxVal = nums[i];
	return maxVal;
}

void myCountSort(vector<int>& nums,int base)
{//利用稳定的计数排序对每一位进行排序
	vector<int> countNums(10,0);//每一位出现的数字只能是0~9
	vector<int> res(nums.size());

	for(int i=0;i<nums.size();i++)
		countNums[(nums[i]/base)%10]++;

	for(int i=1;i<countNums.size();i++)
		countNums[i] += countNums[i-1];

	for(int i=nums.size()-1;i>=0;i--)
	{
		res[countNums[(nums[i]/base)%10]-1]=nums[i];
		countNums[(nums[i]/base)%10]--;
	}
	
	for(int i=0;i<nums.size();i++)
		nums[i] = res[i];
}

void radixSort(vector<int>& nums)
{
	int maxVal=getMax(nums);
	for(int base=1; maxVal/base>0; base*=10)
		myCountSort(nums,base);
}

基数排序代码值得注意的地方就是,对每一列的排序需要用一个稳定的排序算法才能保证基数排序的准确性,上面我们说了计数排序是稳定的排序,因此可以使用到基数排序中来。例如经过最低位的排序的序列为 {21,31,12,33},接下来要对高位进行排序,假如我们采用一种不稳定的计数排序,这种不稳定的计数排序和稳定的计数排序差别在于稳定的计数排序排序从数组的尾部往前把元素放置到正确位置,而非稳定的计数排序是从头部往后将元素放入到最终的位置。对应到上述例子中,采用非稳定的计数排序排高位的话,排序结果为{12,21,33,31},结果错误。

由于基数排序中对每一位排序采用的还是计数排序排序,假设有 n n n 个数,开辟的 c o u n t N u m countNum countNum 数组大小为 k k k,一次排序的时间复杂度为 o ( n + k ) o(n+k) o(n+k),那么进行 d d d 位的时间复杂度为 o ( d ∗ ( n + k ) ) o(d*(n+k)) o(d(n+k))。空间复杂度为 o ( n ) o(n) o(n),并且基数排序是稳定的排序算法。

10 桶排序

十大排序算法终于到了最后一种了,桶排序其实我们在前面已经接触过了,计数排序其实就是一种特殊形式的桶排序。桶排序根据数据的分布,例如根据最大值和最小值之间的差值,把这个差值均分成若干个区间,每个区间就是一个桶,桶排序把数组中所有的数字放到对应的 “桶” 中,然后将桶进行排序,最后把所有的桶直接连接起来就完成了排序。那么问题就在于如何划分这个桶的大小,也就是这个均分的区间应该取多大。计数排序可以看作把桶的大小取为1的一种桶排序。对于更一般的桶排序,桶内排序的时间复杂度影响整个排序的复杂度,

void bucketSort(vector<int>& nums)
{
	int maxV = nums[0], minV = nums[0];
	for (int i = 0; i < nums.size(); i++)
	{//找出数组中的最大值和最小值
		if (nums[i] > maxV)
			maxV = nums[i];
		else if (nums[i] < minV)
			minV = nums[i];
	}
	
	//定义每个桶的大小为20,初始化桶
	int bucketSize = 20;
	int bucketCount = (maxV - minV) / bucketSize + 1;
	vector<vector<int>> buckets(bucketCount);
	
	for (int i = 0; i < nums.size(); i++)
	{//往每个桶中填充数据
		int bk_id = (nums[i] - minV) / bucketSize;
		buckets[bk_id].push_back(nums[i]);
	}
	
	//每个桶单独排序
	int id = 0;
	for (auto &bk : buckets)
	{
		insertSort(bk);
		for (auto v : bk)
			nums[id++] = v;
	}
}

代码中的桶内排序采用了的是插入排序,因此排序复杂度会更高,如果采用快速排序会较好,这时时间复杂度为 o ( N + N l o g N / M ) o(N+NlogN/M) o(N+NlogN/M) M M M 是桶的个数,包括把N个元素放到桶中 o ( N ) o(N) o(N) 的时间复杂度,以及所有的桶内的快速排序 o ( M ∗ N / M l o g N / M ) = o ( N l o g N / M ) o(M*N/MlogN/M)=o(NlogN/M) o(MN/MlogN/M)=o(NlogN/M) 的时间复杂度。当 N = M N=M N=M 的时候,时间复杂度为 o ( N ) o(N) o(N)。空间复杂度为 o ( N + M ) o(N+M) o(N+M)。很明显,桶排序的稳定与否取决于桶内的排序是否是稳定的排序算法。上面的代码采用的是插入排序,因此就是稳定的排序。

总结

偷一张图总结就完事儿了
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值