【探索排序算法的魅力:优化、性能与实用技巧】

本章重点

  1. 排序的概念及其运用

  2. 常见排序算法的实现

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

1.排序的概念及其运用

1.1排序的概念

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

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

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

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

1.2排序运用

1.3 常见的排序算法

2.常见排序算法的实现

2.1 插入排序

2.1.1基本思想:

直接插入排序是一种简单的插入排序法,其基本思想是:把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。

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

2.1.2直接插入排序:

        当插入第i(i>=1)个元素时,前面的array[0],array[1],…,array[i-1]已经排好序,此时用array[i]的排序码与 array[i-1],array[i-2],…的排序码顺序进行比较,找到插入位置即将array[i]插入,原来位置上的元素顺序后移。

插入过程:如果end+1位置的值小于end位置,就将end位置的值挪到end+1位置处,否则,直接插入到end+1位置。注意,这里需要保存end+1位置的值,因为end位置的值挪到end+1位置时,会覆盖end+1位置的值。

结束条件:当end+1位置的值小于当前有序序列所有的值时,此时end的值为-1,也就是排序的终止条件

单趟插入排序代码:单趟[0,end]有序,把end+1位置的值插入到前面序列,控制[0,end+1]序列有序。

// 插入排序
void InsertSort(int* a, int n)
{
	int end = ;
	//保存要排序的值,防止数据被覆盖找不到数据
	int temp = a[end + 1];
	while (end >= 0)
	{
		if (temp < a[end])
		{
			a[end + 1] = a[end];
		}
		else
		{
			break;
		}
	}
	//将temp的值赋给end+1位置,这样就插入有序了
	a[end + 1] = temp;
}

整趟插入排序:我们默认第一个数有序,第二个数插入到有序序列中,调整有序,那么end就是0,如下图,当最后一个元素插入有序系列中,此时end的值时n-2。

#include <stdio.h>
// 插入排序
void InsertSort(int* a, int n)
{
	for (int i = 0; i < n - 1; i++)
	{
		int end = i;
		//保存要排序的值,防止数据被覆盖找不到数据
		int temp = a[end + 1];
		while (end >= 0)
		{
			if (temp < a[end])
			{
				a[end + 1] = a[end];
			}
			else
			{
				break;
			}
			end--;
		}
		//将temp的值赋给end+1位置,这样就插入有序了
		a[end + 1] = temp;
	}
}
int main()
{
	int a[] = { 1,4,2,7,6,9 };
	InsertSort(a, 6);
	for (int i = 0; i < 6; i++)
	{
		printf("%d ", a[i]);
	}
	return 0;
}

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

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

2. 时间复杂度:O(N^2)

3. 空间复杂度:O(1),它是一种稳定的排序算法

4. 稳定性:稳定

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

希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数,把待排序文件中所有记录分成个组,所有距离为的记录分在同一组内,并对每一组内的记录进行排序。然后,取,重复上述分组和排序的工作。当到达=1时,所有记录在统一组内排好序。

思想:

  1. 预排序:间接为gap为一组进行排序
  2. 直接插入排序

单趟排序:将gap为一组的数据进行排序,希尔排序和上面的直接插入排序方法相同,只是希尔排序是将gap间距的数据进行直接插入排序,若gap==1时,希尔排序和直接插入排序相同。

// 希尔排序
void ShellSort(int* a, int n)
{
	int gap = 5;
	for (int i = 0; i < n - gap; i+=gap)
	{
		int end = i;
		int temp = a[end + gap];
		while (end >= 0)
		{
			if (a[end] > temp)
			{
				a[end + gap] = a[end];
			}
			else
			{
				break;
			}
			end -= gap;
		}
		a[end + gap] = temp;
	}
}

多趟排序:通过gap将一组数据分割,依次将分割的数据直接插入排序,每组的数据都将有序。

// 希尔排序
void ShellSort(int* a, int n)
{
	int gap = 5;
	for (int j = 0; j < gap; j++)
	{
		for (int i = j; i < n - gap; i += gap)
		{
			int end = i;
			int temp = a[end + gap];
			while (end >= 0)
			{
				if (a[end] > temp)
				{
					a[end + gap] = a[end];
				}
				else
				{
					break;
				}
				end -= gap;
			}
			a[end + gap] = temp;
		}
	}
}

