数据结构:八大排序(冒泡,堆,插入,选择,希尔,快排,归并,计数)详解

发布于:2025-03-06 ⋅ 阅读:(10) ⋅ 点赞:(0)

目录

一.冒泡排序

二.堆排序

三.插入排序

四.选择排序

五.希尔排序

六.快速排序

1.Lomuto版本(前后指针法)

2.Lomuto版本的非递归算法

3.hoare版本(左右指针法)

4.挖坑法找分界值:

七.归并排序

八.计数排序

九.排序算法复杂度及稳定性分析


一.冒泡排序

冒泡排序即qsort排序,其在几大排序中属于那种效率较低的排序方式,相同条件下,qsort排序的时间要比其他排序多几十乃至几千倍,但由于其在排序过程中不会轻易改变原来数据的相对位置,因此它也算是一种较为稳定的函数(关于排序函数的稳定性我会在下文带着慢慢说明),以下是冒泡排序的具体原理与代码:

在升序条件下,不断遍历左边的数据以找其中最大者然后不断循环放在右边

//冒泡排序
void BubbleSort(int* arr, int n)
{
	int end = n;
	while (end)
	{
		int flag = 0;
		for (int i = 1; i < end; ++i)//代表每一次进入循环左边数据都会少一个
		{
			if (arr[i - 1] > arr[i])
			{
				int tem = arr[i];
				arr[i] = arr[i - 1];
				arr[i - 1] = tem;//右边比左边大就交换
				flag = 1;
			}
		}
		if (flag == 0)
		{
			break;
		}
		--end;
	}
}

看懂这样的代码之后可以仔细想想:冒泡排序在最差的情况下(即恰好每一轮的找最大值都需要它一个不漏地遍历其当时的所有数据)那么冒泡排序的时间复杂度为O(n^2)(即1一直累加到n-1)最好的情况下就是O(n),同时,在冒泡排序的每一趟过程中,假设有两个连续的一样的数据,那么冒泡排序在遍历过程中,是不会改变这两个数据的相对位置的,因此在不考虑时间复杂度的情况下,所以我称冒泡排序这种排序方式是一种稳定的排序方式

二.堆排序

堆排序的具体使用我在以下文章https://blog.csdn.net/2403_87691282/article/details/145888014?spm=1001.2014.3001.5501里有过说明,所以这里就允许我稍微偷下懒展示一下现成代码吧:

