常用排序算法

    C++常用的十大(内部)排序算法,内部排序在这里指的是只用到了电脑内存而不使用外存的排序方式。

一 排序算法分类

    常用的排序算法根据排序过程中是否需要比较两个元素的大小可以分为比较排序非比较排序。
    比较排序算法实现数据排序的方式主要分为三种:交换、插入和选择,当然还包括一种特殊的不通过这三种方式实现的比较排序算法:归并排序。常见的非比较排序算法主要有三种:计数排序、桶排序和基数排序,它们用统计的方法规避了比较,也称为整数排序。

二 排序算法性能衡量标准

    排序算法的衡量标准有很多,但最重要的是时间复杂度空间复杂度。除此之外,还有:稳定性、最优和最差时间复杂度、比较次数、交换次数等衡量标准。

    1 时间复杂度

Asymptotic time complexity(渐进时间复杂度):若存在函数f(n),使得当n趋于无穷大时,T(n)/f(n)的极限值为不等于0的常数,则称f(n)是T(n)的同数量级函数,记作T(n)=O(f(n)),称为O(f(n)),O为算法的渐进时间复杂度,简称为时间复杂度。

    由于时间复杂度通常用O来表示,因此也被称为O表示法。

    基本原则和表示方法

    时间复杂度的推导满足如下几个基本原则,实际了解数学中极限相关的一些基础知识的话,内容非常容易理解。

  1. 常数量级的时间用常数1表示,时间复杂度记住O(1)
  2. 函数中只保留最高阶项,比如T(n)=n(n+1),时间复杂度T(n)=O(n*n)
  3. 时间复杂度的表示中,省略最高项阶的系数,比如T(n)=2n + 1, 时间复杂度T(n)=O(n)

    常见的时间复杂度

  • 常数阶:O(1)
  • 对数阶:O(log2n)
  • 线性阶:O(n)
  • 线性对数阶:O(nlog2n)
  • 平方阶:O(n^2)
  • 立方阶:O(n^3)
  • k 次方阶:O(n^k)
  • 指数阶:O(2^n)

    在满足n足够大的前提下,上述常见的算法时间复杂度由小到大依次为:Ο(1)< Ο(log2n)< Ο(n)< Ο(nlog2n)< Ο(n^2)< Ο(n^3)< Ο(n^k) < Ο(2^n) ,随着问题规模 n 的不断增大,时间复杂度不断增大,算法的执行效率越低。

    2 空间复杂度

Space complexity(空间复杂度):若存在 f(n) 表示所占存储空间的函数,使得当n趋于无穷大时,S(n)/f(n)的极限值为不等于0的常数,则称f(n)是S(n)的同数量级函数,记作 S(n)=O(f(n)),称为O(f(n)),O称为算法的空间复杂度。

    常见的空间复杂度

  • 常数阶 O(1) : 常量空间,用于算法使用的存储空间大小固定,和输入规模没有直接关系的场景。
  • 线性阶 O(n):线性空间,用于算法使用的存储空间是线性集合(比如列表)的情况,线性集合的大小和输入规模n成正比。另外,一般的递归操作相关的空间复杂度也大多跟递归深度n之间的复杂度为线性阶O(n)。
  • 平方阶 O(n^2):二维空间,用于算法使用的存储空间是而为列表集合的情况,二维列表的两个纬度的大小都与输入规模n成正比。

    策略

    在资源有限的情况之下,没有办法两者同时满足,在运算速度和空间资源之间的平衡就是所谓的策略,一般有时间换空间,或者空间换时间的策略。

    3 稳定性

    假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且 r[i] 在 r[j] 之前,而在排序后的序列中,r[i] 仍在 r[j] 之前,则称这种排序算法是稳定的;否则称为不稳定的。比如正常的冒泡排序是稳定的,但是如果将冒泡升序排序的算法中的 > 改为 >=,则会由稳定排序算法变为不稳定排序算法。
    稳定性的意义
    只有当在“二次”排序时不想破坏原先次序,稳定性才有意义。举例:如果我们只对一串数字排序,那么稳定与否确实不重要,因为一串数字的属性是单一的,就是数字值的大小。但是排序的元素往往不只有一个属性,例如我们对一群人按年龄排序,但是人除了年龄属性还有身高体重属性,在年龄相同时如果不想破坏原先身高体重的次序,就必须用稳定的排序算法。

    4 最优与最差时间复杂度

  • 最优时间复杂度:时间复杂度一般是一个平均的时间复杂度,而最优时间复杂度则是指所有情况都利于提升排序速度的情况下,最优的情况下,当前算法所能到的算法效率,能够在一定程度上说明此算法的潜力。
  • 最差时间复杂度:时间复杂度一般是一个平均的时间复杂度,而最差时间复杂度则是指所有情况都不利于提升排序速度的情况下,最差的情况下,当前算法所可能的算法效率,能够在一定程度上说明此算法受外界影响的风险程度。

    5 比较次数与交换次数

  • 比较次数:比较是排序的重要操作,比较次数本身也能直接地反应排序算法的复杂程度和效率,平均比较次数、最优比较次数和最差比较次数能够在一定程度上反应算法的一些特点。
  • 交换:交换也是排序的重要操作,交换次数本身也能直接地反应排序算法的复杂程度和效率,平均交换次数、最优交换次数和最差交换次数能够在一定程度上反应算法的一些特点。

    6 常见排序算法的性能