上面的代码将gap组数据进行直接插入排序,是一组数据排完后再排下一组数据,下面我们来看一个更厉害的代码,它在上面的基础上省略了一个for循环。

// 希尔排序
void ShellSort(int* a, int n)
{
	int gap = 5;
	for (int i = 0; i < n - gap; i++)
	{
		int end = i;
		int temp = a[end + gap];
		while (end >= 0)
		{
			if (a[end] > temp)
			{
				a[end + gap] = a[end];
			}
			else
			{
				break;
			}
			end -= gap;
		}
		a[end + gap] = temp;
	}
}

        上面的代码不是一组数据排完后再排下一组数据,它不用跳到下一组数据排序,而是不跨组排序,多组并排,比刚刚的代码更简洁。经过上面的排序,我们只是使得每组数据有序,并没有使得全部的数据有序。上面的排序使得大的数据更快的移动到后面,小的数据更快的移动到前面,gap越大跳的越快,越不接近有序,gap越小跳的越慢,越接近有序。gap为1,该数据就有序了。所有要想数据有序,就要让gap为1。gap>1时就是预排序,gap==1时就是有序。

// 希尔排序
void ShellSort(int* a, int n)
{
	int gap = n;
	while (gap > 1)
	{
		gap /= 2;//gap等于1,数据有序

		for (int i = 0; i < n - gap; i++)
		{
			int end = i;
			int temp = a[end + gap];
			while (end >= 0)
			{
				if (a[end] > temp)
				{
					a[end + gap] = a[end];
				}
				else
				{
					break;
				}
				end -= gap;
			}
			a[end + gap] = temp;
		}
	}
}
int main()
{
	int a[] = { 1,4,2,7,6,9 };
	ShellSort(a, 6);
	for (int i = 0; i < 6; i++)
	{
		printf("%d ", a[i]);
	}
	return 0;
}

我们上面定义的gap是每次除2,这样排序的次数还是有点多,所以我们定义的gap是每次除3,但是除3不一定能保证最后一次是1,比如gap为8,除3是2,再除3就是0,所以我们只需要除3后加1就可以满足最后一次gap为0。

// 希尔排序
void ShellSort(int* a, int n)
{
	int gap = n;
	while (gap > 1)
	{
		gap = gap / 3 + 1;//gap等于1,数据有序

		for (int i = 0; i < n - gap; i++)
		{
			int end = i;
			int temp = a[end + gap];
			while (end >= 0)
			{
				if (a[end] > temp)
				{
					a[end + gap] = a[end];
				}
				else
				{
					break;
				}
				end -= gap;
			}
			a[end + gap] = temp;
		}
	}
}

希尔排序的特性总结:

  1. 希尔排序是对直接插入排序优化
  2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
  3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些树中给出的希尔排序的时间复杂度都不固定:因为咋们的gap是按照Knuth提出的方式取值的,而且Knuth进行了大量的试验统计,我们暂时就按照:

2.2 选择排序

2.2.1基本思想

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

2.2.2 直接选择排序

  • 在元素集合array[i]–array[n-1]中选择关键码最大(小)的数据元素
  • 若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换
  • 在剩余的array[i]–array[n-2](array[i+1]–array[n-1])集合中,重复上述步骤,直到集合剩余1个元素

// 选择排序
void SelectSort(int* a, int n)
{
	for (int i = 0; i < n - 1; i++)
	{
		//假设下标为0的数据最小
        int mini = i;
		for (int j = mini + 1; j < n; j++)
		{
			if (a[mini] > a[j])
			{
				mini = j;
			}
		}
		//此时a[mini]是数组中最小的值
		Swap(&a[i], &a[mini]);
	}
}

但是上面的每次都是找最小值,然后再放到正确的位置,效率太低,我们是不是可以每次找出一个最大值,再找出一个最小值,然后各自放到正确的位置上,这样效率就提高了很多。

// 选择排序
void SelectSort(int* a, int n)
{
	int begin = 0;
	int end = n - 1;
	while(begin < end)
	{
		int mini = begin;
		int maxi = begin;
		for (int i = begin + 1; i <= end; i++)
		{
			if (a[mini] > a[i])
			{
				mini = i;
			}
			if (a[maxi] < a[i])
			{
				maxi = i;
			}
		}
		//此时a[mini]是数组中最小的值
		//此时a[maxi]是数组中最大的值
		Swap(&a[begin], &a[mini]);
		Swap(&a[end], &a[maxi]);
		begin++;
		end--;
	}
}

