【数据结构与算法】常见排序算法(Sorting Algorithm)

在这里插入图片描述

相关概念

  1. 排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
  2. 稳定性:说简单点就是有相同值时,排序后这些相同值互相顺序没发生变化则称为稳定的排序算法。假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
  3. 内部排序:数据元素全部放在内存中的排序(重点)。
  4. 外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序(了解)。

常见排序算法时间、空间、稳定性:

  1. 直接插入排序:O(n2),正常情况下最快的O(n2)排序算法,稳定。
  2. 希尔排序:O(n1.3),比O(n*log2n)慢一点点,不稳定。
  3. 直接选择排序:O(n2),比冒泡快,比插入慢,不稳定。
  4. 堆排序:O(n*log2n),不稳定。
  5. 冒泡排序:O(n2),稳定。
  6. 快速排序: O(n*log2n),不稳定,空间O(log2n)。
  7. 归并排序 O(n*log2n),稳定,空间O(n)。

排序不特别说明,则排序以升序为例。
时间复杂度不特别说明,则默认最坏时间。
空间复杂度不特别说明,则默认O(1)。

1. 冒泡排序(Bubble Sort)

思想:两两比较,再交换。前一个值比后一个值大,交换两个值。
在这里插入图片描述
在这里插入图片描述
优化冒泡排序,冒泡排序优化版:
在这里插入图片描述

void BubbleSort(int* a, int n) 
{
	int sortBorder = n - 1;
	int lastExchange = 0; 
	for (int i = 0; i < n - 1; ++i) 
	{
		bool isSorted = true; 
		for (int j = 0; j < sortBorder; ++j) 
		{
			if (a[j] > a[j + 1]) 
			{
				Swap(&a[j], &a[j + 1]);
				isSorted = false;
				lastExchange = j;
			}
		}
		if (isSorted) {
			break;
		}
		sortBorder = lastExchange;
	}
}
void Swap(int* px, int* py) 
{
	int tmp = *px;
	*px = *py;
	*py = tmp;
}

2. 直接插入排序(Insertion Sort)

思想:类似将扑克牌排序的过程,数据越有序,排序越快。
在这里插入图片描述
在这里插入图片描述

void InsertionSort(int* a, int n)
{
	for (int i = 0; i < n - 1; ++i)
	{
		int end = i;
		int insertVal = a[end + 1];
		while (end >= 0 && insertVal < a[end])
		{
			a[end + 1] = a[end];
			--end;
		}
		a[end + 1] = insertVal;
	}
}

直接插入排序O(n*n),n方的排序中,直接插入排序是最有价值的。其它的如冒泡,直接选择排序等与直接插入排序一样N方的排序都是五十步和百步的区别,总体来看没啥区别,都不如直接插入排序,看以下几点分析以及排序时间比较,再就是大家自己编一串数据走查一下排序过程即可发现。

1.排升序而数据大致是降序,或排降序而数据大致是升序情况下,直接插入排序的时间复杂度是O(n*n),因为比较挪数据次数是等差数列之和。

2.数据大致有序,且排序顺序与数据顺序一致情况下,直接插入排序的时间复杂度是O(n),因为比较挪数据次数较少(不进入while循环)。比如排升序,而数据也大致也是升序状态(较为有序 或 直接就是有序的)。

3.虽然直接插入排序与冒泡排序的时间复杂度是同一个量级,但不谈上面第一种情况,
正常大多都是数据随机排列情况下前者比后者快很多,这时比较挪数据次数不会是等差数列之和,中间一般多少会有一部分是有序的,有那么几趟是不进入while循环的,比较挪数据次数当然是比等差数列之和要少的。虽然还是O(n*n)的量级,但明显是比冒泡快,至于快多少则是看有序的数据多不多(极限就是第二种情况)。

10w个数据 排序速度对比:
在这里插入图片描述

release环境是发布版本环境,对代码是有很大优化的,优化点大致是:

  1. 相比于debug环境,release环境生成的目标文件包含很少调试信息甚至没有调试信息。
  2. 减少了很多消耗性能或不必要的操作,不对代码进行边界检查,空指针检查、assert断言检查等。
  3. 特别是对递归优化巨大,也就是对函数栈帧的创建/栈的消耗优化很大,比如对于debug环境下栈溢出的程序,切换成release则不会造成栈溢出。

博主水平有限,不知道更多相关细节或是底层原理,如有错误恳请指正。

3. 希尔排序(Shell Sort)

希尔排序是直接插入排序的优化版,对于直接插入排序而言,数据越有序,排序越快,希尔排序正是借助直接插入排序的特点进行了优化。