三 通过交换(swap)实现的排序

1. 冒泡排序(Bubble Sort)

    算法思想:冒泡排序的思想就是利用的比较交换,利用循环将第 i 小或者大的元素归位,归位操作利用的是对 n 个元素中相邻的两个进行比较,如果顺序正确就不交换,如果顺序错误就进行位置的交换。通过重复的循环访问数组,直到没有可以交换的元素,那么整个排序就已经完成了。

    算法性能

算法平均时间最好时间最坏时间额外空间稳定性
冒泡排序O(n^2)O(n)O(n^2)O(1)稳定
  • 稳定性:因为在比较的过程中,当两个相同大小的元素相邻,只比较大或者小,所以相等的时候是不会交换位置的。而当两个相等元素离着比较远的时候,也只是会把他们交换到相邻的位置。他们的位置前后关系不会发生任何变化,所以算法是稳定的。
  • 最好和最坏情况:最好情况是序列正序此时内层循环的条件比较语句始终不成立,不发生交换,内层循环执行n-1次,所以时间复杂度为O(n),空间复杂度为O(1);最坏情况是序列逆序,时间复杂度为O(n^2)。

    算法流程:常规的冒泡排序由内外两层循环构成。
    外层循环:即主循环,需要辅助我们找到当前第 i 小的元素来让它归位。所以我们会一直遍历 n-2 次,这样可以保证前 n-1 个元素都在正确的位置上,那么最后一个也可以落在正确的位置上了。
    内层循环:即副循环,需要辅助我们进行相邻元素之间的比较和换位,把大的或者小的浮到水面上。所以我们会一直遍历 n-1-i 次这样可以保证没有归位的尽量归位,而归位的就不用再比较了。

void bubble(vector<int>& array)
{
    //每次都把第i大的数放在最后第i个位置上
    for(int i = 0; i < array.size()-1; ++i) { //only need n-1 swaps to move the smallest to the front
        for(int j = 0; j < array.size()-1-i; ++j) {
            if(array[j] > array[j+1]) swap(array[j],array[j+1]);
        }
    }
}

    优化方法1 :每遍历完一遍,看是否已经提前完成排序(设置1个标志位,看当前趟是否有交换,如没有交换,则说明已完成排序)

void bubble1(vector<int>& array)
{
    bool hasSorted = false; //标志位
    for(int i = 0; i < array.size()-1 && !hasSorted; ++i) { //hasSorted为true说明已无数据交换,提前结束循环
        hasSorted = true;
        for(int j = 0; j < array.size()-1-i; ++j) {
            if(array[j] > array[j-1]) {
                hasSorted = false;  //当前遍历轮次有交换,仍未完成排序
                swap(array[j],array[j+1]);
            }
        }
    }
}

    优化方法2:根据算法性质,推论最后一个swap的 j 和 j+1 , j之后的元素(不包括j) 都已完成排序。

void bubble2(vector<int>& arr){
	int n = arr.size()-1;
	for(int i=0;i<arr.size()-1;i++){
		int upto = 0;
		for(int j=0;j<n;j++){			//j小于不定排序的最后一位
			if(arr[j]>arr[j+1]){
				upto = j;		//upto = j不定大小的最后一位, j+1 已经完成排序(最后一个if)
				swap(arr[j],arr[j+1]);
			}
		}
		n = upto;
		if(n == 0) break;
	}
}

    优化方法3:同时进行双向的循环,正向循环把最大元素移动到末尾,逆向循环把最小元素移动到最前,这种优化过的冒泡排序,被称为 鸡尾酒排序(Cocktail Sort)

