Leetcode:动态规划学习

记录最近在Leetcode上做DP题目时碰到的感觉让我学到东西的题。

 

1、Shortest Path Visiting All Nodes(847)

题目描述:

An undirected, connected graph of N nodes (labeled 0, 1, 2, ..., N-1) is given as graph.

graph.length = N, and j != i is in the list graph[i] exactly once, if and only if nodes i and j are connected.

Return the length of the shortest path that visits every node. You may start and stop at any node, you may revisit nodes multiple times, and you may reuse edges.

样例:

Input: [[1,2,3],[0],[0],[0]]

Output: 4

Explanation: One possible path is [1,0,2,0,3]

注意:

  1. 1 <= graph.length <= 12
  2. 0 <= graph[i].length < graph.length

======================================================================================

题意:在一张无向连通图上求经过所有的点最少需要经过几条边,可以重复经过某条边、某一点。

思路:由题意可知我们可以以任意节点为起点,只要找到一条步数最少的路即可(下面都用步数描述答案)。那我们可以以每一个节点为起点,找其对应的最少步数,再对所得到的不同答案取最小值得到最终答案。

如何提高效率?

把大问题缩小为小问题看待:如果有N-1个点,我们找到了以K为起点经过所有N-1个点的最小步数(设为D),现在往图中加一个点,该点与K相连,那么现在以该点为起点经过所有N个点的最小步数则为D+1。(1表示从该点到K)。

现在我们结合我们一开始的思想,加上把大问题缩小为小问题的思路,重新理一下解题思路:

1、我们要尝试以不同节点为起点的路径;

2、可以重复经过某条边某个点,所以只需要记录当前已经经过了哪些点,只要经过了所有点就找到一条路径;

3、可以使用BFS来获取最小步数,因为使用BFS相当于按层扩散,我们每次只向四周走一步看是否有路可走,有路即有节点‘

4、要使用BFS,就借助队列实现,最为方便。根据2、3两点,允许元素重复入队,且每次都将与当前节点相连的节点计算步数后入队。一旦所有节点都被访问,则得到答案。

5、根据4,“同时”对以不同起点的路径进行计算,则得到的第一个答案就是最终答案。【同时指的是,以1为起点的路径扩散一次后,由以2为起点的路径扩散,再由3、4如此往下,每次不同起点的路径都扩散一次,那最先经过所有点的路径肯定是步数最小的】

Leetcode上题目的Note往往有用意,有时候是提示做题人要可以以什么时间复杂度或空间复杂度,或者可以用什么比较简洁、巧妙的方式解题。

这道题中图节点最多为12,那我们可以利用二进制表示集合的思想来表示目前已经经过了哪些节点,如10011表示目前经过了0、1、4号节点。又因为我们是依靠起点的不同来区分路径的,故需要将表示已经经过哪些点的变量和该路径的起点变量同步起来。可以用一个结构体来表示这两者。

知识储备完成,可以做题了>>>>>>

class Solution {
public:
    struct state{
        //cover:二进制表示哪些节点已经被访问过
        ///head:表示该路径对应的起点
        int cover,head;
        state(int c,int h):cover(c),head(h){}
    };
    int shortestPathLength(vector<vector<int>>& graph) {
        int n=graph.size();
        //dist[set][start]:表示以start为起点,经过set中所有节点所需的最小步数
        int dist[1<<n][n];
        //dist初始化为较大值因为我们后面要取最小值
        for(int i=0;i<(1<<n);++i)
            for(int j=0;j<n;++j)
                dist[i][j]=n*n;
        queue<state> q;
        for(int i=0;i<n;++i)
        {
            //我们要看以不同节点为起点的路径哪个先访问完所有节点
            q.push(state(1<<i,i));
            //以i为起点只经过i需要0步
            dist[1<<i][i]=0;
        }
        int ans=0;
        while(!q.empty())
        {
            state cur=q.front();
            q.pop();
            int d=dist[cur.cover][cur.head];
            //第一次所有节点都被访问即找到了答案
            if(cur.cover==(1<<n)-1)
            {
                ans=d;
                break;
            }
            for(int i=0;i<graph[cur.head].size();++i)
            {
                int newhead=graph[cur.head][i];
                //允许重复经过同一个节点所以用“|”操作
                int newcover=cur.cover|(1<<newhead);
                //每次去最小值
                if(d+1<dist[newcover][newhead])
                {
                    dist[newcover][newhead]=d+1;
                    q.push(state(newcover,newhead));
                }
            }
        }
        return ans;
    }
    
};

 2、Unique Binary Search Trees(96)