思想:先对数据分组进行几次预排序(对数据分组进行直接插入排序),使数据形成较为有序的情况,最后整体进行一趟直接插入排序即可完成排序。

在这里插入图片描述

void ShellSort(int* a, int n) 
{
	int gap = n; 
	while (gap > 1) {
		gap = gap / 3 + 1; // gap / 2也可
		for (int j = 0; j < n - gap; ++j) 
		{
			int end = j;
			int insertVal = a[end + gap];
			while (end >= 0 && insertVal < a[end]) 
			{
				a[end + gap] = a[end];
				end -= gap;
			}
			a[end + gap] = insertVal;
		}
	}
}
  1. 希尔排序不好计算确切的时间复杂度,有牛人通过大量实验证明平均时间复杂度大致为O(n^1.3),比O(n*logn)要慢一点点,但两者差不多是同一量级。

  2. gap>1时是预排序,gap=1时等于直接插入排序。

  3. gap的取值,gap/2或gap/3+1是当前主流,也被认为是gap最好的取值。gap相当于划分多少组进行预排序,如果定死gap=1则与直接插入排序无异。gap/2或gap/3+1则是划分每组多少个数进行预排序,gap/3+1中的+1是因为要保证最后一组排序时gap=1进行直接插入排序操作。严格来说只要能保证最后一趟gap=1,无论gap除以几加几,都算是希尔排序。

  4. 每一组预排序后,都会逐渐加大数据的有序情况。后面几组预排序虽然每组划分的数据多了(gap逐渐减小间隔变小了),也就是比较次数变多了,但经过前面的预排序后数据渐渐有序,实际不会进行过多的比较挪数据操作,每前一次预排序都为后一次预排序减轻压力。

速度对比(毫秒):
在这里插入图片描述

4. 直接选择排序(Selection Sort)

每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,逐步向后存放。
在这里插入图片描述
在这里插入图片描述

数据较为有序的情况下,直接选择排序选要比冒泡、直接插入排序慢。

void SelectionSort(int* a, int n)
{
	int begin = 0, end = n - 1;
	while (begin < end)
	{
		int min = begin, max = end;
		for (int i = begin; i <= end; ++i)
		{
			if (a[i] < a[min]) min = i;
			if (a[i] > a[max]) max = i;
		}
		Swap(&a[begin], &a[min]);
		if (max == begin)
		{
			max = min;
		}
		Swap(&a[end], &a[max]);
		++begin; --end;
	}
}

在优化版中,必须有这样一个判断max==begin,并更新max的下标值!最小的数a[min]换到了左边begin位置,如果最大的数的下标max正好等于begin,那就出现这种问题:最大的数a[max]已经被换到min下标位置了,即a[min]才是最大数;而本来a[max]是最大的数,由于max==begin,而经过前面a[begin]与a[min]交换的影响,导致a[begin]/a[max]变成了最小的数,不加判断并更新max的后果是把最小的数放在右边end位置了。

5. 堆排序(Heap Sort)

了解堆请看:文章 堆 / 堆排序 / TopK问题(Heap)

时间复杂度O(nlog2n),排序速度与希尔差不多。也可以向上调整建堆,但比向下调整建堆要慢一些。

void HeapSort(int* a, int n)
{
	for (int parent = (n - 1 - 1) / 2; parent >= 0; --parent) {
		AdjustDown(a, n, parent);
	}
	for (int end = n - 1; end > 0; --end)
	{
		Swap(&a[0], &a[end]);
		AdjustDown(a, end, 0);
	}
}
/* 将堆向下调整为大堆 */
void AdjustDown(int* a, int size, int parent)
{
	int child = parent * 2 + 1; // 选出较大子节点
	child = child + 1 < size && a[child + 1] > a[child]
		? child + 1 : child;
	while (child < size && a[child] > a[parent])
	{
		Swap(&a[child], &a[parent]);
		parent = child; // 重复往下
		child = parent * 2 + 1;
		child = child + 1 < size && a[child + 1] > a[child]
			? child + 1 : child;
	}
}

parent初始为最后一个非叶子节点(多一个 -1 的原因),
向下调整(建大堆),往堆顶方向走把所有非叶子结点调整一遍。

堆顶最大值与堆底较小值交换,然后排除这个堆底的最大值(a[end]),
剩下的作为堆,从堆顶较小值开始向下调整为大堆(–end一步步排除新的最大值a[end])。

10w个数据,排序速度对比:
在这里插入图片描述
堆排序时间复杂度严格来算:

  1. 向上调整建堆O(nlogn) + 排序O(nlong):O(2n*2logn)。
  2. 向下调整调整建堆O(n) + 排序O(nlogn):O(2n*logn)。

