动态规划

动态规划(dynamic programming)是一个重要的算法范式,它将一个问题分解为一系列更小的子问题,并通过存储子问题的解来避免重复计算,从而大幅提升时间效率。

动态规划是属于自底而上的算法。与回溯算法一样,动态规划也使用“状态”概念来表示问题求解的特定阶段,每个状态都对应一个子问题以及相应的局部最优解。

同时由于动态规划的状态值依赖于某些局部状态,因此我们可以优化原有算法,消除dp的一个维度来达到降低空间复杂度的目的。

在动态规划问题中,当前状态往往仅与前面有限个状态有关,这时我们可以只保留必要的状态,通过“降维”来节省内存空间。这种空间优化技巧被称为“滚动变量”或“滚动数组”

解题步骤:

第一步:思考每轮的决策,定义状态,从而得到 dp 表;

第二步:找出最优子结构,进而推导出状态转移方程

第三步:确定边界条件和状态转移顺序。

0-1背包问题

定义状态变量

我们可以将 0-1 背包问题看作一个由 n 轮决策组成的过程,对于每个物体都有不放入和放入两种决策,因此该问题满足决策树模型。

每一个物品可以选择放入背包或者不放入背包,假设当前的物品编号是i,以及此时的背包容量是c,那么[i, c]表示当前物品编号是i,背包容量是c,那么定义状态dp[i, c]是前i个物品在容量为c的背包下的最大价值。

那么需要求解的是dp[n, cap]。

状态转移方程

当我们做出第i个物品的决策时,那么剩余的就是前i-1个物品的子问题,那么对于第i个物品:

· 不选,价值不变,容量不变[i-1, c];

· 选,容量减小,c - wgt[i-1],价值增加val[i-1],状态变为[i-1, c-wgt[i-1]]。但是这里得注意,如果当前的物品的重量是大于此时的背包容量的时候,这时候也是不能放入背包的。

那么整个状态转移方程是:

dp[i, c] = max(dp[i-1, c], dp[i-1, c- wgt[i-1]] + val[i-1])

初始条件以及计算顺序

当没有物品或者背包容量为0时,dp[0,c] 以及dp[i, 0] == 0,也就是首行和首列都是0。计算顺序从小到大,经过2层循环遍历。

n = len(wgts)
dp = [[0] * (cap+1) for _ in range(n+1)]
for i in range(1, n+1):
    for c in range(1, cap+1):
        # 还要讨论当前选择物品的重要是否超过了背包容量,超过了,依旧不选
        if wgts[i-1] > c:
            dp[i][c] = dp[i-1, c]
        else:
            dp[i][c] = max(dp[i-1][c], dp[i-1][c- wgts[i-1]] + val[i-1])
return dp[n][cap]

空间优化

从上述状态转移方程可以看出:

当前状态只和上一个状态有关,因此我们其实只需要一个一维数组就可以。因为i代表的是每一行,也就是每一行的状态都是由上一行的状态转移过来的,那么我们可以采用一个一维数组,当遍历第i行的时候,存储的其实是第i-1行的数据。

  • 如果采取正序遍历,那么遍历到d[i, j]时,左上方d[i-1, 1]  ~ d[i-1, j-1]值可能已经被覆盖,此时就无法得到正确的状态转移结果。
  • 如果采取倒序遍历,则不会发生覆盖问题,状态转移可以正确进行。
n = len(wgts)
dp = [0] * (cap+1)
for i in range(1, n+1):
    for c in range(cap, 0, -1):
        # 还要讨论当前选择物品的重要是否超过了背包容量,超过了,依旧不选
        if wgts[i-1] > c:
            dp[c] = dp[c]
        else:
            dp[c] = max(dp[c], dp[c- wgts[i-1]] + val[i-1])
return dp[cap]

可以继续改进

n = len(wgts)
dp = [0] * (cap+1)
for i in range(1, n+1):
    # 如果当前物品的重量大于背包容量,那么该物品也是不选,dp数组保持不变
    # 所以要使得当前dp数组状态转移,那么当前物品的重量必须要小于当前的背包容量c
    # 所以此时的背包容量满足以下关系:
    #     wgts[i-1] <= c <= cap
    for c in range(cap, wgts[i-1]-1, -1):
        dp[c] = max(dp[c], dp[c- wgts[i-1]] + val[i-1])
return dp[cap]

完全背包问题

定义状态变量

dp[i,c]:前i个物品在容量为c的背包下所能产生的最大价值。

状态转移方程

对于第i个物品,依旧是可选可不选:

不选的话,和之前一样,由前i-1个物品决定,状态转移至dp[i-1,c];

选的话,容量变小,c-wgts[i-1],并且由于选了第i个物品之后还可以继续选第i个物品,所以状态转移至dp[i, c-wgts[i-1]]。

由此状态转移方程是:

dp[i,c] = max(dp[i-1, c], dp[i,c-wgts[i-1]])

初始条件以及计算顺序

当没有物品或者背包容量为0时,dp[0,c] 以及dp[i, 0] == 0,也就是首行和首列都是0。因为每个物品都可以重复抽取,那么对应的状态值是可以更新的,这样后面用的时候也说明了这个元素是被多次利用,符合题目要求,计算顺序从小到大,经过2层循环遍历。

def unbounded_knapsack_dp(wgt: list[int], val: list[int], cap: int) -> int:
    """完全背包:动态规划"""
    n = len(wgt)
    dp = [[0] * (cap + 1) for _ in range(n+1)]
    for i in range(1, n+1):
        for c in range(1, cap+1):
            # 如果当前物品质量超过背包容量,则不选当前物品,那么价值由前i-1个物品决定
            if wgt[i-1] > c:
                dp[i][c] = dp[i-1][c]
            else:
                dp[i][c] = max(dp[i-1][c], dp[i][c-wgt[i-1]] + val[i-1])
    return dp[n][cap]

空间优化

由上述状态可知,当前状态值,是由上边的,以及左边的值转移的,因此空间优化的话,需要从正序入手。因为这样的话,已经更新的状态是可以被重复利用的话,说明元素可以被重复利用,因此选正序。

def unbounded_knapsack_dp(wgt: list[int], val: list[int], cap: int) -> int:
    """完全背包:动态规划"""
    n = len(wgt)
    dp = [0] * (cap + 1) 
    for i in range(1, n+1):
        for c in range(1, cap+1):
            # 如果当前物品质量超过背包容量,则不选当前物品,那么价值由前i-1个物品决定
            if wght[i-1] > c:
                dp[c] = dp[c]
            else:
                dp[c] = max(dp[c], dp[c-wgt[i-1]] + val[i-1])
    return dp[cap]

继续优化

def unbounded_knapsack_dp(wgt: list[int], val: list[int], cap: int) -> int:
    """完全背包:动态规划"""
    n = len(wgt)
    dp = [0] * (cap + 1) 
    for i in range(1, n+1):
        for c in range(wght[i-1], cap+1):
            dp[c] = max(dp[c], dp[c-wgt[i-1]] + val[i-1])
    return dp[cap]

“空间优化需要从正序入手,因为已经更新的状态可以被重复利用,说明元素可以被重复利用”,这是完全正确的。正序允许 dp[c-wgt[i]] 可能是本轮已经更新过的,从而支持同一物品多次选取。这与0-1背包的逆序形成鲜明对比。

正序/逆序的讨论,只出现在空间优化的一维 DP 中。在原始的二维 DP 里,我们只是老老实实地按物品顺序、按容量从小到大计算,不需要特意区分正序逆序,因为状态 dp[i][c] 只依赖于上一行 dp[i-1][...](或同一行左边 dp[i][c-wgt] 对于完全背包),二维数组天然隔离了层次,不会发生覆盖问题。

因此,你的这个补充非常到位,抓住了背包问题优化的关键点:

  • 二维 DP:清晰直观,无遍历顺序困扰。

  • 一维 DP:必须根据物品类型选择正序(完全背包)或逆序(0‑1 背包),以避免状态覆盖导致错误。

总结

  • 0-1背包(物品只能选一次):内层容量必须逆序(从大到小),这样才能保证 dp[c - w[i]] 使用的是上一轮(未选当前物品)的状态,避免同一个物品被重复使用。

  • 完全背包(物品可以选无限次):内层容量必须正序(从小到大),这样 dp[c - w[i]] 可能是本轮已经更新过的状态,从而允许物品被多次选取。

编辑距离问题

编辑距离,也称 Levenshtein 距离,指两个字符串之间互相转换的最少修改次数,通常用于在信息检索和自然语言处理中度量两个序列的相似度。

编辑距离问题可以很自然地用决策树模型来解释。字符串对应树节点,一轮决策(一次编辑操作)对应树的一条边。

如图 14-28 所示,在不限制操作的情况下,每个节点都可以派生出许多条边,每条边对应一种操作,这意味着从 hello 转换到 algo 有许多种可能的路径。

从决策树的角度看,本题的目标是求解节点 hello 和节点 algo 之间的最短路径。

第一步:思考每轮的决策,定义状态,从而得到 dp 表

每一轮的决策都是对s进行一次编辑,题目中是要我们找到最少编辑数。那么就是我们在编辑过程中,希望问题的规模越来越小,这样有利于构建子问题。假设字符串s和字符串t的大小分别是n和m,先考虑2字符串的最后一个元素s[n-1]和t[m-1]:

        如果这俩相等,则直接跳过这俩,看s[n-1]和t[m-2];

        如果这俩不相等,则我们需要对s进行一次编辑(替换、删除、添加),使得俩字符尾部元素相等,从而可以跳过它们,考虑更小规模的问题;

那么就是,我们在s中的每一个决策,都会使得s和t中剩余待匹配字符发生变化,因此状态为当前考虑的s中的第i个字符,和t中的第j个字符,记作[i, j]。

那么状态[i, j] 为将s前i个字符转变为t的前j个字符所需要的最小的编辑数目。

第二步:找出最优子结构,进而推导出状态转移方程

对于子问题dp[i, j],对应的是s[i-1]和t[j-1],当s[i-1]和t[j-1]不同的时候,根据不同的编辑操作,分为以下三种:

①在s[i-1]之后添加t[j-1],那么剩下的子问题就是dp[i, j-1];

②删除s[i-1],那么剩余的子问题就是dp[i-1, j];

③替换s[i-1]为t[j-1],那么剩余的子问题就是dp[i-1, j-1]

那么可以直到最少编辑数dp[i,j] 等于上面3个操作的最小值,再加上当前的编辑这一步:

dp[i,j] = min(dp[i,j-1], dp[i-1, j], dp[i-1, j-1])+ 1

如果s[i-1]和t[j-1]相同的话,不需要编辑当前字符,那么直接进入到dp[i-1, j-1],也就是dp[i,j]=dp[i-1,j-1]。

第三步:确定边界条件和状态转移顺序

        当2个字符串都是为空的时候,dp[0, 0] = 0,当s为空的时候,dp[0, j] = j;

        当t为空的时候,dp[i,0] = i;

由状态方程可知,当前状态是由左,上,左上决定的,因此需要两层循环正序遍历才能得到当前结果。

代码

def edit_distance_dp_comp(s: str, t: str) -> int:
    """编辑距离:空间优化后的动态规划"""
    n = len(s)
    m = len(t)
    dp = [[0] * (m+1) for _ in range(n+1)]
    # s不为空,t为空,需要的编辑数目等于s的长度
    for i in range(1, n+1):
        dp[i][0] = i
    # t不为空,s为空,需要的编辑数目等于t的长度
    for j in range(1, m+1):
        dp[0][j] = j
    for i in range(1, n+1):
        for j in range(1, m+1):
            # 如果最后一个元素相同,跳过
            if s[i-1] == t[j-1]:
                dp[i][j] = dp[i-1][j-1]
            else:
                dp[i][j] = min(min(dp[i][j-1],dp[i-1][j]), dp[i-1][j-1]) + 1
    return dp[n][m]

空间优化

由上述状态转移方程可知:当前状态依赖其正上方、正左方、左上方的状态,因此空间优化后正序或倒序遍历都无法正确地进行状态转移。为此,我们利用一个变量暂存左上方状态,从而转化到与完全背包问题等价的情况,可以在空间优化后进行正序遍历。

def edit_distance_dp_comp(s: str, t: str) -> int:
    """编辑距离:空间优化后的动态规划"""
    m = len(t)
    n = len(s)
    dp = [0] * (m+1) 
    # t不为空,s为空,需要的编辑数目等于t的长度(插入)
    for j in range(1, m+1):
        dp[j] = j
    # 此时的dp存储的是上一轮的遍历结果,也就是上一行的结果
    for i in range(1, n+1):
        # 保存上一行的左上,即dp[i-1][0]
        leftUp  = dp[0]
        # dp[0]要累加是因为相当于当t为空,s不为空的时候,s匹配要多少次,编辑距离等于 i(删除操作)
        dp[0]  += 1
        for j in range(1, m+1):
            ## 保存上一行的 dp[j](即 dp[i-1][j]),作为下一轮的左上角
            temp = dp[j]
            # 如果最后一个元素相同,跳过
            if s[i-1] == t[j-1]:
                dp[j] = leftUp
            else:
                dp[j] = min(min(dp[j-1], dp[j]), leftUp) + 1
            # 更新左上角为上一行的当前列值
            leftUp = temp
    return dp[m]

贪心

分数背包问题

class Item:
    """物品"""

    def __init__(self, w: int, v: int):
        self.w = w  # 物品重量
        self.v = v  # 物品价值

def fractional_knapsack(wgt: list[int], val: list[int], cap: int) -> int:
    """分数背包:贪心"""
    # 创建物品列表,包含两个属性:重量、价值
    items = [Item(w, v) for w, v in zip(wgt, val)]
    # 按照单位价值 item.v / item.w 从高到低进行排序
    items.sort(key=lambda item: item.v / item.w, reverse=True)
    # 循环贪心选择
    res = 0
    for item in items:
        if item.w <= cap:
            # 若剩余容量充足,则将当前物品整个装进背包
            res += item.v
            cap -= item.w
        else:
            # 若剩余容量不足,则将当前物品的一部分装进背包
            res += (item.v / item.w) * cap
            # 已无剩余容量,因此跳出循环
            break
    return res
  • [](Item &a, Item &b) { ... } 是一个 lambda 表达式,定义了一个比较函数。

  • 参数 a 和 b 是待比较的两个物品。

  • 返回值是 bool:如果 a 应该排在 b 前面,则返回 true;否则返回 false

  • 这里比较的是 单位价值(价值/重量),使用 > 表示降序(单位价值高的在前)。

  • (double) 强制转换,避免整数除法截断。

所以,排序后 items 中单位价值最高的物品会排在最前面。

sort(items.begin(), items.end(), 
     [](Item &a, Item &b) { 
         return (double)a.v / a.w > (double)b.v / b.w; 
     });

也可以用下面2种方式进行替换

// 方式1:普通函数
bool cmp(const Item &a, const Item &b) {
    return (double)a.v / a.w > (double)b.v / b.w;
}
sort(items.begin(), items.end(), cmp);

// 方式2:先定义 lambda 变量
auto cmp = [](const Item &a, const Item &b) {
    return (double)a.v / a.w > (double)b.v / b.w;
};
sort(items.begin(), items.end(), cmp);
class Item
{
public:
    int w;
    int v;
    Item(int w, int v):w(w), v(v)
    {
    }

};

