【C++ 排序算法】十大经典排序算法原理深度解析

排序算法

一、排序相关概念及常用排序

1. 概念

  • 稳定性:在排序中,是否改变相等元素原本的相对顺序,若改变,则是不稳定;若不改变,则是稳定。
  • 内部排序:数据元素全部放在内存中的排序。
  • 外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不断地在内外存之间移动数据的排序。

2. 常用排序

二、排序原理及代码实现

说明

以下排序如果没有明确指明升序或降序,均以升序为准

1. 插入排序

1.1 排序原理

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

就跟平时对扑克进行排序一样,就是插入排序。

1.2 直接插入排序

插入第i个元素时,那么前i-1个元素已经是有序的了 ,我们只需要将arri先记录下来,再将其与arri-1、arri-1...依次比较,(假设排升序)比它大的后移,直到找到比它小的位置,将其插入即可

cpp 复制代码
void InsertSort(int* arr, int n)
{
	int key, end;
	for (int i = 0; i < n - 1; i++)
	{
		end = i;	//已排序区间为[0,end]
		key = arr[end + 1];	//记录arr[end+1]
		while (arr[end] > key)
		{
			//比key大,移位
			arr[end + 1] = arr[end];
			end--;
		}
		arr[end + 1] = key;
	}
}

性能分析:

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

1.3 希尔排序(缩小增量排序)

希尔排序又称缩小增量排序。

核心思想:先选定一个整数gap ,将序列分组,每隔gap的元素为一组 ,再对每一组进行插入排序 ,之后缩小gap ,重复上述操作,再有序之前的每一次排序都可以认为是预排序 ,它可以认为是先粗调后细调

  • gap越大,大的越快跳到后面,小的越快跳到前面,越不接近有序
  • gap越小,跳的越慢,越接近有序,gap等于1时,就是普通的插入排序
  • 希尔排序的时间复杂度是难以计算的,因为gap的变化方式有很多,我们可以看看部分资料中是怎么记载的

《数据结构(C语言版)》--- 严蔚敏

《数据结构-用面相对象方法与C++描述》--- 殷人昆

因此我就按O(N^1.25)~O(1.6N^1.25)来计算

cpp 复制代码
void ShellSort(int* arr, int n)
{
	int gap = n, key, end;
	while (gap > 1)
	{
		gap = gap / 3 + 1;
		for (int j = 0; j < gap; j++)
		{
			for (int i = j; i < n - gap; i += gap)
			{
				end = i;
				key = arr[end + gap];
				while (end>=0&&arr[end] > key)
				{
					arr[end + gap] = arr[end];
					end-=gap;
				}
				arr[end + gap] = key;
			}
		}
	}
}

性能分析:

  • 时间复杂度:O(N^1.3)
  • 空间复杂度:O(1)
  • 稳定性:不稳定

2. 选择排序

2.1 排序原理

每次从待排序元素中找一个最小(最大)的元素,存放在待排序元素起始位置,直到排序完成

2.1 直接选择排序

直接选择排序的思想非常简单,假设数组arr大小为N,已排序元素为arr0-arri-1,未排序元素为arri-arrn-1,那么我们需要在arri-arrn-1中选择一个最小(最大)的元素与arri交换位置

cpp 复制代码
void SelectSort(int* arr, int n)
{
	int minj,mink=INT_MAX;
	for (int i = 0; i < n-2; i++)
	{
		minj = i;
		mink = arr[minj];
		for (int j = i; j < n; j++)
		{
			if (arr[j] < mink)
			{
				minj = j;
				mink = arr[j];
			}
		}
		swap(arr[i], arr[minj]);
	}
}

性能分析:

  • 选择排序虽然容易理解,但是它效率差、不稳定,非常拉胯,实践中极少使用
  • 时间复杂度:O(N^2)
  • 空间复杂度:O(1)
  • 稳定性:不稳定

2.2 堆排序

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

我们需要先建堆,然后逐步向下调整。

cpp 复制代码
//向下调整
void AdjustDown(int* arr,int parent, int n)
{
	int child = parent * 2 + 1;
	while (child < n)
	{
		if (child + 1 < n && arr[child + 1] > arr[child])
			++child;
		if (arr[parent] < arr[child])
			swap(arr[parent], arr[child]);
		else break;
		parent = child;
		child = parent * 2 + 1;
	}
}

void HeapSort(int* arr, int n)
{
	//建大堆,从第一个非叶子节点开始向下调整
	for (int k = (n - 1) / 2; k >= 0; k--)
	{
		AdjustDown(arr, k, n);
	}
	for (int i = n - 1; i >= 0; i--)
	{
		swap(arr[i], arr[0]);
		AdjustDown(arr, 0, i);
	}
}

