【数据结构】八种排序算法讲解(附自制动图)

文章目录

  • 插入排序
    • 直接插入排序
    • 希尔排序( 缩小增量排序 )
  • 选择排序
    • 直接选择排序
    • 堆排序
  • 交换排序
    • 冒泡排序
    • 快速排序(三种方法)
      • hoare法
      • 挖坑法
      • 前后指针法
    • 快速排序相关优化
      • 选基准优化
      • 减少递归优化
    • 非递归实现快速排序
  • 归并排序
    • 归并排序递归实现
    • 归并排序非递归实现
  • 非比较排序
    • 计数排序
  • 排序算法复杂度及稳定性分析
    • 直接插入排序
    • 希尔排序
    • 直接选择排序
    • 堆排序
    • 冒泡排序
    • 快速排序
    • 归并排序
    • 计数排序
  • 结束语

插入排序

插入排序的基本思想:把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。
实际中我们玩扑克牌时,就用了插入排序的思想:
【数据结构】八种排序算法讲解(附自制动图)_第1张图片

直接插入排序

直接插入排序顾名思义,就是每次选择一个没有排好的数字向有序序列中插入,当插入第i(i>=1)个元素时,前面的array[0],array[1],…,array[i-1]已经排好序,此时用array[i]的排序码与array[i-1],array[i-2],…的排序码顺序进行比较,找到插入位置即将array[i]插入,原来位置上的元素顺序后移,具体操作方式如图所示:

代码实现如下:

void InsertSort(int* a, int n)
{
	for (int i = 0; i < n - 1; i++)
	{
		int end = i;//假设原始数组为[0,end],然后将end+1位置的元素向前进行插入
		int tmp = a[end + 1];
		while (end >= 0)
		{
			if (tmp < a[end])
			{
				a[end + 1] = a[end];//排升序,如果end处数字比tmp大,就要继续向前找
				end--;
			}
			else
			{
				break;
			}
		}
		a[end + 1] = tmp;//否则就是找到地方了,把它放进腾出来的地方
	}
}

直接插入排序的特性总结:

  1. 元素集合越接近有序,直接插入排序算法的时间效率越高
  2. 时间复杂度:O(N^2)
  3. 空间复杂度:O(1)
  4. 稳定性:稳定

希尔排序( 缩小增量排序 )

希尔排序,也称递减增量排序算法,是插入排序的一种更高效的改进版本。但希尔排序是非稳定排序算法。
希尔排序是基于插入排序的以下两点性质而提出改进方法的:

①插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率;
②但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位;
希尔排序的基本思想是:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录"基本有序"时,再对全体记录进行依次直接插入排序。
具体操作如图所示:
第一趟:
【数据结构】八种排序算法讲解(附自制动图)_第2张图片

第二趟:
【数据结构】八种排序算法讲解(附自制动图)_第3张图片

第三趟:
【数据结构】八种排序算法讲解(附自制动图)_第4张图片

具体实现的代码如下:

void ShellSort(int* a, int n)
{
	int gap = n;
	//假设原始数组为[0,end],然后将end+1位置的元素向前进行插入,和直插思路一样
	while (gap > 1)
	{
		gap = gap / 2;//这里选择除以2或者除以3加1都是可以的这里选择除以2
		//其实直插就是把所有的gap变成1,这里gap不为1的所有趟都是在为最后一趟进行预排序
		for (int i = 0; i < n - gap; ++i)
		{
			int end = i;
			int tmp = a[end + gap];
			while (end >= 0)
			{
				if (a[end] > tmp)
				{
					a[end + gap] = a[end];
					end -= gap;
				}
				else
				{
					break;
				}
			}
			a[end + gap] = tmp;
		}
	}
}

希尔排序的特性总结:

  1. 希尔排序是对直接插入排序的优化。
  2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就
    会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
  3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些书中给出的
    希尔排序的时间复杂度都不固定:
    《数据结构-用面相对象方法与C++描述》— 殷人昆
    【数据结构】八种排序算法讲解(附自制动图)_第5张图片
    因为我们这的gap是按照Knuth提出的方式取值的,而且Knuth进行了大量的试验统计,我们暂时就按照:
    O(n1.25)到O(1.6*n1.25)来算。
  4. 稳定性:不稳定