/* 分数背包:贪心 */
double fractionalKnapsack(vector<int> &wgt, vector<int> &val, int cap) {
    // 创建物品列表,包含两个属性:重量、价值
    vector<Item> items;
    for(int i=0;i<wgt.size();i++)
    {
        items.push_back(Item(wgt[i], val[i]));
    }
    // 按照单位价值从高到低排列,
    sort(items.begim(), items.end(), [](Item &a, Item&b){return (double)a.v/a.w > (double)b.v / b.w};
    // 返回价值
    double res = 0;
    for(auto item :items)
    {
        if(item.w < cap)
        {
            res += item.v;
            cap -= item.w;
        }
        else
        { 
            res += cap*(item.v/item.w);
            break;
        }
            
    }
    return res;
    

最大容量问题

容器由任意2个隔板围成,而高是由较短的隔板决定,那么对应的容器面积是:

d[i, j] = min(h[i], h[j]) * (j-i)

如果是穷举n个隔板中任意2个的情况,那么时间复杂度将会达到O(n^{2})

那么贪心策略是:

使用双指针来进行操作,首先分别让双指针指向容器的两端,计算当前的容器容量,并更新最大容量,比较i,j对应的高度,然后移动短板向内一格,直至i、j相遇。

为什么是移动短板?

        因为如果一开始就是移动长板的话,那么容量一定是减小的,因为隔板的高度在减小,两个隔板之间的宽度也在减小,所以移动长板,虽然宽度在减小,但是高度在增加,可能会使得容量增大,而移动长板一定使得容量减小或者不变。

代码

/* 最大容量:贪心 */
int maxCapacity(vector<int> &ht) {
    # 双指针,一个指向头,一个指向尾
    int i = 0;
    int j = ht.size()-1;
    # 最大容量
    int maxcap = 0;
    # 知道2个指针相遇才结束遍历
    while(i<j)
    {
        # 计算当前的容量
        int temp = min(ht[i], ht[j]) * (j-i);
        maxcap = max(temp,maxcap);
        # 判断短板,并移动短板
        if(ht[i]<=ht[j])
        {
            i++;
        }
        else
            j--;
    }
    
    return maxcap;

}

最大切分乘积问题

/* 最大切分乘积:贪心 */
int maxProductCutting(int n) {
    // 当 n <= 3 时,必须切分出一个 1
    if (n <= 3) {
        return 1 * (n - 1);
    }
    // 贪心地切分出 3 ,a 为 3 的个数,b 为余数
    int a = n / 3;
    int b = n % 3;
    if (b == 1) {
        // 当余数为 1 时,将一对 1 * 3 转化为 2 * 2
        return (int)pow(3, a - 1) * 2 * 2;
    }
    if (b == 2) {
        // 当余数为 2 时,不做处理
        return (int)pow(3, a) * 2;
    }
    // 当余数为 0 时,不做处理
    return (int)pow(3, a);
}
/* 最大切分乘积:贪心 */
def maxProductCutting(n):
    # 任何一个大于3的数都可以写作
    #    n = 3q + r(r=0,1,2)
    if n<=3:   
        return n-1
    # q是商,r是余数
    q,r = divmod(n, 3)
    # 3*1 不如 2*2,所以将最后一个3换位2*2
    if r==1:
        return 3**(q-1)*4
    elif r==2:
        return 3**q*2
    # r==0的情况
    else:
        return 3**q

单调栈

10.正则表达式匹配

解题思路

本题考虑用动态规划来实现:

假设s的长度为m,p的长度为n,s的前i个字符记作s[:i],p的前j个字符记作p[:j],那么这道题就可以转换为判断s[:i]和s[:j]是否匹配,从第一个字符串开始查看s[:1]和p[:1]是否匹配,然后逐个增加元素查看是否匹配直至添加到整个字符串。

本题中共有mxn种状态,设置状态矩阵dp,其中dp[i][j]表示s的前i个字符和p的前j个字符是否匹配:

dp[i][j]对应的添加的字符是s[i-1]和p[j-1],其中dp[0,0] = True,空字符是可以匹配的。

首先对于空字符串如何匹配呢?只有如同a*a*……这种形式才可以匹配完成:

        当s为空字符串的时候,状态矩阵需要初始化:每当奇数下标时对应的p的字符为*,并且只有当p中字符个数为偶数的时候才可以匹配s。

而对于d[i][j]而言,此时对应比较的元素是s[i-1]以及p[j-1],而此时的p[j-1]就要分为*的情况和不为*的情况:

        当p[j-1]不是*的时候,那么p[j-1]就必须要和s[i-1]相等或者说p[j-1]==".",不论这2种情况的哪一种,先决条件都是d[i-1][j-1]要是True;

        当p[j-1]是*的时候,这个时候又分为了*代表前面出现的字符次数是0以及多次的情况:

                当前面的字符出现为0次的时候,也就是此时是p[j-2]*,但是*表示这里匹配0次p[j-2],“匹配0次前一元素”就是指忽略掉 x* 这一组合,即模式向前跳两个字符(跳过 p[j-2] 和 p[j-1]),字符串保持不变。这样,dp[i][j] 就直接等于 dp[i][j-2],不需要考虑字符串的当前字符。

                当*表示一次或者多次的时候,那么要比较s[i-1]要和p[j-2]是否匹配,或者p[j-2]是否等于".",那么这样的先决条件是d[i-1][j]要是True(比如说s“aaa”,p“aa*”,s[i-1] == p[j-2] == a,但是我们还要比较s[:i-1]和p[:j]是否匹配,因为*可以代表和p[j-2]多次出现,所以s[:i-1]还是要和p[:j]进行匹配,不能说s往左移一位,p也要往移一位,那么就直接得到不匹配的结果但是实际上s和p其实是匹配的)  

代码

python

class Solution(object):
    def isMatch(self, s, p):
        """
        :type s: str
        :type p: str
        :rtype: bool
        """
        m, n = len(s) + 1, len(p) + 1
        # dp size:mxn
        dp = [[False] * n for _ in range(m)]
        dp[0][0] = True
        # 那些模式可以匹配空字符串: (字符/. + *)这种才可以,并且要是偶数字符个数
        # 只对偶数下标进行赋值,奇数下标默认Fasle
        for j in range(2, n, 2):
            dp[0][j] = dp[0][j-2] and p[j-1] == '*'
        
        # 考虑p[j-1]是否为*,分情况讨论
        for i in range(1, m):
            for j in range(1, n):
                dp[i][j] = dp[i][j-2] or dp[i-1][j] and (s[i-1] == p[j-2] or p[j-2] == '.') \
                        if p[j-1] == '*' else \
                        dp[i-1][j-1] and (p[j-1] == '.' or s[i-1] == p[j-1])
        
        return dp[-1][-1]
        
        

C++

class Solution {
public:
    bool isMatch(string s, string p) {
        // 这里必须加1,因为dp[i][j]代表s的前i个元素和p的前j个元素是否相等
        // 之后循环遍历的时候是要取到s/p的长度,代表s/p的整体
        int m = s.size() + 1;
        int n = p.size() + 1;
        vector<vector<bool>> dp(m, vector<bool>(n, false));


        // 初始化s为空字符串的情况,p得为a*a*a*a*……这种情况,奇数下标都是*
        // 每次步进2个单位,因为p为奇数的话不可能和s匹配
        dp[0][0] = true;
        for(int j = 2;j<n;j+=2)
        {
            dp[0][j] = dp[0][j-2]  && p[j-1] == '*';
        }

        // 状态变化
        for(int i = 1;i<m;i++)
        {
            for(int j = 1;j<n;j++)
            {
                if(p[j-1] == '*')
                {
                    // *为0 + *为一或者多次前一元素的情况
                    dp[i][j] = dp[i][j-2] || (dp[i-1][j] && (s[i-1]==p[j-2] || p[j-2] == '.'));
                }
                else
                {
                    dp[i][j] = dp[i-1][j-1] && (s[i-1]==p[j-1] || p[j-1]=='.');
                }
            }
        }

        // 返回最后一个元素,就是代码s整个和p整个是否匹配
        return dp[m-1][n-1];

    }
};

32.最长有效括号

解题思路

对于这种最值型问题,我们尝试用动态规划来处理:

动态规划问题分为以下4个步骤:

1.确定状态

        研究最优策略的最后一步

        化为子问题

2.转移方程

        根据子问题定义得到

3.初始条件和边界条件

4.计算顺序

首先,我们定义一个dp数组,其中第i个元素代表的是以i为下标结尾的最长有效子字符串的长度。

研究状态:

确定最优策略的最后一步:

        字符串最后一个元素是s[i],需要判断s[i]的情况

        情况1:s[i]=='(',那么d[i] ==0,因此此时不会有和s[i]匹配的右括号,所以dp[i] ==0;

        情况2:s[i]==')',但是这样的话也是需要分情况考虑的:

                  s[i-1]=='(',这样的话,那么d[i] = d[i-2] + 2;因此此时s[i-1]和s[i]是配对的,然后我们还要加上前i-2个字符的dp情况;

                  s[i-1]==')',这样的话,那么我需要先判定以s[i-1]==')'结尾的最长有效子字符串的长度dp[i-1],此时对应的字符串中的序列就是s[i-dp[i-1]].....s[i-1],那么此时我们需要判定s[i-dp[i-1]-1]是否是'(',来和s[i]进行配对,以构成有效的括号对,同时还需要判断dp[i-[dp[i-1]-2]也就是以s[i-dp[i-1]-2]结尾的字符串的最长有效子字符串的长度,因此

                d[i] = dp[i-1] + 2 + dp[i-dp[i-1]-2];

子问题:

        从上述分析可知,我们要得到dp[i]就必须要知道dp[i-1]、dp[i-2]、dp[i-dp[i-1]-2],所以原问题就变成了求dp[i-1]、dp[i-2]、dp[i-dp[i-1]-2]的子问题。

那么此时的状态就是:

设 dp 数组,其中第 i 个元素表示以下标为 i 的字符结尾的最长有效子字符串的长度。

转移方程:

if s[i] == '(':
    dp[i] == 0
elif s[i] == ')':
    if s[i-1] == '(':
        dp[i] = dp[i-2] + 2 # 得保证i-2>=0
    elif s[i-1] == ')' and s[i-dp[i-1]-1] == '(':
        dp[i] = dp[i-1] + 2 + dp[i-dp[i-1]-2] # 得保证 i-dp[i-1]-2>=0

初始条件和边界条件:

        初始条件:dp[i] = 0;

        边界条件:在计算过程中:i-2>=0  以及  i -dp[i-1]-2>=0

计算顺序:

        无论第一个字符是什么第一个数dp[0] = 0;而后计算dp[i]/dp[1]/dp[2]/……dp[n-1],最后取max(dp[i]) 。

代码

python

class Solution(object):
    def longestValidParentheses(self, s):
        """
        :type s: str
        :rtype: int
        """
        m = len(s)
        dp = [0] * m

        maxVal = 0

        # 遍历字符串s
        for i in range(1, m):
            if s[i] == ')':
                # s[i-1] == '('的情况
                if s[i-1] == '(':
                    dp[i] = 2
                    # 边界条件
                    if i-2>=0:
                        dp[i] = dp[i-2] + 2
                # s[i-1] == ')'的情况
                # 先判定dp[i-1]>0, 因为这是后面的基础
                # 如果dp[i-1] == 0,那么下面的判断条件其实就是无效了,i-1>=0 and s[i-1]=='('
                # 因为这个时候s[i-1]==')',我们是要在0~i-2这个区间内,找到有没有和i-1/i位置对应的左括号
                elif dp[i-1]>0:
                    # dp[i-1]代表以i-1下标结尾的子字符串的最长有效括号的长度,这个之前的下标是i-dp[i-1]-1
                    # 我们要判断这个下标对应的符号是不是和s[i]对应的右括号对应,同时的得注意到i-dp[i-1]-2以这个下标
                    # 结尾的子字符串中是不是存在最长有效括号
                    if i-dp[i-1]-1 >=0 and s[i-dp[i-1]-1] == '(':
                        dp[i] = dp[i-1] + 2
                        if i-dp[i-1]-2 >=0:
                            dp[i] = dp[i-1] + 2 + dp[i-dp[i-1]-2]
            
            maxVal = max(maxVal,dp[i])
        return maxVal

C++

class Solution {
public:
    int longestValidParentheses(string s) {
        int size = s.length();
        vector<int> dp(size, 0);

        int maxVal = 0;
        for(int i = 1; i<size;i++)
        {
            if(s[i]==')')
            {
                if(s[i-1]=='(')
                {
                    dp[i] = 2;
                    if(i-2>=0)
                        dp[i] = dp[i-2] + dp[i];
                }
                else if(dp[i-1] >0)
                {
                    if((i-dp[i-1]-1)>=0 && s[i-dp[i-1]-1] == '(')
                    {
                        dp[i] = dp[i-1]+2;
                        if(i-dp[i-1]-2>=0)
                            dp[i] = dp[i-1] + dp[i-dp[i-1]-2] + 2;
                    }
                    
                }
            }
            maxVal = max(maxVal, dp[i]);
        }

        return maxVal;
    }
};

53.最大子数组和

解题思路

本题依旧采用动态规划的方法来解决:

取状态数组dp[i],其中第i个数值代表以nums[i]结尾的最大连续子数组和。其中dp[i]必须包括了nums[i]这个元素。

1.确定状态

        对于dp[i],它是由dp[i-1]以及nums[i]共同决定,需要作如下判断:
                如果dp[i-1]<=0,那对于dp[i]而言,dp[i-1]的贡献是负值,这只会使得dp[i]的值越来越小,所以此时dp[i]就取值nums[i];

                如果dp[i-1]>0,那么对于dp[i]而言,dp[i-1]对dp[i]是由贡献的,这样会使得当前的dp[i]值可能变得更大,使得连续子数组和可以由更大的值。

2.转移方程

dp[i-1] >=0, dp[i] = nums[i] + dp[i-1]

dp[i-1]< 0, dp[i] = nums[i]

3.初始条件

初始条件:dp[0] = nums[0],以nums[0]结尾的数组中的最大值其实就是自身;

4.计算顺序

要得到dp[i]就需要知道dp[i-1],所以从0开始一致计算到n-1,也即从dp[0]一致到dp[n-1],而后取max(dp[i])返回即可。

优化:

因为我们状态数组dp[i]只和dp[i-1]与nums[i]有关,所以我们可以直接在nums[i]上进行操作,这样可以节省额外的dp数组的空间开销,使得空间复杂度降为O(1)。

代码

python

class Solution(object):
    def maxSubArray(self, nums):
        """
        :type nums: List[int]
        :rtype: int
        """
        for i in range(1, len(nums)):
            nums[i] = max(nums[i-1], 0) + nums[i]
        return max(nums)

C++

class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        int ans = nums[0];
        for(int i=1;i<nums.size();i++)
        {
            // 在nums上直接修改
            // nums[i-1]>0,nums[i] = nums[i-1] + nums[i]
            // 反之nums[i]保持不变
            if(nums[i-1] >0)
                nums[i] = nums[i-1] + nums[i];
            if(ans < nums[i]) 
                ans = nums[i];
        }
        return ans;
    }
};

62.不同路径

解题思路

本题还是使用动态规划,其中题目中要求的是从起点到终点之间的路径由多少条,而从起点出发每次只能往右走或者往下走,那么我们取状态变量dp[i][j],代表从起点到(i.j)位置的路径的数目。

初始条件以及边界条件:

在第一行以及第一列,由于上面或者左边都是处于边界,所以dp[0][j] == dp[i][0] == 1;

确定状态:

dp[i][j]代表起点到(i,j)的路径数目,那么到达(i.j)位置的话,只能从(i-1,j)往下或者是(i,j-1)往右走,那么dp[i][j] = dp[i-1][j] + dp[i][j-1];

转移方程:

dp[i][j] = dp[i-1][j] + dp[i][j-1]

计算顺序:

由上述转移方程可知,要想计算dp[i][j],就得知道dp[i-1][j]和dp[i][j-1],所以计算顺序就是从dp[0][0]一行行遍历一直计算到dp[m-1][n-1]。

返回值:

返回dp[n-1]即可。

优化:

要想得到当前位置(i,j)的路径数目,需要得到其左边位置的路径数目和上边位置的路径数目,而我们是逐行遍历的,并且第一行和第一列的值都是初始化好了的,那么我们可以用一个一维数组进行优化:

dp[j] += dp[j-1]

在循环遍历中,dp开始是上一行的遍历结果,对于当前行,我们的循环都是从1开始,因为0位置已经在初始化的时候定义好了,那么在当前行,dp[1] = dp[0] + 上一行的dp[1],以此类推,其他也是一样的。

举例说明:

初始化时第一行都是1,从第二行开始遍历,dp[1]需要上方的dp[1]以及左边的dp[0],而此时我们的dp还是上一行的遍历结果[1 1 1],所以dp[1] = dp[0] + dp[1] = 2

对于第一行第二列一样,dp[2]需要上方的dp[2]以及左边的dp[1],dp[1]的值已经更新,而此时我们的dp[1 2 1],所以dp[2] = dp[1] + dp[2] = 3

下一行同理。

代码

python

class Solution(object):
    def uniquePaths(self, m, n):
        """
        :type m: int
        :type n: int
        :rtype: int
        """
        dp = [1] * n
        for i in range(1, m):
            for j in range(1, n):
                dp[j] += dp[j-1]
        
        return dp[-1]

C++

class Solution {
public:
    int uniquePaths(int m, int n) {
        vector<int> dp(n, 1);
        for(int i = 1;i<m;i++)
        {
            for(int j=1;j<n;j++)
            {
                // dp[j-1]保存的是d[j]左边的dp结果
                // dp[j]是由上边的结果加上左边的结果
                // 开始d[j]保存的是上一次循环的结果,也就是上一行的结果
                dp[j] += dp[j-1];
            }
        }
        return dp[n-1];

    }
};

64.最小路径和

解题思路

本题依旧采用动态规划来做:

首先题中要求的是求的从左上角到右下角的最小路径和,我们取dp[i][j]代表从左上角到(i,j)的最小路径和,其中dp[i][j]是由左边以及上边二者的最小值加上当前位置的权值。

初始化以及边界条件:

当i==j==0的时候,这时候在起点,dp[i][j] == grod[i][j];

当处在第0行时,这个时候dp[i][j]只能由左边来提供路径和,也即dp[i][j] = dp[i][j-1] + grid[i][j];

当处在第0列的时候,dp[i][j]只能由上边来提供路径和,也即dp[i][j] = dp[i-1][j] + grid[i][j];

当不是上述情况的时候:

        dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]

