【数据结构】各大排序算法

文章目录

  • 排序的相关概念
    • 排序
    • 稳定性
    • 内部排序
    • 外部排序
  • 常见排序介绍
    • 插入类排序
      • 直接插入排序
      • 希尔排序
    • 选择类排序
      • 选择排序(优化版本)
      • 堆排序
    • 交换类排序
      • 冒泡排序
      • 快速排序
        • 递归版本
        • 划分方法&基准值确定
        • 非递归版本
    • 归并排序
      • 递归
      • 非递归
    • 计数排序
    • 基数排序(了解主要思想即可)
      • LSD(动图展示)
  • 排序算法复杂度&稳定性总结

排序的相关概念

排序

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

稳定性

待排序数据在排序前后相同元素的相对位置是否发生改变
如果改变:该排序不稳定
反之就是稳定的

内部排序

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

外部排序

数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。

常见排序介绍

【数据结构】各大排序算法_第1张图片

插入类排序

直接插入排序

主要思想

  1. 给定一组序列,默认第一个元素已经有序,从第二个元素开始遍历数组
  2. 对于每一个待排序的元素,与已经排好序的元素进行从后向前的比较,直到找到待插入元素的位置
  3. 数组遍历完毕后排序也就完成

代码实现

//插入排序(升序)

void InsertSort(int array[], int size)
{
	for (int i = 1; i < size; i++)//控制循环趟数
	{
		int key = array[i];//标记待插入元素
		int end = i - 1;//end标记已经排好序的序列的最后一个下标
		while (end >= 0 && array[end] > key)
		{
			array[end + 1] = array[end];
			end--;
		}
		array[end + 1] = key;//找到插入元素的位置
	}
}

复杂度分析

  1. 时间复杂度O(N^2)
    两层循环,外层控制循环趟数,循环size-1次,内层循环寻找插入位置,每个元素要找到自己的位置最差需要遍历已经排好序的所有元素,因此要循环N次
    综上,时间复杂度为O(N^2)
  2. 空间复杂度O(1)
    未借助辅助空间

稳定性 :稳定!!!

插入排序没有出现跨元素交换的情况,因此相同的元素排序前后相对位置不会发生变化

适用场景

元素基本有序 或者 元素数量比较少

希尔排序

主要思想:希尔排序是对直接插入排序的升级

  1. 选定一个基准值,将数组下标%基准值相等的元素分为一组
  2. 对每一组采用直接插入排序的方法进行排序
  3. 对基准值进行更新,循环执行以上两步,直到基准值减小到1停止
  4. 此时序列已经有序

:基准值如何选取?
采用Kunth提出的gap = gap/3 (向下取整)+1

代码实现

//希尔排序
//定义gap,将数组下标%gap相等的元素分为一组
//对每一组采用插入排序的方式进行排序
//对gap每次执行 gap/3 +1 直到gap减小为1

void ShellSort(int array[],int size)
{
	int gap = size;
	//gap判断条件应该是大于1,因为gap的值始终是>=1的
	while (gap > 1)
	{
		gap = gap / 3 + 1;//gap等于1时,最后一次排序,排完之后整个数组已经有序
		for (int i = gap; i < size; i++)
		{
			int key = array[i];
			int end = i - gap;
			while (end>=0 && array[end]>key)
			{
				array[end + gap] = array[end];
				end -= gap;
			}
			array[end + gap] = key;
		}
	}
}

复杂度分析

  1. 时间复杂度
    希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些树中给出的希尔排序的时间复杂度都不固定
    在这里我们gap的计算采用的是Kunth的方法,这样对gap取值,最终的时间复杂度位于N1.25~1.6*N1.25
  2. 空间复杂度
    未借助任何辅助空间,所以是O(1)

稳定性 :不稳定

存在跨区间交换元素

适用场景

数据比较随机 数据量比较大

选择类排序

选择排序(优化版本)

主要思想

