涛涛leetcode算法笔记

26 阅读14分钟

PS:这是我在刷leetcode的时候做的一些笔记,提到了很多框架,套路,这些是为了解题,但是算法真正吸引人的,是逻辑思考的过程。就像登山的目的并不只是为了登上山顶,拍照打卡。

计算机解决问题其实没有任何特殊的技巧,它唯一的解决办法就是穷举,穷举所有可能性。算法设计无非就是先思考“如何穷举”,然后再追求“如何聪明地穷举”。

1. 链表

some tips:

  • 当你需要创造一条新链表的时候,可以使用虚拟头结点简化边界情况的处理
  • 如果我们需要把原链表的节点接到新链表上,而不是 new 新节点来组成新链表的话,那么最好断开节点和原链表之间的链接
  • 在处理数组和链表相关问题时,双指针技巧是经常用到的,双指针技巧主要分为两类:左右指针快慢指针

1.1. 典型例题

1.1.1. 删除重复链表(我觉得这个比较有套路,思想很简单,但是调试很麻烦)

class Solution {
    public ListNode deleteDuplicates(ListNode head) {
        ListNode dump = new ListNode(0);
        dump.next = head;
        ListNode p = dump;
        ListNode cur =head;
        while (cur != null) {
            if (cur.next != null && cur.val == cur.next.val) {
                while(cur.next != null && cur.val == cur.next.val){
                    cur=cur.next;
                }
                cur = cur.next;
                if(cur==null){
                    p.next=null;
                }
            } else {
                p.next = cur;
                cur = cur.next;
                p = p.next;
            }

        }
        return dump.next;

    }
}

1.1.2. 两两交换链表中的节点

这应该是 leetcode 中唯一一道需要用递归来解决的链表题目吧(用迭代很麻烦)

class Solution {
    public ListNode swapPairs(ListNode head) {
        if(head==null||head.next==null){
            return head;
        }
        ListNode newHead = head.next;
        head.next=swapPairs(newHead.next);
        newHead.next=head;
        return newHead;
    }
}

1.1.3. 反转链表

ListNode revsese(ListNode head){
        ListNode cur = head,pre = null  ;
        while(cur!=null){
            ListNode next = cur.next;
            //这里的pre其实趋向于后面,但是
            cur.next =pre;
            pre=cur;
            cur=next;
        }
        return pre;
    }

2. 数组

主要技巧: 左右指针快慢指针

2.1. 滑动窗口算法框架

// 滑动窗口算法框架伪码
int left = 0, right = 0;

while (right < nums.size()) {
    // 增大窗口
    window.addLast(nums[right]);
    right++;

    while (window needs shrink) {
        // 缩小窗口
        window.removeFirst(nums[left]);
        left++;
    }
}

2.2. 典型例题(技巧性强的题)

2.2.1. 何为 K 的子数组

这个题目需要用到前缀和的技巧

class Solution {
    public int subarraySum(int[] nums, int k) {
        // 啦、
        int n = nums.length;
        int[] preNum =  new int[n+1];
        Map<Integer,Integer> map = new HashMap<>();
        int res = 0;
        map.put(0,1);
        for(int i=0;i<n;i++){
            preNum[i+1] = nums[i]+preNum[i];

            int need = preNum[i+1]-k;

            if (map.containsKey(need)){
                res+= map.get(need);
            }

            map.put(preNum[i+1],map.getOrDefault(preNum[i+1],0)+1);

        }

        return res;
    }
}

2.2.2. 多数元素

技巧就是正负电荷抵消

class Solution {
    public int majorityElement(int[] nums) {
        // 我们想寻找的那个众数
        int target = 0;
        // 计数器(类比带电粒子例子中的带电性)
        int count = 0;
        for (int i = 0; i < nums.length; i++) {
            if (count == 0) {
                // 当计数器为 0 时,假设 nums[i] 就是众数
                target = nums[i];
                // 众数出现了一次
                count = 1;
            } else if (nums[i] == target) {
                // 如果遇到的是目标众数,计数器累加
                count++;
            } else {
                // 如果遇到的不是目标众数,计数器递减
                count--;
            }
        }
        // 回想带电粒子的例子
        // 此时的 count 必然大于 0,此时的 target 必然就是目标众数
        return target;
    }
}

