算法套路十八——区间 DP

算法套路十八——区间 DP

  • 线性DP: 具有前缀/后缀结构的问题,其中每个阶段只依赖于前一阶段的状态
  • 区间DP:需要确定给定区间内所有可能状态的问题,并从较小区间向较大区间进行转移。

区间DP介绍:https://oi-wiki.org/dp/interval/

算法示例:LeetCode516. 最长回文子序列

给你一个字符串 s ,找出其中最长的回文子序列,并返回该序列的长度。
子序列定义为:不改变剩余字符顺序的情况下,删除某些字符或者不删除任何字符形成的一个序列。
在这里插入图片描述

法一:递归+记忆化搜索

递归定义:递归函数dfs(i,j),返回s[i:j+1]这个子串的最长回文子序列长度。
递归过程

  • 如果s[i]和s[j]相等,说明这两个字符可以成为回文子序列的一对,因此我们可以继续考虑子串s[i+1:j-1]所对应的最长回文子序列,再将长度加二。
  • 如果s[i]和s[j]不相等,那么必须从s[i+1:j]和s[i:j-1]两种情况中选择一个较长的回文子序列,作为s[i:j+1]子串的回文子序列。

边界条件:- 如果已经越界(即i>j),那么返;- 如果只有一个字符(即i==j),那么返回1
返回值:返回整个输入字符串s的最长回文子序列长度,即调用dfs(0, n-1)。

class Solution:
    def longestPalindromeSubseq(self, s: str) -> int:
        n=len(s)
        @cache
        def dfs(i:int,j:int):
            if i>j:
                return 0
            if i==j:
                return 1
            if s[i]==s[j]:
                return dfs(i+1,j-1)+2
            return max(dfs(i+1,j),dfs(i,j-1))
        return dfs(0,n-1)

法二:二维数组动态规划

根据递归转化为动态规划,不过本题更新dp[i][j]时,需要用到dp[i+1][j],此时dp[i+1][j]若未更新将导致结果错误;而倒序遍历,则可以保证用到的dp[i+1][j]已经是最新计算出来的值,因此我们倒序遍历i。

class Solution:
    def longestPalindromeSubseq(self, s: str) -> int:
        n=len(s)
        dp=[[0]*(n)for _ in range(n)]
        for i in range(n-1,-1,-1):
            dp[i][i]=1
            for j in range(i+1,n):
                if s[i]==s[j]:
                    dp[i][j]=dp[i+1][j-1]+2
                else:
                    dp[i][j]=max(dp[i+1][j],dp[i][j-1])
        return dp[0][n-1]

算法练习一:LeetCode1039. 多边形三角剖分的最低得分

你有一个凸的 n 边形,其每个顶点都有一个整数值。给定一个整数数组 values ,其中 values[i] 是第 i 个顶点的值(即 顺时针顺序 )。
假设将多边形 剖分 为 n - 2 个三角形。对于每个三角形,该三角形的值是顶点标记的乘积,三角剖分的分数是进行三角剖分后所有 n - 2 个三角形的值之和。
返回 多边形进行三角剖分后可以得到的最低分 。
算法套路十八——区间 DP_第1张图片

法一 :递归+记忆化搜索

算法套路十八——区间 DP_第2张图片算法套路十八——区间 DP_第3张图片

  • 递归函数定义:dfs,接收两个参数 i 和 j 表示当前处理的顶点范围从i到j,返回值是组成最小三角剖分的分值。

  • 状态转移方程:在这里插入图片描述

  • 边界值:在递归函数中,当当前处理的顶点范围只有一个或两个点,无法组成三角形时,直接返回 0;当前顶点范围有三个点时,可以直接计算出这三个点组成的三角形的得分返回。

  • 返回值:dfs(0,n-1)