确定状态:

如上所述

计算顺序:

还是从dp[0][0]依次从左到右从上到下逐行逐列遍历到dp[m-1][n-1].

返回值:

dp[m-1][n-1]

优化:

其实我们可以直接在原grid数组上直接进行操作,这样就可以节省额外的空间开销。因为我们是逐个遍历,那么grid数组的值只会在计算当前的值的时候用到,之后用不上:

grid[i][j] = min(grid[i-1][j], grid[i][j-1]) + grid[i][j]

代码

python

class Solution(object):
    def minPathSum(self, grid):
        """
        :type grid: List[List[int]]
        :rtype: int
        """
        for i in range(len(grid)):
            for j in range(len(grid[0])):
                # 起点
                if i==j==0:
                    continue
                # 上边界,只能从左边来
                elif i==0:
                    grid[i][j] += grid[i][j-1]
                # 左边界,只能从上面来
                elif j==0:
                    grid[i][j] += grid[i-1][j]
                else:
                    grid[i][j] += min(grid[i-1][j], grid[i][j-1]) 
        return grid[-1][-1]

C++

class Solution {
public:
    int minPathSum(vector<vector<int>>& grid) {
        int m = grid.size();
        int n = grid[0].size();
        for(int i=0;i<m;i++)
        {
            for(int j=0;j<n;j++)
            {
                // 起点值保持不变
                if(i==0 && j==0)
                    continue;
                // 第0行,只能由左边的数值已经路径和
                else if(i==0)
                    grid[i][j] += grid[i][j-1];
                // 第0列,只能由上边的数值已经路径和
                else if(j==0)
                    grid[i][j] += grid[i-1][j];
                // 处于grid数组中的其他值,路径和是由从左边走的以及从上面走的二者的最小值加上当前值
                else
                    grid[i][j] += min(grid[i-1][j], grid[i][j-1]);
            }
        }
        return grid[m-1][n-1];
    }
};

70.爬楼梯

解题思路

这道题其实思路很简单,我最后一级楼梯可以是从倒数第一级爬一个台阶,也可以是倒数第二级爬2个台阶,那么可以得到状态转移方程:

dp[i] = dp[i-1] + dp[i-2]

初始以及边界条件

        当n==1的时候,直接返回1,因为1级台阶只有1种方法

确定状态

        dp[i]表示爬到第i级台阶共有多少种方法

计算顺序

        从第一级台阶开始逐个计算

返回dp[-1]或者dp[n]即可。

优化

        由于dp[i] = dp[i-1] + dp[i-2]

        当前状态值之和上一个以及上上一个状态值有关,所以我们只需要处理这三个值即可。空间复杂度由O(N)降为O(1)。

代码

python

class Solution(object):
    def climbStairs(self, n):
        """
        :type n: int
        :rtype: int
        """
        if n==1:
            return 1
        dp = [1] * n
        dp[1] = 2
        for i in range(2,n):
            dp[i] = dp[i-1] + dp[i-2]
        return dp[-1]

优化后的代码

class Solution(object):
    def climbStairs(self, n):
        """
        :type n: int
        :rtype: int
        """
        """
        if n==1:
            return 1
        dp = [1] * n
        dp[1] = 2
        for i in range(2,n):
            dp[i] = dp[i-1] + dp[i-2]
        return dp[-1]
        """
        if n==1:
            return 1
        a = 1
        b = 2
        for i in range(3, n+1):
            c = a+b
            a = b
            b = c
        return b

C++

空间复杂度为O(N)

class Solution {
public:
    int climbStairs(int n) {
        // 处理边界条件
        if(n==1)
            return 1;
        vector<int> fn(n);
        fn[0] = 1;
        fn[1] = 2;
        for(int i=2; i<n;i++)
        {
            fn[i] = fn[i-1] + fn[i-2];
        }
        return fn.back();
    }
};

优化后的代码

class Solution {
public:
    int climbStairs(int n) {
        // 处理边界条件
        /*
        if(n==1)
            return 1;
        vector<int> fn(n);
        fn[0] = 1;
        fn[1] = 2;
        for(int i=2; i<n;i++)
        {
            fn[i] = fn[i-1] + fn[i-2];
        }
        return fn.back();
        */
        if(n==1)
            return 1;
        // 因为dp[i] = dp[i-1] + dp[i-2]
        // 所以只要知道i-1,i-2的值,就知道i的值,所以只需要从0遍历到i-2,共i-1个数即可
        int a = 1;
        int b = 2;
        int sum = 0;
        for(int i=3;i<=n;i++)
        {
            sum = a + b;
            a = b;
            b = sum;
        }
        return b;
    }
};

72.编辑距离

解题思路

本题解题思路依旧是采用动态规划,其中本题要求给定两个单词如何经过最少的操作可以让2个单词相同。

取word1为s,word2为t,在编辑过程中,我们希望问题的规模越来越小,这样才能构建子问题,设s和t的长度分别为m,n,从最后一个元素开始做对比s[m-1]与t[n-1],如果s[m-1]!=t[n-1],则需要经过编辑(插入、删除、替换),使得二者相等;如果s[m-1]==t[n-1],则跳过当前元素,两个字符串都往前移动一位,重复此操作。

因此状态为当前在s中考虑第i个字符,t中的第j个字符:

状态[i,j]定义为s的前i个字符转变为t的前j个字符之间最少的操作数;

dp[i,j]对应的是s[i-1]和t[j-1],此时对于s[i-1]和t[j-1]之间的匹配存在以下情况:

在s[i-1]之后添加t[j-1],之后处理的问题是dp[i, j-1];

删除s[i-1],之后处理的问题是dp[i-1,j];

替换s[i-1],使之和t[j-1]相同,之后处理的问题是dp[i-1, j-1]。

由上述分析可知,dp[i,j]的状态值是由dp[i, j-1]、dp[i-1, j]、dp[i-1][j-1]共同决定的:

dp[i, j] = min(dp[i, j-1], dp[i-1, j], dp[i-1, j-1]) + 1

上述分析针对的是s[i-1]与t[j-1]不相同的情况,如果s[i-1]==t[j-1]的话,那么则不考虑这两个字符,直接都往前走一个字符,也即:

dp[i, j] = dp[i-1, j-1]

确定边界条件和初始条件:

        当字符串都为空的时候,dp[0,0] = 0;

        当s为空,t不为空的时候,dp[0, j] = j,也就是说这时候最少的编辑数目等于t中字符的长度;

        当s不为空,t为空的时候,dp[i, 0] = i,也就是说这个时候最少的编辑数目等于s中字符的长度。

由状态转移公式可以知道,dp[i, j]的值依赖左、上、左上这三个值。

优化:

        上述复杂度是O(m*n),但是其实因为dp[i, j]的值依赖左、上、左上这三个值,因此我们可以用一个一维数组来替代上述二维数组,以此减少空间开销。

而对于左上的元素,需要保存起来,从而只考虑左和上的这2元素。

代码

python

class Solution(object):
    def minDistance(self, word1, word2):
        """
        :type word1: str
        :type word2: str
        :rtype: int
        """
        m = len(word1)
        n = len(word2)
        dp = [[0]*(n+1) for _ in range(m+1)]

        # 初始条件
        # word1不为空,word2为空的情况
        for i in range(m+1):
            dp[i][0] = i
        # word1为空,word2不为空的情况        
        for j in range(n+1):
            dp[0][j] = j
        
        for i in range(1, m+1):
            for j in range(1, n+1):
                if word1[i-1] == word2[j-1]:
                    dp[i][j] = dp[i-1][j-1]
                else:
                    dp[i][j] = min(min(dp[i-1][j], dp[i][j-1]), dp[i-1][j-1]) + 1
        
        return dp[m][n]
优化
class Solution(object):
    def minDistance(self, word1, word2):
        """
        :type word1: str
        :type word2: str
        :rtype: int
        """
        """
        m = len(word1)
        n = len(word2)
        dp = [[0]*(n+1) for _ in range(m+1)]

        # 初始条件
        # word1不为空,word2为空的情况
        for i in range(m+1):
            dp[i][0] = i
        # word1为空,word2不为空的情况        
        for j in range(n+1):
            dp[0][j] = j
        
        for i in range(1, m+1):
            for j in range(1, n+1):
                if word1[i-1] == word2[j-1]:
                    dp[i][j] = dp[i-1][j-1]
                else:
                    dp[i][j] = min(min(dp[i-1][j], dp[i][j-1]), dp[i-1][j-1]) + 1
        
        return dp[m][n]
        """
        m = len(word1)
        n = len(word2)
        dp = [0]*(n+1)

        # 初始条件
        # word1为空,word2不为空的情况        
        for j in range(n+1):
            dp[j] = j

        # 其余行
        for i in range(1, m+1):
            # 取出上一行的dp数组的第一个数用leftup保存
            leftup = dp[0]
            # 当前行的第一个元素是i(dp[i] = i),需要对应好
            dp[0] = i
            for j in range(1, n+1):
                # 保存上一轮的dp[j],也就是当前行当前值的上一行的对应值
                temp = dp[j]
                if word1[i-1] == word2[j-1]:
                    dp[j] = leftup
                else:
                    dp[j] = min(min(dp[j-1], dp[j]), leftup) + 1
                leftup = temp
        
        return dp[n]

C++

class Solution {
public:
    int minDistance(string word1, string word2) {
        int m = word1.size();
        int n = word2.size();
        vector<vector<int>> dp(m + 1, vector<int>(n+1, 0));

        // 边界条件
        // 1.word1不为空,而word2为空的情况
        for(int i = 1;i<=m;i++)
            dp[i][0] = i;
        // 2.word1为空,而word2不为空的情况 
        for(int j =1;j<=n;j++)
            dp[0][j] = j;
        
        for(int i =1;i<=m;i++)
        {
            for(int j =1;j<=n;j++)
            {
                // 两字符相同,则word1和word2跳过当前字符
                if(word1[i-1]==word2[j-1])
                    dp[i][j] = dp[i-1][j-1];
                else
                    dp[i][j] = min(min(dp[i][j-1], dp[i-1][j]), dp[i-1][j-1]) + 1;
            }
        }

        return dp[m][n];
    }
};
优化后的代码
class Solution {
public:
    int minDistance(string word1, string word2) {
        /*
        int m = word1.size();
        int n = word2.size();
        vector<vector<int>> dp(m + 1, vector<int>(n+1, 0));

        // 边界条件
        // 1.word1不为空,而word2为空的情况
        for(int i = 1;i<=m;i++)
            dp[i][0] = i;
        // 2.word1为空,而word2不为空的情况 
        for(int j =1;j<=n;j++)
            dp[0][j] = j;
        
        for(int i =1;i<=m;i++)
        {
            for(int j =1;j<=n;j++)
            {
                // 两字符相同,则word1和word2跳过当前字符
                if(word1[i-1]==word2[j-1])
                    dp[i][j] = dp[i-1][j-1];
                else
                    dp[i][j] = min(min(dp[i][j-1], dp[i-1][j]), dp[i-1][j-1]) + 1;
            }
        }

        return dp[m][n];
        */
        int m = word1.size();
        int n = word2.size();
        vector<int> dp(n + 1, 0);

        // 边界条件
        // 首行
        for(int i = 1;i<=n;i++)
            dp[i] = i;
        
        // 其余行
        for(int i =1;i<=m;i++)
        {
            // 取leftup等于上一行的0位置的值,也即左上
            int lefup = dp[0];
            dp[0] = i;
            for(int j =1;j<=n;j++)
            {
                // 先将上一轮的结果保存,之后再改变当前值
                int temp = dp[j];
                // 两字符相同,则word1和word2跳过当前字符
                if(word1[i-1]==word2[j-1])
                    dp[j] = lefup;
                else
                    dp[j] = min(min(dp[j], dp[j-1]), lefup) + 1;
                lefup = temp;
            }
        }

        return dp[n];

    }
};