2.2.3. 移动零

给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序

这个题目初看很有迷惑性,我想到的是,前后指针,然后每次找到 0 之后把数据都移动一遍,但是其实和删除重复数组一样的解法,比较像直接前后指针

3. BFS

//这里是按照图的理解
// 计算从起点 start 到终点 target 的最近距离
int BFS(Node start, Node target) {
    // 核心数据结构
    Queue<Node> q;
    // 避免走回头路
    Set<Node> visited;
    
    // 将起点加入队列
    q.offer(start);
    visited.add(start);

    while (q not empty) {
        int sz = q.size();
        // 将当前队列中的所有节点向四周扩散
        for (int i = 0; i < sz; i++) {
            Node cur = q.poll();
            // 划重点:这里判断是否到达终点
            if (cur is target)
                return step;
            // 将 cur 的相邻节点加入队列
            for (Node x : cur.adj()) {
                if (x not in visited) {
                    q.offer(x);
                    visited.add(x);
                }
            }
        }
    }
    // 如果走到这里,说明在图中没有找到目标节点
}

4. 动态规划

首先,动态规划的核心思想就是穷举求最值,只有列出正确的「状态转移方程」,才能正确地穷举。而且,你需要判断算法问题是否具备「最优子结构」,是否能够通过子问题的最值得到原问题的最值。另外,动态规划问题存在「重叠子问题」,如果暴力穷举的话效率会很低,所以需要你使用「备忘录」或者「DP table」来优化穷举过程,避免不必要的计算。

明确「状态」-> 明确「选择」 -> 定义 dp 数组/函数的含义

状态转移方程可以用归纳法

想满足最优子结,子问题之间必须互相独立

但反过来,最优子结构性质作为动态规划问题的必要条件,一定是让你求最值的, 以后碰到那种恶心人的最值题,思路往动态规划想就对了,这就是套路。

动态规划不就是从最简单的 base case 往后推导吗,可以想象成一个链式反应,从小变大。但只有符合最优子结构的问题,才有发生这种链式反应的性质。

找最优子结构的过程,其实就是证明状态转移方程正确性的过程,方程符合最优子结构就可以写暴力解了,写出暴力解就可以看出有没有重叠子问题了,有则优化,无则 OK。这也是套路,经常刷题的读者应该能体会。

dp 数组要设置为 dp【n+1】,目的是处理 base case

1、遍历的过程中,所需的状态必须是已经计算出来的

2、遍历结束后,存储结果的那个位置必须已经被计算出来

4.1.1. 股票问题

状态(变量)

选择(变量变化)

状态转移方程

base case:
dp[-1][...][0] = dp[...][0][0] = 0
dp[-1][...][1] = dp[...][0][1] = -infinity

状态转移方程:
dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])

实际代码最基础的版本,k是无穷
class Solution {
    public int maxProfit(int[] prices) {
        int dp_i_0 = 0,dp_i_1 = -prices[0];
        for(int i=1;i<prices.length;i++){
          int temp=dp_i_0;
            dp_i_0=Math.max(dp_i_0,dp_i_1+prices[i]);
            dp_i_1=Math.max(dp_i_1,temp-prices[i]);
        }
        return dp_i_0;
    }
}

5. 二分搜索

5.1. 框架