性能分析:

  • 用堆来选数的堆排序的效率要远高于直接选择排序
  • 时间复杂度:O(NlogN)
  • 空间复杂度:O(1)
  • 稳定性:不稳定

我们也可以借助priority_queue进行排序

cpp 复制代码
void STLHeapSort(int* arr, int n)
{
	priority_queue<int,vector<int>,greater<int>> pq;
	for (int i = 0; i < n; i++)
		pq.push(arr[i]);
	for (int i = 0; i < n; i++)
	{
		arr[i] = pq.top();
		pq.pop();
	}
}

性能分析:

  • 时间复杂度:O(NlogN)
  • 空间复杂度:O(N)
  • 稳定性:不稳定

3. 交换排序

3.1 冒泡排序

复制代码
void BubbleSort(int* arr, int n)
{
	int flag = 0;
	for (int i = n - 1; i >= 0; i--)
	{
		for (int j = 0; j < i; j++)
		{
			if (arr[j] > arr[j + 1])
			{
				swap(arr[j], arr[j + 1]);
				flag = 1;
			}
		}
		if (flag == 0) break;
	}
}

性能分析:

  • 冒泡排序非常容易理解,初学者必备
  • 时间复杂度:O(N^2)
  • 空间复杂度:O(1)
  • 稳定性:稳定

3.2 快速排序

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

3.2.1 Hoare法快速排序
  1. 数组arr大小为N,最左元素下标为left,最右元素下标为right
  2. 先指定一个比较的标准值下标keyi保证keyi在最左边(便于比较)
  3. begin=keyi+1,end=right ,我们需要在**begin,end**进行操作
  4. begin向右走,确保遇到的每个元素都小于arrkeyi,遇到大于arrkeyi的元素则停下;end向左走,确保遇到的每个元素都大于arrkeyi,遇到小于arrkeyi的元素则停下
  5. 那么begin的元素是大于keyi的,end的元素是小于keyi的,接着交换begin和end的元素,继续4的操作,直到begin>=end,一趟快排就完成了
  6. 退出循环时,begin==end,将这个位置与keyi的元素交换,这个位置记录为keyi,接着递归排左边和右边即可
cpp 复制代码
void HoareQuickSort(int* arr, int left, int right)
{
	if (left >= right) return;
	int keyi = left;
	int begin = keyi, end = right;
	while (begin < end)
	{
		//end找小
		while (begin<end && arr[end]>=arr[keyi])
			end--;
		//begin找大
		while (begin<end && arr[begin]<=arr[keyi])
			begin++;
		swap(arr[begin], arr[end]);
	}
	swap(arr[keyi], arr[end]);
	keyi = end;
	HoareQuickSort(arr, left, keyi - 1);
	HoareQuickSort(arr, keyi + 1, right);
}

注意:如果比较基准数在最左边,一定要先走end后走begin;如果在最右边,一定要先走begin后走end

解释: 以基准值 key 在最左侧为例 ,最终 begin 和 end 相遇后,需要将相遇位置的元素与 key 交换,因此必须保证相遇位置上的元素 小于等于 key

为了做到这一点,需要让 end 先走因为 end 从右向左寻找的是第一个 小于 key 的元素,而 begin 从左向右寻找的是第一个 大于 key 的元素

当两者不断交换并继续移动时,若最终相遇,则最后一次移动导致相遇的一定是 begin。而 begin 能够继续向右移动的条件是:

cpp 复制代码
a[begin] <= key

因此相遇位置上的元素一定满足:

cpp 复制代码
a[meet] <= key

这样最后执行:

cpp 复制代码
swap(a[left], a[meet]);

才能保证:

cpp 复制代码
[ <= key ] key [ >= key ]

分区正确。

3.2.2 前后指针法快速排序

整体思想:利用两个指针将元素分成大于和小于指定值的两部分

  1. 数组与keyi的设置与Hoare法一致,即keyi==left
  2. prev=keyi,cur=prev+1 ,相当于我们要保证**left,prev小于等于arrkeyi** ,prev,cur大于arrkeyi
  3. cur找小于arrkeyi的元素,找到后prev++,如果prev!=cur,再与prev交换,直到cur大于right停止,完成单趟排序
  4. 接下来步骤与前面一样,递归完成剩余排序