普通版本的选择排序一次只会从中选出一个元素(最大或最小)
优化后的版本,一次可以从带排序数组中选取出两个元素,分别是最大最小元素
具体步骤如下:

  1. 以升序为例,遍历数组,找到数组中最大和最小的两个元素
  2. 将最大元素与数组末尾元素交换,将最小元素与数组开头元素交换
  3. 循环,直到对数组完成一次遍历后,排序结束

代码实现 原始版本+优化版本

//选择排序

static void Swap(int *left, int *right)
{
	int temp = *left;
	*left = *right;
	*right = temp;
}

void SelectSort(int array[], int size)
{

	for (int i = 0; i < size-1; i++)//控制循环趟数 size个元素,需要循环size-1趟
	{
		int maxPos = 0;
		for (int j = 0; j < size - i; j++)//每一趟遍历数组中未排序元素,更新maxPos的位置
		{
			if (array[maxPos] < array[j])
			{
				maxPos = j;
			}
		}
		if (maxPos != size - 1 - i)
		{
			Swap(&array[maxPos], &array[size - 1 - i]);
		}
	}
}

//优化版本
void SelectSortOP(int array[], int size)
{
	int left = 0;
	int right = size - 1;
	while (left < right)
	{
		int maxPos = left;
		int minPos = left;

		int index = left + 1;
		while (index <= right)
		{
			if (array[maxPos] < array[index])
			{
				maxPos = index;
			}
			if (array[minPos]>array[index])
			{
				minPos = index;
			}
			index++;
		}
		if (maxPos != right)
		{
			Swap(&array[maxPos],&array[right]);
		}

		if (minPos == right)
		{
			minPos = maxPos;
		}

		if (minPos != left)
		{
			Swap(&array[minPos],&array[left]);
		}
		left++;
		right--;
	}
}

复杂度分析

  1. 时间复杂度 O(N2)
  2. 空间复杂度 O(1)

稳定性 不稳定

选择排序存在跨区间交换

适用场景

基本情况都可以使用,但都不使用(由于时间复杂度的原因)

堆排序

主要思想

  1. 建堆:将待排序元素按照要求进行建堆操作
    升序:建大堆
    降序:建小堆
  2. 利用堆删除的思想进行排序
    将堆顶元素与最后一个元素交换
    将堆元素规模-1
    然后对新的堆顶元素采用向下调整算法是其称为新的堆结构

代码实现

//堆排序

static void HeapAdjust(int array[], int size, int parent)
{
	int child = 2 * parent + 1;//默认标记左孩子(完全二叉树,必定是先有左孩子,再有右孩子)
	while (child < size)
	{
		if (child + 1 < size && array[child + 1] > array[child])//更新child,让其指向较大的孩子结点
		{
			child += 1;
		}
		if (array[child]>array[parent])  //判断较大孩子结点与父节点的大小关系
		{
			Swap(&array[child], &array[parent]);
			//交换后更新parent与child,因为交换可能会导致子树不符合堆的结构
			parent = child;
			child = 2 * child + 1;
		}

		else//满足堆的结构
		{
			return;
		}
	}
}

void HeapSort(int array[], int size)
{
	//1.建立堆(向下调整)  升序--大堆   降序---小堆
	for (int root = (size - 2) / 2; root >= 0; root--)
	{
		HeapAdjust(array, size, root);
	}
	//2.利用堆删除的思想进行排序
	int end = size - 1;
	while (end > 0)
	{
		Swap(&array[0], &array[end]);
		HeapAdjust(array, end, 0);
		end--;
	}
}


复杂度分析

  1. 时间复杂度:O(NlogN)
  2. 空间复杂度:O(1)

稳定性 : 不稳定

适用场景 : Top-K问题

交换类排序

冒泡排序

主要思想

以升序为例:

给定一组数据,相邻元素之间两两进行比较,一趟下来,将最大的元素排到了数组最后,循环size-1趟后,数组整体将会有序。在每一趟循环过程中,定义一个标志量,用来标记本趟排序是否发生交换,如果发生,说明数组没有完全有序,继续下一趟循环,如果没有发生改变,说明数组已经有序,可以提前跳出循环

代码实现

//冒泡排序
//两两比较,一趟遍历找到一个元素的最终位置(最大或最小)

void BubbleSort(int array[],int size)
{
	for (int i = 0; i < size - 1; i++)//控制循环趟数,size个元素循环size-1次即可完成排序
	{
		int isChanged = 0;//标记一趟遍历中是否有元素交换,若没有交换,说明数组已经有序。此时直接返回,无需再进入下面的循环
		for (int j = 0; j < size - 1 - i; j++)//将最大元素搬移至数组最后
		{
			if (array[j]>array[j + 1])
			{
				isChanged = 1;
				Swap(&array[j], &array[j + 1]);
			}
		}
		if (!isChanged)
		{
			return;
		}
	}
}

复杂度分析

  1. 时间复杂度:O(N2)
  2. 空间复杂度:O(1)

稳定性 :稳定

适用场景:数据接近有序

快速排序

递归版本

主要思想

  1. 选定一个基准值,将该组数据小于基准值的划分子在基准值的左侧,大于基准值的划分在基准值的右侧
  2. 递归:
    使用快排对基准值的左侧进行排序
    使用快排对基准值的右侧进行排序

代码实现

/递归
void QuickSort(int array[], int left, int right )
{
	if (right - left > 1)
	{
		//根据Partion对数组进行划分--->小于div的位于div的左侧,大于等于div的位于div的右侧
		//主要划分方式有 hoare版本  挖坑法  前后指针法
		int div = Partion(array, left, right);

		QuickSort(array, left, div);

		QuickSort(array, div + 1, right);
	}
}
划分方法&基准值确定

上述说法中有两个点值得我们深究:

  1. 如何对数据划分
    首先,有三种比较常见的划分方式,分别是
    hoare版本
    挖坑法
    前后指针法

hoare版本

  1. 让begin从前向后遍历,找到大于基准值的元素停下来
  2. 让end从后向前遍历,找到小于基准值的元素停下来
  3. 将begin和end所指向的元素交换
  4. 循环1、2、3步,直到begin与end相遇,划分结束

代码展示

 //hoare版本  
 static int Partion1(int array[], int left, int right)
 {
	 int begin = left;
	 int end = right - 1;
	 int mid = GetMidPos(array, left, right);
	 if (mid != end)
	 {
		 Swap(&array[end], &array[mid]);
	 }
	
	 int key = array[end];
	 while (begin < end)
	 {
		 while (begin < end&& array[begin] <= key)
		 {
			 begin++;
		 }
		 while (begin<end && array[end]>= key)
		 {
			 end--;
		 }
		 if (begin < end)
		 {
			 Swap(&array[begin], &array[end]);
		 }
	 }
	 if (begin != right-1)
	 {
		 Swap(&array[begin], &array[right-1]);
	 }

	 return begin;
 }

挖坑法 主要思想与hoare版本相似
前提:将基准值保存到key中,此时数组基准值所在位置相当于可以占的“坑位”,用end标记

  1. begin从前向后遍历,找到大于基准值key的元素后停止,然后将该元素赋值给空着的坑位end,此时,begin所指的位置称为新的坑位
  2. end指针从后向前移动,遇到小于基准值key的元素后,将其赋值给新的坑位begin
  3. 循环执行上述操作,直到begin与end相遇时,将key值赋值到begin所在位置,一次划分结束

代码展示