题目描述:

Given n, how many structurally unique BST's (binary search trees) that store values 1 ... n?

====================================================================================

题目说得很清楚,给一个数字N表示共有N个不同节点,问这N个不同节点能组成多少二叉搜索树。

思路:

1、了解BST的性质:每个节点的左子树都比该节点小,右子树都比该节点大;

2、共有N个节点,每个节点都可以作为根,不同节点为根形成的BST的数量总和就是答案;

3、第1点我们提到了子树的概念,子树也是一棵BST,这暗示我们不需要每次都真正地去构造出一棵BST,只需要知道有多少种可能就好。也就是对于以某个节点为根的BST,其左子树有L种可能,右子树有R种可能,则以该节点为根的BST就有L*R种可能;

4、根据第3点,假设DP[n]表示由1,2,...,n个节点共能组成多少二叉搜索树。假设F(i,n)表示以i为根节点,节点为[1,n]的整数序列的BST的个数,其中1<=i<=n。则DP[n]=F(1,n)+F(2,n)+...+F(n,n)。

5、结合第3点和第4点,我们可以知道F(i,n)=以[1,i-1]序列组成的BST的数量 * 以[i+1,n]序列组成的BST的数量,其中以[1,i-1]序列组成的BST的数量可以用DP[i-1]表示,以[i+1,n]序列组成的BST的数量其实就等于以[1,n-(i+1)+1]即[1,n-i]序列组成的BST的数量。那就好办了,F(i,n)=DP[i-1]*DP[n-i]。

6、结合DP[n]=F(1,n)+F(2,n)+...+F(n,n) 和 F(i,n)=DP[i-1]*DP[n-i],可知DP[n]=DP[0]*DP[n-1]+DP[1]*DP[n-2]+...+DP[n-1]*DP[0]

好了,我们得到我们的状态方程了。

class Solution {
public:
    int numTrees(int n) {
        vector<int> dp(n+1,0);
        //根据dp的含义初始化才能使我们的状态方程正确计算得到结果
        dp[0]=dp[1]=1;
        for(int i=2;i<=n;++i)
            for(int j=0;j<=i-1;++j)
                dp[i]+=dp[j]*dp[i-1-j];
        return dp[n];
    }
};

 

3、Unique Binary Search Trees II(95)

题目描述:

Given an integer n, generate all structurally unique BST's (binary search trees) that store values 1 ... n.

样例:

Input: 3

Output: [   [1,null,3,2],   [3,2,null,1],   [3,1,null,null,2],   [2,1,3],   [1,null,2,null,3] ]

===========================================================================================

这是上一题的进阶版,上道题只要我们计算BST的数量,这道题要我们给出每一个可能的BST。

思路:

万变不离其宗,我们的核心思想还是跟上一题的一样,所以我直接在代码里面解释。要注意的是,我们的答案是将所有可能的BST的根节点存在一个vector里面然后返回(因为知道了根节点就能通过BFS或者DFS知道整棵树的样子了)。

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode(int x) : val(x), left(NULL), right(NULL) {}
 * };
 */
class Solution {
public:
    vector<TreeNode*> generateTrees(int n) {
        if(!n)
            return {};
        return solve(1,n);
    }
    //这里我们使用了递归
    //我个人觉得,要使用递归你就必须了解你的递归函数的作用是什么,然后你才能真正懂得怎么写代码
    //BST由[start,end]之间的整数序列构造
    //我们的solve函数就是要求由[start,end]构造的所有BST
    vector<TreeNode*> solve(int start,int end)
    {
        vector<TreeNode*> ans;
        //递归必有的边界条件判断,对应这份代码则表示根节点的左子树或右子树为空
        if(start>end)
        {
            ans.push_back(NULL);
            return ans;
        }
        //每个节点都可以作为根节点
        for(int i=start;i<=end;++i)
        {
            //根据BST的性质,获取根节点的所有左子树和右子树
            vector<TreeNode*> left=solve(start,i-1);
            vector<TreeNode*> right=solve(i+1,end);
            //根据根节点的左右子树构造新的BST
            for(int l=0;l<left.size();++l)
                for(int r=0;r<right.size();++r)
                {
                    TreeNode* root=new TreeNode(i);
                    root->left=left[l];
                    root->right=right[r];
                    ans.push_back(root);
                }
        }
        return ans;
    }
};

 

4、Maximum Subarray(53)

题目描述:

Given an integer array nums, find the contiguous subarray (containing at least one number) which has the largest sum and return its sum.

