【数据结构和算法】八大排序算法(插入排序,希尔排序,选择排序,堆排序,冒泡排序,快速排序,归并排序,计数排序)

一、常见的排序算法


插入排序:
把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。
实际中我们玩扑克牌时,就用了插入排序的思想

选择排序:
每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。

交换排序:
所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。

归并排序:
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide andConquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。 归并排序核心步骤:

二、排序算法详解

1.直接插入排序

基本思路
以升序为例:

  1. 单趟排序:将目标值(有序序列的后一个元素)插入到一个已经排好序的有序序列中
    • 从有序序列的末尾倒着比较,如果目标值较小,就将该元素后移,并向前继续比较。
    • 否则,就将目标值插入到该元素之后。
    • 这里需要注意插入在序列中间和序列开头两种情况。
  2. 单趟拍完,有序序列的末尾向后扩张,继续下一趟排序,直到全部待排序的数据元素排完 。
void InsertSort(int *arr, int sz){
	assert(arr != NULL);
	//end的范围[0,sz-2],给x留一个空间
	for (int i = 0; i < sz - 1; i++)
	{
		int end = i;
		int x = arr[end + 1];
		//end==0时也要比较一次
		while (end >= 0)
		{
			if (x < arr[end])
			{
				arr[end + 1] = arr[end];
				end--;
			}
			else
			{
				break;//记得break
			}
		}
		//把x填入end的下一个位置
		arr[end + 1] = x;
	}
}

把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。

  • 动画演示:

    直接插入排序的特性总结:
    1. 元素集合越接近有序,直接插入排序算法的时间效率越高
    2. 时间复杂度:O(N) ~ O(N^2)
    3. 空间复杂度:O(1)
    4. 稳定性:稳定

2.希尔排序

基本思路:

  • 希尔排序是直接插入排序的优化版本:由于直接插入排序对顺序有序或接近有序的序列排序效率很高。所以希尔排序先通过多次分组预排序使序列接近有序,最后再进行直接插入排序≈O(N),以此来提高效率。
  • 多次分组预排序:就是将序列进行间隔分组,对同一组内的元素进行直接插入排序,并不断缩小分组间距。
  • 如何分组:
    • 通过增量gap对序列进行分组控制,gap是组内元素之间的间距,同时也是组数。
    • gap初始化为n; 每次分组预排gap = gap/3+1;除3是进过多次试验得出的最佳缩小系数,加1是为了避免跳过最后一次直接插入排序。
    • 直到gap==1进行最后一次直接插入排序使序列顺序有序。

投机取巧:希尔排序的写法其实就是将直接插入排序中的+1变成+gap,再加上对增量gap的控制。

特性总结:

  1. 希尔排序的时间复杂度平均为:O(N^1.3)

  2. 共进行logN次直接插入排序,其中:

    1. 开始时gap较大:组内元素数量较少,因此即便是最坏情况时间复杂度都大约为:O(N);同时由于分组间隔较大,大数字会很快移动到数列后面,使数列逐渐接近有序;
    2. gap逐渐变小直到1:组内元素数量增多,但由于数列逐渐接近有序,因此时间复杂度也大约为:O(N)

    因此希尔排序的时间复杂度大约为:O(N*logN),做到了对直接插入排序的优化。

以升序为例:

void ShellSort(int *arr, int n){
	assert(arr != NULL);

	//写法一(便于理解):排完一组再排下一组
	int gap = n;
	while (gap > 1)
	{
		//多次分组预排序,分组数量每次减少,直到1组排完。
		gap = gap / 3 + 1;//加1,防止跳过gap == 1
		//每次排序的起点
		for (int i = 0; i < gap; i++)
		{
			//同直接插入排序。j < n - gap,保证最后一个待排记录不会越界。
			for (int j = i; j < n - gap; j+=gap)
			{
				int end = j;
				int x = arr[end + gap];
				while (end >= 0)
				{
					if (x < arr[end])
					{
						arr[end + gap] = arr[end];
						end -= gap;
					}
					else
					{
						break;
					}
				}
				//找到插入位置后,end还会减一次,所以+gap
				arr[end + gap] = x;
			}
		}
	}

	//写法二(简洁):gap组数据交替插入排序
	int gap = n;
	while (gap > 1)
	{
		gap = gap / 3 + 1;
		for (int i = 0; i < n - gap; i++)
		{
			int end = i;
			int x = arr[end + gap];
			while (end >= 0)
			{
				if (x < arr[end])
				{
					arr[end + gap] = arr[end];
					end -= gap;
				}
				else
				{
					break;
				}
			}
			arr[end + gap] = x;
		}
	}
}