第一个,最基本的二分查找算法:
int binary_search(int[] nums, int target) {
    int left = 0, right = nums.length - 1; 
    while(left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid - 1; 
        } else if(nums[mid] == target) {
            // 直接返回
            return mid;
        }
    }
    // 直接返回
    return -1;
}
第二个,寻找左侧边界的二分查找:
int left_bound(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid - 1;
        } else if (nums[mid] == target) {
            // 别返回,锁定左侧边界
            right = mid - 1;
        }
    }
    // 判断 target 是否存在于 nums 中
    if (left < 0 || left >= nums.length) {
        return -1;
    }
    // 判断一下 nums[left] 是不是 target
    return nums[left] == target ? left : -1;
}
第三个,寻找右侧边界的二分查找:
int right_bound(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid - 1;
        } else if (nums[mid] == target) {
            // 别返回,锁定右侧边界
            left = mid + 1;
        }
    }
    // 判断 target 是否存在于 nums 中
    // if (left - 1 < 0 || left - 1 >= nums.length) {
    //     return -1;
    // }
    
    // 由于 while 的结束条件是 right == left - 1,且现在在求右边界
    // 所以用 right 替代 left - 1 更好记
    if (right < 0 || right >= nums.length) {
        return -1;
    }
    return nums[right] == target ? right : -1;
}

5.2. 典型题目

5.2.1. 找峰值

class Solution {
    public int findPeakElement(int[] nums) {
        int l =0,r= nums.length-1;
        while(l<r){
            int mid = (r-l)/2+l;
            //这里有意思的点是,如果变成if(nums[mid-1]>nums[mid]),
            //从逻辑上是对的,但是会出现递增情况死循环的问题
            //仔细想了一下,是因为l是0开始的,如果下面的代码出现递减情况,
            // r=mid,可以保证一直往左逼进,任何数一直除以2,都会变成0的
            //但是如果是if(nums[mid-1]>nums[mid])
            // l=2,r=3,mid一直是2。会出现(n+(n+1))/2 等于n的循环。
            if(nums[mid]<nums[mid+1]){
                l=mid+1;
            }else{
                r=mid;
            }
        }
        return l;
    }
}

6. 回溯/DFS

6.1. 回溯和 DFS 区别?

其实不就是遍历吗?它俩的本质是一样的,都是「遍历」思维下的暴力穷举算法。唯一的区别在于关注点不同,回溯算法的关注点在「树枝」,DFS 算法的关注点在「节点」

result = []
def backtrack(路径, 选择列表):
    if 满足结束条件:
        result.add(路径)
        return
    
    for 选择 in 选择列表:
        做选择
        backtrack(路径, 选择列表)
        撤销选择



// 另一种理解:这两种其实没多大区别
// 回溯算法框架模板
void backtrack(...) {
    if (到达叶子节点) {
        return;
    }

    for (int i = 0; i < n; i++;) { 
        // 做选择
        ...

        backtrack(...)

        // 撤销选择
        ...
    }
}


// DFS 算法框架模板
void dfs(...) {
    if (到达叶子节点) {
        return;
    }
    // 做选择
    ...
    for (int i = 0; i < n; i++) {
        dfs(...)
    }
    // 撤销选择
    ...
}

6.2. 回溯之排列/组合/子集问题

其实这写问题本质就是穷举所有解,而这些解呈现树形结构,所以合理使用回溯算法框架,稍改代码框架即可把这些问题一网打尽首先,组合问题和子集问题其实是等价的,这个后面会讲;至于之前说的三种变化形式,无非是在这两棵树上剪掉或者增加一些树枝罢了

这里其实就是多叉树遍历的视角,但是要涉及到减枝

6.2.1. 代码框架

class Solution {

    List<List<Integer>> res = new LinkedList<>();
 
    LinkedList<Integer> track = new LinkedList<>();

  
    public List<List<Integer>> subsets(int[] nums) {
        backtrack(nums, 0);
        return res;
    }

    //求子集(元素无重复不可重复选)
    void backtrack(int[] nums, int start) {
        res.add(new LinkedList<>(track));
    
        for (int i = start; i < nums.length; i++) {
           
            track.addLast(nums[i]);
            
            backtrack(nums, i + 1);
          
            track.removeLast();
        }
    }
    //求组合问题(元素无重复不可重复选)
     void backtrack(int[] nums, int start) {
        // 回看之前的组合树,就是树的第nums.length层
         if(track.size()==nums.length){
             res.add(new LinkedList<>(track));
         }
    
        for (int i = start; i < nums.length; i++) {
           
            track.addLast(nums[i]);
            
            backtrack(nums, i + 1);
          
            track.removeLast();
        }
    }


