LeetCode进阶之路(三)算法题的核心套路

一、框架思维

1.数据结构存储方式

  数据结构底层存储方式有两种:数组和链表(即顺序存储和链式存储)。

2.树的遍历框架

//二叉树遍历框架
class
TreeNode{ int val; TreeNode left,right; } public void traverse(TreeNode root){ //前序遍历 traverse(root.left); //中序遍历 traverse(root.right); //后序遍历 }

 

//n叉树比遍历框架
class TreeNode{
int val;
TreeNode[] children;
}

void traverse(TreeNode root){
for(TreeNode child:root.children){
traverse(child);
  }
}

 

注:涉及递归的问题,基本都是树的问题。

 

 

二、动态规划解题框架

(一)概述

1.动态规划通常是求最值的问题。核心问题是穷举。

2.重叠子问题:使用备忘录或DPtable优化穷举。

3.最优子结构:通过子问题的最值得到原始问题的最值。

4.状态转移方程(分段函数):状态、选择、dp的定义。(思考最简单情况、问题的状态有什么、对每个状态可以进行什么操作得到什么新的状态、如何定义dp数组或函数来表现“状态”和“选择”。

 

框架如下:

//初始化base case
dp[0][0][···]=base case
//进行状态转移
for 状态1 in 状态1的所有取值:
    for 状态2 in 状态2的所有取值:
        for ···
            dp[状态1][状态2][···]=求最值(选择1,选择2,···)

 

(二) 具体方法:

1.暴力递归

  递归算法的时间复杂度:子问题个数乘以解决单个子问题需要的时间。

2.带备忘录的递归解法

  一般用一个数组充当备忘录,也可以使用哈希表(字典),将子问题的答案记录在备忘录内,需要时直接取出来,就不用再耗时计算了。

3.dp数组的迭代解法

  用一个独立的数组表示备忘录。如果当前状态之和前几个状态有关,可以用多个变量表示dpTable——状态压缩。

 

三、回溯算法解题套路框架(DFS深度优先搜索)

  回溯方法即穷举,解决回溯问题就是决策树遍历的问题。具体包括:

  1.路径:已经做出的选择。

  2.选择列表:当前可以做的选择。

  3.结束条件:到达决策树底层无法再做选择的条件。

  回溯算法是动态规划的暴力求解阶段。

  回溯算法是一个多叉树遍历的问题,关键是前序遍历和后序遍历位置的操作。写Backtrack函数时,需要维护走过的“路径”和当前可以做的“选择列表”,当触发“结束条件”时,将“路径”计入结果集。

 

回溯算法的况下如下:

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

for循环中的递归在条用之前做选择,在调用递归之后撤销选择。

  维护节点的选择列表和路径的方法:在递归之前做出选择,在递归之后撤销刚才的选择。

 

四、BFS广度优先搜索算法框架

  核心思想:把问题想象成图,从一个点开始向四周扩散。每次将一个节点周围的所有节点加入队列。

  BFS的特点:BFS找到的路径是最短的,但空间复杂度比DFS大得多。

  应用场景:在一个图中,找到从起点到终点的最短距离。

  算法框架如下所示:

//计算从起点到终点的最短距离
int BFS(Node start,Node target){
        Queue<Node> q; //核心数据结构
        Set<Node> visited; //避免走回头路

        q.offer(start); //将起点加入队列
        visited.add(start);
        int step=0; //记录扩散的步数

        while(q not empty){
        int sz=q.size();
//        将队列中的所有节点向四周扩散
        for(int i=0;i<sz;i++){
        Node cur=q.pool();
//            这里判断是否到达终点
        if(cur is target)
        return step;
//            将cur的相邻接点加入到队列,cur.adj()指cur相邻的节点
        for(Node X:cur.adj()){
        if(x not in visited){
        q.offer(x);
        visited.add(x);  //visited是防止走回头路,一般的二叉树没有子节点到父节点的指针,不需要visited
        }
        }
        }
//        在这里更新步数
        step++;
        }
        }

BFS和DFS的关系

1.寻找最短路径时,广度优先相当于面,深度优先相当于线,深度优先也可以找到最短路径。

2.DFS空间复杂度小,时间复杂度大BFS空间复杂度大,时间复杂度小。

 

五、双指针框架

(一)快慢指针

  1.定义:初始化两个指针指向链表头部节点head,fast指针在前,slow指针在后。

  2.应用场景:

  ①判断链表中是否有环

  用双指针,如果链表无环,快的会遇到null,如果链表有环,快的最终会超过慢的一圈和慢的相遇。

boolean hasCycle(ListNode head){
    ListNode fast,slow;
    //初始化快、慢指针指向头节点
    fast=slow=head;
    while(fast!=null&&fast.next!=null){
        //快指针每次前进两步
        fast=fast.next.next;
        //慢指针每次前进一步
        slow=slow.next;
        //如果有环,快慢指针必然相遇
        if(fast==slow) return true;
    }
}

  ②已知链表有环,返回环的起始位置。

   方法:把快慢指针中的其中任意一个重新指向head,然后两个指针同速前进,再次相遇的位置就是环的起点。

  原因:假设首次相遇是,slow走的长度是k,那么fast就走了2k(fast比slow多走了一圈,所以环的周长也是k)。假设此时的位置距离环的起点距离为m,那么环的起始位置可以用k-m表示。此时首次相遇的位置距离环的起点距离同样是k-m。因此重置一个指针,再次相遇即可找到环的起点。

具体方法如下:

    ListNode detectCycle(ListNode head) {
        ListNode fast, slow;
        //初始化快、慢指针指向头节点
        fast = slow = head;
        while (fast != null && fast.next != null) {
            //快指针每次前进两步
            fast = fast.next.next;
            //慢指针每次前进一步
            slow = slow.next;
            //如果有环,快慢指针必然相遇
            if (fast == slow) break;
        }
        //以上代码类似hasCycle函数,接下来先把指针重新指向head
        slow = head;
        while (slow != fast) {
//            两个指针同速前进
            fast = fast.next;
            slow = slow.next;
        }
        return slow;
    }

 

  ③寻找无环单链表的中点。

  暴力方法:先遍历一遍链表,算出长度N。再走n/2步,这样就到了链表的中点。

  更优雅的方法:快慢两个指针,快指针到终点时候慢指针的位置就是中点的位置。 

while(fast!=null&&fast.next!=null){
        fast=fast.next.next;
        slow=slow.next;
        }
        return slow;    //slow就在中间位置了

  链表中点的一个重要作用:用链表进行归并排序。递归的把数组分成两部分,然后对两部分分别排序,最后合并两个有序数组。

  ④寻找单链表中倒数第K个元素

   让快指针先走k步,然后快慢指针同速前进。当快指针到达末尾null时,慢指针的位置就是倒数第K个节点。

  框架如下:

  

ListNode slow, fast;
slow=fast=head;
    while(k-->0)
    fast=fast.next;
    while(fast!=null){
    slow=slow.next;
    fast=fast.next;
    }
    return slow;

 

(二)左右指针

  1.定义:左右指针用在数组问题中,实际是两个索引值,通常定义

left=0;
right=len(nums)-1;

  2.应用场景:

  ①二分搜索

  ②两数之和

  ③反转数组

  ④滑动窗口算法

  以上内容下文详细介绍。滑动窗口算法是快慢指针在数组上的应用,解决字符串匹配问题,下文单独介绍。

 

六、二分搜索算法

二分法注意问题:
确定好搜索区间,定位闭区。
while条件带等号。
if条件相等了就返回。
mid要加减1,因为是闭区间。
while结束就返回-1。
最后用if条件确保索引不出边界。
尽量用else if把条件写清楚了,不用else

1.寻找一个数

  计算mid时防止溢出。使用left+(right-left)/2与(left+right)/2的计算结果是一样的,但前者更不容易出现整数溢出的问题。

寻找一个整数的方法如下:

int binarySearch(int[] nums,int target){
    int left=0,right=nums.lentth-1;
    while(left<=right){
        int mid=left+(right-left)/2;
        if(nums[mid]==target){
            return mid;
        }else if(nums[mid]<target){
            left=mid+1;
        }else if(nums[mid]>target){
            right=mid-1;
        }
    }
    return -1;
}

2.寻找边界:数组中连续出现的多个相同的数值,找左边界或右边界。

  方法与查找某个数基本一致,不同的是,当min和目标相等时不出结果,找哪个边界就往哪个边收。再加一步检查边界。

①左边界

//当nums[mid]=target时,右边界往左收
right=mid-1;
//检查出界情况(原return -1的位置)
if(left>=nums.length||nums[left]!=target){
    return -1;
        }
return left;

②右边界

//当nums[mid]=target时
left=mid+1
//检查边界
if(right<0||nums[right]!=target){
    return -1;
        }
return right;

 

七、滑动窗口

   维护一个窗口不断滑动,然后更新答案。通常用于解决子字符串的问题。

基本框架如下:①额和②表示需要更新的数据。

    void slidingWindow(string s, string t) {
        unordered_map<char, int> need, window;
        for (char c : t) need[c]++;

        int left = 0, right = 0;
        int valid = 0;
        while (right < s.size()) {
            //c是将移入窗口的字符
            char c = s[roght];
            //右移窗口
            right++;
            //将窗口内的一系列数据更新
//        ①...
            prindf("window:[%d,%d]\n", left, right);

            //判断左窗口是否需要收缩
            while (window needs shrink){
                //d是将移出窗口的字符
                char d = s[left];
                //左移窗口
                left++;
                //进行窗口内数据的一系列更新
//            ②...
            }
        }
    }

常见的几个问题:

①最小覆盖子串

需要考虑以下四个问题:当right扩大窗口,加入字符时,应该更新哪些数据;

           什么条件下窗口停止扩大,开始移动left缩小窗口。

           移动left缩小窗口时,应该更新哪些数据。

              最终结果是在扩大窗口还是缩小窗口时候更新。

对框架的修改通常为

 void minWindow(string s, string t) {
        unordered_map<char, int> need, window;
        for (char c : t) need[c]++;

        int left = 0, right = 0;
        int valid = 0;
        while (right < s.size()) {
            //c是将移入窗口的字符
            char c = s[roght];
            //右移窗口
            right++;
            //将窗口内的一系列数据更新
            if (need.count(c)) {
                window[c]++;
                if (window[c] = need[c]) {
                    valid++;
                }
            }
            prindf("window:[%d,%d]\n", left, right);

            //判断左窗口是否需要收缩
            while (window needs shrink){
                //d是将移出窗口的字符
                char d = s[left];
                //左移窗口
                left++;
                //进行窗口内数据的一系列更新
                if (need.count(d)) {
                    if (window[d] == need[d])
                        walid__;
                    window[d]--;
                }
            }
        }
        return len == INT_MAX ?
                "" : s.substr(start, len);
    }

 

②字符串排列

  方法和①几乎一致,注意返回值的不同即可。

③找所有字母异位词

  同样,差异在于返回值的不同。

④最长无重复子串

  去掉need和valid,更新窗口数据也只需要window即可。

 

posted @ 2022-03-11 14:28  StarZhai  阅读(258)  评论(0)    收藏  举报