当前位置: 首页 > news >正文

动态规划 -- 子数组问题

本篇文章中主要讲解动态规划系列中的几个经典的子数组问题。

1 最大子数组和

53. 最大子数组和 - 力扣(LeetCode)

解析题目: 子数组是一个数组中的连续部分,也就是说,如果一个数组以 nums[i]结尾,那么有两种情况,一种就是子数组长度为1,那么子数组只包含nums[i]本身,一种就是子数组的长度大于1,那么此时至少有两个连续的元素,也就是说,nums[i-1]一定是包含在以nums[i]为结尾的长度大于1的子数组中的。而我们的题目要求我们求出数组中的具有最大和的子数组,并返回这个最大和。

那么我们按照常规的状态表示的经验,可以定义:

dp[i] 表示以 nums[i] 为结尾的子数组的最大和

注意,这里的dp[i] 存储的只是以 nums[i] 为结尾的子数组的最大和,并不一定就是整个数组的 [0,i]区间的子数组的最大和,因为[0,i]区间的最大和的子数组并不一定就以nums[i]为结尾,我们只是求出了一个局部的最大和,而不是整体的最大和。

那么接下来分析dp[i]的状态转移方程:

我们可以根据前面的题目解析,将 dp[i] 划分为两种情况:

1 子数组长度为1 ,那么子数组和就是 dp[i] = nums[i]

2 子数组的长度大于1,那么以nums[i]为结尾的子数组其实可以分成两部分看,nums[i]本身 + 以nums[i-1]为结尾的子数组,而我们需要的是具有最大和的子数组,而nums[i]其实已经固定了,那么我们就需要让以 nums[i]为结尾的子数组和最大,那么也就是 dp[i] = nums[i] + dp[i-1]

我们最终需要的是两种情况的最大值,那么其实也很简单,dp[i] = max(dp[i-1]+nums[i],nums[i]),其实更简单的,我们可以直接判断一下dp[i-1]是否大于0,如果大于零那么就是第二种情况,如果小于或者等于0,那么就是第一种情况。

那么状态转移方程就是:

dp[i] = nums[i] + (dp[i-1]>0?dp[i-1]:0)

细节问题:

初始化:由于填表过程中需要用到dp[i-1],那么i=0时我们是需要手动初始化的,而dp[0]其实没得选,就是 nums[0]。或者我们可以多开一个空间,多出的一个空间的值也很简单,就填0。

填表顺序:从左往右填写dp表

返回值:我们需要的是整个数组的最大子数组和,那么结果可能是以任意位置元素为结尾的子数组,所以我们的返回值是整个dp表的最大值,那么我们可以在填表的过程中就记录返回值。

class Solution {
public:int maxSubArray(vector<int>& nums) {int n = nums.size() , res = nums[0];vector<int> dp(n);dp[0] = nums[0];for(int i = 1 ; i < n ; ++i){dp[i] = nums[i] + (dp[i-1] > 0 ? dp[i-1] : 0 );res = max(res,dp[i]);}return res;}
};

注意在这种情况下res初始化为nums[0]

多开一个空间:此时res需要初始化为负无穷大

class Solution {
public:int maxSubArray(vector<int>& nums) {int n = nums.size() , res = -0x3fffffff;vector<int> dp(n + 1);for(int i = 1 ; i <= n ; ++i){dp[i] = nums[i - 1] + (dp[i-1] > 0 ? dp[i-1] : 0 );res = max(res,dp[i]);}return res;}
};

这个题目理解子数组问题的基本入口,我们在思考子数组类问题的时候都可以按照这种思维方式来推导状态表示以及状态转移方程,也就是按照子数组的长度来划分为两类情况,分别讨论,得到我们想要的结果。

2 环形子数组的最大和

918. 环形子数组的最大和 - 力扣(LeetCode)

这个题和上一个题的区别就是数组环形的,也就是第一个元素的前面可以看成最后一个元素。这样一来,我们可以换一种思路,对于环形数组,他的最大和的子数组有两种情况:1 未成环 2 成环

对于第一种情况,我们只需要按照上一个题的思路去求出最大子数组和就行了

而对于第二种情况,最大和的子数组成环的话,我们可以发现,虽然子数组本身不连续,但是除了最大和的子数组之外,剩余的元素是连续的,剩余的元素就是一个普通的不成环的子数组。那么首先数组的总和是固定的,我们要使成环的子数组的和最大,那么我们就需要使得这个剩余元素的子数组和最小,因为这个环形子数组的和就是数组的总和减去中间的连续子数组的和。