    //求排列问题(元素无重复不可重复选),排列相比组合是有顺序的,
    //每次 for循环从0开始
    // 同时加一个字段判断num[i]有没有被使用过

    static boolen[] used = new boolean[nums.length];
     void backtrack(int[] nums) {
       
         if(track.size()==nums.length){
             res.add(new LinkedList<>(track));
         }
    
        for (int i = 0; i < nums.length; i++) {

            if(!used[i]){
                continue;
            }
            track.addLast(nums[i]);
            used[i]!=used[i];
            backtrack(nums);
            used[i]!=used[i];
            track.removeLast();
        }
    }


    
    //求子集(元素重复不可重复选)
    //比如 [ [],[1],[2],[1,2],[2,2],[1,2,2] ]
    // 加个去重逻辑就可以了呗
    // 1.先排序,然后if (i > start && nums[i] == nums[i - 1]) {continue;}
    // 2.直接用set存储去重结果    
    //针对(元素重复不可重复选)这种问题组合和排列都是一样的解法
    void backtrack(int[] nums, int start) {
        res.add(new LinkedList<>(track));
    
        for (int i = start; i < nums.length; i++) {
            if (i > start && nums[i] == nums[i - 1]) {
                continue;
            }
            
            track.addLast(nums[i]);
            
            backtrack(nums, i + 1);
          
            track.removeLast();
        }
    }
    //求子集(元素无重复可重复选)
    //这种问题核心就是如何让元素重复选择?
    //把backtrack(nums, i);该为backtrack(nums, i+1);就可以了
    //为了防止树的不断增长那个,肯定是有相对应的停止条件的
}

7. 二叉树

二叉树题目的解法可以分两类思路,第一类是遍历一遍二叉树得出答案,第二类是通过分解问题计算出答案,

7.1. 遍历

前中后序遍历

前序位置的代码在刚刚进入一个二叉树节点的时候执行;

后序位置的代码在将要离开一个二叉树节点的时候执行;

中序位置的代码在一个二叉树节点左子树都遍历完,即将开始遍历右子树的时候执行。

你可以发现,前序位置的代码执行是自顶向下的,而后序位置的代码执行是自底向上的

但这里面大有玄妙,意味着前序位置的代码只能从函数参数中获取父节点传递来的数据,而后序位置的代码不仅可以获取参数数据,还可以获取到子树通过函数返回值传递回来的数据

遇到子树问题,首先想到的是给函数设置返回值,然后在后序位置做文章。

void traverse(TreeNode root) {
    if (root == null) {
        return;
    }
    // 前序位置
    traverse(root.left);
    // 中序位置
    traverse(root.right);
    // 后序位置
}

7.2. 分解

这里思路就是递归了

整个递归函数看做是在这个节点里面发生的事情,如何跳出,然后递归主要逻辑,最后 return 什么。(递归本身就很)

🙋‍♀️🌰:递归些前序遍历:

序遍历的特点是,根节点的值排在首位,接着是左子树的前序遍历结果,最后是右子树的前序遍历结果:那这不就可以分解问题了么,一棵二叉树的前序遍历结果 = 根节点 + 左子树的前序遍历结果 + 右子树的前序遍历结果

class Solution {
    // 定义:输入一棵二叉树的根节点,返回这棵树的前序遍历结果
    List<Integer> preorderTraversal(TreeNode root) {
        List<Integer> res = new LinkedList<>();
        if (root == null) {
            return res;
        }
        // 前序遍历的结果,root.val 在第一个
        res.add(root.val);
        // 利用函数定义,后面接着左子树的前序遍历结果
        res.addAll(preorderTraversal(root.left));
        // 利用函数定义,最后接着右子树的前序遍历结果
        res.addAll(preorderTraversal(root.right));
        return res;
    }
}

