从开始刷leetcode到现在正好31天(一个月)的时间,在这一个月里,也刷了300到题。从时间上来说并不算长,就题量而言也并不算多,但在这个小阶段里的无论是从心态上、算法思维上、做题技巧上等方面的收获还是很多。
1.前言
在准备刷leetcode之前还是做了很大的心理斗争的。虽然在学校里学了一些基础的数据结构和经典算法,但学习的内容只是仅限与书面上的内容,并没有很多具体的场景去应用这些内容。
开始刷题时,那必然选择的是 两数之和
。做完这道题我的第一反应:就这?。是的,我膨胀了,直接挑战中等难度的题并且看上去类似的题目三数之和
。结果终究是我年轻了,没做出来。之后又尝试了几道题目,直接给我整崩溃了。
在崩溃之余,我还是决定要认真、系统的学以下算法题了。我在小破站找了一些算法课程,开始变学习边刷题。其中,我个人认为讲的很好的使用就是左程云的算法课程。左程云算法课程
在刷题的过程中,主要是以简单和中等难度的题目为主,对于困难难度的题目还不能解决,所以并没有把重心放在这个难度的题目上。在初期,主要以简单类型的题目为主,这部分题目大多可以根据简单的业务逻辑编写相应的代码,对算法思维的考验并不是很大,主要是对coding能力的检验。这也渐渐的给了我信心,开始想中等难度的题目转移。对于这类题目,对基本的数据结构和算法思维有较高的要求。但再不断的刷题过程中,也不断的提升了我相应的能力。
总的来说,在整个刷题过程中,从开始的心态崩溃、到学习算法时的尝试、最后到现在的渐入佳境,我的能力也不断地提升。根据我自身的体会,我得出的建议就是:不要怕这些题,无非就是花时间学,花精力去做。有了一定的积累,就会量变引起质变,发现这些算法题都是有技巧、有规律、有模板的。
2. 思维及技巧
1. 双指针
双指针的技巧主要应用于数组、链表等线性数据结构。双指针在实际题目中的应用又有很多形式,主要可以分为两大类:左右指针
和快慢指针
。对于其它形式的双指针应用大多也都是利用双指针的移动、双指针指向内容进行变化。
(1)左右指针
-
二分查找:leetcode中的第704题,其考察内容就是最经典的二分查找算法。二分查找可以说是左右指针在实际应用中最典型的例子,可以大大提高遍历数组的效率。
public int search(int[] nums, int target) { int result=-1,left=0,right= nums.length-1; while (left<=right){ int mid=(left+right)/2; if (nums[mid]==target) { result=mid; break; } else if (nums[mid]<target) left=mid+1; else right=mid-1; } return result; }
-
有序数组的平方:leetcode中的第977题,该题的解决思路也可使用左右指针解决。
public static int[] sortedSquares(int[] nums) { int length= nums.length,left=0,right=length-1; int[] result=new int[length]; int powLeft=0,powRight=0,index=length-1; while (index>=0){ powLeft=nums[left]*nums[left]; powRight=nums[right]*nums[right]; if (powLeft>=powRight){ result[index]=powLeft; left++; index--; }else { result[index]=powRight; right--; index--; } } return result; }
左右指针的本质就是让两个指针依据某一规则相向而行或相背而行。由于左右指针的特性,也让其的应用范围有了一定限制,左右指针在数组中的应用较多,但对于单链表而言,左右指针就显得无能为力了。
(2)快慢指针
-
删除链表的倒数第N个节点:leetcode中的第19题,若用Map记录每个节点进而删除或其它思路的话,虽然可以解决但效率并不高。而若用快慢指针的思路的话,利用两指针初始位置差即可很好的解决该问题。
public ListNode removeNthFromEnd(ListNode head, int n) { ListNode virtual=new ListNode(); virtual.next=head; ListNode p1=virtual,p2=head; for (int i=0;i<n;i++) p2=p2.next; while (p2!=null){ p1=p1.next; p2=p2.next; } p1.next=p1.next.next; return virtual.next; }
-
环形链表:leetcode中的第141题、142题都是环形链表系列的题目,该类题目使用快慢指针技巧只需最多遍历两次链表就可以解决该类问题。
public ListNode detectCycle(ListNode head) { if (head==null||head.next==null) return null; ListNode slow=head,fast=head; do { if (fast!=null&&fast.next!=null) { fast = fast.next.next; slow = slow.next; }else return null; }while (fast!=slow);//判断存在环 if (fast!=null) {//填补快慢指针步差 slow = head; while (slow!=fast){ slow=slow.next; fast=fast.next; } } return fast; }
快慢指针主要应用与链表结构中,快慢指针与左右指针的不同之处是两只针同向而行,一快一慢。快慢指针的快、慢主要体现在初始位置的差距或在行进过程中两指针前进距离的差距。
2. 滑动窗口
滑动窗口其实是双指针演变出来的技巧。主要应用与数组结构中。其算法框架大致为:
public void slidingWindow(int[] arr){
int left=0,right=0;
// ……其它记录变量
while(right<arr.length){
right++;//窗口右边界扩充
// 窗口扩充后窗口内数据的更新
……
while(window shrink condition){//窗口收缩条件
//判断窗口左边界
left++;//窗口移动
// 窗口内数据更新
……
}
}
}
-
长度最小的子数组:leetcode中的第209题,使用滑动窗口只需遍历一遍数组即可解决,大大提高了效率。
public int minSubArrayLen(int target, int[] nums) { int min=Integer.MAX_VALUE,left=0,right=0,sum=nums[0]; while (left< nums.length){ if (sum<target&&right!=nums.length-1){//当前窗口和小于target且右边界未达最右侧 right++; sum+=nums[right]; }else if (sum>=target){//当前窗口和大于等于target min=Math.min(min,right-left+1); sum-=nums[left]; left++; }else if (right== nums.length-1){//窗口右边界到底,左边界移动 sum-=nums[left]; left++; } } return min==Integer.MAX_VALUE? 0:min; }
使用滑动窗口思想时只需考虑以下几个条件:
- 窗口扩大后需更新窗口内的信息
- 窗口缩小的条件
- 窗口缩小时需更新窗口内的信息
3. 辅助结构
在处理某些问题时,需根据题目的要求得出延申信息,再根据这些信息得出最终答案。对于这种题目,可以引入一个辅助空间记录延申信息,在根据这些信息得出答案。
(1)前缀和
-
除自身以外数组的乘积:leetcode中的第238题,对于该题目,可以引入一个辅助数组用于记录每个元素的前缀乘积,然后根据该辅助数组得出答案。
public int[] productExceptSelf(int[] nums) { int length= nums.length; int[] leftMulti=new int[length],rightMulti=new int[length],result=new int[length]; for (int i=0;i<length;i++){ if (i==0) leftMulti[0]=1;//第0个左侧累乘为1 else leftMulti[i]=leftMulti[i-1]*nums[i-1];//第i个左侧累乘为i-1的累乘*i-1的元素 } for (int i=length-1;i>=0;i--){ if (i==length-1) rightMulti[length-1]=1;//第length-1个右侧累乘为1 else rightMulti[i]=rightMulti[i+1]*nums[i+1];//第i个右侧累乘为i+1的累乘*i+1的元素 } for (int i=0;i<length;i++){ result[i]=leftMulti[i]*rightMulti[i];//左侧累乘*右侧累乘 } return result; }
前缀和只是辅助结构技巧中的一种体现,辅助结构要根据题目信息来规定所记录的信息有哪些。
(2)辅助空间
-
接雨水:leetcode中的第42题,该题虽然是困难程度的题目,但使用辅助空间的技巧就能很好的解决。
public int trap(int[] height) { int result=0,length= height.length; int[] leftHeight=new int[length],rightHeight=new int[length]; int temp=height[0]; for (int i=0;i<length;i++){//i位置左侧最高值 if (i==0) leftHeight[i]=0; else { if (temp>height[i]) leftHeight[i]=temp; else { temp=height[i]; leftHeight[i]=0; } } } temp=height[length-1]; for (int i=length-1;i>=0;i--){//i位置右侧最高值 if (i==length-1) rightHeight[i]=0; else { if (temp>height[i]) rightHeight[i]=temp; else { temp=height[i]; rightHeight[i]=0; } } } for (int i=0;i<length;i++){ int min=Math.min(leftHeight[i],rightHeight[i]); if (min>height[i]) result+=min-height[i];//两侧高度大于当前高度,则能接雨水 } return result; }
在引入辅助结构时并不仅限与数组结构,要根据题目需要引入对应的结构(队列、栈、大小根堆等),必要时甚至要改写原本的数据结构或自定义一个数据结构。
4. 二叉树模板
关于二叉树的总结,我单写了一篇文章。文章中详细的总结了二叉树一些技巧。二叉树模板总结
5. 动态规划(由暴力递归到动态规划)
动态规划的问题一般为解决最值问题,动态规划的关键即是列出状态转移方程,然而状态转移方程对于新手而言并不是很友好。因此,可以换一种思路来解决动态规划,动态规划是求最值,那么我们可以把所有情况都列出来,然后选最值情况就解决了。在根据这个思路我们可以不断的优化直至得出状态转移方程。
以简单的斐波那契数列为例,完成由暴力递归优化到动态规划。
(1)暴力递归
public static void main(String[] args) {
System.out.println(forceRecursion(20));
}
public int forceRecursion(int n){
if (n==1||n==2) return 1;
return forceRecursion(n-1)+forceRecursion(n-2);
}
暴力递归就是回溯,将所有请求均列出来然后得到答案。对于暴力递归方法,只需确认base case及递归条件即可。
(2)缓存递归
public static void main(String[] args) {
int[] memory=new int[21];
System.out.println(cacheRecursion(20,memory));
}
public int cacheRecursion(int n,int[] memory){
if (n==1||n==2) return 1;
if (memory[n]!=0) return memory[n];
memory[n]=cacheRecursion(n-1,memory)+cacheRecursion(n-2,memory);
return memory[n];
}
缓存递归相较于暴力递归而言,大量减少了重复运算,进而提高了效率。每次将运算结果存入某个结构中,当再次计算该运算式时,无需运算,只需查表即可。
(3)动态规划
public static void main(String[] args) {
System.out.println(dynamicProgrammingTable(20));
}
public static int dynamicProgrammingTable(int n){
int[] resultTable=new int[n+1];
resultTable[1]=resultTable[2]=1;
for (int i=3;i<=n;i++){
resultTable[i]=resultTable[i-1]+resultTable[i-2];
}
return resultTable[n];
}
动态规划表相较于相较于暴力递归和缓存递归,不仅大量减少了重复运算,而且降低了空间复杂度。其本质为模拟了暴力递归的递归过程,并利用了缓存递归的缓存记录思想。利用一个数组(或其它结构)将每次递归的结果记录,利用递归调用时的特点进行数组内的计算。即当计算f(20)时就等同于获取resultTable[20]的值,根据递归调用特点可以列出状态转移方程:f(n)=f(n-1)+f(n-2)。可知要不断得出resultTable[19]、resultTable[18]、resultTable[17]……。因此将resultTable[1]、resultTable[2]根据base case初始化为1,进而依次得出resultTable[3]、resultTable[4]……
在使用动态规划表时,首先分析base case的特殊情况结果,再根据递归调用特点列出状态转移方程
(4)动态规划的空间压缩
public int dynamicProgrammingTableCompress(int n){
int last=1,cur=1,next=0;
for (int i=3;i<=n;i++){
next=last+cur;
last=cur;
cur=next;
}
return next;
}
在某些可以使用动态规划表解决的问题中,可能存在问题规模十分巨大的情况,需要耗费的空间十分庞大,所造成的空间浪费也十分巨大。因此,可以对该动态规划表进行压缩,如:一个二维动态规划表可以压缩为一个一维数组进行操作,只需明确原二维动态规划表中各个元素的依赖关系即可。空间压缩技巧可讲二维动态规划表降为一维数组,一维数组降为常数个变量。
动态规划表的空间压缩并不适用于所有的动态规划表,若某一动态规划表中各个元素的依赖关系较大、较复杂则不适用压缩技巧。
在一些题目中,根据题目信息所得出的规划过程及base case可能造成递归过程无限循环依赖,造成无法求解。因此,需根据实际情况增加自定义的base case使得递归过程可以终止。例如:寻找平凡解(即是寻找一个可以解决的方案,该方案不一定为最优方案,但可作为最优方案的筛选标准,进而使递归过程得以终止。)、根据题意建立条件限制关系。
3. 总结
-
300道题目对我的算法思维有了很大的提升,也学会了很多做题技巧。
-
心态很重要,不要刚开始就因为困难就不去做。只有当静下心来,一步一步的去做才能有收获,至于收获有多少就交给方法和时间了。
-
做过的300道题目中对很多方面还为涉及,接下来的刷题还应涉及以下几个方面
-
图论
-
贪心算法
-
特殊数据结构(如:并查集、前缀树、线段树)
-
经典算法及其变型
- 10大排序算法
- KMP算法
- Manacher算法
- 摩尔投票算法
……
-