样例:

Input: [-2,1,-3,4,-1,2,1,-5,4],

Output: 6

Explanation: [4,-1,2,1] has the largest sum = 6.

=========================================================================================

题意:

给一组包含正数和负数的整数序列,要求一个拥有最大和的连续子序列,要求的是这个最大的和。

这题使用穷举法肯定就能找到答案,但出题人希望我们使用O(n)的时间复杂度来解这道题。我们也最好对每道题都学习高效的解法,这样才能学得到东西。

思路:

1、对序列中的每一个元素,我们只有选和不选两种方案,同时我们要保证由我们选择的元素组成的序列是连续的(相邻的)。

2、一个元素只有当它被选取后能为已经选取的连续序列做贡献(使得连续序列的总和增大)我们才会选择它,否则我们需要比较该元素和已经选取的连续序列的总和哪个大,来更新我们的答案,并开始新的连续序列的选取。

3、根据上述两点,假设dp[i]表示以第i个元素结尾的且包含第i个元素的最大连续子序列,既然是这样那么对每个dp[i],我们只需要判断我们是要开启新的连续序列还是将第i个元素和之前的连续序列算在一起,同时我们也要更新我们当前计算到的最大连续序列总和。

class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        int n=nums.size();
        int dp[n]={0},ans=nums[0];//dp[i]:以i结尾且包含i的最大连续子序列
        //DP必要的初始化
        dp[0]=nums[0];
        for(int i=1;i<n;++i)
        {
            //根据dp的定义,dp[i]必含有nums[i]
            //如果dp[i-1]<0,则dp[i-1]+nums[i]<nums[i]
            //所以为了得到最大连续子序列和我们需要舍弃之前已经选取的序列
            //并换做以nums[i]开头的连续序列
            dp[i]=nums[i]+max(dp[i-1],0);
            ans=max(ans,dp[i]);
        }
        return ans;
    }
};

 

5、Word Break(139)

题目描述:

Given a non-empty string s and a dictionary wordDict containing a list of non-empty words, determine if s can be segmented into a space-separated sequence of one or more dictionary words.

注意:

  • The same word in the dictionary may be reused multiple times in the segmentation.
  • You may assume the dictionary does not contain duplicate words.

样例:

Input: s = "leetcode", wordDict = ["leet", "code"]

Output: true

Explanation: Return true because "leetcode" can be segmented as "leet code".

============================================================================================

题意:

给你一个字符串和一个包含不同单词的字典,问你该字符串能否由字典中的单词组成。判断能不能就好了。

思路:

1、要判断字符串中有没有字典中的单词,就要对字符串做分割。

2、采用穷举的方式也很麻烦,因为要判断是的整个字符串是不是全由字典中的单词组成。既然要判断是的整个字符串,那如果字符串的后部分由字典中的单词组成和前部分不是,或者倒过来,都不行,即整个判断过程其实是环环相扣的,只有前部分子串是由字典中的单词组成,我们才可以继续往下判断。

3、由第2点,可以假设dp[i]表示以要判断的字符串的第i个字符结尾的子串是否满足题目条件,若字符串长度为N,则只有当dp[0],dp[1],...,dp[N-1]中有一个为真,dp[N]才有可能有真。即若dp[i]为真,说明以第i个元素结尾的子串是由字典中的单词组成的,那么如果从字符串中的第i+1个字符开始到最后一个字符组成的子串是字典中的单词,则可说明整个字符串可以有字典中的单词组成。

class Solution {
public:
     bool wordBreak(string s, vector<string>& wordDict) {
        //因为要查找s的子串是不是字典中的单词,所以使用unordered_set比较省时
        unordered_set<string> wordset(wordDict.begin(),wordDict.end());
        vector<bool> dp(s.size()+1,false);
        //dp的必要初始化
        dp[0]=true;
        //依次判断以各个字符结尾的子串的情况
        for(int i=1;i<=s.size();++i)
            for(int j=i-1;j>=0;--j)
                if(dp[j]==true)
                {
                    if(wordset.count(s.substr(j,i-j))!=0)
                    {
                        dp[i]=true;
                        break;
                    }
                }
         return dp[s.size()];
    }
    
};

 

另外在做题过程中我发现一些动态规划的题可以用纯数学思想去解题,这样有时会使得解法简便很多,也就是去发现题目中的数学规律。

我觉得动态规划方程的选择是一个需要训练和想象的过程,无法一接触就懂,继续做题,继续学习吧。

 

 

 

 

 

 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值