void bubble3(vector<int>& arr){
	int beg = 0;
	int end = arr.size()-1;
	while(beg<end){
		int nbeg = beg, nend = end;
 
		//正向循环
		for(int i=beg;i<end;i++){
			if(arr[i]>arr[i+1]){
				nend=i;  //记录正向最后一次交换位置
				swap(arr[i],arr[i+1]);
			}
		}
		if(nend==end) break;  //已全部有序
		end = nend;
 
		//逆向循环
		for(int i=end; i>beg;i--){
			if(arr[i]<arr[i-1]){
				nbeg=i;  //记录反向最后一次交换位置
				swap(arr[i], arr[i-1]);
			}
		}
		if(nbeg==beg) break;  //已全部有序
		beg = nbeg;
 
	}
}

2. 快速排序(Quick Sort)

    算法思想:通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据比另一部分的所有数据要小,再按这种方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,使整个数据变成有序序列。

    算法性能

算法平均时间最好时间最坏时间额外空间稳定性
快速排序O(nlogn)O(nlogn)O(n^2)O(logn)不稳定

    算法流程:快速排序是一种分治的排序算法。其思想非常简单,在待排序的数列中,首先找一个数字作为基准数。为了方便,一般选择第 1 个数字作为基准数。接下来把这个待排序的数列中小于基准数的元素移动到待排序的数列的左边,把大于基准数的元素移动到待排序的数列的右边。此时左右两个分区的元素已相对有序;再递归对两个分区的元素按以上方法排序,直到各个分区只有一个数时为止。

void quickSort(vector<int>& arr,int begin,int end) {
    if(begin + 1 >= end) { //至多只有一个元素,不需要再排序
        return;
    }
    int left = begin;
    int right = end;
    int temp = 0;
    if(left <= right) {   //待排序的元素至少有两个的情况
        temp = arr[left];  //待排序的第一个元素作为基准元素
        while(left < right) {   //从左右两边交替扫描,直到left = right
            while(right > left && arr[right] > temp) {
                right --;        //从右往左扫描,找到第一个比基准元素小的元素
            }
            if(left < right) {
                arr[left] = arr[right];  //找到这种元素arr[right]后与arr[left]交换
                left++;
            }
 
            while(left < right && arr[left] < temp) {
                left ++;         //从左往右扫描,找到第一个比基准元素大的元素
            }
            if(left < right) {
                arr[right] = arr[left];  //找到这种元素arr[left]后,与arr[right]交换
                right--;
            }
        }
        arr[left] = temp;    //基准元素归位
        quickSort(arr,begin,left-1);  //对基准元素左边的元素进行递归排序
        quickSort(arr, right+1,end);  //对基准元素右边的进行递归排序
    }        
}

    优化方法1:小数组采用插入排序替代
    算法思想:当快排达到一定深度后,划分的区间很小时,再使用快排的效率不高。当待排序列的长度达到一定数值后,可以使用插入排序。

void quickSort(vector<int>& arr,int begin,int end)
{
    int pivotPos;
    if (end - begin + 1 < 10)  //如果区间很小,直接选择插入排序效率更高
    {
        InsertSort(arr,begin,end);
        return;
    }
    if(begin < end)  //否则,还是继续用快速排序
    {
        pivotPos = Partition(arr,begin,end);
        quickSort(arr,begin,pivotPos-1);
        quickSort(arr,pivotPos+1,end);
    }
}

    优化方法2:三平均划分(三数取中)
    算法思想:选取数组开头,中间和结尾的元素,通过比较,选择中间的值作为快排的基准。其实可以将这个数字扩展到更大(例如5数取中,7数取中等)。这种方式能很好的解决待排数组基本有序的情况,而且选取的基准没有随机性。