所以说希尔排序O(n1.3)比O(n*log2n)要慢些,但却是同一量级。不过堆排序的时间复杂度严格来说比真正的O(nlog2n)要慢一点点,所以希尔排序与堆排序的速度相同。

6. 快速排序(Quick Sort)

快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法。

6.1 hoare快排(最早的快排方法)

基本思想:取待排序数据中的某个元素作为基准值,将数据分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后左右子序列重复该过程进行分割,直到所有元素都排列在相应位置上为止。
在这里插入图片描述

// 1.hoare递归(最早的快排方法)
void QuickSort1(int* a, int begin, int end)
{
	if (begin < end) 
	{
		int left = begin;
		int right = end;
		int keyi = begin; // 基准值(下标)
		while (left < right) 
		{	/* 必须加上left<right防止内循环越界;
			   >=而不是>,<=而不是<,防止重复值死循环。*/
			while (left < right && a[right] >= a[keyi]) {
				--right; // 找小的
			}
			while (left < right && a[left] <= a[keyi]) {
				++left; // 找大的
			}
			Swap(&a[left], &a[right]);
		}
		Swap(&a[left], &a[keyi]);
		QuickSort0(a, begin, left - 1); // 左区间序列
		QuickSort0(a, left + 1, end); // 右区间序列
	}
}

基准值的取法:

  1. 取序列第一个数据,需要右指针right先走(学习时往往采用的方式,上面动图演示也是基于这个方式);或取序列最后一个数据,需要左指针left先走(本质与前者没区别)。
  2. 三位取中法:key、left和right中取第二大的值作为基准值。(这是优化版,推荐)

优化快排(重要)

1. 减少函数递归的栈帧开销(虽然不用,但必须了解)

优化hoare快排的递归开销:递归树最后两三层(小区间)改用插入排序,减少大量函数栈帧内存消耗。该优化在debug环境下确实能优化,在逻辑上也确实能优化,但release环境同样也对递归进行了优化,而且优化力度只会更大,所以小区间使用插入排序减少递归栈帧的优化方案或许起不到效果。

例如一颗满二叉树,可以看到最后两三层的数量是最多的:
在这里插入图片描述
对于hoare快排划分左右区间同理:
在这里插入图片描述

#define RECUR_MAX 10
void QuickSortX(int* a, int begin, int end)
{
	if (begin < end)
	{
		if (end - begin + 1 <= RECUR_MAX)
		{
			InsertionSort(a, end - begin + 1);
		}
		else
		{
			int left = begin;
			int right = end;
			int keyi = begin; // 基准值(下标)
			while (left < right)
			{	/* 必须加上left<right防止内循环越界;
				   >=而不是>,<=而不是<,防止重复值死循环。*/
				while (left < right && a[right] >= a[keyi]) {
					--right; // 找小的
				}
				while (left < right && a[left] <= a[keyi]) {
					++left; // 找大的
				}
				Swap(&a[left], &a[right]);
			}
			Swap(&a[left], &a[keyi]);
			QuickSort0(a, begin, left - 1); // 左区间序列
			QuickSort0(a, left + 1, end); // 右区间序列
		}
	}
}

2.三位取中法取基准值(重点)

该优化提升非常大,主要是优化对较为有序的数据进行排序的情况。先看例子:一个较为有序的序列 1 2 3 4 7 6 8 10 9 对于这组数据,对于现在没有使用三位取中的快排而言,前面几趟排序是比较难受的。

比如第一趟,right一直不到比key要大的值,找最后搞得–right来到了key的位置,这就导致没有左区间,右区间从2开始,数据越是有序,快排速度越慢,最慢时退化到O(n2)。
在这里插入图片描述
解决办法就是不要直接取第一位作为基准值,从begin、mid和end之间选出第二大的值作为基准值。
在这里插入图片描述
每趟排序前先三位取中做交换,这样就不至于面对这种情况,每趟排序right都走到最右边。

6.2 挖坑法快排

该方法思想与hoare版差不多,算是hoare版的改进,可能更好理解一些,但排序速度比起hoare版没啥大变化,差不多。
在这里插入图片描述

void QuickSort2(int* a, int begin, int end)
{
	if (begin < end)
	{
		if ((end - begin) + 1 <= RECUR_MAX) {
			InsertionSort(a + begin, (end - begin) + 1);
		}
		else
		{
			int midi = MidIndex(a, begin, end);
			Swap(&a[begin], &a[midi]);
			int left = begin;
			int right = end;
			int key = a[begin];
			int pos = begin;
			while (left < right)
			{
				while (left < right && a[right] >= key) {
					--right;
				}
				a[pos] = a[right];
				pos = right;
				while (left < right && a[left] <= key) {
					++left;
				}
				a[pos] = a[left];
				pos = left;
			}
			a[pos] = key;
			QuickSort2(a, begin, pos - 1);
			QuickSort2(a, pos + 1, end);
		}
	}
}