//挖坑法
 static int Partion2(int array[], int left, int right)
 {
	 int begin = left;
	 int end = right - 1;
	 int mid = GetMidPos(array, left, right);
	 if (mid != end)
	 {
		 Swap(&array[end], &array[mid]);
	 }
	 int key = array[end];
	 while (begin<end)
	 {
		 while (begin < end && array[begin] <= key)
		 {
			 begin++;
		 }

		 if (begin < end)
		 {
			 array[end] = array[begin];
			 end--;
		 }

		 while (begin < end && array[end] >= key)
		 {
			 end--;
		 }
		 if (begin < end)
		 {
			 array[begin] = array[end];
			 begin++;
		 }
	 }
	 array[begin] = key;
	 return begin;
 }

前后指针法

//前后指针法
 static int Partion(int array[], int left, int right)
 {
	 int cur = left;
	 int prev = cur - 1;
	 int mid = GetMidPos(array, left, right);
	 if (mid != right - 1)
	 {
		 Swap(&array[mid], &array[right - 1]);
	 }

	 int key = array[right - 1];
	 while (cur < right)
	 {
		 if (array[cur] < key && ++prev != cur)
		 {
			 Swap(&array[cur], &array[prev]);
		 }
		 cur++;
	 }

	 if (++prev != right - 1)
	 {
		 Swap(&array[prev], &array[right - 1]);
	 }
	 return prev;
 }
  1. 过分过程中的基准值如何选取
    采用三数取中法
    即:取数组左右端点以及中间元素三者中值居中的作为基准值

注:一般情况下找到的基准值要是不在数组末尾,将基准值与末尾元素交换(确保基准值始终在数组的末尾)


//三数取中法确定基准值的下标
 static int GetMidPos(int array[], int left, int right)
{
	//返回左右端点与中间三个元素中值在中间的元素的下标
	int mid = left + ((right - left) >> 1);
	if (array[left] < array[right - 1])
	{
		if (array[left] <= array[mid])
		{
			return mid;
		}
		else if (array[mid] >= array[right - 1])
		{
			return right - 1;
		}
		else
		{
			return left;
		}
	}
	else  //array[left]>=array[right - 1]
	{
		if (array[left] < array[mid])
		{
			return left;
		}
		else if (array[right - 1]>array[mid])
		{
			return right - 1;
		}
		else
		{
			return mid;
		}
	}
}
非递归版本

主要思想

  1. 与递归的思路基本一致,只不过是采用栈这一数据结构来存储待处理元素的下标范围
  2. 先存储右区间,再存储左区间。每个区间先存储右边界,再存储左边界

代码实现

#include "Stack.h"
//非递归
void QuickSortNor(int array[], int size)
{
	int begin = 0;
	int end = size;

	Stack s;
	StackInit(&s);
	StackPush(&s, end);

	StackPush(&s, begin);

	while (!StackEmpty(&s))
	{
		begin = StackTop(&s);
		StackPop(&s);
		end = StackTop(&s);
		StackPop(&s);
		if (end - begin > 1)
		{
			//div将数组分为左右两部分
			int div = Partion(array, begin, end);

			//按照先处理左边后处理右边的思想将边界下标入栈
			//入栈顺序为先入右后入左
			StackPush(&s, end);
			StackPush(&s, div + 1);
			StackPush(&s, div);
			StackPush(&s, begin);
		}
	
	}
	//栈为空,表示排序完成,将栈销毁
	StackDestroy(&s);
}

复杂度分析

  1. 时间复杂度:O(NlogN)
  2. 空间复杂度:O(logN)

稳定性 : 不稳定

适用场景:接近有序 | 求数组中第k大(小)的数据

归并排序

递归

主要思想

  1. 均分 将待排序数组不断均分,分为左右两半部分,直到每一部分都是有序(仅有一个元素)
  2. 归并。情形:合并两个有序数组
  3. 将存储在临时数组中的元素copy至原数组

代码实现

//归并排序

static void MergeData(int array[], int left, int mid, int right, int temp[])
{
	int begin1 = left;
	int end1 = mid;
	int begin2 = mid;
	int end2 = right;
	int index = left;
	while (begin1 < end1 && begin2 < end2)
	{
		if (array[begin1] <= array[begin2])
		{
			temp[index++] = array[begin1++];
		}
		else
		{
			temp[index++] = array[begin2++];
		}
	}
	while (begin1 < end1)
	{
		temp[index++] = array[begin1++];
	}
	while (begin2 < end2)
	{
		temp[index++] = array[begin2++];
	}
}