int NumberOfThree(vector<int>& arr,int begin,int end)
{
	int mid = begin + ((end - begin) >> 1);//右移相当于除以2
	//把三个数的中位数移到begin的位置
	if (arr[mid] > arr[end])
	{
		Swap(arr[mid],arr[end]);
	}
	if (arr[begin] > arr[end])
	{
		Swap(arr[begin],arr[end]);
	}
	if (arr[mid] > arr[begin]) 
	{
		Swap(arr[mid],arr[begin]);
	}
	//此时,arr[mid] <= arr[begin] <= arr[end],以三数中的中位数arr[begin]作为基准数
	return arr[begin];
}

    优化方法3:三分区划分
    算法思想:将排序的数组划分成小于,等于,大于三部分,这样重复的值就可以不用再次划分排序。

	/*三切分主要使用两个指针lt,gt来维护数组的三部分,遍历数组时不断交换元素并移动指针来进行元素的三切分。
	遍历过程中,指针移动和元素交换有三种情况,假设遍历元素下标为i,基准值为v:
	1. a[i]>v,a[i]和a[g]交换,g左移
	2. a[i]<v,a[i]与a[l]交换,l右移,i+1
	3. a[i]=v,i+1 */
	void partional(vector<int>& nums){
    int gt=nums.size()-1;
    int lt=0,i=0;
    int temp;
    while(i<=gt){
        if(nums[i]>v)  //这里的v是基准值
        {
            swap(nums[i],nums[gt]);  //比基准值大的元素放最后,并把尾指针前移(尾指针之后的都是比v大的元素)
            gt--;
        }
        else if(nums[i]==v){
            i++;  //等于基准值的元素不动,继续遍历下一个元素
        }
        else{
            swap(nums[i],nums[lt]);  //比基准值小的元素放前面,并把首指针后移(首指针之前的都是比v小的元素)
            i++;
            lt++;
        }
    }
}

四 通过插入(Insertion)实现的排序

3. 插入排序(Insertion Sort)

直接插入排序

    算法思想:插入排序(Insertion Sort)是一种简单直观的排序算法,它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

    算法性能

算法平均时间最好时间最坏时间额外空间稳定性
快速排序O(n^2)O(n)O(n^2)O(1)稳定
  • 时间复杂度:最好情况是数据有序,时间复杂度为O(n);最坏情况是逆序有序(全部需要移动一遍),时间复杂度为O(n^2)
  • 空间复杂度:需要一个额外数据来存储要插入的数据即可,空间复杂度为O(1)
  • 稳定性:插入排序算法在排序过程中,无序数列插入到有序区的过程中,不会改变相同元素的前后顺序,是一种稳定排序算法。
void insertion(vector<int>& arr){
	for(int i=1;i<arr.size();i++){
		int temp=i;
		for(int j=i-1;j>=0;j--){  //从右向左在有序区找到找到插入位置
			if(arr[temp]<arr[j]) swap(arr[temp--],arr[j]);  //这里会逐个交换
		}
	}
}

    优化方法1:找到当前元素的位置之后,再插入

void insertion1(vector<int>& arr){
	for(int i=1;i<arr.size();i++){
		int temp=arr[i];
        int j=i-1;
		for(;j>=0 && temp<arr[j];j--){
			arr[j+1] = arr[j];  //arr[j]后移
		}
		arr[j+1] = temp;  //直到找到合适位置之后,插入
	}
}

折半插入排序

    算法思想:以待排关键字所在位置将序列分为有序数列和无序数列两部分,然后对有序数列进行折半查找,找出一个点,左边的序列都是小于待排序关键字,该点与其右边至待排关键字的序列都是大于待排关键字的,将右边序列右移然后插入空处。(用二分查找来更快找到元素插入的位置)。

    算法性能

算法平均时间最好时间最坏时间额外空间稳定性
快速排序O(n^2)O(n)O(n^2)O(1)稳定

    折半插入排序,只是减少了比较次数,但是元素的移动次数不变。

  • 时间复杂度:最好情况是有序,时间复杂度为O(n),最坏情况是逆序,时间复杂度为O(n^2)
  • 稳定性:根据代码分析可以知道,当待插入数与mid位置的值相等时,接下来相当于进入了有序序列的右半区,mid+1到high,之后经过多次折半查找,该元素所找到的合适位置就是前一个与之相等元素的后一位,所以说两者相对位置没有发生变化,这般插入排序是稳定的。
void InsertSort_OP(vector<int>& a, int n)//插入排序—利用二分法优化
{
    int i,j,low,high,mid;
    int tmp;
    for(i=1;i<n;i++)
    {
        tmp=a[i];
        low=0;high=i-1;
        while(low<=high)			//在a[low .. high ]中折半查找 有序插入位置
        {
            mid=(low+high)/2;		//取中间位置
            if(tmp<a[mid])
            	high=mid-1;				//插入点在左边
            else
            	low=mid+1;  			//插入点在右边
        }
        for(j=i-1;j>=high+1;j--)	//元素后移
        	a[j+1]=a[j];
        a[high+1]=tmp;              //插入
    }
}