//向下调整算法
void AdjustDown(HP* hp, int parent, int n)//n是向下调整的下界范围
{
	int child = parent * 2 + 1;//公式
	while (child < n)
	{
		//建小根堆
		if (child + 1 < n && hp->arr[child + 1] < hp->arr[child])//child+1的目的是确保两个孩子结点都是有意义的结点
		{
			child++;
		}
		if (hp->arr[parent] > hp->arr[child])
		{
			swap(&hp->arr[parent], &hp->arr[child]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			//我最小的孩子都比父节点大,那么这个堆就是正常堆了,直接退出
			break;
		}
	}
}


// 升序,建⼤堆
// 降序,建⼩堆
// O(N*logN)

//基于数组的堆排序
void HeapSort(int* a, int n)
{
 
     // a数组直接建堆 O(N)
 for (int i = (n-1-1)/2; i >= 0; --i)
 {
     AdjustDown(a, n, i);
 }
     // O(N*logN)
     int end = n - 1;
 while (end > 0)
 {
     Swap(&a[0], &a[end]);
     AdjustDown(a, end, 0);
     --end;
 }
}

堆排序的时间复杂度也是比较好理解的:n*log(n),log(n)是向下调整算法每一层遍历需要的时间,n则是每一次为了出堆顶数据而进行的循环调用,由于每一次的出堆顶数据都需要调用一次向下调整算法,因此堆排序的时间复杂度为n*log(n),而且需要补充的一点是由于每一次的取堆顶出堆顶,可能会导致原待排序数据中相同数据的相对位置在堆排序后出现改变,因此也可以说尽管堆排序的效率在排序里算不错的,但其稳定性是属于不稳定的那一批

三.插入排序

插入排序的逻辑其实也不难:

1.从第一个元素开始,假设该元素已经被排序
2.取下一个元素tem,从已排序的元素序列从后往前遍历
3.如果该元素大于tem,则将该元素移到下一位
4.重复步骤3,直到找到已排序元素中小于等于tem的元素
5.tem插入到该元素的后面,若已排序所有元素都大于tem,则将tem插入到下标为0的位置
6.循环步骤2~5

void InsertSort(int* arr, int n)
{
	for (int i = 0; i < n - 1; ++i)
	{
		//记录有序序列最后一个元素的下标
		int end = i;
		//待插入的元素
		int tem = arr[end + 1];
		//单趟排
		while (end >= 0)
		{
			//比插入的数大就向后移
			if (tem < arr[end])
			{
				arr[end + 1] = arr[end];
				end--;
			}
			//比插入的数小,跳出循环
			else
			{
				break;
			}
		}
		//tem放到比插入的数小的数的后面
		arr[end  + 1] = tem;
		//代码执行到此位置有两种情况:
		//1.待插入元素找到应插入位置(break跳出循环到此)
		//2.待插入元素比当前有序序列中的所有元素都小(while循环结束后到此)
	}
}

代码补充解释:由于end作为记录有序数据的最后一个数据的变量,所以在大循环for中,end的实际值是一直在改变的,因此就需要在单趟排之后随时重置tmp的位置,而且tmp作为始终处于end后一位的变量(作为end的侦察兵,始终为了确定end是否需要与后一位交换位置而存在),在小循环while里end值每交换一次,就代表其与tmp值交换位置,因此需要在每个小循环的最后再将end值--使其回到正确的实际值,同样还有一点需要注意的就是直接插入排序的时间复杂度为O(n^2),且由于其在遍历过程中不会改变相同数据的相对位置,所以其排序性质是稳定的

四.选择排序

选择排序的思路也不算难,主要就是通过每次从待排序序列中挑一个最大再挑一个最小然后分别放在数组的两端,依次类推:

具体代码:

//选择排序
void swap(int* a, int* b)
{
	int tem = *a;
	*a = *b;
	*b = tem;
}
void SelectSort(int* arr, int n)
{
	//保存参与单趟排序的第一个数和最后一个数的下标
	int begin = 0, end = n - 1;
	while (begin < end)
	{
		//保存最大值的下标
		int maxi = begin;
		//保存最小值的下标
		int mini = begin;
		//找出最大值和最小值的下标
		for (int i = begin; i <= end; ++i)
		{
			if (arr[i] < arr[mini])
			{
				mini = i;
			}
			if (arr[i] > arr[maxi])
			{
				maxi = i;
			}
		}
		//最小值放在序列开头
		swap(&arr[mini], &arr[begin]);
		//最大值放在序列结尾
		swap(&arr[maxi], &arr[end]);
		++begin;
		--end;
	}
}

当理解了选择排序后有一点需要注意的就是直接选择排序的时间复杂度无论是最佳情况(即待排序数据已经排序完毕)还是最差情况(每一次找最大最小都要遍历全部待排序数据)都是O(n^2),这点与冒泡排序不同,冒泡排序虽然最差情况时与直接排序相同,但在最好情况下的时间复杂度却是O(n),主要还是因为冒泡排序如果排已经拍好的数组时,其中的小循环几乎已经不需要进入执行了,但直接选择排序即便是遍历已经排好的数组也仍旧需要从头到尾遍历一遍,并且直接选择在遇到相同数据时同样是有可能会改变原始数据的相对顺序的,是不稳定的

五.希尔排序

希尔排序的逻辑可能就有些复杂:其基本思想为先选定⼀个整数(通常是gap=n/3+1),把 待排序⽂件所有记录分成各组,所有的距离相等的记录分在同⼀组内,并对每⼀组内的记录进行排序,然后gap=gap/3+1得到下⼀个整数,再将数组分成各组,进行插入排序,当gap=1时,就相当于直接插入排序,

再简单点来说就是先将待排序列进行预排序,使待排序列接近有序,然后再对该序列进行一次插入排序,此时插入排序的时间复杂度为O(N)

由于它是在直接插入排序算法的基础上进行改进⽽来的,综合来说它的效率肯定是更高的

//希尔排序
void ShellSort(int* arr, int n)
{
	int gap = n;
	while (gap>1)
	{
		//每次对gap折半操作
		gap = gap / 2;
		//单趟排序
		for (int i = 0; i < n - gap; ++i)
		{
			int end = i;
			int tem = arr[end + gap];
			while (end >= 0)
			{
				if (tem < arr[end])
				{
					arr[end + gap] = arr[end];
					end -= gap;
				}
				else
				{
					break;
				}
			}
			arr[end + gap] = tem;
		}
	}
}

代码方面的解释也不算很难,希尔排序通过对原始数据不断的分小组细化,然后对每一个小组使用直接插入排序,从而减少直接插入排序算法里循环的次数,希尔算法里对于其中小循环的解释跟直接插入排序算法是有一样的,都是基于end与tmp的相对位置不变,然后小的往前放,大的往后放并且随时重置tmp(希尔里就是end+gap)使其一直处于end的前面而实现的算法,同样,我们再说到其时间复杂度,这里希尔算法的时间复杂度由于数学理论的局限,因此:

但唯一可以确定的是,希尔排序确实在很大程度上优化了直接插入算法

六.快速排序

快速排序我认为是几大排序里最复杂也是探究的最深的一个排序算法思想,其根本的逻辑就是通过先在原始数据任意建立一个参考点,然后通过各种方法使参考点的左边的数据小于参考点,然后使参考点右边的数据大于参考点

1.Lomuto版本(前后指针法)

思路:
1.选出一个keyi,一般是最左边或是最右边的。
2.起始时,prev指针指向序列开头,cur指针指向prev+1
3.若cur指向的内容小于keyi,则prev先向后移动一位,然后交换prev和cur指针指向的内容,然后cur指针++;若cur指向的内容大于key,则cur指针直接++,(因此在排序过程中在没遇到大于keyi参考值时,prev和cur都会在下一步之前重合,只有当遇到大于参考值时,cur才会多走一步从而拉开与prev的距离,而这样拉开一步的解雇就是使prev的下一步指向值是一个比参考值大的数,因此此时交换prev和cur就可以实现数据大的往后移动)如此进行下去,直到cur到达end+1(出界)位置,此时将keyi和prev指针指向的内容交换即可。

经过一次单趟排序,最终也能使得key左边的数据全部都小于key,key右边的数据全部都大于key。

4.然后也还是将key的左序列和右序列再次进行这种单趟排序,如此反复操作下去,直到左右序列只有一个数据,或是左右序列不存在时,便停止操作

//Lomuto前后指针
int _QuickSort2(int* arr, int left, int right)
{
	int keyi = left;
	int prev = left, cur = prev + 1;
	while(cur <= right)
	{
		if (arr[cur] < arr[keyi] && ++prev != cur)
		{
			Swap(&arr[prev], &arr[cur]);
		}
		++cur;
	}
	Swap(&arr[prev], &arr[keyi]);
	return prev;
}

2.Lomuto版本的非递归算法

非递归版本的快速排序就是在Lomuto算法的基础上充分利用了栈的性质(这里有些具体的栈的操作我在之前有篇文章提过,具体请参考:https://blog.csdn.net/2403_87691282/article/details/145228592?spm=1001.2014.3001.5501),通过将数组的首尾元素存储在栈里从而实现一种类似于不断循环的二分数组的结构,而其中二分的核心即是我们最核心的代码:Lomuto算法(找参考值)

#include<stdio.h>

//定义栈的结构
typedef int STDataType;
typedef struct Stack
{
	STDataType* arr;
	int top;      //指向栈顶的位置
	int capacity;//栈的容量
}ST;

void QuickSortNonR(int* arr, int left, int right)
{
	ST st;
	STInit(&st);
	StackPussh(&st, right);
	StackPussh(&st, left);
	while (!StackEmpty(&st))
	{
		//取栈顶两次
		int begin = StackTop(&st);
		StackPop(&st);

		int end = StackTop(&st);
		StackPop(&st);

		//[begin,end]--找基准值
		int key1 = begin;
		int prev = begin, cur = prev + 1;
		//Lomuto前后指针法排序
		while (cur <= end)
		{
			if (arr[cur] < arr[key1] && ++prev != cur)
			{
				Swap(&arr[prev], &arr[cur]);
			}
			cur++;
		}
		Swap(&arr[prev], &arr[key1]);


		key1 = prev;//重新设定基准值以区分两个数组
		//begin key1 end
		//左序列【begin,key1-1】  右序列【key1+1,end】
		//要确保有效
		if (key1 + 1 < end)
		{
			StackPush(&st, end);
			StackPush(&st, key1+1);
		}
		if (begin < key1-1)
		{
			StackPush(&st, key1 - 1);
			StackPush(&st, begin);
		}
	}
	//销毁栈
	STDestroy(&st);
}
 

3.hoare版本(左右指针法)

//快速排序   hoare版本(左右指针法)
void QuickSort(int* arr, int begin, int end)
{
	//只有一个数或区间不存在
	if (begin >= end)
		return;
	int left = begin;
	int right = end;
	//选左边为key
	int keyi = begin;
	while (begin < end)
	{
		//右边选小   等号防止和key值相等    防止顺序begin和end越界
		while (arr[end] >= arr[keyi] && begin < end)
		{
			--end;
		}
		//左边选大
		while (arr[begin] <= arr[keyi] && begin < end)
		{
			++begin;
		}
		//小的换到右边,大的换到左边
		swap(&arr[begin], &arr[end]);
	}
	swap(&arr[keyi], &arr[end]);
	keyi = end;
	//[left,keyi-1]keyi[keyi+1,right]
	QuickSort(arr, left, keyi - 1);
	QuickSort(arr,keyi + 1,right);
}

在这个算法里有两点需要额外关注,一就是递归时所使用的函数的参数,这个就是三路递归法,之所以使用这样的方法是因为在快排的双指针法中,可以确保递归的平衡,避免Lomuto双指针法里产生的元素集中在左侧或者右侧而产生的递归不平衡,二就是代码过程中判断指针是否运动时的等号,之所以在相等时也让指针持续运动,是为了使数据中相同的元素最后集中在三路里的中间一路,以避免多次重复的递归计算

4.挖坑法找分界值:

思路:

        创建左右指针,⾸先从右向左找出⽐基准⼩的数据,找到后⽴即放⼊左边坑中,当前位置变为新 的"坑",然后从左向右找出⽐基准⼤的数据,找到后⽴即放⼊右边坑中,当前位置变为新的"坑",结 束循环后将最开始存储的分界值放⼊当前的"坑"中,返回当前"坑"下标(即分界值下标)

int _QuickSort(int* a, int left, int right)
{
	int mid = a[left];
	int hole = left;
	int key = a[hole];
	while (left < right)
	{
		while (left < right && a[right] >= key)
		{
			--right;
		}
		a[hole] = a[right];
		hole = right;
		while (left < right && a[left] <= key)
		{
			++left;
		}
		a[hole] = a[left];
		hole = left;
	}
	a[hole] = key;
	return hole;
}

七.归并排序

总结来说:归并排序是一种基于分治法的高效排序算法,其核心思想是将数组逐步拆分为更小的子数组进行排序,再将有序的子数组合并为整体有序的数组

void _MergeSort(int* a, int left, int right, int* tmp)
{
	if (left >= right)
	{
		return;
	}
	int mid = (right + left) / 2;
	//[left,mid] [mid+1,right]
	_MergeSort(a, left, mid, tmp);
	_MergeSort(a, mid + 1, right, tmp);

	int begin1 = left, end1 = mid;
	int begin2 = mid + 1, end2 = right;
	int index = begin1;

	 //合并两个有序数组为⼀个数组 
	while (begin1 <= end1 && begin2 <= end2)
	{
		if (a[begin1] < a[begin2])
		{
			tmp[index++] = a[begin1++];
		 }
		else
		{
			tmp[index++] = a[begin2++];
		 }
	}
	while (begin1 <= end1)
	{
		tmp[index++] = a[begin1++];
	}
    while (begin2 <= end2)
	{
		tmp[index++] = a[begin2++];
	}
	for (int i = left; i <= right; i++)
	{
		a[i] = tmp[i];
	}
}

void MergeSort(int* arr, int n)
{
	//外部申请临时数组空间,因此不计入空间复杂度
	int* tmp = (int*)malloc(sizeof(int*)*n);
	_MergeSort(arr, 0, n - 1, tmp);
	free(tmp);
}

八.计数排序


void CountSort(int* a, int n)
{
	int min = a[0], max = a[0];
	for (int i = 1; i < n; i++)
	{
		if (a[i] > max)
			max = a[i];
		if (a[i] < min)
			min = a[i];
	}
	int range = max - min + 1;
	int* count = (int*)malloc(sizeof(int) * range);
	if (count == NULL)
	{
		perror("malloc fail");
		return;
	}
	 memset(count, 0, sizeof(int) * range);//初始化
	// 统计次数 
	for (int i = 0; i < n; i++)
	{
		count[a[i] - min]++; 
	}
	// 排序 
	int j = 0;
	for (int i = 0; i < range; i++)
	{
		while (count[i]--)
		{
			a[j++] = i + min;
		}
	}
}

九.排序算法复杂度及稳定性分析

ok阿,到这里为止,数据结构的最后一章排序也结束了,下面是整个数据结构章节的所有内容的概览图:

好了,那就到此为止吧

全文终


网站公告

今日签到

点亮在社区的每一天
去签到