综上,我们这个题可以定义两个状态表示:

f[i] 表示以nums[i]为结尾的子数组最大和

g[i] 表示以nums[i]为结尾的子数组最小和

那么f[i] 的最大值就是不成环的最大子数组和,而 数组总和 - g[i]的最小值 就是成环的子数组的最大和,那么整体的最大值就是二者之中的较大值。

而f[i]的状态表示我们上一个题已经分析过了

f[i] = nums[i] + (f[i-1] > 0 ?f[i-1]:0)

对于g[i]的状态表示,我们则需要反着来:

首先还是根据子数组的长度进行讨论,以nums[i]为结尾的子数组有两种情况:

1、 长度为1,此时子数组和就是 nums[i]
2、长度大于1,那么需要使得以nums[i]为结尾的子数组和最小,就需要保证以nums[i]为结尾的子数组和最小,也就是 g[i] = nums[i] +g[i=1]

我们需要去两种情况的最小值,也就是min(nums[i] ,nums[i] + g[i-1]),那么综上:

g[i] = nums[i] + (g[i-1] < 0 ? g[i-1] : 0)

环形子数组的最大和我们需要预处理求出整个数组的和。

我们求最小和的时候,有可能数组的所有元素都是负数,那么就会导致最小和的子数组就是整个数组,所以我们再填g表的时候是需要判断一下中间是否出现断层,否则使用数组总和减去最小和会出错,此时的结果为空子数组,但是题目要求子数组的元素个数至少为1。

细节问题:

需要处理边界 f[0] = g[0] = nums[0];

填表顺序:两个表都是从左往右填。 

最终需要的是 f 表中的最大值和 g 表中的最小值,我们可以一变填表一遍记录。

返回值就是两种情况的最大值。

代码:

class Solution {
public:int maxSubarraySumCircular(vector<int>& nums) {int n = nums.size(), sum = 0;for(auto&e:nums) sum += e;vector<int> f(n);  //f表用于求最大子数组和auto g = f;         //g表用于求最小子数组和bool gflag = false;    //用来表示最小和的子数组是否是整个nums数组f[0] = g[0] = nums[0];int fmax = f[0] , gmin = g[0];for(int i = 1 ; i < n ; ++i){f[i] = nums[i] +(f[i-1] > 0 ? f[i-1] : 0);fmax = max(fmax,f[i]);g[i] = nums[i] + (g[i-1] < 0 ? g[i-1] : 0);if(g[i-1] >= 0) gflag = true; //说明最小和的子数组并不是整个nums数组gmin =min(gmin,g[i]);}int res = fmax;if(gflag) res = max(res , sum - gmin);return res;}
};

当然对于 gflag 还有一种更简单的判断方法,就是在求 sum 的时候顺便判断一下 nums 中的元素是否全为负数和0,如果全为负数,那么就说明最小和的子数组一定是整个数组。 如果出现了0或者整数,那么最小子数组和一定是nums数组的一部分而不是全部。 

当然这样的话我们会将数组全为 0 的情况漏掉,但是这仅仅是在 g 表中漏掉,在f表中是能够将 0 统计好的,当数组全部都是 负数和0 的时候,f表的最大值就是最终的结果。

3 最大乘积数组

152. 乘积最大子数组 - 力扣(LeetCode)

解析题目:题目给一个整数数组nums,我们需要求出nums中的一个乘积最大的非空子数组。

那么我们还是按照子数组的分析思路来分析,定义状态表示为:

dp[i] 表示以 nums[i] 为结尾的子数组中,最大的乘积。

然后我们分析 dp[i] 的转移方程,还是按照子数组的长度来划分问题:

1 子数组长度为1,此时只有nums[i],那么乘积就是nums[i]

2 子数组的长度大于1,那么此时我们可以将以nums[i]为结尾的子数组划分为 nums[i] 和 以nums[i-1]为结尾的子数组。 但是在这个题目中,我们需要的是乘积,乘法运算中,dp[i-1]是以nums[i]为结尾子数组最大的乘积,但是 nums[i] * dp[i-1] 则不一定是以nums[i]为结尾的子数组的最大乘积,因为 nums[i] 本身有可能是负数,这就会导致 nums[i] * dp[i-1] 最终是一个负数,此时的意义就变成了以 nums[i] 为结尾的子数组的最小乘积

也就是,在长度大于 1 的时候,我们需要将 nums[i] 分为正数和负数两种情况来讨论: 

当nums[i] 为正数时,此时我们需要求出以 nums[i-1] 为结尾的子数组的最大乘积 f[i-1], nums[i]*f[i-1]就是以nums[i]为结尾的子数组的最大乘积。我们可以自己试验一下。比如以nums[i-1]为结尾的子数组的乘积中全是正数,那么自然是正数越大越好,而如果有正有负,也是需要使用最大的正数,而如果全部都是负数,那么就意味着最终结果一定是负数了,那么自然是负数的绝对值越小越好,也就是负数本身越大越好,三种情况都是需要子数组的最大的乘积。

当nums[i] 为负数的时候,我们需要求出以 num[i-1] 为结尾的子数组的最小乘积 g[i-1] ,nums[i]*g[i-1]就是有nums[i]为结尾的额子数组的最大乘积。 我们也可以按照上面的方法试验一下。

按照上面的推导,我们只定义一个状态表示是不够的,需要两个状态表示,如下:

f[i] 表示以 nums[i] 为结尾的子数组中,最大的乘积。

g[i] 表示以 nums[i] 为结尾的子数组中,最小的乘积。

状态转移方程按照上面的推导思路,最大乘积使用 f[i-1] 还是 g[i-1] 取决于 nums[i] 的正负性,而最小成绩使用 f[i-1] 还是 g[i-1] 也是取决于nums[i]的正负性,但是与最大乘积相反。

所以不管怎么说,我们的最大乘积和最小乘积一定是在三种情况下取出:  nums[i] , nums[i]*f[i-1] , nums[i]*g[i-1] ,这三者中的最大值就是最大成绩,最小值就是最小乘积,所以状态转移方程就是:

f[i] = max( nums[i] , nums[i] * f[i-1] , nums[i] * g[i-1] )

g[i] = min( nums[i] , nums[i] * f[i-1] , nums[i] * g[i-1] )

细节问题:

边界值的初始化,f[0] 和 g[0] 都需要初始化为 nums[0] ,如果我们开辟额外空间,那么虚拟空间的值可以初始化为 1.

填表顺序:从左往右,两个表一起填。

返回值: 需要的是所有子数组中的最大乘积,那么返回值就是 f 表的最大值,可以边填表边记录,res可以初始化为nums[0]

代码如下:

class Solution {
public:int maxProduct(vector<int>& nums) {int n = nums.size() , res = nums[0];vector<int> f(n),g(n);f[0] = g[0] = nums[0];for(int i = 1  ; i < n ; ++i){f[i] = max(nums[i] , max(nums[i] * f[i-1] , nums[i] * g[i-1]));g[i] = min(nums[i] , min(nums[i] * f[i-1] , nums[i] * g[i-1]));res = max(res,f[i]);}return res;}
};

4 乘积为正数的最长子数组长度

