【图解算法数据结构】(二)动态规划

目录

序、递归和循环

一、剑指 Offer 10- I. 斐波那契数列

1.1 题求

1.2 求解

1.3 解说

二、剑指 Offer 10- II. 青蛙跳台阶问题

2.1 题求

2.2 求解

2.3 解答

三、剑指 Offer 19. 正则表达式匹配

3.1 题求

3.2 求解

3.3 解答

四、剑指 Offer 42. 连续子数组的最大和

4.1 题求

4.2 求解

4.3 解答

五、剑指 Offer 46. 把数字翻译成字符串

5.1 题求

5.2 求解

5.3 解答

六、剑指 Offer 47. 礼物的最大价值

6.1 题求

6.2 求解

6.3 解答

七、剑指 Offer 48. 最长不含重复字符的子字符串

7.1 题求

7.2 求解

7.3 解答

八、剑指 Offer 49. 丑数

8.1 题求

8.2 求解

8.3 解答

九、剑指 Offer 60. n 个骰子的点数

9.1 题求

9.2 求解

9.3 解答

十、剑指 Offer 63. 股票的最大利润

10.1 题求

10.2 求解

10.3 解答


序、递归和循环


一、剑指 Offer 10- I. 斐波那契数列

1.1 题求

1.2 求解

法一:无记忆递归

Python - 2021/5/11 - 超出时间限制

# Python
class Solution:
    def fib(self, n: int) -> int:
        def recur(n: int):
            if n < 2:
                return n
            return recur(n-1) + recur(n-2)
        return recur(n) % 1000000007

法二有记忆递归 (自顶向下)

使用哈希映射 (字典) 来记忆计算过的结果,避免重复冗余的计算。

Python - 2021/5/11 - 82.25% (36ms)

# Python
class Solution:
    def fib(self, n: int) -> int: 
        hashtable = {0: 0, 1: 1}  # 记录所有以计算的结果
        
        def recur(n: int):
            if hashtable.get(n) is None:
                hashtable[n] = recur(n-1) + recur(n-2)  # 无记录就计算, 有则直接取出
            return hashtable[n]
        
        return recur(n) % 1000000007

C++ - 2021/5/11 - 100.00% (0ms) - 7.51% (6.2MB)

注 1:对 C++ map 使用下标索引,若 key 不在容器中,则 会添加一个具有此 key 的元素到 map 中,这与常见的顺序容器因下标不存在而报错的行为很不同。

注 2:若仅对最后的结果取模,则在过程中会存在 整型数溢出 问题,因此改为每次计算都取模。

class Solution {
private:
    int mode = 1000000007;
    map<int, int> fib_memo = {{0, 0}, {1, 1}};  // 使用关联容器 map 记录已计算过的值
    
    int recur(int n)
    {
        if (fib_memo.find(n) == fib_memo.end())  // 若尚未记录
        {
            fib_memo[n] = (recur(n-1) + recur(n-2)) % mode;  // 计算并记录
        }
        return fib_memo[n];
    }
    
public:
    int fib(int n) 
    {
        return recur(n);
    }
};

法三有记忆递归 (自底向上)

使用哈希映射 (字典) 来记忆计算过的结果,避免重复冗余的计算。

Python - 2021/5/11 - 82.25% (36ms)

# Python
class Solution:
    def fib(self, n: int) -> int:
        if n < 2:
            return n

        hashtable = {0: 0, 1: 1}
        
        for i in range(2, n+1):
            hashtable[i] = hashtable[i-1] + hashtable[i-2]
        
        return hashtable[n] % 1000000007

C++ - 2021/5/11 - 100.00% (0ms) - 13.2% (6.1MB)

// C++
class Solution {
public:
    int fib(int n) 
    {
        if (n < 2) 
        {
            return n;
        }
        
        int mode = 1000000007;
        map<int, int> fib_memo = {{0, 0}, {1, 1}};
        
        for (int i = 2; i <= n; ++i)
        {
            fib_memo[i] = (fib_memo[i-1] + fib_memo[i-2]) % mode;
        }
  
        return fib_memo[n];
    }
};

官方解答 (重点:动态规划)

# Python - 推荐
class Solution:
    def fib(self, n: int) -> int:
        a, b = 0, 1  # 初始状态
        for _ in range(n):  # n 为几, 就转移几次状态, 从而得到第 n 个状态
            a, b = b, (a + b) % 1000000007  # 状态转移
        return a

# Python
# 不考虑大数越界问题
class Solution:
    def fib(self, n: int) -> int:
        a, b = 0, 1
        for _ in range(n):
            a, b = b, a + b
        return a % 1000000007  # 仅在最后取模

 C++ - 2021/5/11 - 100.00% (0ms) - 67.66% (5.8MB)

// C++ - 推荐
class Solution {
public:
    int fib(int n) 
    {
        // 初始状态 + 中间状态
        int a = 0, b = 1, sum;
        for (int i = 0; i < n; ++i)
        {
            // 状态转移
            sum = (a + b) % 1000000007;
            a = b;
            b = sum;
        }
        return a;
    }
};

