前言
(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中都有体现,课上也将其描述为(括号内是我的总结):
- 单边递归法(结构性优化)
- 无监督partition(结构性优化)
- 三点取中法(基准值优化)
- 小数据规模,停止快排过程(小区间优化)
- 使用插入排序进行首(首?我认为应该是收)尾(小区间优化)
优化快排
在优化前,先讨论一下接下来准备讨论的3种排序(插入排序、快速排序、堆排序)的优劣,然后结合其优点合成一个综合性能更好的排序,称为内省排序(内省排序(英语:Introsort)是由David Musser在1997年设计的排序算法。这个排序算法首先从快速排序开始,当递归深度超过一定深度(深度为排序元素数量的对数值)后转为堆排序。)优先队列中采用的就是内审排序。
- 快排:通常复杂度为
nlogn
,但是恶劣情况下会退化为n*n
。 - 堆排序:通常复杂度为
nlogn
,劣于快排,但是比快排稳定不会出现退化 - 插入排序:通常复杂度为
n*n
,但是在最好情况下(数组有序)会进化为n
(快排和堆排序虽然复杂度接近,但是快排综合更优的地方在于:虽然数学层面上二者操作的速度是接近的,但是实际在计算机中运行时:虽然计算数据存储的下标会很快完成,但是在大规模的数据中对数组指针寻址也需要一定的时间。而快速排序相对于堆排序只需要将数组指针移动到相邻的区域。这个现象随着数据量的增长而愈发显著)
因此对于这3种排序的优缺点进行总结可以发现,如果期望实现最高的效率应该尽可能的使用快排,但要防止在恶劣情况下的退化,同时如果数组有序还应尽可能的切换为插入排序。
进一步思考:如何判断恶劣情况
采用的方法为判断递归的深度,当递归的深度大于nlogn
级别时,例如大于2logn
时(之所以为2logn,是因为通常都是试图接近logn但是劣于logn的因此采用2logn作为显著劣化的分界线)
进一步思考:如何对小区间进行优化
之前描述到对于数据有序时,插入排序有更高的性能,但插入排序同样在小区间内有良好的表现。因此常用插入排序作为收尾工作。
源码赏析(略)
虽然看顶级的源码可以提升自身编程的格调和境界,但是我不知道从哪整个源码来。(从课程中截取几张图片,如有侵权,请通知我删除,谢谢)
(如果没截图代表我忘了,就略过)
结构性优化
首先是结构性进行优化,通过单边递归和无监督法减少快排本身步骤数目。
无监督法必然是减少了执行步骤,而单边递归则是依据递归需要额外的栈空间,性能不如迭代,所以用循环代替了递归(从而略微降低时间复杂度,相对大幅度的降低了空间复杂度)。
单边递归(左递归)法也常在系统源码中使用,因为这种极限优化的模式在工业级市场化的大背景下,总能从牙缝中省下些成本,最终积少成多。
但是常在非底层开发(对于极限性能要求不高的应用场所)中,一般不会使用这种单递归法,因为这种方法的代码可读性相比较下过于不足。
这种无所不用其极的优化理念也是本课需要领悟的核心思想。
单递归在本次具体的优化思想总结为:用递归处理右区间,用循环处理左区间。
优化结果如下:
public static void quick_sort_v2(int [] arr,int l ,int r) {
//if(l>=r)return;//如下改为无监督
while(l<r) {
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_v2(arr,lp+1,r);
r = lp-1;
}
}
再对比代码的执行步骤:
原始数列: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轮排序:选择任何一个数为基准,本次我选择开头的:8
当前数列:8 , 7 , 9 , 8
依次调换位置的数对:
本轮结束
左侧数列:7
右侧数列:9 , 8
第4轮排序:选择任何一个数为基准,本次我选择开头的:9
当前数列:9 , 8
依次调换位置的数对:
本轮结束
左侧数列:8
右侧数列:
第5轮排序:选择任何一个数为基准,本次我选择开头的:1
当前数列:1 , 4 , 5 , 1 , 6 , 4 , 2 , 5 , 3
依次调换位置的数对:
本轮结束
左侧数列:
右侧数列:4 , 5 , 1 , 6 , 4 , 2 , 5 , 3
第6轮排序:选择任何一个数为基准,本次我选择开头的:4
当前数列:4 , 5 , 1 , 6 , 4 , 2 , 5 , 3
依次调换位置的数对:(5 , 3) (6 , 2)
本轮结束
左侧数列:3 , 2 , 1
右侧数列:4 , 6 , 5 , 5
第7轮排序:选择任何一个数为基准,本次我选择开头的:4
当前数列:4 , 6 , 5 , 5
依次调换位置的数对:
本轮结束
左侧数列:
右侧数列:6 , 5 , 5
第8轮排序:选择任何一个数为基准,本次我选择开头的:6
当前数列:6 , 5 , 5
依次调换位置的数对:
本轮结束
左侧数列:5 , 5
右侧数列:
第9轮排序:选择任何一个数为基准,本次我选择开头的:5
当前数列:5 , 5
依次调换位置的数对:
本轮结束
左侧数列:
右侧数列:5
第10轮排序:选择任何一个数为基准,本次我选择开头的:3
当前数列:3 , 2 , 1
依次调换位置的数对:
本轮结束
左侧数列:1 , 2
右侧数列:
第11轮排序:选择任何一个数为基准,本次我选择开头的:1
当前数列:1 , 2
依次调换位置的数对:
本轮结束
左侧数列:
右侧数列:2
最终数列:1 , 1 , 2 , 3 , 4 , 4 , 5 , 5 , 6 , 6 , 7 , 8 , 8 , 9 , 9
-- over --
最终可以发现:轮次相同,区间的排序顺序不同,改为先对右侧进行递归排序再对左侧进行递归排序。从而明确左递归的优化在于——减少递归需要额外的栈空间从而降低空间复杂度。
而无监督法则体现在了单个轮次中减少了若干步操作。
(上述代码中,我没有对递归深度做一个计算,因此可能有些抽象,有条件的可以自身加一下并调试加深理解,或者练习一下抽象思维来理解我演示的模型)
进一步结构性优化,基准值优化,小区间优化
(下方示例代码中小区间被定为4,但实际使用时,可以考虑为16,因为16作为cpu最小的读入分度,采用16作为小区间时,可以最大化的发挥性能,4仅作为演示使用)
- 结构性优化:在版本2上进一步优化,仅将数列中所有小于基准值的数放在左侧
- 基准值优化:通过取数列的头中尾三个数中的中间数作为基准值
- 小区间优化:区间小于定义的大小时,不再对区间内进行快排,而将这个任务最终交付给执行迭代算法的插入排序。
示例代码:
private static final int threshold = 4; //最小分度16
public static void swap(int [] a,int x,int y){
int temp;
temp=x;
x=y;
y=temp;
a [0] = x;
a [1] = y;
}
private static int median (int l,int m,int r) {
//取中值
int temp [] = new int [2];
if(l>m) {
swap(temp,l,m);
l = temp[0];
m = temp[1];
}
if(l>r) {
swap(temp,l,r);
l = temp [0];
r = temp [1];
}
if(m>r) {
swap(temp,m,r);
m = temp [0];
r = temp [1];
}
return m;
}
public static void __quick_sort_v3(int [] arr,int l ,int r) {
while(r-l > threshold) {
int lp = l,rp =r,base = median(arr[l],arr[(l+r)/2],arr[r]);
System.out.printf("第%d轮排序:选择三个数中选一个数为基准,本次我选择:%d\n",n++,base);
System.out.print("当前数列:");
printArr(arr,l,r+1);
do {
while(arr[lp]<base) lp++;
while(arr[rp]>base) rp--;
if(lp<=rp) {
int temp;
temp=arr[lp];
arr[lp]=arr[rp];
arr[rp]=temp;
lp++;
rp--;
}
}while(lp<=rp);
System.out.print("左侧数列:");
printArr(arr,l,lp);
System.out.print("剩余数列:");
printArr(arr,lp,r+1);
__quick_sort_v3(arr,lp,r);
r = rp;
}
}
public static void final_insert_sort(int [] arr,int l ,int r) {
int ind = l;
//找到最小值的下标
for(int i = l+1;i<=r;i++) {
if(arr[i]<arr[ind]) {
ind = i;
}
}
//将最小值插入到最前方(将所有最小值前的数依次后移一位)
while(ind>l) {
int temp;
temp=arr[ind];
arr[ind]=arr[ind-1];
arr[ind-1]=temp;
--ind;
}
//从第三个数开始,依次将更大的数逐步插入所应存在的位置
for(int i = l+2;i<=r;i++) {
int j = i;
while(arr[j]<arr[j-1]) {
int temp;
temp=arr[j];
arr[j]=arr[j-1];
arr[j-1]=temp;
--j;
}
}
}
public static void quick_sort_v3(int [] arr,int l,int r) {
__quick_sort_v3(arr,l,r);
System.out.print("半完成列:");
printArr(arr,0,arr.length);
final_insert_sort(arr,l,r);
}
运行时打印如下:
原始数列:9 , 8 , 5 , 1 , 6 , 4 , 2 , 7 , 8 , 3 , 5 , 4 , 9 , 1 , 6
第1轮排序:选择三个数中选一个数为基准,本次我选择:7
当前数列:9 , 8 , 5 , 1 , 6 , 4 , 2 , 7 , 8 , 3 , 5 , 4 , 9 , 1 , 6
左侧数列:6 , 1 , 5 , 1 , 6 , 4 , 2 , 4 , 5 , 3
剩余数列:8 , 7 , 9 , 8 , 9
第2轮排序:选择三个数中选一个数为基准,本次我选择:6
当前数列:6 , 1 , 5 , 1 , 6 , 4 , 2 , 4 , 5 , 3
左侧数列:3 , 1 , 5 , 1 , 5 , 4 , 2 , 4
剩余数列:6 , 6
第3轮排序:选择三个数中选一个数为基准,本次我选择:3
当前数列:3 , 1 , 5 , 1 , 5 , 4 , 2 , 4
左侧数列:2 , 1 , 1
剩余数列:5 , 5 , 4 , 3 , 4
半完成列:2 , 1 , 1 , 5 , 5 , 4 , 3 , 4 , 6 , 6 , 8 , 7 , 9 , 8 , 9
最终数列:1 , 1 , 2 , 3 , 4 , 4 , 5 , 5 , 6 , 6 , 7 , 8 , 8 , 9 , 9
-- over --
有兴趣的可以自行调试一番争取在直觉上能意识到优化的方向,因为编程的优化更多的是来自直觉的感受和逻辑的推断。(我认为直觉是一种快速逻辑,训练编程直觉十分重要,当产生了一个认知冲动时,应及时的去验证猜想,以此达到进步的可能)。
总结
通过对本次快排的优化,我总结了一下算法优化的核心理念:
- 因地制宜,充分发挥运行环境的性能(体现在递归排序与快排的抉择和终止快排的条件这两个方面上)
- 极限施压,尽可能的减少一切步骤(体现在单边递归和无监督上)
- 扬长避短,竟可能让算法工作在适宜环境(体现在内省排序的组合上)
经典例题
我始终致力于琢磨最高效的学习策略,本次采取的步骤为:
- 读题,思考解决方案
- 思考对本题是否感兴趣
- 思考是否利于学习本课知识
- 做满足步骤2和3的题目
- 考虑是否有额外的兴趣,如果有进一步学习
(在没有预习的情况下,这套方案效率更高)
基础
Leecode-148. 排序链表
来源:https://leetcode-cn.com/problems/sort-list/
给你链表的头结点 head ,请将其按 升序 排列并返回 排序后的链表 。
进阶:
你可以在 O(n log n) 时间复杂度和常数级空间复杂度下,对链表进行排序吗?
解题思路
本题是一道基础的链表排序,从题意中可以明确期望不要使用额外空间并且用二分递归(归并排序、堆排序和快速排序)的方式进行排序。
但是由于是链表,链表的特性是头即代表一段数据,所以应该采用归并排序
(但是本题用作学习快排,所以用快排来解)
(复习一遍快排的思路:找一个基准值,实现将小于基准值放左侧,其余放右侧)
结合链表的数据结构,应该改为左链表,基准节点,右链表,随后进行拼接。但是链表结构其实存在一个难题,链表结构不能进行反向寻找节点,因此,本题实际上利用快排的解法为:
寻找基准点,遍历链表,将小于基准点的节点挂载第一个小于基准点的节点下,将其余节点挂在基准点下。
示例代码
(快排、未优化导致超出时间限制)
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode() {}
* ListNode(int val) { this.val = val; }
* ListNode(int val, ListNode next) { this.val = val; this.next = next; }
* }
*/
class Solution {
public ListNode sortList(ListNode head) {
if(head == null||head.next==null) return head;
int base = head.val;
ListNode lh = null, rh = null,temp = head;
ListNode le = null,re =null;
while(temp.next!= null){
temp = temp.next;
if(temp.val<base){
if(le == null){
lh = temp;
le = lh;
}else{
le.next = temp;
le = le.next;
}
}else{
if(re == null){
rh = temp;
re = rh;
}else{
re.next = temp;
re = re.next;
}
}
}
if(le!=null){
le.next = null;
}
if(re!=null){
re.next = null;
}
lh = sortList(lh);
rh = sortList(rh);
le = lh;
if(le!=null){
while (le.next!=null){
le = le.next;
}
le.next = head;
}else{
lh = head;
}
head.next = rh;
return lh;
}
}
(同样是快排,但是优化了基准值并多了一个小范围的的截止条件,从而满足了要求)
class Solution {
public ListNode sortList(ListNode head) {
if(head == null||head.next==null) return head;
ListNode temp = head;
double base ; //应对负数情况
int mi = head.val,mx = head.val;
while(temp!=null){
mi = Math.min(mi,temp.val);
mx = Math.max(mx,temp.val);
temp = temp.next;
}
if(mi==mx)
return head;
base = (mi+mx)/2.0;
temp = head;
ListNode lh = null, rh = null;
ListNode le = null,re =null;
while(temp!= null){
if(temp.val<=base){
if(le == null){
lh = temp;
le = lh;
}else{
le.next = temp;
le = le.next;
}
}else{
if(re == null){
rh = temp;
re = rh;
}else{
re.next = temp;
re = re.next;
}
}
temp = temp.next;
}
if(le!=null){
le.next = null;
}
if(re!=null){
re.next = null;
}
//System.out.println(le.val);
//System.out.println(lh.val);
lh = sortList(lh);
rh = sortList(rh);
le = lh;
if(le!=null){
while (le.next!=null){
le = le.next;
}
}else{
lh = head;
}
le.next = rh;
return lh;
}
}
(至于用归并怎么做,可以看官网的解析,讲的很详细)
拓展
Leecode-面试题 17.14. 最小K个数
来源:https://leetcode-cn.com/problems/smallest-k-lcci/
设计一个算法,找出数组中最小的k个数。以任意顺序返回这k个数均可。
解题思路
本题是很经典的例题了,绝大部分排序算法的学习都会跟这题扯上关系。本题的解题核心思路就是partition。
快排的核心思路中包含了左右区间,并且可以知道他们符合:小,中,大的关系,因此可以根据区间的大小,来判断是否需要继续细分:
- 当左区间大小大于k时,答案将在左区间中寻找
- 当左区间大小等于k时,答案就是左区间
- 当左区间大小小于k时,返回左区间和基准值,并在右区间内搜寻剩余答案
根据这3个规则写一个递归函数即可。
(本题在设计完毕基础解法后有2个优化方式,一个是优化基准值,另一个是改写为迭代)
(可以采用迭代的原因在于,本题可以丢弃一侧的区间,相当于左递归法不需要进行递归)
(最终我采用v2版本的快排代码,v3版本尝试了2个小时失败了,有兴趣的可以自己试一下)
示例代码
(迭代法,并优化基准值)
class Solution {
public int[] smallestK(int[] arr, int k) {
int [] res = new int [k];
if(k<=0)return res;
// Arrays.sort(arr);
// for(int i= 0;i<k;i++)
// res[i]=arr[i];
// if(true)
// return res;
int ki = 0,need_k = k;
int l = 0,r =arr.length-1;
int lp,rp;
while(need_k>0&&r-l>0){
int base = getMid(arr[l],arr[r],arr[(l+r)/2]);
if(base == arr[r]) swap(arr,l,r);
if(base == arr[(l+r)/2]) swap(arr,l,(l+r)/2);
//System.out.println("base---"+base);
lp = l;
rp = r;
//v2
//核心代码保持不变
while(lp<rp) {
while(lp<rp && arr[rp]>=base)rp--;
if(lp<rp) {
arr[lp++] = arr[rp];}
while(lp<rp && arr[lp]<=base)lp++;
if(lp<rp) arr[rp--] = arr[lp];
}
arr[lp] = base;
int left_len = lp -l;
if(left_len > need_k){
r = lp-1;
}else {
for(int i=0;i<left_len;i++){
res[ki+i] = arr [l+i];
}
if(left_len == need_k){
need_k = 0;
}else{
ki += left_len;
need_k -= left_len;
l = lp+1;
res[ki++] = base;
need_k--;
}
}
}
//Arrays.sort(res);
return res;
}
private static int getMid(int a,int b,int c){
int [] arr = {
a,b,c};
if(a>b) swap(arr,0,1);
if(a>c) swap(arr,0,2);
if(b>c) swap(arr,1,2);
return arr[1];
}
public static void swap(int[] data, int a, int b) {
int temp;
temp = data[a];
data[a] = data[b];
data[b] = temp;
}
}
Leecode-75. 颜色分类
来源:https://leetcode-cn.com/problems/sort-colors/
给定一个包含红色、白色和蓝色,一共 n 个元素的数组,原地对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。
此题中,我们使用整数 0、 1 和 2 分别表示红色、白色和蓝色。
示例 1:
输入:nums = [2,0,2,1,1,0]
输出:[0,0,1,1,2,2]
示例 2:
输入:nums = [2,0,1]
输出:[0,1,2]
示例 3:
输入:nums = [0]
输出:[0]
示例 4:
输入:nums = [1]
输出:[1]
解题思路
本题没有必要考虑什么颜色,就当成一个数组排序问题即可。简单的考虑可以是直接进行一轮排序,但是可以简化。
需要注意的细节是:将有可能出现大量的连续数字,并只有3种数字。
因此本题实际上可以理解为,把比中值大的数插入末尾,把比中值小的数插入头部。
(这是插入排序的思想,本题我认为是误导,只用快排理解总有不合理的地方)
根据这个思路,准备3个指针,一个指向头一个指向尾,这两个我称为端指针,当出现大或者小的情况时,将这个数根据情况和端指针指向的内容互换,并且端指针向内一步。
剩下的一个指针执行遍历的功能,当等于中值时,仅移动,其余时候进行交换。
最终当执行遍历的指针和右侧端指针相遇时,代表所有元素都判断完成,排序结束。
示例代码略
(本题如果一昧想着快排的哨兵,就很难解。唯一和快排搭边的就是相遇的概念)
思维训练
leecode-95. 不同的二叉搜索树 II
来源:https://leetcode-cn.com/problems/unique-binary-search-trees-ii/
给你一个整数 n ,请你生成并返回所有由 n 个节点组成且节点值从 1 到 n 互不相同的不同 二叉搜索树 。可以按 任意顺序 返回答案。
解题思路
(本题和快排无关)
理解本题,需要先明确什么是2叉搜索树,二叉搜索树左子树一定不小于根节点,右子树一定大于根节点,中序遍历的结果是一个有序数列。
(等于的情况本次不考虑)
(这里我提出一个解题思路,能否枚举所有的前序遍历然后和当前已经确定的中序遍历进行结合构建二叉树,根据这个结论来生成答案呢?)
(是不行的,瞎想了)
换一个思路,本题是在已知中序遍历的情况下,猜想所有的二叉树形状。因此可以直接选择构建二叉树方法如下:
- 遍历的选取一个树为根节点,将剩下的值分割为左子树的中序遍历和右子树的中序遍历
- 向下递归,需要传递内容为当前节点,左子树的中序遍历,右子树的中序遍历。
- 递归函数的返回结果为一个结果集,最终是进行左结果集乘右结果集,再向上返回。
(关于结果集相乘的概念为,即使确定了左子树的情况,也无法确定右子树的情况,因此在已知左右中序遍历的情况下,合成的树的可能性是两种各自的可能性的乘积)
(所以本题和快排一点关系都没有)
(因此从本课学习计划中排除)
leecode-394. 字符串解码
来源:https://leetcode-cn.com/problems/decode-string/
给定一个经过编码的字符串,返回它解码后的字符串。
编码规则为: k[encoded_string],表示其中方括号内部的 encoded_string 正好重复 k 次。注意 k 保证为正整数。
你可以认为输入字符串总是有效的;输入字符串中没有额外的空格,且输入的方括号总是符合格式要求的。
此外,你可以认为原始数据不包含数字,所有的数字只表示重复的次数 k ,例如不会出现像 3a 或 2[4] 的输入。
示例 1:
输入:s = "3[a]2[bc]"
输出:"aaabcbc"
示例 2:
输入:s = "3[a2[c]]"
输出:"accaccacc"
解题思路
(这题显然也和排序无关)
本题定义了一种规则:数字和括号
看到括号,很快就要意识到这是一个层次结构,想到层次结构很快要意识到这是递归与栈知识点的应用。
然后先用简单的解法,递归:
- 准备一个用于递归的解码函数入参为字符串,返回值为字符串
- 在函数内遍历字符,提取最外侧的数字和括号,将括号内元素向下递归
- 将返回值根据数字进行字符串拼接
(代码略)
思维训练2
Leecode 11. 盛最多水的容器
来源:https://leetcode-cn.com/problems/container-with-most-water/
给你 n 个非负整数 a1,a2,…,an,每个数代表坐标中的一个点 (i, ai) 。在坐标内画 n 条垂直线,垂直线 i 的两个端点分别为 (i, ai) 和 (i, 0) 。找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。
说明:你不能倾斜容器。
示例 1:
输入:[1,8,6,2,5,4,8,3,7]
输出:49
解释:图中垂直线代表输入数组 [1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49。
解题思路
(本题,感觉跟排序没有重要关系,感觉更像是穷举)
(并不是,仔细读题)
本题要把握一个关键点,容量的限制。容量由最短板,和板间距决定。因此:如果板长不变,则应尽可能的让板间距扩大。
而反向思维可以得到,如果板间距选择缩小,则应该试图找更长的板。
(至此,本题更趋向于贪心算法)
解题思路为:
- 先从最外侧选两块板作为初始板,计算容积
- 然后试图更换更短的那张板,当更换的结果能扩大容积时,更新最大容积
- 持续试图更换最短板,直到没有选择余地
(代码略,本题跟快排唯一的联系就是两个外侧指针向内靠拢的思想,属于碰瓷题)
Leecode 470. 用 Rand7() 实现 Rand10()
链接:https://leetcode-cn.com/problems/implement-rand10-using-rand7
已有方法 rand7 可生成 1 到 7 范围内的均匀随机整数,试写一个方法 rand10 生成 1 到 10 范围内的均匀随机整数。
不要使用系统的 Math.random() 方法。
解题思路
(本题是纯粹的练习思维了,跟课程没有一点关系)
已知可以生成随机数的区间为17,一共7个数,而期望新生成的目标为110共10个数。
那么首先要明确变化的区间是1+(06),而生成的区间为1+(09)。
由此就可以得出,期望令A区间((06))随机的结果可以等概率的分布在B区间(09)中。
(本题的难度我认为主要在调试上)
其次,A区间一定要全部使用吗?06的分布是均匀概率的,那么05的分布式均匀概率的吗,显然也是的。
因此我只需要用rand7生成一个能包含B区间的等概率分布区间,再从中截取B区间即可。
(理解本题需要一定的数学知识)
示例代码
(最简版本)
class Solution extends SolBase {
public int rand10() {
// int test = 0;
// for(int i=0;i<1100;i++){
// test += rand_10();
// }
// System.out.println(test/11);
return rand_10() ;
}
public int rand_10() {
for(;;){
int temp = (rand7()-1)*7+rand7(); //1~49 rand49
if(temp<=10) return temp;
}
}
}
思维训练3 (附加题)
Leecode 239. 滑动窗口最大值
链接:https://leetcode-cn.com/problems/sliding-window-maximum
给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。
返回滑动窗口中的最大值。
示例 1:
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
解释:
滑动窗口的位置 最大值
--------------- -----
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7
解题思路
(本题跟快排没有关系)
想要理解本题最重要的就是明白滑动窗口移动时变化了什么。
当移动时,窗口内将加入一个数,失去一个数,而窗口内有意义的只有最大值。
因此,在变化过程中,是2个变化因素去影响一个变化因素。逻辑上去判断一下这些因素是如何相互影响的:
- 当新加入的数大于当前的最值时,最值变化,更新为新加入的数
- 当失去的数等于当前的最值时,最值变化,需要重新选举一个
另外可以观测到窗口内的数可能是重复的。
由此生成第一个简单的解题思路:
- 准备一组变量用于存储窗口内的当前最值和最值的数量
- 准备一个函数用于选取一个区间内最值和最值的数目
- 新加入数时有3种情况
- 等于最值:最值数目加1
- 大于最值:最值更新,最值数目变为1
- 小于最值:无事发生
- 失去数是有2种情况
- 等于最值:最值数目-1,当最值数目归零时,重新进行选举
- 小于最值:无事发生
- 开始移动窗口
(此外我想不到有什么合适的数据结构能方便我解这题)
(简单的方案可以成功,接下来考虑进一步优化的方案)
进一步观察模型,可以发现,主要的性能损耗在于新最值的选举上,那么此处提出一个优化的思想,有没有一种办法让上一次最值选举的部分成果可以用于当前次的最值选举呢。
此处提出第一种优化方案:
已知新的高峰或等高锋在不离开前将一直是窗口的返回对象,因此,要有一个数据模型能够存储峰值以后的窗口内的递减的次级峰值。
(但是这种数据模型管理起来很困难)
此时回归峰值的概念,能不能发现一个细节,当出现一个峰值时,峰值前所有小于其的数都将在窗口中失去意义。
此处提出第二种优化方案:
通过实际赋值的方式,当一个新的数生成时,另窗口内所有小于该数的值变为该数,以这种模型运作时,将离开数始终会是窗口内的最大值
(但这种问题在于,每一个新的数生成时,都需要去窗口中跟上一个峰值比较来判断能否覆盖这个区间)
(搞不定)
示例代码
(基础解法)
class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
if(k==1)
return nums;
int max = 0;
int cnt = 0;
int r= 0+k-1;
int res [] = new int [nums.length -k +1];
for(int i = 0;r+i<nums.length;i++){
if(cnt ==0 ){
max = nums [i];
cnt = 1;
for(int j=1;j<k;j++){
if(nums [i+j]>max){
max = nums [i+j];
cnt = 1;
}else if(nums [i+j] == max){
cnt ++;
}
}
}
res [i] = max;
if(r+i+1<nums.length){
if(nums[r+i+1]>max){
//System.out.println(i);
max = nums [r+i+1];
cnt = 1;
}else if(nums[r+i+1]==max){
cnt ++;
}
}
if(nums [i] == max){
cnt--;
}
}
return res;
}
}
结语
学习完毕。本次用了4个时间段,累计用时越20小时,挑战失败。
- 至少需要一遍时间来看视频包括回放约4h
- 至少需要一段时间来想怎么描述概念并写下来约 4h
- 至少需要一段时间来抄题目,约1h
- 至少需要一段时间来解题,约5h
- 当一道题目做不出来的时候至少深入思考1~2H
本次学习被几道题目难住额外消耗了几个小时,其余的已经没有压缩的空间了。我认为学习最重要的是证明自己学会了,否则只是浪费时间。
本次用于解题的时间预计10H小时,如此高耗时的根本原因在于每一题都尝试解题,提供完整的解题思路,不像之前部分题目简写解题思路,甚至略写。并且本次学习过程中,习题过程完全脱离视频,由此可见自学的能力还是不如听课的。
综上,我期望下一次我能对于非重点题减少投入,并减少死磕难题的行为,充分发挥学习视频的收益,提高学习效率。
目前实验得出效率最高的学习方案为:
- 先听概念,听的时候写大纲,然后做笔记,需要用时2~3倍时间,约4H
- 再预习题目,每题完成读题,理解,需要用时1H
- 接着跟着视频,过完每题的思路。需要0.8倍时间,约3H (本次跳过了这个步骤导致超时)
- 最后整理代码,需要1~2倍时间,约5H
加油ε≡٩(๑>₃<)۶ 一心向学