C++实现不同的排序算法,以及实现过程中的难点

为了查找方便,产生了各种各样地排序算法,根据不同的使用场景,有不同类型的排序方式,如插入排序、交换排序、选择排序、归并排序、基数排序等。
很多人明白不同排序的运作原理,但是并没有自己实现过,这篇文章将分析几种排序方式的原理,并介绍利用c++实现算法过程中用到的小技巧。

首先,所有的排序方式如下面的目录所示,一个简单的比较如下表。

排序方式时间复杂度空间
复杂度
稳定性
初始状态影响适用情况
适用规模比较次数移动次数
最好
平均最坏最小
最大






直接插入排序nn21
越有序越好顺序表
链表
<10000
n-1
n(n-1)/20~n(n-1)/2
折半插入排序nn21越有序越好顺序表<10000
log2n+1n20~n-1
希尔排序n1.3n21
越有序越好顺序表<1000
<n2小于直插



冒泡排序nn21越有序越好顺序表<10000
n(n-1)/20~n(n-1)/2
快速排序nlog2nn2log2n有序反而慢顺序表n很大nlog2nn(n-1)/2



简单选择排序n21比较次数无关
移动次数有关
<10000
n2
n2n
堆排序nlog2n1有关
但不怕坏情况
顺序表n很大nlog2n




桶排序n+kn+k基本无关含关键字
计数排序
n+max-minmax-min最大最小值
差距越小越好
基数排序
ndn+k只能非
负整数排序
最大最小值
差距越小越好
归并排序nlog2n1无关顺序表
链表



多路归并排序nlog2nn
置换-选择排序d(n+r)r

内部排序

插入排序(Insert Sort)

  • 基本思想

插入排序的思想是一次拿一个元素出来,放在前面已经排好顺序的序列中。
类似于扑克牌整牌的过程。
在这个过程中,每一次都有一个元素会落到最终的位置。

  • 排序过程

排序过程主要分为三步。
第一步,在待排序列(也就是后面的序列)中提取出第一个元素。
第二步,在已排的有序序列(前面排好的序列)中查找这个元素应该在的位置。
第三步,将该元素插入到它应该在的位置。

在第二步查找的过程中,如果使用顺序查找,那么就是直接插入排序,如果使用折半查找,那么就是折半插入排序。
另外,如果抽取出部分序列进行排序,最后再综合排序,那么就是希尔排序。

直接插入排序
  • 过程
  1. 在待排序列中提取第一个元素;
  2. 用顺序查找的方法查找该元素在前边已排序列的位置;
  3. 插入该元素。
  • 适用范围:顺序表,链表都可以,但链表使用该方法是一大优势,因为插入元素比较简单,不需要移动后续的所有元素。另外,越有序的序列排序速度越快。

  • 性能:直接插入排序要对每个元素进行比较和插入,n个元素,当全部都排序好时,每个元素只需要比较一次,所以最好时间复杂度是o(1)。在最坏和平均情况时每个元素比较的次数大约是n次,所以时间复杂度o(n2),因为没有用到额外的存储空间,所以空间复杂度o(1)

  • 稳定性:稳定,但写代码的时候注意插入的时候遇到相同的数据,要插入到已排序列该数据元素之后,这样才能保证稳定性。

  • 比较次数:当序列本身有序的时候,比较的次数是最小的,因为每个元素只需要与其前一个元素(也就是已排元素的最后一个元素)作比较,然后发现它已经比这个元素大,所以只需要比较n-1次。但在最坏情况下,每个元素都要和前面的所有已排元素比较一遍也就是1+2+…+n-1 = n(n-1)/2次。

  • 移动次数:最好情况下,就根本不需要移动。在最坏情况下,每个元素都要使之前的已排元素全部后移一次,也是n(n-1)/2次。

  • 代码实现:首先对整个序列进行遍历,也就是第一步的待排序列取元素。
    按照顺序,应该是通过顺序查找的方式找到待插入位置的下标,然后再顺序后移元素,将取出的元素插入到空出的位置。

