第一届排序算法性能大赛(上万字激烈解说)

Posted 人间清醒杜师傅

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了第一届排序算法性能大赛(上万字激烈解说)相关的知识,希望对你有一定的参考价值。

写在前面
最近学到了一些重要的排序,并且取巧地测了一下各种排序算法在不同的算法实现、优化以及递归和非递归下的运行速度,想着写篇文章记录学习成果,同时分享给大家。
本文一共提及了以下几种常用到的排序,其他排序使用场景较少,便没有提及。

并且本文的全部代码实现均为通俗易懂的c语言,希望能够得到大家的认可和支持,如果觉得本文不错的话,欢迎三连哦。好了,接下来就是文章本章了。

必备排序常识

稳定性:在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
时间复杂度:一个排序算法在执行过程中所耗费的时间量级的度量。
空间复杂度:一个排序算法在运行过程中临时占用存储空间大小的度量。

注意:在使用的时候尽量按照排序方式的英文书写,便于阅读。

第一组参赛选手:插入排序

原理:把待排序的数据按照关键码的大小,按照安排徐规则,将当前数据插入到已经排序好的数据中,使其称为一个新的排序好的数据,直到所有数据插完为止。
实际中我们玩扑克牌时,就用了插入排序的思想。

1.直接插入排序

排序原理:
当插入第i个元素时,前面的i-1个元素已经时有序的,此时用第i个元素的值和前面i-1个元素进行比较,直到找到插入位置,插入即可,原来位置的元素顺序后移。
过程展示:

代码实现:

// 插入排序
void InsertSort(int* a, int n)
{
	for (int tail = 0;tail < n - 1 ;tail++)
	{
		int temp = tail;//记录有序序列的最后一个元素的下标
		int x = a[tail + 1];
		while (temp >= 0)
		{
			if (a[temp] > x)//说明还有数大于x,继续把前面数组的往后移
			{
				a[temp + 1] = a[temp];
				temp--;
			}
			else//如果前面的值小于x,说明不需要往前进行比较了
			{
				break;
			}
		}
		a[temp + 1] = x;//排到所有小于x的数的后面一位
	}
}

特性总结:

  1. 元素集合越接近有序,直接插入排序算法的时间效率越高,因为当有序的情况下会直接跳出该次循环
  2. 时间复杂度:O(N) ~ O(N^2)。最好的情况是刚好和算法的顺序一致的情况,只遍历一遍;最坏的情况为刚好有序的但是和算法的顺序刚好相反,此时时间复杂度为n * (n-1) * ··· *2 * 1为等差数列,按公式计算得为O(N^2)。所以在数据大部分有序的情况下使用插入排序还是不错的。
  3. 空间复杂度:O(1),它是一种稳定的排序算法
  4. 稳定性:稳定

2.希尔排序

排序原理:

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

过程展示:

代码实现:

// 希尔排序
void ShellSort(int* a, int n)
{
	int gap = n;
	while (gap > 1)//外层循环不断进行预排序
	{
		gap =gap / 3 + 1;//取排序的间隔,+1是为了确保一定有1,有一才能正确排出正确的顺序
		//gap /= 2;//取除2的商也可以,也满足最后的数有1
		for (int tail = 0;tail < n - gap ;tail ++)//整体思想和插入排序差不多,但是间隔却不一样
		{
			int temp = tail;
			int x = a[tail + gap];
			while (temp >= 0)
			{
				if (a[temp] > x)
				{
					a[temp + gap] = a[temp];
					temp -= gap;
				}
				else
				{
					break;
				}
					
			}
			a[temp + gap] = x;
		}
	}
}

特性总结:

  1. 希尔排序是对直接插入排序的优化。
  2. gap > 1时都是预排序,目的是让数组更接近于有序,不要小看这些预排序,可以让插入排序的性能有很大的提升。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。后面可以进行性能测试的对比。
  3. 希尔排序的时间复杂度不好计算,需要进行推导,推导出来平均时间复杂度:O(n*logn)~O(n^2),当间隔gap取得好的条件下可以达到最好情况,最不理想的情况是当gap == 1的时候,也就是直接插入排序。
  4. 稳定性:不稳定。当相同的值被分到不同的组中在进行排序,不同的组中数值大小不确定,此时就不能保证这些数还能保持先后顺序。

