算法学习之经典排序算法(二)

195 阅读6分钟

导语

咕咕咕~~~
这是一篇鸽了很久的文章。公司的加班、大小周制度,让我不管是在周末还是日常下班的时候,都实在是不想学习,只想躺平。但最近笔者的生活中有了一些非同寻常的变化,让我重新有动力开始继续学习并努力把算法学习系列更新下去。

在上一章节中笔者针对三种简单排序算法进行了详细介绍,并提供了可行的优化方法。但从最终的优化结果来看,其效率仍然不足以进行实际使用。

这里是经典排序算法第二章节,本章是笔者利用闲暇时间对经典排序算法的复习、学习后所进行的记录。基本概念相关请见算法学习之经典排序算法(一),恕不在本文中进行过多叙述。

文中提到的所有排序算法均会附有文字描述,实现代码,且笔者会在自己能力范围之内对算法进行优化,同样的,所有的工程文件均会上传到Github,目前所有的代码均采用C语言代码进行编写,以后若有时间,可能会更新JAVA版本。

一下为笔者Github项目地址,若有需要可以自取。 C语言版本

注1:未经允许,禁止转载!
注2:本文中的所有代码,均以升序排序为例,如需要降序排序代码,相信读者能自行进行修改。
注3:如果文中出现错误/异议,欢迎进行指正/讨论,谢谢。

高级排序算法(一)

本节将介绍经典排序算法中的三种高级排序算法:希尔排序,归并排序,快速排序。这几种排序算法的时间复杂度都为nlognnlogn级别,但在实际运行中,效率还是存在一定的差距。

1、希尔排序

  • 算法描述:希尔排序算法又称为缩小增量排序,是对简单插入排序进行改进后的高级版本。对简单插入排序进行分析,发现当位置为0的元素位于序列最后时,需要进行较多次数(n1n-1次)的比较、移动元素,才能让其排列到正确位置,希尔排序则利用一个根据待排序序列长度和一定规则生成的增量序列来解决这个问题。设存在一序列长度为n,希尔排序以增量gap(小于n)为间隔,将n个元素分割为gap个序列,并对这些小序列进行插入排序。由于刚开始时gap较大,每个序列中的元素很少,效率较高,在每个迭代中gap缩小,分割出的序列也慢慢变长,但由于经过前几次迭代的排序,整个序列已经基本有序,所以排序效率依旧较高。

  • 代码实现:

//希尔排序
void myShellSort(int* arr, int length) {

	for (int gap = floor(length / 2); gap > 0; gap = floor(gap / 2)) {

		//对分组进行插入排序
		for (int i = gap, j, temp; i < length; i++) {
			j = i;
			temp = arr[j];
			while (j - gap >= 0 && temp < arr[j - gap]) {
				arr[j] = arr[j - gap];
				j = j - gap;
			}
			arr[j] = temp;
		}

	}
}
  • 算法优化:上面的算法采用shell最先提出的gap=length/2gap = length / 2作为增量序列,但由于奇数位和偶数位的元素会在最后才进行比较,所以效率相对较低。后来又有Knuth提出gap=gap/3+1gap = gap / 3 +1作为序列,希尔排序的运行效率与增量序列的取法有很大关系。

  • 时间复杂度:目前没人能进行严谨的数学归纳,恕笔者无法给出确切的表达式。但根据笔者自行使用程序生成的测试用例测试100次,统计平均排序时间发现,希尔排序对百万级别的整型随机数组排序效率高于归并排序,并低于快速排序。

  • 空间复杂度:O(1)O(1)

  • 小结:希尔排序是针对插入排序所做的改进,其效率叫简单排序算法有极大提升,且代码实现较为简单。但由于其增量序列的设计与选取对排序效率存在很大影响,所以在实际开发中并不常用。

2、归并排序

  • 算法描述:归并排序主要运用了一种分治的思想,分,即将待排序序列不断二分为多个小序列,直到每个小序列长度为1,治,即在完成划分后将小序列中的元素有序排列并合并,重复直至整个序列有序。在序列完全划分后,其结构类似一颗完全二叉树,然后在树形结构的基础上进行归并,能够使得排序效率相较简单排序有很大提升,归并排序是一种十分高效的,稳定排序方式。

  • 递归法:

//5.1、归并排序-递归版
void myRecursionMergeSort(int* arr, int left, int right) {
	if (left < right) {
		int mid = left + (right - left) / 2;

		myRecursionMergeSort(arr, left, mid);

		myRecursionMergeSort(arr, mid + 1, right);

		//merge
		myMerge(arr, left, mid, right);

	}
}

void myMerge(int* arr, int left, int mid, int right) {

	//定义一个数组进行暂存
	int* tmpArr = (int*)calloc(right - left + 1, sizeof(int));

	//双指针依次遍历两个子数组
	int i = left, j = mid + 1, k = 0;

	//两个数组长度最多相差1
	while (i <= mid && j <= right) {
		if (arr[i] < arr[j]) tmpArr[k++] = arr[i++];
		else tmpArr[k++] = arr[j++];
	}

	while (i <= mid) tmpArr[k++] = arr[i++];
	while (j <= right) tmpArr[k++] = arr[j++];
        //把排序好的数组倒回去
	while (right >= left) arr[right--] = tmpArr[--k];

	free(tmpArr);
}
  • 迭代法:
//5.2归并排序-迭代版
void myIterationMergeSort(int* arr, int length) {
	int left, mid, right;

	for (int subLen = 1; subLen < length; subLen *= 2) {
		left = 0;
		mid = left + subLen - 1;
		right = mid + subLen;

		while (right < length) {
			myMerge(arr, left, mid, right);

			left = right + 1;
			mid = left + subLen - 1;
			right = mid + subLen;
		}

		if (left < length && mid < length) myMerge(arr, left, mid, length - 1);
	}
}
  • 时间复杂度:排序序列会首先被划分为log2nlog2n个子序列,在对每两个子序列进行归并时,其耗时为O(n)O(n),所以综合来看其时间复杂度为O(nlogn)O(nlogn),且在最好、最坏情况下,归并排序的排序效率均为O(nlogn)O(nlogn)

  • 空间复杂度:由于在进行归并时需要对排序元素进行暂存,所以其空间复杂度为:O(n)O(n)

  • 小结:归并排序在排序中引入了分治的编程思想,且根据其算法思路,使用递归进行实现十分容易,但要注意在面临大数据量(百万、千万级)排序时,递归往往容易超出最大的递归层数导致堆栈溢出,进而引发程序崩溃,所以笔者补充了迭代法的代码实现,使之能够避免这一问题。归并排序是一种稳定排序,且具有十分优秀的时间复杂度,虽然需要使用额外空间,但总归是一种高效的排序算法。

3、快速排序

  • 算法描述:快速排序是由东尼·霍尔发明的一种排序算法,该算法的设计同样基于分治的思想。在执行过程中,算法会首先选取一个基准(pivot),并把序列中小于该基准的元素放到其左边,大于该基准的元素放到其右边,然后以基准元素的位置为准,将序列分为左右两个序列,并递归地重复以上操作,直到整个序列有序。

注:在本篇中,暂不进行快速排序优化的讨论,故此处仅介绍最基本的快速排序法

  • 递归版:
//递归版 基础快速排序-不能处理大数据量的序列,递归过深会引起堆栈溢出
void myBasicRecursionQuickSort(int* arr, int left, int right) {

	//区间长度为1时,即为有序
	if (right - left <= 1) return;

	//此处默认将left处的元素设置为pivot元素,
	//算法中则对当前数据区段的[left,right]区间进行排序
	int pivot = arr[left];

	//这里的low从序列左端开始,以low表示的区间的值均小于pivot,将该区间记作低值区间
	int low = left;

	//这里的high从序列左端开始,以high表示的区间的值均大于pivot,将该区间记作高值区间
	int high = right;

	while (low < high) {
		//从序列右边寻找第一个小于pivot的值,如果找到这样一个元素,则high停在那儿
		while (high > low && arr[high] >= pivot) --high;

		//上面的循环跳出时,high找到了高值区间中小于pivot的低值,此时low还指向pivot
		//交换两者的位置,以保证高值区间中的值均大于pivot
		swap(arr, low, high);

		//先从序列左边寻找第一个大于pivot的值,如果找到这样一个元素low则停在那里
		while (low < high && arr[low] <= pivot) ++low;

		//同理,此时low指向低值区间中的高值,将其与high指向的值交换
		swap(arr, low, high);
	}


	//low区间,[left,low-2]
	if(left < low - 1)
	myBasicRecursionQuickSort(arr, left, low - 1);

	//high区间,[low,right]
	if(low + 1 < right)
	myBasicRecursionQuickSort(arr, low + 1, right);
}
  • 迭代版:
//迭代版 可以处理大量级的数据
void myBasicIterationQuickSort(int* arr, int length) {

	//左指针和右指针
	int left = 0, right = length - 1;

	//这里不用专门去实现一个栈,转而使用一个数组进行模拟
	int* arrStack = (int*)calloc(length+1, sizeof(int));
	int top = -1;

	//将每次迭代需要进行 分治排序 的左右下标存入栈中,用来模拟递归
	arrStack[++top] = right;
	arrStack[++top] = left;

	while (top > 0) {
		//区间前后界出栈

		left = arrStack[top--];
		right = arrStack[top--];

		int low = left, high = right;

		//取left处的值作为pivot
		int pivot = arr[left];

		while (low < high) {

			while (low < high && arr[high] >= pivot) high--;

			swap(arr, low, high);

			while (low < high && arr[low] <= pivot) low++;

			swap(arr, low, high);
		}

		//保存低值区间的前后界
		if (left < low - 1) {
			arrStack[++top] = low - 1;
			arrStack[++top] = left;
		}

		//保存高值区间的前后界
		if (low + 1 < right) {
			arrStack[++top] = right;
			arrStack[++top] = low + 1;
		}
	}
}
  • 算法分析:在上文提到的两种算法,分别采用了递归与迭代的方式进行实现。算法的核心部分实际是相同的,将传入序列的第一个元素选做基轴(pivot)元素,随后由高值区间的指针high开始,反向扫描序列,直到找到第一个小于pivot的值并停住,交换low与high的值(此时由于low没有移动,其指向的元素就是基轴元素pivot)。再由低值区间的指针low正向扫描序列,找出第一个大于pivot的值并停住,并交换high和low的值。当low与high相遇时,pivot则已经处于正确的位置,其左边的值均不大于它,右边的值均不小于它。这时将序列分为左边的低值区间与右边的高值区间,并对着两个区间的序列进行递归,直至整个序列有序。而在迭代法中,则采取了一个辅助数组模拟栈的形式进行区间前后界的数据的保存,使之能够支持任意数据量的序列排序,不用担心堆栈溢出的情况。

  • 算法优化:由于快速排序的优化方法多种多样,其中也有很多的学问,所以准备放到快速排序专题中去进行讲解,此处就不做过多叙述。

  • 时间复杂度:在一般情况下,其时间复杂度为O(nlogn)O(nlogn),但当在输入序列有序的极端情况下,时间复杂度会退化至O(n2)O(n^2),并且该算法的耗时与pivot值的取法有着一定的关联,后续讲解的部分相关优化,也会围绕着pivot的取法进行讨论。

  • 空间复杂度:在最有情况下,每一次pivot都能平分序列,此时空间复杂度为O(logn)O(logn),在极端情况下,快速排序会退化为冒泡排序,此时其空间复杂度为O(n)O(n)

  • 小结:与归并排序相似的,快速排序同样的引入了分而治之(Divide and conquer)的思想,切根据其实现思路,在针对不太大(不过百万级)数据量级别的情况下,使用递归写法能够较为容易地实现,但如果需要处理极大数据量的情况,则推荐使用迭代法的写法以避免递归层数过深引发的堆栈溢出。快速排序是一种非稳定排序,它在大多数的O(nlogn)O(nlogn)级别时间复杂度的算法中,效率极高,应用范围十分的广,同样的,由于一些极端情况的存在,对其优化的方式也多种多样,但这些优化方式将放在以后进行介绍。

结语

本文介绍了经典排序算法中的三种高级排序算法:希尔排序,归并排序以及最常使用的快速排序。这三者中,快速排序的平均效率最高,归并其次,希尔排序的效率受到缩小增量(gap)的选取方式的制约,仅推荐在特定情况下使用。后两种排序的时间复杂度均为O(nlogn)O(nlogn)级别,效率优秀,即使在百万级的数据量情况下,其运行时间仍十分优秀,在实际开发中,虽说已经有各类框架封装的排序算法,但了解其实现逻辑有助于更好的使用。

在下一章节中,笔者将介绍快速排序的各种优化方式,尽情期待...