void DirectInsertSort1(vector<int> &nums) {
	//默认第一个元素就是一个已排序列,从第二个元素开始向后循环
	int n = nums.size();
	for (int i = 1; i < n; ++i) {//从第二个元素开始遍历待排序列
		int i2 = 0;
		//查找nums[i]元素应该在前面已排元素的哪个位置
		//因为是查找过程,所以可以直接把i这个元素当成哨兵,减少判断界限的语句
		while (nums[i2] < nums[i]) {
			i2++;
		}
		//找到位置以后,从i这个位置依次后移元素
		int temp = nums[i];
		for (int i3 = i; i3 > i2; --i3) {//移动元素的过程
			nums[i3] = nums[i3 - 1];//后移
		}
		//最后把i这个元素放在前面已排序列中应有的位置
		nums[i2] = temp;
	}
}

上述代码符合直接插入排序的概念,易于理解,但是实际上,查找和插入的过程上没有必要分开,从前往后的查找过程可以改成从提取元素的位置向前查找,并且在查找的过程中就可以移动了。

void DirectInsertSort2(vector<int>& nums) {
	int temp = 0;
	//默认第一个元素就是一个已排序列,从第二个元素开始向后循环
	int n = nums.size();
	for (int i = 1; i < n; ++i) {
		int temp = nums[i];
		int i2 = i - 1;
		for (; i2 >= 0; --i2) {
			//直接从该元素向前查找(因为该元素之前就是已排序列)
			if (nums[i2] > temp) {//只要没找到位置就将元素向后移动
				nums[i2 + 1] = nums[i2];
			}
			else {//找到应有的位置,后面的元素也后移完了
				nums[i2 + 1] = temp;//刚好插入
				break;
			}
		}
		if (i2 < 0) {//如果一直找到头了还没有找到位置,那么就放在最开始
			nums[0] = temp;
		}
	}
}

更简单地,也可以直接在查找的过程中进行交换,这样就不需要设置一个temp来进行保存了,当查找结束的时候,交换也结束,元素也被交换到了应有的位置。
不过实际上这种方式看起来简单,但会降低算法的性能,交换一个元素至少要三个操作,而多次交换其实都用的是这同一个元素,所以不如上一种算法来的快。

void DirectInsertSort3(vector<int>& nums) {//优化版本
	int temp = 0;
	//默认第一个元素就是一个已排序列,从第二个元素开始向后循环
	int n = nums.size();
	for (int i = 1; i < n; ++i) {
		int temp = nums[i];
		for (int i2 = i - 1; i2 >= 0; --i2) {//向前边查找边交换
			if (nums[i2] > nums[i2 + 1]) {//没找到位置,就交换
				swap(nums[i2], nums[i2 + 1]);
			}
			else {
				break;
			}
		}
	}
}
折半插入排序(Binary Insert Sort)
  • 过程
  1. 在待排序列中提取第一个元素;
  2. 用折半查找的方法查找该元素在前边已排序列的位置;
  3. 插入该元素。
  • 适用范围:只能顺序表,链表不行因为折半查找需要跳跃,链表无法跳跃查找。另外,越有序的序列排序速度越快。

  • 性能:虽然折半查找的时间复杂度为log2n,但是即便快速查找到了插入位置,后面的元素还是需要依次后移,所以时间复杂度o(n2)空间复杂度o(1)

  • 稳定性:稳定

  • 比较次数:当序列本身有序的时候,比较的次数是最小的,因为每个元素只需要与其前一个元素(也就是已排元素的最后一个元素)作比较,然后发现它已经比这个元素大,所以只需要比较n-1次。但在最坏情况下,每个元素通过折半查找要和前面的元素比较log2k次,k是已排序列的个数,所以总共需要比较的次数是log21+log22+…+log2n-1 ≈ log2(n!) ≈ nlog2n (log2(n!)和nlog2n是同阶函数)

  • 移动次数:最好情况下,就根本不需要移动。在最坏情况下,每个元素都要使之前的已排元素全部后移一次,也是n(n-1)/2次。

  • 代码实现:与直接插入排序的唯一区别就是查找用的是折半查找。