3.性能比拼时刻

测试代码:

利用时间生成的随机数进行的粗劣测试,看个热闹,哈哈。
如果大家使用这段代码一定要在release版本下进行测试哦,因为release版本进行了优化,能够更加客观地测试出速度。

void TestOP()
{
	srand(time(0));
	const int N = 100000;//取十万个随机值数据进行排序比较
	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* a7 = (int*)malloc(sizeof(int) * N);

	for (int i = 0; i < N; ++i)
	{
		a1[i] = rand();//生成随机数
		//a1[i] = i;//生成有序数
		a2[i] = a1[i];
		a3[i] = a1[i];
		a4[i] = a1[i];
		a5[i] = a1[i];
		a6[i] = a1[i];
		a7[i] = a1[i];
	}

	int begin1 = clock();
	InsertSort(a1, N);
	int end1 = clock();

	int begin2 = clock();
	ShellSort(a2, N);
	int end2 = clock();

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

	int begin4 = clock();
	HeapSort(a4, N);
	int end4 = clock();

	int begin5 = clock();
	QuickSortNonR(a5, 0, N - 1);
	QuickSort(a4, 0, N - 1);
	int end5 = clock();

	int begin6 = clock();
	MergeSort(a6, N);
	int end6 = clock();

	int begin7 = clock();
	BubbleSort(a7, N);
	//BubbleSort(a4, N);
	int end7 = clock();

	printf("InsertSort:%d ms\\n", end1 - begin1);
	printf("ShellSort:%d ms\\n", end2 - begin2);
	printf("SelectSort:%d ms\\n", end3 - begin3);
	printf("HeapSort:%d ms\\n", end4 - begin4);
	printf("BubbleSort:%d ms\\n", end7 - begin7);
	printf("QuickSort:%d ms\\n", end5 - begin5);
	printf("MergeSort:%d ms\\n", end6 - begin6);

	free(a1);
	free(a2);
	free(a3);
	free(a4);
	free(a5);
	free(a6);
	free(a7);
}

十万个随机数据的测试结果:

可见此时得益于希尔排序优秀的时间复杂度,快了插入排序将近200倍。希尔排序循环大概进行的次数为:O(n*logn)约等于170万。
而直接插入排序接近最坏的情况,O(n^2)后的计算结果为100亿次。由此可见两种排序在这种情况下根
本不是一个数量级的。

一百万个有序数据的测试结果:

但是在数据大量并且有序的情况下,直接插入排序的时间复杂度接近于O(n),在一百万个数据的条件下耗时大大减少,所以如果在数据大量且接近有序的条件下直接插入排序也是不错的。
虽然希尔排序的本质是插入排序,但是由于需要预排序,耗时肯定会比直接插入排序多。

所以最后胜出的选手是希尔排序

第二组参赛选手:选择排序

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

1.直接选择排序

排序原理:

第一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,然后再从剩余的未排序元素中寻找到最小(大)元素,继续排在已排序元素后,直到未排序元素个数为0。

过程展示:

代码实现:

void Swap(int* a, int* b)//交换函数
{
	int temp = *a;
	*a = *b;
	*b = temp;
}

//选择排序,一次只选择一个数
void SelectSort(int* a, int n)
{
	for (int i = 0;i < n;i++)
	{
		int temp = i;//保存未排序的元素下标
		for (int j = i; j < n;j++)
		{
			if (a[temp] > a[j])//有元素大于中间值
			{
				temp = j;//更新下标
			}
		}
		if (temp != i)//有元素小于小于未排序的头元素,交换两者位置
			Swap(a + i, a + temp);
	}
}