为什么结果不对呢?我们上面的代码出现了什么问题?

很明显,按照上图的情况,maxi和begin下标相同,当begin和minx下标的数据交换之后,maxi的下标的值就发生变化了,此时end和maxi交换就不正确了,所以上面的程序是有问题的。我们只需要更新一下maxi的位置就行啦。

void Swap(int* x, int* y)
{
	int tmp = *x;
	*x = *y;
	*y = tmp;
}
// 选择排序
void SelectSort(int* a, int n)
{
	int begin = 0;
	int end = n - 1;
	while(begin < end)
	{
		int mini = begin;
		int maxi = begin;
		for (int i = begin + 1; i <= end; i++)
		{
			if (a[mini] > a[i])
			{
				mini = i;
			}
			if (a[maxi] < a[i])
			{
				maxi = i;
			}
		}
		//此时a[mini]是数组中最小的值
		//此时a[maxi]是数组中最大的值
		Swap(&a[begin], &a[mini]);
		//maxi如果被换走就要修正一下
		if (maxi == begin)
			maxi = mini;
		Swap(&a[end], &a[maxi]);
		begin++;
		end--;
	}
}
int main()
{
	int a[] = { 9,1,2,5,7,4,8,6,3,5,1,2,3,5,1,8,3 };
	SelectSort(a, 17);
	for (int i = 0; i < 17; i++)
	{
		printf("%d ", a[i]);
	}
}

2.2.3 堆排序

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

       所以我们可以建大堆,将堆顶的数据和最后一个叶子结点交换,由于此时的堆结构没有破坏,左子树和右子树仍然是堆,使用堆的向下调整去调整堆,然后在缩小下次向下调整的范围,也就是把最大的那个数不算做堆的范围了,这样最大的数据就保存在了下标最大的位置处,满足了升序的要求。

void Swap(int* x, int* y)
{
	int tmp = *x;
	*x = *y;
	*y = tmp;
}
// 堆排序
void AdjustDown(int* a, int n, int parent)
{
	int child = parent * 2 + 1;
	while (child < n)
	{
		if (child + 1 < n && a[child] < a[child+1])
		{
			child++;
		}
		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);
	}
	
	int end = n - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);
		AdjustDown(a, end, 0);
		end--;
	}
}

堆排序的特性总结:

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

2.3 交换排序

2.3.1基本思想

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

2.3.2冒泡排序

冒泡排序是数据两两比较,将小的交换到前面,每趟排序将最大的排序到最后面,假设有n个数据,只用进行n-1趟排序,同时我们还要注意每趟排序的比较次数是不同的,第一轮,所有数据都要排序,就是n-1轮比较,第二轮比较的时候,最大的数据已经到了正确的位置,所以第二轮就只有n-2个数据比较。

void Swap(int* x, int* y)
{
	int tmp = *x;
	*x = *y;
	*y = tmp;
}
//冒泡排序
void BubbleSort(int* a, int n)
{
	for (int i = 0; i < n - 1; i++)//比较的趟数
	{
		for (int j = 0; j < n - i - 1; j++)
		{
			if (a[j] > a[j + 1])
			{
				Swap(&a[j], &a[j + 1]);
			}
		}
	}
}

上面的代码就是我们的冒泡排序,但是如果经过第一轮交换后,数据就已经有序了,但是我们的程序还在上面一轮一轮比较,这样效率较低,我们可以设置一个标志,如果经过一轮排序没有数据交换,那就说明数据已经有序了。

void Swap(int* x, int* y)
{
	int tmp = *x;
	*x = *y;
	*y = tmp;
}
//冒泡排序
void BubbleSort(int* a, int n)
{
	for (int i = 0; i < n - 1 ; i++)//比较的趟数
	{
		int flag = 1;//默认数据有序
		for (int j = 0; j < n - i - 1; j++)
		{
			if (a[j] > a[j + 1])
			{
				Swap(&a[j], &a[j + 1]);
				flag = 0;//数据无序
			}
		}
		if (flag == 1)
			break;
	}
}

