算法学习 (门徒计划)3-1 快速排序(Quick-Sort)及优化及经典问题 学习笔记

前言

(6.30,又是很长时间没有更新笔记。有种积重难返的感觉,但亡羊补牢。)

本文为开课吧门徒计划算法课第七讲学习笔记。

3-1 快速排序(Quick-Sort)及优化

本课将简要描述快速排序的模型,并分析快排的优缺点,来了解如何优化快排,再通过对照STL中快排的实现进一步加深理解,最后以一些算法题目进行收尾。

(按着惯例,本次依然挑战最短学习时间)

快排及优化

排序的意义

(在讲快排前,先了解排序的意义,能加深对排序的学习欲望)

排序,就是令无序变得有序。

而描述无序和有序的状态,在物理学中用熵来进行描述。而这种描述的方式也是适合描述代码所要面对的问题。当一个场景熵更小时,代码的处理效率将变得高,例如查询一个数时,通常二分法能优于顺次查询的遍历法,而二分法的前提就是目标数据是有序的。

快排的基本概念

(了解快排的可以跳过这一块)

当说起排序算法时候,如果要评选一种最优的排序方式,我首先推荐快排,因为快排有最好的综合性能。

基本原理

对于一列位置未知顺序的数列,以其中一个数为基准,通过调换位置的方式,让小于该数字的值位于其左侧,其余的数位于其右侧,将这个动作称为步骤1,随后分别对于左侧区间和右侧区间重复执行步骤1,最终使得区间大小小于等于1,此时整体数列将有序。
关于调换位置的方式为,从数列左侧找第一个大于基准数的值和右侧第一个小于基准数的值进行位置互换,如果互换完成则继续寻找。当在寻找时如果发现左侧寻找数的指针和右侧寻找数的指针相遇,则停止寻找,并且将相遇的位置和基准数进行互换。

总结:

  • 第一步:找一个基准值,将小于基准值的数放前面,其余的放后面。(分区-partition)
  • 第二步:基准值的左侧区域和右侧区域重复第一步。

文本图解

原始数列:9 , 8 , 5 , 1 , 6 , 4 , 2 , 7 , 8 , 3 , 5 , 4 , 9 , 1 , 6

第1轮排序:选择任何一个数为基准,本次我选择开头的:9
当前数列:9 , 8 , 5 , 1 , 6 , 4 , 2 , 7 , 8 , 3 , 5 , 4 , 9 , 1 , 6
依次调换位置的数对:
本轮结束
左侧数列:6 , 8 , 5 , 1 , 6 , 4 , 2 , 7 , 8 , 3 , 5 , 4 , 9 , 1
右侧数列:

第2轮排序:选择任何一个数为基准,本次我选择开头的:6
当前数列:6 , 8 , 5 , 1 , 6 , 4 , 2 , 7 , 8 , 3 , 5 , 4 , 9 , 1
依次调换位置的数对:(8 , 1) (7 , 4) (8 , 5) 
本轮结束
左侧数列:1 , 4 , 5 , 1 , 6 , 4 , 2 , 5 , 3
右侧数列:8 , 7 , 9 , 8

第3轮排序:选择任何一个数为基准,本次我选择开头的:1
当前数列:1 , 4 , 5 , 1 , 6 , 4 , 2 , 5 , 3
依次调换位置的数对:
本轮结束
左侧数列:
右侧数列:4 , 5 , 1 , 6 , 4 , 2 , 5 , 3

第4轮排序:选择任何一个数为基准,本次我选择开头的:4
当前数列:4 , 5 , 1 , 6 , 4 , 2 , 5 , 3
依次调换位置的数对:(5 , 3) (6 , 2) 
本轮结束
左侧数列:3 , 2 , 1
右侧数列:4 , 6 , 5 , 5

第5轮排序:选择任何一个数为基准,本次我选择开头的:3
当前数列:3 , 2 , 1
依次调换位置的数对:
本轮结束
左侧数列:1 , 2
右侧数列:

第6轮排序:选择任何一个数为基准,本次我选择开头的:1
当前数列:1 , 2
依次调换位置的数对:
本轮结束
左侧数列:
右侧数列:2

第7轮排序:选择任何一个数为基准,本次我选择开头的:4
当前数列:4 , 6 , 5 , 5
依次调换位置的数对:
本轮结束
左侧数列:
右侧数列:6 , 5 , 5

第8轮排序:选择任何一个数为基准,本次我选择开头的:6
当前数列:6 , 5 , 5
依次调换位置的数对:
本轮结束
左侧数列:5 , 5
右侧数列:

第9轮排序:选择任何一个数为基准,本次我选择开头的:5
当前数列:5 , 5
依次调换位置的数对:
本轮结束
左侧数列:
右侧数列:5

第10轮排序:选择任何一个数为基准,本次我选择开头的:8
当前数列:8 , 7 , 9 , 8
依次调换位置的数对:
本轮结束
左侧数列:7
右侧数列:9 , 8

第11轮排序:选择任何一个数为基准,本次我选择开头的:9
当前数列:9 , 8
依次调换位置的数对:
本轮结束
左侧数列:8
右侧数列:

最终数列:1 , 1 , 2 , 3 , 4 , 4 , 5 , 5 , 6 , 6 , 7 , 8 , 8 , 9 , 9
-- over --

示例代码:(最简版快排,实际使用时应把打印取消)

	public static void quick_sort_v1(int [] arr,int l ,int r) {
   
		if(l>=r)return;
		
		int lp = l,rp =r,base = arr[l];
		System.out.printf("第%d轮排序:选择任何一个数为基准,本次我选择开头的:%d\n",n++,base);
		System.out.print("当前数列:");		
		
		printArr(arr,l,r+1);
		System.out.print("依次调换位置的数对:");
				
		while(lp<rp) {
   
			int wantl =1,wantr =2;

			while(lp<rp && arr[rp]>=base)rp--;
			wantl = arr[rp];

			if(lp<rp) {
   arr[lp++] = arr[rp];}
			while(lp<rp && arr[lp]<=base)lp++;
			
			if(lp<rp) arr[rp--] = arr[lp];
			
			wantr = arr[lp];
			if(wantr != wantl)
				System.out.printf("(%d , %d) ",wantr,wantl);
			
		}
		System.out.print("\n");
		arr[lp] = base;
		
		System.out.print("本轮结束\n");	
		
		System.out.print("左侧数列:");
		printArr(arr,l,lp);
		System.out.print("右侧数列:");
		printArr(arr,lp+1,r+1);
		
		System.out.print("\n");
		
		quick_sort_v1(arr,l,lp-1);
		quick_sort_v1(arr,lp+1,r);
	}

//演示用工具函数(后续将略过)
	public static void printArr(int [] arr,int l ,int r) {
   
		for(int i = l;i<arr.length &&i<r;i++) {
   
			System.out.print(arr[i]);
			if(i+1<arr.length&&i+1<r)
				System.out.print(" , ");
		}
		System.out.print("\n");
	}

	public static void main(String[] args) {
   
		int arr [] = {
   9 , 8 , 5 , 1 , 6 , 4 , 2 , 7 , 8 , 3 , 5 , 4 , 9 , 1 , 6};
		
		System.out.print("原始数列:");
		printArr(arr,0,arr.length);
		System.out.print("\n");
		
		quick_sort_v1(arr,0,arr.length-1);
		//quick_sort_v2(arr,0,arr.length-1);
		//quick_sort_v3(arr,0,arr.length-1);
		
		System.out.print("最终数列:");
		printArr(arr,0,arr.length);
		
		System.out.println("-- over --");
	}

分析优劣

根据上述代码,直觉上可以意识到快排的性能优秀的来源就在于分区优化,分区使得横向宽度减少,使得遍历的时间维度的重复的性减少(已经知道小于基准值的区块内的数,不再需要和基准值比较)。