选择排序

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

直接选择排序

直接选择排序的实现是在元素集合array[i]–array[n-1]中选择关键码最大(小)的数据元素,若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换在剩余的array[i]–array[n-2](array[i+1]–array[n-1])集合中,重复上述步骤,直到集合剩余1个元素时,排序结束。
下面是一趟简单选择排序的动图:
【数据结构】八种排序算法讲解(附自制动图)_第6张图片
一趟排序结束后,两个指针都回到起点,并将搜索范围减少一,找出新范围内的最大值,并放在数组最后。这个排序的思路非常简单,后续动图就不再画了.

此外,简单选择排序既然每次都要遍历整个数组,我们不如每趟都找出最大值和最小值,这样能一定程度上减少遍历次数。但是也要注意一点,遍历后我们找到了最小值与最大值,比如最大值为100,位于数组头,最小值为26,位于中间,此时我们将最小值放在数列头部(升序排列),最大值跑到了中间位置,这个时候我们再去找最大值就不对了,因此如果我们的最大值指向了我们存最小值的地方(max ==begin),那么我们就要将min处的值赋给max,(因为已经交换完了,min处是原来开头的值)这样就可以保证每次能将最大最小值放在数列首尾了。代码实现如下(已经优化过的):

void SelectSort(int* a, int n)
{
	int begin = 0, end = n - 1;
	while (begin < end)
	{
		int maxi = begin;
		int mini = begin;
		for (int i = begin + 1; i <= end; i++)
		{

			if (a[i] > a[maxi])
			{
				maxi = i;
			}
			if (a[i] < a[mini])
			{
				mini = i;
			}
		}

		Swap(&a[begin], &a[mini]);
		if (maxi == begin)
		{
			maxi = mini;
		}
		Swap(&a[end], &a[maxi]);
		++begin;
		--end;
	}
}

直接选择排序的特性总结:

  1. 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
  2. 时间复杂度:O(N^2)
  3. 空间复杂度:O(1)
  4. 稳定性:不稳定

堆排序

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

排序前要先建堆,建堆的知识想必大家都略知一二,这里不再赘述。
这里用数列:49,38,65,97,76,13,27来举例,建堆后如图所示:【数据结构】八种排序算法讲解(附自制动图)_第7张图片
堆排序的整体思路是,我们将建好的堆的堆顶元素与最后一个叶子结点进行交换,将堆的size减一(也就是最后一个数不再参与整体调整,又因为这里我们是用数组实现的堆,我们就直接将最大值放在了数组的最后面),然后对堆进行向下调整,然后重复上述操作,如图所示:
堆排序的实现代码如下:

void AdJustToBottom(int* a, int size, int parent)
{

	int maxChild = parent * 2 + 1;

	while (maxChild < size)
	{
		if (maxChild + 1 < size && a[maxChild] < a[maxChild + 1])
		{
			mxaChild++;
		}
		if (a[parent] < a[minChild])
		{
			Swap(&a[parent], &a[maxChild]);
			parent = minChild;
			maxChild = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

void HeapSort(int* a, int n)
{
	//向上调整建堆 时间复杂度O(NlogN)
	//for (int i = 1; i < a_size; i++)
	//{
	//	AdjustToTop(a, i);
	//}

	//向下调整建堆 时间复杂度O(N)
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
		AdJustToBottom(a, n, i);
	}

	//排升序用大顶堆,降序用小顶堆
	int i = 1;
	while (i < n)
	{
		Swap(&a[0], &a[n - i]);
		AdJustToBottom(a, n - i, 0);
		++i;
	}
}

建堆部分:我们先看主程序中包含向下调整函数的建堆循环,我们定义i的初值为(n-2)/2,这个位置对应的是整棵树的最后一个非叶子结点。因为我们这里是用一维数组来实现的堆,n个元素,那么最后一个元素的下标就是n-1,然后n-1位置的双亲结点的求法就是将其减去一再除以二,得到的结果就是双亲结点,而n-1是最后一个结点,他的双亲结点就是最后一个非叶子结点。从这个位置开始向下调整,然后向树的上面进行循环,直到根结点完成向下调整,建堆完毕。
排序部分:这里我们已经完成了建堆,假设我们排升序序列,建的是大顶堆,所以我们就将堆定元素a[0]与当前数组的最后一个元素(第一趟是a[n-1],后续就是a[n-i]了)进行交换,然后将数组规模减小一,而后对现在的数组,哦不,堆进行向下调整,因为它虽然堆顶不一定符合大顶堆要求,但是其左右子树都符合,所以调整起来最坏也就是O(logN),调整结束后,重复上述操作即可。
向下调整算法部分:这里我们接收到的参数分别是数组、数组大小以及我们要调整的根的位置。因为我们每次都要从根开始调整,所以parent参数始终是0。然后定义maxChild,我们先将他指向父亲结点的左侧,也就是默认左侧结点值较大,然后我们进行一次判断,如果右侧的值比左侧的大,那么我们就让maxChild++。在找到孩子里面哪个比较大之后,我们就可以开始调整工作了。如果在0这个位置父亲就和最大的孩子相等或者比maxChild大,那就直接可以结束循环,因为除了堆顶下面都符合大顶堆的条件;如果父亲结点比较小,那么就把他和较大的孩子进行交换,然后让原来孩子的位置变成双亲的位置,并通过公式求出新双亲的左孩子,循环此过程。
堆排序特性总结:

  1. 堆排序使用堆来选数,效率高了很多。
  2. 时间复杂度:O(N*logN)。共排序N个数,向下调整的时间复杂度为logN。
  3. 空间复杂度:O(1)
  4. 稳定性:不稳定

交换排序

交换排序的基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。主要包括冒泡排序和快速排序两种。

冒泡排序

冒泡排序(Bubble Sort)是一种简单直观的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢"浮"到数列的顶端。具体过程如图所示:

冒泡排序的代码实现如下:

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

在排序过程中,我们可以增加一个判断值,如果一趟下来没有发生交换,说明整个数组已经有序,那么此时就要跳出排序的循环。此方法能够提升排序的效率,但提升效果微乎其微。
冒泡排序的特性总结:

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

快速排序(三种方法)

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

void QuickSort(int* a, int begin, int end)
{
	if (begin >= end)
	{
		return;
	}
	//若数据量小可以直接换插排,减少递归
	//if (end - begin < 8)
	//{
	//	InsertSort(a + begin, end - begin + 1);
	//}
	int keyi = PartSort3(a, begin, end);
	QuickSort(a, begin, keyi - 1);
	QuickSort(a, keyi + 1, end);
}

不难发现,快排的递归实现方式和我们的二叉树遍历非常像。后面的主要工作就是如何实现每一趟的排序,这里介绍三种方法:

hoare法

这种方法的主要思想就是先将最左侧的数作为枢轴,然后先动右侧指针找到比枢轴小的数,然后动左侧指针找到比枢轴大的数,然后交换。如果两指针相遇,说明相遇处的左侧如果存在数字,那么已经全部比枢轴处的值要小;相遇处右侧如果存在数字,那么已经全部比枢轴处的值要大。此外因为我们先动右指针,所以相遇处的值也比枢轴要小(可以尝试思考一下为什么),因此我们直接将枢轴处的值与两指针相遇处的值进行交换,即可完成一趟排序,后续趟数经过递归也可以完成,实现代码如下:

//hoare
int PartSort1(int* a, int left, int right)
{
	//三数取中
	int mid = GetMidLocation(a, left, right);
	Swap(&a[mid], &a[left]);

	int guard = left;
	while (left < right)
	{
		while (left < right && a[right] >= a[guard])
		{
			right--;
		}
		while (left < right && a[left] <= a[guard])
		{
			left++;
		}
		Swap(&a[left],&a[right]);
	}

	Swap(&a[guard], &a[left]);
	int meet_location = left;

	return meet_location;
}

挖坑法


挖坑法顾名思义,就是先将基准值所在的位置挖一个“坑”,然后右侧指针左移找比基准值大的数,然后将它扔到左边的“坑”中,左侧指针右移找比基准值小的数,将它扔到右边的坑中,直到两指针相遇“坑中”,坑的左边就是比基准值要小的数,坑的右边就是比基准值大的数,将基准值放进坑中,即可完成一趟排序。实现代码如下:

//挖坑法
int PartSort2(int* a, int left, int right)
{
	//三数取中
	int mid = GetMidLocation(a, left, right);
	Swap(&a[mid], &a[left]);
	int key = a[left];
	int hole = left;

	while (left < right)
	{
		while (right > left && 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;
}

前后指针法


这里我们使用两个指针,开始时分别指向第一个元素和第二个元素,然后用cur指针去找比基准值小的元素,如果指向的元素比基准值要小,就让prev指针向后移动一格,然后判断prev指针和cur指针指向的是否是同一个元素。如果是,则不用进行值的交换(其实交不交换都一样),cur继续向后移动,如果指向的不是同一个元素,则交换两指针指向的元素后,cur继续向后移动;如果cur指针指向的元素比基准值要大,则不操作prev指针,单独使cur指针向后移动。当cur指针离开数组时,prev指向的元素就是当前数组中最靠右侧的、小于基准值的元素,此时将pivot指向的元素与prev指向的元素进行交换,即可完成一趟排序。我们还会发现,其实prev指针和cur指针之间的数据一直都是比基准值要大的数据,如果cur在后面找到小的了,那就让prev往后动一下,拿最靠近左侧的大于基准值的数去交换,交换后prev指向的仍然是最靠近右侧的、小于基准值的数据。具体实现代码如下:

//前后指针
int PartSort3(int* a, int left, int right)
{
	//三数取中
	int mid = GetMidLocation(a, left, right);
	Swap(&a[mid], &a[left]);

	int key = a[left];
	int prev = left;
	int cur = left + 1;

	while (cur <= right)
	{
		if (a[cur] < key && ++prev != cur)
		{
			Swap(&a[cur], &a[prev]);
		}
		cur++;
	}
	Swap(&a[prev], &a[left]);

	return prev;
}

快速排序相关优化

选基准优化

我们知道,快速排序的最差性能会在它排序有序序列或者接近有序的序列时出现,时间复杂度会达到O(n2)。这是因为我们总是从数列的一段选取基准,而这个基准就会使其他的所有数都分布在其的一侧,并不能将数列进行有效分割,自然也就不能体现出快速排序分治思想的优势。为解决这一问题,我们一般采用三数取中法或者随机数法来解决这个问题。

三数取中法顾名思义就是取出数列中头、尾以及位于中间的数,将他们三者进行比较,选取中间大小的数作为本趟的基准,实现代码如下:

int GetMidLocation(int* a, int left, int right)
{
	int mid = left + (left + right) / 2;
	if (a[mid] < a[left])
	{
		if (a[right] < a[mid])
		{
			return mid;
		}
		else if (a[right] > a[left])
		{
			return left;
		}
		else
		{
			return right;
		}
	}
	else  //(a[mid] > a[left])
	{
		if (a[right] > a[mid])
		{
			return mid;
		}
		else if (a[left] > a[right])
		{
			return left;
		}
		else
		{
			return right;
		}
	}
}

取随机数法就是将当前排序数组的左右界限作为随机数的界限,用随机数来确定数组中的某个位置,然后将其与最左侧数交换,并使其成为基准。实现代码如下:

int GetRandomLocation(int left, int right)
{
	return rand() % right + left;//调用rand函数需要在主函数中用srand来生成种子
}

减少递归优化

我们知道,作为一棵满二叉树其最后一层结点的数量要占到总结点数的百分之五十,而倒数三层的结点数加和要占到总数的百分之八十以上。但此时还未被排序的数列都是很短的区间,因为递归调用函数要进行压栈,继续使用递归会非常浪费内存,所以当待排序区间的大小小于一个定值时,我们将排序方式换为插排,能够一定程度上减少内存的开销:

void InsertSort(int* a, int n)
{
	for (int i = 0; i < n - 1; i++)
	{
		int end = i;//假设原始数组为[0,end],然后将end+1位置的元素向前进行插入
		int tmp = a[end + 1];
		while (end >= 0)
		{
			if (tmp < a[end])
			{
				a[end + 1] = a[end];//排升序,如果end处数字比tmp大,就要继续向前找
				end--;
			}
			else
			{
				break;
			}
		}
		a[end + 1] = tmp;//否则就是找到地方了,把它放进腾出来的地方
	}
}

void QuickSort(int* a, int begin, int end)
{
	if (begin >= end)
	{
		return;
	}
	//若数据量小可以直接换插排,减少递归
	if (end - begin < 8)
	{
		InsertSort(a + begin, end - begin + 1);
	}
	int keyi = PartSort1(a, begin, end);
	QuickSort(a, begin, keyi - 1);
	QuickSort(a, keyi + 1, end);
}

非递归实现快速排序

我们知道一般来说递归的程序想要用非递归来实现,一般都是通过循环,或者是借助其他的数据结构来实现的:比如斐波那契数列的非递归就是借助循环进行迭代,汉诺塔的非递归实现要借助栈,快速排序也不例外。这里我们也借助栈这个数据结构来实现非递归的快排。代码如下:

void QuickSortNonR(int* a, int begin, int end)
{
	Stack st;
	StackInit(&st);
	StackPush(&st, begin);
	StackPush(&st, end);

	while (!StackEmpty(&st))
	{
		int right = GetStackTop(&st);
		StackPop(&st);
		int left = GetStackTop(&st);
		StackPop(&st);
		int keyi = PartSort3(a, left, right);
		if (keyi + 1 < right)
		{
			StackPush(&st, keyi + 1);
			StackPush(&st, right);
		}
		if (left < keyi - 1)
		{
			StackPush(&st, left);
			StackPush(&st, keyi - 1);
		}
	}
	StackDestroy(&st);
}

可以看到,我们先将整个待排序数组的左右边界压入栈中,然后在栈内非空的条件下进行循环操作。从栈顶依次取出两个边界并对其进行排序(这里单趟排序我们选用前面三种中的一种即可),一趟排序结束后返回已经完成排序元素的位置,然后分别判断其左侧子区间和右侧子区间是否需要继续排序,(也就是判断区间长度是否大于1)如果需要我们就继续将其压栈。这里我们先判断右侧区间是否合法、然后再判断左侧区间是否合法的目的在于模拟真正递归时的排序顺序,我们先压入右区间,再压入左区间,左区间就在栈顶,我们下一步就会先排左区间,这和递归实现的顺序是一样的。(当然先排右区间也是可以的)栈中具体的操作流程如图所示:
注意,这里栈中标红的数字其实是已经出栈了的,这里我们为了观察更直观选择将其留在栈里。此外,因为我们的代码实现中加入了判断区间是否为有效区间(区间大小>1),因此我们没有将区间为1或是0或是-1的边界条件压入栈中,而在递归实现算法时这种情况是存在的,请大家注意。

快速排序特性总结:

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

归并排序

归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。 整个的排序过程如图所示:
【数据结构】八种排序算法讲解(附自制动图)_第8张图片
看到这张图,我们的第一反应就是,这个排序算法应该是用递归实现的吧?没错!但这里的递归类似于二叉树的后序遍历,我们要先递到最深处,然后在归的同时完成排序。归并排序主程序的代码如下:

归并排序递归实现

void MergeSort(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc failed");
		exit(-1);
	}
	_MergeSort(a, 0, n - 1, tmp);

	free(tmp);
	tmp = NULL;
}

我们看到,在排序之前我们就申请了一块与待排序数组大小相同的一块内存,因此可以知道归并排序的空间复杂度应该是O(n),这是因为归并排序需要合并两个数组,这个步骤需要额外的空间来进行。主程序我们看不出什么,接下来看子程序,也就是实现排序的程序:

void _MergeSort(int* a, int begin, int end, int* tmp)
{
	if (begin >= end)
	{
		return;
	}
	int mid = (begin + end) / 2;//找到当前数组的中间

	_MergeSort(a, begin, mid, tmp);//向两侧递归
	_MergeSort(a, mid + 1, end, tmp);

	int begin1 = begin;//能有函数到这里说明递归向下到头了,从这里开始向上合并数组
	int end1 = mid;//仍然是左右两个子数组,然后像插入链表一样,找较小的数尾插(升序)
	int begin2 = mid + 1;
	int end2 = end;

	int i = begin;
	while (begin1 <= end1 && begin2 <= end2)
	{
		if (a[begin1] <= a[begin2])
		{
			tmp[i++] = a[begin1++];
		}
		else
		{
			tmp[i++] = a[begin2++];
		}
	}
	while (begin1 <= end1)//到这里说明有一个数组到头了,将没有遍历完的数组按序尾插
	{
		tmp[i++] = a[begin1++];
	}
	while (begin2 <= end2)
	{
		tmp[i++] = a[begin2++];
	}
	memcpy(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));//最后将排好的片段拷回原数组
}

通过代码可以看出,在递归深度达到最大后我们开始向上合并。先定义好左右区间,然后比较两个数组中最靠前的数,哪个小(升序),就把他尾插临时分配的数组中,直到有一个数组被全部遍历,将还有剩余元素的数组直接放到malloc数组剩余的位置处。动态图如下所示:

归并排序非递归实现

同样,归并排序也能够使用非递归方法实现。有的老铁可能会说,归并排序的思路也是分治思想,那是不是也可以使用栈来实现呢?答案是肯定的。但这里我们选择一种更加便捷的方法,有些类似于希尔排序中的跳步排序的思路,我们来看具体实现:

void MergeSortNonR(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc failed");
		exit(-1);
	}
	int gap = 1;
	while (gap < n)
	{
		for (int j = 0; j < n; j += 2 * gap)
		{
			int begin1 = j;
			int end1 = j + gap - 1;
			int begin2 = j + gap;
			int end2 = j + 2 * gap - 1;
			if (end1 >= n)
			{
				break;
			}
			if (begin2 >= n)
			{
				break;
			}
			if (end2 >= n)
			{
				end2 = n - 1;
			}
			int i = j;
			while (begin1 <= end1 && begin2 <= end2)
			{
				if (a[begin1] <= a[begin2])
				{
					tmp[i++] = a[begin1++];
				}
				else
				{
					tmp[i++] = a[begin2++];
				}
			}
			while (begin1 <= end1)
			{
				tmp[i++] = a[begin1++];
			}
			while (begin2 <= end2)
			{
				tmp[i++] = a[begin2++];
			}
			memcpy(a + j, tmp + j, sizeof(int) * (end2 - j + 1));
		}
		gap *= 2;
	}
	free(tmp);
	tmp = NULL;
}

我们同样也是先申请数组大小n的额外空间以用来拷贝,然后正片开始:
直接在数组中进行操作,我们可以通过控制gap直接省去了向下递归的步骤并直接开始往回并,因为gap为1的时候,就相当于数组被拆成了n份,随着gap不断乘2,数组也就在不断的归并。
与此同时,非递归的实现方法与递归方法的不同主要就是我们需要手动的去控制边界,因为我们的左右区间的始末都是通过gap来进行控制的,所以只有在数组元素个数为2n时才不会出现越界的情况,其他时候都会有越界的情况,看下面几个例子:
在这里插入图片描述
①当数组元素个数为2的n时不会越界。
【数据结构】八种排序算法讲解(附自制动图)_第9张图片
②这里测试的是元素个数为九个的时候,变量j最后会走到的位置。九个元素的最大下标应该是8,所以图中所有的大于8的数全都是越界的。不难看出越界共包含三种情况。

  1. 第二组整体越界(也就是end1不越界,begin2越界),比如第一行最后的[9,9]
  2. 第二组部分越界(也就是begin2不越界,end2越界),比如第四行的[8,15]
  3. 第一组end1越界,比如比如第二行的 [8,9] [10,11]

根据不同的情况我们要制定相应对策:
第二组部分越界:将end2指向数组最后一个位置
如果只是end2越界,说明begin2后面是有可能存在需要归并的数据的,所以此时无法直接break,要对于end2进行边界调整,使其指向n-1位置,防止丢失待排数据。
第一组end1越界:直接break。
我们是在归并两组数据的时候发现第一组最后的边界已然越界,那第二组必然是全部越界的,所以此次循环中的这组归并就应该直接停止,后续归并就会在合法归并或者break的交替进行下走向只有end2越界,最后修改一下end2就ok了。
第二组全部越界:直接break
第二组全部越界和end1越界有些类似,都是发现这组递归没有进行下去的必要了,通过后续循环使gap是半个数组大小时直接修改end2即可。

此外,细心的你可能还会发现,这种break或者修改end2的方法需要修改回拷规则,因为我们选择暴力的break后往原数组进行拷贝时,tmp数组中必然是存在随机数的(没有归回来的位置),所以我们拷贝时就不能进行全部拷贝,需要排一段拷贝一段,所以memcpy的参数进行了改动。(j是待合并的两个数组的头,end2是尾。)
到此为止,归并排序的非递归 方式也陈述完了
归并排序的特性总结:
① 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
② 时间复杂度:O(N*logN)
③ 空间复杂度:O(N)
④ 稳定性:稳定

非比较排序

非比较排序有很多,这里我们选择计数排序来进行讲解。

计数排序

计数排序又称为鸽巢原理,是对哈希的直接定址法的变形应用。 操作步骤:

  1. 统计相同元素出现次数
  2. 根据统计的结果将序列回收到原来的序列中

具体实现代码如下:

void CountSort(int* a, int n)
{
	int max = a[0];
	int min = 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* CountA = (int*)calloc(range,sizeof(int));
	if (CountA == NULL)
	{
		perror("malloc failed");
		exit(-1);
	}

	for (int j = 0; j < n; j++)
	{
		CountA[a[j] - min]++;
	}
	int i = 0;
	for (int j = 0; j < range; j++)
	{
		while (CountA[j]--)
		{
			a[i] = j + min;
			i++;
		}
	}
}

代码很好理解,操作步骤如下:

  1. 我们先遍历数组,找到数组的极差。
  2. 然后根据极差创建出相应的存储各数据出现次数的数组
  3. 为了能够使整个数组中的最小的数存在计数数组的“0”位置处,要将待排数组中的数据减去他们的最小值并将得到的值作为索引,在计数数组的对应位置进行计数,(++)
  4. 然后我们遍历计数数组,如果计数数组对应索引不是0,我们就将其减一,然后用该处索引加上当初我们减掉的最小值覆盖到原数组中,最后结束时计数数组中的全部元素应该都为-1,而待排数组已然有序。

计数排序的特性总结:
①计数排序在数据范围集中时,效率很高,但是适用范围及场景有限,比如无法排浮点数或是字符串
② 时间复杂度:O(MAX(N,范围))
③ 空间复杂度:O(范围)
④ 稳定性:稳定

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

先贴一张思维导图:【数据结构】八种排序算法讲解(附自制动图)_第10张图片复杂度分析:

直接插入排序

时间复杂度:O(N2)
空间复杂度:O(1)
稳定性:稳定

直接插入排序的时间复杂度为O(n2),不难理解,我们共进行插入操作n次,平均下来每次需要向前查找n/2次,所以平均的时间复杂度就是O(n2)。但是当数据基本有序的时候,直接插入排序的时间复杂度就会大大提升变为O(n),所以直插排序在众多时间复杂度为O(n2)的排序算法中算是优秀的一员了。

直接插入排序并没有借助额外的辅助空间,因此空间复杂的为O(1)。

直接插入排序是一种稳定的算法,当拿到元素向前进行比较的时候遇到和自己相同的放到其后面就可保证其稳定性。

希尔排序

时间复杂度:约为O(N1.3)
空间复杂度:O(1)
稳定性:不稳定

希尔排序的时间复杂度不是你我等凡人能够随便求的,听大佬的就完事了。

希尔排序并没有借助额外的辅助空间,因此空间复杂的为O(1)。

尽管希尔排序的多趟预排和最后一趟排序都是插排,但也正是因为希尔排序中预排序的存在,相同的数据可能被分到不同的组里,这时候他们两个的相对位置就不再可控了。

直接选择排序

时间复杂度:O(N2)
空间复杂度:O(1)
稳定性:不稳定

这种辣鸡算法的时间复杂度和数据顺序无关,铁fw一个,稳稳地O(n2)。

直接选择排序并没有借助额外的辅助空间,因此空间复杂的为O(1)。

直接选择排序看似稳定,实则一点不稳,上例子:【数据结构】八种排序算法讲解(附自制动图)_第11张图片
不多说了,都在图里。

堆排序

时间复杂度:O(NlogN)
空间复杂度:O(1)
稳定性:不稳定

堆排序的时间复杂度也很优秀,只有O(NlogN)。同样也不难理解,我们前面讲到了,每次向下调整的时间复杂度是logN,共重复N次,二者相乘得到时间复杂度。

堆排序也是原地排序,没有额外的空间开销,空间复杂度为O(1)。

堆排序在每次向下调整的时候相等数字之间的顺序是不可控的,因此堆排序不具有稳定性。

冒泡排序

时间复杂度:O(N2)
空间复杂度:O(1)
稳定性:稳定

冒泡排序是我们的老朋友了,他虽然也很捞,但是也是最基础的排序算法之一。它每次对于未排列部分进行遍历(平均n/2),每次将一个数字放在他最后的位置上(n),妥妥的O(N2),尽管加个flag能稍微优化一下,但是优化效果微乎其微。

冒泡排序并没有借助额外的辅助空间,因此空间复杂的为O(1)。

冒泡排序稳如老狗,在往前冒泡或者往后冒泡的过程中遇到相等的不要越过,停下就好。

快速排序

时间复杂度:O(NlogN)
空间复杂度:O(logN)
稳定性:不稳定

快速排序敢叫这个名字他自己是有底气的,并且他也确实是常见算法中综合性能最优的,尽管在遇到有序或者基本有序的数组时会降低效率,但我们也可以通过三数取中或者随机取数来规避这种情况。快速排序共进行N趟,每趟进行对数次交换,因此其平均情况下时间复杂度为O(NlogN)。

关于空间复杂度,我们在前面实现的时候应该就能看出些端倪。快速排序要么用递归实现,要么用栈实现,必然是要借助一定的额外空间的。但是层数不会很多,他只是将整个区间进行不断的二分,所以空间复杂度就是需要的层数,也就是O(logN)。

快速排序是不稳定的,主要就是因为基准本来在数组头,往回放的时候可能本来在自己后面的数字跑到自己前面去了,(因为和基准相等的数是不会被挪动的)所以它不稳定。据说有论文中将快排改成了稳定的算法,但是会牺牲一定的排序效率。这个我觉得看看就好,毕竟是叫快排,牺牲效率多少有些毁名声。

归并排序

时间复杂度:O(NlogN)
空间复杂度:O(N)
稳定性:稳定

归并排序也是使用分治思想,时间复杂度里的N来自于每趟的元素遍历,logN来自于N个元素就需要排logN趟(分治),所以时间复杂度为O(NlogN)。

空间复杂度也很好理解,因为归并排序需要一个额外的数组存放全部数据。

关于稳定性,我们只需要要求当归并遇到相等的值的时候,统一从左边区间取元素即可。

计数排序

时间复杂度:O(MAX(N,范围))
空间复杂度:O(范围)
稳定性:稳定

时间复杂度看代码就能一目了然了,遍历原数组是O(N),遍历计数数组是O(范围),所以最后的时间复杂度就是O(MAX(N,范围))。

空间复杂度就是我们申请的计数数组的大小,所以计数排序适合数据较集中的数组进行排序,跨度较大或者存在离群点的数据会造成很大的空间浪费。

计数排序是稳定的,具有相同值的元素在输出数组中的相对次序与它们在输入数组中的相对次序是相同的。 也就是说,对两个相同的数来说,在输入数组中先出现的数,在输出数组中也位于前面。

结束语

以上就是关于八种排序算法的讲解,动图做的实属不易,还请大家多多点赞。如文章有不足或遗漏之处还请大家指正,笔者感激不尽;同时也欢迎大家在评论区进行讨论,一起学习,共同进步!

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