4. 希尔排序(Shell Sort)

    算法思想:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录"基本有序"时,再对全体记录进行依次直接插入排序。

    算法性能

算法平均时间最好时间最坏时间额外空间稳定性
快速排序O(n^3/2)O(n)O(n^2)O(1)不稳定
  • 稳定性:对于相同的两个数,可能由于分在不同的组中而导致它们的顺序发生变化。
  • 最坏时间复杂度:最坏情况下,每两个数都要比较并交换一次,则最坏情况下的时间复杂度为O(n^2), 最好情况下,数组是有序的,不需要交换,只需要比较,则最好情况下的时间复杂度为O(n)。
  • 最好时间复杂度:希尔排序的时间复杂度与增量(即,步长gap)的选取有关。例如,当增量为1时,希尔排序退化成了直接插入排序,此时的时间复杂度为O(N²),而Hibbard增量的希尔排序的时间复杂度为O(N3/2)。
//只需要把之前insert function的gap=1改成变量gap就行
void shellInsert(vector<int>& arr, int beg, int gap){
	for(int i=beg+gap;i<arr.size();i+=gap){
		int temp=arr[i];
		int j=i-gap;
		for(;j>=0 && temp<arr[j];j-=gap){
			arr[j+gap] = arr[j];
		}
		arr[j+gap] = temp;
	}
}
 
void shell(vector<int>& arr){
	int gap = arr.size()/2;
	while(gap>0){
		int beg=gap-1;
		while(beg>=0){
			shellInsert(arr, beg, gap);
			beg--;
		}
		gap = gap/2;
	}
}

五 通过选择(Selection)实现的排序

5. 选择排序(Selection Sort)

    算法思想:每一趟从待排序的数据元素中选出最小(或最大)的一个元素,顺序放在已排好序的数列的最后,直到全部待排序的数据元素排完。

    算法性能

算法平均时间最好时间最坏时间额外空间稳定性
快速排序O(n^2)O(n^2)O(n^2)O(1)不稳定
  • 时间复杂度:无论好坏都需要O(n^2), 因为每次选出最小值都需要遍历所有剩余元素。
  • 稳定性:排序算法在一趟选择中,如果当前元素比一个元素小,而该小的元素又出现在一个和当前元素相等的元素后面,那么交换后稳定性就被破坏了。例如:8 5 8 3 9,第一遍扫描,第1个8会与3交换,此时两个8之间的顺序就变了。

    算法流程:选择排序算法通过选择和交换来实现排序,其排序流程如下:

  • 首先从原始数组中选择最小的1个数据,将其和位于第1个位置的数据交换。
  • 接着从剩下的n-1个数据中选择次小的1个元素,将其和第2个位置的数据交换。
  • 然后,这样不断重复,直到最后两个数据完成交换。最后,便完成了对原始数组的从小到大的排序。
void select(vector<int>& arr){
	int s = arr.size();
	for(int i=0;i<s;i++){
		int m = arr[i];  //用 m 存放当前轮次遍历到的最小值
		int index = i;   //用 index 存放该最小值的位置
		for(int j=i+1;j<s;j++){
			if(arr[j]<m){
				m = arr[j];
				index = j;
			}
		}
		swap(arr[i], arr[index]);
	}
}

6. 堆排序(Selection Sort)

    算法思想:将待排序序列构造成一个大顶堆(或小顶堆),此时,整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值。然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。如此反复执行,便能得到一个有序序列了。

    :堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。如下图:

    同时,我们对堆中的结点按层进行编号,将这种逻辑结构映射到数组中就是下面这个样子。     该数组从逻辑上讲就是一个堆结构,我们用简单的公式来描述一下堆的定义就是:
  • 大顶堆:arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2]
  • 小顶堆:arr[i] <= arr[2i+1] && arr[i] <= arr[2i+2]

    算法性能

算法平均时间最好时间最坏时间额外空间稳定性
快速排序O(nlogn)O(nlogn)O(nlogn)O(1)不稳定
  • 时间复杂度:堆排序分为初始堆和重建堆两部分。初始化建堆的时间复杂度为O(n),排序重建堆的时间复杂度为O(nlogn),所以总的时间复杂度为 O(n+nlogn) = O(nlogn)。
  • 稳定性:堆排序是不稳定的,因为可能会交换相同的子结点。

    算法流程

  • 将无序序列构建成一个堆,根据升序降序需求选择大顶堆或小顶堆(一般升序采用大顶堆,降序采用小顶堆)。
  • 将堆顶元素与末尾元素交换,将最大元素"沉"到数组末端。
  • 重新调整结构,使其满足堆定义,然后继续交换堆顶元素与当前末尾元素,反复执行调整 + 交换步骤,直到整个序列有序。