总结:快排通过的不断的进行折半(分区)以指数的效率去完成排序。

但是实际操作中能完全折半吗,显然是不能,用更多的数进行测试可以发现,折半的效率越高(左右区间长度越接近)代码总的执行轮次就越少。由此可以得出第一个影响快排的因素(基准值的优劣)。

换一种思维,快排本身是一个二分递归的模型,与二叉树类似,因此这棵树越完全(空的指针域越少)树高就越低,性能就越好。(越平衡越接近 O(nlogn))

此外再根据上方的打印,可以发现每一个轮次的执行效率(实际完成的排序量/轮次内全部操作步数)都随着区间内元素的减少而降低,并且元素越少降低幅度越大。由此可以归纳出第二个可以优化快排的因素(小区间内的排序)

综上做一个总结:
快排作为一种近似二叉树的排序模型,根据选择的基准点的不同,在极端情况(退化为链表的二叉树)与平衡情况(完全平衡二叉树)间波动,并且所需的排序区间越小排序实际性能效率越差。由此可以优化2点:基准值、小区间

此外双边递归的方式,在性能上略低于单边递归(左递归),因此还可以用单边递归进行优化。(对此我不进行证明,只做学习了解,至此已经有3种主要的优化方式了)

而优化越趋于极限就越是要在细节下功夫

if(l>=r)return;这一段代码用于判断边界条件,但在STL的优化中,也可以以结构性的方式进行省略,这种优化被称为:无监督partition

以上的优化的方式在c++STL中都有体现,课上也将其描述为(括号内是我的总结):

  1. 单边递归法(结构性优化)
  2. 无监督partition(结构性优化)
  3. 三点取中法(基准值优化)
  4. 小数据规模,停止快排过程(小区间优化)
  5. 使用插入排序进行首(首?我认为应该是收)尾(小区间优化)

优化快排

在优化前,先讨论一下接下来准备讨论的3种排序(插入排序、快速排序、堆排序)的优劣,然后结合其优点合成一个综合性能更好的排序,称为内省排序(内省排序(英语:Introsort)是由David Musser在1997年设计的排序算法。这个排序算法首先从快速排序开始,当递归深度超过一定深度(深度为排序元素数量的对数值)后转为堆排序。)优先队列中采用的就是内审排序。

  • 快排:通常复杂度为nlogn,但是恶劣情况下会退化为n*n
  • 堆排序:通常复杂度为nlogn,劣于快排,但是比快排稳定不会出现退化
  • 插入排序:通常复杂度为n*n,但是在最好情况下(数组有序)会进化为n

(快排和堆排序虽然复杂度接近,但是快排综合更优的地方在于:虽然数学层面上二者操作的速度是接近的,但是实际在计算机中运行时:虽然计算数据存储的下标会很快完成,但是在大规模的数据中对数组指针寻址也需要一定的时间。而快速排序相对于堆排序只需要将数组指针移动到相邻的区域。这个现象随着数据量的增长而愈发显著)

因此对于这3种排序的优缺点进行总结可以发现,如果期望实现最高的效率应该尽可能的使用快排,但要防止在恶劣情况下的退化,同时如果数组有序还应尽可能的切换为插入排序。

进一步思考:如何判断恶劣情况

采用的方法为判断递归的深度,当递归的深度大于nlogn级别时,例如大于2logn时(之所以为2logn,是因为通常都是试图接近logn但是劣于logn的因此采用2logn作为显著劣化的分界线)

进一步思考:如何对小区间进行优化

之前描述到对于数据有序时,插入排序有更高的性能,但插入排序同样在小区间内有良好的表现。因此常用插入排序作为收尾工作。

源码赏析(略)

虽然看顶级的源码可以提升自身编程的格调和境界,但是我不知道从哪整个源码来。(从课程中截取几张图片,如有侵权,请通知我删除,谢谢)
(如果没截图代表我忘了,就略过)

结构性优化

首先是结构性进行优化,通过单边递归和无监督法

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值