void BinaryInsertSort(vector<int> &nums){
	//默认第一个元素就是一个已排序列,从第二个元素开始向后循环
	int n = nums.size();
	for (int i = 1; i < n; i++) {//从第二个元素向后遍历
		int low = 0;
		int high = i - 1;
		int mid = (low + high) / 2;
		int target = nums[i];
		while (low <= high) {
			if (nums[mid] > target) {
				high = mid-1;
				mid = low + (high - low) / 2;
			}
			else {
				low = mid+1;
				mid = low + (high - low) / 2;
			}
		}
		//折半查找后,调整一下mid的位置
		if (nums[mid] < target) {
			mid++;
		}
		//最后从i这个位置,向后移动元素,为目标元素空出位置
		for (int i2 = i; i2 > mid; --i2) {
			nums[i2] = nums[i2 - 1];
		}
		nums[mid] = target;
	}
}
希尔排序
  • 过程
    以排序一个20个元素的序列为例
  1. 选取一系列缩小增量序列,选取规则随意,一般是对半选取,如{10,5,2,1},直到缩小到1。
  2. 对第一个缩小增量10,选取(1),(11)两个元素,使用直接插入排序,然后再选取(2),(12)两个元素,使用直接插入排序…直到选取(10),(20)两个元素,使用直接插入排序。
  3. 对第二个缩小增量5,选取(1),(6),(11),(16)四个元素,使用直接插入排序,然后再选取(2),(7),(12),(17)四个元素,使用直接插入排序…直到选取(5),(10),(15),(20)四个元素,使用直接插入排序。
  4. 继续缩小增量,直到增量为1,选取(1)~(20)这20个元素,使用直接插入排序,这时候序列本身已经基本有序,所以很快就排列完成了。

注意:增量序列的选取是任意的,不一定非要对半选取。

  • 适用范围:只能顺序表,链表不行因为无法跳跃查找。希尔排序也是越有序越好。

  • 性能时间复杂度o(n1.3)~o(n2),希尔排序时间复杂度的下限是nlog2n,没有快速排序快,但快速排序在数据已经有序的时候,会变得非常慢,所以可以建议在任何情况下先使用希尔排序进行排序。由于没有用到额外辅助空间,所空间复杂度o(1)。希尔排序之所以比直接插入排序快,是因为通过大增量的排序,一个元素已经大致移动到了它最终该在的位置上了,所以它总体上不需要像直接插入排序那样移动那么多次。

  • 稳定性:稳定

  • 代码实现