void HeapSort(vector<int>& arr) {
    //构建大顶堆
    for(int i=arr.length()/2-1;i>=0;i--){
    	//从第一个非叶子结点从下至上,从右至左调整结构
        adjustHeap(arr,i,arr.length());
    }
    //2.调整堆结构+交换堆顶元素与末尾元素
    for(int j=arr.length-1;j>0;j--){
        swap(arr[0],swap[j]);//将堆顶元素与末尾元素进行交换
        adjustHeap(arr,0,j);//重新对堆进行调整
    }
}
//构建大顶堆 -- 交换最大子节点和父节点
void adjustHeap(vector<int>& arr, int i, int length) {
    int temp = arr[i];//先取出当前元素i
    for(int k=i*2+1;k<length;k=k*2+1){//从i结点的左子结点开始,也就是2i+1处开始
        if(k+1<length && arr[k]<arr[k+1]){//如果左子结点小于右子结点,k指向右子结点
            k++;
        }
        if(arr[k] >temp){//如果子节点大于父节点,将子节点值赋给父节点(不用进行交换)
            arr[i] = arr[k];
            i = k;
        }else{
            break;
        }
    }
    arr[i] = temp;//将temp值放到最终的位置
}

7. 归并排序(Merge Sort)

    算法思想:将序列不断分解为子序列直到只剩于0或1位。再将子序列不断按大小合并,最终恢复为原来序列的长度。

    算法性能

算法平均时间最好时间最坏时间额外空间稳定性
快速排序O(nlogn)O(nlogn)O(nlogn)O(n)稳定
  • 时间复杂度:由归并排序的递归公式:T(n) = 2T(n/2) + O(n)可知时间复杂度为O(nlogn);数组的初始顺序会影响到排序过程中的比较次数,但是总的而言,对复杂度没有影响。平均情况或最坏情况下它的复杂度都是O(nlogn)。此外,归并排序中的比较次数是所有排序中最少的。因为它一开始是不断地划分,比较只发生在合并各个有序的子数组时。
  • 空间复杂度:需要另外的空间来存子序列,因此空间复杂度为O(n)。
  • 稳定性:当元素相等时并不改变元素的前后位置, 所以归并排序是稳定的。
  • 归并排序虽然比较稳定,在时间上也是非常有效的(最差时间复杂度和最优时间复杂度都为 O(nlogn) ),但是这种算法很消耗空间,一般来说在内部排序不会用这种方法,而是用快速排序;外部排序才会考虑到使用这种方法。
vector<int> merge(vector<int> a, vector<int> b){
	vector<int> res;
	int ba = 0;
	int bb = 0;
 
	while(ba<a.size() && bb<b.size()){
		if(a[ba]<=b[bb]){
			res.push_back(a[ba++]);
		}
		else{
			res.push_back(b[bb++]);
		}
	}
 
	if(ba==a.size()){ //数组a元素已用尽,数组b的剩余元素按序写入
		while(bb<b.size()) res.push_back(b[bb++]);
	}else if(bb==b.size()){ //数组b元素已用尽,数组a的剩余元素按序写入
		while(ba<a.size()) res.push_back(a[ba++]);
	}
 
	return res;
}
 
vector<int> mergeSort(vector<int> arr){
	int s = arr.size();
	if(s<2) return arr;
	int mid = s/2;
    //数组arr分成两个数组front和back
	vector<int> front(arr.begin(), arr.begin()+mid);
	vector<int> back(arr.begin()+mid, arr.end());
	return merge(mergeSort(front), mergeSort(back));
 
}

六 整数排序(Integer Sort)

8. 计数排序(Counting Sort)

    算法思想:在一个有确定范围的整数空间中,建立一个长度更大的数组,如当输入的元素是 n 个 0 到 k 之间的整数时,建立一个长度大于等于k的数组。该数组的每一个下标位置的值代表了数组中对应整数出现的次数。根据这个统计结果,直接遍历数组,输出数组元素的下标值,元素的值是几, 就输出几次。

    算法性能

