动态规划初步

简述

动态规划(Dynamic Programming)是一种以时间换空间,将指数复杂度的搜索转化为 多项式复杂度解的技术。其中 Programming 指一种表格方法,而不是编程。

题目

常见特征

此表部分总结,收集于《算法导论》以及个人经验。

钢条切割

对于不同整数长度的钢条,我们给出不同的价格。现在可以任意对一条钢条切割, 求可以获得的最大价值。

暴力搜索解

很容易想到的,由于钢条长度为整数,对一根长度为 n 的钢条我们只能在 n - 1 个 位置上切割。于是可以使用这样的代码(Lua)

-- Assuming that the values are stored in table value
local cutRod;
cutRod = function(l)
    local v = 0;
    for i = 1,l
    do
        v = math.max(v,cutRod(l - i) + value[i]);
    end
    return v;
end

这段代码肯定工作,但是让我们分析一下它做了什么

Profiling

简记 cutRod(l) 为 T(l)

对于 T(4),它需要计算 T(1),T(2),T(3)

T(2) 计算 T(1);T(3) 计算 T(2) T(1)

最终我们会发现,对于小的 l,T(l) 被多次计算,事实上 cutRod() 一共被调用了 8 次 才得出结果,对于更大的 l 这个数字会更加恐怖。

自顶向下方法(记忆化搜索)

一种自然的联想是,我们可以把 T(l) 的返回值保存起来,这样可以避免重复计算了

-- Assuming that the values are stored in table value
local cutRod,mem = nil,{};	-- A better way is to create a factory
cutRod = function(l)
    if mem[l]
    then
        return mem[l];
    end

    local v = 0;
    for i = 1,l
    do
        v = math.max(v,cutRod(l - i) + value[i]);
    end
    mem[l] = v;
    return v;
end

这就可以了。

自底向上方法(常见的 DP)

函数调用是有消耗的。而如果我们仔细观察开始的暴力解,我们可以写出一个式子: $$ cutRod(l) = \begin{cases} value[l] & l = 1 \\ max \{ p \vert 1 \leq p \leq l \} & \text{otherwise} \end{cases} $$ (这个式子通常被叫做递推式)

观察到,对于 cutRod(l) 的计算事实上只依赖 cutRod(0) ... cutRod(l)。如果我们 从 cutRod(0) 逐步推后计算,我们有可能一定能消除掉递归。

local dp = {[0] = 0};
for l = 1,length
do
    local v = 0;
    for p = 1,l
    do
        v = math.max(v,value[p] + dp[l - p]);
    end
    dp[l] = v;
end

恭喜你,时间复杂度已经变成了 $\Theta(n^2)$

总结

我们从一个暴力算法开始,利用记忆化将它优化到多项式复杂度,再通过展开递归式 来优化常量因子。这次事件中我们可以得到一些关于动态规划的有关事项:

  1. 将递归形式展开为递推形式计算的时候,我们需要注意依赖次序。如 cutRod(l) 依赖从 cutRod(1) 至 cutRod(l - 1) 的所有结果,所以我们必须从 1 到 l 计算 解
  2. 递推形式的计算需要直接将边界条件存入 DP 数组(这是被展开的记忆花搜索 唯一的遗产了(x))
  3. 利用好语言特性。例如在 C/C++ 中未初始化的静态变量(全局变量一定是静态的, 它们都位于 ELF 的 .bss 段)会被初始化为 0
  4. 递推形式可以利用 滚动数组 优化空间复杂度。有的时候递归式向前依赖的 子问题是有限的(例如背包问题),此时滚动数组是可用的
  5. 递归形式不一定比递推形式慢。偶尔并不是所有子问题都需要求解,此时递归形式 更快。但递归形式难以应用滚动数组
Publish: 2022-08-12
如无特殊说明,本站所有内容以 CC-BY-SA 4.0 协议发布
GO BACK