「分治思想」、「空间换时间」、「最优解」等多种算法思想
「分治」是算法中的一种基本思想,其通过将原问题分解为子问题,不断递归地将子问题分解为更小的子问题,并通过组合子问题的解来得到原问题的解。
类似于分治算法,「动态规划」也通过组合子问题的解得到原问题的解。不同的是,适合用动态规划解决的问题具有「重叠子问题」和「最优子结构」两大特性。
动态规划的子问题是有重叠的,即各个子问题中包含重复的更小子问题。若使用暴力法穷举,求解这些相同子问题会产生大量的重复计算,效率低下。
动态规划在第一次求解某子问题时,会将子问题的解保存;后续遇到重叠子问题时,则直接通过查表获取解,保证每个独立子问题只被计算一次,从而降低算法的时间复杂度。
「暴力递归」→→「记忆化递归」→→「动态规划」
int fibonacci(int n) { if (n == 0) return 0; // 返回 f(0) if (n == 1) return 1; // 返回 f(1) return fibonacci(n - 1) + fibonacci(n - 2); // 分解为两个子问题求解 }
记忆递归:一个函数初始化数组存储数值,一个函数返回
int fibonacci(int n, vector<int> dp) { if (n == 0) return 0; // 返回 f(0) if (n == 1) return 1; // 返回 f(1) if (dp[n] != 0) return dp[n]; // 若 f(n) 以前已经计算过,则直接返回记录的解 dp[n] = fibonacci(n - 1, dp) + fibonacci(n - 2, dp); // 将 f(n) 则记录至 dp return dp[n]; } // 求第 n 个斐波那契数 int fibonacciMemorized(int n) { vector<int> dp(n + 1, 0); // 用于保存 f(0) 至 f(n) 问题的解 return fibonacci(n, dp); }
动态递归**
这个就很妙,看起来像是和前面的一样,实则就是利用一个数组来实现状态转移
// 求第 n 个斐波那契数 int fibonacci(int n) { if (n == 0) return 0; // 若求 f(0) 则直接返回 0 vector<int> dp(n + 1, 0); // 初始化 dp 列表 dp[1] = 1; // 初始化 f(0), f(1) for (int i = 2; i <= n; i++) { // 状态转移求取 f(2), f(3), ..., f(n) dp[i] = dp[i - 1] + dp[i - 2]; } return dp[n]; // 返回 f(n) } /
上述动态规划解法借助了一个 dp 数组保存子问题的解,其空间复杂度为 O(N)
而由于 f(n) 只与 f(n−1) 和 f(n−2) 有关,因此我们可以仅使用两个变量 a , b 交替前进计算即可。此时动态规划的空间复杂度降低至 O(1)
// 求第 n 个斐波那契数 int fibonacci(int n) { if (n == 0) return 0; // 若求 f(0) 则直接返回 0 int a = 0, b = 1; // 初始化 f(0), f(1) for (int i = 2; i <= n; i++) { // 状态转移求取 f(2), f(3), ..., f(n) int tmp = a; a = b; b = tmp + b; } return b; // 返回 f(n) }
这里第二种方法已经有点失去KISS了,因为他已经把简单的思想给优化了一下利用O(1)空间,可能稍微有点看不懂,自己在纸上画几下就懂啦
「以上内容参考《算法导论》与《算法与数据结构》」