希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数gap,把待排序文件中所有记录分成gap个组,所有距离为gap的记录分在同一组内,并对每一组内的记录进行排序。然后,取gap = gap/3+1,重复上述分组和排序的工作。当到达gap==1时,所有记录在统一组内排好序。

  • 动画演示:

  • 希尔排序的特性总结:

    1. 希尔排序是对直接插入排序的优化。
    2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时是直接插入排序,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。
    3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些书中给出的希尔排序的时间复杂度都不固定。
  • 希尔排序的时间复杂度:

    1. gap越大,预排越快,预排后越不接近有序。(大数字会很快移动到后面)
      gap越小,预排越慢,预排后越接近有序
    2. 分组预排序的时间复杂度:
      最好:N/gap*gap —> O(N)(每组大约N/gap个元素)
      最坏:(1+2+3+…+N/gap)*gap; (gap越大,越接近N最坏情况越接近O(N))
    3. 希尔排序的时间复杂度:
      • gap = gap/2;
        大约为N*log2 N(进行lon2 N次预排,每次复杂度接近O(N))
      • gap = gap/3+1;
        大约为N*log3 N
      • gap由大变小,开始时由于gap很大,时间复杂度都大约为O(N)。多次预排序使得数组越来越接近有序。虽然gap变小了,每次排序的复杂度也都大约为O(N)。
    4. 多次实验得出希尔排序的时间复杂度平均大约为O(N^1.3)

3.选择排序

基本思路

  • left和right记录区间的左端和右端;

  • 不断遍历数组,经过一次遍历选出区间中的最大值和最小值;

  • 然后将最小值换到左端,最大值换到右端;

  • ++left; –right; 当left < right时循环继续。

注意:交换元素时如果先后交换的下标恰好相同需要做出调整。

void SelectSrot(int *arr, int n){
	assert(arr != NULL);
	
	int begin = 0;
	int end = n-1;
	//begin,end向中间靠拢
	while (begin < end)
	{
		int maxi = begin;
		int mini = begin;
		//一次循环找出一个最大值和一个最小值分别放到begin和end位置
		for (int i = begin; i <= end; i++)
		{
			if (arr[i] < arr[mini])
			{
				mini = i;
			}
			if (arr[i] > arr[maxi])
			{
				maxi = i;
			}
		}
		//因为先将maxi和end交换,所以当后换的mini==end 时原来end的值已经被换走了,转换一下
		if (mini == end)
		{
			mini = maxi;
		}
		Swap(&arr[end], &arr[maxi]);
		Swap(&arr[begin], &arr[mini]);	
		begin++;
		end--;
	}
}

在元素集合array[i]–array[n-1]中选择关键码最大(小)的数据元素若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换在剩余的array[i]–array[n-2](array[i+1]–array[n-1])集合中,重复上述步骤,直到集合剩余1个元素

  • 动画演示:
  • 直接选择排序的特性总结:
    1. 选择排序思路简单好理解,但是对有序序列无反应,复杂度恒为O(N^2)。效率低,实际中很少使用。
    2. 时间复杂度:O(N^2)
    3. 空间复杂度:O(1)
    4. 稳定性:不稳定

4.堆排序

基本思路:

  • 要先写一个向下调整函数。

  • 先调堆,向下调整建堆:

    • 升序:调大堆
    • 降序:调小堆
  • 利用堆删除思想来进行排序:

    • 记录堆尾下标end,同时end是删除堆尾元素后的size值;

    • 交换堆顶(0)堆尾(end)元素——将最大值交换到序列尾。

    • 向下调整,但此时的调整范围到end——恢复堆结构选出最大值

    • –end——进行下一轮选择交换。