参考

14.6 编辑距离问题

121.买卖股票的最佳时机

解题思路

本题的话,其实思路很简单,因为题目中要求我们得到最大利润,而最大利润又是需要我们买入的时候,成本要是最低的,然后卖出的时候价格应该是最高的,这样得到的利润才会是最大的。

因此我们取cost作为成本,profit作为当天卖出利润。

初始化cost为极大值,profit为0。

遍历整个prices数组,通过不断比较当天的price和cost的大小不断更新cost的值(取二者较小值),通过比较当天卖出price-cost和profit的值(取二者最大值),来得到可以获得的最大值。

总的来说,就是对于第i天而言,如果第i天卖出股票,那么第i天获得的利润的最大值等于第i天的股票价格-前i天中股票的最低价,然后与已知的最大利润之间作比较,大于已知的最大利润则更新该利润profit的值。

代码

python

class Solution(object):
    def maxProfit(self, prices):
        """
        :type prices: List[int]
        :rtype: int
        """
        cost, profit = float('inf'), 0
        for price in prices:
            # 成本是前i天的价格的最小值,不断更新
            cost = min(price, cost)
            # 利润是前i天的最高利润,用上面得到的最小买入成本,用当天的价格减去该最小成本,比较得到的利润值
            profit = max(profit, price - cost)
        return profit

C++

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int cost = INT_MAX;
        int profit = 0;
        for(int price : prices)
        {
            cost = min(price, cost);
            profit = max(profit, price - cost);
        }
        return profit;
    }
};

139.单词拆分

解题思路

本题依旧使用动态规划,题目中要求我们能够判断s中的单词是由wordDict中的几个单词拼接而成,就返回True。

那么本题定义状态数组dp[i],其中i表示s的前i个字符能否被wordDict中的单词表示;

初始化:

dp = [False] * (n+1);

其中dp[0] = True,表示空字符也可以被表示

执行流程:

        ①遍历s,for i in range(n),也就是遍历s的所有子串

        ②边界结束索引j,取值区间是[i+1, n+1)

        ③判断条件,如果dp[i]是True,并且s[i:j)在wordDict中的话,则把dp[j]置为True。这里需要注意一点,dp的大小是n+1,因为dp中包括了0,0代表空字符,所以dp中1~n才是和字符s中一一对应的,这里也就是说明了为什么是dp[j] = True,此时的j在s中因为左闭右开,j的取值在子串中是取不到的,对应的是s中下一个子串的起始,但是在dp中j的位置恰好对应的s[i:j)子串的末尾,也就是能够用wordDict中某一可以表示的单词结尾。

        举个例子,

        输入: s = "leetcode", wordDict = ["leet", "code"]

        dp和s的对应关系:

s[0] s[1] s[2] s[3] s[4] s[5] s[6] s[7]
"" l e e t c o d e
dp[0] dp[1] dp[2] dp[3] dp[4] dp[5] dp[6] dp[7] dp[8]
dp True False False False True False False False True

                dp[0] = True

                s[0:4] in wordDict,此时的j==4,s中是左闭右开,dp中这个4正好对应一个单词的结尾,将结尾设置为True,等待下一次遍历的时候,i从j位置开始,查询s[i:j]是否和wordDict中某一单词对应。

        ④返回值:dp[-1]。

代码

python

class Solution(object):
    def wordBreak(self, s, wordDict):
        """
        :type s: str
        :type wordDict: List[str]
        :rtype: bool
        """
        n = len(s)
        dp = [False] * (n+1)
        # 空字符可以被表示
        dp[0] = True

        for i in range(n):
            for j in range(i+1, n+1):
                # 注意dp中0代表空字符,1代表的s的第0个字符
                if (dp[i] and s[i:j] in wordDict):
                    dp[j] = True
        
        return dp[-1]

C++

substr(pos, count)返回从pos开始长度为count的子串,索引是左闭的,即包含pos位置的字符,连续取count个字符。因为是从pos位置开始取,包括了pos,所以等同于python的左闭右开。

find
class Solution {
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        int n = s.size();
        unordered_set<string> dict(wordDict.begin(), wordDict.end());
        vector<bool> dp(n+1, false);
        dp[0] = true;
        for(int i=0;i<n;i++)
        {
            for(int j = i+1;j<=n;j++)
            {
                string sub = s.substr(i,j-i);
                if(dp[i] && dict.find(sub)!=dict.end())
                    dp[j] = true;
            }
        }
        return dp[n];
    }
};
count
class Solution {
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        int n = s.size();
        unordered_set<string> dict(wordDict.begin(), wordDict.end());
        vector<bool> dp(n+1, false);
        dp[0] = true;
        for(int i=0;i<n;i++)
        {
            for(int j = i+1;j<=n;j++)
            {
                string sub = s.substr(i,j-i);
                if(dp[i] && dict.count(sub))
                    dp[j] = true;
            }
        }
        return dp[n];
    }
};

152.乘积最大子数组

解题思路

本题是53.最大子数组和的变式。

本题中要求一个乘积最大的非空连续子数组,要求非空和连续,并且乘积最大,那么对于nums[i]而言,到下标i的时候,乘积最大有如下情况:

1.当前nums[i]  == x为正值,那么对于以nums[i]为右端点的子数组的最大乘积有如下情形:

        ①nums[i]既是最大;

        ②需要在[0:i-1]这个区间内找到以nums[i-1]为右端点的最大的乘积,之后再乘以nums[i]也就是x;

2.当前nums[i]  == x为负值,那么对于以nums[i]为右端点的子数组的最大乘积有如下情形:

        ①nums[i]既是最大;

        ②因为是负值,根据负负得正,需要在[0:i-1]这个区间内找到以nums[i-1]为右端点的最小的乘积,之后再乘以nums[i]也就是x;

那么根据上述情况,我们可以知道,再nums的遍历中,我们需要维护下面2个值:

        ①以nums[i]为右端点的子数组的最大乘积,记作f_{max}{[i]}

        ②以nums[i]为右端点的子数组的最小乘积,记作f_{min}{[i]}

对于x=nums[i]:

        要么是x自己是最大乘积;

        要么就是,x和前面的子数组的最大乘积的结合才是最大乘积,而这个时候前面子数组的最大乘积又有2种,要么是当x>0的时候,f_{max}{[i]} = f_{max}{[i-1]}*x ;要么是当x<0的时候,f_{max}{[i]} = f_{min}{[i-1]}*x

        那么在遍历中我们把这些都考虑进去,就不需要单独对每一个nums[i]进行判断了。

f_{max}{[i]} = max(f_{max}{[i-1]} * x, f_{min}{[i-1]} * x, x)

f_{min}{[i]} = min(f_{max}{[i-1]} * x, f_{min}{[i-1]} * x, x)

与此同时,我们注意到:
f_{max}{[i]} = f_{max}{[i-1]}*x

f_{max}{[i]} = f_{min}{[i-1]}*x

这些起始每个只和当前值与前一个状态值有关,因此我们可以优化空间。只用f_{max}以及f_{min}来滚动计算即可,那么状态转移方程可以改写为:

f_{max} = max(f_{max} * x, f_{min}* x, x)

f_{min} = min(f_{max}* x, f_{min} * x, x)

那么对于第一个数而言,以nums[0]为右端点的子数组,最大值和最小值都是自己,所以这个时候我们取f_{max} = 1以及f_{min} = 1。这样的话,代码内部就可以从第0个开始进行遍历。

这里需要注意的是,上面2个式子需要同时计算,不然在计算f_{min}的时候,f_{max}已经是更新之后的值,而不是上一级的值了。

代码

python

class Solution(object):
    def maxProduct(self, nums):
        """
        :type nums: List[int]
        :rtype: int
        """
        ans = -float('inf')
        fmax = 1
        fmin = 1
        for x in nums:
            fmax, fmin = max(fmax * x, fmin * x, x), \
                         min(fmax * x, fmin * x, x)
            ans = max(ans, fmax)
        
        return ans

C++

class Solution {
public:
    int maxProduct(vector<int>& nums) {
        int ans = INT_MIN;
        int fmax = 1;
        int fmin = 1;
        for(int x: nums)
        {
            // 用一个中间变量保存fmax,在后面计算fmin的时候,
            // 应该用上一级的fmax,不然用到的是更新之后fmax,结果不对
            int temp = fmax;
            fmax = max(max(fmax * x, fmin * x), x);
            fmin = min(min(temp * x, fmin * x), x);
            ans = max(ans, fmax);
        }

        return ans;
    }
};

221.最大正方形

本题在之前的专题中已经做过,此处不再重复。

279.完全平方数

解题思路

在本题中,要求我们返回和为n的完全平方数的最少数量。

我们把问题分解为一个完全平方数+剩余部分,要使得整体数最少,那么剩余部分的数要最小。这就是原问题与子问题之间的关系。

这里我们用状态变量dp[i]来表示,和为i最少需要多少个完全平方数。

初始化dp,各个位置都为0;

遍历数字n,对于当前位置i,每次都将当前位置赋给dp[i],表示当前最坏的结果就是一直都是1相加,由i个i相加之后得到。

在i中,一定存在一个完全平方数j*j被用到了,取值i-j*j,那么剩下的数值就是i-j*j,那么我们就要判断如何使得i-j*j中完全平方数最少,最优,也就是dp[i-j*j],那么所有的完全平方数就是dp[i-j*j] + 1(其中1代表的是j*j这个平方数,dp[i-j*j]中没有包括整个,只有j*j + i -j *j 才等于此时的i)。

所以需要遍历j,判定条件是i-j*j>=0。

而后比较dp[i],和dp[i-j*j] + 1之间哪个更小,说明对应的完全平方数更少。

最后返回dp[n]即可。

代码

python

class Solution(object):
    def numSquares(self, n):
        """
        :type n: int
        :rtype: int
        """
        dp = [0] * (n+1)
        for i in range(1, n+1):
            dp[i] = i
            j = 1
            while i - j*j >= 0:
                dp[i] = min(dp[i], dp[i-j*j] + 1)
                j += 1
        
        return dp[-1]

C++

class Solution {
public:
    int numSquares(int n) {
        // dp[i]表示值为i最少需要多少个完全平方数
        vector<int> dp(n+1, 0);
        for(int i=1;i<=n;i++)
        {
            // 代表最差情况,i个1相加得到结果
            dp[i] = i;
            for(int j = 1;i-j*j>=0;j++)
            {
                // 取较小值
                // 在i中一定存在某个数的完全平方,这里取作j*j
                // 那么要使得最少的完全平方数的话,就是i-j*j中要得到的完全平方数要最少
                // 因为这里dp[i-j*j]是没有把j这个数的完全平方放进来的,所以需要+1
                dp[i] = min(dp[i], dp[i-j*j]+1);
            }
        }
        return dp[n];
    }
};

300.最长递增子序列

解题思路

本题中所要求的是,在一个给定的整数数组中,找到其中最长的严格单调递增的子序列的长度,但是:

        ①这个严格单调递增子序列不一定是连续的;

        ②子序列的最后一个元素未必一定是数组的末尾元素。

那么状态数组的定义:

定义dp[i]是以nums[i]结尾的最长严格单调递增的子序列的长度;

转移方程:

遍历nums,当遍历到nums[i]的时候,这个时候在遍历j,其中j∈[0,i),观察nums[i]和nums[i]之间的大小:

如果nums[i]>nums[j],说明nums[i]是可以放到nums[j]之后,并且也不改变原有数组元素的顺序,所以dp[j]+1,因为nums[i]可以接到以nums[j]结尾的最长子序列的后面(dp[j]),组成新的最长子序列,长度自然就是dp[j]+1。

当nums[i]<=nums[j],此时不满足严格单调递增的情形,所以直接跳过就行;

为什么是 dp[j] + 1 而不是 dp[i] + 1

因为 dp[j] 是已经计算好的、以 nums[j] 结尾的最长递增子序列长度。
当 nums[i] > nums[j] 时,我们可以把 nums[i] 接到那个子序列的末尾,形成一个新的、更长的子序列。这个新子序列的长度就是 dp[j](原来的长度)加上 1(这个新元素 nums[i]),所以是 dp[j] + 1

而 dp[i] 本身正是我们正在计算的目标(以 nums[i] 结尾的最长长度),它还没有被确定,因此不能用来作为加数。实际上,我们要从所有可能的 j 中找到最大的 dp[j] + 1,然后把这个最大值赋给 dp[i]

简单来说:j 是过去的结尾,i 是现在的结尾。把现在的元素接到过去的后面,长度 = 过去长度 + 1。

因为j∈[0,i),对于在这个区间内的j,每遍历一次,我们需要更新dp[i]的值,使得最终dp[i]的值为最大:

dp[i] = max(dp[i], dp[j]+1)

初始条件:因为nums中的每一个元素都可以单独组成最长递增子序列,所以给dp中的所有值都赋值为1。

返回值:返回dp数组中的最大值,不是返回dp[n]即可,因为未必是以最后一个元素结尾的序列是最长严格递增的子序列。

上述代码的时间复杂度是O(N^2);题目要求降到o(nlogN);看到对数时间复杂度就想到二分法。

优化

使用贪心+二分,因为题目中的进阶要求是需要时间复杂度是O(nlogn),由此我们想到用二分法:

上面的算法中,我们每一次循环中nums[i]都要和之前nums[j]做比较,因此我们想到的是,能不能避免每次都要回头比较呢?而是维护一个结构,只需要比较一次就可以知道新数应该放在哪个位置:

  • 贪心+二分:tails 直接维护每个长度的最小末尾,新数只需二分定位,不需要回头比较所有前面的数。

tails[i] 表示长度为 i+1 的递增子序列的最小末尾值。

例如,对于nums,遍历nums,对于nums[i],在tail中查询是否由比nums[i]大的数,如果有,则在找到的第一个答案中,将该值添加到对应位置的末尾,一次说明,这个长度的递增子序列的最小末尾值应该是nums[i];

反之,如果没有找到任何大于nums[i]的数,那么则在tails中最后新开辟一个位置给nums[i];

最终tails的长度就是题目中所要求的最长的严格递增的子序列长度。因为tails中的元素其实是单调递增的,因为新加入的数nums[i]要和tails中已有的数做比较,如果大于tails中已有的数,则在tails的末尾添加nums[i],如果小于tails中已有的数,则找到第一个小于tails中的数,将其覆盖为nums[i],说明长度为i+1的递增子序列的最小末尾值应该是nums[i]。

例如:nums = [10, 9, 2, 5, 3, 7, 101, 18] 

tails初始化为[];

x=10,tails中为空,新建一堆,顶部为10,直接tails = [10];

x=9,其中10>9,9将10覆盖,于是,tails = [9](意义就是长度为1的子序列中,最小末尾值可以是9,而不是10)

x=2,其中9>2,2将9覆盖,于是tails = [2](意义就是长度为1的子序列中,最小末尾值可以是2,而不是9)

x=5,tails中没有大于5的数,因此新开一个堆,于是tails = [2, 5]

x=3,5>3,于是3将5覆盖,tails = [2, 3](意义就是长度为2的子序列中,最小末尾值可以是3,而不是5)

x=7,tails中没有大于7的数,因此新开一个堆。tails = [2, 3, 7]