static void _MergeSort(int array[], int left, int right,int temp[])
{
	int mid = left + ((right - left) >> 1);
	if (right - left > 1)
	{
		//分解
		_MergeSort(array, left, mid,temp);
		_MergeSort(array, mid, right,temp);

		//合并
		MergeData(array,left,mid,right,temp);

		memcpy(array + left, temp + left, (right - left)*sizeof(array[0]));
	}
}

//递归

void MergeSort(int array[], int size)
{
	int* temp = (int*)malloc(sizeof(int)*size);
	if (NULL == temp)
	{
		assert(0);
		return;
	}
	_MergeSort(array, 0, size, temp);
	free(temp);
}

非递归

主要思想

  1. 将待排序数组的每一个元素看做是一个独立的个体,那么他们每个都是有序的
  2. 对每一个部分直接归并,
    而直接归并分三步,分别是
    ①采用gap来标记每次每组参与归并的元素个数
    ②gap从1开始,每归并完成一次,gap*2
    ③直到gap的值大于数组元素个数size时,说明排序完成
    :随着gap的不断增大,归并函数中边界变量可能会越界,需要做合法性判断
  3. 每归并一次,将临时数组中的元素拷贝至原数组

代码实现


//非递归

void MergeSortNor(int array[], int size)
{
	int* temp = (int*)malloc(sizeof(array[0])*size);
	if (NULL == temp)
	{
		assert(0);
		return;
	}

	int gap = 1;
	while (gap < size)
	{
		for (int i = 0; i < size; i+= 2*gap)
		{
			int left = i;
			int mid = left + gap;
			int right = mid + gap;

			//mid 与right可能会越界,需要进行合法性检验
			if (mid > size)
			{
				mid = size;
			}
			if (right > size)
			{
				right = size;
			}
			//默认从一个元素为一组进行归并
			MergeData(array, left, mid, right, temp);
		}
		//将临时数组temp中的元素copy至array数组
		memcpy(array, temp, sizeof(array[0])*size);
		gap *= 2;
	}
	free(temp);
}

复杂度分析

  1. 时间复杂度:O(NlogN)
  2. 空间复杂度:O(N)

稳定性:稳定

适用场景 :数据量大,无法一次加载到内存 外部排序

计数排序

【数据结构】各大排序算法_第2张图片
动图展示

// 计数排序
void CountSort(int* array, int size)
{
	//1、确定辅助数组大小
	int maxValue = array[0];
	int minValue = array[0];
	for (int i = 1; i < size; i++)
	{
		if (array[i]>maxValue)
		{
			maxValue = array[i];
		}
		if (array[i] < minValue)
		{
			minValue = array[i];
		}
	}
	int len = maxValue - minValue + 1;
	int *count = (int*)malloc(sizeof(int)*len);
	if (NULL == count)
	{
		assert(0);
		return;
	}
	memset(count, 0, sizeof(int)*len);
	//2、遍历待排序数组,统计每个元素出现的次数
	for (int i = 0; i < size; i++)
	{
		count[array[i] - minValue]++;
	}
	//3、遍历辅助数组,按照数组值输出对应个数的元素
	int index = 0;

	for (int i = 0; i < len; i++)
	{
		while (count[i]--)
		{
			//printf("%d ", i + minValue);
		
			array[index++] = i + minValue;
		}
	}
	printf("\n");
	//4、释放辅助空间
	free(count);
}

基数排序(了解主要思想即可)

【数据结构】各大排序算法_第3张图片

LSD(动图展示)

排序算法复杂度&稳定性总结

【数据结构】各大排序算法_第4张图片

你可能感兴趣的:(数据结构,排序算法,数据结构,算法)