冒泡排序的特性总结:

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

2.3.2快速排序

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

// 假设按照升序对array数组中[left, right)区间中的元素进行排序
void QuickSort(int array[], int left, int right)
{
	//= 代表只剩下一个值
    if (left >= right)
		return;

	// 按照基准值对array数组的 [left, right)区间中的元素进行划分
	int div = PartSort(array, left, right);

	// 划分成功后以div为边界形成了左右两部分 [left, div-1) 和 [div+1, right)
	// 递归排[left, div-1)
	QuickSort(array, left, div-1);

	// 递归排[div+1, right)
	QuickSort(array, div + 1, right);
}

上述为快速排序递归实现的主框架,发现与二叉树前序遍历规则非常像,同学们在写递归框架时可想想二叉树前序遍历规则即可快速写出来,后序只需分析如何按照基准值来对区间中数据进行划分的方式即可。

单趟排序

//1. hoare版本
int PartSort(int* a, int left, int right)
{
	int key = a[left];
	while (left < right)
	{
		//右边找小
		while (a[right] > key)
		{
			right--;
		}
		//左边找大
		while (a[left] < key)
		{
			left++;
		}
		Swap(&a[left], &a[right]);
	}
	//key和相遇位置交换
	Swap(&key, &a[left]);
	return left;
}

我们看看上面的代码有问题没?我们来看看下面的图。

很明显,根据我们上面的代码,当key和a[left]、a[right]相等的时候,程序是不是就卡死啦。所以我们需要修改上面的循环条件key和a[left]、a[right]相等的时候,left和right的值都要改变。

//1. hoare版本
int PartSort(int* a, int left, int right)
{
	int key = a[left];
	while (left < right)
	{
		//右边找小
		while (a[right] >= key)
		{
			right--;
		}
		//左边找大
		while (a[left] <= key)
		{
			left++;
		}
		Swap(&a[left], &a[right]);
	}
	//key和相遇位置交换
	Swap(&key, &a[left]);
	return left;
}

我们再看看上面的代码还有问题没?我们来看看下面的图。

如果a[right]的值都大于key,那么right会一直减,最后就会越界的问题;同样a[left]的值都大于key,那么left会一直加,最后就会越界的问题。

//1. hoare版本
int PartSort(int* a, int left, int right)
{
	int key = a[left];
	while (left < right)
	{
		//右边找小
		while (left < right && a[right] >= key)
		{
			right--;
		}
		//左边找大
		while (left < right && a[left] <= key)
		{
			left++;
		}
		Swap(&a[left], &a[right]);
	}
	//key和相遇位置交换
	Swap(&key, &a[left]);
	return left;
}

我们再看看上面的代码还有问题没?我们来看看下面的图。

当最后right和left相遇的时候,此时执行Swap(&key, &a[left]),但是只是和key这个局部变量交换,并不是和数组里面的内容交换,数组里面的那个key值元素没有变化,我们可以通过下标去实现数组内容的交换。

//1. hoare版本
int PartSort(int* a, int left, int right)
{
	int keyi = left;
	while (left < right)
	{
		//右边找小
		while (left < right && a[right] >= a[keyi])
		{
			right--;
		}
		//左边找大
		while (left < right && a[left] <=  a[keyi])
		{
			left++;
		}
		Swap(&a[left], &a[right]);
	}
	//key和相遇位置交换
	Swap(&a[keyi], &a[left]);
	return left;
}

这里有一个疑问?为什么相遇的地方的值比key值一定小呢?怎么做到的呢???右边先走才能做到相遇的地方的值比key值一定小。

相遇情况:

  1. 如果左边先走,相遇位置是R的位置,L和R在上一轮交换过,交换后R的位置一定比key大,此时和key交换没有意义。
  2. 如果R先走找到比key小的值停下来了,然后L再走,找比key大的值,没有找到和R相遇了,相遇位置比key小,交换之后满足左边值比key小,右边比key大。

上面的hoare版本有很多的坑,下面我们来换一种写法:挖坑法

//挖坑法
int PartSort1(int* a,int left,int right)
{
	int key = a[left];//保存key值,形成第一个坑位
	int hole = left;
	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;
	}
	//相遇的位置放key
	a[hole] = key;

	return hole;
}