x=101,tails中没有大于101的数,因此新开一个堆,tails = [2, 3, 7 101]

x=18,tails中101>18,所以18将101覆盖,tails = [2, 3, 7, 18]

最终返回tails的长度,tails.size()即为最长单调递增子序列的长度。

代码

python

class Solution(object):
    def lengthOfLIS(self, nums):
        """
        :type nums: List[int]
        :rtype: int
        """
        dp = [1] * len(nums)
        for i in range(len(nums)):
            for j in range(i):
                if nums[i] > nums[j]:
                    dp[i] = max(dp[i], dp[j] + 1)
        
        return max(dp)

C++

class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        int n = nums.size();
        vector<int> dp(n, 1);
        int maxvalue = 0;
        for(int i=0;i<n;i++)
        {
            for(int j =0;j<i;j++)
            {
                if(nums[i]>nums[j])
                {
                    dp[i] = max(dp[i], dp[j]+1);
                }
                    
            }
            maxvalue = max(maxvalue, dp[i]);
        }
        return maxvalue;
    }
};
class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        int n = nums.size();
        vector<int> dp(n, 1);
        int maxvalue = 1;
        for(int i=0;i<n;i++)
        {
            for(int j =0;j<i;j++)
            {
                if(nums[i]>nums[j])
                {
                    dp[i] = max(dp[i], dp[j]+1);
                    maxvalue = max(maxvalue, dp[i]);
                }
                    
            }
        }
        return maxvalue;
    }
};

优化

python(bisect_left实现二分,返回查找到的大于等于x的下标,如果没有找到大于x的数,则返回序列长度,即len(tails))
class Solution(object):
    def lengthOfLIS(self, nums):
        """
        :type nums: List[int]
        :rtype: int
        """
        '''
        dp = [1] * len(nums)
        for i in range(len(nums)):
            for j in range(i):
                if nums[i] > nums[j]:
                    dp[i] = max(dp[i], dp[j] + 1)
        
        return max(dp)
        '''
        # tails维护的是长度为i+1的子序列的最小末尾数
        tails = []
        for x in nums:
            i = bisect_left(tails, x)
            # 说明在tails中没有找到大于等于x的数
            if i == len(tails):
                tails.append(x)
            else:
                tails[i] = x
        return len(tails)

C++(通过lower_bound实现二分,返回迭代器,如果没有大于x的数,则返回end()迭代器)
 vector<int> tails;
        for(int x:nums)
        {
            // 找到tails中小于x的第一个数
            auto it = lower_bound(tails.begin(), tails.end(), x);
            if(it == tails.end())
            {
                // 没找到比x小的数,另开一个堆
                tails.push_back(x);
            }
            // 反之,则将找到的哪个堆顶元素置为x
            else
                *it = x;
            
        }
        // 最后返回tails的长度就是题中所需要的最长严格单调递增的子序列的长度了
        return tails.size();

198.打家劫舍

解题思路

题目中要求如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警,那么就是说相邻的房间不能一起偷。

我们取状态变量:dp[i],表示在满足条件下偷前i个房屋能够获得的最大金额。

对于前i个房间:

0 1 2 3 …… i-4 i-3 i-2 i-1

有如下2种情况:

        第i-1间房屋,我们偷的话,那么第i-2间房就不能偷,那么此时可以获得的最大金额就是前i-2间房能得到的最大金额加上第i-1间房的金额;

        不偷第i-1间房的话,那么此时可以获得的最大金额就是前i-1间房可以获得的最大金额。

        最终可以得到的最大金额就是上面2种情况中的较大值,具体转移方程如下:

dp[i] = max(dp[i-1], nums[i-1] + dp[i-2])

初始条件:

        前0个房间能够偷到的最大金额是0;

        前1个房间能够偷到的最大金额是第一个房间的金额;

计算顺序:

        要得到dp[i]就要得到dp[i-1]、dp[i-2],所以是自底而上的。

优化,因为dp[i]只和dp[i-1]以及dp[i-2]有关,所以我们可以只维护这2个值。

代码

python

class Solution(object):
    def rob(self, nums):
        """
        :type nums: List[int]
        :rtype: int
        """
        # 原始版本
        n = len(nums)
        dp = [0] * (n+1)
        dp[1] = nums[0]
        for i in range(2, n+1):
            dp[i] = max(dp[i-1], dp[i-2]+nums[i-1])

        return dp[n]

C++

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

        return dp[n];
    }
};

优化

python

class Solution(object):
    def rob(self, nums):
        """
        :type nums: List[int]
        :rtype: int
        """
        """
        # 原始版本
        n = len(nums)
        dp = [0] * (n+1)
        dp[1] = nums[0]
        for i in range(2, n+1):
            dp[i] = max(dp[i-1], dp[i-2]+nums[i-1])

        return dp[n]
        """
        n = len(nums)
        cur, pre = 0, 0
        for x in nums:
            cur, pre = max(cur, x + pre), cur
        return cur

C++

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

        return dp[n];
        */
        int n = nums.size();
        int cur = 0;
        int pre = 0;
        int temp = 0;
        for(int num:nums)
        {
            temp = cur;
            cur = max(cur, pre+num);
            pre = temp;
        }

        return cur;
    }
};

309.买卖股票的最佳时机含冷冻期

解题思路

题目中的要求就是在卖出股票之后,第二天不能立即买入。要有一个至少要间隔一天的时间才能继续买入股票,也就是所谓的冷冻期。

那么本题中第i天结束的时候你有如下状态:

状态0:持有股票(host)——今天结束的时候手里还有股票(可能昨天就有这股票,今天没有卖出;也可能是你今天刚买入);

状态1:刚卖出(sold)——今天卖出股票;

状态2:观望/冷冻期(rest)——今天结束的时候手里没有股票(可能昨天刚卖出,可能一直没有股票)

三种状态之间的关系:

状态0:

        昨天是已经持有,今天选择继续持有;

        昨天是观望/冷冻期(状态2),今天选择买入,那么此时的利润要减去今天股票的价格

状态1:

        昨天是持有股票(状态0),今天选择卖出,利润要加上今天的股票价格

状态2:

        昨天是观望/冷冻期(状态2),今天选择继续观望;

        或者是昨天卖出股票(状态1),今天进入冷冻期。

用hold[i]sold[i]rest[i] 三个变量表示在第 i 天结束时进入该状态时,我们能获得的最大利润。

由上述分析可知:

hold[i] = max(hold[i-1], rest[i-1] - price[i])

sold[i] = hold[i-1] + price[i]

rest[i] = max(rest[i-1], sold[i-1])

边界条件:

hold[0] = -price[0]

sold[0] = 0

rest[0] = 0

最后题目要求的最大利润应该是在sold[i]和rest[i]里面了。

代码

python

class Solution(object):
    def maxProfit(self, prices):
        """
        :type prices: List[int]
        :rtype: int
        """
        n = len(prices)
        if n==0:
            return 0
        # 定义状态数组
        hold = [0] * n
        sold = [0] * n
        rest = [0] * n
        hold[0] = -prices[0]
        for i in range(1, n):
            # 状态转移方程
            hold[i] = max(hold[i-1], rest[i-1]-prices[i])
            sold[i] = hold[i-1] + prices[i]
            rest[i] = max(rest[i-1], sold[i-1])
        
        # 最终的最大利润在rest和sold之间
        return max(rest[n-1], sold[n-1])

C++

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int n = prices.size();
        if(n==0)
            return 0;
        // 三种状态的初始化
        vector<int> hold(n, 0);
        vector<int> sold(n, 0);
        vector<int> rest(n, 0);
        hold[0] = -prices[0];
        for(int i=1;i<n;i++)
        {
            // 状态转移方程
            hold[i] = max(hold[i-1], rest[i-1] - prices[i]);
            sold[i] = hold[i-1] + prices[i];
            rest[i] = max(rest[i-1], sold[i-1]);
        }
        // 最终的利润最大值在rest和sold之间
        return max(sold[n-1], rest[n-1]);
    }
};

312.戳气球

解题思路

本题如果是从正向思考的话,一个一个去戳破气球,那么因为每戳破一个气球,余下气球之间的相邻关系就改变了,比如戳破第i个气球,那么原本不相邻的i-1和i+1的气球就是相邻的了,那么这些气球之间就会存在依赖,不利于构建子问题。

因此我们反向思考,从最后一个气球反过来看:

我们先延申整个数组,给它加上左右边界,值都是1,

定义状态数组:状态数组dp[i][j],表示在开区间(i,j)中可以获得的最大硬币数目。当j=i+1时,dp[i][j] = 0;那么对于最后一个气球k,它可以获得的硬币数目是vals[k]*vals[i]*vals[j],因为此时的k处在(i,j)区间内,区间内只有k这一个气球,而区间(1,k)以及(k,j)中的气球都已经被戳破了,所以需要关注在区间(i,k)和(k,j)中可以获得的最大硬币数目,也就是dp[i][k]和dp[k][j],由此原问题和子问题就由此构建出来了,那么可以获得的最大硬币数目就是:

dp[i][j] = \max_{i<k<j} (dp[i][k]+dp[k][j]+vals[i]*vals[j]*vals[k])

那么最终题目要求的最大硬币数目其实就是dp[0][n+1]。

初始条件:

        dp[0][0] = 0;

        dp[0][1] = 0;(区间内没有气球,所以置零)

        原数组需要加上左右边界,值都是1,也即vals[0] =1, vals[n+1] = 1,中间[1,n]就是原数组的值。

计算顺序

        这里需要从小到大计算区间长度len(也就是j-i),由于大区间(i,j),依赖于更小的区间(i,k)和(k.j),所以(i,j)之间必须可以放气球的话,那么区间长度len就得从2开始(i=0,j=2,k=1),一直到len=n+1,得到最终结果,这也是确保,在计算dp[i][j]时更小的依赖已经计算完毕。

        len的区间长度从2(i=0, j=2,区间内只有一个气球),到len=n+1(i=0,j=n+1,区间内就是原数组的所有值),所以len的遍历顺序是j-i的长度,从2~n+2(这个区间内才会有真气球)

代码

python

class Solution(object):
    def maxCoins(self, nums):
        """
        :type nums: List[int]
        :rtype: int
        """
        n = len(nums)
        # 扩充边界,得到n+2长度的数组
        vals = [1] + nums + [1]


        # 构建状态数组
        dp = [[0] * (n+2) for _ in range(n+2)]
        for length in range(2, n+2):
            for i in range(0, n+2-length):
                j = i+length
                for k in range(i+1,j):
                    # 状态转移方程
                    dp[i][j] = max(dp[i][j], dp[i][k]+dp[k][j]+vals[i]*vals[k]*vals[j])
        
        return dp[0][n+1]
        

C++

class Solution {
public:
    int maxCoins(vector<int>& nums) {
        int n = nums.size();
        // 扩充边界
        vector<int> vals(n+2, 1);
        for(int i=0;i<n;i++)
            vals[i+1] = nums[i];
        
        // 定义状态数组dp
        // dp[i][j]表示在开区间(i,j)中可以获得的最大硬币数目
        vector<vector<int>> dp(n+2, vector<int>(n+2, 0));

        // 逻辑处理
        for(int len=2;len<n+2;len++)
        {
            for(int i=0;i<n+2-len;i++)
            {
                int j = i+len;
                for(int k=i+1;k<j;k++)
                {
                    dp[i][j] = max(dp[i][j], dp[i][k]+dp[k][j]+vals[i]*vals[k]*vals[j]);
                }
            }
        }
        // 返回dp[0][n+1]就是题目要求的整个nums中的最大硬币数目
        return dp[0][n+1];

    }
};

322.零钱兑换

解题思路

本题的解题思路如下:

题目中要求在给定的整数数组中,计算并返回可以凑成总金额的所需的最小的硬币个数;

那么我们取状态数组dp[i]表示凑成金额i所需要的最少的硬币个数。

那么对于dp[i],要凑i元的金额,我们需要遍历整个coins数组,在数组中进行枚举,然后返回其中的最小的个数。也就是外层循环是遍历i从0(1也可以)到amount,内层循环是遍历coins数组

对于金额i,我们在coins数组中先取处coin,那么余下的i-coin金额一定是比i要小的,那么也就是说dp[i-coin]所需要的最少的硬币个数在前面是已经确定了的,因此dp[i] = dp[i-coin] +1(+1是因为我们取出了coin这个硬币,所以+1)。

因此状态转移方程就是:

dp[i] = min(dp[i], dp[i-coin] + 1)

初始条件:

因为这里是取最小值,那么dp中的原始值都要是最大值float('inf'),而dp[0]表示金额0的最少硬币数==0,也即dp[0] = 0。

返回值:

比较dp[amount]与float('inf)之间的关系,相等返回-1,说明没有任何一种硬币组合能组成总金额;反之则返回dp[amount]。

计算顺序:

从上面的分析可以知道,计算顺序就是从1元一直计算到amount元,顺序进行。

代码

python

class Solution(object):
    def coinChange(self, coins, amount):
        """
        :type coins: List[int]
        :type amount: int
        :rtype: int
        """
        # dp[i]表示凑i元所需要最少的硬币个数(需要遍历整个coins数组,取最小的个数)
        # dp[i] = min(dp[i], dp[i-coin]+1)
        # 其中dp[i-coin]+1是,我先取出coin,那么余下的金额就是
        # i-coin,肯定比i小,在之前就已经被确定下来了,也就是dp[i-coin]的值

        # 初始化
        dp = [float('inf')]*(amount+1)
        dp[0] = 0
        for i in range(amount+1):
            for coin in coins:
                if i>= coin:
                    dp[i] = min(dp[i], dp[i-coin]+1)
        
        # 返回dp[amount],如果不等于float('inf),说明找到了最小值
        # 反之则说明没有任何一种硬币可以组成amount
        return dp[amount] if dp[amount]!=float('inf') else -1
        
class Solution(object):
    def coinChange(self, coins, amount):
        """
        :type coins: List[int]
        :type amount: int
        :rtype: int
        """
        # dp[i]表示凑i元所需要最少的硬币个数(需要遍历整个coins数组,取最小的个数)
        # dp[i] = min(dp[i], dp[i-coin]+1)
        # 其中dp[i-coin]+1是,我先取出coin,那么余下的金额就是
        # i-coin,肯定比i小,在之前就已经被确定下来了,也就是dp[i-coin]的值
        """
        # 初始化
        dp = [float('inf')]*(amount+1)
        dp[0] = 0
        for i in range(amount+1):
            for coin in coins:
                if i>= coin:
                    dp[i] = min(dp[i], dp[i-coin]+1)
        
        # 返回dp[amount],如果不等于float('inf),说明找到了最小值
        # 反之则说明没有任何一种硬币可以组成amount
        return dp[amount] if dp[amount]!=float('inf') else -1
        """
        # 初始化
        dp = [float('inf')]*(amount+1)
        dp[0] = 0
        
        for coin in coins:
            for i in range(coin, amount+1):
                dp[i] = min(dp[i], dp[i-coin]+1)
        
        # 返回dp[amount],如果不等于float('inf),说明找到了最小值
        # 反之则说明没有任何一种硬币可以组成amount
        return dp[amount] if dp[amount]!=float('inf') else -1
        

C++(注意数据溢出,初始设置最大值设置INT_MAX/2,不然后面dp[i-coin]+1会溢出)

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        // 初始化状态变量
        vector<int> dp(amount+1, INT_MAX/2);
        dp[0] = 0;
        for(int i=0;i<=amount;i++)
        {
            for(int coin:coins)
            {
                // 保证数组下标索引的有效性
                if(i>=coin)
                {
                    // 状态转移方程
                    dp[i] = min(dp[i], dp[i-coin]+1);
                }
            }
        }
        return dp[amount]==INT_MAX/2?-1:dp[amount];
    }
};
class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        // 初始化状态变量
        /*
        vector<int> dp(amount+1, INT_MAX/2);
        dp[0] = 0;
        for(int i=0;i<=amount;i++)
        {
            for(int coin:coins)
            {
                // 保证数组下标索引的有效性
                if(i>=coin)
                {
                    // 状态转移方程
                    dp[i] = min(dp[i], dp[i-coin]+1);
                }
            }
        }
        return dp[amount]==INT_MAX/2?-1:dp[amount];
        */
        vector<int> dp(amount+1, INT_MAX/2);
        dp[0] = 0;
        for(int coin:coins)
        {
            for(int i=coin;i<=amount;i++)
            {
                    // 状态转移方程
                    dp[i] = min(dp[i], dp[i-coin]+1);
            }
        }
        return dp[amount]==INT_MAX/2?-1:dp[amount];
    }
};