void AdJustDown(int *arr, int sz, int root){
	assert(arr != NULL);

	int parent = root;
	int child = parent * 2 + 1;
	while (child < sz)
	{
		if (child + 1 < sz && arr[child + 1] < arr[child])
		{
			child++;
		}
		if (arr[child] < arr[parent])
		{
			Swap(&arr[child], &arr[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

void HeapSort(int *arr, int sz){
	assert(arr != NULL);
	//将数组向下调整成堆
	//调到堆顶还需再调一次堆,所以i >= 0;
	for (int i = (sz - 2) / 2; i >= 0; i--)
	{
		AdJustDown(arr, sz, i);
	}
	//类似与删除堆顶元素,将剩余元素向下调整
	//排到最后一个元素不需要再排了,所以end > 0;
	for (int end = sz - 1; end > 0; end--)
	{
		Swap(&arr[0], &arr[end]);
		AdJustDown(arr, end, 0);
	}
}

堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆

  • 动画演示:

    HeapSort

  • 堆排序的特性总结:

    1. 堆排序使用堆来选数,效率就高了很多。
    2. 时间复杂度:O(N*logN)
    3. 空间复杂度:O(1)
    4. 稳定性:不稳定

5.冒泡排序

基本思路

  • end记录冒泡的最终位置;
  • 待排区间中的数据前后两两比较,交换,冒泡到end;
  • 如果在一趟比较中没有发生交换则提前结束循环;
  • –end,准备冒下一个泡。当end > 0时循环继续。
void BubbleSort(int *arr, int sz){
	assert(arr != NULL);
	//将最大值换到最后
	int end = sz - 1;
	//只剩最后一个元素不需再排
	while (end > 0)
	{
		//优化冒泡排序,一轮冒泡未发生交换返回
		bool exchange = false;
		for (int i = 1; i <= end; i++)
		{
			if (arr[i] < arr[i - 1])
			{
				exchange = true;
				Swap(&arr[i], &arr[i - 1]);
			}
		}
		end--;
		if (!exchange)
		{
			break;
		}
	}
}
  • 动画演示:

  • 冒泡排序的特性总结:

    1. 冒泡排序是一种非常容易理解的排序
    2. 时间复杂度:O(N) ~ O(N^2)
    3. 空间复杂度:O(1)
    4. 稳定性:稳定
  • InsertSort VS BubbleSort:

    • 两者对已经有序的数组排序一样好
    • 对于接近有序的数组,InsertSort更好

    例如:一半有序,一半逆序的数组:

    • InsertSort:n/2+1+2+…+n/2
    • BubbleSort:n-1+n-2+…+n/2
    • InsertSort更优
  • 冒泡排序是效率最低的排序

6.快速排序

快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后对左右子序列重复该过程,直到所有元素都排列在相应位置上为止。

基本思路:
1. 选择一个关键字key,一般选最左值或最右值。
2. 单趟排序:目的是利用基准值key将序列分成左右两个部分:key左边的值比key要小,右边的值比key要大。即直接将key移动到排序后的最终位置。
3. 递归思想:单趟排完,再使用同样的方法使得左子区间有序,右子区间也有序,整体就有序了。

void QuickSort(int *arr, int left, int right){
	//小区间优化:当分割到小区间时(10左右),不再用递归分割的思路让这段子区间有序。对于递归快排,大量减少了递归次数
	if (right - left + 1 < 10)
	{
		//right - left + 1 区间内元素的数量
		//arr + left 起始位置不都在开头
		InsertSort(arr+left, right - left + 1);
		return;
	}

	// 按照基准值对array数组的 [left, right]区间中的元素进行划分
	int div = Partion3(arr, left, right);
	// 划分成功后以div为边界形成了左右两部分 [left, div-1] 和 [div+1, right]
	//left == right 区间内只有一个值,left > right 区间内没有值
	if(left < div-1)
	{
		// 递归排[left, div]
		QuickSort(arr, left, div - 1);
	}
	if(div+1<right)
	{
		//递归排[div+1, right]
		QuickSort(arr, div + 1, right);
	}	
	
}

上述为快速排序递归实现的主框架,与二叉树前序遍历规则非常像,在写递归框架时可想想二叉树前序遍历规则即可快速写出来,后续只需分析如何按照基准值来对区间中数据进行划分的方式即可。

将区间按照基准值划分为左右两半部分的常见方式有:

6.1 左右指针法(Hoare原版)

选最左值做key,右边先走找小于key的数,左边再走找大于key的数,找到后将两者互换。左右相遇时结束循环,最后key与相遇位置互换。

//三数取中
int GetMidIndex(int *arr, int left, int right){
	int mid = left + (right - left) / 2;

	int tmp1 = arr[left] > arr[mid] ? left : mid;
	int tmp2 = arr[mid] > arr[right] ? mid : right;
	return arr[tmp1] > arr[tmp2] ? tmp2 : tmp1;

}

int Partion1(int *arr, int left, int right){
	//三数取中 -- 有序的情况每次二分,将最坏情况变成最好情况
	int midi = GetMidIndex(arr, left, right);
	Swap(&arr[midi], &arr[left]);
	
	int keyi = left;
	while (left < right)
	{
		//右边先走,找小
		//">="  "<="注意等于条件,防止死循环
		//每次都要判断left<right,防止越界
		while (left < right && arr[right] >= arr[keyi])
		{
			right--;
		}
		//左边再走,找大
		while (left < right && arr[left] <= arr[keyi])
		{
			left++;
		}
		//交换left,right的值
		Swap(&arr[left], &arr[right]);
	}
	//left和right相遇时,left与key交换
	Swap(&arr[left], &arr[keyi]);
	return left;
}

动画演示:

规律总结:

  • 选最左值做key,右边先走:左右相遇时比key小
  • 选最右值做key,左边先走: 左右相遇时比key大

为什么?
没有相遇之前谁先走都无所谓,L找大R找小
相遇时(key选最左),无非是 L<–R 或 L–>R

  • L<-R (R找不到小),由于上次交换后L还未发生移动,此时的L< key (或L == key,其余所有数都比key要大);
  • L->R(L找不到大),由于是每次循环R先走,此时的R<key;

6.2 挖坑法

挖坑法不同于hoare原版将left和right的值直接进行交换。而是先将关键字key挖走;右边找小,放到左边的坑;左边找大,放到右边的坑;左右相遇后将关键字填入最后的坑中(相遇位置)。比起第一种方法,挖坑法更容易理解。

int Partion2(int *arr, int left, int right){
	
	//三数取中 -- 将有序的最坏情况变成最好情况
	int midi = GetMidIndex(arr, left, right);
	Swap(&arr[midi], &arr[left]);
	
	int key = arr[left];
	int pit = left;
	while (left < right)
	{
		//右边找小,放到左边的坑
		while (arr[right] >= key && left < right)
		{
			right--;
		}
		arr[pit] = arr[right];
		pit = right;
		//左边找大,放到右边的坑
		while (arr[left] <= key && left < right)
		{
			left++;
		}
		arr[pit] = arr[left];
		pit = left;
	}
	arr[pit] = key;
	return pit;
}

动画演示:

注意:挖坑法和左右指针法单趟排完后序列的顺序不同:

设一组初始记录关键字序列为(65,56,72,99,86,25,34,66),则以第一个关键字65为基准而得到的一趟快速排序结果是(A, B)
A . 34,56,25,65,86,99,72,66
B. 25,56,34,65,86,99,72,66
C. 34,56,25,65,66,99,86,72
D. 34,56,25,65,99,86,72,66

6.3 前后指针法

前后指针法划分数组思路详解:283. 移动零 (数组划分)

  1. 定义两个指针(下标)cur与prev一前一后:prev指向left,cur指向left+1;
  2. 选最左端left做keyi;(需提前做三数取中优化:让left,mid,right三个数的中间值于left交换);
  3. cur不断向前找小于key的数,找到后与++prev(大于key)交换;如果++prev==cur,则可以不交换。
  4. 经过这样的交换,prev左边的值 (keyi, prev] 都小于key,prev右边的值 (prev, cur)都大于等于key。(prev是左右区间的交界)
  5. 最后将prev和key交换,就可以实现快排单趟的分割。
//写法一:
int Partion3(int *arr, int left, int right){
	int midi = GetMidIndex(arr, left, right);
	Swap(&arr[midi], &arr[left]);

	int keyi = left;
	//cur从关键字的下一个位置开始
	int cur = left + 1;
	int prev = left;

	//cur<=right,最后一个位置也要进行比较
	while (cur <= right)
	{
		//重复条件cur<=right,防止越界
		while (cur <= right && arr[cur] >= arr[keyi])
		{
			cur++;
		}
		//重复条件cur<=right,防止cur>right,越界访问(顺序有序)
		if (cur <= right)
		{
			//如果prev紧跟cur,原地交换后,如果cur不加1会导致cur无法正常继续前进
			//如:6 1 2 7 8
			Swap(&arr[cur++], &arr[++prev]);
		}
	}
	//由于关键字取最左值,而arr[prev]<key,交换后符合条件
	Swap(&arr[prev], &arr[keyi]);
	return prev;
}

//写法二(推荐):
int Partion4(int *arr, int left, int right){
	int midi = GetMidIndex(arr, left, right);
	Swap(&arr[midi], &arr[left]);

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

	while (cur <= right)
	{
		//cur一直向前走,找到小的交换,交换后继续走
		//这种写法简介,不易错,推荐
		//++prev != cur,不进行原地交换
		if (arr[cur] < arr[keyi] && ++prev != cur)
		{
			Swap(&arr[cur], &arr[prev]);
		}
		cur++;
	}
	Swap(&arr[prev], &arr[keyi]);
	return prev;
}

  • 如果选最左值为关键字
    1. prev= left; cur = left+1;(将key隔离在外)
    2. 循环条件cur<=right
    3. 最后要将prev的位置与key交换。(左边比key小)
  • 如果选最右值为关键字
    1. prev = left-1; cur = left;
    2. 循环条件cur<right(将key隔离在外)
    3. 最后就要将prev+1的位置与key交换。(右边比key大)

发生交换后,prev位置的值小于key,prev+1的位置要么是cur,要么是比key要大的数。

6.4 非递归快排

极端情况下,如果递归深度太深会导致栈溢出的问题,这时就要改非递归算法:

递归改非递归的两种方法:

  1. 递归逆向思维强改迭代:求斐波那契数列的非递归算法,归并排序的非递归算法
  2. 借助数据结构模拟递归过程:二叉树的层序遍历,快速排序的非递归算法

利用栈模拟递归算法 (类似二叉树的前序遍历):

首先要清楚递归算法递归的是子区间的下标范围,因此我们可以在原算法的基础上将递归的部分改为:将子区间范围压入栈中;只要栈不为空(表示任有未排序的区间)就一直循环,每次循环先从栈中取出待处理的区间 (注意LIFO),然后进行分割,压栈,重复循环…

void _QuickSortNonR(int *arr, int left, int right){
	Stack st;
	StackInit(&st);
	//区间入栈,入左右
	StackPush(&st, left);
	StackPush(&st, right);
	//栈中保存待排序的区间,栈空表示排序完成
	while (!StackEmpty(&st))
	{
		//区间出栈,出右左
		int end = StackPop(&st);
		int begin = StackPop(&st);
		//小区间优化
		if (end- begin + 1 < 10)
		{
			InsertSort(arr+begin, end-begin+1);
			continue;
		}
		
		int div = Partion4(arr, begin, end);
		//left>=right的情况已经有序,不入栈处理
		//右区间先进,左区间先处理
		if (div + 1 < end)
		{
			StackPush(&st, div + 1);
			StackPush(&st, end);
		}
		if (begin < div - 1)
		{
			StackPush(&st, begin);
			StackPush(&st, div - 1);
		}
	}
	StackDestroy(&st);
}

利用用队列模拟递归算法 (类似二叉树的层序遍历):

与栈模拟算法的写法类似,只不过由于队列FIFO的性质,此时的非递归算法不再是递归算法类似前序遍历的模拟,其处理顺序类似于二叉树的层序遍历规则。

void _QuickSortNonR2(int *arr, int left, int right){
	Queue que;
	QueueInit(&que);
	QueuePush(&que, left);
	QueuePush(&que, right);

	while (!QueueEmpty(&que))
	{
		int begin = QueuePop(&que);
		int end = QueuePop(&que);
		//小区间优化
		if (end- begin + 1 < 10)
		{
			InsertSort(arr+begin, end-begin+1);
			continue;
		}
		
		int div = Partion1(arr, begin, end);

		if (begin < div - 1)
		{
			QueuePush(&que, begin);
			QueuePush(&que, div - 1);
		}

		if (div + 1 < end)
		{
			QueuePush(&que, div + 1);
			QueuePush(&que, end);
		}	
	}
	QueueDestroy(&que);
}

6.5 快速排序的时间复杂度

  • 最好情况:如果每次选中的key都是(接近)中位数:O(N*logN)

    • 单趟排序的时间复杂度O(N)
    • 每趟排序都要返回一个div,N个数据都要返回。所以二叉树共有N个节点,深度为logN
  • 最坏情况:有序序列排序(顺序、逆序、相等、重复序列):O(N^2)

    • 由于序列有序,因此每次div都在序列两端从而无法将序列分成两份。致使N个数就要进行N层递归,效率低下。
    • 大数据量下,递归层次太深会造成栈溢出(stack overflow)
  • 递归程序的缺陷:
    1. 相比循环程序,性能差。(只针对早期编译器。现在的编译器对递归调用,建立栈帧的优化都很好,递归相比循环性能差不了多少)
    2. 递归深度太深,可能导致栈溢出。

  • 如何解决快排面对有序的选key问题:
    1. 随机选key(稳定性差)
    2. 三数取中(左中右,取 中位数做key)有序的情况每次二分,将最坏情况变成最好情况

6.6 快速排序优化及特性总结

  • 三数取中:关键字key取序列左中右三个数中的中位数,主要针对序列有序的情况。每趟排序都可以将序列二分,将最坏的情况变成最好的情况。

  • 小区间优化:快速排序将序列分割到小区间时(20个左右),不再用递归分割的思路让这段子区间有序。对于递归快排,大量减少了递归次数。

  • 快速排序的特性总结:
    1. 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
    2. 时间复杂度:O(N*logN)
    3. 空间复杂度:O(logN)(用于开辟栈帧空间)
    4. 稳定性:不稳定

  • 快速排序的缺陷:
    1. 无法解决相等或重复序列的排序问题(有序且三数取中无效)
    2. 如:5,5,5,5,5,5 或 2,3,2,3,2,3,2
    3. 要针对实际问题选择排序算法

7.归并排序

7.1 递归归并

基本思路

  1. 正式排序前需要创建与待排数组相同大小的数组tmp。

  2. 首先计算出中间位置的下标,将序列一分为二。

  3. 如果子区间元素个数大于1则向下递归先使左右子区间有序

  4. 然后将左右子区间归并到tmp数组

  5. 最后将数据考回原数组。

void _MergeSort(int *arr, int left, int right, int *tmp)
{
	//注意递归的结束条件
	if (left >= right)
	{
		return;
	}
	//计算出中间位置的下标
	int mid = left + (right - left) / 2;
	//划分左右区间
	int begin1 = left, end1 = mid;
	int begin2 = mid + 1, end2 = right;
	//先使左右两区间有序
	_MergeSort(arr, begin1, end1, tmp);
	_MergeSort(arr, begin2, end2, tmp);
	//将左右两区间归并到tmp数组
	//注意排序的区间不从0开始
	int i = left;
	while (begin1 <= end1 && begin2 <= end2)
	{
		if (arr[begin1] < arr[begin2])
		{
			tmp[i++] = arr[begin1++];
		}
		else
		{
			tmp[i++] = arr[begin2++];
		}
	}
	while (begin1 <= end1)
	{
		tmp[i++] = arr[begin1++];
	}
	while (begin2 <= end2)
	{
		tmp[i++] = arr[begin2++];
	}
	//将排好的数据从tmp数组考回arr
	for (int j = left; j <= right; j++)
	{
		arr[j] = tmp[j];
	}
}

void MergeSort(int *arr, int sz){
	//递归排序过程中需使用额外空间
	int *tmp = (int*)malloc(sizeof(int) * sz);
	if (tmp == NULL)
	{
		perror("MergeSort");
		exit(1);
	}
	_MergeSort(arr, 0, sz - 1, tmp);

	//排序结束后记得释放额外空间
	free(tmp);
	tmp = NULL;
}

将数组一分为二,先使数组的左右区间有序,再将左右区间归并成一个有序数组。对比快速排序,归并排序与二叉树的后序遍历思想更为相似。

  • 动画演示:

归并排序的特性总结:
1. 归并的缺点在于需要O(N)的空间复杂度。
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(N)
4. 稳定性:稳定

归并排序思想用于解决在磁盘中的外排序问题:

7.2 非递归归并

归并排序的非递归需采用逆向思维进行改写:

  • 非递归归并没有递归归并对序列分解的过程,而是直接从最小的子区间(只有一个元素)向上进行归并排序。
  • 而且不同于递归归并左右根的后序遍历次序,非递归法将子区间同为1的区间一并排完到tmp,并一起拷回原数组。才进行下一层子区大小为2的区间的排序。
  • 定义变量gap表示子区间元素个数,gap从1开始排完后不断乘2,直到gap>=size为止。
  • 除非元素个数恰好是2的次方倍,否则在排序的过程中序列就不会被完整的划分,因此要针对不完整或不存在的区间进行调整。
  • end1, begin2, end2都有越界的可能:end1和begin2越界不归并,直接拷贝到tmp;end2越界需将end2调整到size再进行归并。

提示:定义tmp数组,将子区间归并到tmp,将数据考回原数组等操作和递归相同。

  • 写法一:
void MergeSortNonR1(int *arr, int sz){
	int *tmp = (int*)malloc(sizeof(int)*sz);
	if (tmp == NULL)
	{
		perror("MergeSortNonR1");
		exit(1);
	}
	//gap是待排区间子区间的大小
	int gap = 1;
	while (gap < sz)//保证左右两个子区间存在才能进行归并排序。
	{
		//i是待排区间的开始位置
		for (int i = 0; i < sz; i += 2 * gap)
		{
			//待排区间的第一个子区间
			int begin1 = i, end1 = i + gap - 1;
			//待排区间的第二个子区间
			int begin2 = i + gap, end2 = i + 2 * gap - 1;

			//如果元素个数不是2的次方倍,就不会被完整划分,可能出现越界访问
			//因此要对边界可能出现的各种情况进行处理
			//最后一个区间的第一个子区间不完整
			if (end1 >= sz)
			{
				end1 = sz - 1;
			}
			//最后一个区间的第二个子区间不存在
			if (begin2 >= sz)
			{
				begin2 = sz;
				end2 = sz-1;
			}
			//最后一个区间的第二个子区间不完整
			if (end2 >= sz)
			{
				end2 = sz - 1;
			}
			//将两个有序的子区间归并排序
			int j = i;
			while (begin1 <= end1 && begin2 <= end2)
			{
				if (arr[begin1] < arr[begin2])
				{
					tmp[j++] = arr[begin1++];
				}
				else
				{
					tmp[j++] = arr[begin2++];
				}
			}

			while (begin1 <= end1)
			{
				tmp[j++] = arr[begin1++];
			}
			while (begin2 <= end2)
			{
				tmp[j++] = arr[begin2++];
			}

		}
		//一层归并排完,一起拷贝回原数组的大区间拷贝法
		for (int k = 0; k < sz; k++)
		{
			arr[k] = tmp[k];
		}	
		//子区间大小乘2,准备进行下一层排序
		gap *= 2;
	}
	free(tmp);
	tmp = NULL;
}
  • 非递归归并的难点在于边界问题的处理,如果元素个数不是2的次方倍,就不会被完整划分,最后一个区间的排序可能出现越界访问。因此要对边界可能出现的各种情况进行处理。
    1. begin1不可能越界,begin1 = i,受循环条件控制。但end1,begin2,end2都有可能越界。
    2. 以序列 [10,6,7,1,3,9,4,2,5] 举例说明:
      1. end1 >= sz;最后一个区间的第一个子区间不完整

        • 第一个子区间不完整也同时表示第二个子区间不存在。
        • 截断第一个子区间,将第二个子区间置空;第一个子区间不进行归并直接移动到tmp数组
        • 即end1 = sz-1; begin2 = sz, end2 = sz-1;(区间非法即置空)
      2. begin2 >= sz;最后一个区间的第二个子区间不存在

        • 将第二个子区间置空;第一个子区间不进行归并直接移动到tmp数组
        • 即begin2 = sz, end2 = sz-1;(区间非法即置空)
      3. end2 >= sz;最后一个区间的第二个子区间不完整

        • 截断第二个子区间;与第一个子区间归并。
        • 即end2 = sz-1;
  • 写法二:
void MergeSortNonR2(int *arr, int sz){
	int *tmp = (int*)malloc(sizeof(int)*sz);
	if (tmp == NULL)
	{
		perror("MergeSortNonR1");
		exit(1);
	}
	//gap是待排区间子区间的大小
	int gap = 1;
	while (gap < sz)
	{
		//i是待排区间的开始位置
		for (int i = 0; i < sz; i += 2 * gap)
		{
			//待排区间的第一个子区间
			int begin1 = i, end1 = i + gap - 1;
			//待排区间的第二个子区间
			int begin2 = i + gap, end2 = i + 2 * gap - 1;

			//如果元素个数不是2的次方倍,就不会被完整划分,可能出现越界访问
			//因此要对边界可能出现的各种情况进行处理
			//最后一个区间只有一个子区间,不需要进行归并
			if (end1 >= sz || begin2 >= sz)
			{
				break;
			}
			//最后一个区间有两个子区间,但第二个子区间不完整,需要进行截断归并
			if (end2 >= sz)
			{
				end2 = sz - 1;
			}
			//将两个有序的子区间归并排序
			int j = i;
			while (begin1 <= end1 && begin2 <= end2)
			{
				if (arr[begin1] < arr[begin2])
				{
					tmp[j++] = arr[begin1++];
				}
				else
				{
					tmp[j++] = arr[begin2++];
				}
			}
			while (begin1 <= end1)
			{
				tmp[j++] = arr[begin1++];
			}
			while (begin2 <= end2)
			{
				tmp[j++] = arr[begin2++];
			}
			//每组子区间归并排完就立即拷回原数组的小区间拷贝法
			//此处应注意区间范围
			for (int k = i; k <= end2; k++)
			{
				arr[k] = tmp[k];
			}
		}
		//子区间大小乘2
		gap *= 2;
	}
	free(tmp);
	tmp = NULL;
}
  • 从以上解释中不难看出写法一中的越界情况可以简化为:
    • 当最后一个区间只有一个子区间时,不需要进行归并;不进行归并的区间就相当于原样拷贝,不如不拷贝,原地不动。
    • 当最后一个区间有两个子区间,但第二个子区间不完整时,需要进行截断后归并;
  • 但上述的思路不能采用一层归并排完,一起拷回原数组的大区间拷贝法,因为不拷贝就意味着不进入tmp数组,会导致tmp数组中的随机值覆盖原数组中的数据。因此,写法二采取每组子区间归并排完就立即拷回原数组的小区间拷贝法

8.计数排序

基本思路

  • 第1次遍历数组:选出最大最小值,max-min+1得到相对范围,创建计数数组。
  • 第2次遍历数组:统计序列中各元素出现的次数,并映射记录(-min)到计数数组。
  • 遍历计数数组:根据各值出现的次数,将数据映射填入(+min)到原数组中。
void CountSort(int *arr, int sz){
	//确定数据范围
	int max = arr[0];
	int min = arr[0];
	for (int i = 0; i < sz; i++)
	{
		if (arr[i] < min)
		{
			min = arr[i];
		}
		if (arr[i] > max)
		{
			max = arr[i];
		}
	}
	int range = max - min + 1;
	//根据数据范围开辟计数空间
	int *count = (int*)malloc(sizeof(int)*range);
	if (count == NULL)
	{
		perror("CountSort");
		exit(1);
	}
	memset(count, 0, sizeof(int)*range);
	//计数
	for (int i = 0; i < sz; i++)
	{
		count[arr[i] - min]++;
	}
	//排序
	int j = 0;
	for (int i = 0; i < range; i++)
	{
		while (count[i]--)
		{
			arr[j++] = i + min;
		}
	}
	free(count);
	count = NULL;
}

思想:计数排序又称为鸽巢排序,是对哈希直接定址法的变形应用。 操作步骤:

  1. 统计相同元素出现次数
  2. 根据统计的结果将序列回收到原来的序列中

  • 动画演示:

    计数排序的特性总结:
    1. 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
    2. 范围较大,或者是浮点数都不合适
    3. 时间复杂度:O(MAX(N,range))
    4. 空间复杂度:O(range)
    5. 稳定性:稳定

三、比较总结

在实际应用当中,应结合排序算法的时间复杂度,空间复杂度,稳定性,算法与数据的关系,选择最合适的排序算法。

1.时间复杂度和空间复杂度

2.算法的稳定性

稳定性:假定在待排序的记录序列中,存在多个具有相同关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定。

  • 各种排序的稳定性分析

3.算法与数据的关系

  • 数据不敏感:归并、选择和堆排序。
  • 数据敏感:
    • 冒泡排序(优化后):序列越有序越快
      • 最好情况:随机数据基本有序的可能性和范围很大
      • 最坏情况:随机数据基本有序的可能性和范围很小
    • 快速排序:序列越无序越快
      • 最好情况:随机数据基本有序的可能性和范围很小
      • 最坏情况:随机数据基本有序的可能性和范围很大
      • 对于元素相同或重复序列的数组,快速排序的效率极低O(N^2)
    • 插入排序:序列越有序越快
      • 最好情况:随机数据基本有序的可能性和范围很大
      • 最坏情况:随机数据基本有序的可能性和范围很小
      • 对于乱序随机数据的适应性,插入排序优于冒泡排序
    • 希尔排序:序列越有序越快
      • 最好情况:随机数据基本有序的可能性和范围很大
      • 最坏情况:随机数据基本有序的可能性和范围很小
      • 在数据量较小的排序中,希尔排序的优势并不明显 ,与直接插入排序相近。
    • 计数排序:数据范围越小越集中越快,最快可以达到O(N)
      • 最好情况:数据范围比较集中
      • 最坏情况:数据范围很大
      • 数据的范围较大或是浮点数排序都不合适。
  • 希尔排序和快速排序的比较

版权声明:本文为博主作者:芥末虾原创文章,版权归属原作者,如果侵权,请联系我们删除!

原文链接:https://blog.csdn.net/zty857016148/article/details/129149170

共计人评分,平均

到目前为止还没有投票!成为第一位评论此文章。

(0)
xiaoxingxing的头像xiaoxingxing管理团队
上一篇 2024年1月3日
下一篇 2024年1月3日

相关推荐