排序-通俗易懂!给你说明白数据结构中的八大排序,超详细

目录

一、插入类排序

1.1 直接插入排序

1.2 希尔排序

二、选择类排序

2.1 直接选择排序

2.2 堆排序

三、交换类排序

3.1 冒泡排序

3.2 快速排序

hoare法

挖坑法

lomuto前后指针法

四、其他排序

4.1 归并排序

4.2 计数排序

五、时间复杂度and稳定性

六、碎碎念


一、插入类排序

1.1 直接插入排序

先借助一张图(下图排的是升序),我们来了解一下啥是直接插入排序

直接插入就是直接替换

如图,7>2,这里并不是将7和2的位置互换,而是将数据7直接覆盖2

当然为了我们不丢失原来的数据2,我们要创建临时变量tmp保存2的值。

那么,现在我带大家把上图的流程走一遍:

先创建一个变量end用于遍历,还要创建一个临时变量tmp来保存我们需要排的值

一开始end,tmp分别指向4和7,满足升序。

满足升序,end就往后走。

4和7已经排序完成,接下来排2,故tmp走向2。

7>2,所以7直接替换2(也就相当于7往后走了一步),

这个时候end要再回退一步(因为我们还不确定2要排的正确位置在哪里,所以要将之前排好了的数据都比较一遍,直至找到正确位置

此时end指向4,4>2,所以4直接替换原来旧的7的位置(就是arr[1]的位置)

由于4和7都排好了,end回退到了arr[-1]

那么2现在就是最小的数据,故排到arr[0]处

然后我们再让end回到正确的位置,以此类推,重复上述步骤

代码如下:

void InsertSort(int* arr, int n)//排升序
{
	for (int i = 0; i < n - 1; i++)
	{
		int end = i;//先从第一个元素开始向后遍历
		int tmp = arr[end + 1];

		while (end >= 0)//每次往前走到第一个元素结束,说明没有数据比tmp小了
		{
			if (arr[end] > tmp)
			{
				//往后移
				arr[end + 1] = arr[end];
				end--;//往前走,继续比较前面的元素,看和tmp的大小关系
			}
			else
			{
				break;
			}
		}
		//找到tmp应该插入的位置
		arr[end + 1] = tmp;
	}
}

其实,直接插入排序就是比较大小,然后找正确插入位置

1.2 希尔排序

先借助一张图,我们来了解一下啥是希尔排序

其实,希尔排序就是升级版的直接插入排序

因为希尔排序的底层逻辑就是直接插入排序,而与之不同的就是希尔排序多了分组这个步骤。

而正是因为多了分组,才让希尔排序的效率更高。

为什么分组让希尔排序的效率比直接插入排序高嘞?

我们拿上图来说,4,7,2,6,5分成了三组【4,6】【7,5】【2】,让每一组分别进行直接插入排序,易得【4,6】【5,7】【2】

这个时候我们再进行一次直接插入排序,显然比一开始就进行直接插入排序效率高的多

再来举个例子,一组数据9,1,2,5,7,4,8,6,3,5,再gap=3的情况下,分为了【9,5,8,5】【1,7,6】【2,4,3】这三组。

当我们对每一组直接插入排序后,得到【5,5,8,9】【1,6,7】【2,3,4】

那么如何分组呢?

我们用步幅(相当于一段差距)来划分组,用gap来表示步幅。

分组不能分太多也不能分太少,通常gap = n / 3 +1

这里除以3,分出来的组数就刚刚好。+1是为了保证最后一次gap = 1.

因为gap = 1就代表着直接插入排序。

希尔排序的最后一步一定得是直接插入排序

接下来,我们来分析一下希尔排序的排序思路:

首先根据gap分组,然后在每个组内进行排序,但是这里是交叉排序

也就是第一组的第一对数据比较完,接下来比较第二组的第一对数据,以此类推。

而不是说把第一组的全部数据都排好后,再去排第二组。

当每一组都排好之后,再对gap进行变化,然后重复上述操作,直至gap = 1。

gap = 1时再对所有的数据进行一次直接插入排序。

代码实现:

void ShellSort(int* arr, int n)
{
	int gap = n;

	while (gap > 1)//控制gap组
	{
		//分组
		gap = gap / 3 + 1;//保证最后一次gap一定为1,为1也就是说明进行直接插入排序

		for (int i = 0; i < n - gap; i++)//控制组中的小组
		{
			//i++:第一小组的第一对数据比完,直接到第二小组的第一对
			//i<n-tmp: 结束条件就是,tmp走到末尾元素arr[n-1]

			int end = i;
			int tmp = arr[end + gap];
			while (end >= 0)//这个while循环的底层逻辑就是直接插入排序
			{
				if (arr[end] > tmp)
				{
					arr[end + gap] = arr[end];
					end -= gap;
				}
				else
				{
					break;
				}
			}
			arr[end + gap] = tmp;

			//走到这,说明某小组中的某对数据已经排好;然后i++
		}
	}
}

不难发现,希尔排序其实就是两步

①先分组预排序

②直接插入排序

二、选择类排序

2.1 直接选择排序

先借助一张图,我们来了解一下啥是直接排序

直接选择排序又叫做简单选择排序。

听着名字,又直接又简单的,那么真的很简单吗?

(在我看来,还不如快速排序当中的lomuto法好理解嘞。)

不过还是给大家说一下,直接选择排序的思路吧。

用一个变量去遍历,在一轮遍历中,我们能确定出本轮最大的数和最小的数。

将最大数放到末尾,最小的数放到开头

我们先把max和min都统一指向首元素,

拿一个变量从第二个元素开始往后遍历,去找比首元素大的值,以及比首元素小的值。

找到后,再将max和min分别指向对应的位置

一轮遍历后,我们肯定可以找到这一轮中最大的数和最小的数

然后将max和min分别放到尾元素和头元素的位置。也就是上图中的end和begin

再将end往前走,begin往后走,再确定下一轮中的最大最小数,以此类推。

代码实现:

void Swap(int* x, int* y)
{
	int tmp = *x;
	*x = *y;
	*y = tmp;
}


void SelectSort(int* arr, int n)
{
	int begin = 0;
	int end = n - 1;

	while (begin < end)
	{
		//从左边开始,同时找大和找小
		int mini = begin, maxi = begin;
		for (int i = begin + 1; i <= end; i++)//每一轮中都要找出此轮中最大的数和最小的数
		{
			//下标i开始往后遍历,i每往后走一步就比较两次
			if (arr[i] > arr[maxi])
			{
				maxi = i;//maxi指向i此时所在位置
			}
			if (arr[i] < arr[mini])
			{
				mini = i;//mini指向i此时所在位置
			}
		}

		//避免maxi 和 begin指向同一位置(begin和mini交换之后,maxi数据变成了最小的数据)
		if (maxi == begin)
		{
			maxi = mini;
		}

		//一轮遍历结束,已找到此轮中最大和最小的数
		//交换,把最大的数放到后面,把最小的数放在前面
		Swap(&arr[mini], &arr[begin]);
		Swap(&arr[maxi], &arr[end]);

		//交换完,忽视已经排好的位置,故begin往后走,end往前走
		++begin;
		--end;

		//继续进入大循环,开始下一轮的遍历
	}
}

2.2 堆排序

在之前的博客中已详细讲解,可移步至:

堆排序(详解:向上调整算法&向下调整算法)-CSDN博客

三、交换类排序

3.1 冒泡排序

先借助一张图,我们来了解一下啥是冒泡排序

说白了,冒泡排序就是大循环套着小循环

那这两个循环分别是啥意思嘞?

先来说小循环,小循环就是每两个数据进行比较

如图,第一次小循环里面就是,先3,5比较,5,8比较,再8,2比较。

若在小循环里面发生交换,那么就拿交换后的数据,与其紧挨着的后一个数据进行比较。

一趟小循环结束后,每两个紧挨着的数据是有序的,并且这趟循环中最大的数据会出现在末尾位置

然后,再重头来一遍小循环,直至排序完成。

所谓大循环就是控制一个又一次的发生小循环

我们知道每次小循环结束后都会找到最大的数据放到最后,

但是每次循环我们又得要忽视掉上一次循环的最后一个位置,如此才能按照一定的顺序排好数据。

以此类推,当大循环结束时,就代表着所有小循环已经完成,数据已经排好

代码实现:

void BubbleSort(int* arr, int n)//排降序
{
	for (int i = 0; i < n; i++)//大趟
	{
		int exchange = 0;
		for (int j = 0; j < n - i - 1; j++)
		{
			//每一小趟结束后都会排出一个最大的,所以到下一次小趟的时候就不用再比较最后一个元素
			//故n-i(最后一个元素的下标)之后还要-1

			if (arr[j] < arr[j + 1])
			{
				exchange = 1;
				Swap(&arr[j], &arr[j + 1]);
			}
		}
		if (exchange == 0)
		{
			break;//如果在一小趟中没有发生交换,说明已经排好了,直接退出
		}

	}
}

void Swap(int* x, int* y)
{
	int tmp = *x;
	*x = *y;
	*y = tmp;
}

简单来说,冒泡排序就是循环两两对比

3.2 快速排序

快速排序,顾名思义它的排序效率一定是最高的。

快速排序的核心就是找基准值的正确位置

最终使得基准值左侧的序列中的每个值都小于基准值,右侧序列中的每个值都大于基准值

通常将首元素视为基准值

找基准值有三种方法:

hoares法

挖坑法

lomuto前后指针法

我依次来给大家介绍这三种方法:建议先掌握lomuto前后指针法

hoare法

hoare法就是左边和右边同时开始遍历。

从右往左找比基准值小的数;从左往右找比基准值大的数

当我们把这两个数都找到之后,再把这两个数进行交换,这样小的就会换到左边,大的就会换到右边。

交换完成后再继续朝着各自的方向遍历,直至交叉越过

此时right所在的位置就是基准值所在的正确位置,二者一交换,便可让基准值在正确的位置。

每一轮都会找到基准值的正确位置

然后就是递归的思想了,让基准值的左侧区间继续找其基准值,让其右区间找其基准值。

因为每一轮都会确定下来基准值的位置,当所有轮结束,序列也就排序好了

代码实现:

void Swap(int* x, int* y)
{
	int tmp = *x;
	*x = *y;
	*y = tmp;
}

//hoare法
int _QuickSort01(int* arr, int left, int right)
{
	int keyi = left;//通常把首元素看作基准值
	++left;//从基准值的下一个元素开始比较

	//找到基准值的应该所在的正确位置,确保基准值的左边都是小的,右边都是大的
	while (left <= right)//=是为了,避免left和right相遇的位置的值比基准值要大,然后把大的值放到了左边
	{
		//先right从右往左找比基准值小的值
		while (left <= right && arr[right] > arr[keyi])
		{
			right--;//没找到往前走,继续找
		}

		//再left从左往右找比基准值大的值
		while (left <= right && arr[left] > arr[keyi])
		{
			left++;//没找到往后走,继续找
		}

		//找到了大值or小值
		if (left <= right)//并且第一轮完还没有走完,就先让它们交换,交换完,继续走
		{
			Swap(&arr[left++], &arr[right--]);//小的值换到左边去,大的值换到右边去
		}
	}
	//找到基准值的正确位置
	Swap(&arr[keyi], &arr[right]);

	return right;

}

挖坑法

挖坑法就是不断的挖坑然后填坑,最后一个坑就是基准值所在的位置。

对于挖坑法和hoare法,其实我感觉啊大差不差。

最大的区别就是hoare法是左右两边同时遍历,而挖坑法则是有顺序的,先右边找基准值小的,找到之后,再从左边找比基准值大的,然后再从右边找(即右、左、右、左……的次序)。

好了,现在我们具体来讲一下怎么挖坑填坑。

我们要创建一个变量保存基准值(相当于在一开始的时候就在最前面挖了一个坑,不过坑里面的基准值被我们事先保存了下来)。

然后再从右边找到比基准值小的数,找到时,再将这个数填到坑(旧坑)里面,再在找到这个数的原来位置上挖一个坑,以此类推。

由于左边右边不是同时进行的,而是带有次序的,所以在结束的时候必然是左边和右边相遇。

左右相遇的位置就是基准值应该在的正确位置

代码实现:


//挖坑法
int _QuickSort02(int* arr, int left, int right)
{
	int hole = left;//一开始把坑放到最左边
	int key = arr[hole];//保存基准值

	//找基准值的位置
	while (left < right)
	{
		//left = right的时候说明已经找到基准值的位置

		//先从右找比基准值小的数
		while (left < right && arr[right] > key)
		{
			--right;//没找到,就往前走,继续找
		}

		//找到了就填坑,把这个数放到坑里面
		arr[hole] = arr[right];
		hole = right;//然后在这个数原来的位置上挖坑

		//右边结束一次后,调转方向,从左边开始找比基准值大的数
		while (left < right && arr[left] < key)
		{
			++left;
		}
		arr[hole] = arr[left];
		hole = left;
	}

lomuto前后指针法

前后指针法就是有两个指针,一前一后,后面的指指针再和前面的指针交换。

前后指针法在我看来是最简单的方法,并且相较于hoare法和挖坑法来说,代码的实现也更简洁

lomuto法的第一步仍旧是保存基准值,

准备两个变量cur(相当于后指针)和prev(相当于前指针)

cur从基准值的下一个数据开始往后遍历,prev指向基准值(也就是头元素),这样刚好就是一前一后。

cur往后遍历找比基准值小的数据,找到之后,先让prev往后走一步,再将prev和cur进行交换,直至cur遍历结束。

此时,prev所在的位置就是基准值所在的位置,最后再交换头元素(视头元素为基准值)和prev的位置。

代码实现:

void Swap(int* x, int* y)
{
	int tmp = *x;
	*x = *y;
	*y = tmp;
}


int _QuickSort03(int* arr, int left, int right)
{
	int prev = left, cur = left + 1;
	int keyi = left;

	while (cur <= right)//让cur往后遍历,找比基准值小的数
	{
		if (arr[cur] < arr[keyi] && ++prev != cur)//找到小的数,让prev往后走一步后,再交换,把小的数换到左边来
		{
			Swap(&arr[cur], &arr[prev]);
		}
		cur++;
	}
	//跳出循环,说明cur遍历完了
	//prev左边的值(包括prev)都是比基准值小的,右边的都是比基准值大的
	Swap(&arr[keyi], &arr[prev]);

	return prev;//返回基准值所在的位置
}

以上找基准值的方法都已经给大家介绍完毕。

接下来我们进入到快速排序中,

快速排序的实现分为递归类和非递归类。

但是归根结底,快速排序就两步:

①找基准值

②递归去分区间,找每个区间的基准值

由于递归类的实现非常好理解,我就直接把代码放下来了:

//快速排序(递归版)的主函数:
void QuickSort(int* arr, int left, int right)
{
	if (left >= right)
	{
		return;//这个子区间已经排完了,直接返回
	}

	//找基准值的正确位置
	int keyi = _QuickSort01(arr, left, right);//选一个找基准值的方法

	//把基准值的左右分为两个子区间,利用递归的思想,继续确认每个区间内的基准值的所在位置
	QuickSort(arr, left, keyi - 1);
	QuickSort(arr, keyi+1, right);
}


非递归类的快速排序,需要借助栈

栈中的基本操作代码得包含(如push、top、pop…),才能有条件去实现非递归的快速排序

我们就只是为了排一个序,而去把栈的代码全都写了一遍,其实是得不偿失的。

故只要求理解有这种方法不要求掌握。

确实出于感性化这个非递归的不好用,写出来太麻烦了。但是,它的好处是不用递归,不用考虑栈溢出的问题。

好了,接下来,我们说一下利用栈如何去实现快速排序。

将一个区间的头元素和尾元素,放到栈里面去,

用一个while循环,取出头元素和尾元素,确定出一个区间,

然后去找这个区间基准值的正确位置,找完之后。

再利用这个基准值给这个区间再一分为二,然后继续入栈,以此类推。

用来回入栈出栈去实现递归的效果

换句话来说,就是用循环实现递归的效果,以此达到排序的目的。

代码实现:

其中关于栈基本操作的代码,有需要的宝宝,可以根据函数名去下面的链接找对应的代码

栈和队列的相互实现-CSDN博客  //也是我之前写的

void Swap(int* x, int* y)
{
	int tmp = *x;
	*x = *y;
	*y = tmp;
}


//快速排序(非递归版)的主函数:
//非递归的实现需要借助栈
//然后再利用循环来实现递归的效果
void QuickSortNonR(int* arr, int left, int right)
{
	//创建一个栈并初始化
	ST st;
	STInit(&st);

	//将这组数据的首元素和尾元素入栈【先入尾,再入头;因为排序的时候是先左再右】
	StackPush(&st, right);
	StackPush(&st, left);

	//利用循环实现递归效果
	while (!StackEmpty(&st)) //直到全部排好,才能跳出循环
	{
		//取栈顶元素,因为要取到一个区间,所以要取两次
		int begin = StackTop(&st);//区间的头元素
		StackPop(&st);
		int end = StackTop(&st);//区间的尾元素
		StackPop(&st);

		//用lomuto法找基准值的正确位置
		int prev = begin;
		int cur = begin + 1;
		int keyi = begin; //保存基准值

		while (cur <= end)
		{
			if (arr[cur] < arr[keyi] && ++prev != cur)
			{
				Swap(&arr[cur], &arr[prev]);
			}
			cur++;
		}
		Swap(&arr[keyi], &arr[prev]);

		keyi = prev;

		//根据基准值的位置划分左右区间
		//左区间:[begin,keyi-1] ; 右区间:[keyi+1,end]
		
		if (keyi + 1 < end)//右区间的入栈条件
		{
			StackPush(&st, end);
			StackPush(&st, keyi + 1);
		}
		if (keyi - 1 > begin)//左区间的入栈条件
		{
			StackPush(&st, keyi - 1);
			StackPush(&st, begin);
		}

	}
	//跳出循环,说明已经排好,那就销毁栈
	STDestroy(&st);
}

四、其他排序

4.1 归并排序

先借助一张图,我们来了解一下啥是归并排序

归并排序就是先分开,再合并。(上图就是最常见的二路归并排序)

归并排序的思路特别好理解,

就是先把一组数据不断的二分,直至每个子区间都只整下一个元素

然后再把临近的两个区间的数据有序的放到一个稍大的区间。

不过为了实现的更为方便,我们通常会再创建一个临时数组,保存暂排好的数据

最后全都排好后,统一把临时数组中的数据全都复制到原来数组中。

代码实现:

void _MergeSort(int* arr, int left, int right, int* tmp)
{
	//开始分
	if (left >= right)
	{
		return;//说明这段区间已经排好了,直接返回
	}
	int mid = (left + right) / 2;//mid用于保存这组区间中间值的下标,方便后续从中间一分为二

	_MergeSort(arr, left, mid, tmp);//左区间
	_MergeSort(arr, mid + 1, right, tmp);//右区间

	//开始合并,也就是治的过程
	//每次都是两个区间(左区间和右区间)开始合并,记录每个区间的头和尾
	int begin1 = left, end1 = mid;
	int begin2 = mid + 1, end2 = right;
	int index = begin1;//记录临时数组的下标

	while (begin1 <= end1 && begin2 <= end2)//这个循环就是之前让两个有序数组变成一个有序数组
	{
		//分别遍历需要合并的两个区间,并比较大小,找到小的就放到临时数组里面
		if (arr[begin1] < arr[begin2])
		{
			tmp[index++] = arr[begin1++]; //然后再往后走,继续遍历比较
		}
		else
		{
			tmp[index++] = arr[begin2++];
		}
	}

	//跳出上面一个循环说明
	//其中一个区间已经遍历完,但是另一个还没有(那就把剩下的数据直接插入到临时数组中)
	while (begin1 <= end1)//要么begin2越界但begin1没有越界
	{
		tmp[index++] = arr[begin1++];
	}
	while (begin2 <= end2)//要么begin1越界但begin2没有越界
	{
		tmp[index++] = arr[begin2++];
	}

	//已经排好了,把tmp中的数据复制到原数组arr中
	for (int i = left; i <= right; i++)
	{
		arr[i] = tmp[i];
	}
}

void MergeSort(int* arr, int n)
{
	//在治的时候,要用一个临时的数组保存暂时排好的数
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc fail");
		exit(1);
	}

	_MergeSort(arr, 0, n - 1, tmp);//用于分治的子函数

	free(tmp);
}

4.2 计数排序

先借助一张图,我们来了解一下啥是直接排序

计数排序属于非比较类排序,也就是说,不需要通过具体比较数据的大小就能排序。

基数排序的核心就是统计一组数据中每个数出现的次数

由此,我们在实现的过程中,要借用到一个新的数组,来放我们统计的次数

然后再通过新数组将数据反射到原数组中,这个时候原数组就已经排序完成。

那为什么就只是统计一下次数就能排好序呢?

注意啊,这里我们统计次数的方法非常的巧妙,

让原数组中的每个数据都对应着新数组的中的每个下标。

正因为这种巧妙的方法,我们不仅不会让这个新数组浪费空间,也可以在对应下标的时候,就潜在的排好了序

小下标对应的数一定是小于大下标的

所以最后通过新数组去还原数的时候,在原数组中呈现的结果就是已经排序好的。

代码实现:

void CountSort(int* arr, int n)
{
	//计数排序需要借助一个新数组来排序
	//找最大值最小值确定数组大小
	int max = arr[0], min = arr[0];
	for (int i = 1; i < n; i++) //一轮遍历完找到这组数据的最大值最小值
	{
		if (arr[i] > max)
		{
			max = arr[i];
		}
		if (arr[i] < min)
		{
			min = arr[i];
		}
	}

	//确定新数组的范围,也就是新数组中的元素个数
	int range = max - min + 1;

	//创建新数组(即计数数组)
	int* count = (int*)malloc(sizeof(int) * range);
	if (count == NULL)
	{
		perror("malloc fail!");
		exit(1);
	}

	//把新数组中的每个元素初始化为0
	memset(count, 0, range * sizeof(int));

	//统计原数组中每个数据出现的次数
	for (int i = 0; i < n; i++)
	{
		count[arr[i] - min]++;//i用来遍历原数组,出现一次就往新数组中加一次
	}

	//取count中的数据,往arr中放
	int index = 0;//index用来遍历原数组
	for (int i = 0; i < range; i++)//i用来遍历新数组
	{
		//从新数组中开头开始遍历
		while (count[i]--)//直至新数组中每个计数到0
		{
			arr[index++] = i + min;//i+min能够还原出原来的数据,放到原数组arr中
			//放完一个数据,继续往后放
		}

		//跳出循环,看新数组中下一个的统计结果
	}
}

计数排序的时间复杂度:O(range)

可见计数排序的效率是非常高的,但是不推荐使用哈,因为这个计数排序通常只是在特定情况(大量重复数据)下才是非常好用的。

五、时间复杂度and稳定性

时间复杂度这里讲起来有点费时,所以我就直接贴在下面了。

如上图,有个稳定性的分区。

啥是稳定性呢?

简单来说就是相对序列不发生改变,那就是稳定

比如一组数据,排序前5在5*的前面,排序后5仍在5*的前面,那就说明是稳定的,否则就是不稳定。

判断是否稳定,除了死记硬背,我还有个简单的方法。

就是简单的去想一下是不是稳定的,如果很容易就能判断出稳定,那就是稳定。如果还需要自己思考一下,甚至是举例那就是不稳定的。

就拿冒泡排序来讲,就是不断的两两交换,遇到两个相同的数肯定就直接越过去了,不会交换,何谈相对位置发生改变一说,那它就是稳定的。同理直接插入排序,归并排序也能很快判断出是稳定的。

六、碎碎念

无特殊要求且需要排序的情况下,首选快速排序的(lomuto法)!代码简单且效率高。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值