class Solution:
    def minScoreTriangulation(self, values: List[int]) -> int:
        n=len(values)
        @cache
        def dfs(i:int,j:int)->int:
            # 如果两个顶点之间没有其他点,则不能组成三角形,分值为0
            if j-i<2:
                return 0
            # 如果两个顶点之间有两个其他点,则只有一种组合方式,直接计算返回分值
            if j-i==2:
                return values[i]*values[i+1]*values[j]
            score=inf
            # 第二步:枚举第k号顶点(i+1 <= k <= j-1),将第i、j号顶点和第k号顶点连边,
            # 分成"第i号顶点到第k号顶点"和"第k号顶点到第j号顶点"两部分,递归求解
            # 然后将两部分分值相加,并加上连接上第1号、k号和j号顶点的得分 values[i]*values[k]*values[j]
            for k in range(i+1,j):
                #k等于i+1时dfs(i,k)=0,k等于j-1时有dfs(k,j)=0
                score=min(score,dfs(i,k)+values[i]*values[k]*values[j]+dfs(k,j))
            return score
        return dfs(0,n-1)

法二:动态规划

直接利用上述递归思路进行转换,不过需要注意与示例一样,dp[i][j]更新时需要dp[k][j],而k是大于i的,所以在遍历i时需要倒序遍历

func minScoreTriangulation(values []int) int {
    n:=len(values)
    dp:=make([][]int,n)
    for i :=range dp{
        dp[i]=make([]int,n)
    }
    // 倒序枚举左端点,且由于三角形至少3个点,故左端点从n-3开始
    for i:=n-3;i>=0;i--{
        // 正序枚举右端点,且由于三角形至少3个点,右端点从i+2开始枚举
        for j:=i+2;j<n;j++{
            dp[i][j]=math.MaxInt
            // 枚举中间断点k从i+1到j-1
            for k:=i+1;k<j;k++{
                dp[i][j]=min(dp[i][j],dp[i][k]+dp[k][j]+values[i] * values[j] *values[k])
            }
        }
    }
    return dp[0][n-1]
}
func min(a,b int)int{if a>b{return b};return a}

算法练习二:LeetCode375. 猜数字大小 II

我们正在玩一个猜数游戏,游戏规则如下:
我从 1 到 n 之间选择一个数字。
你来猜我选了哪个数字。
如果你猜到正确的数字,就会 赢得游戏 。
如果你猜错了,那么我会告诉你,我选的数字比你的 更大或者更小 ,并且你需要继续猜数。
每当你猜了数字 x 并且猜错了的时候,你需要支付金额为 x 的现金。如果你花光了钱,就会 输掉游戏 。
给你一个特定的数字 n ,返回能够 确保你获胜 的最小现金数,不管我选择那个数字 。
算法套路十八——区间 DP_第4张图片

法一:递归+记忆化搜索

  • 递归函数定义:dfs(i, j) 表示选择在范围 [i, j] 中猜数时所需要的最小代价。

  • 递归方程:由于我们要找出一个代价最小的 x 来猜测结果,因此 dp[i][j] 可以通过以下方式转移而来:在 [i,j] 的所有可能猜测数中,选出一个数 k 进行猜测,根据猜测的结果将区间 [i,j] 分成两个子区间 [i,k-1] 和 [k+1,j],求出两个区间的最大值。其中,猜测数字 k 的代价可以表示为 k,由于要最小化代价,因此我们需要枚举所有可能的 k 值,然后选取其中的最小值。可能最小的最大值有点难以理解,但是最大值是为了确保能胜利,而最小值是因为我们可以根据不同的n选择每次不同的k值即选择最佳策略,能得到最小的确保胜利的金钱。递归方程如下所示
    d f s ( i , j ) = m i n ( k + m a x ( d f s ( i , k − 1 ) , d f s ( k + 1 , j ) ) ) ( i < = k < = j ) dfs(i, j) = min(k + max(dfs(i, k-1), dfs(k+1, j))) (i <= k <= j) dfs(i,j)=min(k+max(dfs(i,k1),dfs(k+1,j)))(i<=k<=j)

  • 边界值:在递归函数中,当处理区间只剩下 1 个数时,猜测次数为 0,返回 0;当处理区间只剩下 2 个数时,肯定最多只需要猜一次,猜较小的那个即可,返回小的数 i;

  • 返回值:dfs(1,n)返回 1 到 n 所需要的最小代价。