6.3 双指针法快排

在这里插入图片描述

void QuickSort3(int* a, int begin, int end)
{
	if (begin < end)
	{
		int midi = MidIndex(a, begin, end);
		Swap(&a[begin], &a[midi]);
		int keyi = begin;
		int pre = begin;
		int cur = begin + 1;
		while (cur <= end)
		{
			if (a[cur] <= a[keyi]) 
			{
				++pre;
				Swap(&a[pre], &a[cur]);
			}
			++cur;
		}
		Swap(&a[keyi], &a[pre]);
		keyi = pre;
		QuickSort3(a, begin, keyi - 1);
		QuickSort3(a, keyi + 1, end);
	}
}

6.4 非递归快排

需要借助栈(Stack),本质与递归一样,递归也是栈帧的开辟与销毁。

void QuickSortNonRecur(int* a, int begin, int end)
{
	assert(begin < end);
	Stack stack;
	Init(&stack);
	Push(&stack, begin); 
	Push(&stack, end);

	// 类似递归
	while (!Empty(&stack))
	{
		// 出栈
		int right = Top(&stack); 
		Pop(&stack);
		int left = Top(&stack); 
		Pop(&stack);

		if (left < right)
		{
			// 一趟快排
			int keyi = left;
			int previ = left;
			int curi = left + 1;
			while (curi <= right)
			{
				if (a[curi] <= a[keyi])
				{
					++previ;
					Swap(&a[previ], &a[curi]);
				}
				++curi;
			}
			Swap(&a[keyi], &a[previ]);
			keyi = previ;

			// 入栈
			if (left < keyi - 1)
			{
				Push(&stack, left);
				Push(&stack, keyi - 1);
			}
			if (keyi + 1 < right)
			{
				Push(&stack, keyi + 1);
				Push(&stack, right);
			}
		}
	}
	Destroy(&stack);
}

快速排序的排序速度比较(包含测试代码)

单位为毫秒。

500w个数据:
在这里插入图片描述
1000w个数据:
在这里插入图片描述

#include "Sort.h"

void TestPerformance();

int main() {
	TestPerformance();
}

void TestPerformance() {
	const int N = 10000000;
	//int* a1 = (int*)malloc(sizeof(int) * N);
	//int* a2 = (int*)malloc(sizeof(int) * N);
	int* a3 = (int*)malloc(sizeof(int) * N);
	//int* a4 = (int*)malloc(sizeof(int) * N);
	int* a5 = (int*)malloc(sizeof(int) * N);
	int* a6 = (int*)malloc(sizeof(int) * N);
	int* a10 = (int*)malloc(sizeof(int) * N);
	int* a11 = (int*)malloc(sizeof(int) * N);
	int* a12 = (int*)malloc(sizeof(int) * N);

	srand((unsigned int)time(0));
	for (int i = 0; i < N; i++) {
		//a1[i] = rand();
		//a2[i] = a1[i];
		a3[i] = rand();
		//a4[i] = a1[i];
		a5[i] = a3[i];
		a6[i] = a3[i];
		a10[i] = a3[i];
		a11[i] = a3[i];
		a12[i] = a3[i];
	}

	//int begin1 = clock();
	//BubbleSort(a1, N);
	//int end1 = clock();

	//int begin2 = clock();
	//InsertionSort(a2, N);
	//int end2 = clock();

	int begin3 = clock();
	ShellSort(a3, N);
	int end3 = clock();

	//int begin4 = clock();
	//SelectionSort(a4, N);
	//int end4 = clock();

	int begin5 = clock();
	HeapSort(a5, N);
	int end5 = clock();

	int begin6 = clock();
	QuickSort1(a6, 0, N - 1);
	int end6 = clock();

	int begin10 = clock();
	QuickSort2(a10, 0, N - 1);
	int end10 = clock();

	int begin11 = clock();
	QuickSort3(a11, 0, N - 1);
	int end11 = clock();

	int begin12 = clock();
	QuickSort3(a12, 0, N - 1);
	int end12 = clock();

	//printf("BubbleSort: %d\n", end1 - begin1);
	//printf("InsertionSort: %d\n", end2 - begin2);
	printf("ShellSort: %d\n", end3 - begin3);
	//printf("SelectionSort: %d\n", end4 - begin4);
	printf("HeapSort: %d\n", end5 - begin5);
	printf("QuickSort1: %d\n", end6 - begin6);
	printf("QuickSort2: %d\n", end10 - begin10);
	printf("QuickSort3: %d\n", end11 - begin11);
	printf("QuickSortNonRecur: %d\n", end12 - begin12);
}