void ShellSort(vector<int> &nums) {
	int n = nums.size();
	vector<int> temp;
	for (int gap = n / 2; gap > 0; gap /= 2) {//对每一种增量进行排序
		for (int i = 0; i < gap; i++) {//对该种增量下的全部子序列进行排序
			for (int ii = i + gap; ii < n; ii += gap) {//对该子序列进行直插排序
				//以下是直接插入排序的过程
				int temp = nums[ii];
				int i2 = ii - gap;
				for (; i2 >= i; i2 -= gap) {
					if (nums[i2] > temp) {
						nums[i2 + gap] = nums[i2];
					}
					else {
						nums[i2 + gap] = temp;
						break;
					}
				}
				if (i2 < 0) {
					nums[i] = temp;
				}
			}
		}
	}

交换排序(Swap Sort)

  • 基本思想

交换排序的基本思想是相邻的元素两两之间进行比较,然后根据大小关系进行交换,使得元素逐渐有序。

  • 特征:每次都会有一个元素到达最终位置。
冒泡排序(Bubble Sort)
  • 过程
    以从从头到尾,两两元素之间进行比较,并根据大小进行交换,在一趟冒泡排序后,最大的元素就冒到最后面去了,然后剩下的元素再次进行,一直冒泡n次,就有序了。

  • 适用范围:顺序表,链表。

  • 稳定性:稳定。

  • 性能:在最好的情况下,已经有序,时间复杂度o(n),平均和最坏的时间复杂度都是o(n2)空间复杂度o(1)

  • 稳定性:稳定

  • 比较次数:冒泡排序总要搞那么n-1轮,所以比较次数一定是n(n-1)/2,。

  • 移动次数:当已经有序的情况下,冒泡排序不需要移动元素,当初始状态是反序时,冒泡排序每一趟要将元素从起始位置移动到该趟的最后边,n-1趟中,每一趟移动的次数分别是n-1,n-1…,1,所以最多移动的次数是n(n-1)/2。

  • 代码实现

void BubbleSort(vector<int> &nums) {	
	for (int n = nums.size(); n > 1; n--) {//遍历未排序的序列
		for (int i = 0; i + 1 < n; i++) {//遍历每两个相邻的元素
			if (nums[i] > nums[i + 1]) {//比较并交换
				swap(nums[i], nums[i + 1]);
			}
		}
	}
}
快速排序(Quick Sort)
  • 过程
    基于分治法的原则,递归地求解问题。
  1. 随意选取一个元素(可以是首元素也可以是尾元素也可以是随机一个元素),然后将它放在头部或者尾部。放在头部是左基准快排,放在末尾是右基准快排。
  2. 通过交换的方式(具体2种方法见代码实现),将该基准元素放在其最终排序的位置,即左边的元素均小于它,右边的元素均大于它。
  3. 递归地将左右两边的子序列快速排序。
  • 适用范围:顺序表,链表,其中的数据最好较为随机,有序反而排序可能变慢。

  • 性能:在最好的情况下,时间复杂度o(nlog2n),平均下来也是o(nlog2n),最坏的时间复杂度都是o(n2),空间复杂度o(log2n)~oo(n)

问:如何计算快速排序的时间复杂度?

  1. 递归分析法

首先分析最坏情况,当序列本身已经有序,那么n个元素,分成两个部分,一个部分的长度为n-1,另一个部分长度为0。这样就需要递归n次。在第一次,需要把基准元素比较n-1次,第二次递归,需要比较n-2次…最后需要比较1次,所以总共执行的比较次数是n(b-1)/2次,时间复杂度是o(n)。

在正常情况下,n个元素,分成两个部分递归,一个部分长a,另一个部分长b,有a+b=n-1的关系。其中,a序列需要执行的比较次数为a,b序列需要执行的比较次数为b,所以这一层递归需要执行的比较次数是n-1,同理,下一层的4个递归序列总共加起来要执行的比较次数为n-2。按照递归树,n个元素的递归树,其高度为log2(n+1),而根据上面的分析,每一层需要执行的比较次数已经确定,所以总共需要执行的比较次数大约为nlog2n,这既是算法的最差时间复杂度,也是算法的平均复杂度。

  1. 主定理法

假设有递推关系式T(n)=aT(n/b)+f(n),其中为n问题规模,a为递推的子问题数量,n/b为每个子问题的规模(假设每个子问题的规模基本一样),f(n)为递推以外进行的计算工作。

(1) 若f(n)=o(nlogba-ε),ε>0,那么T(n)=o(nlogba)。

(2) 若f(n)=o(nlogba),那么T(n)=o(nlogbalogn)。

(3) 若f(n)=o(nlogba+ε),ε>0,且对于某个常数c<1和所有充分大的n有af(n/b)≤cf(n),那么那么T(n)=o(f(n))。

在快速排序中,a = 2, b = 2, f(n) = o(n),所以f(n)满足第二种情况,所以直接根据主定理,可以判断其算法的时间复杂度为o(nlog2n)

问:如何计算快速排序的空间复杂度?

递归算法的空间复杂度=递归调用的深度×每次递归调用的额外空间

在最坏情况下,递归树的深度为n,每个递归实例并没有额外的空间使用,所以最差空间复杂度为o(n)。

在正常情况下,递归树的深度为log2n,无额外空间使用,所以空间复杂度为o(log2n)。

  • 稳定性:不稳定
  • 比较次数:在最坏的情况下,已经有序,退化为冒泡排序算法,第一个元素需要比较n-1次,第二个元素需要比较n-2次,总共需要比较最多n(n-1)/2次。在一般情况下,根据时间复杂度计算,总共需要比较nlog2n次。
  • 代码实现

第一种方法,从序列最开始进行遍历,每当遇到一个比基准元素小的元素,就把它扔到前面去,遍历结束后,所有比他小的都扔在了前面,剩下的就是比它大的,然后把基准元素放在中间。

void QuickSort(vector<int>& nums) {
	QuickRecursion2(nums, 0, nums.size()-1);
}
void QuickRecursion(vector<int>& nums, int low, int high) {
	if (low >= high) {//结束循环的标志是待排子序列已经长度为1
		return;
	}
	//右基准快排
	//(实际上可以随机一个元素并放在右边作为基准,防止待排序列本身有序)
	int key = nums[high];
	int mid = low;//找到基准元素的最终位置mid,保证左小于它,右大于它
	for (int i = low; i <= high; i++) {
		if (nums[i] < key) {//每当遇到一个比基准元素小的数字
			swap(nums[mid], nums[i]);//就把它和mid交换,并++mid
			mid++;
		}
	}
	//找到最终位置后,将它和基准元素high交换
	swap(nums[mid], nums[high]);
	//递归地解决左序列和右序列的排序问题
	QuickRecursion(nums, low, mid - 1);
	QuickRecursion(nums, mid + 1, high);
}

第二种方法,设置一个low指针一个high指针,low指针从前向后,找到一个比基准元素大的数字,与high指针对换,然后high指针从后向前,找到一个比基准元素小的数字,与low指针对换,直到low=high,就找到了该基准元素的位置。

void QuickSort(vector<int>& nums) {
	QuickRecursion2(nums, 0, nums.size()-1);
}
void QuickRecursion2(vector<int>& nums, int low, int high) {
	if (low >= high) {//结束循环的标志是待排子序列已经长度为1
		return;
	}
	//右基准快排
	//以最右边的数作为基准
	int key = nums[high];
	int l = low;
	int h = high;
	while (l != h) {//寻找mid的位置
		//low从前向后,找到比key大的,就和high作交换
		for (; l != h; l++) {
			if (nums[l] > key) {
				swap(nums[l], nums[h]);
				break;
			}
		}
		//high从前向后,找到比key小的,就和low作交换
		for (; h!=l; h--) {
			if (nums[h] < key) {
				swap(nums[h], nums[l]);
				break;
			}
		}			
	}
	//交换结束后,l和h的位置都指向mid,并且基准元素也被交换到了这个位置
	int mid = l;
	QuickRecursion2(nums, low, mid - 1);
	QuickRecursion2(nums, mid + 1, high);
}

选择排序

  • 基本思想

从所有元素中选择一个最小的,放在第一位,然后再选、再选、再选。

  • 特征:每次都会有一个元素到达最终位置。
简单选择排序
  • 过程:从未排序的序列中找出最小的,和头交换,然后去掉头,再继续找。

-适用范围:顺序表,链表。

  • 性能时间复杂度o(n2) = 比较次数o(n2)+移动次数o(n),不论初始状态如何都要比较n2次,但是移动的次数不一定。
    空间复杂度o(1)

  • 稳定性:不稳定。

  • 比较次数:每一个元素都需要比较n2次才能选择出来,所以比较次数永远是n2次。

  • 移动次数:每次选出来一个元素以后,只需要移动一次即可,所以总共的移动次数是n。

  • 代码实现

void SelectionSort(vector<int>& nums) {
	int n = nums.size();
	int temp = INT_MAX;//保存最小的元素
	int index = 0;
	for (int i = 0; i < n; i++) {//遍历整个序列
		for (int i2 = i; i2 < n; i2++) {//
			if (nums[i2] < temp) {//找到最小的元素
				temp = nums[i2];
				index = i2;
			}
		}
		swap(nums[i], nums[index]);//与当前的头元素交换
		temp = INT_MAX;
	}
}
堆排序
  • 过程
  1. 把序列看成是一个完全二叉树(注意只是看成,不是转换,实际上序列还是顺序存储结构)。
  2. 通过筛选方式将这个树化为大根堆(所有非叶子结点的值大于或等于其子节点的值)。筛选的方法是从i=n/2-1(n为元素个数)这个点开始,比较其与其孩子的关系,并作交换,然后再比i-1,一直比到第一个节点,每一次比较都要让整个树满足定义,即每一个节点都满足比其孩子大的性质。
  3. 输出顶端最大元素(输出的方式是将其与末尾元素替换,末尾元素时最大元素,下一次不参与建堆)。
  4. 从头结点(刚刚缓过来的末尾元素)开始再一次构建大根堆。
  5. 输出-构建直到所有的元素都已经输出,这时正好排序成为了升序序列。
  • 适用范围:顺序表。和初始状态基本无关,堆排序的最大好处,就是不怕任何坏情况,所有情况都能在nlog2n的时间内完成。

  • 性能时间复杂度o(nlog2n)
    空间复杂度o(1)

问:堆排序的时间复杂度如何计算?

堆排序需要使用到递归,总共有两个过程会影响到时间复杂度。第一个过程是初始化建堆的过程,第二个阶段是不断输出堆顶元素,调整剩余堆的过程。

在第一阶段,我们从n/2-1这个非叶子结点开始,比较其与孩子的大小关系(这作为一个基本操作),然后一直比到第一个根节点,总共要比n/2-1个节点。每个节点比较的次数,取决于其所在的层次。例如,倒数第二层的非叶子结点,只比较其与下一层的关系;倒数第三层的非叶子结点,在比较与其孩子关系时,如果发生交换,那么还要递归地保证其子树也是符合大根堆定义的,所以其孩子也要比较一次,直到最后一层,所以要比较2次。可见,假设树的高度是h,那么第i层的元素,最多都要比较h-i次。

对于一个n个元素的序列,把它看成树,其树的高度为h=log2n,拿第i层来分析,第i层一共有2i-1个节点,每个节点要比较h-i次,所以该层总共要比较的次数是

a_i = 2^{i-1}(h-i) = h2^{i-1}-i2^{i-1}

总共需要比较的层数是从1~h-1(因为h-1是倒数第二层)。也就是说要求ai的前h-1项和

S_m = (2^0h+2^1h+...+2^{m-1}h) - (1*2^0+2*2^1+...+i*2^{i-1})

Si的左半部分是等比数列,根据等比数列前m项和,为(2m-1)h,右半部分为差比数列,运用错位相减法,可得其前m项和为(m-1)2m-1,那么可得总体的前m项和为

S_m = (2^{m}-1)h-(m-1)2^{m}-1

带入h-1,得到前h-1项和为

S_{h-1} = n-log_2n-1

可以发现,增长速度最快的是n项,所以在初始化建堆过程中,算法的时间复杂度为o(n)

在第二阶段,我们要反复地将大根堆顶部元素输出(与末尾元素交换),并重新建堆。这个过程显然要重复n次,因为堆的高度是h=log2n,所以第一次最多要比较的次数为log2n次,第二次最多要比较的次数为log2n-1次…最后一次只需要比较log21次。所以共需要执行的比较次数最多为

log_2n +log_2(n-1)+...+log_2(1) = log_2(n!) 

因为log2n!和nlog2n是同阶函数,所以第二步重建堆的时间复杂度为o(nlog2n)

综合可得,堆排序的时间复杂度为o(n)+o(nlog2n) = o(nlog2n)

  • 稳定性:不稳定。
  • 比较次数:上述对时间复杂度的分析,可以看出堆排序的比较次数总是o(n)+o(nlog2n) = o(nlog2n)
  • 代码实现
void HeapSort(vector<int>& nums) {
	int n = nums.size();
	for (int i = n / 2 - 1; i >= 0; i--) {
		//首先从n/2-1这个节点开始,依次向上构建大根堆
		AdjustHeap(nums, n, i);
	}
	//将堆顶元素与末尾元素交换(也就是输出堆顶元素)
	swap(nums[0], nums[n - 1]);
	//默认剩余堆的元素少了一个
	for (int i = n-1; i > 0; i--) {
		//对剩余的堆进行大根堆调整
		AdjustHeap(nums,i,0);
		//输出元素
		swap(nums[0], nums[i-1]);
	}

}
//构建大根堆,n是长度,start是调整位置,自上而下调整
void AdjustHeap(vector<int>& nums, int n, int start) {
	int lc = 2 * start + 1;
	int rc = lc + 1;
	int maxindex = start;
	if (rc < n) {//如果该节点含有两个子节点,选取大的子节点交换
		if (nums[rc] > nums[lc]) {
			maxindex = rc;
		}
		else {
			maxindex = lc;
		}
		if (nums[maxindex] < nums[start]) {
			maxindex = start;
		}
	}
	else if (lc < n) {//如果该节点只含有左儿子,那么比较并交换
		if (nums[lc] > nums[start]) {
			maxindex = lc;
		}
	}
	else {//如果没孩子,那么就不管了
		maxindex = start;
	}
	if (maxindex != start) {
		//交换后,递归地解决被交换孩子节点的子树
		swap(nums[maxindex], nums[start]);
		AdjustHeap(nums, n, maxindex);
	}
	
}

归并排序

  • 过程
  1. 先将n个元素看成是n个单元素有序表;
  2. 通过merge函数将两个有序表之间归并;
  3. 通过merge函数将再将两个有序表之间归并(因为表内本身有序,所以很快);
  4. 一直到只剩一个大的有序表,或者只剩一个元素,单独处理一下。
  • 适用范围:顺序表,链表。尤其适用于大量数据的外部排序。

  • 性能:总共n个元素,两两归并,两两再归并,可以形成满二叉树,树的叶子节点共有n个,根据树的性质,共有log2n+1层,其中,每一层都要比较n次元素,所以时间复杂度o(nlog2n)。归并排序中,在merge的过程中,需要一个辅助表来进行临时排序存储,这个表的长度为n,所以
    空间复杂度o(n),当然,如果通过旋转等操作,让归并的过程中不使用辅助空间,也可以让归并函数迭代实现的的空间复杂度变为o(1),但是递归实现的归并排序最少也要o(log2n)。

  • 稳定性:稳定。

  • 代码实现

void MergeSort(vector<int> &nums) {
	int n = nums.size();
	MergeSortRecursion(nums, 0, n-1);
}
void MergeSortRecursion(vector<int> &nums, int start, int end) {
	//递归地将序列二分
	if (end <= start) {
		return;
	}
	int n = end - start + 1;
	int mid = start + n / 2;
	//对左右序列分别递归地求解问题
	MergeSortRecursion(nums, start,mid - 1);
	MergeSortRecursion(nums, mid, end);
	//左右序列此时都已经有序,然后通过merge函数合并左右序列
	Merge(nums, start, mid - 1, mid, end);
}
void Merge(vector<int> &nums, int s1, int e1, int s2, int e2) {
	vector<int> out;//辅助数组
	int i1 = s1, i2 = s2;
	//拿出左右序列的第一个和第一个比,哪个小就把哪个放在辅助数组,然后继续
	while (i1 <= e1 & i2 <= e2) {
		if (nums[i1] < nums[i2]) {
			out.push_back(nums[i1]);
			i1++;
		}
		else {
			out.push_back(nums[i2]);
			i2++;
		}
	}
	//把剩余一个(肯定是都很大的元素)再依次放进辅助空间
	for (; i1 <= e1; i1++) {
		out.push_back(nums[i1]);
	}
	for (; i2 <= e2; i2++) {
		out.push_back(nums[i2]);
	}	
	//把辅助数组的内容再放回原数组
	for (int i = 0,i2 = s1; i <= e2 - s1; i++,i2++) {
		nums[i2] = out[i];
	}
}

分配式排序/桶子排序(Distribution Sort)

上述提到的所有排序方式都是基于比较的排序方式,通过两两元素之间的比较来判断元素应当处在的位置,而分配式排序非比较式的排序,它是通过每个元素内的的部分资讯(关键字),将要排序的元素分配至某些“桶”中。如通过个十百千位关键字的比较,再比如通过日期的年月日关键字的比较,或者姓名中姓的比较等。

桶排序是分配式排序的基本原理,基数排序是桶排序在正整数排序中的一个特例应用,计数排序是桶排序在数字最大最小差值小的情况下适用的一个特例。

分配式排序都是稳定的。

桶排序

  • 过程:基本思路是将待排序元素划分到不同的桶。
  1. 先扫描一遍序列求出最大值 maxV 和最小值 minV,然后确定一个桶的个数 k,
  2. 把区间 [minV, maxV]均匀划分成k个区间,每个区间就是一个桶。将序列中的元素分配到各自的桶。
  3. 对每个桶内的元素进行排序。可以选择任意一种排序算法。
  4. 将各个桶中的元素合并成一个大的有序序列。
  • 适用范围:序列元素含有多关键字可供比较。

  • 性能:当 k 接近于n时,桶排序的时间复杂度就可以近似认为是 O(n) 的。即桶越多,时间效率就越高,而桶越多,空间就越大(k=n就是计数排序)。

  • 稳定性:稳定

  • 代码实现:

计数排序

  • 过程:是一种O(n)的排序算法,但耗费空间较多。
  1. 开一个长度为 max-min+1 的数组。
  2. 扫描一遍原始数组,以当前值- minValue 作为下标,将该下标的计数器增1。
  3. 收集。扫描一遍计数器数组,按顺序把值收集起来。

举个例子, nums=[2, 1, 3, 1, 5] , 首先扫描一遍获取最小值和最大值,maxValue=5 , minValue=1 ,于是开一个长度为5的计数器数组 counter ,

  1. 分配。统计每个元素出现的频率,得到 counter=[2, 1, 1, 0, 1] ,例如 counter[0] 表示值 0+minValue=1 出现了2次。
  2. 收集。 counter[0]=2表示1出现了两次,那就向原始数组写入两个1,3。counter[1]=1 表示2出现了1次,那就向原始数组写入一个2,依次类推,最终原始数组变为 [1,1,2,3,5] ,排序好了。

计数排序本质上是一种特殊的桶排序,当桶的个数最大的时候,就是计数排序。

  • 适用范围:最大最小值差距不要太大,不然开辟的数组太大占用太多空间。
  • 性能

基数排序

  • 过程
  1. 将所有待排序整数(注意,必须是非负整数)统一为位数相同的整数,位数较少的前面补零。一般用10进制,
  2. 从最低位开始,依次进行一次稳定排序(不稳定的话上一次的结果放到下一次可能就乱了)。这样从最低位一直到最高位排序完成以后,整个序列就变成了一个有序序列。

举个例子,有一个整数序列,0, 123, 45, 386, 106,下面是排序过程:

第一次排序,个位,000 123 045 386 106,无任何变化

第二次排序,十位,000 106 123 045 386

第三次排序,百位,000 045 106 123 386

最终结果,0, 45, 106, 123, 386, 排序完成。

  • 适用范围:含关键字的正整数排序。

外部排序

多路平衡归并

置换-选择排序

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值