详解数据结构八大排序(源码实现)(动图分析)

Posted 周周汪

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了详解数据结构八大排序(源码实现)(动图分析)相关的知识,希望对你有一定的参考价值。


秃头侠们好呀,今天咱们来聊聊 排序

本章重点

1.排序的概念及其运用
2.常见排序算法的实现
3.排序算法复杂度及稳定性分析

排序的概念及其运用

排序的概念

排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。

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

内部排序:数据元素全部放在内存中的排序。

外部排序:外部排序指的是大文件的排序,即待排序的记录存储在外存储器上,待排序的文件无法一次装入内存,需要在内存和外部存储器之间进行多次数据交换,以达到排序整个文件的目的。

排序运用


还有的等等太多地方都要使用到排序了

常见的排序算法

常见排序算法的实现

插入排序

基本思想:

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

实际中我们玩扑克牌时,就用了插入排序的思想

直接插入排序


代码实现:

void InsertSort(int* a, int n)

	assert(a);
	for (int i = 0; i < n - 1; i++)
	
		int end = i;
		int x = a[end + 1];
		while (end>=0)
		
			if (a[end] > x)
			
				a[end+1] = a[end];
			
			else
			
				break;
			
			end--;
		
		a[end + 1] = x;
	

直接插入排序的特性

1、时间复杂度:O(N^2)
最坏O(N^2)— 逆序
最好O(N)—有序或接近有序

2、空间复杂度O(1)

3、稳定性:稳定

4、元素集合越接近有序,直接插入排序算法的时间效率越高

希尔排序

希尔排序法又称缩小增量法,是对直接插入排序思想上的优化
基本思想:

将n个待排序的元素序列,取一个小于n的整数m作为间隔,将全部的n个元素分为m个子序列,所有距离为m的元素放在同一个子序列中,在每个子序列中分别进行直接插入排序;然后,缩小间隔m,比如取m=m/2,重复划分子序列和排序;直到m==1为止(即将所有元素放在同一个序列中排序)。

希尔的想法其实就是:
1、分组预排序,想让数组接近有序,让小数(大数)快速到前面,大数(小数)快速到后面。
2、最后因为数组已经接近有序,再进行一次直接插入排序

代码实现:

void ShellSort(int* a, int n)

	int gap = n;
	while (gap>1)
	
		gap /= 2;
		for (int i = 0; i < n - gap; i++)
		
			int end = i;
			int x = a[end + gap];
			while (end>=0)
			
				if (a[end] > a[end + gap])
				
					a[end + gap] = a[end];
					end -= gap;
				
				else
				
					break;
				
			
			a[end + gap] = x;
		
	
	

希尔排序的特性

  1. 希尔排序是对直接插入排序的优化。
  2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。
  3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好多书中给出的希尔排序的时间复杂度都不固定。
  4. 时间复杂度(总结下来一般有这几种,NlogN以2为底,NlogN以3为底,约等于N^1.3)
  5. 稳定性:不稳

选择排序

基本思想:

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

直接选择排序

在元素集合选出最小的数(或最大的数),放开头(或者末尾),然后,再找次小的(或次大的)。。。

我们这里为了更高效一点,我们直接一次选两个数,最大和最小,把最小的放开头,最大的放最后。

代码实现:

void SelectSort(int* a, int n)

	assert(a);
	int begin = 0;
	int end = n - 1;
	
	while (begin<end)
	
		int min = begin;
		int max = begin;
		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 (begin == max)    //begin==max时,最大值被换走了,修正一下max的位置
			max = min;
		Swap(&a[end], &a[max]);
		begin++;
		end--;
	

直接选择排序的特性

  1. 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
  2. 时间复杂度:O(N^2)
    最好最坏都是O(N^2)
  3. 整体而言最差的排序,因为无论什么情况下都是O(N^2)
  4. 稳定性:不稳定

堆排序

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

代码实现:

void AdjustDown(int* a, int n, int parent)

	assert(a);
	int child = parent*2 + 1;
	while (child<n)
	
		if (child + 1 < n && a[child + 1] > a[child])
		
			child = child + 1;
		
		if (a[child] > a[parent])
		
			Swap(&a[child] , &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		
		else
		
			break;
		
	

void HeapSort(int* a, int n)

	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	
		AdjustDown(a, n, i);
	
	for (int end = n - 1; end > 0; end--)
	
		Swap(&a[0], &a[end]);
		AdjustDown(a, end, 0);
	

堆排序的特性

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

具体堆排序问题详解

交换排序

基本思想:

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

冒泡排序

第一个数和第二个数比较,第一个大于第二的话交换,然后看第二个和第三个的比较,一趟下来把最大的数放到最后面了,然后不管最后的数,再看前N-1个数。

代码实现:

void BubbleSort(int* a, int n)

	assert(a);
	int flag = 0;
	for (int i = 0; i < n; i++)
	
		for (int j = 0; j < n - 1 - i; j++)
		
			if (a[j] > a[j + 1])
			
				flag = 1;
				Swap(&a[j], &a[j + 1]);
			
		
		if (flag == 0)
			break;
	

冒泡排序的特性

  1. 时间复杂度:O(N^2)
    最好是O(N)—有序
    最坏是O(N^2)
  2. 空间复杂度:O(1)
  3. 稳定性:稳定

快速排序

快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,
基本思想

任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。

我们首先来学快排的递归

先看一下快排递归的框架

// 假设按照升序对array数组中[left, right]区间中的元素进行排序
void QuickSort(int*a, int left, int right)

    if(left>=right)
        return;
// 按照基准值对数组的 [left, right]区间中的元素进行划分
    int keyi = partion(a, left, right);
// 划分成功后以keyi为边界形成了左右两部分 [left, keyi-1] 和 [keyi+1, right]
// 递归排[left, keyi-1]
    QuickSort(a, left, keyi-1);
// 递归排[keyi+1, right]
    QuickSort(a, keyi+1, right);

上述为快速排序递归实现的主框架,发现与二叉树前序遍历规则非常像,现在我们只需要完成partion部分的选key就可以了,这里介绍3种方法。

hoare版本

hoare的基本思想:
1、取最左边的做key,最左边的为L指针,右边的为R指针
2、L指针往后走,找比key大的数,R往前走,找比key小的数,找到之后将其交换
3、当L和R相遇时,让key位置数和相遇处交换,此时key左边都是小于key的,key右边都是大于key的
4、此时key的位置就是这个值该待的位置
5、之后再递归key左边的区间和key右边的区间

注意一点
选左边的值做key,右边先走
选右边的值做key,左边先走
至于为什么家人们可以带进去试一试就知道了

代码实现:

int Partion1(int* a, int left, int right)

	assert(a);
	int keyi = left;
	while (left<right)
	
		while (left < right && a[left] <= a[keyi])
			left++;
		while (left < right && a[right] >= a[keyi])
			right--;
		Swap(&a[left], &a[right]);
	
	Swap(&a[keyi], &a[left]);
	return left;

讨论一下存在的问题同时也是对快排的优化
那么怎么解决快排面对有序序列的问题?

那就是 三数取中
就是拿最左边的数,最右边的数和中间数,三者比较选出不是最大也不是最小的那个作为key,这样就解决了上述问题。

代码实现:

int GetMidIndex(int* a, int left, int right)

	int mid = left + ((right - left) >> 1);//取中间值,这样写是怕大于int的最大值溢出
	if (a[left] > a[mid])
	
		if (a[right] > a[left])
			return left;
		else if (a[mid] > a[right])
			return mid;
		else
			return right;
	
	else
	
		if (a[left] > a[right])
			return left;
		else if (a[right] > a[mid])
			return mid;
		else
			return right;
	

挖坑法

挖坑法的基本思想
1、将最左边的值作为key,并且将该位置作为一个坑位
2、也是有两个指针L和R,R先向前走,找比key小的值,放到坑位,然后自己成为坑位
3、之后L向后走,找比key大的值,放到刚才的坑位,然后自己变成坑位
4、最后L和R相遇时,把最开始的key值放到相遇处的坑位
5、至此就把key放到它排好序该在的位置了

代码实现:

void Partion2(int* a, int left, int right)

	assert(a);
	int key = a[left];
	int pivot = left;
	while (left<right)
	
	//右边找小,放到左边的坑位
		while (left<right && a[right]>=key)
			right--;
		a[pivot] = a[right];
		pivot = right;
  //左边找大,放到右边的坑位
		while (left < right && a[left] <= key)
			left--;
		a[pivot] = a[left];
		pivot = left;
	
	a[pivot] = key;
	return pivot;

双指针法(推荐)


最后一种方法是双指针法,也是最推荐的一种方法

双指针法的基本思想
1、选取最左边的作为key值
2、双指针prev和cur
3、cur往后找比key小的值
4、当找到之后,让prev++然后交换数值
5、等于cur找小往左边扔,prev把大的数往右边扔
6、当cur结束的时候,将key和prev交换
7、至此就让key到了它排好序该在的位置了

代码实现:

void Partion3(int* a, int left, int right)

	assert(a);
	int keyi = left;
	int prev = left;
	int cur = left + 1;
	while (cur<=right)
	
		if (a[cur] < a[keyi])
		
			prev++;
			Swap(&a[prev], &a[cur]);
		
		cur++;
	
	Swap(&a[prev], &a[keyi]);
	return prev;

还有一个地方可以优化
那就是 小区间优化
现在我们已经知道快排是一种二叉树结构的交换排序法,
二叉树是递归的,
而且二叉树的最后1,2层的递归次数几乎是整个二叉树递归的次数了,
所以当递归到最后几层小区间时,我们可以不再让其使用快速排序,不让他再递归了,
而是使用插入排序

所以最终我们的快排代码实现:

int Partion3(int* a, int left, int right)

	assert(a);
	int mid = GetMidIndex(a, left, right);
	Swap(&a[left], &a[mid]);
	int keyi = left;
	int prev = left;
	int cur = left + 1;
	while (cur<=right)
	
		if (a[cur] < a[keyi])
		
			prev++;
			Swap(&a[prev], &a[cur]);
		
		cur++;
	
	Swap(&a[prev], &a[keyi]);
	return prev;


void QuickSort(int* a, int left,int right)

	if (left >= right)
		return;

	if (right - left + 1 < 10)
		InsertSort(a + left, right - left + 1);
	else
	
		int keyi = Partion3(a, left, right);

		QuickSort(a, left, keyi - 1);
		QuickSort(a, keyi + 1,right);
	

说完了快排的递归实现,我们知道递归函数是在栈上开辟空间的,但栈的空间很小,如果递归深度太深,很有可能造成栈溢出
(栈溢出的原因有二:递归深度太深;没有写返回出口条件)

递归相比循环,性能较差,因为之前讲过了,递归就意味着要开辟和释放很多次函数栈帧,而每次的函数栈帧的开辟和释放都是对时间和空间的消耗。只不过随着科技进步,现在的编译器的优化已经很好了(比如VS上的release版本就对函数栈帧优化了很多),所以现在的递归和循环性能差不了多少了。

快排的非递归

快排的非递归运用的是数据结构中的栈结构

运用栈结构的后进先出,栈是malloc出来的了,所以是在堆上开辟的,优势就很明显了:
堆比栈空间大的多。

代码实现:

void QuickSortNonR(int* a, int left, int right)

	assert(a);
	ST st;
	StackInit(&st);
	StackPush(&st, left);
	StackPush(&st, right);
	while (!StackEmpty(&st))
	
		int end = StackTop(&st);
		StackPop(&st);
		int begin= StackTop(&st);
		StackPop(&st);
		int keyi = Partion3(a, begin, end);
		if (keyi + 1 < end)
		
			StackPush(&st, keyi+1);
			StackPush(&st, end);
		
		if (begin < keyi - 1)
		
			StackPush(&st, begin);
			StackPush(&st, keyi-1);
		
	

快速排序的特性
1、快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
2、时间复杂度:O(N*logN)

3、空间复杂度:O(logN)
4、稳定性:不稳定
5、快排的非递归遍历可以使用模拟二叉树的前序遍历来实现,也可以用队列模拟二叉树的层序遍历来实现。
6、快排的非递归没有本质上提高时间效率,时间复杂度没变,但因为没有递归,所以可以减少栈空间的开销
7、快排是基于分治法的一个排序算法

归并排序

基本思想:

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

归并的递归代码实现:

void 数据结构初阶第九篇——八大经典排序算法总结(图解+动图演示+代码实现+八大排序比较)

数据结构初阶第九篇——八大经典排序算法总结(图解+动图演示+代码实现+八大排序比较)

八大排序(详细分析+动图演示)

八大排序算法——堆排序(动图演示 思路分析 实例代码java 复杂度分析)

八大排序算法——冒泡排序(动图演示 思路分析 实例代码java 负杂度分析)

糊涂算法之「八大排序」总结——用两万字,8张动图,450行代码跨过排序这道坎(建议收藏)