7. 归并排序(Merge Sort)

7.1 递归版

思想:分治法(Divide and Conquer),递归分治后小规模两两排序,逐渐合并大规模两两排序,最后到两个子序列合并成一个有序列表,该方法也称“二路归并”,时间复杂度为O(nlogn)。
在这里插入图片描述
归并排序需要借助一个额外的数组,因此空间复杂度为O(n),在这个临时数组中排好序后,将排好序的数据复制回原序列。

在这里插入图片描述

// 二路归并排序
void Merge(int* a, int begin, int end, int* tmpArr);
void MergeSort(int* a, int begin, int end)
{
	if (begin < end)
	{
		int* tmpArr = (int*)malloc(sizeof(int) * (end + 1));
		if (tmpArr == NULL)
		{
			perror("MergeSort malloc failed.");
			return;
		}
		Merge(a, begin, end, tmpArr);
		free(tmpArr);
		tmpArr = NULL;
	}
}
void Merge(int* a, int begin, int end, int* tmpArr)
{
	// 分解
	int mid = (begin + end) / 2;
	if (begin < end)
	{
		Merge(a, begin, mid, tmpArr);
		Merge(a, mid + 1, end, tmpArr);
	}

	// 排序,合并存入临时数组
	int begin1 = begin;
	int begin2 = mid + 1;
	int k = begin;
	while (begin1 <= mid && begin2 <= end)
	{
		if (a[begin1] < a[begin2]) 
			tmpArr[k++] = a[begin1++];
		else
			tmpArr[k++] = a[begin2++];
	}
	// 两个序列中某一个可能有剩余
	while (begin1 <= mid) {
		tmpArr[k++] = a[begin1++];
	}
	while (begin2 <= end) {
		tmpArr[k++] = a[begin2++];
	}
	// 临时数组中排好序的数组,拷贝回原数组
	for (int i = begin; i <= end; i++) {
		a[i] = tmpArr[i];
	}
}

归并排与快排的排序速度比较:
在这里插入图片描述

7.2 非递归版

void MergeSortNonRecur(int* a, int n)
{
	int* tmpArr = (int*)malloc(sizeof(int) * n);
	if (tmpArr == NULL)
	{
		perror("MergeSortNonRecur malloc failed.");
		return;
	}
	// gap:区间间隔
	for (int gap = 1; gap < n; gap *= 2)
	{
		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 (end2 >= n) {
				end2 = n - 1;
			}
			if (end1 >= n || begin2 >= n) {
				break;
			}

			// 排序
			int k = begin1;
			while (begin1 <= end1 && begin2 <= end2)
			{
				if (a[begin1] < a[begin2])
					tmpArr[k++] = a[begin1++];
				else
					tmpArr[k++] = a[begin2++];
			}

			// 两个序列中某一个可能有剩余
			while (begin1 <= end1) {
				tmpArr[k++] = a[begin1++];
			}
			while (begin2 <= end2) {
				tmpArr[k++] = a[begin2++];
			}
			// 临时数组中排好序的数组,拷贝回原数组
			for (int j = i; j <= end2; ++j) {
				a[j] = tmpArr[j];
			}
		}
	}
}

8. 计数排序

特点:效率极高。
缺点:适用范围有限,小数、结构体数据无法排序。
时间复杂度:O(MAX(n, range)),range = max - min(最大值-最小值)。
空间复杂度:O(range)。
计数排序是稳定排序算法。

排序思想:需要借助一个临时数组,在临时数组中统计原数组中每个值出现的次数。

void CountSort(int* a, int n)
{
	int min = a[0];
	int max = a[0];
	for (int i = 0; i < n; ++i)
	{
		if (a[i] < min) {
			min = a[i];
		}
		if (a[i] > max) {
			max = a[i];
		}
	}

	int range = max - min + 1;
	int* count = (int*)calloc(range, sizeof(int));
	if (count == NULL)
	{
		perror("CountSort malloc failed.");
		return;
	}

	// 计数
	for (int i = 0; i < n; ++i)
	{
		count[a[i] - min]++;
	}
	// 排序
	int k = 0;
	for (int i = 0; i < range; ++i)
	{
		for (int j = 0; j < count[i]; ++j)
		{
			a[k++] = i + min;
		}
	}
}

  • 28
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

念来过倒字名qwq

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值