除了上面的这种写法,我们还有一张前后指针法。cur找小,找到之后让prev加加,再交换cur和prev处的值,prev在这里有两种情况,在cur还没遇到比key大的值得时候,此时prev紧跟cur,在cur遇到比key大的值的时候,prev在比key大的一组值得前面。本质是:把一段大于key的区间往右推,同时把小的甩到左边。

//前后指针版本     
int PartSort(int* a,int left,int right)
{
	int cur = left + 1;
	int prev = left;
	int keyi = left;
	while(cur < right + 1)
	{
		if(a[cur] < a[keyi] && cur != prev)
		{
			Swap(&a[cur],&a[++prev]);
		}
		cur++;
	} 
	Swap(&a[keyi],&a[prev]);

	return prev;
}

其实我们上面的三种快速排序遇到有一种情况就会对时间的消耗巨大,在利扣上运行就会出现超出时间限制的错误,什么情况呢?如果我们要排序的数是一组数据量极大的重复数字,此时的三数取中就没有任何意义,且上面三个版本的写法都不能处理这个问题,拿上面的hoare版本来说,如果是一组重复数据,hoare每次都是从右边先开始以此往左边找比keyi小的值,但是此时数组中的值都是一样的,找不到比keyi小的值,只能keyi就只能和right交换,我们想想,如果是一组数据重复极大的值,每次都要这样,消耗的时间是非常巨大的。因此这里可以使用第四种写法,三路划分:把小于key往左推,等于key换到中间,大于key的往右推。

三路划分:

  • 1.cur小于key时,交换cur和left位置的值,然后再++cur,++left。
  • 2.cur等于key时,直接++cur。
  • 3.cur大于key时,交换cur和right位置的值,这里不能直接++cur,因为交换cur和right位置的值之后,此时不清楚cur位置与key位置值得大小关系,需要先–right,然后再比较cur位置与key位置值得大小关系。

结束条件:cur > right

根据上面的算法,要排序的数是一组数据量极大的重复数字,此时就是上面的第二种写法,此时一直++cur就可以排序好这个数组,时间复杂度就是O(N)。