中序和后序遍历也是类似的,只要把 add(root.val) 放到中序和后序对应的位置就行了。

7.3. 通用解题步骤

遇到一道二叉树的题目时的通用思考过程是:

1、是否可以通过遍历一遍二叉树得到答案?如果可以,用一个 traverse 函数配合外部变量来实现。

2、是否可以定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?如果可以,写出这个递归函数的定义,并充分利用这个函数的返回值。

3、无论使用哪一种思维模式,你都要明白二叉树的每一个节点需要做什么,需要在什么时候(前中后序)做只有后序位置才能通过返回值获取子树的信息。

7.4. 思考

动归/DFS/回溯算法都可以看做二叉树问题的扩展,只是它们的关注点不同:

  • 动态规划算法属于分解问题(分治)的思路,它的关注点在整棵「子树」。
  • 回溯算法属于遍历的思路,它的关注点在节点间的「树枝」。
  • DFS 算法属于遍历的思路,它的关注点在单个「节点」。

7.5. 常用细节

//一般判断叶子节点这样判断
if (root.left == null && root.right == null) {
            // 到达叶子节点,更新最大深度
      res = Math.max(res, depth);
}

8. 常用 api

hashmap

h.containsKey(k)//Key要大写
h.putIfAbsent(k,v)
h.size()
h.getOrDefault(c,0)
h.remove;

LinkedHashSet

// 获取第一个
lhs.iterator().next()

Lsit

l.size();
res.add(new ArrayList<>(track));//多重
res.remove(track.size()-1);
 res.add(String.join(".",track));
Integer.parseInt

Deque

// 用作栈
pop(),peek(),push()
// 用做队列
offer()(作用与尾部),poll()(头部),remove()(头部)

Arrays

Arrays.fill(num,value);
Arrays.sort(a);
Arrays.asList();
                 

返回数组

return  new ListNode[]{tail,head};
return new int[0];

基础

boolean[] used = new boolean[nums.length];

优先队列

// 表示最小堆
PriorityQueue<ListNode> pq = new PriorityQueue<>(lists.length, (a, b)->(a.val - b.val));
lists.length需要检查不能为0
!pq.isEmpty()
pq.add()
pq.peek()
pq.poll()//这个是弹出

String

s.substring(s,i,j);//左闭右开
StringBuilder str = new StringBuilder();
return s.substring(i+1,j);

Integer

Integer.parseInt()
 int ran = new Random.nextInt(rigth-left+1)+left;
while(l<r&&nums[l+1]==nums[l]) l++;
while(l<r&&nums[r-1]==nums[r]) r--;

9. 常用小技巧

10. 注意事项

要给出具体字符串要用回溯,但是最后给一个数字是dp

看有没有可能写错大小写

有时候报错要分析上下文,冷静,在某一点报错可能是别的地方的原因

可以慢一点,但是不能出错

复制的地方记得改回来

不要先入为主的思路去看代码

固定一下函数命令习惯:backtrack/dfs/traverse

11. 附加题

11.1. 抽牌问题

  • 你手中有一个初始的牌堆 hand,并且有一个辅助的空牌堆 desk
  • 你将按以下规则操作牌:
  • hand 的顶部取出一张牌,放到 desk
  • 如果 hand 中还有牌,再从 hand 的顶部取出一张牌,然后将这张牌放回 hand 的底部。
  • 重复以上步骤,直到 hand 中的所有牌都移动到 desk
  • 把场景抽象成一个具体的结构
//左侧是队头,右侧是队尾
」  while (!hand.isEmpty()) {
            desk.add(hand.poll();
            if (!hand.isEmpty()) {
                hand.add(hand.poll());
            }
        }
        但是 deque 不支持 add(0,e)这种,支持 addLast

        while (!desk.isEmpty()) {
            hand.add(desk.remove());
            if (!hand.isEmpty()) {
                hand.add(hand.remove());
            }
        }