// 选择排序优化版本,一次循环可以选出最大和最小的值
void SelectSortOp(int* a, int n)
{
	int end = n - 1;//未排序元素尾位置
	for (int slow = 0;slow < end;slow++)
	{
		int min = slow;//保存最小值的下标
		int max = slow;//保存最大值的下标
		for (int fast = slow + 1;fast < end + 1 ;fast++)
		{
			if (a[fast] < a[min])
			{
				min = fast;
			}
			if (a[fast] > a[max])
			{
				max = fast;
			}
		}
		if(min != slow)//有小于的元素,交换两者的位置
		Swap(&a[min], &a[slow]);
		if (max == slow)//最大值就是未排序元素首元素,前面发生了值交换,更新下标
			max = min;
		Swap(&a[max], &a[end]);
		end--;
	}
}

特性总结:

  1. 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
  2. 时间复杂度:O(n^2)
  3. 空间复杂度:O(1)
  4. 稳定性:不稳定。交换值的同时可能将其他相同值的位置改变了。
    例: 4 9 5 5 8 5 6 9 -> 4 9 5 9 8 5 6 5
    此时就有相同值的位置顺序改变了

2.堆排序

排序原理:

堆排序(Heap Sort)是利用堆进行排序的方法。其基本思想为:将待排序列构造成一个大堆(或小堆),整个序列的最大值(或最小值)就是堆顶的根结点,将根节点的值和堆数组的末尾元素交换,此时末尾元素就是最大值(或最小值),然后将剩余的n-1个序列重新构造成一个堆,这样就会得到n个元素中的次大值(或次小值),如此反复执行,最终得到一个有序序列。

堆是用数组表示的完全二叉树。要学习堆排序,首先要学习堆的向下调整算法,因为要用堆排序先得建堆,而建堆需要执行多次堆的向下调整算法。

堆的向下调整算法(使用前提)
 若想将其调整为小堆,那么根结点的左右子树必须都为小堆。
 若想将其调整为大堆,那么根结点的左右子树必须都为大堆。

向下调整算法的基本思想(以建大堆为例)
 1.从根结点处开始,选出左右孩子中值较大的孩子。
 2.让大的孩子与其父亲进行比较。
若大的孩子比父亲还大,则该孩子与其父亲的位置进行交换。并将原来大的孩子的位置当成父亲继续向下进行调整,直到调整到叶子结点为止。
若大的孩子比父亲小,则不需处理了,调整完成,整个树已经是大堆了。

使用堆的向下调整算法,最坏的情况下(即一直需要交换结点),需要循环的次数为:h - 1次(h为树的高度)。而h = log2(n+1)(n为树的总结点数)。所以堆的向下调整算法的时间复杂度为:O(logn)
此时需要找出最后一个有叶子结点的父结点,n为结点的总个数,(n - 2) / 2就是满足条件的下标,并以该结点从下往上依次向下调整。最后就可以建成一个大堆。

代码实现:

void Swap(int* a, int* b)//交换函数
{
	int temp = *a;
	*a = *b;
	*b = temp;
}

//向下调整函数,保证满足条件左右子树是小堆或者是大堆
void AdjustDwon(int* a, int n, int root)
{
	int parent = root;
	int child = root * 2 + 1;
	while (child < n)
	{
		if (child + 1<n && a[child + 1] < a[child])//默认是左孩子
		{
			child += 1;//让下标为数值较大的孩子
		}
		//改变上下两个if条件控制为大堆或小堆,同时控制升序和降序
		if (a[child] < a[parent])
		{
			Swap(a + parent, a + child);//交换父结点和较大的孩子
			parent = child;
			child = parent * 2 + 1;//更新父子结点,继续向下调整
		}
		else
		{
			break;
		}
	}
}
// 堆排序
void HeapSort(int* a, int n)
{
	//找到最后一个有叶子结点的父结点,并以该结点从下往上依次向下调整
	for (int i = (n - 2) / 2; i >= 0;--i)//建堆
	{
		AdjustDwon(a, n, i);
	}
	int end = n - 1;
	for (;end > 0;end--)//逐个取出根结点,再次建堆
	{
		Swap(&a[0], &a[end]);
		AdjustDwon(a, end, 0);
	}
}