416.分割等和子集

解题思路

本题中定义状态变量dp[i],表示能否选出若干数使得和为j

状态转移:

如何判断是否可以把整个nums分成2个子集,首先我们取子集的和为target,接着遍历nums数组,而后倒序遍历j从target倒序到num,在这个过程中,观察dp[j-num]是否为True,也就是j-num这个数值是不是可以被凑到,如果可以那么j-num+num = j也是可以凑到的,那么将其置为True。

for num in nums:
    for j in range(target, num-1,-1):
        if dp[j-num]:
            dp[j] = True

初始条件:

首先题目中要求的是给定的数组要将它分割成2个子集,使得这两个子集的元素和相等,那么这也就是说明这个给定的数组nums的和得是偶数,因为分成两个等和的子集的值不能是小数。

对于状态数组,其中第0个数,也就是能否选出若干个数使得和为0,这个设置为True;

而其余数则初始化为False。

dp数组大小为target+1,因为要加上0这个值。

返回值:

        dp[target]

至于为什么要倒序遍历

如果我们正序遍历的话:

for num in nums:
    for j in range(num, target+1):
        if dp[j-num]:
            dp[j] = True

那么dp[j]的更新是从小到达进行的,dp[j-num]可能在之前已经被遍历更新过,而之后又在更大的j的时候又再次使用了它。而题目中说明了一个数只能被使用一次,那么就说明出现了重复使用的情况。比如nums=[2],target = 4

显然一个数是凑不出来4的,结果为False;

从正序来看:

num = 2:

        j=2, dp[2-2] = dp[0] = True;

        j=3, dp[3-2] = dp[1] = False;

        j=4, dp[4-2] = dp[2](之前更新过的值)= True 

       但是明显一个2凑不出4来,所以结果有问题,这是因为我们更新j=4的时候,用到了先前在j=2时更新中已经使用过的dp[2],这相当于把2用了2次,不满足题意。

从倒序来看:

num = 2:

        j=4, dp[4-2] = dp[2] = False;

        j=3, dp[3-2] = dp[1] = False;

        j=2, dp[2-2] = dp[0] = True'

结果dp[target] = False;正确。

倒序保证了每个物品最多被使用一次:当更新dp[j]时,它依赖的dp[j-num]还没有被本轮更新过,仍然是旧的(还没加入当前物品)状态。

正序则会让小金额先被更新,然后大金额用到了刚更新的小金额,从而导致同一个物品被多次使用。

2种理解:

  • 第一种理解:“原来用 num 可以凑出 j,现在对于更大的 j,我必须再凑一个 num 才能凑出这个更大的 j,说明 num 用到了两次。”
    这直接描述了重复使用的过程:先凑出 j(这里 j=2),再加一个 num 凑出 j+num(即 4)。自然就是用两次。

  • 第二种理解:“在判断 dp[4-num] 时,因为 4-num 和先前遍历更新的一个 dp[j] 相同,说明了 num 用到了两次。”
    这里的“先前遍历更新的一个 dp[j]”就是指 dp[2],而 dp[2] 正是由本轮 num 刚刚更新得到的。因此 dp[4] 依赖了一个已经“用过一次”的状态,相当于再次使用 num

所以,无论是从“递推过程”还是“依赖状态来源”来看,核心都是:正序更新使得小状态被当前物品更新后,大状态又利用了这个已更新的小状态,从而导致同一物品被多次使用

代码

python

class Solution(object):
    def canPartition(self, nums):
        """
        :type nums: List[int]
        :rtype: bool
        """
        # target的先决条件
        target = sum(nums)
        if target%2 == 1:
            return False
        target /= 2
        
        # 初始化
        dp = [False] * (target + 1)
        dp[0] = True
        
        # 状态转移
        for num in nums:
            for j in range(target, num-1, -1):
                if dp[j-num]:
                    dp[j] = True
        
        # 返回值
        return dp[target]

C++

class Solution {
public:
    bool canPartition(vector<int>& nums) {
        // target的确定
        // nums的和只能为偶数
        int n = nums.size();
        int total = accumulate(nums.begin(), nums.end(), 0);
        if(total%2==1)
            return false;
        int target = total /2;
        // 定义状态数组
        vector<bool> dp(target+1, false);
        dp[0] = true;

        // 状态转移
        for(int num :nums)
        {
            for(int j = target;j>=num;j--)
            {
                if(dp[j-num])
                    dp[j] = true;
            }
        }

        // 返回值
        return dp[target];
    }
};

494.目标和

解题思路

本题的解题思路如下:

首先nums数组的总和为sum,设其中添加了+号的部分的总和为add,其中添加了-号部分的总和的绝对值是sub,存在如下关系:
add + sub = sum

add - sub = target

由上可知:

add = (sum + target) / 2

sub = (sum - target) / 2

由此可知,sum+target必须要是偶数,add也不可能超过sum,target的值也不可能超过sum。

题目中要求通过构造找到运算结果等于target的不同表达式的数目,实际上add确定了,sub也随之确定,所以我们只需要找到满足子集的和为add的数目,也就是说在nums中存在多少种方案使得某些子集的和为add

状态变量:

dp[j]:表示,组成和为j的子集共有多少种方案或者选法;

转移方程:

在组成和为add的子集中,数组中的一个数只能被选择一次,或者说不被选择,那么对于dp[j]而言,对于数组中的每个数num,存在以下2种情况:

不选择num,那么dp[j]不变,继承之前的方案数;

选择num,那么余下的部分的总和就是j-num,对应的方案数目就是dp[j-num];

上述2种情况合并,得到总的方案数:

dp[j] = dp[j] + dp[j-num]

初始条件:

dp[0] = 1(对于总和为0的方案,就是什么都不选这一种情形)

其余的初始化都初始化为0。

sum+target必须要是偶数,add也不可能超过sum,target的值也不可能超过sum。

需要判断(sum+target)%2==1?,以及abs(target)>sum这两个判定条件。

遍历顺序:

因为如果是正序的话,dp[j]会用到之前已经更新或者使用过的dp[j-num],这就造成了一个数被多次使用,并不符合题目中一个数只能被使用1次或者0次的可能,故本题需要逆序。

返回值:

dp[add]。

代码

python

class Solution(object):
    def findTargetSumWays(self, nums, target):
        """
        :type nums: List[int]
        :type target: int
        :rtype: int
        """
        # 初始化,判定合理性
        total = sum(nums)
        if (total + target) % 2 == 1:
            return 0
        if abs(target) > total:
            return 0
        # target <= add < total
        add = (total + target) / 2

        # 状态数组
        # dp[j],表示组成子集和为j共有多少种方案
        dp = [0] *(add +1)
        dp[0] = 1

        # 转移方程
        for num in nums:
            for j in range(add, num-1, -1):
                dp[j] += dp[j-num]
        
        return dp[add]

C++

class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int target) {
        // 初始化add
        int add = 0;
        int sum = accumulate(nums.begin(), nums.end(), 0);
        
        // 判断合理性
        // target的数值不可能大于sum,题中说明了nums是非负正整数
        // sum+target的值必须得是偶数,add是nums中子集的和,不可能为小数之类的
        if(abs(target) > sum)
            return 0;
        if((sum + target) % 2 == 1)
            return 0;
        add = (sum + target) / 2;
        
        // 初始化dp
        vector<int> dp(add+1, 0);
        dp[0] = 1;

        // 状态转移
        for(int num :nums)
        {
            for(int j = add; j>=num;j--)
            {
                dp[j] += dp[j-num];
            }
        }

        // 返回值
        return dp[add];


    }
};

55.跳跃游戏

解题思路

本题中需要我们判断是否能够到达最后一个下标,其中数组中的每个元素代表在该位置可以跳跃的最大值,那么假设我们在当前位置可以跳跃的最大下标是maxreach,遍历整个nums数组,

        对于某一个下标i>maxreach,说明当前位置无法到达这个i下标所在的位置,则直接返回False;

        反之则说明当前位置是可以达到i的,而i下标位置可以跳跃的最大长度是nums[i],那就说明maxreach可以达到的最大长度是i+nums[i],更新maxreach为i+nums[i],也即是maxreach = max(maxreach, i+nums[i]);

        如果maxreach>=n-1,说明当前位置的最大下标超过了数组的最大下标,说明可以达到最后一个下标,返回True。

代码

python

class Solution(object):
    def canJump(self, nums):
        """
        :type nums: List[int]
        :rtype: bool
        """
        # 维护当前位置可以跳跃到的最大下标
        maxreach = 0
        for i in range(len(nums)):
            # 如果当前下标i是我们maxreach达不到的,所以我们当前位置是到达不了这个i下标,直接返回false
            if i>maxreach:
                return False
            # 反之说明可以到达,那么就直接更新maxreach的值,maxreach可以到达的最大下标是i
            # 到了位置i,可以跳跃的长度又是nums[i],所以maxreach = i+nums[i]
            maxreach = max(maxreach, i+nums[i])
            # 当maxreach超过了数组的最大下标,说明可以跳跃到数组的最大下标的,直接返回true即可
            if maxreach >=len(nums) -1:
                return True
        return True

C++

class Solution {
public:
    bool canJump(vector<int>& nums) {
        // maxreach是我们维护的当前位置可以到达的最大下标
        int maxreach = 0;
        for(int i=0;i<nums.size();i++)
        {
            // 如果nums[i]所在的下标是大于maxreach的,说明我们当前位置是达到不了i的,而i又是在数组下标[0~n-1]之间
            // 说明当前位置是到达不了数组的最后一个下标
            if(i>maxreach)
                return false;
            // 反之说明当前位置到达的最大距离是i,而下标i上可以跳跃的最大长度是nums[i],
            //                     说明可以条约的最大长度是i+nums[i],更新maxreach
            maxreach = max(maxreach, i+nums[i]);
            // 如果maxreach大于数组的最后一个下标,说明了从第一个下标是可以到达最后一个下标的,返回true
            if(maxreach>=nums.size()-1)
                return true;
        }

        return true;
    }
};

56.合并区间

解题思路

本题的解题思路如下:

本题中要求我们要把所有的重叠的区间合并,对于[1, 3] [2, 6]这样的区间得合并,对于[1, 4] [4, 5]这样的区间也得合并,我们不难发现,可以合并的基本上都满足,当前子区间的左端点要小于等于上一个子区间的右端点的值,那么具体做法如下:

初始化最后结果列表为ans=[]

首先我们把intervals中的所有区间的左端点进行排序,而后遍历intervals数组,观察其中子区间p的左端点和上一个子区间的右端点大小,如果满足左端点的值小于等于上一个子区间的右端点的值,

即:

p[0] <= ans[-1][1]

或者

p[0] <= ans.back()[1]

则合并这两个区间,因为左端点是从小到大排序了的,左端点还是上一个子区间的左端点,但是右端点得是当前子区间和上一个子区间的右端点的最大值,比如[1, 4] [2, 3]合并后的子区间就是[1,4];再比如[1, 3] [2, 4]合并后的子区间就是[1 , 4] 。

        ans[-1][1] = max(ans[-1][1], p[1])

       或者

        ans.back()[1] = max(ans.back()[1], p[1])

        反之不满足就直接在ans最后添加就行。

最后返回ans即可。

代码

python

class Solution(object):
    def merge(self, intervals):
        """
        :type intervals: List[List[int]]
        :rtype: List[List[int]]
        """
        # 把intervals左端点按照从小到大进行排列
        intervals.sort(key = lambda p:p[0])
        ans = []

        for p in intervals:
            # 当前intervals中的区间的左端点小于ans中最后一个区间的右端点
            # 则可以合并区间
            # 此时更新右端点为ans中最后一个区间的右端点和p区间右端点的最大值
            if ans and p[0] <=ans[-1][1]:
                ans[-1][1] = max(ans[-1][1], p[1])
            # 反之,说明这两个区间不相交
            else:
                ans.append(p)
        
        return ans

C++

在 C++ 中,sort(intervals.begin(), intervals.end()) 默认使用 vector<int> 的 operator<,它会按照 字典序 比较两个 vector<int>:即先比较第一个元素,如果相等再比较第二个元素,依此类推。

因此,对于形如 [[1,3],[2,6],[2,4]] 的二维数组,默认排序的结果是:

  • 先按每个区间的左端点升序排列

  • 左端点相同时,再按右端点升序排列(因为 vector 的第二元素也会参与比较)

在合并区间问题中,这样排序是可以接受的,因为:

  • 左端点相同时,右端点小的排在前面,不影响合并逻辑(合并时会取最大右端点)

  • 如果显式只按左端点排序(忽略右端点),也完全可以,但默认排序实际上更细致。

class Solution {
public:
    vector<vector<int>> merge(vector<vector<int>>& intervals) {
        // 让intervals中的每个子区间的左端点按照从小到大排列
        sort(intervals.begin(),intervals.end());
        vector<vector<int>> ans;

        for(auto &p:intervals)
        {
            // 满足p的左端点的值要小于等于ans的最后一区间的右端点的值,则可以合并区间
            if(!ans.empty() && p[0]<=ans.back()[1])
                // 合并区间的右端点为and最后区间的右端点的值与p的右端点的值的最大值
                ans.back()[1] = max(p[1], ans.back()[1]);
            else
                // 反之压入ans中
                ans.push_back(p);
        }

        return ans;
    }
};

406.根据身高重建队列

解题思路

本题的我们对身高先进行降序排列,对身高相同的对k进行升序排列,那么为什么这么做呢?

首先为什么要先对身高降序排列呢?

        对身高先进行降序排列,在结果队列当中,当前处理的是p,已经存放在数组中的都是身高大于等于p的,而p要求前面要有k个大于等于它身高的元素,那我们把p插入到当前队列的k索引下标处,那么此时队列的前面(0,k-1)就是存在k个大于等于p身高的数,满足条件。

        如果后面有比p的身高要小的元素,但是k值很小,会插入到当前p的前面,会增加p前面人的数量,但是对于p而言,前面身高大于等于它的人数并没有改变,不会改变k的计数,不会破坏原来的k的大小。