 1567. 乘积为正数的最长子数组长度 - 力扣(LeetCode)

这个题与上一题类似,是一道乘法相关的问题,还是按照子数组系列问题的动态规划分析思路,我们可以分析以 nums[i] 为结尾的子数组的情况,用于延伸到所有的 0 <= i < n

根据题目,我们需要子数组的乘积为正数,要求的是最大子数组的长度,那么还是将以 nums[i] 为结尾的子数组按照长度分为两个大类

1 长度为1的子数组,也就是只包含nums[i],此时只取决于nums[i]是否为正数

2 长度大于1 的子数组,那么此时以nums[i]为结尾的子数组可以分为两个部分,nums[i] 和以nums[i-1]为结尾的子数组,那么此时需要分为三种情况来讨论:

2.1:nums[i] 为正数,那么要使nums[i]*dp[i-1]的乘积为正数,就需要dp[i-1]为正数,而我们要的是乘积为正的最长的子数组的长度,那么就需要知道以nums[i-1]为结尾的乘积为正的子数组的最长长度

2.2:nums[i]为负数,那么要使 nums[i] * dp[i-1] 的乘积为正数,就需要dp[i-1]为负数,而我们要的是成绩为正的最长的子数组的长度,那么就需要知道以nums[i-1]为结尾的乘积为负数的子数组的最长长度。

2.3:nums[i] = 0 ,那么乘积不可能为正,以nums[i]为结尾的子数组中不存在乘积为正数的子数组,所以dp[i] = 0 ,表示空数组。

按照上面的推导过程,我们至少需要两个状态表示:

f[i] 表示以nums[i] 为结尾的乘积为负数的子数组中,最长的子数组长度

g[i] 表示以nums[i]为结尾的成绩为正数的子数组中,最长的子数组长度

状态转移方程推导:

如果nums[i] 为0,那么以nums[i] 为结尾的子数组中,子数组的乘积都为0,不可能为正数或者负数,所以 f[i] = g[i] = 0;

如果nums[i] < 0,那么要使以nums[i] 为结尾的子数组的乘积为正,就需要以nums[i-1]为结尾的子数组的乘积为负数,同时需要子数组的长度最长,那么就需要以 nums[i-1]为结尾的乘积为负数的子数组的长度最长,在该子数组后面加上一个 nums[i] 形成一个新的子数组,那么f[i] = g[i-1]+1;而对于g[i],要使以nums[i] 为结尾的子数组的乘积为负数,那么就需要以nums[i-1]为结尾的子数组的长度为正数,同时要使得子数组的长度最长,那么就需要以 nums[i-1]为结尾的乘积为正数的子数组的长度最长,也就是f[i-1],在稿子数组大的后面加上一个nums[i]形成新的子数组,那么g[i] = f[i-1]+1;

如果nums[i]为正数,那么跟第二种情况就是反着来:f[i] = f[i-1]+1,g[i] = g[i-1]+1;

if(nums[i] == 0) f[i] = g[i] = 0;

if(nums[i] < 0 ) f[i] = g[i-1] + 1 , g[i]  = f[i-1] + 1

if(nums[i] > 0 ) f[i] = f[i-1] + 1 , g[i] = g[i-1] +1

细节问题:

初始化:我们需要初始化f[0] 和g[0],很简单,如果nums[0]为正数,那么f[0]=1,g[0]=0,如果nums[0]为负数,那么f[0] = 0 ,g[0] = 1,如果nums[0]=0,那么f[0]=g[0]=0;这样一来初始化就很复杂,我们可以在f表和g表都多开一块空间,那么这一块虚拟的空间怎么填呢? 为了不影响后续的f[1]和g[1]的值,我们可以直接填0,甚至我们可以直接根据状态定义来填值,躲开一块空间之后的f[0]和g[0]表示以nums[-1]为结尾的子数组的乘积为正和为负的最长长度,而他们本身就是空数组,自然就是0.注意多开一个空间之后,f[i]和g[i]映射的就是nums[i-1]了。

填表顺序:从左往右,f表和g表一起填

返回值:返回f表的最大值,可以一边填表一边记录最大值,res可以初始化为0,表示空数组。

本体是允许空数组的,因为可能存在这样的情况[-1],那么此时不可能出现为乘积正数的非空子数组,所以只能选择空数组。

代码如下(额外空间):

class Solution {
public:int getMaxLen(vector<int>& nums) {int n = nums.size(),res = 0;vector<int> f(n+1 , 0),g(n+1 , 0);for(int i = 1 ; i <= n ; ++i){if(nums[i-1] == 0) f[i] = g[i] = 0;if(nums[i-1] < 0 ) f[i] = g[i-1] > 0 ? g[i-1] + 1 : 0 , g[i] = f[i-1]+1;if(nums[i-1] > 0 ) f[i] = f[i-1] + 1 , g[i] = g[i-1] > 0 ?g[i-1] +1:0;res = max(res,f[i]);}   return res;}
};

5 等差数列划分

413. 等差数列划分 - 力扣(LeetCode)

解析题目:给定一个nums,要求我们求出nums中为等差数列的子数组的数目。等差子数组的定义:1 至少要有三个元素 2 相邻元素之间的差值相等。

对于子数组类的题目,我们还是以结尾位置来定义状态,

dp[i] 表示以nums[i] 为结尾的等差子数组的个数

然后对 dp[i] 进行分析,以nums[i] 为结尾的子数组可以按照长度分为两类:

1 长度为1的子数组,也就是nums[i]自身,此时不满足等差数列

2 长度大于1的子数组,那么我们可以看成是 nums[i] 和以nums[i-1]的子数组 组合而成。如果我们想要以nums[i]为结尾的子数组是等差数组,那么至少要满足 nums[i] - nums[i-1] = nums[i-1] - nums[i-1],如果nums[i-2] , nums[i-1] , nums[i] 这三个元素构成了等差子数组,对于以nums[i-1]为结尾的等差子数组,他一定是包含 nums[i-2]的,而nums[i-1] - nums[i-2] 是确定的,那么就说明了以nums[i-1]为结尾的等差子数组的公差一定是 nums[i-1] - nums[i-2] 。那么如果nums[i] - nums[i-1] 与nums[i-1] - nums[i-2] 相等,说明我们将nums[i] 拼接到以nums[i-1]为结尾的等差子数组之后,照样是等差子数组,同时,以nums[i]为结尾的等差子数组除了所有的以nums[i-1]为结尾的等差子数组后面加上一个nums[i]之外,还会多出一个长度为3的等差子数组,也就是{nums[i-2].nums[i-1],nums[i]} ,所以 dp[i] = dp[i-1] + 1;

但是如果 nums[i] - nums[i-1] != nums[i-1] - nums[i-2] ,那么就说明这三个数不构成等差数列了,而以nums[i-1]为结尾的所有的等差子数组的公差都是 nums[i-1] - nums[i-2] ,那么以nums[i]为结尾的子数组中没有等差子数组了,也就是dp[i] = 0;

那么综上所述,状态转移方程如下:

dp[i] = (nums[i-1] * 2 == nums[i] + nums[i-2] ?dp[i-1]+1:0)

细节问题:

初始化:填表过程中我们需要用到dp[i-1] 和 dp[i-2],那么当 i = 0 和 i = 2时会越界,所以我们需要手动初始化 dp[0] 和 dp[1] ,根据状态表示,以nums[0] 和以nums[1]为结尾的子数组中不可能构成等差子数组,因为长度一定小于3,所以 dp[0] = dp[1] = 0;

填表顺序:从左往右填表

返回值:题目要求返回所有的等差子数组,那么我们需要返回dp表的所有值之和,我们可以一变填表一边记录res。

代码如下:

class Solution {
public:int numberOfArithmeticSlices(vector<int>& nums) {int n = nums.size() , res = 0;vector<int> dp(n);for(int i = 2 ; i < n ; ++i){dp[i] = (nums[i-1] * 2 == nums[i] + nums[i-2] ? dp[i-1] + 1 : 0);res += dp[i];}return res;}
};

6 最长湍流子数组

978. 最长湍流子数组 - 力扣(LeetCode)

解析题目:首先理解湍流子数组,比较符号在子数组中的每个相邻元素对之间翻转。其实很简单,对于每一个i,如果nums[i] > nums[i-1] ,那么只有当 nums[i+1] < nums[i] 的时候,nums[i-1],nums[i].nums[i+1]这三个数才构成湍流子数组。

我们可以更抽象一点,将比较关系用上升和下降来表示,如果nums[i]>nums[i-1],我们称在nums[i-1]处处于上升趋势,如果nums[i] < nums[i-1] ,那么我们称在nums[i-1]处处于下降趋势。

那么如果一个数组是湍流子数组,那么对于 k ,在[0,n]或者说在 [0,n-1]范围内,每一个位置的上升和下降是轮着出现的,用图画出来就是类似于这样的图:

每一个元素本身就是一个湍流子数组,同时,每两个相邻的元素也是一个湍流子数组,因为两个元素只存在一个关系,并没有违反湍流子数组的规则。

那么我们还是来分析中间的任意一个nums[i] ,要使得以nums[i]为结尾的子数组是湍流子数组,还是根据子数组长度划分为两类:

1 子数组长度为1 ,那么此时表示的是 nums[i] 一个元素构成的子数组,是湍流子数组,长度为1

2 子数组长度大于1,此时如果nums[i] 与nums[i-1] 不相等,那么此时长度就至少为2了,我们需要知道nums[i] 与nums[i-1]的关系,如果nums[i] > nums[i-1] ,那么说明在i-1 位置处于上升趋势,那么只有在 i-2 位置处于下降趋势的时候, 也就是以nums[i-1]为结尾的湍流子数组的最后一对元素呈下降趋势,那么nums[i] 就可以和以nums[i-1]为结尾的湍流子数组组合构成一个新的更长的湍流子数组。相反的,如果nums[i] < nums[i-1] ,那么nums[i] 只有与以nums[i-1]为结尾的且最后一对元素处于上升趋势的湍流子数组构成新的更长的湍流子数组。

如果nums[i] == nums[i-1] ,那么以nums[i]为结尾的湍流子数组其实就只能是长度为1的子数组了。

那么我们的状态表示如下:

dp[i] 表示以nums[i]为结尾的子数组中,最长的湍流子数组的长度

其实在dp[i]中隐藏了一个状态,就是最后一对元素处于下将状态还是上升状态,但是在填写dp[i]的时候,我们只会用到nums[i-1]和nums[i-2]的关系,我们可以使用一个变量来记录,避免每一次都取出数组元素来比较。

状态转移方程:

首先我们根据上面的推导,如果nums[i] == nums[i-1] ,那么dp[i] = 1,因为nums[i]无法和其前面的元素构成湍流子数组。

如果 nums[i] > nums[i-1] ,那么有两种情况,1 nums[i-1] > nums[i-2] ,那么说明nums[i]最多只能和nums[i-1]构成湍流子数组,如果再加入一个nums[i-2],就不符合湍流子数组的定义了,此时dp[i] = 2;  2  如果nums[i-1] < nums[i-2],那么说明nums[i]和nums[i-1]以及nums[i-2]能够构成湍流子数组,更深入的说: nums[i] 能够连接在 以nums[i-1] 为结尾的湍流子数组 之后构成新的湍流子数组,而我们dp[i]需要的是以nums[i]为结尾的最长的湍流子数组的长度,那么我们就需要以nums[i-1]为结尾的最长湍流子数组的长度dp[i-1] ,而dp[i] = dp[i-1]+1;

如果nums[i] < nums[i-1] , 那么与上面的情况相反。

我们前面也说了,可以使用一个变量来记录 nums[i-1] 和nums[i-2] 的关系,我们可以使用一个flag来记录,定义:nums[i-1] == nums[i-2] ,flag = 0 , nums[i-1] > nums[i-2] ,那么flag = 1,如果nums[i-1] < nums[i-2] ,flag = 2.

那么上面的状态转移方程可以这样总结: 

if(nums[i] == nums[i-1]) {

        dp[i] = 1;

        flag = 0;

}

else if(nums[i] > nums[i-1]){

        if(flag == 2)         dp[i] = dp[i-1] + 1 ;

        else  dp[i] = 1;

        flag = 1; //更新flag 

}

else{

        if(flag == 1) dp[i] = dp[i-1] + 1;

        else dp[i] = 2;

        flag = 2;

}

细节问题: 

初始化: 我们上面的状态转移方程是基于flag来推导的,意思就是至少要能够初始化flag的值,也就是需要手动初始化 do[0] 和 dp[1] 的值,来获取一个初始的 flag

填表顺序: 从左往右填表

返回值: 返回整个dp表的最大值,一变填表一边记录

代码如下:

class Solution {
public:int maxTurbulenceSize(vector<int>& nums) {int n = nums.size() , res = 0 ,flag = 0; if(n < 2) return n;if(nums[1] == nums[0]) flag = 0;else if(nums[1] > nums[0]) flag = 1;else flag = 2;vector<int>dp(n);dp[0] = 1,dp[1] = (flag == 0 ? 1 : 2);res = dp[1];for(int i = 2 ; i < n ; ++i){if(nums[i] == nums[i-1]){flag = 0 ; dp[i] = 1;}else if(nums[i] > nums[i-1]){if(flag == 2) dp[i] = dp[i-1] + 1;else dp[i] = 2;flag = 1; }else{if(flag == 1) dp[i] = dp[i-1] + 1;else dp[i] = 2;flag = 2; }res = max(res , dp[i]);}   return res;}
};

对于本题,直接贪心肯定是要比动态规划好的,而贪心的做法也很简单,利用我们上面使用的flag,然后再使用了两个变量来记录历史长度以及最长长度就行了。

7 单词拆分

139. 单词拆分 - 力扣(LeetCode)

解析题目:给定一个字符串 s ,在给定我们一个单词string数组,要我们判断能不能由数组中的单词组成字符串s。

对于这个题目,其实本质上还是一个子数组的问题,我们要让 s 能够由单词组成,那么对于s的最后一个字符,一定能和前面的0个或者多个字符组成一个在数组中出现的单词,然后再判断剩余部分能否由单词组成。

那么分析思路还是一样的,我们想要判断以 s[i] 为结尾的子串能否由给定的单词构成,那么我们还是将这个字串分为两部分看,结尾为s[i]的单词 + 子串剩余部分。只有这两个部分都能够有给定的单词组成,那么我们才认为这个子串能够给定的单词组成。

两个条件: 1、 [j,i] 子串是一个给定的单词