整个排序过程:

特性总结:

  1. 堆排序使用堆来选数,效率就高了很多。
  2. 时间复杂度:O(n*logn)

前面提到向下建堆的时间复杂度为O(logn),相乘后的时间度就为O(n*logn)

  1. 空间复杂度:O(1)
  2. 稳定性:不稳定。因为向下调整的时候可能将相同的值位置顺序改变。
    例:

3.性能比拼时刻

十万个随机数据的测试结果:

因为堆排序的时间复杂度为固定的O(n*logn),而选择排序的时间复杂度为固定的O(n^2),所以在算法上得到大大提升,看图快了将近1000倍。

十万个有序数据的测试结果:

因为两个排序算法的时间复杂度是固定的,就算是有序的数据也吴差别。

所以最后胜出的选手是堆排序

第三组参赛选手:交换排序

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

1.冒泡排序

排序原理:

比较相邻的元素。如果第一个比第二个大,就交换他们两个。
每趟从第一对相邻元素开始,对每一对相邻元素作同样的工作,直到最后一对。
针对所有的元素重复以上的步骤,除了已排序过的元素(每趟排序后的最后一个元素),直到没有任何一对数字需要比较。

过程展示:

代码实现:

//冒泡排序
void BubbleSort(int a[], int n)
{
	for (int i = n;i >= 0;i--)//循环层数
	{
		int flag = 1;//判断是否有序的标志位
		for (int j = 0 ;j < i - 1;j++)//比较次数
		{
			if (a[j] > a[j + 1])
			{
				Swap(&a[j], &a[j + 1]);
				flag = 0;
			}
		}
		if (flag)//没发生交换,说明数组有序,终止循环
			break;
	}
}

特性总结:

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

2.快速排序(重量级选手)

排序原理:

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

对于如何按照基准值将待排序列分为两子序列,常见的方式有:
 1. 挖坑法
 2. 前后指针法(Hoare版本)
 3. 快慢指针法

递归实现:

挖坑法

排序原理:

挖坑法的单趟排序的基本步骤如下:
 1、选出一个数据(一般是最左边或是最右边的)存放在key变量中,在该数据位置形成一个坑。
 2、还是定义一个L和一个R,L从左向右走,R从右向左走。(若在最左边挖坑,则需要R先走;若在最右边挖坑,则需要L先走,否则会无法正确排出顺序)。
 3、在走的过程中,若R遇到小于key的数,则将该数抛入坑位,并在此处形成一个坑位,这时L再向后走,若遇到大于key的数,则将其抛入坑位,又形成一个坑位,如此循环下去,直到最终L和R相遇,这时将key抛入坑位即可。(选取最左边的作为坑位)

以上就是挖坑法的单趟排序,经过一次单趟排序,最终也使得key左边的数据全部都小于key,key右边的数据全部都大于key,但是此时key的左边和右边的数据还是乱序的。
然后将key的左序列和右序列再次进行这种单趟排序,如此反复操作下去,直到左右序列只有一个数据,或是左右序列不存在时,便停止操作,最终数据就是有序的。

过程展示:

代码实现:

//快速排序挖坑法
int PartSort1(int* a, int left, int right)
{
	int start = left;
	int end = right;
	//取一个数值,将比他小的数放左边,比他大的放右边
	int key = a[start];
	while (start < end)//前后下标还没相遇时一直循环
	{
		int pivot = start;//坑位下标
		while (start < end && a[end] >= key)
		{
			end--;
		}
		a[pivot] = a[end];//将右边比key小的值放在坑位中
		pivot = endMATLAB | 一起来感受数学之美,第一届迷你黑客大赛回顾

MATLAB | 一起来感受数学之美,第一届迷你黑客大赛回顾

首届!全国博士后创新创业大赛获奖名单公布!

首届!全国博士后创新创业大赛获奖名单公布!

万字长文浅析Java集合中的排序算法

详细解说 STL 排序(Sort)(转)