Leetcode算法笔记

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;
        
    }
}

同样的应用还有

739. 每日温度

85. 最大矩形

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 二叉搜索树

二叉搜索树在很多地方都有妙用,比如排序,边界问题

220. 存在重复元素 III

此题就是在滑动窗口内找元素,一般的方法是遍历,但是我们只需要找出最接近当前元素的两个值,可以用二叉搜索,维持一种序列,很方便的就找出最接近当前元素的两个值(此题也可用同桶排序)

注意此题不适合用单调队列,因为单调队列维护的是窗口里面的最值,此题不是找最值,而是最接近目标元素的值

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章