                   2、剩余部分[0,j-1] 能够由给定的单词组成

那么要满足第一个条件,我们的j其实可以在[0,i] 范围内,只要有一个 j 满足就行了,但是要于此同时满足第二个条件,而第2个条件显然比第 1 个条件更加严格,那么其实我们在选择 j 的时候,其实只能够在[0,j-1]的字串部分能够被给定的单词组成的前提下来选择。

那么我们可以定义状态表示为:

dp[i] 表示以s[i]为结尾的子串能否被给定的单词组成,如果能dp[i] = true,不能的话dp[i] = false

那么对于状态转移方程,我们其实是需要从i开始,往前遍历,找到一个 j ,要满足dp[j] = true ,然后判断 [j+1,i] 这个子串是不是给定的单词,如果是,那么就说明 [0,i] 这个子串是能够被所给定的单词组成的,如果不是,那么还需要继续向前遍历找 j ,如果j = 0 还是找不到一个符合条件的,那么就说明 [0,i] 子串无法被给定的单词组成。

那么这样一来,遍历 j 的时候其实很多的时候 dp[j] 都是false,为true的在少数,但是dp[j]=false时其实对于后面的状态是没用的,所以其实填写 dp[i] 的时候,我们只会用到前面的为 true 的状态,那么优化一下,我们是不是可以不使用一个数组来保存所有的dp[i]  ,而是只有当dp[i] = true的时候,我们将此时的 i 尾插到数组中。那么后续在判断一个 dp[i] 的时候,要找到[0,i]之间的dp[j] = true的时候,那么我们只需要遍历这个数组就行了。

最后我们的返回值其实就看为true的dp[i] 中,是否有 i=n-1.如果有,说明s能够被给定的单词组成,如果没有,那么就是不能的。

同时,为了方便判断[j+1,i]子串是否是一个给定的单词,我们可以将所有的单词用一个unordered_set存储起来,方便后续查找。

代码:

class Solution {
public:bool wordBreak(string s, vector<string>& wordDict) {//dp[i] 表示以s[i]为结尾的子串能否由给定的单词组成//但是由于大部分的dp[i]无效//我们只保存dp[i] = true的下标i,用一个数组 OK 来存储int n = s.size();unordered_set<string> ss;vector<int> OK;OK.push_back(-1); //初始条件,表示空串for(auto&str:wordDict) ss.insert(str);for(int i = 0 ; i < n ; ++i){cout<<i<<endl;for(int j = OK.size() - 1 ; j >= 0 ; --j){if(ss.find(s.substr(OK[j]+1,i-OK[j]))!= ss.end()){  //[j+1,i] 是否是一个给定的单词OK.push_back(i);cout<<i<<":"<<OK[j]<<endl;break;}}}return OK.back() == n-1;}
};

当然其实还可以进一步优化,比如在遍历OK[j]的时候,当 [ OK[j]+1 , i ] 这个子串的长度已经大于给定的单词中最长的单词的长度,那么根本不需要set中查找,一定是不存在的的。

8 环绕字符串中的唯一子字符串

467. 环绕字符串中唯一的子字符串 - 力扣(LeetCode)

解析题目:题目定义了环绕字符串,其实就是一个 a~z 的无限循环的字符串。而给定一个s,要求找出s中有多少子串(相同的子串只计算一边)在环绕字符串中出现过。

s中的环绕子串,我们可以简单理解,就是字符成递增序列的子串,比如 "a" 或者 "abc" 或者"yza"这样的,而类似的,"ba","ca" 这样的子串就不是环绕字符串的子串。

那么还是按照数组的动态规划思路,定义状态表示为:

dp[i] 表示以 s[i]为结尾的子串中,是环绕字符串的子串的个数。

那么分析dp[i] 的状态转移方程:

长度为1时,s[i]自身就是一个环绕字符串的子串。

如果s[i] 是s[i-1] +1 或者说 s[i] == 'a' && s[i-1] == 'z' 那么s[i-1]和s[i] 就构成长度为2的环绕子串,同时,以 s[i-1]为结尾的环绕子串都可以在后面加上一个s[i]构成一个更长的环绕子串,那么这样一来 dp[i] = dp[i-1] + 1 ,+1是因为多了一个子串也就是 s[i] 自身。

我们需要手动初始化dp[0] = 1,但是由于所有的dp[i] ,不管怎么说,即便不满足场面的条件,他的环绕字串的数量也会是1,所以我们干脆将dp表全部初始化为1,在填表过程中我们就不需要考虑长度为1的情况了。

而返回值就是dp表的元素之和。

那么我们初步将代码写出来就是这样的:

class Solution {
public:int findSubstringInWraproundString(string s) {int n = s.size();vector<int> dp(n,1);int res = 1;for(int i = 1 ; i < n ; ++i){if(s[i] == s[i-1] + 1 ||(s[i] == 'a' && s[i-1] == 'z')) dp[i] = dp[i-1] + 1;res += dp[i];}return res;}
};

但是如果只是这样的话,我们会发现一个问题:

测试用例2结果不对,原因也很简单,因为 "c" 这个环绕子串被算了两次,结果自然就不同了。

也就是我们其实还有一个关键问题没有处理,就是去重

那么针对本题如何去重呢? 或者说我们可以思考一下在什么情况下会出现重复子串呢? 

首先,只要出现重复的字符,这些重复的字符一定会被计算多次。

还有就是这样的情况: s = "abczabcdyzabc",在这个子串中,"abc" 其实出现了三次,那么"abc"的环绕子串也被计算了三次。

我么可以思考一下对于 "abc" 和 "zabc"这样的子串,怎么进行去重? 其实很简单,我们把这两个字符串叫做完整环绕子串,如果完整环绕子串的结尾字符相同的情况下,那么长度更长的那个完整子串,是包含了长度较短的那个完整子串的,也就是说,在计算长度更长的完整子串的所有环绕子串的时候,其实会把长度较短的完整子串的所有环绕子串计算一遍。前提是结尾字符相同,因为我们的状态表示中就是定义了dp[i] 的环绕子串一定是以s[i]为结尾的。

那么优化的思路就很简单,我们可以统计所有字符为结尾的完整子串的最长长度,比如s存在"abc"和"yzabc"的时候,我们记录 c 的完整子串的最长长度为 5 ,那么这样一来,我们就可以计算出以每一个字符为结尾的最长完整环绕子串,那么以其为结尾的环绕子串的数量其实就是最长完整子串的长度。 比如"abc" 和 "yzabc",以c为结尾的环绕子串其实我们只需要计算"yzabc"的以c为结尾的环绕子串的数量,其实就是长度从 1 一直到 length ,也就是length个。

那么综上所述,其实我们只需要记录每一个字符为结尾的环绕子串的最长的长度,这个最长的长度其实也可以理解为以该字符为结尾的环绕子串的数量,这样就能达到去重的目的了。记录最大值我们可以使用一个unordered_map来记录每一个字符为结尾的环绕子串的最长的长度。

当然dp表我们还是正常填,最后的返回值就是返回每一个字符为结尾的最长环绕字串的长度之和,也就是把哈希表中的所有值加起来。

代码如下:

class Solution {
public:int findSubstringInWraproundString(string s) {int n = s.size();unordered_map<char,int> mm;vector<int> dp(n,1);int res = 0;mm[s[0]] = 1;for(int i = 1 ; i < n ; ++i){if(s[i] == s[i-1] + 1 ||(s[i] == 'a' && s[i-1] == 'z')) dp[i] = dp[i-1] + 1;mm[s[i]] = max(mm[s[i]] , dp[i]);}for(auto&[ch,len] : mm) res += len;  //只看需要计算最长的就行了return res;}
};

总结:关于动态规划的子数组的问题,我们都可以按照一个思路来思考,首先状态表示的定义一般定义为以数组的某个元素为结尾,来分析该元素的子数组的情况,而分析子数组的时候,将所有的子数组划分为两类,长度为1和长度大于1.。

相关文章:

  • Java大师成长计划之第8天:Java线程基础
  • 农产品园区展示系统——仙盟创梦IDE开发
  • Kotlin与Jetpack Compose的详细使用指南
  • React 第三十六节 Router 中 useParams 的具体使用及详细介绍
  • 力扣hot100——98.验证二叉搜索树
  • Python 数据智能实战 (4):智能用户分群 - 融合行为
  • libevent详解
  • Kubernetes Service 详解
  • 《Ultralytics HUB:开启AI视觉新时代的密钥》
  • 复杂度和顺序表(双指针方法)
  • HOW - 经典详情页表单内容数据填充(基于 Antd 组件库)
  • NLP 分词技术学习
  • Qwen 2.5 VL多模态模型的应用
  • 力扣——20有效的括号
  • 10、属性和数据处理---c++17
  • PCB设计工艺规范(二)基本布局要求
  • 虚拟机centos7安装docker
  • 第 12 届蓝桥杯 C++ 青少组中 / 高级组省赛 2021 年 4 月 24 日真题
  • 深入掌握CSS背景图片:从基础到实战
  • hadoop集群建立
  • 首部关于民营经济发展的基础性法律,有何亮点?专家解读
  • 刘国中:毫不松懈巩固拓展脱贫攻坚成果,全力以赴抓好农业防灾减灾工作
  • “80后”杨占旭已任辽宁阜新市副市长,曾任辽宁石油化工大学副校长
  • 五一“大车流”来了,今日午后G40沪陕高速开始迎来出沪高峰
  • 济南高新区一季度GDP增长8.5%,第二产业增加值同比增长14.4%
  • 铁路上海站今日预计发送旅客65.8万人次,同比增长超16%