## 这就牛掰了, 开始使用数学思想了, 所以更有些费解, 但很值得学习!
class Solution:
    def fib(self, N: int) -> int:
        if (N <= 1):
            return N
 
        A = [[1, 1], [1, 0]]  # 中间幂矩阵的基数矩阵
        self.matrix_power(A, N-1)  # 使用递归函数 matrixPower 计算给定矩阵 A 的幂。幂为 N-1
        return A[0][0]
 
    def matrix_power(self, A: list, N: int):
        if (N <= 1):
            return A
 
        self.matrix_power(A, N//2)  # matrixPower 函数将对 N/2 个斐波那契数进行操作!
        self.multiply(A, A)  # 矩阵乘法
        B = [[1, 1], [1, 0]]
 
        if (N%2 != 0):  # 奇数次补乘 如 3, 因为对于偶数次幂 N=2x 而奇数次幂 N=2x+1
            self.multiply(A, B)
 
    def multiply(self, A: list, B: list):
        x = A[0][0] * B[0][0] + A[0][1] * B[1][0]
        y = A[0][0] * B[0][1] + A[0][1] * B[1][1]
        z = A[1][0] * B[0][0] + A[1][1] * B[1][0]
        w = A[1][0] * B[0][1] + A[1][1] * B[1][1]
 
        A[0][0] = x
        A[0][1] = y
        A[1][0] = z
        A[1][1] = w

# 这是什么神仙操作 ...
class Solution:
  def fib(self, N):
  	golden_ratio = (1 + 5 ** 0.5) / 2
  	return int((golden_ratio ** N + 1) / 5 ** 0.5)

其他实现 

若此时要求 通过递归返回斐波那契数列的前 N 项,则除了有记忆递归,还可以:

 LRU cache 缓存装饰器 (decorator):根据参数缓存每次函数调用结果,对于相同参数的,无需重新函数计算,直接返回之前缓存的返回值。缓存数据是并不一定快,要综合考量缓存的代价以及缓存的时效大小。经典例子是改造 fib 序列。

  • 若 maxsize=None,则禁用 LRU 功能,且缓存可无限制增长;当 maxsize=2^x 时,LRU 功能执行得最好;
  •  typed=True,则 不同类型的函数参数将单独缓存。例如,f(2) 和 f(2.0) 将被视为具有不同结果的不同调用;
  • 缓存是有内存存储空间限制的;
from functools import lru_cache
class Solution:
    @lru_cache
    def fib(self, N: int) -> int:
        if N <= 1:
            return N
        return self.fib(N-1) + self.fib(N-2)

1.3 解说

参考文献:

《剑指 Offer 第二版》

https://leetcode-cn.com/leetbook/read/illustration-of-algorithm/50fxu1/

https://blog.csdn.net/qq_39478403/article/details/107468507

https://leetcode-cn.com/leetbook/read/illustration-of-algorithm/50fji7/


二、剑指 Offer 10- II. 青蛙跳台阶问题

2.1 题求

2.2 求解

# Python
# 不考虑大数越界问题
class Solution:
    def numWays(self, n: int) -> int:
        a, b = 1, 1  # 与斐波那契数列问题的唯一区别是起始值不同
        for _ in range(n):
            a, b = b, a + b
        return a % 1000000007
// C++
class Solution {
public:
    int numWays(int n) 
    {
        int a = 1, b = 1, sum;
        for(int i = 0; i < n; i++)
        {
            sum = (a + b) % 1000000007;
            a = b;
            b = sum;
        }
        return a;
    }
};

2.3 解答

 参考文献:

《剑指 Offer 第二版》

https://leetcode-cn.com/leetbook/read/illustration-of-algorithm/57hyl5/

https://leetcode-cn.com/leetbook/read/illustration-of-algorithm/57xs06/

https://leetcode-cn.com/problems/climbing-stairs/


三、剑指 Offer 19. 正则表达式匹配

3.1 题求

3.2 求解

法一:动态规划

  • 时间复杂度 O(mn),其中 mn 分别是字符串 s 和 p 的长度。需要计算出所有的状态,且每个状态在进行转移时的时间复杂度为 O(1)
  • 空间复杂度 O(mn),即为存储所有状态使用的空间。

Python - 2021/6/21 - 77.43% (56ms) - 该解答及其解释比官方解答清晰易懂得多

class Solution:
    def isMatch(self, s: str, p: str) -> bool:
       
        # 两个字符串的长度
        len_s = len(s)
        len_p = len(p)
        
        # dp table
        # dp[i][j] 表示 s 的前 i 个字符和 p 的前 j 个字符是否匹配
        #          换言之, s[0:i] 和 p[0:j] 是否匹配
        dp = [[False] * (len_p+1) for _ in range(len_s+1)]
        
        # base case
        # 两个空字符串什么也不用做就能匹配
        dp[0][0] = True
        # 若当前 p 的字符 p[j-1]='*', 则 dp[0][k] 取决于 dp[0][j-2]
        # 因为可以令 p[j-1]='*' 对 p[j-2] 的字符重复 0 次,
        # 使得 dp[0][j] 的结果只取决于 p[j-3] 是否与当前 s 的字符匹配
        for j in range(1, len_p+1):
            if p[j-1] == '*':
                dp[0][j] = dp[0][j-2]
                
        # 状态转移
        # 注意索引偏移问题, s 和 p 的第 i 和 j 个字符分别为 s[i-1] 和 p[j-1]
        for i in range(1, len_s+1):
            for j in range(1, len_p+1):
                # 若 p 的当前字符 p[j-1] = s[i-1] or '.'
                # 则 dp[i][j] 的结果仅取决于 dp[i-1][j-1]
                # 若 dp[i-1][j-1]=True, 则 dp[i][j]=True
                # 若 dp[i-1][j-1]=False, 则 p[j-1] 即使匹配也没用, 仍有 dp[i-1][j-1]=False
                if p[j-1] in {s[i-1], '.'}:
                    dp[i][j] = dp[i-1][j-1]
                    
                # 若 p 的当前字符 p[j-1] = '*', 则结果取决于前一个字符 p[j-2] 
                # 若前一个字符 p[j-2] = s[i-1] or '.', 则有两个匹配可能
                # 否则, 不匹配, 令 p[j-1] = '*' 对 p[j-2] 重复 0 次, 
                # 结果取决于 p[j-3] 与 s[i-1] 的匹配结果 dp[i][j-2]
                elif p[j-1] == '*':
                    if p[j-2] in {s[i-1], '.'}:
                        dp[i][j] = dp[i-1][j] or dp[i][j-2] 
                    else:
                        dp[i][j] = dp[i][j-2]
                    
        return dp[len_s][len_p]

    
'''    
以一个例子详解上述动态规划转移方程 :

                            S = abbbbc
                            P = ab*d*c

1. 当前第 i, j 个字符 s[i-1] 和 p[j-1] 均为字母(或 '.' 可以看成一个特殊的字母)时,
   只需判断对应位置的字符 s[i-1] 和 p[j-1] 的相等关系
   若相等,只需判断 s[i-1] 和 p[j-1] 之前的字符串是否匹配即可,转化为子问题 dp[i-1][j-1].
   若不等,则当前的 s[i-1] 和 p[j-1] 肯定不能匹配,为 dp[i][j] = false.
   
                      dp[i-1][j-1]  i-1
                            |        |
                   S [a  b  b  b  b][c] 
                                            子问题:dp[i-1][j-1]
                   P [a  b  *  d  *][c]
                                     |
                                    j-1
   

2. 若当前第 j 个字符 p[j-1] = '*',则不妨把类似 'a*', 'b*' 等的当成整体看待,
   此时为子问题 dp[i][j]

                           i-1
                            |
                   S  a  b [b] b  b  c  
                                            子问题:dp[i][j]   
                   P  a [b  *] d  *  c
                            |
                           j-1
   
   
   当 'b*' 匹配完 'b' 后,它仍可继续发挥作用,因此可把 i-1 前移 1 位到 i-2,而不丢弃 'b*', 
   转化为子问题 dp[i-1][j]:
   
                        i-2
                         | <--
                   S  a [b] b  b  b  c  
                                            子问题:dp[i-1][j]
                   P  a [b  *] d  *  c
                            |
                           j-1
   
   
   另外,也可让 'b*' 不再进行匹配,把 j-1 前移 2 位到 j-3, 从而丢弃 'b*' 
   转化为子问题 dp[i][j-2]:

                           i-1
                            |
                   S  a  b [b] b  b  c  
                                            子问题:dp[i][j-2]    
                   P [a] b  *  d  *  c
                      | <--
                     j-3 

3. 冗余的状态转移不会影响答案,
   因为当 j-2 指向 'b*' 中的 'b' 时, 这个状态对于答案是没有用的,
   原因参见评论区 稳中求胜 的解释, 当 j-1 指向 '*' 时,
   dp[i][j] 只与 dp[i][j-2]有关, 跳过了 dp[i][j-1].
'''

# 重要图解:https://leetcode-cn.com/problems/regular-expression-matching/solution/zheng-ze-biao-da-shi-pi-pei-by-leetcode-solution/452279

3.3 解答

参考文献:

《剑指 Offer 第二版》

https://leetcode-cn.com/leetbook/read/illustration-of-algorithm/9a1ypc/

https://leetcode-cn.com/problems/regular-expression-matching/


四、剑指 Offer 42. 连续子数组的最大和

4.1 题求

4.2 求解

法一:动态规划 - 一维 dp table

  • 空间复杂度 O(n)
  • 时间复杂度 O(n)
class Solution:
    def maxSubArray(self, nums: List[int]) -> int:
        n = len(nums)
        # dp[i] 表示 num[:i] 中最大的连续子数组之和
        # 换言之,代表以元素 nums[i 为结尾的连续子数组最大和
        dp = [0 for _ in range(n+1)]  
        max_sum = nums[0]  # 注意要使用一个 max_sum 变量维护过程中遇到的最大和

        # 状态转移
        for i in range(1, n+1):
            cur_num = nums[i-1]
            dp[i] = max(dp[i-1] + cur_num, cur_num)
            max_sum = max(max_sum, dp[i])

        return max_sum

法二:动态规划 - 滚动数组

  • 空间复杂度 O(1)
  • 时间复杂度 O(n)
class Solution:
    def maxSubArray(self, nums: List[int]) -> int:
        dp_i = 0  # 由于 dp[i] 只与 dp[i-1] 有关,使用一个状态变量维护即可
        max_sum = nums[0]  # 注意要使用一个 max_sum 变量维护过程中遇到的最大和

        for i in range(len(nums)):
            # dp_i = (dp_i + nums[i]) if dp_i > 0 else nums[i]
            dp_i = max(dp_i+nums[i], nums[i])  
            max_sum = max(max_sum, dp_i)

        return max_sum

官方说明

4.3 解答

 参考文献:

《剑指 Offer 第二版》

https://leetcode-cn.com/leetbook/read/illustration-of-algorithm/59gq9c/

https://leetcode-cn.com/leetbook/read/illustration-of-algorithm/59h9mr/


五、剑指 Offer 46. 把数字翻译成字符串

5.1 题求

5.2 求解

法一:动态规划 - 1-D dp table

  • 空间复杂度 O(n)
  • 时间复杂度 O(n)
class Solution:
    def translateNum(self, num: int) -> int:
        nums = str(num)
        n = len(nums)
        if n < 2:
            return n
        
        # dp[i] 表示 num[:i] 共有几种翻译方法
        dp = [0 for _ in range(n+1)]
        # 初始状态
        dp[0] = 1  # 没有数字也是一种翻译
        dp[1] = 1  # 首个数字只能单独翻译

        for i in range(2, n+1):
            prev_num = nums[i-2]  # 先前数字
            curr_num = nums[i-1]  # 当前数字

            # 必然可令当前数字独立翻译
            dp[i] = dp[i-1]

            # 若满足组成 2 位数的条件, 则当前数字还可与先前数字组合翻译 (注意合法性条件一定要写对)
            if (prev_num in {'1' ,'2'}) and (eval(prev_num + curr_num) < 26):
                dp[i] += dp[i-2]
            
        return dp[n]

法二:动态规划 - 滚动数组 / dp table 降维

  • 空间复杂度 O(1)
  • 时间复杂度 O(n)
class Solution:
    def translateNum(self, num: int) -> int:
        nums = str(num)
        n = len(nums)
        if n < 2:
            return n

        dp_0 = 1  # 没有数字也是一种翻译
        dp_1 = 1  # 首个数字只能单独翻译
        dp_2 = 0  # 当前状态 - 最新状态
        
        for i in range(2, n+1):
            prev_num = nums[i-2]  # 先前数字
            curr_num = nums[i-1]  # 当前数字

            # 必然可令当前数字独立翻译
            dp_2 = dp_1

            # 若满足组成 2 位数的条件, 则当前数字还可与先前数字组合翻译 (注意合法性条件一定要写对)
            if (prev_num in {'1' ,'2'}) and (eval(prev_num + curr_num) < 26):
                dp_2 += dp_0
            
            # 先前状态更新 - 状态后移
            dp_0 = dp_1
            dp_1 = dp_2
            
        return dp_2

官方说明

class Solution:
    def translateNum(self, num: int) -> int:
        s = str(num)
        a = b = 1
        for i in range(2, len(s) + 1):
            tmp = s[i - 2:i]
            c = a + b if "10" <= tmp <= "25" else a
            b = a
            a = c
        return a

class Solution:
    def translateNum(self, num: int) -> int:
        a = b = 1
        y = num % 10
        while num > 9:
            num //= 10
            x = num % 10
            a, b = (a + b if 10 <= 10 * x + y <= 25 else a), a
            y = x
        return a

5.3 解答

 参考文献:

《剑指 Offer 第二版》

https://leetcode-cn.com/leetbook/read/illustration-of-algorithm/99wd55/

https://leetcode-cn.com/leetbook/read/illustration-of-algorithm/99dnh6/


六、剑指 Offer 47. 礼物的最大价值

6.1 题求

6.2 求解

法一:动态规划 - 2-D dp table - 自底向上

  • 空间复杂度 O(mn)
  • 时间复杂度 O(mn)
class Solution:
    def maxValue(self, grid: List[List[int]]) -> int:
        
        m = len(grid)  # 行数
        n = len(grid[0])  # 列数
        
        # dp[i][j] 表示在 i 行 j 列可得到的最多价值的礼物
        dp = [[0 for _ in range(n+1)] for _ in range(m+1)]
        
        # 状态转移 - 自底向上
        for i in range(1, m+1):
            for j in range(1, n+1):
                # 选出当前位置 上侧和左侧 最大的一个 与当前位置求和 作为状态
                dp[i][j] = max(dp[i-1][j], dp[i][j-1]) + grid[i-1][j-1]
                
        return dp[m][n]

法二:动态规划 - 2D- dp table 降维 1-D - 每次仅保留相邻列 (或行) 的状态

  • 空间复杂度 O(min(m, n))
  • 时间复杂度 O(mn)
class Solution:
    def maxValue(self, grid: List[List[int]]) -> int:
        
        m = len(grid)  # 行数
        n = len(grid[0])  # 列数
        
        # dp[i] 表示在 i 行可得到的最多价值的礼物
        # 由于从 2-D 降维 1-D 每次仅保存相邻 1 列的状态
        # 整体空间复杂度降低了, 但时间复杂度不会有任何改善
        dp = [0 for _ in range(m+1)]  # 多整一个第 0 行和第 0 列简化判断
 
        # 状态转移
        for j in range(1, n+1):      # 0 ~ n 列 共 n+1 列
            for i in range(1, m+1):  # 0 ~ m 行 共 m+1 行
                dp[i] = max(dp[i-1], dp[i]) + grid[i-1][j-1]
                    
        return dp[m]

官方说明

class Solution:
    def maxValue(self, grid: List[List[int]]) -> int:

        for i in range(len(grid)):
            for j in range(len(grid[0])):
                if i == 0 and j == 0: 
                    continue
                if i == 0:
                    grid[i][j] += grid[i][j - 1]
                elif j == 0: 
                    grid[i][j] += grid[i - 1][j]
                else: 
                    grid[i][j] += max(grid[i][j - 1], grid[i - 1][j])

        return grid[-1][-1]

class Solution:
    def maxValue(self, grid: List[List[int]]) -> int:

        m, n = len(grid), len(grid[0])

        for j in range(1, n): # 初始化第一行
            grid[0][j] += grid[0][j - 1]

        for i in range(1, m): # 初始化第一列
            grid[i][0] += grid[i - 1][0]

        for i in range(1, m):
            for j in range(1, n):
                grid[i][j] += max(grid[i][j - 1], grid[i - 1][j])

        return grid[-1][-1]

6.3 解答

  参考文献:

《剑指 Offer 第二版》

https://leetcode-cn.com/leetbook/read/illustration-of-algorithm/5vokvr/

https://leetcode-cn.com/leetbook/read/illustration-of-algorithm/5vr32s/


七、剑指 Offer 48. 最长不含重复字符的子字符串

7.1 题求

7.2 求解

法一:双指针-滑动窗口 - 使用 dict 维护窗口

  • 空间复杂度 O(1)
  • 时间复杂度 O(n)
class Solution:
    def lengthOfLongestSubstring(self, s: str) -> int:
        if len(s) < 2:
            return len(s)

        left, right = 0, 0  # # 左指针-指向旧字符, 右指针-指向新字符 [left, right)
        char_win = {}       # 当前滑窗内的字符计数字典
        max_len = 0         # 维护滑窗过程中的最大不重复字符串长度
        
        # 遍历右指针
        while right < len(s):
            # 右指针右移
            new_char = s[right]
            right += 1
            
            # 增加新字符
            if char_win.get(new_char) is None:
                char_win[new_char] = 0
            char_win[new_char] += 1
            
            # 一旦存在重复, right 便停止右移动
            while char_win[new_char] > 1:
                # 左指针右移
                old_char = s[left]
                left += 1
                
                # 移除旧字符
                char_win[old_char] = max(char_win[old_char]-1, 0)

            # 若不存在重复, 更新最大长度
            max_len = max(max_len, right-left) 
                
        return max_len

法二: 双指针-滑动窗口 - 使用 set 维护窗口

  • 空间复杂度 O(1)
  • 时间复杂度 O(n)
class Solution:
    def lengthOfLongestSubstring(self, s: str) -> int:       

        left, right = 0, 0  # 左指针-指向旧字符, 右指针-指向新字符 [left, right)
        char_win = set()    # 当前滑窗内的字符计数字典
        max_len = 0         # 维护滑窗过程中的最大不重复字符串长度
        
        # 遍历右指针
        while right < len(s):
            # 右指针右移
            new_char = s[right]
            right += 1
     
            # 一旦存在重复, right 便停止右移动, 删除旧字符至不重复
            while new_char in char_win:
                # 左指针右移
                old_char = s[left]
                left += 1
                # 删除旧字符
                char_win.remove(old_char)

            # 加入新字符
            char_win.add(new_char)    
                
            # 此时必已不存在重复, 更新最大长度
            max_len = max(max_len, len(char_win)) 
                
        return max_len

法三:双指针-滑动窗口-最简化版 (效率最佳)

  • 空间复杂度 O(1)
  • 时间复杂度 O(n)
class Solution:
    def lengthOfLongestSubstring(self, s: str) -> int:
        char_win = {}  # 当前滑窗内最右字符索引记录
        max_len = 0    # 最大长度
        left = -1      # 左指针 - 无重复的最大左边界
        
        # 遍历字符串 s
        for right in range(len(s)):
            # 右指针指向当前字符 cur_char
            cur_char = s[right]
            
            # 若当前字符 cur_char 已存在于滑窗 char_win 中, 更新最大左指针 left
            if cur_char in char_win:
                left = max(char_win[cur_char], left) 
                
            # 更新当前字符 cur_char 的最大 index 为 right 
            char_win[cur_char] = right 
            
            # 更新最大长度 max_len
            max_len = max(max_len, right-left) 
            
        return max_len

法四:动态规划-哈希表

  • 空间复杂度 O(1)
  • 时间复杂度 O(n)
class Solution:
    def lengthOfLongestSubstring(self, s: str) -> int:
        char_win = {}  # 滑窗内字符索引字典
        cur_len = 0    # 当前长度
        max_len = 0    # 最大长度

        # 遍历字符串
        for right in range(len(s)):
            # 获取与 s[right] 最近的相同字符的索引 left, 否则返回 -1
            left = char_win.get(s[right], -1)  

            # 更新哈希表, 使用 right 覆盖 s[right] 的索引作为当前最新索引
            char_win[s[right]] = right  

            # 更新当前长度 dp[right - 1] -> dp[right] (分两种情况)
            d = right - left  # d 与 f(i-1) 存在两种大小关系 (详见 7.3 解答)
            cur_len = cur_len + 1 if cur_len < d else d 

            # 更新最大长度 max(dp[right - 1], dp[right])
            max_len = max(max_len, cur_len)  
            
        return max_len

官方说明

class Solution:
    def lengthOfLongestSubstring(self, s: str) -> int:
        dic = {}
        res = tmp = 0
        for j in range(len(s)):
            i = dic.get(s[j], -1)  # 获取与 s[j] 最近的相同字符的索引 i, 否则返回 -1
            dic[s[j]] = j  # 更新哈希表, 使用 j 覆盖 s[j] 的索引作为当前最新索引
            tmp = tmp + 1 if tmp < j - i else j - i  # 更新当前长度 dp[j - 1] -> dp[j]
            res = max(res, tmp)  # 更新最大长度 max(dp[j - 1], dp[j])
        return res

class Solution:
    def lengthOfLongestSubstring(self, s: str) -> int:
        res = tmp = i = 0
        for j in range(len(s)):
            i = j - 1
            while i >= 0 and s[i] != s[j]: i -= 1  # 线性查找 i
            tmp = tmp + 1 if tmp < j - i else j - i  # dp[j - 1] -> dp[j]
            res = max(res, tmp)  # max(dp[j - 1], dp[j])
        return res

class Solution:
    def lengthOfLongestSubstring(self, s: str) -> int:
        dic, res, i = {}, 0, -1
        for j in range(len(s)):
            if s[j] in dic:
                i = max(dic[s[j]], i)  # 更新左指针 i
            dic[s[j]] = j  # 哈希表记录
            res = max(res, j - i)  # 更新结果
        return res

7.3 解答

 参考文献:

《剑指 Offer 第二版》

https://leetcode-cn.com/leetbook/read/illustration-of-algorithm/5dgr0c/

https://leetcode-cn.com/leetbook/read/illustration-of-algorithm/5dz9di/


八、剑指 Offer 49. 丑数

8.1 题求

8.2 求解

官方说明

class Solution:
    def nthUglyNumber(self, n: int) -> int:
        ### https://leetcode-cn.com/leetbook/read/illustration-of-algorithm/9hq0r6/
        ### 丑数的递推性质:丑数只包含因子 2, 3, 5 ,因此有 “丑数 == 某较小丑数 × 某因子”
        
        # dp[i] 代表第 i + 1 个丑数
        dp = [1] * n
        # 辅助指针 a, b, c 指向首个丑数, 根据递推公式得下个丑数,并每轮将对应指针 +1
        a, b, c = 0, 0, 0
        # 状态转移
        for i in range(1, n):
            # dp[a], dp[b], dp[c] 为首个乘 2, 3, 5 后大于 dp[i] 的数
            n2, n3, n5 = dp[a] * 2, dp[b] * 3, dp[c] * 5
            # dp[i] 是三种情况中的最小值
            dp[i] = min(n2, n3, n5)
            # 对应 dp[i] 的指针 +1
            # 为什么要 +1?因为同一个 [丑数*因子] 的组合用过之后就不能再用了!!!!!
            if dp[i] == n2: 
                a += 1
            if dp[i] == n3: 
                b += 1
            if dp[i] == n5: 
                c += 1
                
        return dp[-1]

网上解法

# 99.88% - 预计算法
class Ugly:
    def __init__(self):
        self.dp = [1]  # 作为类属性的 dp 数组
        a = b = c = 0  # 辅助指针
     
        ### 提前计算好所有结果, 然后查表!!!
        for i in range(1, 1690):
            n2, n3, n5 = self.dp[a] * 2, self.dp[b] * 3, self.dp[c] * 5
            ugly = min(n2, n3, n5)
            self.dp.append(ugly)
            if ugly == n2: 
                a += 1
            if ugly == n3: 
                b += 1
            if ugly == n5: 
                c += 1

class Solution:
    u = Ugly()
    def nthUglyNumber(self, n):
        return self.u.dp[n-1]

8.3 解答

 参考文献:

《剑指 Offer 第二版》

https://leetcode-cn.com/leetbook/read/illustration-of-algorithm/9h3im5/

https://leetcode-cn.com/leetbook/read/illustration-of-algorithm/9hq0r6/

https://leetcode-cn.com/submissions/detail/193153631/


九、剑指 Offer 60. n 个骰子的点数

9.1 题求

9.2 求解

法一:动态规划 - 2D - dp table

  • 空间复杂度 O(n^2)
  • 时间复杂度 O(n)
### 20ms - 100.00% - 逆向遍历

class Solution:
    def dicesProbability(self, n: int) -> List[float]:
        # n 个骰子 共有 6^n 种组合 (有重复)
        # n 个骰子 最小值 n*1, 最大值 n*6, 有 n*(6-1) + 1 种 (不重复)

        num_max = 6 * n   # 骰子和 最大值 (每个骰子取 6 之和即为最小值 6n)
        num_min = n       # 骰子和 最小值 (每个骰子取 1 之和即为最小值 n)
        num_kind = num_max - num_min + 1  # 骰子和 种类数 (最大值 - 最小值 + 1 = 6*n - 5*n + 1)
        num_sum = 6 ** n  # 骰子 组合总数 (如 1 个 6 种、2 个 6**2 = 36 种、...)
        
        # dp[i][j]: 投出第 i 个骰子时累积和中第 j 小数字的频数
        dp = [[0 for _ in range((n*6+1))] for _ in range(n+1)]  
        # 初始状态 - 哑节点 - 没投也是一种组合
        dp[0][0] = 1

        # 投 1 ~ n 个骰子 (无偏移)
        for i in range(1, n + 1):
    
            # 中值 - 山峰数组峰顶(左、中)
            end = 7 * i 
            mid = int(end // 2)
    
            # 第 i 次骰子的和涉及的数值范围前半段 (无偏移)
            for j in range(i, mid+1):   # 2 ~ 7
                
                # 与当前之和 j 相关的、上一轮的前 num 个数值
                num = min(7, j-i+2)  # 至多与前 6 个有关
                for k in range(1, num):  
                    # i-1 表示上一个骰子, j-k 表示前 k 个
                    dp[i][j] += dp[i-1][j-k] 

                # 第 i 次骰子的和涉及的数值范围后半段 (无偏移)   
                dp[i][end - j] = dp[i][j]  # 对称 - 直接复制

        # 组合频率 = 组合频数 / 组合总数
        return [dp[n][n+i] / num_sum for i in range(num_kind)]

法二:动态规划 - 1D - dp table

  • 空间复杂度 O(n)
  • 时间复杂度 O(n)
# 降维, 每次仅保存相邻的两个状态
class Solution:
    def dicesProbability(self, n: int) -> List[float]:

        num_max = 6 * n   # 骰子和 最大值 (每个骰子取 6 之和即为最小值 6n)
        num_min = n       # 骰子和 最小值 (每个骰子取 1 之和即为最小值 n)
        num_kind = num_max - num_min + 1  # 骰子和 种类数 (最大值 - 最小值 + 1 = 6*n - 5*n + 1)
        num_sum = 6 ** n  # 骰子 组合总数 (如 1 个 6 种、2 个 6**2 = 36 种、...)
        
        # dp[i][j]: 投出第 i 个骰子时累积和中第 j 小数字的频数
        dp = [0 for _ in range((n*6+1))] 
        # 初始状态 - 哑节点 - 没投也是一种组合
        dp[0] = 1

        # 投 1 ~ n 个骰子 (无偏移)
        for i in range(1, n + 1):
            tmp = [0 for _ in range((n*6+1))]  # 临时保存当前 dp 数组
            # 中值 - 山峰数组峰顶(左、中)
            end = 7 * i 
            mid = int(end // 2)
    
            # 第 i 次骰子的和涉及的数值范围前半段 (无偏移)
            for j in range(i, mid+1):   # 2 ~ 7
                
                # 与当前之和 j 相关的、上一轮的前 num 个数值
                num = min(7, j-i+2)  # 至多与前 6 个有关
                for k in range(1, num):  
                    # i-1 表示上一个骰子, j-k 表示前 k 个
                    tmp[j] += dp[j-k] 

                # 第 i 次骰子的和涉及的数值范围后半段 (无偏移)   
                tmp[end - j] = tmp[j]  # 对称 - 直接复制
            dp = tmp  # dp 数组 更新
                
        # 组合频率 = 组合频数 / 组合总数
        return [dp[n+i] / num_sum for i in range(num_kind)]

官方说明

### 28ms - 98.99% - 正向遍历(非常简洁推荐)
class Solution:
    def dicesProbability(self, n: int) -> List[float]:
        # dp 数组:第 1 次骰子概率均等
        dp = [1.0 / 6.0] * 6
        # 余 2 ~ n 次骰子
        for i in range(2, n + 1):
            # 当前可能产生结果数 num_kind
            tmp = [0] * (5*i + 1)  
            # 根据上一次结果正向遍历
            for j in range(len(dp)):
                # 每个上一次结果 影响 当前相邻 6 个结果
                for k in range(6):
                    tmp[j + k] += dp[j] / 6
            # 更新 dp 数组
            dp = tmp
        return dp

9.3 解答

 参考文献:

《剑指 Offer 第二版》

https://leetcode-cn.com/leetbook/read/illustration-of-algorithm/ozzl1r/

https://leetcode-cn.com/leetbook/read/illustration-of-algorithm/ozsdss/


十、剑指 Offer 63. 股票的最大利润

10.1 题求

10.2 求解

法一:动态规划 - 3-D dp table

  • 空间复杂度 O(n)
  • 时间复杂度 O(n)
# 动态规划 - 基本版
class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        
        # 总交易天数
        n = len(prices)
        
        # dp[i][j][k] 表示第 i 天、可交易次数为 j、股票持有状态为 k 状态下的最大利润
        # 以 0 初始化省去很多细节
        dp = [[[0 for k in range(2)] for j in range(2)] for i in range(n+1)]  
        
        # 初始状态
        dp[0][0][1] = float('-inf')  # 第 0 天, 还没交易不可能持有股票
        for i in range(n+1):
            dp[i][1][1] = float('-inf')  # 第 i 天, 还没交易不可能持有股票     
        
        # 状态转移
        for i in range(1, n+1):
            # 第 i 天交易次数为 0 且未持有股票, 只可能源自第 i-1 天卖出股票 or 保持第 i-1 天的相同状态
            dp[i][0][0] = max(dp[i-1][0][1] + prices[i-1], dp[i-1][0][0])
            # 第 i 天交易次数为 0 且持有股票, 只可能源自第 i-1 天买入股票 or 保持第 i-1 天的相同状态
            dp[i][0][1] = max(dp[i-1][1][0] - prices[i-1], dp[i-1][0][1])
            
            # 其余两个状态不用转移
            # dp[i][1][0] = 0  # 毕竟总可交易次数为 1 是不可能有利润的
            # dp[i][1][1] = float('-inf')  # 还没交易不可能持有股票

        # 最大利润
        return dp[n][0][0]

法二:动态规划 - 2D dp table (滚动数组)

  • 空间复杂度 O(1)
  • 时间复杂度 O(n)
class Solution:
    def maxProfit(self, prices: List[int]) -> int:

        # dp[j][k] 表示当前天、可交易次数为 j、股票持有状态为 k 状态下的最大利润
        # 以 0 初始化省去很多细节
        dp = [[0 for k in range(2)] for j in range(2)]  
        
        # 初始状态
        dp[0][1] = float('-inf')  # 第 0 天, 还没交易不可能持有股票
        dp[1][1] = float('-inf')  # 第 0 天, 还没交易不可能持有股票     
    
        # 状态转移
        for i in range(1, len(prices)+1):
            ### 注意, 因为涉及 dp[0][1] 的转移, 顺序不能反, 否则需要用 temp 保存临时状态 ###
            # 第 i 天交易次数为 0 且未持有股票, 源自第 i-1 天卖出股票 or 保持第 i-1 天的相同状态
            dp[0][0] = max(dp[0][1] + prices[i-1], dp[0][0])
            # 第 i 天交易次数为 0 且持有股票, 源自第 i-1 天买入股票 or 保持第 i-1 天的相同状态
            dp[0][1] = max(dp[1][0] - prices[i-1], dp[0][1])

        # 最大利润
        return dp[0][0]

        更好、更快的写法

class Solution:
    def maxProfit(self, prices: List[int]) -> int:

        # dp_j_k 表示当前天、可交易次数为 j、股票持有状态为 k 状态下的最大利润
        dp_0_0 = dp_1_0 = 0
        dp_0_1 = dp_1_1 = float('-inf')  # 第 0 天, 还没交易不可能持有股票
 
        # 状态转移
        for i in range(len(prices)):
            ### 注意, 因为涉及 dp_0_1 的转移, 顺序不能反, 否则需要用 temp 保存临时状态 ###
            # 第 i 天交易次数为 0 且未持有股票, 源自第 i-1 天卖出股票 or 保持第 i-1 天的相同状态
            dp_0_0 = max(dp_0_1 + prices[i], dp_0_0)
            # 第 i 天交易次数为 0 且持有股票, 源自第 i-1 天买入股票 or 保持第 i-1 天的相同状态
            dp_0_1 = max(dp_1_0 - prices[i], dp_0_1)

        # 最大利润
        return dp_0_0

法三 - 一次遍历

  • 空间复杂度 O(1)
  • 时间复杂度 
class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        min_price = float('inf')  # 最小买入价格
        max_profit = 0  # 最大利润 = 最大卖出价格 - 最小买入价格
        for price in prices:
            # 由于必须在未来卖出股票, 故先计算最大利润, 再计算最小买入价格
            max_profit = max(max_profit, price-min_price)
            min_price = min(min_price, price)
        return max_profit

官方说明

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        cost, profit = float("+inf"), 0
        for price in prices:
            cost = min(cost, price)
            profit = max(profit, price - cost)
        return profit

10.3 解答

 参考文献:

《剑指 Offer 第二版》

https://leetcode-cn.com/leetbook/read/illustration-of-algorithm/58nn7r/

https://leetcode-cn.com/leetbook/read/illustration-of-algorithm/58vmds/

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值