算法平均时间最好时间最坏时间额外空间稳定性
快速排序O(n+range)O(n)O(n+range)O(n+range)稳定
  • 时间复杂度:时间复杂度O(n+range),range为数据范围,当 range = O(n) 时,排序的时间复杂度为 O(n)。
  • 空间复杂度:空间复杂度为O(n+range),当 range = O(n) 时,排序的空间复杂度为 O(n)。
  • 稳定性:具有相同值的元素在输出数组中的相对次序与它们在输入数组中的相对次序是相同的。 也就是说,对两个相同的数来说,在输入数组中先出现的数,在输出数组中也位于前面。
  • 优点:当整数最大值和最小值相差较小时,计数排序的时间复杂度和空间复杂度都是非常有效的。
  • 缺点:①当数据最大值和最小值差距过大时,并不适用于计数排序;②当数列元素不是整数时,并不适用于计数排序。

    算法流程:假设 n 个输入元素中的每一个都是在 0 到 k 区间内的一个整数,其中 k 为某个整数。
    算法执行过程中需要三个数组:

  • 数组 A ,为要排序的输入数组,数组大小为 n。
  • 数组 B ,提供临时存储空间,用于存储小于该下标值的元素个数,数组大小为 k + 1。
  • 数组 C ,存放排序的输出,数组大小为 n。

    算法执行过程如下:

  • 根据待排序数组 A 中元素的最大值 max 和最小值 min,申请大小为 max − min + 1 的数组 C。
  • 扫描数组 A ,记录每个不同的元素出现的次数,将其记录在数组 C 中。数组 C 的每一位 C[i] 就代表数组 A 中元素 i + min 出现的次数。可以发现,计数排序的该过程,其实就是将待排序集合 A 中的每个元素值本身大小作为下标,依次进行了存放。而此时的数组 C 就是为了确定每个元素值出现了几次。
  • 对数组 C 中的每个值从前向后进行累加,每个位置的值更新为加上前一个位置的值,此时数组 C 中每个元素 C[i] 表示待排序数组 A 中 小于等于该下标值 i 的元素个数。
  • 反向遍历待排序数组 A 来填充目标数组 B :将 A 中每个元素值 A[j] 放在新数组的第 C[A[j]-min] 位,然后将 C[A[j]-min] 减一。
void count(vector<int>& arr, int range){
	vector<int> temp(range+1, 0);
	for(int i=0;i<arr.size();i++){
		temp[arr[i]]++;
	}
 	//temp数组中的元素写入数组arr
	int c=0;
	for(int i=0;i<arr.size();i++){
		while(temp[c]==0) c++;
		arr[i] = c;
		temp[c]--;
	}
 
}

9. 桶排序(Bucket Sort)

    算法思想:首先需要知道所有待排序元素的范围,然后需要有在这个范围内的同样数量的桶,将数据分到有限数量的桶子里,然后每个桶再分别排序(有可能再使用别的排序算法或是以递回方式继续使用桶排序进行排序)。

    算法性能

算法平均时间最好时间最坏时间额外空间稳定性
快速排序O(n+C)O(n)O(n+C)O(n+M)稳定
  • 时间复杂度:桶排序的平均时间复杂度为线性的O(n+C),其中C=n*(logn-logM)。如果相对于同样的n,桶数量M越大,其效率越高,最好的时间复杂度达到O(n)。
  • 空间复杂度:桶排序的空间复杂度 为O(n+M)。
  • 稳定性:桶排序是稳定的,不会改变相同元素的相对关系。
  • 适用场景:桶排序适用于在数据分布相对比较均匀或者数据跨度范围并不是很大时,当数据跨度过大时,这个空间消耗就会很大。

    算法流程:桶排序按下面4步进行:

  1. 设置固定数量的空桶。
  2. 把数据放到对应的桶中。
  3. 对每个不为空的桶中数据进行排序。
  4. 拼接从不为空的桶中数据,得到结果。

    桶排序,主要适用于小范围整数数据,且独立均匀分布,可以计算的数据量很大,而且符合线性期望时间。

const int offset = 105; // 为桶的边界
const int maxSize = 100; // 数组的最大存储范围

/*这里没有用数据映射,也没有用二维数组存放桶的计数vector<vector<int>> buckets*/
template <typename T>
void BucketSort(T arr[], int n) {
    int i, j;
    T buckets[offset];
     
    for(i = 0; i < offset; i++) // 清零
        buckets[i] = 0;
    // 1.计数,将数组arr中的元素放到桶中
    for(i = 0; i < n; i++)
        buckets[arr[i]]++; // 将arr[i]的值对应buckets数组的下标,每有一个就加1
    // 2.排序
    for(i = 0, j = 0; i < offset; i++) {
        while(buckets[i] > 0) { // 说明存有元素,相同的整数,要重复输出
            arr[j] = i;
            buckets[i]--;
            j++;
        }
    }
}