void PartSort(int *a,int left,int right)
{
	if(left >= left)
		return;
		
	// 三数取中 
	int midi = GetMidi(a,left,right);
	Swap(&a[midi],&a[left]);
	
	int begin = left;
	int end = right;
	
	int key = left;
	int cur = left;
	while(cur <= right)
	{
		if(a[key] > a[cur])
		{
			Swap(&a[key],&a[left]);
			++cur;
			++left;
		}
		else if(a[key == a[cur])
		{
			++cur;
		}
		else
		{
			Swap(&a[key],&a[right]);
			--right;
		}
	}
	// [begin,left-1][left,right][right+1,end]
	PartSort(a,begin,left-1);
	PartSort(a,right+1,end);
}

我们现在再来想一下快排的效率,当数组的数据每次交换后,key就是中间位置,那么此时的时间复杂度就是O(N*logN),但是当数组数据有序的时候,key每次就是第一个位置,那么此时的时间复杂度就是O(N^2),那么此时怎么优化呢???

1. 三数取中法选key

2. 递归到小的子区间时,可以考虑使用插入排序

三数取中法选key:有了三数取中,快排就不会出现最坏情况。

//三数取中
int GetMidi(int* a, int left, int right)
{
	int mid = (left + right) / 2;
	if (a[left] < a[mid])
	{
		if (a[right] > a[mid])
		{
			return mid;
		}
		else if(a[right] < a[left]) //mid最大
		{
			return left;
		}
		else
		{
			return right;
		}
	}
	else //a[left] > a[mid]
	{
		if (a[mid] > a[right])
		{
			return mid;
		}
		else if (a[left] > a[right]) //mid最小
		{
			return right;
		}
		else
		{
			return left;
		}
	}
}
int PartSort(int* a, int left, int right)
{
	int midi = GetMidi(a, left, right);
	Swap(&a[left], &a[midi]);

	......//三种PartSort任意一种
}

根据完全二叉树的特点,最后一层的节点个数占总数的50%。对比到快速排序的递归而言,递归层数越深,每层递归的次数变多,消耗也是越大的。我们拿10个数据对比一下:

 我们要快速排序10个数,就要递归3层,消耗太多,非常的不划算。因此递归到小的子区间时,可以考虑使用插入排序。该小区间就可以设置为只剩下10个数时候开始使用直接插入排序,最后一层的节点个数占总数的50%,倒数第二层的节点个数占总数的25%,倒数第三层的节点个数占总数的12.5%,根据上面的计算,能优化87.5%递归算法。

递归到小的子区间时,可以考虑使用直接插入排序。

// 假设按照升序对array数组中[left, right)区间中的元素进行排序
void QuickSort(int array[], int left, int right)
{
	//= 代表只剩下一个值
	if (left >= right)
		return;

	//将剩下的10个元素进行直接插入排序
	if ((right - left + 1) > 10)
	{
		// 按照基准值对array数组的 [left, right)区间中的元素进行划分
		int div = PartSort(array, left, right);

		// 划分成功后以div为边界形成了左右两部分 [left, div-1) 和 [div+1, right)
		// 递归排[left, div-1)
		QuickSort(array, left, div - 1);

		// 递归排[div+1, right)
		QuickSort(array, div + 1, right);
	}
	else
	{
		//指针+-跳过指向类型大小
		InsertSort(array + left, right - left + 1);
	}
}

掌握了递归思路的快速排序,我们再来掌握一下非递归思路的快速排序,非递归的快速排序需要使用栈来解决。我们先处理左边数据,再处理右边数据,根据栈先进后出的特点,因此右边数据先入栈,左边数据再入栈。这里也可以使用队列实现,不过队列不是先处理左边数据,再处理右边数据而是是一层一层处理,所以这里我们不用队列,使用栈能更好体现非递归的思路。

//导入之前写的栈实现接口
#include "Stack.h"

void QuickSortNonR(int* a, int left, int right)
{
	Stack st;
	StackInit(&st);
	//先进后出,先入右,再入左
	StackPush(&st, right);
	StackPush(&st, left);

	while (!StackEmpty(&st))
	{
		int left = StackTop(&st);
		StackPop(&st);

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

		int keyi = PartSort(a, left, right);
		//分为三个区间:[left,keyi-1] keyi [keyi+1,right]

		//先入右区间,再入左区间
		if (keyi + 1 < right)
		{
			StackPush(&st, right);
			StackPush(&st, keyi + 1);
		}
		if (left < keyi - 1)
		{
			StackPush(&st, keyi - 1);
			StackPush(&st, left);
		}
	}
	StackDestroy(&st);
}

这里提出一个问题:递归是使用的系统栈,而上面的栈是使用的人工栈,栈的深度不是一样的嘛,有什么区别???

人工栈是通过数组实现,是在堆上开辟的,而递归使用的系统栈,系统会自动为每个函数调用分配一帧。递归的栈深度受系统栈的限制,通常比人工栈小得多,系统栈很容易满栈。

#include <stdio.h>
int Func(int n)
{
	if (n == 0)
		return 0;
	return Func(n - 1) + n;
}
int main()
{
	printf("%d\n", Func(10000));
	return 0;
}

我们可以看到当n为5215时,我们的系统栈就满了,递归是由消耗的,所以掌握非递归的快速排序是非常有意义的。

快速排序的特性总结:

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

  • 3.空间复杂度:O(logN)
  • 4.稳定性:不稳定

2.4 归并排序

2.4.1基本思想

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

2.4.2归并排序

我们这里的归并是让小区间数据有序,先将数组分为若干小区间,然后将小区间的数按照从小到大的顺序尾插到tmp数组中,再拷贝回原数组,之后再让两个小区间的数尾插插到tmp数组中,再拷贝回原数组……依次,直至整个数组有序。

//为防止每次递归调用都会malloc空间,这里写一个子函数
void _MergeSort(int* a, int left, int right, int* tmp)
{
	if (right <= left)
		return;
	
	int mid = (right + left) / 2;
	//分割
	//[left,mid] [mid+1,right]
	_MergeSort(a, left, mid, tmp);
	_MergeSort(a, mid + 1, right, tmp);

	//归并到tmp数组,再拷贝回去
	// a->[left,mid] [mid+1,right]->tmp
	int begin1= left, end1 = mid;
	int begin2 = mid + 1, end2 = right;
	int index = left;
	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++];
	}

	//把tmp的数组归并到原数组上
	memcpy(a + left, tmp + left, (right - left + 1) * sizeof(int));
}

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