为什么要对身高相同的对k进行升序排列呢?

        倘若我们对身高相同不进行升序排列呢?比如存在一个[6, 4] [6,5],如果说k大的先放到队列中,那么[6, 5]先放到队列当中,队列当中身高大于等于6的有5个,而后再放入[6,4],[6, 4]得插入到[6, 5]之前,那么[6, 5]之前所保持的队列前面有5个大于等于6的就不对了,因为此时[6, 4]的加入,使得这个5不满足了,此时是6,所以不能降序排列。

        应当先往队列当中放k较小的[6,4],之后再放[6,5],这样k值之间关系才得以满足,不会被打乱。

代码

python

python中的lambda表达式

lambda 参数列表: 表达式
people.sort(key=lambda x: (-x[0], x[1]))

or

people = sorted(people, key = lambda x:(-x[0], x[1]))

lambda x: (-x[0], x[1]) 接收一个元素 x(即 [h, k]),返回一个元组 (-h, k)。

排序时,Python 会先比较元组的第一个元素(即 -h),如果相等再比较第二个元素(即 k)。

由于 -h 是负的,所以 h 大的(即身高高的)对应的 -h 小,会排在前面,实现了降序。

当 -h 相同时(即身高相同),比较 k 值,k 小的排在前面,实现了升序。
class Solution(object):
    def reconstructQueue(self, people):
        """
        :type people: List[List[int]]
        :rtype: List[List[int]]
        """
        # 构造结果数组
        res = []
        # 对people队列中h进行降序排列,k进行升序排列
        people = sorted(people, key = lambda x:(-x[0], x[1]))
        for p in people:
            # res元素长度小于当前p的k索引,也就是说明当前res队列的元素,大于等于p的元素个数不满足k
            # 直接再res末尾加上p
            if len(res) <= p[1]:
                res.append(p)
            # 反之就是说res的长度已经是大于当前p的k值,说明队列中元素的数目是大于当前p中的k值
            # 那么我们直接再res的索引p[1]处,插入p即可
            else:
                res.insert(p[1],p)
        
        return res

C++

注意这里需要自定义排序的比较函数

还有就是在插入到时候,插入到索引为k的位置,就是ans.begin()+p[1],就是索引为k的位置。