10. 基数排序(Radix Sort)

    算法思想:将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。

    算法性能

算法平均时间最好时间最坏时间额外空间稳定性
快速排序O(d(n+r))O(d(n+r))O(d(n+r))O(n+r)稳定
  • 时间复杂度:在基数排序中,r为基数,d为位数。则基数排序的时间复杂度为O(d(n+r))。我们可以看出,基数排序的效率和初始序列是否有序没有关联。
  • 空间复杂度:对于任何位数上的基数进行“装桶”操作时,都需要n+r个临时空间。
  • 稳定性:在基数排序过程中,每次都是将当前位数上相同数值的元素统一“装桶”,并不需要交换位置。所以基数排序是稳定的算法。

    算法流程
    第一步:假设有欲排数据序列如下所示:73 22 93 43 55 14 28 65 39 81首先根据个位数的数值,在遍历数据时将它们各自分配到编号0至9的桶(个位数值与桶号一一对应)中。

    第二步:分配结束后。接下来将所有桶中所盛数据按照桶号由小到大(桶中由顶至底)依次重新收集串起来,得到如下仍然无序的数据序列:81 22 73 93 43 14 55 65 28 39,接着,再进行一次分配,这次根据十位数值来分配(原理同上)。

void RadixSort(vector<int> &data)
{
    if(data.size() < 2)  return; //如果只有0个或1个数,直接返回
    // 遍历待排序序列获取最大值,并计算出最大值的位数digits,这决定我们要循环排序的次数.
    int length = static_cast<int>(data.size());
    int max = data.at(0);
    for(int i = 1; i < length; i++)
    {
    	if(data.at(i) > max)
    	{
        	max = data.at(i);
    	}
    }
    // 计算最大值位数.
    int digits = 1;
    while(max/10 > 0)
    {
    	++digits;
    	max /= 10;
    }
    // 创建10个桶(0-9),因为需要频繁地往桶里面插入元素,所以我们使用list容器,将十个桶放入vector中.
    vector<list<int>> bucket_list;
    bucket_list.resize(10);
    // 从个位开始进行每一趟的排序,其实就是将待排序序列的元素放入当前排序位数
    // (个/十/百...)数字对应的桶中.
    for(int i = 1; i <= digits; i++)
    {
    	for(int j = 0; j < length; j++)
    	{
        	// 计算出当前元素data.at(j)在本轮属于哪一个桶.
        	// pow()函数需要include<cmath>.
        	int radix = static_cast<int>(data.at(j)/pow(10,i-1)) % 10;
        	bucket_list.at(radix).push_back(data.at(j));
    	}
    	// 每完成一轮便将桶里的元素按顺序合并放入原序列.
    	int k = 0;
    	for(int n = 0; n < 10; n++)
    	{
        	for(auto value : bucket_list.at(n))
        	{
        		data.at(k++) = value;
        	}
        	// 同时需要将桶清空以便下一轮的排序.
        	bucket_list.at(n).clear();
    	}
    }
}

七 总结归纳

  1. 比较排序的运行时间下界是O(nlogn),也就是说任何基于比较操作的排序算法都不可能突破这个下界,这里指平均时间复杂度,不考虑最优情况(部分算法的最优情况可以达到n,本身就有序),但非比较排序则不受该下届影响(如桶排序)。
  2. 一般认为对于排序算法来说,时间 > 空间,因此时间复杂度更重要,但空间复杂度也得控制在合理范围内。
  3. 衡量标准排序算法是否稳定的标准是:是否会更改相同元素的相对位置。冒泡、插入、归并、计数、桶、基数排序是稳定的,快速、希尔、选择、堆排序是不稳定的。
  4. 内部排序算法快速排序比归并排序更受青睐,因为归并排序时间复杂度固定O(nlogn),而快速排序可以通过各种手段来避免快排中的极端情况。同时,归并排序空间复杂度为O(n),快速排序空间复杂度为O(1)。
  5. 快速排序要优于堆排序:①对于快速排序来说,数据是顺序访问的;而对于堆排序来说,数据是跳着访问的,这样对CPU缓存是不友好的。②堆排序算法的数据交换次数要多于快速排序。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值