递归图:

上面的快速排序我们写了非递归的写法,它是一种很明显的前序方法,左边排完序就不需要再管了,而这里的归并是否也有非递归的写法呢?很明显,归并排序当人也有非递归的写法,但是我们这里的归并是一种后序,排完左边的序列还需要回到根,比如上面的 10 6 7 1,左边排序完是 6 10,右边排序完是 1 7,其序列还未有序,需要回到根后再排序,比较麻烦,这里我们就不使用栈的方法呢?这里我们可以借鉴一下斐波那契数列的非递归的方法。

我们怎么才能实现上面的归并呢?我们可以定义一个gap,通过gap确定每次排序的区间。我们先来实现一下一一归并的写法。

void MergeSortNonR(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc fail");
		return;
	}
	
	int gap = 1;
	//i的位置依次是0,2,4......
	for (int i = 0; i < n; i += 2 * gap)
	{
		int begin1 = i, end1 = i + gap - 1;//[0,0]
		int begin2 = i + gap,end2 = i + 2 * gap - 1;//[1,1]
		//第一次一一归并的两个区间[0,0] [1,1]
		//第二次一一归并的两个区间[2,2] [3,3]
		int index = i;
		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++];
		}
		//拷贝回原数组
		memcpy(a + i, tmp + i, 2 * gap * sizeof(int));
	}
	free(tmp);
}

所以我们只需要控制gap就可以实现非递归的归并排序。gap的取值是1,2,4…….当gap小于数组的元素就停止。

void MergeSortNonR(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc fail");
		return;
	}
	int gap = 1;
	while (gap < n)
	{
		//i的位置依次是0,2,4......
		for (int i = 0; i < n; i += 2 * gap)
		{
			int begin1 = i, end1 = i + gap - 1;//[0,0]
			int begin2 = i + gap, end2 = i + 2 * gap - 1;//[1,1]
			//第一次一一归并的两个区间[0,0] [1,1]
			//第二次一一归并的两个区间[2,2] [3,3]
			int index = i;
			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++];
			}
			//拷贝回原数组
			memcpy(a + i, tmp + i, 2 * gap * sizeof(int));
		}
		gap *=2;
	}
	free(tmp);
}

上面的数据刚好是2的整个倍,可是如果数据是9个呢?

此时我们再进行归并就会出现越界的问题。数据个数为9,下标为9及以上的都是越界。

此时每次归并上都出现了越界的问题,越界的问题都出现再end1,begin2和end2上面。这里我们需要想一个问题,我们每次排序的数据必须成对出现才能归并嘛?其实,如果数据不成对出现,我们就不要归并这数据,因为它本身就是独自出现,本身就可以当作有序。所以我们只用加下面的代码就可以了。如果越界了,这组数据就不用管了,直接退出即可。但是当只有end2越界的时候,此时需要归并,因为此时有两组数据需要归并。

//如果第二组不存在,这一组就不用归并了
if (end1 >= n)
{
	break;
}

//如果第二组的右边界越界,修正下标
if (end2 >= n)
{
	//此时需要归并,只用修改下标即可
	end2 = n - 1;
}

同时我们还需要改一下memcpy拷贝的个数

memcpy(a + i, tmp + i, (end2 - i + 1) * sizeof(int));

归并排序的特性总结:

  • 1. 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
  • 2. 时间复杂度:O(N*logN)
  • 3. 空间复杂度:O(N)
  • 4. 稳定性:稳定

2.5 非比较排序

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

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

上面的这种就是我们的计数排序,如果我们的数据是101、105、199,我们再通过上面的方法就要开辟200个空间大小的数组,就会存在很大的空间浪费,所以我们就要不能使用绝对映射(一个数据存在对应的下标下面),这里需要使用我们的相对映射(最大值-最小值获取区间)(根据a[i] – min)就只用开辟相对较少的空间。

void CountSort(int* a, int n)
{
	int max = a[0];
	int min = a[0];
	for (int i = 1; i < n; i++)
	{
		if (a[i] < min)
			min = a[i];
		if (a[i] > max)
			max = 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;
		}
	}
}

