动态规划原理

动态规划原理

自顶而下的分治 vs. 自底向上的动态规划

分治法:面对规模为 n 的问题,从顶层出发,将其拆为若干个更小的子问题,分别求解后合并

动态规划(DP):当同一子问题重复出现时,与其一再从顶层“把问题拆碎”,不如记录子问题的答案,并按规模从小到大把所有需要的子问题一次性求出来。

什么才算“同一”子问题

与AI教师讨论,如何区分“相同规模的子问题”和“同一(可复用的)子问题”

切绳(Rod Cutting)问题

给定一根长度为 n 的绳子,价格表 price[i] 表示长度为 i 的一段可以卖出的价格。允许将绳子切成多段出售,目标是使总收益最大。

分治法与时间复杂度

R(n) 为长度 n 的最大收益:

R(0) = 0, R(1)=price[1]
R(n) = max_{1 ≤ i ≤ n} ( price[i] + R(n - i) )

纯递归分治会对同一规模的子问题多次求解,子问题规模组合数呈指数级,时间复杂度为 O(n^n) 量级。

记忆化(自顶向下)

思想:用哈希表/数组 memo[n] 记录 R(n)。当再次需要 R(n) 时,直接返回已存结果,避免重复计算。

代码任务 A:实现记忆化递归(Top-Down)

def rod_cut_topdown(price: dict[int, int], n: int, memo: list[int]) -> int:
    """
    返回长度 n 的最大收益(记忆化递归)。
    TODO:
      1) 处理 n==0;2) 命中 memo 直接返回;3) 枚举第一刀长度 i;4) 写回 memo[n]。
    """
    pass

自底向上(反向记忆化)

将规模从小到大推进:dp[x] 表示长度 x 的最优收益。 代码任务 B:实现自底向上(Bottom-Up)

def rod_cut_bottomup(price: dict[int, int], n: int) -> int:
    """
    返回长度 n 的最大收益(自底向上)。
    TODO:
      1) 初始化 dp[0]=0;2) for x in 1..n:dp[x] = max_{1..x}( price[i] + dp[x-i] )
    """

"""
以下内容无需修改,注意将你实现的rod_cut_topdown代码复制过来
"""

import time
import random
import signal
from functools import wraps


#超时异常定义
class TimeoutError(Exception):
    pass

#超时装饰器
def timeout(seconds):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            # 定义超时处理函数
            def handle_timeout(signum, frame):
                raise TimeoutError(f"Function {func.__name__} timed out after {seconds} seconds")
            
            # 设置信号处理
            signal.signal(signal.SIGALRM, handle_timeout)
            signal.alarm(seconds)  # 触发超时
            
            try:
                result = func(*args, **kwargs)
                return result
            finally:
                signal.alarm(0)  # 取消超时
        return wrapper
    return decorator

#带超时的纯递归实现(用于对比)
@timeout(1)  # 1秒超时
def rod_cut_recursive(price: dict[int, int], n: int) -> int:
    if n == 0:
        return 0
    max_rev = -float('inf')
    for i in range(1, n + 1):
        if i in price:
            max_rev = max(max_rev, price[i] + rod_cut_recursive(price, n - i))
    return max_rev


#对比实验
def compare_algorithms():
    # 生成测试用的价格表(随机生成1到10的价格)
    max_length = 50
    price = {i: random.randint(1, 10) for i in range(1, max_length + 1)}
    
    print(f"{'n':<5} {'递归(ms)':<10} {'记忆化(ms)':<12} {'自底向上(ms)':<15}")
    print("-" * 50)
    
    # 测试n从10到50的情况
    for n in range(10, 51, 5):
        # 纯递归(带超时处理)
        try:
            start = time.time()
            recursive_result = rod_cut_recursive(price, n)
            recursive_time = (time.time() - start) * 1000
        except TimeoutError:
            recursive_result = "超时"
            recursive_time = ">1000"
        
        # 记忆化递归
        memo = [-1] * (n + 1)
        start = time.time()
        topdown_result = rod_cut_topdown(price, n, memo)
        topdown_time = (time.time() - start) * 1000
        
        # 自底向上
        start = time.time()
        bottomup_result = rod_cut_bottomup(price, n)
        bottomup_time = (time.time() - start) * 1000
        
        # 验证结果一致性(仅当递归未超时)
        if isinstance(recursive_result, int):
            assert recursive_result == topdown_result == bottomup_result, f"结果不一致 for n={n}"
        
        # 输出结果
        print(f"{n:<5} {recursive_time:<10} {topdown_time:<12.4f} {bottomup_time:<15.4f}")

if __name__ == "__main__":
    compare_algorithms()

完成上面的代码,讨论实验对比结果。

动态规划的“组成”:如何写出一个 DP

四大组成

两大性质

例题:矩阵连乘(Matrix-Chain Multiplication, MCM)

问题描述

矩阵乘法有严格的维度匹配要求:只有前一个矩阵的列数 = 后一个矩阵的行数,才能相乘。 即: 矩阵 A:维度为 m × k(共 m 行、k 列) 矩阵 B:维度为 k × n(共 k 行、n 列) 矩阵 C = A×B,维度为 m × n 产生标量乘法次数为 m×n×k 给定矩阵链 A₁A₂…Aₖ,维度数组 p[0..k] 满足 Aᵢ 大小为 p[i-1] × p[i]。目标:只改变乘法括号化顺序,最小化标量乘法次数。

递推与 DP 表

m[i][j] 表示从 Aᵢ…Aⱼ 的最小乘法次数(1-index)。则:

m[i][i] = 0
m[i][j] = min_{i ≤ k < j} ( 尝试推导一下这里的转移式 )

记录断点

s[i][j] 存储从 Aᵢ…Aⱼ 的最优断点。 $ s[i][j] = k \text{ if } m[i][j] == 与上面的式子一样 $

代码任务

def matrix_chain_order(p: list[int]) -> tuple[list[list[int]], list[list[int]]]:
    """
    返回 (m, s),m[i][j] 为最小代价,s[i][j] 为最优断点。
    TODO: 长度 n = len(p)-1;按区间长度 L=2..n 填表。
    """
    ...

def print_optimal_parens(s: list[list[int]], i: int, j: int) -> str:
    """根据断点矩阵 s 输出最优括号化方案"""
    if i == j:
        return f"A{i}"
    else:
        return f"({print_optimal_parens(s, i, s[i][j])}" \
               f"{print_optimal_parens(s, s[i][j]+1, j)})"

#测试验证部分
if __name__ == "__main__":
    # 经典测试案例
    p = [30, 35, 15, 5, 10, 20, 25]
    expected_result = 15125
    
    # 计算最优解
    m, s = matrix_chain_order(p)
    n = len(p) - 1
    result = m[1][n]
    
    # 输出测试结果
    print(f"矩阵维度数组: {p}")
    print(f"矩阵数量: {n}")
    print(f"计算得到的最小标量乘法次数: {result}")
    print(f"预期的最小标量乘法次数: {expected_result}")
    print(f"测试{'通过' if result == expected_result else '失败'}")
    print(f"最优括号化方案: {print_optimal_parens(s, 1, n)}")
    
    # 额外测试案例
    p2 = [40, 20, 30, 10, 30]
    m2, s2 = matrix_chain_order(p2)
    print("\n第二个测试案例:")
    print(f"矩阵维度数组: {p2}")
    print(f"最小标量乘法次数: {m2[1][4]}")  # 预期结果为 26000
    print(f"最优括号化方案: {print_optimal_parens(s2, 1, 4)}")

综合讨论