Leetcode算法笔记
做题思路
1 拿到题目首先仔细的理解问题,找到问题的特点,有些题目有暴力的方式会很慢,但是只要找到问题本身特点,就会很快。
1423. 可获得的最大点数此题暴力方式是dfs,遍历全部,指数级别 的复杂度,但是可以找到突破口就是实际上就是左右选择长度的问题,左边选1个,右边选k-1个,复杂度降到了线性。
2 找到问题的切入点,有些题目看似无从下手,但是可以有个切入点供我们突破
312. 戳气球此题切入点就是计算一个序列最后戳破的气球
3 心中预计一下时空复杂度,想想可不可以或者有没有更好
4 遇到模板题或者做过的题目不要急,思考一下。
1 算法数据结构精髓
结构化
结构化的根本目的是用特殊的结构保存已有信息避免重复计算。很多算法都利用了这个特性。比如:
快速排序,我们在对数组进行排序,直观的来说,必须把全部数据两两比较,才能分出大小,不然如果存在两个数没有比较,我们如何才能知道这两个数谁大谁小呢?其实也可以,假如我们知道a<b,b<c,那么不用比较ac我们也知道ac的大小。所以快排排序用人为规定的结构来记录这些信息,避免重复比较:
每次选择一个数,把小于这个数的放在左边,把大于这个数的方法,这样左边右边的大小就不必比较,从而节省大量的比较次数。
二叉搜索树,人为去规定树的结构,即:对于一个节点,左子树全部元素小于它,右子树全部元素大于它,这样无论是搜索还是插入,都节约了很多搜索次数。
单调队列单调栈,人为定义其单调性,这样能极大提高搜索速度,比如用二分搜索代替遍历,用队列的头直接找到最大值,而不用遍历整个队列去找最大值,而维护他们也能保持在O(n)的复杂度,因为每个元素,最多都会进去一次,出来一次。
贪心化
不少问题都是寻优问题,比如求最值等,这样可能问题存在贪心性质,当前的选择可以不全部遍历,而是选择当前看起来比较好的选择,这样可以避免遍历所有选择,从而加快运算。比如最短路问题,最小生成树问题。
记忆化
很多子问题是重复的,我们不必重复计算它,可以直接把子问题结果保存下来,从而极大减少运算。比如记忆化的回溯或者动态规划,他们的区别是一个是自顶向下,一个是自底向上。
分治
将一个问题分为若干个子问题,从而避免重复计算,如快速排序。
2 java技巧
subList 可以轻松取一段List
lmabda 表达式可以套用别的函数,hashmap
新建list 可以直接导入Collection
平均值:(a&b) + ((a^b)>>1)
Arrays.binarySearch(int[] a, start,end,key) 找到返回key的下标,找不到返回-x-1
3 常见坑
-
连续的if判断一定要用if else 而不是 if if,因为第一个if 会改变状态,导致互斥的两个if可能同时满足!
-
一定要看清楚题目的数据范围,输出条件!
-
dp 搞清楚状态和初始值就成功了一大半。
-
Integer 不能用==判断
4 绕人的递归
有些题目的递归非常绕人,很难想出正确的递归函数,但是把握住一点:只关心当前函数的输入,功能和返回值,基本就成功了,并且假设子递归已经成功的完成了功能(实际也是完成了),我们只需要在子递归的基础上做出当前的计算就行了。
比如:
337. 打家劫舍 III,此题难点在于对于树的动态规划,需要自底向上,从树叶开始到树根,必须写出一个正确的递归函数。我们现在只关心函数本身的功能,就是返回一个数组,表示当前子树偷或者不偷的最大值,那么状态转移方程可以直接从子树里面得到。
输入:一个root
返回值:一个数组
功能:计算数组
class Solution {
public int rob(TreeNode root) {
int[] res = dfs(root);
return Math.max(res[0],res[1]);
}
public int[] dfs(TreeNode root){
if(root==null) return new int[2];
int[] res =new int[2];
int[] left = dfs(root.left);//关键点,把函数看作一个功能的黑盒,不管它内部,我们把当前
int[] right=dfs(root.right);//函数写对,其他的自然就对了,此时假设这两个数组都成功计算
//接下来我们只需要在这个功能的基础上算出当前的就行了!!
res[0]=Math.max(left[0]+right[0],Math.max(left[1]+right[1],Math.max(left[0]+right[1],left[1]+right[0])));
res[1]=root.val+left[0]+right[0];
return res;
}
}
206. 反转链表此题用递归或者循环都非常烧脑,先说递归:
我们首先关心递归的本身功能:翻转一个链表,输入表头head,返回翻转后的表头,并且把 head的next变成null
输入:一个head
返回值:head连接的链表翻转之后的表头
功能:翻转链表,并且把head的next 变成空
class Solution {
public ListNode reverseList(ListNode head) {
if(head==null) return null;
if(head.next==null) return head;
ListNode root = reverseList(head.next);//假设后面的已经完成!!!
head.next.next=head;//翻转当前的
head.next=null;//
return root;//返回值
}
}
5 左右两趟
有些题目需要左边的右边的信息来计算,一种好用的方式是左右两趟计算出一些信息,再进行综合。
例题:
135. 分发糖果此题一个人的糖果由左右的最低谷决定,很方便的可以 分别从左向右 从右向左跑一边最低值,然后在两者之前找最大值。
class Solution {
public int candy(int[] ratings) {
if(ratings.length==0) return 0;
if(ratings.length==1) return 1;
if(ratings.length==2) return ratings[0]-ratings[1]==0?2:3;
int ans=0;
int[][] t = new int[2][ratings.length];
//t[0][0]=1;
for(int i=1 ;i<ratings.length;i++){
if(ratings[i]>ratings[i-1]){//左边最小值
t[0][i]=t[0][i-1]+1;
}
// else{
// t[0][i]=1;
// }
}
ans+=Math.max(t[0][ratings.length-1],t[1][ratings.length-1]);
for(int i=ratings.length-2;i>=0;i--){
if(ratings[i]>ratings[i+1]) t[1][i]=t[1][i+1]+1;//右边最小值
ans+=Math.max(t[0][i],t[1][i]);//必须同时满足左边和右边
}
return ans+ratings.length;
}
}
239. 滑动窗口最大值 分别记录左右窗口的最大值
581. 最短无序连续子数组用栈找左右边界
6 动态规划
将一个完整的问题拆分成能一步步扩大的子问题。主要目的是三个:
1 便于求解
当我们面对一个问题束手无策的时候,动态规划或者类似的思想能让我们不全部考虑,而是碰瓷一样,故意把一个完整的问题搜小一步,找到状态方程,类似于左脚踩右脚上天。
需要注意的地方有两个:
一是定义的状态能给完整的表达问题,不能表达,就增加状态的维度,不怕状态多就怕状态少,比如子串问题一般是两个状态dp(i)(j)表示从第i到第j长度的字串的某个性质。
而状态方程要考虑所有可能情况,不怕考虑多,就怕考虑少。(这也是可以优化的地方,比如最长上升子序列用单调性+二分查找)
2 记忆化
尤其在DFS中,回重复计算子问题,如果把子问题的解都记录下来,那么复杂度会和动态规划一模一样,DFS是自顶向下,更方便理解,动态规划是自底向上。当面对一个问题束手无策的时候,可以用最暴力的DFS搜索所有解,然后考虑记忆化。
3 状态化
几乎所有动态规划问题都能画出状态图
7 滑动窗口
特点:窗口中记录信息,窗口向右滑动的时候更新左右边界信息
经典问题
滑动窗口中位数
滑动窗口中位数难度困难64中位数是有序序列最中间的那个数。如果序列的大小是偶数,则没有最中间的数;此时中位数是最中间的两个数的平均数。
例如:
[2,3,4],中位数是 3
[2,3],中位数是 (2 + 3) / 2 = 2.5
给你一个数组 nums,有一个大小为 k 的窗口从最左端滑动到最右端。窗口中有 k 个数,每次窗口向右移动 1 位。你的任务是找出每次窗口移动后得到的新窗口中元素的中位数,并输出由它们组成的数组。
链接:https://leetcode-cn.com/problems/sliding-window-median
方法:用一个大堆和小堆记录中位数,每次更新。
滑动窗口和单调队列结合
滑动窗口最大值
给定一个数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。
返回滑动窗口中的最大值。
链接:https://leetcode-cn.com/problems/sliding-window-maximum
需要用单调队列记录窗口中的信息,不然每次都需要扫描窗口。或者巧妙用dp(见左右两趟),不过很难想到。
8 字典树、前缀树
特点:用字典树去记录单词,把时间复杂度从单词个数n*L降低到O(L)
细节:树的开头最好不存放单词
典型结构:
class Tree{
Tree[] c;
boolean isword;
public Tree(){
c = new Tree[26];
}
}
9 并查集
特点:快速合并两个子图,可以用来判断连通性,寻找连通子图个数,压缩路径的并查集union和find 可以认为时间复杂度是O(1)。
压缩路径方法:
int find(int[] a,int x){
while(a[x]!=x){
a[x]=a[a[x]];//压缩路径
x=a[x];
}
return x;
}
合并方法:
void union(int[] a,int i,int j){
int x = find(a,i);
int y = find(a,j);
a[x]=y;
}
带权值的并查集
399. 除法求值方法:给每个方向加上权值进行计算。
10 线段树
特点:有一颗用数组表示的完全二叉树,每个节点都有区间,叶子节点的区间是1
结构:
class Tree{
int l;
int r;
int value;
}
11 单调队列
可以用来找子数组子序列的最大值最小值,比如滑动窗口最大值问题: 滑动窗口最大值
此处用单调队列的目的是,我们可以不用遍历,就能通过单调队列来始终保存窗口最大值。
而且单调队列可以二分查找,参考最大上升子序列
12 单调栈
和单调队列类似,我们用结构性的数据结构来保存信息,达到不用重复计算的目的。这也是个人理解数据结构的精髓之一,类似的结构很多,比如快速排序,二叉查找树,最大堆等。而单调栈特别适合处理边界问题!
只要确定左右边界,就立马出栈计算!
单调栈的典型问题就是接雨水:接雨水,每一个空间能接的雨水数量仅仅和它的左右比它高的边界有关系,而用单调栈很容易维护这种边界关系,不是边界就入栈,是边界就会出栈,每一个元素都会在出栈的时候计算。
import java.util.*;
class Solution {
public int trap(int[] height) {
if(height.length<=2){
return 0;
}
int ans=0;
int lmax=0;
LinkedList<Integer> s = new LinkedList<Integer>();
s.addLast(0);
for(int i=0;i<height.length;i++){
while(s.size()>1 && height[i]>=lmax){//确定左边边界,立马计算
int d = s.removeLast();
ans=ans+Math.max(0,Math.min(height[i],lmax)-d);
}
s.addLast(height[i]);
lmax=Math.max(lmax,height[i]);
}
int rmax=s.getLast();
while(s.size()>1){
int d =s.removeLast();
ans=ans+Math.max(0,Math.min(rmax,lmax)-d);//计算
rmax = Math.max(rmax,d);
}
return ans;
}
}
还有柱状图中最大的矩形,同样是边界关系。
class Solution {
public int largestRectangleArea(int[] heights) {
if(heights.length==0)
return 0;
int ans=0;
Stack<Integer> s =new Stack<>();
s.push(-1);
for(int i=0;i<heights.length;i++){
while(s.peek()!=-1&&heights[s.peek()]>heights[i]){//不单调说明出现边界
ans=Math.max(ans,heights[s.pop()]*(i-1-s.peek()));//边界仅仅和相邻的元素有关系
}
s.push(i);
}
int t=s.peek();
while(s.peek()!=-1){
ans=Math.max(ans,heights[s.pop()]*(t-s.peek()));
}
return ans;
}
}
同样的应用还有
581. 最短无序连续子数组用栈找左右边界
13 子序列问题
最长上升子序列
方法一:用dp[i]记录第i个元素为结尾的最长序列,每次回头找最长的:
dp[i]=max(dp[i-1]) i=0~i-1 ,num[i-1]<num[i]
复杂度n^2
方法二:维护一个单调dp,dp[i]表示i为长度为i的子序列的末尾的最小值
dp是单调上升的,每次用二分查找更新dp,这也是性能可以提高的原理
复杂度nlogn
单调队列用数组模拟会快很多
相关问题
俄罗斯信封套娃
堆箱子
14 染色/二分图问题
1.深度优先搜索
搜索这个图,并且交替染色,如果出现冲突则不是二分图,复杂度O(V+E)
class Solution {
ArrayList<Integer>[] graph;
Map<Integer, Integer> color;
public boolean possibleBipartition(int N, int[][] dislikes) {
graph = new ArrayList[N+1];
for (int i = 1; i <= N; ++i)
graph[i] = new ArrayList();
for (int[] edge: dislikes) {
graph[edge[0]].add(edge[1]);
graph[edge[1]].add(edge[0]);
}
color = new HashMap();
for (int node = 1; node <= N; ++node)
if (!color.containsKey(node) && !dfs(node, 0))
return false;
return true;
}
public boolean dfs(int node, int c) {
if (color.containsKey(node))
return color.get(node) == c;
color.put(node, c);
for (int nei: graph[node])
if (!dfs(nei, c ^ 1))
return false;
return true;
}
}
2.奇偶状态转移
现在只考虑边,首先对边进行排序,然后交替对边的顶点进行奇偶赋值,出现冲突就不是二分图
class Solution {
public boolean possibleBipartition(int N, int[][] dislikes) {
int[] dp = new int[N+1];
int k = 1;
for (int[] dislike : dislikes) {
int a = dislike[0], b = dislike[1];
if (dp[a] == 0 && dp[b] == 0) {//都为初始状态
dp[a] = k++;
dp[b] = k++;
} else if (dp[a] == 0) {
dp[a] = dp[b] % 2 == 0 ? dp[b] - 1 : dp[b] + 1;
} else if (dp[b] == 0) {
dp[b] = dp[a] % 2 == 0 ? dp[a] - 1 : dp[a] + 1;
} else { //都不为初始状态
if(dp[a] == dp[b]) return false;
}
}
return true;
}
}
复杂度O(E)或者O(E+log(E))
15 划分分治
将一个数组划分成两个部分,用于快速排序,求第K大的数,数组逆序数。
快速排序:
public void qiuksort(int[] a,int l,int r){
if(l>=r) return;
int p = partition(a,l,r);
qiuksort(a,l,p-1);
qiuksort(a,p+1,r);
}
public int partition(int[] a,int l,int r){
int t =a[l];
while(l<r){
while(l<r){
if(a[r]<t){
a[l]=a[r];
break;
}
else r--;
}
while(l<r){
if(a[l]>t){
a[r]=a[l];
break;
}
else l++;
}
}
a[l]=t;
return l;
}
三分快速排序
public void qiuksort2(int[] a,int l,int r){
if(l>=r) return;
int ll=l;
int rr=r;
int i = l+1;
int t = a[l];
while(i<=rr){
if(a[i]==t) i++;
else if(a[i]>t) exchange(a,rr--,i);
else exchange(a,ll++,i++);
}
qiuksort(a,l,ll-1);
qiuksort(a,rr+1,r);
}
数组中的第K大元素:
class Solution {
public int findKthLargest(int[] nums, int k) {
int n = nums.length;
for(int i=n-1;i>=1;i--){
int x=(int)(Math.random()*(i+1));
int t = nums[x];
nums[x]=nums[i];
nums[i]=t;
}
return partition(nums,k,0,n-1);
}
public int partition(int[] nums,int k,int l,int r){
int x=l;
int y=r;
if(l>=r) return nums[l];
int t = nums[l];
while(l<r){
while(l<r){
if(nums[r]>t){
nums[l]=nums[r];
break;
}
r--;
}
while(l<r){
if(nums[l]<t){
nums[r]=nums[l];
break;
}
l++;
}
}
nums[l]=t;//划分
if(l==k-1) return nums[l];。。找到第k个
if(l<k-1) return partition(nums,k,l+1,y);//之后
return partition(nums,k,x,l-1);//之前
}
}
逆序对问题,在分治排序和合并过程中计算逆序对,由于两边都是排序的,所以不用回溯,并且对下标排序不改变原来数组,并且计算右边有多少个元素比左边小的时候,由于右边是排好序的,所以每次应该计算区间。
class Solution {
int[] index;
int[] temp;
int ans=0;
public int reversePairs(int[] nums) {
int n = nums.length;
if(n<2) return 0;
index = new int[n];
temp = new int[n];
for(int i=0;i<n;i++){
index[i]=i;
}
mergesort(nums,0,n-1);
// System.out.println(Arrays.toString(index));
return ans;
}
public void mergesort(int[] nums,int l,int r){
// System.out.println(l+" "+r);
if(l>=r) return;
int mid = l+((r-l)>>1);
mergesort(nums,l,mid);
mergesort(nums,mid+1,r);
if(nums[index[mid]]<=nums[index[mid+1]]) return;
merge(nums,l,mid,r);
}
public void merge(int[] nums,int l,int mid,int r){
for(int i=l;i<=r;i++){
temp[i]=index[i];
}
int i=l;
int j=mid+1;
for(int k=l;k<=r;k++){//排序后,因为左右都是有序的,可以不回溯,所以复杂度是n
if(i>mid){
temp[k]=index[j++];
}
else if(j>r){
temp[k]=index[i++];
ans=ans+r-mid;//计算区间
}
else if(nums[index[i]]<=nums[index[j]]){
temp[k]=index[i++];
ans=ans+j-mid-1;//计算区间,而不是只算一个
}
else{
temp[k]=index[j++];
}
}
for(i=l;i<=r;i++){
index[i]=temp[i];
}
}
}
翻转对:排序后,因为左右都是有序的,可以不回溯,所以复杂度是nlongn
class Solution {
int ans;
int[] t;
public int reversePairs(int[] nums) {
int n =nums.length;
if(n<2) return 0;
t = new int[n];
ans=0;
mergersort(nums,0,n-1);
//System.out.println(Arrays.toString(nums));
return ans;
}
public void mergersort(int[] nums,int l,int r){
// System.out.println(l+" "+r);
if(l==r) return;
int mid = l+((r-l)>>1);
mergersort(nums,l,mid);
mergersort(nums,mid+1,r);
merge(nums,l,mid,r);
}
public void merge(int[] nums,int l,int mid,int r){
int j = mid+1;
for(int i=l;i<=mid;i++){//排序后,因为左右都是有序的,可以不回溯,所以复杂度是n
while(j<=r){
long g=nums[j];
if(nums[i]>(g<<1)){
j++;
}
else break;
}
ans=ans+j-mid-1;
}
int x=l;
int y=mid+1;
for(int i=l;i<=r;i++){
t[i]=nums[i];
}
for(int i=l;i<=r;i++){
if(x>mid){
nums[i]=t[y++];
}
else if(y>r){
nums[i]=t[x++];
}
else if(t[x]<t[y]){
nums[i]=t[x++];
}
else{
nums[i]=t[y++];
}
}
}
}
16 左右指针/双指针
和滑动窗口类似,核心是指针不回溯,从而达到时间复杂度是线性。
比如三数只和四数之和 ,可以排序之后左右指针进行夹逼,不回溯指针
17 哈希表
用哈希表来存储信息,达到随机搜索的目的,应用很多,比如
两数之和问题:两数之和用哈希表记录一个数
class Solution {
public int[] twoSum(int[] nums, int target) {
int[] ans = new int[2];
HashMap<Integer,Integer> mp = new HashMap<>();
for(int i=0;i<nums.length;i++){
if(mp.containsKey(target-nums[i])){
ans[0]=mp.get(target-nums[i]);
ans[1]=i;
return ans;
}
mp.put(nums[i],i);
}
return ans;
}
}
和为K的子数组,两数之和的变形
public class Solution {
public int subarraySum(int[] nums, int k) {
int count = 0, pre = 0;
HashMap < Integer, Integer > mp = new HashMap < > ();
mp.put(0, 1);
for (int i = 0; i < nums.length; i++) {
pre += nums[i];
if (mp.containsKey(pre - k))
count += mp.get(pre - k);
mp.put(pre, mp.getOrDefault(pre, 0) + 1);
}
return count;
}
}
一些和前缀和有关的问题都可以往两数之和上面靠拢
18 摩尔投票
169. 多数元素相互抵消,每次选择两个不同的元素相互抵消,最终留下来的就是大多数元素
class Solution {
public int majorityElement(int[] nums) {
int n=0,count=0;
for(int a:nums){
if(count==0) n=a;
count+=(a==n?1:-1);
}
return n;
}
}
19 二叉搜索树
二叉搜索树在很多地方都有妙用,比如排序,边界问题
此题就是在滑动窗口内找元素,一般的方法是遍历,但是我们只需要找出最接近当前元素的两个值,可以用二叉搜索,维持一种序列,很方便的就找出最接近当前元素的两个值(此题也可用同桶排序)
注意此题不适合用单调队列,因为单调队列维护的是窗口里面的最值,此题不是找最值,而是最接近目标元素的值