sort(people.begini(), people.end(), [](const vector<int>&a, const vecor<int>&b)
{
    return a[0] > b[0] ; // 降序排列
};

sort(people.begini(), people.end(), [](auto&a, auto&b)
{
    return a[0] > b[0] ; // 降序排列
};
class Solution {
public:
    vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
        vector<vector<int>> ans;
        // 这里需要自己重构这个排序
        sort(people.begin(), people.end(), [](const vector<int>& u, const vector<int>&v)
        {
            return u[0] > v[0] || (u[0] == v[0] && u[1] < v[1]);
        });

        // 逻辑处理
        for(auto& p:people)
        {
            // ans中元素长度不满足p[1],直接在ans末尾添加p
            if(ans.size()<=p[1])
                ans.push_back(p);
            // 满足的话,就在p[1]==k的位置插入p
            else
                ans.insert(ans.begin()+p[1], p);
        }
        return ans;
    }
};

621.任务调度器

解题思路

本题的解题思路如下:

题目中要求的是,每2个相同种类的任务之间必须要有长度为n的冷却时间,在这个冷却时间内,我们可以取填充别的任务,来达到这个n的冷却时间;

那么假设tasks中出现次数最多的任务,次数是maxExec,可能有1个,也可能有多个都出现了maxExec次。

count_maxExec 是任务次数也达到最大值 maxExec 的任务种类数

而假设首先我们取出其中的一个任务A,然后把它排列在一块,每2个A之间空出n的冷却时间,用来填充n个不相同的任务,最后一个A之后接上其余的maxExec-1个任务,因为这些任务不相同,没有所谓的冷去时间。

而其余的maxExec个任务都一起穿插在这些n中,会有如下情况(假设tasks = ['A', 'A', 'A', 'B', 'B', 'B', 'C', 'C'] n=3:

①这些任务填不满n的冷却时间:

A _ _ _ A _ _ _ A 

那么最终的填充情况就是:

A B C _ A B C _ A B 

总共所需的时间是total_time = (maxExec -1 ) * (n+1) + count_maxExec,这个地方等于10;

②这些任务可以填满n的冷却时间,甚至是超过了冷却时间假设tasks = ['A', 'A', 'A', 'B', 'B', 'B', 'C', 'C'] n=2:

A _ _ A _ _  A    ====>   A B C  A B C A B 

此时最终所需要的最短时间间隔是8,就是tasks的任务总数len(tasks)

如果n=1:

A B  A B A B ,2个C可以穿插在任意位置,只要不相邻即可,举一种例子:A B  A C B A B C

此时最终所需要的最短时间间隔是8,同样是tasks的任务总数len(tasks)

所以最终的最短时间间隔是取上述2种情况的最大值:

如果total_time要大于len(tasks),说明冷却时间内部有空闲;

如果total_time小于len(tasks),说明任务多到可以填满所有空闲,那么实际时间就由任务总数决定。

在上述例子中,情况1,任务总数len(tasks) = 8,但是实际需要的时间是10,所以是取最大值;

在情况2中,任务总数是8,按照公式计算(3-1)*(2+1)+ 2 = 8;

如果是n=1的情况,公式得到的是(3-1)*(1+1)+ 2 = 6,但是实际需要8次才可以。

综上所述,最终的最短时间间隔就是取max(total_time, len(tasks)).

代码

python

class Solution(object):
    def leastInterval(self, tasks, n):
        """
        :type tasks: List[str]
        :type n: int
        :rtype: int
        """
        # 统计每个字符出现的次数
        freq = [0] * 26
        for task in tasks:
            freq[ord(task) - ord('A')] += 1
        
        # 找到最大出现次数
        max_freq = max(freq)

        # 计算有多少个任务的次数等于最大出现次数
        max_count = freq.count(max_freq)
        # 计算桶时间
        total_time = (max_freq - 1)*(n + 1) + max_count

        # 取最大值
        return max(total_time, len(tasks))

C++

*max_element
在 C++ 中,max_element 是标准库 <algorithm> 里的一个函数,用于在指定范围内找到最大元素的位置(迭代器)。

int max_freq = *max_element(freq.begin(), freq.end());
  • freq.begin() 和 freq.end() 分别是 vector 的起始迭代器和结束迭代器,表示整个 freq 数组的范围。

  • max_element 会遍历这个范围,返回一个迭代器(可以理解为指向该元素的指针/索引),它指向找到的最大元素。

  • 由于我们需要的是元素的值而不是位置,所以前面加 *(解引用操作符)来取得该位置上的值。

也就是说:*max_element(...) 就等于 freq 数组中的最大值。

class Solution {
public:
    int leastInterval(vector<char>& tasks, int n) {
        // 统计每个任务的出现次数
        vector<int> freq(26,0);
        for(char task:tasks)
        {
            freq[task-'A']++;
        }

        // 计算最大出现次数,最大出现次数对应的任务的个数
        int max_freq = *max_element(freq.begin(), freq.end());
        int max_count = 0;
        for(int f:freq)
        {
            if(f==max_freq)
                max_count++;
        }

        // 计算桶时间
        int total_time = (max_freq-1)*(n+1) + max_count;
        // 返回比较最大值
        return max(total_time, (int)tasks.size());
    }
};

581.最短无需连续子数组

解题思路

本题使用的方法如下:

对整个数组采取从左往右,以及从右往左2次遍历,分别找到出现乱序的右边界和左边界;

因为整个数组是要求升序排列,只是某些部分数字的顺序被打乱了,因此我们要找出这部分打乱的最短的数组,使得它按照升序排列之后,整个数组都是升序。

        从左往右遍历:对于升序排列,每个数 >= 左边所有数的最大值。如果存在某个数 nums[i] < max_left(即比左边的最大值还小),说明它需要排序,更新right = i。;

        从右往左遍历:对于升序排列,每个数 ≤ 右边所有数的最小值。如果存在某个数 nums[i] > min_right(即比右边的最小值还大),说明它需要排序,更新 left = i

对于right和left值,如果right<=left,说明不存在这个区间,原数组就是升序排列的,返回0;

反之,则返回right - left +1即可。

为什么[left , right]就是所要求的最短子数组:

因为数组要求是升序排列的,那么[0,left-1]是升序的,[right+1, n-1]是升序的,而[left, right]中是因为我们发现了在这个区间存在不满足升序的数,任何更短的不能使得整个数组升序,再往外延申会包括本身就是升序的元素,所以[left, right]区间就是最短子数组区间。

初始化:

left= n

right = -1

因为从左往右遍历是找right,right一开始的值就得比原数组的所有下标都要小,取-1,而后不断遍历更新更大的值;

而从右往左遍历是找left,left一开始的值就得比原数组所有的下标都要大,取n,而后不断遍历更新更小的值;

其实取left = n-1和right=0也可以,因为最后的判定条件是left<right,满足才返回right - left+1,即使是数组中只有一个元素,left = 0,right= 0,同样不满足left<right,依旧返回0;

而取left = n,right = -1,是为了防止最后的判定条件是left<=right的情形,这样当遇到只有一个元素的数组的时候,如果是上述情况会返回1,但是实际应该返回0,而在这种情况下left = n,right = -1,即使是left<=right这个条件,也不会被满足,直接返回0。

所以说left取n,right取-1是一种更为保险的做法。

代码

python

class Solution(object):
    def findUnsortedSubarray(self, nums):
        """
        :type nums: List[int]
        :rtype: int
        """
        n = len(nums)
        left = n
        right = -1

        # 从左往右遍历 
        max_left = nums[0]
        for i in range(1, n):
            if nums[i] < max_left:
                right = i
            else:
                max_left = nums[i]
        
        # 从右往左遍历
        min_right = nums[-1]
        for i in range(n-1, -1, -1):
            if nums[i] > min_right:
                left = i
            else:
                min_right = nums[i]
        
        return right-left+1 if left < right else 0

C++

class Solution {
public:
    int findUnsortedSubarray(vector<int>& nums) {
        int n = nums.size();
        int left = n;
        int right = -1;

        // 从左往右遍历
        int max_left = nums[0];
        for(int i=1;i<n;i++)
        {
            if(nums[i]<max_left)
                right = i;
            else
                max_left = nums[i];
        }

        // 从右往左遍历
        int min_right = nums[n-1];
        for(int j=n-2;j>=0;j--)
        {
            if(nums[j] >min_right)
                left = j;
            else    
                min_right = nums[j];
        }

        return left<right?right - left +1:0;
    }
};

253.会议室II

解题思路

用双指针模拟事件顺序

本题我们使用双指针来表示会议的开始和结束时间,在intervals中,我们遍历每一个元素,将起始时间和结束时间都按顺序保存在starts和ends数组中,然后比较这两个数组中的元素:

假设i代表下一个事件的开始时间,j代表下一个事件的结束时间。

关键在于:比较starts[i]和ends[j]之间的大小关系

如果starts[i]< ends[j] ,说明下一个未处理的开始时间是小于下一个未处理的结束时间,说明接下来要发生的是会议开始,而上一个会议还没有结束,所以我们应该新开一个会议室;

如果starts[i] >= ends[j],说明下一个未处理的开始时间是大于下一个未处理的结束时间,说明接下来要发生的是会议结束,会有会议室空出来复用,此时会议室的数量保持不变。

举例说明:

假设有三个会议:

  • A: [0, 30]

  • B: [5, 10]

  • C: [15, 20]

starts = [0, 5, 15]        ends = [10, 20, 30]        room代表需要的会议室数目

按照常理[0, 30]分配一个会议室,而[5, 10]分配一个会议室,时间结束后[15, 20]继续利用这个会议室进行会议。所以总共是2个。

i= 0,starts[i] = 0 ,        ends[j] = 10

        starts[i] < ends[i],那么就是说明下一个事件是开始,那么就需要新开一个会议室,room+1,room =1。

i= 1, starts[i] = 5,            ends[j] = 10

        starts[i] < ends[j],那么就是说明下一个事件是开始,但是此时已经有一个会议在进行[0, 30],没有任何会议结束,因此需要新开一个会议室,room+1,room = 2。

i = 2, starts[i] = 15,         ends[j] = 10

        starts[i] > ends[j],说明下一个事件是结束,意味这我们可以空出一间会议室出来复用,所以room的数量保持不变,继续找下一个j。

最终返回room=2即可。

如果不太好理解的话,可以把开始时间记作+1,而后结束时间记作-1,那么上面的内容升序排列之后:

0        +1

5        +1

10        -1

15        +1

20        -1

30        -1

这个过程中右边计数的最大值是2,表示在整个会议时间内,需要新开2间会一直供整个会议时间内使用。其实和上面的例子是一个道理。

至于为什么不用考虑时间配对问题,就是说每一个会议的开始和结束时间不应该是一一对应的么?上面那样做就是打乱了所有的开始和结束时间,这样不会有影响的么?

其实这样做没有影响,因为存在开始时间就说明有会议要进行,而一碰到结束时间,说明有会议结束,对应的会议室可以复用,这样不必追求每个时间都要一一匹配。

代码

python

class Solution:
    def minMeetingRooms(self, intervals):
        # 判定异常
        if not intervals:
            return 0
        # 升序得到开始时间和结束时间
        starts = sorted(i[0] for i in intervals)
        ends   = sorted(i[1] for i in intervals)
        # 初始化
        rooms = 0
        j = 0
        for i in range(len(intervals)):
            # 下一个事件是开始,所以会议室数量+1
            if starts[i] < ends[j]:
                rooms +=1
            # 反之说明下一个事件是结束,有会议室空出,可以复用,会议室数量保持不变,j往后移
            else:
                j += 1
        return rooms

C++

class Solution{
public:
    int minMeetingRooms(vector<vector<int>> & intervals)
    {
        // 判断intervals是否为空
        if(intervals.empty())
            return 0;
        // 初始化
        vector<int> starts, ends;
        
        // 按升序存储起始时间、结束时间
        for(auto &inv:intervals)
        {
            starts.push_back(inv[0]);
            ends.push_back(inv[1]);
        }
        
        int j = 0;
        int room = 0;
        for(int i =0;i<intervals.size();i++)
        {
            // 未处理事件的开始时间大于结束时间,得新开一个会议室
            if(starts[i] < ends[j])
                room++;
            // 反之,有会议结束,空出来的会议室可以复用,继续比较下一个结束时间,当前结束时间已完成,说明上一个会议已经结束
            else
                j++;
        }

        return room;

    }


}

39.组合总和(回溯专题漏了这题)

解题思路

 回溯的基本做法,这个地方我们只用选或者不选这种方法来做。

初始化path=[], ans = []

终止条件:

        当left == 0相等时,此时path数组内的值满足target了,直接取出即可;

判定条件:

        如果遍历到了candidates的末尾,说明在candidates中没有满足target的组合或者说最后left的值小于0,同样说明凑不到满足target的组合;

选:

        对于数组中第i个元素,此时我们目前组合的和是left,如果我们选择了candidates[i],那么余下的数的和是left- nums[i],而后还是可以继续选择nums[i],因为题目中说明了同一个数字可以无限制重复被选取;选择了nums[i[],将其放入path数组中

不选:

        对于数组中第i个元素,此时我们目前组合的和是left,如果我们不选择nums[i],那么余下的数的和是left不变,而后走到第i+1个数;

返回值:

        返回ans数组即可。

优化:

        对candidates进行升序排列,在判定无效的时候,如果left小于cadidates[i],那么不应继续递归了,因为后面的candidates[i+1]……都是大于left的,凑不到left==0,我们要找到满足使得left==0的组合是不可能的,所以这样直接剪枝,不用继续后面的判断。

代码

python

class Solution(object):
    def combinationSum(self, candidates, target):
        """
        :type candidates: List[int]
        :type target: int
        :rtype: List[List[int]]
        """
        # 初始化
        candidates.sort()
        ans = []
        path = []
        # 回溯
        # i代表candidates中的下标
        # left代表剩余元素的和,起始是target
        def dfs(i, left):
            # 终止条件
            if left == 0:
                ans.append(path[:])
                return 
            
            # 判定条件
            if i == len(candidates) or left < 0:
                return 
            # 优化
            if i == len(candidates) or left < candidates[i]:
                return

            # 不选
            dfs(i+1, left)

            # 选
            path.append(candidates[i])
            # 继续i是因为题目中说明了元素可以无限利用
            dfs(i, left - candidates[i])
            path.pop()
        
        dfs(0, target)

        return ans

C++

class Solution {
public:
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        // 对candidates进行升序排列
        sort(candidates.begin(), candidates.end());

        vector<int> path;
        vector<vector<int>> ans;

        // left是凑成target组合还需要多大
        auto dfs = [&](this auto &&dfs, int i, int left)
        {
            // 终止条件
            if(left == 0)
            {
                ans.push_back(path);
                return;
            }

            // 判定边界
            if(i==candidates.size() || left < candidates[i])
                return;
            
            // 不选
            dfs(i+1, left);

            // 选, 还是遍历i,因为一个元素可以多次利用
            // 先压入path中,再继续往下递归
            path.push_back(candidates[i]);
            dfs(i, left - candidates[i]);

            // 恢复现场
            path.pop_back();
        };

        dfs(0, target);

        return ans;
    }
};

42.接雨水

解题思路

本题还是使用单调栈,对于能够形成水坑,来接雨水的坑,满足一下情况:

left是当前坑底元素(栈顶元素)弹栈之后的栈顶元素,在文中我们把它当作栈顶元素的下一个元素。

①坑底的左边一定有元素,并且是高度比它搞得元素,从左到坑底这块是单调递减的,在坑底右边的柱子的高度是必须大于坑底的,也就是右边的柱子不满足左边的单调递减规律;

②能够装雨水的区域的面积:

        假设坑的形状、左右是这个样子:left bottom i(i是当前遍历到的索引)

        那么坑的宽度是right-left-1,高度是min(height[left], height[right]) - height[bottom]

能够接水的面积是:宽度* 高度 = (right - left -1) * (min(height[left], height[i]) - height[bottom])

那么就有如下问题:

①这个bottom怎么求呢?

首先我们知道,bottom是一个坑的坑底,那么从左到坑底是单调递减的,在遍历这个height数组的时候,我们用一个单调栈来维护,栈中元素自底向上是单调递减的,如果在遍历的时候发现当前元素height[i]大于栈顶元素,说明当前元素高度大于栈底,并且栈底元素左边有存在比它高的元素,那么就可以组成一个坑来接雨水。

故,bottom就是在遍历的过程中,按照单调递减存入栈中,而遍历到大于栈顶的元素时,此时的栈顶元素。

②这个left怎么求呢?

因为要想组成坑来接雨水,那么左右边界都必须要比坑底要高,并且都得存在,当我们在上面知道了bottom的时候,其实right也就是当前遍历的元素i,那么left就得是bottom左边且比它要高的元素,也就是left是栈顶元素的下一个元素。当栈中弹出栈顶元素bottom之后,栈为空,说明了bottom左边没有比它高的柱子,因此形成不了接雨水的坑,直接跳出当前循环。

③对于存在多个相同的坑底元素该怎么办呢?

例如:height = [3, 2, 1, 1, 2]

情况一:如果栈是严格单调递减的话,那么

i=0, 开始st= [0]

i=1, 2<3,st=[0, 1]

i=2, 1<2,st=[0, 1, 2]

i=3, 1==1,此时进入while循环中,i=3,bottom = 2,bottom_hd = 1,left = 1, height[left] =2,hd = min(height[i], height[left]) - bottom_hd = min(1, 2) - 1 = 0,所以能接水的区域是0;

此时的栈st=[0,1],height[st[-1]]  = 2 height[i] = 1,不满足height[st[-1]] <=h,所以不满足,继续往后遍历。

由上可知,对于严格单调递减,栈中只会存储一个相同的坑底元素。

情况二:如果是栈是非严格单调递增的话,那么

i=0, st = [0]

i=1, 2<3,st=[0, 1]

i=2, 1<2,st=[0, 1, 2]

i=3, 1=1,st=[0, 1, 2, 3]

i=4, 1<2,此时进入while循环中,i=4, bottom = 3, bottom_hd = 1, left = 2, height[left] = 1,hd = min(height[i], height[left]) - bottom_hd = min(2, 1) - 1 = 0,所以能接水的区域是0;

此时栈st=[0, 1, 2],这个情况和情况1一致,后续不具体分析。

总之,对于上述情况,不论坑底是否有多个相同的坑底元素,最终都能找到正确的答案。

代码

python

class Solution(object):
    def trap(self, height):
        """
        :type height: List[int]
        :rtype: int
        """
        # 初始化
        ans = 0
        # 栈中维护的是下标
        st = []

        # 遍历height数组
        for i, h in enumerate(height):
            # st按照自底向上,单调递减排列
            # 一旦出现不满足单调递减的,可能会形成坑,要在此处做更进一步的判断
            while st and height[st[-1]] <= h:
                # 此时坑底元素就是栈顶元素
                # 将其弹出栈
                bottom_h = height[st.pop()]
                # 将bottom弹出后,判断栈是否为空
                # 栈为空,形成可以接水的坑的左边界是不存在的,因此无法接水
                if not st:
                    break
                # 栈不为空,此时形成坑的左边界就是此时的栈顶元素
                left = st[-1]
                width = i - left - 1
                hd = min(h, height[left]) - bottom_h
                ans += width * hd
            
            # 此时元素不满足上述关系,也就是栈顶元素大于当前遍历的height数组元素值
            # 满足递减,存入栈中
            st.append(i)

        return ans

C++

class Solution {
public:
    int trap(vector<int>& height) {
        // 初始化
        stack<int> st;
        int ans = 0;
        int n = height.size();

        for(int i =0;i<n;i++)
        {
            // st不为空,且st遵循严格单调递减
            // 不满足进入循环,求解可行区域
            while(!st.empty() && height[st.top()] <= height[i])
            {
                int bottom = st.top();
                int bottom_hd = height[bottom];
                st.pop();
                // 判断弹出栈顶元素后,栈左边还有没有数,没有形成不了可以接水的区域
                if(st.empty())
                    break;
                int left = st.top();
                int width = i - left - 1;
                int hd = min(height[i], height[left]) - bottom_hd;
                ans += width * hd;
            }

            // st单调递减
            st.push(i);
        }

        return ans;
    }
};

84.柱形图中最大的矩形

解题思路

本题要求柱形图中面积最大的矩形,其实要求面积最大的矩形,就是遍历整个heights数组,然后以heights[i]为中心,各找到左右第一个比heights[i]小的左右边界left,right,而后这样的话,矩形的宽就是right-left-1,高就是height[i],不断更新这个面积的最大值。

而在这个过程中,我们选择维护一个单调递增栈,也就是栈自底向上是单调递增的,假设当前遍历到的元素是heights[i],栈顶元素的大小比当前元素要大,那么就以此时栈顶元素为高(栈pop()出来),原始栈中栈顶元素的下一个位置,因为是单调递增栈,该位置比栈顶元素小,也就是得到的左边界left,此时的i就是右边界,计算可以形成的区域面积:

area = (i - left - 1) * heights[st.pop()]

随后,如果此时的栈不为空,且栈顶元素依旧是大于heights[i],则继续更新area,直到栈顶元素此时小于等于height[i]。

为什么要在heights左右都加上0?

在整个过程中,为了使得所有的柱子都经过计算,我们在heights数组中左右两边界都补上了0,这样在遍历到最后的0的时候,会把所有柱子都计算。

因为我们考虑极端情况,当heights是单调递增的情况下,那么栈中存储也是单调递增的,就不会有计算面积这一步,但是即使是严格单调递增,依旧需要计算面积,因此为了让栈中的所有元素都计算面积,我们在最后加入了0,以及最前面加入了0,保证了左右边界,也就是left和right,它会强制栈中所有剩余柱子依次弹出并计算面积。

总之:

前面加0,保证栈不为空;

最后加0,保证栈中所有元素都弹出并计算面积。

代码

python

class Solution(object):
    def largestRectangleArea(self, heights):
        """
        :type heights: List[int]
        :rtype: int
        """
        # 矩形的计算:
        #      以heights[i]为高,左右两边出现的第一个小于heights[i]的元素作为left和right
        #      wd = right - left -1
        #      hd = heights[i]
        #      area = wd * hd
        # 左右边界补0
        heights = [0] + heights + [0]
        # 构建单调递增栈,自底向上单调递增
        st = []
        area = 0

        for i, h in enumerate(heights):
            # 栈不为空,且此时的栈顶元素大于当前元素
            while st and heights[st[-1]] > h:
                # 此时栈顶元素作为高,弹出
                hd = heights[st.pop()]
                # 此时的栈顶元素作为左边界
                left = st[-1]
                # i为右边界,计算宽度
                w_d = i - left - 1
                # 计算面积
                area = max(area, hd * w_d)
            st.append(i)
        return area

C++

class Solution {
public:
    int largestRectangleArea(vector<int>& heights) {
        // 在heights前后加入0
        heights.push_back(0);
        heights.insert(heights.begin(), 0);

        // 单调递增栈,自底向上,单调递增
        // 存的是下标
        stack<int> st;
        
        int area = 0;
        int n = heights.size();
        for(int i=0;i<n;i++)
        {
            // 当前栈不为空,并且栈顶元素大于heights[i],找到左右边界
            while(!st.empty() && heights[st.top()] > heights[i])
            {
                // 此时栈顶元素作为高
                int hd = heights[st.top()];
                st.pop();
                // 弹出后,此时栈顶元素是左边界
                int left = st.top();
                int wd = i - left -1;
                area = max(area, hd * wd);
            }
            st.push(i);
        }

        return area;

    }
};

85.最大矩形

解题思路

本题的做法其实和84题基本一致,只不过本题是在一个矩阵上,但是我们是可以以每行为一个基准,对每行进行84题一样的操作,计算每行的最大的矩形,然后逐行累加,计算最大矩形。

对于矩阵中的元素如果是0,那么高度就是0,如果是1,那么对应列的height+1。对于每一行的元素,有1则在上一层的基础上+1。

其实矩形的话,把其中每一列为一的结果累加之后,组成的图形就是84的柱形图,不是么?只不过我们在这里计算了矩形每一列的结果。

代码

python

class Solution(object):
    def largestRectangleArea(self, heights):
        # 单调栈,自底向上单调递增
        st = []
        # 内部添加0,保证栈中所有原本元素都弹出并计算面积
        heights = [0] + heights + [0]
        n = len(heights)
        area = 0
        for i, h in enumerate(heights):
            while st and heights[st[-1]] > h:
                hd = heights[st.pop()]
                left = st[-1]
                wd = i - left - 1
                area = max(area, hd * wd)
            st.append(i)
        return area

    def maximalRectangle(self, matrix):
        """
        :type matrix: List[List[str]]
        :rtype: int
        """
        ans = 0
        n = len(matrix[0])
        heights = [0] * n
        for row in matrix:
            for j, c in enumerate(row):
                if c=='0':
                    heights[j] = 0
                else:
                    heights[j] += 1
            ans = max(ans, self.largestRectangleArea(heights))
        
        return ans

        

C++

class Solution {
public:
    int largeRectangleArea(vector<int> heights)
    {
        heights.push_back(0);
        heights.insert(heights.begin(), 0);
        int n = heights.size();
        stack<int> st;
        int area = 0;
        for(int i=0;i<n;i++)
        {
            while(!st.empty() && heights[st.top()] > heights[i])
            {
                int hd = heights[st.top()];
                st.pop();
                int left = st.top();
                int wd = i - left - 1;
                area = max(area, hd * wd);
            }
            st.push(i);
        }
        return area;
    }
    int maximalRectangle(vector<vector<char>>& matrix) {
        int n = matrix[0].size();
        vector<int> heights(n);
        int ans = 0;
        for(auto& row :matrix)
        {
            for(int i=0;i<n;i++)
            {
                if(row[i] == '0')
                    heights[i] = 0;
                else
                    heights[i] += 1;
            }
            // 将每行的结果送给上面的函数计算面积
            ans = max(ans, largeRectangleArea(heights));
        }
        return ans;
    }
};

739.每日温度

解题思路

我们用单调栈来解决这个问题,首先本题的单调栈自底向上是降序排列。

每次遍历的时候,如果当前元素t[i]大于栈顶元素,则弹出栈顶元素,同时记录对应的下标差值,即为出现下一个更高温度出现在第几天。

因此栈中维护的其实是下标。

具体算法流程:

        初始化结果数组ans,栈st

        当栈不为空并且当前元素大于栈顶元素的时候,栈顶元素出栈,并记录对应的下标索引差值存在结果数组ans中,如此一直循环,直到当前元素不大于栈顶元素;

        反之,如果不满足上述条件:栈不为空并且当前元素大于栈顶元素,则将当前元素的下标压入栈中,而后继续遍历t中其余元素。

最终返回ans。

代码

python

class Solution(object):
    def dailyTemperatures(self, temperatures):
        """
        :type temperatures: List[int]
        :rtype: List[int]
        """
        n = len(temperatures)
        # 栈中存储的是下标
        st = []
        ans = [0] * n

        for i, t in enumerate(temperatures):
            # 如果栈不为空,并且当前元素大于栈顶元素,那么栈顶元素一直要被弹出,
            # 直到当前元素小于等于栈顶元素
            while st and t > temperatures[st[-1]]:
                # 取出栈顶元素的索引
                index = st.pop()
                # i-index就是题中要求的下一个更高温度出现在几天后
                ans[index] = i - index
            # 当不满足上述条件,将当前元素下标压入栈中
            st.append(i)
    
        return ans

        

C++

class Solution {
public:
    vector<int> dailyTemperatures(vector<int>& temperatures) {
        // 初始化
        int n = temperatures.size();
        vector<int> ans(n, 0);
        // 栈中存储的是下标值
        stack<int> st;
        for(int i=0;i<n;i++)
        {
            // 当栈不为空,并且当前元素大于栈顶元素
            while(!st.empty() && temperatures[i]>temperatures[st.top()])
            {
                // 取出栈顶元素的下标
                int j = st.top();
                st.pop();
                ans[j] = i - j;
            }

            // 不满足条件的话,那么把当前元素的下标压入栈中
            st.push(i);
        }
        
        return ans;
    }
};

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