cpp 复制代码
void TwoPointerQuickSort(int* arr, int left, int right)
{
	if (left > right) return;
	int keyi = left;
	int prev = keyi, cur = prev + 1;
	while (cur <= right)
	{
		//cur找小,找到后prev++,如果prev!=cur,再与prev交换
		if (arr[cur] < arr[keyi] && ++prev != cur)
			swap(arr[cur], arr[prev]);
		cur++;
	}
	swap(arr[keyi], arr[prev]);
	keyi = prev;
	TwoPointerQuickSort(arr, left, keyi - 1);
	TwoPointerQuickSort(arr, keyi + 1, right);
}
3.2.3 性能分析
  • 不难发现,快排框架与二叉树的前序遍历很相似,它的排序顺序是总区间-->左区间-->右区间
  • 快排的综合性能较为优秀,效率较高,所以叫快排
  • 时间复杂度:O(NlogN)
  • 空间复杂度:平均O(logN)最差:O(N) ,这取决于递归深度每次递归都要开一个函数栈帧的空间
3.2.4 缺陷及优化

1. 如果待排序序列已经接近有序,如果keyi都取最左边元素的话,那么keyi就容易多次取到最小或最大,这样会大大增加递归深度

对于这种情况,我们就采取三数取中 的思想,我们可以在最左边,中间和最右边三个数中取大小在中间的那个数,这样就确保了keyi不会取到最值

cpp 复制代码
//三数取中
int GetMidi(int* arr, int left, int right)
{
	int mid = (left + right) / 2;
	if (arr[left] < arr[right])
	{
		if (arr[right] < arr[mid])
			return right;
		else if (arr[left] < arr[mid])
			return mid;
		else return left;
	}
	else //arr[right]<arr[left]
	{
		if (arr[left] < arr[mid])
			return left;
		else if (arr[right] < arr[mid])
			return mid;
		else return right;
	}
}

void BetterHoareQuickSort(int* arr, int left, int right)
{
	if (left >= right) return;
	//三数取中
	int midi = GetMidi(arr,left,right);
	swap(arr[midi], arr[left]);
	int keyi = midi;
	int begin = keyi + 1, end = right;
	//...
}

2. 递归到小区间时,再用快排递归,会产生一些不必要的开销,因此,我们可以采用插入排序来完成剩余的排序

完整代码:

cpp 复制代码
void BetterHoareQuickSort(int* arr, int left, int right)
{
	if (left >= right) return;
	if (right - left + 1 <= 16)
	{
		InsertSort(arr, right - left + 1);
		return;
	}
	//三数取中
	int midi = GetMidi(arr,left,right);
	swap(arr[midi], arr[left]);
	int keyi = left;
	int begin = keyi + 1, end = right;
	while (begin < end)
	{
		//end找小
		while (begin<end && arr[end] >= arr[keyi])
			end--;
		//begin找大
		while (begin < end && arr[begin] <= arr[keyi])
			begin++;
		swap(arr[begin], arr[end]);
	}
	swap(arr[keyi], arr[end]);
	keyi = end;
	BetterHoareQuickSort(arr, left, keyi - 1);
	BetterHoareQuickSort(arr, keyi + 1, right);
}
3.2.5 非递归快排

实现非递归快排,主要是利用栈来存储排序区间,逐个进行排序,我们可以先定义一个单趟排序的函数,再循环调用它

cpp 复制代码
int PartQuickSort(int* arr, int left, int right)
{
	int midi = GetMidi(arr, left, right);
	swap(arr[midi], arr[left]);
	int keyi = left;
	int begin = keyi, end = right;
	while (begin < end)
	{
		//end找小
		while (begin<end && arr[end] >= arr[keyi])
			end--;
		//begin找大
		while (begin < end && arr[begin] <= arr[keyi])
			begin++;
		swap(arr[begin], arr[end]);
	}
	swap(arr[keyi], arr[end]);
	keyi = end;
	return keyi;
}

void QuickSortNonR(int* arr, int left, int right)
{
	stack<int> st;
	st.push(right);
	st.push(left);
	int begin, end, keyi;
	while (!st.empty())
	{
		begin = st.top();
		st.pop();
		end = st.top();
		st.pop();
		keyi = PartQuickSort(arr, begin, end);
		if (begin < keyi - 1)
		{
			st.push(keyi - 1);
			st.push(begin);
		}
		if (end > keyi + 1)
		{
			st.push(end);
			st.push(keyi + 1);
		}
	}
}

3.3 归并排序

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

以下是归并排序的核心步骤:

cpp 复制代码
void _MergeSort(int* arr, int* tmparr, int begin, int end)
{
	if (begin >= end) return;
	int midi = (begin + end) / 2;
	_MergeSort(arr, tmparr, begin, midi);
	_MergeSort(arr, tmparr, midi + 1, end);
	int begin1 = begin, end1 = midi, begin2 = midi + 1, end2 = end, cur = begin;
	while (begin1 <= end1 && begin2 <= end2)
	{
		if (arr[begin1] < arr[begin2])
			tmparr[cur++] = arr[begin1++];
		else
			tmparr[cur++] = arr[begin2++];
	}
	while (begin1 <= end1)
		tmparr[cur++] = arr[begin1++];
	while (begin2 <= end2)
		tmparr[cur++] = arr[begin2++];
	memcpy(arr + begin, tmparr + begin, (end - begin + 1)*sizeof(int));
}

void MergeSort(int* arr, int n)
{
	int* tmparr = new int[n];
	_MergeSort(arr, tmparr, 0, n - 1);
	delete[] tmparr;
}
非递归归并排序

非递归归并排序的思路就是手动控制要排序的子数组大小和排序起终点

cpp 复制代码
void MergeSortNonR(int* arr, int n)
{
	int* tmparr = new int[n];
	//gap表示每次的元素个数
	int gap = 1;
	while (gap < n)
	{
		//i表示每次的起始位置
		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;
			//第二组数据不存在,第一组已经有序
			if (end1 >= n)
				break;
			//第二组存在,需要修正end2
			if (end2 >= n)
				end2 = n - 1;
			int j = i;
			while (begin1 <= end1 && begin2 <= end2)
			{
				if (arr[begin1] < arr[begin2])
					tmparr[j++] = arr[begin1++];
				else
					tmparr[j++] = arr[begin2++];
			}
			while (begin1 <= end1)
			{
				tmparr[j++] = arr[begin1++];
			}
			while (begin2 <= end2)
			{
				tmparr[j++] = arr[begin2++];
			}
			memcpy(arr + i, tmparr + i, (end2 - i + 1) * sizeof(int));
		}
		gap *= 2;
	}
	delete[] tmparr;
}

性能分析:

  • 归并的缺点在于需要O(N)的空间复杂度 ,但它依然是一个优秀的排序思想,归并排序的思考更多的是解决在磁盘中的外排序问题
  • 时间复杂度:O(NlogN)
  • 空间复杂度:O(N)
  • 稳定性:稳定

非比较排序

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

  1. 统计相同元素出现次数
  2. 根据统计的结果将序列回收到原来的序列中
cpp 复制代码
void CountSort(int *arr, int n)
{
	if (n <= 0) return;
	int min = arr[0];
	int max = arr[0];
	for (int i = 1; i < n; i++)
	{
		if (arr[i] < min)
			min = arr[i];
		if (arr[i] > max)
			max = arr[i];
	}
	int range = max - min + 1;
	int *count = new int[range]();
	for (int i = 0; i < n; i++)
	{
		count[arr[i] - min]++;
	}
	int idx = 0;
	int j = 0;
	while (j < n && idx < range)
	{
		if (count[idx] == 0)
			idx++;
		else
		{
			arr[j] = idx + min;
			count[idx]--;
			j++;
		}
	}
	delete[] count;
}

性能分析:

  • 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
  • 设元素范围大小为M,时间复杂度:O(MAX(N,M))
  • 空间复杂度:O(M)
  • 稳定性:稳定

总结

以下是各种排序算法的时空复杂度,稳定性 ,排序方式的总结

排序算法 最好时间复杂度 平均时间复杂度 最坏时间复杂度 空间复杂度 稳定性 排序方式
直接插入排序 O(n) O(n²) O(n²) O(1) 稳定 内部排序
希尔排序 O(n log n) O(n1.3~n1.5) O(n²) O(1) 不稳定 内部排序
冒泡排序 O(n) O(n²) O(n²) O(1) 稳定 内部排序
选择排序 O(n²) O(n²) O(n²) O(1) 不稳定 内部排序
堆排序 O(n log n) O(n log n) O(n log n) O(1) 不稳定 内部排序
快速排序 O(n log n) O(n log n) O(n²) O(log n) 不稳定 内部排序
归并排序 O(n log n) O(n log n) O(n log n) O(n) 稳定 内部排序
计数排序 O(n+k) O(n+k) O(n+k) O(n+k) 稳定 非比较排序
桶排序 O(n+k) O(n+k) O(n²) O(n+k) 视实现而定 非比较排序
基数排序 O(d(n+r)) O(d(n+r)) O(d(n+r)) O(n+r) 稳定 非比较排序