计数排序的特性总结:

  • 1. 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
  • 2. 时间复杂度:O(MAX(N,范围))
  • 3. 空间复杂度:O(范围) 
  • 4. 稳定性:稳定
  • 5.局限性:只能排序整型数据

2.6内排序和外排序

内排序和外排序是计算机科学中与排序算法相关的两个重要概念。

  1. 内排序(In-Place Sorting):

    • 内排序是指在排序过程中,所有数据都存储在计算机的内存中进行排序的方法。
    • 这意味着排序算法不需要使用外部存储(如硬盘或其他存储设备)来存储数据。
    • 内排序的优点是速度较快,因为内存访问通常比外部存储快得多。
    • 常见的内排序算法包括冒泡排序、选择排序、插入排序、快速排序、归并排序等。
  2. 外排序(External Sorting):

    • 外排序是指排序的数据量太大,无法完全放入计算机的内存中,因此需要使用外部存储设备来辅助排序的方法。
    • 外排序通常涉及到将大量数据分割成较小的块,分别在内存和外部存储之间进行排序,然后再将这些排序好的块合并起来以获得最终的有序结果。
    • 外排序的主要应用场景是处理大型数据集,如数据库排序、外部存储设备上的大文件排序等。
    • 常见的外排序算法包括归并排序、多路归并排序等。

假设我们当前的内存是1G,当我们要排序一个10亿个整型数据的时候,要怎么排序呢?

  • 10亿个整型数据 = 1024 * 1024 *1024 Byte * 4 = 4G > 内存1G,在内存中无法排序。
  • 4G的整型数据太大而无法一次性加载到内存中,需要使用外排序。
  • 外排序通常涉及将数据分成多个小块,每个小块可以适应内存大小。
  • 首先,将数据分块并逐块加载到内存中,对每个块使用内排序算法进行排序。
  • 排序后的块可以写回磁盘或者合并成更大的块。
  • 最后,进行块之间的合并操作,以获得最终排序结果。

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

稳定性:相同的数据进行排序后,其相对位置没有发生变化,就说明该排序具有稳定性。

4.选择题练习

1. 快速排序算法是基于( )的一个排序算法。

A分治法

B贪心法

C递归法

D动态规划法

解析:快速排序是基于分治法的一个排序算法。

2.对记录(54,38,96,23,15,72,60,45,83)进行从小到大的直接插入排序时,当把第8个记录45插入到有序表时,为找到插入位置需比较( )次?(采用从后往前比较)

A 3

B 4

C 5

D 6

解析:第8个记录45插入到有序表时,前7个数据已经有序(15,23,38,54,60,72,96),次数依次向前比较,需要比较5次。

3.以下排序方式中占用O(n)辅助存储空间的是

A 简单排序

B 快速排序 

C 堆排序

D 归并排序

解析:归并排序需要将小区间排序的结果保存下来,然后再拷贝到原数组上

4.下列排序算法中稳定且时间复杂度为O(n2)的是( )

A 快速排序

B 冒泡排序

C 直接选择排序

D 归并排序

5.关于排序,下面说法不正确的是

A 快排时间复杂度为O(N*logN),空间复杂度为O(logN)

B 归并排序是一种稳定的排序,堆排序和快排均不稳定

C 序列基本有序时,快排退化成冒泡排序,直接插入排序最快

D 归并排序空间复杂度为O(N), 堆排序空间复杂度的为O(logN)

6.下列排序法中,最坏情况下时间复杂度最小的是( )

A 堆排序

B 快速排序

C 希尔排序

D 冒泡排序

7.设一组初始记录关键字序列为(65,56,72,99,86,25,34,66),则以第一个关键字65为基准而得到的一趟快速排序结果是()

A 34,56,25,65,86,99,72,66

B 25,34,56,65,99,86,72,66

C 34,56,25,65,66,99,86,72

D 34,56,25,65,99,86,72,66

解析:这里采用的是挖坑法,右边先找小,左边再找到,最后将关键字65放到坑位。

文章出处登录后可见!

已经登录?立即刷新

共计人评分,平均

到目前为止还没有投票!成为第一位评论此文章。

(0)
心中带点小风骚的头像心中带点小风骚普通用户
上一篇 2023年12月27日
下一篇 2023年12月27日

相关推荐