动态规划
动态规划定义
动态规划(Dynamic Programming,简称DP)是一种用于求解最优化问题的数学方法。它把一个复杂的问题分解成一系列相互重叠的子问题,并从最基础的子问题开始解决,将每个子问题的解存储下来以供后面使用,从而避免了重复计算。
动态规划的核心思想
动态规划的核心思想是"记住已经解决过的子问题的最优解",这被称作"记忆化"。通过把原问题分解为相对简单的子问题,并在求解过程中保存子问题的解,动态规划能够显著减少计算量。
动态规划的基本特征
- 最优子结构:一个问题的最优解包含其子问题的最优解。这意味着问题可以通过组合子问题的最优解来解决。
- 子问题重叠:在解决一个问题时,相同的子问题会被反复计算多次。动态规划算法通过存储这些子问题的解来避免重复计算。
- 无后效性:一旦某个给定子问题的解被确定后,就不会再改变,不管在这之后会解决哪些子问题。
- 有重叠子问题:动态规划适用于子问题空间有大量重叠的情况,即不同的子问题具有相同的子子问题。
通过斐波拉契函数来体现以上的基本特征:
问题描述:
斐波那契数列是一个非常经典的递归问题,也可以用动态规划来解决。斐波那契数列的定义如下:
java
F(0) = 0
F(1) = 1
F(n) = F(n-1) + F(n-2) for n > 1
当求F(5)
动态规划的基本思路
1.定义状态 :将问题分解为一系列的子问题,并定义状态变量来表示这些子问题的解。
2.状态转移方程 :找出状态变量之间的关系,建立数学公式,这被称为状态转移方程或递推关系。
3.边界条件 :确定最基础子问题的解,这些解将作为算法的起点。
4.计算顺序 :确定子问题的解决顺序,确保在解决一个子问题时,它所依赖的子问题已经被解决。
5.实现 :根据状态转移方程和边界条件,使用自底向上的方法计算每一个子问题的解,并存储起来。
6.构造最优解 :所有子问题的解都计算出来后,根据这些解构造出原问题的最优解。
动态规划可以采用两种基本的实现方式:自顶向下 (带记忆化的递归)和 自底向上 (迭代)。在实际应用中,自底向上的方法更常见,因为它避免了递归的开销,并且能够更直接地利用数组等数据结构来存储状态。
做每道题目时需要思考的问题,将会在下面的例题中举例讲解:
dp数组以及下标的含义
确定状态转移方程(递推公式)
dp数组如何初始化
遍历顺序(从后向前还是从前向后遍历呢?比较典型的是背包问题)
例题
322.零钱兑换
思路:
本题是一个背包模型,题意总结出来是装满一个容量为j的背包的需要的最少物品数,按照动态规划的解题步骤来逐步分析:
1.dp
数组以及下标的含义:
dp[j]
:装满容量为j
的背包需要的最少物品为dp[j]
。
最终需要求的是dp[amount]
2.确定状态转移方程:
java
dp[j] = min(dp[j - coins[i]] + 1, dp[j])
当我们选择使用硬币 coins[i]
,那么已经有的硬币数量为 dp[j - coins[i]]
,再加上一个硬币就得到了dp[j]
,因为我们有很多个coins[i]
可以选择,所以就会对应有很多个dp[j]
,而我们需要求最少硬币数量,所以取所有dp[j]
的最小值,即可以得到上面的状态转移方程。
3.dp
数组的初始化
dp[0] = 0
:凑满价值为0的物品需要的硬币数为0;
非零下标:由于最后取最小值,所以为了防止干扰,所有非零下标的初始值应该为INT_MAX
。
4.遍历顺序
在本题目中遍历顺序无影响,因为既不是排列数也不是组合数。(如果是组合数需要先遍历物品再遍历背包,如果是排列数需要先遍历背包再遍历物品)
时间复杂度:
代码包含两层嵌套的循环:
外层循环遍历所有的硬币种类,次数为 coins.length
内层循环遍历从当前硬币的面值到 amount
,次数为 amount - coins[i] + 1
因此,总的时间复杂度可以表示为:
O(coins.length * amount)
这是因为每个硬币种类都可能遍历到 amount
次。
代码实现:
java
class Solution {
public int coinChange(int[] coins, int amount) {
// 初始化 dp 数组,长度为 amount + 1
int[] dp = new int[amount + 1];
// 基本状态,组成金额 0 需要 0 个硬币
dp[0] = 0;
// 将 dp 数组的所有元素初始化为正无穷
for(int i = 1;i < dp.length;i++){
dp[i] = Integer.MAX_VALUE;
}
// 遍历每一种硬币
for(int i = 0;i < coins.length;i++){
// 更新 dp 数组,从 coin 到 amount
for(int j = coins[i] ; j <= amount;j++){
//检查 dp[j - coins[i]] 是否为 Integer.MAX_VALUE。
//如果是,表示金额 j - coins[i] 不能用当前硬币组合出,因此不更新 dp[j]
if(dp[j - coins[i]] !=Integer.MAX_VALUE){
dp[j] = Math.min(dp[j - coins[i]] + 1, dp[j]);
}
}
}
// 如果 dp[amount] 仍为正无穷,说明无法组成该金额,返回 -1
return dp[amount] == Integer.MAX_VALUE ? -1 : dp[amount];
}
}
53.最大子数组和
思路 :
本题采用动态规划来求解,动态规划最重要的是得到动态规划方程,在本题中我们要得到数组中和最大的连续子数组,用f(i)
来表示到下标为i时子数组的和,那么f(i-1)
就是指到下标为i-1
的子数组的和,如果f(i-1)<0
,所以要得到最大的和,f(i)
就是nums[i]
,如果f(i-1)>0
,那么f(i)
的最大和为f(i-1)+nums[i]
,需要注意的题目中要求是连续的子数组,如果不是连续子数组,那么就不能用该方法求解,可以使用dfs
。如果没有理解上面的讲解可以点击观看视频讲解视频讲解-最大子数组和。
时间复杂度 :
由于只对数组进行了一次遍历,所以时间复杂度为O(n)
。
代码实现:
java
class Solution {
public int maxSubArray(int[] nums) {
int pre = nums[0];
int ans = nums[0];
for(int i = 1; i < nums.length;i++){
pre = pre > 0 ? pre + nums[i] : nums[i];
ans = Math.max(ans,pre);
}
return ans;
}
}
72.编辑距离
思路 :
本题采用动态规划来解决,设f(i,j)
为匹配word1
中的前i
个字符和word2
中的前j
个字符所需要的最少步数,f(i,j)
的值可分为两种情况
(1)word1[i] == word2[j]
:此时不需要进行任何操作即可匹配,f(i,j) = f(i-1,j-1)
(2)word1[i] != word2[j]
:由于有三种操作可供选择,所以又分为三种情况,最后的结果需要在这三种情况中取最小值:
- 插入:在
word1[i]
的后面插入word2[j]
,回到第一种情况,此时word1[i+1]
=word2[j]
,所以f(i,j)=f(i,j-1)+1
(这个+1是指插入操作) - 删除:删除
word1[i]
,此时需要比较word1[i-1]
和word2[j]
,所以f(i,j)=f(i-1,j)+1
(+1是指删除操作) - 替换:替换
word1[i]
为word2[j]
,此时与第一种情况完全相同,f(i,j)=f(i-1,j-1)+1
(+1是指替换操作)
需要注意的是,我们需要对第一行和第一列特殊处理,在两个字符串前加上空格,在初始化第一列时,f[i][0]
表示word1
的前i
个字符匹配word2[0]
的最少步骤,也就是匹配空字符串的步数,因此替换word1
前i
个字符为空字符即可,所以步骤为i
,初始化第一行同理,视频讲解点击视频讲解-编辑距离。
按照动态规划的解题步骤来逐步分析:
1.dp数组以及下标的含义:
f[i][j]
:匹配word1
中的前i
个字符和word2
中的前j
个字符所需要的最少步数
最终需要求的是f[n][m]
2.确定状态转移方程:
java
//word1[i] == word2[j]
f[i][j] = f[i - 1][j - 1];
//word1[i] != word2[j]
f[i][j] = Math.min(f[i - 1][j - 1],Math.min(f[i - 1][j] ,f[i][j - 1])) + 1;
3.dp数组的初始化
需要先对两个字符串进行预处理,在前面加上' '
。
f[i][0]和f[0][j]
:f[i][0]
表示word1
的前i
个字符匹配word2[0]
的最少步骤,也就是匹配空字符串的步数,因此替换word1
前i
个字符为空字符即可,所以初始化为i
,f[0][j]
同理。
非零下标:由于最后取最小值,所以为了防止干扰,所有非零下标的初始值应该为INT_MAX
。
4.遍历顺序
先遍历word1
再遍历word2
和先遍历word2
再遍历word1
没有影响,因为可以看成两个字符串的相互经过一系列的操作最后变为相同,其中插入和删除以及替换操作是相互的,所以对遍历顺序没有要求。
时间复杂度 :
这段代码的时间复杂度为 O(n * m)
,其中 n
是 word1
的长度,m
是 word2
的长度。
代码实现:
java
class Solution {
public int minDistance(String word1, String word2) {
int n = word1.length();
int m = word2.length();
//给word1和word2前面添加空格,方便处理,所以在后面f数组时,长度要+1
word1 = ' ' + word1;
word2 = ' ' + word2;
//创建二维数组并初始化
int[][] f = new int[n + 1][m + 1];
for(int[] item : f){
Arrays.fill(item,Integer.MAX_VALUE);
}
//处理第一行和第一列
for(int i = 0; i <= n;i++) f[i][0] = i;
for(int j = 0; j <= m;j++) f[0][j] = j;
for(int i = 1;i <= n;i++){
for(int j = 1;j <= m;j++){
if(word1.charAt(i) == word2.charAt(j)){
f[i][j] = f[i - 1][j - 1];
}else{
f[i][j] = Math.min(f[i - 1][j - 1],Math.min(f[i - 1][j],f[i][j - 1])) + 1;
}
}
}
return f[n][m];
}
}
139.单词拆分
思路:
本题采用动态规划求解,按照动态规划的解题步骤来逐步分析:
1.dp数组及下标的含义
dp[i]
:表示匹配字符串s
前i
个字符的结果,布尔类型
最终要求的是dp[n]
2.确定状态转移方程:
java
if(dp[j - 1] == true && st.contains(s.substring(j, i + 1))){
dp[i] = true;
}
由于本题中的dp
数组是布尔类型,所以只要判断赋值为true
还是赋值为false
,判断条件需要依靠第i
个字符之前字符的匹配情况。首先从第i
个字符开始向前 遍历,找到第j
个字符,能使得从j
到i
的子串包含在字典中(使用哈希表来存储字典元素,方便比较),此时当dp[j - 1]
为true
时,说明可以匹配成功,dp[i]
赋值为true
,这个时候就可以直接退出内循环,继续匹配下一个i
。
3.dp数组的初始化
在最开始需要给字符串前面加一个' '
,为了方便边界的处理
dp[0]
:dp[0]
应该初始化为true
,这样才能进行字符串的拆分,因为刚开始的拆分时需要依赖dp[0]
,如果dp[0]
为false
,那么后续的拆分就不能继续进行,下面举个例子:
字符串 s = "leetcode"
和字典 wordDict = ["leet", "code"]
。当算法检查是否可以将 s
拆分成字典中的单词时,它会首先检查 s
的前缀 leet
,这是可以拆分的。但是,为了标记 s
的前 4 个字符(即 leet
)可以被拆分,算法需要依赖于 dp[0]
为 true
,因为 leet
是基于空字符串的拆分结果。如果 dp[0]
是 false
,那么 dp[4]
也将会是 false
,即使 leet
可以被拆分。
4.遍历顺序:
按照字符串的长度顺序,逐个计算 dp[i]
的值。对于每个 i
,需要遍历所有可能的拆分点 j
(从 1
到 i
),并检查 s
的从 j
到 i
的子字符串是否在字典中,并且 dp[j-1]
是否为 true
,如果这两个条件都满足,那么 dp[i]
可以被设置为 true
。
视频讲解点击视频讲解-单词拆分
时间复杂度:
在两层循环中外层循环运行 n
次,内层循环在最坏情况下也可能运行 n
次(当没有任何单词匹配时),所以时间复杂度为O(n ^ 2)
。
代码实现:
java
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
Set<String> st = new HashSet<>(wordDict);
int n = s.length();
//给字符串前面加' ',方便边界的判断
s = ' ' + s;
//定义dp数组,长度为n + 1
boolean[] dp = new boolean[n + 1];
//初始化dp数组
dp[0] = true;
for(int i = 1; i <= n; i++){
for(int j = i; j > 0; j--){
if(dp[j - 1] == true && st.contains(s.substring(j, i + 1))){
dp[i] = true;
break;
}
}
}
return dp[n];
}
}
62.不同路径
思路 :
本题采用动态规划来求解,按照动态规划的解题步骤来逐步分析:
1.dp数组及下标的含义:
f[i][j]
:到第i
行第j
列的格子的路径数
最终要求的是f[m-1][n-1]
2.确定状态转移方程:
java
f[i][j] = f[i - 1][j] + f[i][j - 1];
由于每次只能向下或者向右移动,所以可以用f(i,j)=f(i-1,j)+f(i,j-1)
来计算路径数量,其中f(i-1,j)
是指到f[i][j]
上一格的数量,f(i,j-1)
是指到达f[i][j]
左边一格的路径数量,两中情况相加即为到f[i][j]的路径总数量。
3.dp数组的初始化:
将dp
数组全部的值初始化为1
,因为第一列只能通过从第一格向下走,第一列的每一格只有一种走法,第一行只能通过从第一格向右走,第一行的每一格只有一种走法,其他的格子需要依靠其上一个格子和左边的格子的路径数量来确定,全部初始化为1
后可以在计算出每一个格子的值后替换,不涉及比较问题,所以为了方便统一进行初始化,全部初始化为1
。
4.遍历顺序:
只要对整个二维数组全部遍历到即可,先遍历行还是先遍历列没有影响,所以遍历顺序可以是先行后列,也可以是先列后行。
详细的视频讲解点击视频讲解-不同路径。
时间复杂度 :
由于要对整个二维数组进行遍历计算,所以时间复杂度为O(mn)
,需要开辟一个二维数组来存储对应格子的路径数,所以空间复杂度也为O(mn)
。
代码实现:
java
class Solution {
public int uniquePaths(int m, int n) {
int[][] f = new int[m][n];
for(int[] item : f){
Arrays.fill(item,1);
}
for(int i = 1; i < m; i++){
for(int j = 1;j < n;j++){
f[i][j] = f[i - 1][j] + f[i][j - 1];
}
}
return f[m-1][n-1];
}
}
知识点扩展 :
对数组元素进行初始化设置可以使用以下函数,需要注意的是是的,Arrays.fill(nums,x)
方法只能用于一维数组。该方法用指定的值填充整个数组,对于多维数组,每个维度需要分别使用fill
方法进行填充。
java
//将nums数组的元素初始化为x
Arrays.fill(nums,x)
63.不同路径Ⅱ
思路 :
1.dp数组及下标的含义:
f[i][j]
:到第i
行第j
列的格子的路径数
最终要求的是f[m-1][n-1]
2.确定状态转移方程:
java
//i>0时 路径数等于到达上面格子的路径数+f[i][j]
f[i][j] += f[i - 1][j];
//j>0时 路径数等于到达左边格子的路径数+f[i][j]
f[i][j] += f[i][j - 1];
本题是62题的加强版,思路基本一样,都用到了动态规划的方法,唯一不同的是本题中多了一个障碍物的限制,有障碍物的网格是不能经过的,所以我们只要加一个判断条件,如果某个网格有障碍物,则将这个网格的可到达路径设置为0即可。
dp
数组默认值为0,在循环时首先判断是否有障碍物,有的话直接跳过即可,这里将i
和j
分开处理是因为dp
数组的初始化和62题不同,下面第3步中详细解释。
3.dp数组的初始化:
f[0][0]
:初始化为1,但是初始化的前提条件是该格没有障碍物,所以在初始化之前会进行判断。
非零下标的格子:本题中每个格子都有可能出现障碍物,所以不能像62题那样统一初始化为1,因为第一行和第一列也可能出现障碍物,因此遍历也需要从0开始,对于第一行和第一列不能直接使用62题中的状态转移方程,需要将两个条件分开来分别处理,注意这两个条件是并列的,都会执行,所以对于里面的格子相当于先整体加了从其上面格向下走的路径数量,再加上从其左边格子向右走的路径数量,本质上和第62题是一样的,只不过这样可以对第一行和第一列方便处理,另一点就是开始时先判断该格是否有障碍物,有的话默认值0不改变,在后续更新其他格子的路径时0也不会有影响。
4.遍历顺序:
只要对整个二维数组全部遍历到即可,先遍历行还是先遍历列没有影响,所以遍历顺序可以是先行后列,也可以是先列后行。
视频讲解点击视频讲解-不同路径Ⅱ。
时间复杂度 :
时间复杂度和空间复杂度同62题,均为O(mn)
。
代码实现:
java
class Solution {
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
int m = obstacleGrid.length;
int n = obstacleGrid[0].length;
int[][] f = new int[m][n];
for(int i = 0; i < m; i++){
for(int j = 0; j < n; j++){
//判断是否有障碍物,有则直接跳过
if(obstacleGrid[i][j] == 1) continue;
//将f[0][0] = 1,方便对第一行和第一列的路径数的计算
if(i == 0 && j == 0) f[i][j] = 1;
else{
//i>0时 路径数等于到达上面格子的路径数+f[i][j]
if(i > 0) f[i][j] += f[i - 1][j];
//j>0时 路径数等于到达左边格子的路径数+f[i][j]
if(j > 0) f[i][j] += f[i][j - 1];
}
}
}
return f[m - 1][n - 1];
}
}
64.最小路径和
思路 :
1.dp数组及下标的含义:
f[i][j]
:表示从(0,0)到(i,j)的路径和
最终要求的是f[m - 1][n - 1]
2.确定状态转移方程
由于每次只能向右或者向下走,所以可以得到以下的两个动态方程:
java
//从上面格子向下走
f[i][j] = f[i - 1][j] + grid[i][j]
//从左边格子向右走
f[i][j] = f[i][j - 1] + grid[i][j]
要得到最小路径和,所以两种走法中取最小值即可,所以最终的动态方程为:
java
f[i][j] = Math.min(f[i - 1][j],f[i][j - 1]) + grid[i][j]
注意最后在代码中不要直接使用该动态方程,因为要分别处理i
和j
为0的情况
3.dp数组的初始化
由于最后要求的是最小值,为了防止干扰,所以需要将dp数组全部初始化为Integer.MAX_VALUE
,这样在使用Math.min()
比较时就不会受到干扰。
4.遍历顺序
只要对整个二维数组全部遍历到即可,先遍历行还是先遍历列没有影响,所以遍历顺序可以是先行后列,也可以是先列后行。
视频讲解点击视频讲解-最小路径和。
时间复杂度 :
由于要遍历二维数组得到每一格的路径和,所以时间复杂度为O(mn)
,开辟一个二维数组来记录每一格的路径和,所以空间复杂度为O(mn)
。
代码实现:
java
class Solution {
public int minPathSum(int[][] grid) {
int m = grid.length;
int n = grid[0].length;
int[][] f = new int[m][n];
//初始化为最大值
for(int[] item : f){
Arrays.fill(item,Integer.MAX_VALUE);
}
for(int i = 0;i < m;i++){
for(int j = 0;j < n;j++){
//(0,0)处的值为网格的值,特判处理
if(i == 0 && j == 0) f[i][j] = grid[i][j];
else{
//处理j为0,即第一列的情况
if(i > 0) f[i][j] = Math.min(f[i][j],f[i - 1][j] + grid[i][j]);
//处理i为0,即第一行的情况
if(j > 0) f[i][j] = Math.min(f[i][j],f[i][j - 1] + grid[i][j]);
}
}
}
return f[m - 1][n - 1];
}
}
70.爬楼梯
思路:
1.dp数组及下标含义
f[i]
:表示到第i
阶台阶的方法数
最终要求的是f[n]
2.确定状态转移方程
java
f[i] = f[i - 1] + f[i - 2]
由于一次可以爬1阶或2阶,所以动态方程为f(i)=f(i-1)+f(i-2)
,其中f(i-1)
表示爬到i-1
阶的方法数,爬到i
需要爬1阶,可以到达i
阶,f(i-2)
表示爬到i-2
阶的方法数,爬到i
阶需要爬2阶,可以到达i
阶,两者加起来即为爬到i
阶的方法数
3.dp数组的初始化
f[1]
:初始化为f[1] = 1
,第1阶爬1阶到达,有一种方法
f[2]
:初始化为f[2] = 2
,第2阶可以可以从1阶爬到2阶,也可以直接爬2阶到2,所以有两种方法
4.遍历顺序
本题不需要区分遍历顺序,在这里对另一个容易出错的点进行说明,需要注意的是遍历时从3开始,方法数的计算依赖于爬到前一个和前两个阶梯的方法数,这里dp
数组创建时的长度要设置为n+2
,否则会发生溢出错误,一般情况下,如果dp
数组下标从0开始,那么数组长度设置为n+1
,否则计算f[n]
时会溢出(如果长度为n
,那么下标只能到n - 1
),在该题中,由于没有第0个台阶,下标从1开始,所以长度设置为n+2
,其他情况依次类推增加dp
数组的长度。
视频讲解点击视频讲解-爬楼梯。
时间复杂度 :
时间复杂度为O(n)
,由于开辟了一个数组来保存爬到每一阶的方法数,空间复杂度为O(n)
。
代码实现:
java
class Solution {
public int climbStairs(int n) {
//数组大小设置为n+2,防止溢出
int[] f = new int[n + 2];
f[1] = 1;
f[2] = 2;
for(int i = 3;i <= n;i++){
f[i] = f[i - 1] + f[i - 2];
}
return f[n];
}
}
121.买卖股票的最佳时机
思路1 :
股票问题是动态规划的典型题,按照动态规划的解题步骤来逐步分析:
1.dp数组及下标的含义
dp[i][0]
:第i
天持有股票获得的最大现金,需要注意的是这里并不表示第i
天买入,可能在第i
天前就买入了,第i
天还持有没有卖出
dp[i][1]
:第i
天不持有股票获得的最大现金,需要注意的是这里并不表示第i
天卖出,可能在第i
天前就卖出了,此时第i
天是不持有的,因为只能进行一次买卖
最终要求的是dp[n - 1][1]
2.确定状态转移方程
java
dp[i][0] = Math.max(dp[i - 1][0],-prices[i]);
dp[i][1] = Math.max(dp[i - 1][1],dp[i - 1][0] + prices[i]);
dp[i][0]
的计算包括两种情况:
(1)第i - 1
天也是持有的,那么说明在这两天前的某一天买入的,此时dp[i][0]
持有的现金和dp[i - 1][0]
是一样的,因此dp[i][0] = dp[i - 1][0]
(2)第i - 1
天不持有,那么说明是在第i
天买入的,所以可以此时dp[i][0] = -prices[i]
。
dp[i][1]
的计算包括两种情况:
(1)第i - 1
天也是不持有的,那么说明在这两天前的某一天就卖出了,此时dp[i][0]
持有的现金和dp[i - 1][1]
是一样的,因此dp[i][0] = dp[i - 1][1]
(2)第i - 1
天还是持有状态,那么说明是在第i
天卖出的,所以可以此时dp[i][0] = dp[i - 1][0] + prices[i]
。
3.dp数组的初始化
dp[0][0] = -prices[0]
:第0天持有,说明第0天买入,所以初始值为-prices[0]
。
dp[0][1] = 0
:第0天不持有,因此此时拥有现金为0
其他情况默认初始值为0,后续需要求最大值,初始值为0不会在比较时有影响。
4.遍历顺序
因为后一天的状态依赖于前一天,所以从前向后遍历,并且第0天已经初始化,所以从1开始遍历。
视频讲解点击视频讲解-买卖股票的最佳时机
时间复杂度:
时间复杂度为O(n)
,只对数组进行了一次遍历。
代码实现:
java
class Solution {
public int maxProfit(int[] prices) {
int n = prices.length;
int[][] dp = new int[n + 1][2];
dp[0][0] = -prices[0];
dp[0][1] = 0;
for(int i = 1; i < n; i++){
dp[i][0] = Math.max(dp[i - 1][0],-prices[i]);
dp[i][1] = Math.max(dp[i - 1][1],dp[i - 1][0] + prices[i]);
}
return dp[n - 1][1];
}
}
思路2:
本题还可以使用贪心算法来解决,通过找到第i
天之前的最小价格,并取后续每一天减去之前最小价格的最大值即可。视频解析点击视频解析-买卖股票的最佳时机。
时间复杂度:
时间复杂度为O(n)
,n
为数组长度,只对数组进行了一次遍历。
代码实现:
java
class Solution {
public int maxProfit(int[] prices) {
int ans = 0;
int minPrice = prices[0];
for(int i = 0; i < prices.length; i++){
ans = Math.max(ans, prices[i] - minPrice);
minPrice = Math.min(minPrice, prices[i]);
}
return ans;
}
}
152.乘积最大子数组
思路:
本题如果使用动态规划做会比较复杂,但是可以用到动态规划的思想,由于需要求得乘积最大的连续子数组,可能会出现负负得正的情况,所以需要同时维护一个最大值和最小值,每次循环都需要更新最大值和最小值,并更新ans
的值,最后返回ans
即可。
注:本题使用int类型或者long类型都会有一个测试用例([0,10,10,10,10,10,10,10,10,10,-10,10,10,10,10,10,10,10,10,10,0]
)不通过,因为会发生整型溢出,但其实按照题目中的"测试用例的答案是一个 32-位 整数"这个测试用例是不合法的,在这里我使用了double
类型可以通过全部的测试用例,是因为在这个特定的测试用例中,最大的乘积是在没有发生整数溢出的情况下计算得出的。即使使用 double
类型,也不会影响到最终结果的精度,因为最终的结果仍然是一个整数,使用 double
类型可以避免在乘法运算中可能发生的整数溢出问题,因为它可以表示比 long
类型更大范围的数值。然而,这并不意味着 double
类型总是适合所有情况,因为 double
类型在表示大数时可能会有精度损失。在这个特定的例子中,由于最终结果是一个整数,所以使用 double
类型不会导致精度损失,这种情况应该使用 Java
中的 BigInteger
类来处理,有兴趣的伙伴可以去看看,我后续会补上相关的内容。
视频讲解点击视频讲解-乘积最大子数组。
时间复杂度:
时间复杂度是 O(n)
,其中 n
是数组 nums
的长度。
代码实现:
java
class Solution {
public int maxProduct(int[] nums) {
double ans = nums[0];
double preMax = nums[0];
double preMin = nums[0];
for(int i = 1; i < nums.length; i++){
double a = preMax * nums[i];
double b = preMin * nums[i];
preMax = Math.max((double)nums[i],Math.max(a,b));
preMin = Math.min((double)nums[i],Math.min(a,b));
ans = Math.max(ans,preMax);
}
return (int)ans;
}
}