class Solution:
    def getMoneyAmount(self, n: int) -> int:
        # 缓存中间计算结果的记忆化递归函数
        @cache
        def dfs(i:int,j:int)->int:
            # 边界情况,当 i > j 时没有可猜的数,返回 0
            if i>=j:
                return 0
            # 边界情况,当只剩下两个数时,肯定最多只需要猜一次,猜较小的那个即可,返回小的数i
            if i==j+1:
                return i
            # 初始化当前区间的最小代价
            ans=inf
            # 枚举可能的猜测数字k,计算从 [i,j] 区间猜这个数字的代价
            for k in range(i,j+1):
                # 递归地计算两个子问题的最大代价,并求出当前 k 猜测的代价
                cost=k+max(dfs(i,k-1),dfs(k+1,j))
                # 取所有可能的代价中最小的那个作为当前区间的最小代价
                ans=min(ans,cost)
            return ans

        # 调用记忆化递归函数,返回从1到n所需要的最小代价
        return dfs(1,n)

法二:动态规划

直接利用上述递归思路进行转换,不过需要注意与示例一样,dp[i][j]更新时需要dp[k+1][j],而k是大于i的,所以在遍历i时需要倒序遍历

func getMoneyAmount(n int) int {
    dp:=make([][]int,n+2) // 初始化动态规划数组dp
    for i:=range dp{
        dp[i]=make([]int ,n+2)
    }
    for i:=n;i>0;i--{ // 倒序枚举i
        for j:=i+1;j<=n;j++{
            dp[i][j]=math.MaxInt // 初始化dp[i][j]为正无穷大
            for k:=i;k<=j;k++{ // 枚举[i,j]区间内的数k
                dp[i][j]=min(dp[i][j],k+max(dp[i][k-1],dp[k+1][j])
            }
        }
    } 
    return dp[1][n] 
}
func min(a,b int)int{if a>b{return b};return a} 
func max(a,b int)int{if a>b{return a};return b} 

算法练习三:LeetCode1312. 让字符串成为回文串的最少插入次数

给你一个字符串 s ,每一次操作你都可以在字符串的任意位置插入任意字符。
请你返回让 s 成为回文串的 最少操作次数 。「回文串」是正读和反读都相同的字符串。在这里插入图片描述

法一:递归+记忆化搜索

递归函数定义:dfs(i,j),其中i 和 j 表示当前递归处理的子串的左右两端的索引位置。

状态转移方程:分为两种情况。若当前子串两端字符相同,则需要插入次数与去掉两端后的子串相同。若当前子串两端字符不相同,则可以讨论在左端或右端插入一个字符,使得当前子串变成回文串。因此需要递归处理两种情况并取最小值。

边界值:当子串为空或只有一个字符时不需要插入;当子串只有两个字符时,若两端字符相等则不需要插入,否则需要插入一次。

返回值:dfs(0,n-1)

class Solution:
    def minInsertions(self, s: str) -> int:
        n=len(s)
        @cache  
        def dfs(i:int,j:int)->int:
            if i >= j:  # 当子串为空或只有一个字符时不需要插入
                return 0
            if i + 1 == j:  # 当子串只有两个字符时
                if s[i] == s[j]:  # 对称则不需要插入
                    return 0
                else:  # 不对称需要插入一次
                    return 1
            if s[i] == s[j]:  # 当子串两端字符相同,则递归处理去掉两端后的子串
                return dfs(i + 1, j - 1)
            else:  # 当子串两端不相同时,则用两种方式插入一次字符,取最小值
                return 1 + min(dfs(i, j - 1), dfs(i + 1, j))
        return dfs(0,n-1)

法二:动态规划

直接利用上述递归思路进行转换,不过需要注意与示例一样,dp[i][j]更新时需要dp[k+1][j],而k是大于i的,所以在遍历i时需要倒序遍历

func minInsertions(s string) int {
    n:=len(s)
    dp:=make([][]int,n+1)
    for i:=0;i<n;i++{
        dp[i]=make([]int,n+1)
    }
    // 自底向上计算dp数组
    for i:=n-1;i>=0;i--{
        dp[i][i]=0 // 只有一个字符时不需要插入
        for j:=i+1;j<n;j++{
            dp[i][j]=math.MaxInt // 初始化为最大值
            if s[i] == s[j]{
                dp[i][j]=dp[i+1][j-1] // 当两端字符相同时,去掉两端后的子串已经是回文串
            }else{
                dp[i][j]=1+min(dp[i][j-1],dp[i+1][j]) // 分别插入左右使得两端相同,取最小值
            }
        }
    }
    return dp[0][n-1]
func min(a,b int)int{if a>b{return b};return a}

算法进阶一:LeetCode1547. 切棍子的最小成本

给你一个整数数组 cuts ,其中 cuts[i] 表示你需要将棍子切开的位置。你可以按顺序完成切割,也可以根据需要更改切割的顺序。
每次切割的成本都是当前要切割的棍子的长度,切棍子的总成本是历次切割成本的总和。对棍子进行切割将会把一根木棍分成两根较小的木棍(这两根木棍的长度和就是切割前木棍的长度)。请参阅第一个示例以获得更直观的解释。
返回切棍子的 最小总成本 。
算法套路十八——区间 DP_第5张图片

法一:递归+记忆化搜索,不排序cuts直接遍历长度n

class Solution:
    def minCost(self, n: int, cuts: List[int]) -> int:
        # 定义递归函数,i和j表示当前区间的左右端点
        @cache
        def dfs(i:int,j:int)->int:
            # 边界条件:当区间长度小于等于1时,不需要再切割,返回0
            if i+1>=j:
                return 0
            res = inf 
            # 枚举所有可能的切割点
            for cut in cuts:
                if i < cut < j:
                    # 递归计算左右两个子区间的最小代价,并更新最小值
                    res = min(res, dfs(i, cut) + dfs(cut, j) )
            # 返回当前区间的最小代价,加上当前区间的长度
            return res + j - i if res != inf else 0
        # 调用递归函数,计算整个区间[0,n]的最小代价
        return dfs(0,n)

法二:递归+记忆化搜索,排序遍历cuts

class Solution:
    def minCost(self, n: int, cuts: List[int]) -> int:
    	cuts = [0] + sorted(cuts) + [n]
        @cache
        def dfs(i, j):  # (i, j)
            if i + 1 >= j:
                return 0
            res = inf 
            for k in range(i + 1, j):
                res = min(res, dfs(i, k) + dfs(k, j) + cuts[j] - cuts[i])
            return res
        return dfs(0, len(cuts) - 1)

法三:动态规划,排序遍历cuts

不知道为什么对于法一换为动态规划后有错误,只能对于法二进行动态规划转换

func minCost(n int, cuts []int) int {
    m := len(cuts)
    sort.Ints(cuts)
    cuts = append([]int{0}, cuts...)
    cuts = append(cuts, n)
    f := make([][]int, m+2)
    //[i][j] 表示在当前待切的木棍左端点为 cuts[i-1],右端点为 cuts[j+1] 时,将木棍全部切开的最小成本
    for i := range f {
        f[i] = make([]int, m+2)
    }
    for i := m; i >= 1; i-- {
        for j := i; j <= m; j++ {
            // 初始化 f[i][j] 的值为最大整数
            f[i][j] = math.MaxInt32
            // 枚举所有可能的切割点 k
            for k := i; k <= j; k++ {
                // 更新 f[i][j] 的值为左右两个子区间的最小代价加上当前区间的代价
                f[i][j] = min(f[i][j], f[i][k-1]+f[k+1][j])
            }
            f[i][j] += cuts[j+1] - cuts[i-1]
        }
    }
    return f[1][m]
}
func min(a,b int)int{if a>b{return b};return a}

算法练习五:LeetCode1000. 合并石头的最低成本

有 N 堆石头排成一排,第 i 堆中有 stones[i] 块石头。
每次移动(move)需要将连续的 K 堆石头合并为一堆,而这个移动的成本为这 K 堆石头的总数。
找出把所有石头合并成一堆的最低成本。如果不可能,返回 -1 。
算法套路十八——区间 DP_第6张图片

法一:递归+记忆化搜索

本题有一定的难度,首先对于数组nums想要求nums[i:j]的和,我们要想到使用前缀和,定义它的前缀和 s [ 0 ] = 0 , s [ i + 1 ] = ∑ j = 0 i stones [ j ] \textit{s}[0]=0,\textit{s}[i+1] = \sum\limits_{j=0}^{i}\textit{stones}[j] s[0]=0s[i+1]=j=0istones[j]

通过前缀和,我们可以把子数组的元素和转换成两个前缀和的差,即

∑ j = left right stones [ j ] = ∑ j = 0 right stones [ j ] − ∑ j = 0 left − 1 stones [ j ] = s [ right + 1 ] − s [ left ] \sum_{j=\textit{left}}^{\textit{right}}\textit{stones}[j] = \sum\limits_{j=0}^{\textit{right}}\textit{stones}[j] - \sum\limits_{j=0}^{\textit{left}-1}\textit{stones}[j] = \textit{s}[\textit{right}+1] - \textit{s}[\textit{left}] j=leftrightstones[j]=j=0rightstones[j]j=0left1stones[j]=s[right+1]s[left]

其次,也要考虑能否将数组合并成1堆,由于每次都会合并K堆为1堆,即每次减少k-1堆,原本n堆,最后剩余1堆,故可以通过判断n-1能否整除k-1判断是否能够为合并为1堆。

class Solution:
    def mergeStones(self, stones: List[int], k: int) -> int:
        n = len(stones)
        # 每次减少k-1堆,最后剩一堆,如果无法整除说明无法合并成一堆
        if (n - 1) % (k - 1):  
            return -1
        s = list(accumulate(stones, initial=0))  # 前缀和
        @cache  # 缓存装饰器,避免重复计算 dfs 的结果
        def dfs(i: int, j: int) -> int:
            if i == j:  # 只有一堆石头,无需合并
                return 0
            #m每次增加k-1,故每次dfs(i, m)与dfs(m + 1, j)都可以合并成一堆
            res = min(dfs(i, m) + dfs(m + 1, j) for m in range(i, j, k - 1))
            
            # 如果j-i是k-1的倍数,则一定可以合并成一堆
            # 说明从i到j所有元素都要移动,故加上从i到j的所有前缀和
            #如果j-i不是k-1的倍数,则说明当前不能合并为一堆,故不能将从i到j所有合并,需要留到后续添加
            if (j - i) % (k - 1) == 0:  
                res += s[j + 1] - s[i]
            return res
        return dfs(0, n - 1)

可能不太容易理解,就拿示例举例,dfs(0,4)首先m为i即m=0,故可分为dfs(0,0)+dfs(1,4),而dfs(1,4)可以分为dfs(1,1)+dfs(2,4)或者dfs(1,3)+dfs(4,4),其中最小的是dfs(1,3)+dfs(4,4)=8+0=8。此时回到dfs(0,4)中的res=dfs(0,0)+dfs(1,4)=0+8=8,之后由于 (j - i) % (k - 1) = 0,故说明能合并成一堆,从i到j所有的石头都会移动,故 res += s[5] - s[0]=8+17=25.

法二:动态规划

同理根据递归代码修改为二维数组DP,i倒序遍历

func mergeStones(stones []int, k int) int {
    n := len(stones)
    if (n-1)%(k-1) > 0 { // 无法合并成一堆
        return -1
    }
    //前缀和数组s
    s := make([]int, n+1)
    for i, x := range stones {
        s[i+1] = s[i] + x 
    }
    dp := make([][]int, n)
    for i := n - 1; i >= 0; i-- {
        dp[i] = make([]int, n)
        for j := i + 1; j < n; j++ {
            dp[i][j] = math.MaxInt
            for m := i; m < j; m += k - 1 {
                dp[i][j] = min(dp[i][j], dp[i][m]+dp[m+1][j])
            }
            if (j-i)%(k-1) == 0 { // 可以合并成一堆
                dp[i][j] += s[j+1] - s[i]
            }
        }
    }
    return dp[0][n-1]
}
func min(a, b int) int { if b < a { return b }; return a }

你可能感兴趣的:(#,算法套路,算法,深度优先,golang)