動的計画法上の

動的なプログラミングアルゴリズムは、(動的計画法、DPと呼ばれる)謎のアルゴリズムであるように思わ高いです、あなたはいくつかの先進的な技術やアルゴリズム面接の本の一部に関連するコンテンツが表示されます、どのような状態遷移方程式、次善の子供の重複問題背の高い構造上の語彙はまた、あなたが尻込みことがあります。

また、あなたは、動的プログラミングを使用して問題を解決するために、コードを見たとき、あなたはこの問題はとても賢いと思うだろうが、理解することは困難であり、あなたは他の人がこのソリューションをどのように考えるかに驚くかもしれません。

実際には、これらの用語の意味は、限り、あなたは個人的に実際には、いくつかの体験として、動的計画法は、一般的な「アルゴリズム設計技法」であり、計り知れないものはありません、人を怖がらせるために使用されている背の高い様々な、上の用語として明らかに、それは簡単なことではありません。

ダイナミックプログラミングはプロセスの固定セット従っているため、最終的な解決策は、とても微妙らしい理由について:再帰的な暴力的なソリューションを- >覚書と再帰的ソリューション- >非再帰的、動的プログラミングソリューションあなたは、もちろん、ないだろう、と感じNiubiの最終的な非再帰的な動的なプログラミングソリューションを直接見、寝具の前にない場合は、このプロセスでは、問題解決プロセスの進行層です。

もちろん、より多くのを見て、より多くの、動的計画法の非再帰的なソリューションを書くことでステップすることができると思います。任意のスキルは、これらのルーチンを練習が必要、我々はこのプロセスに従うことを最初に行く、アルゴリズム設計し、加えて、本当に深遠な何もありません。

フィボナッチと港の変化の問題は、上記3つの処理を記述し、ダイナミックプログラミングの謎を明らかにする:この記事では、2つの比較的単純な例を通過します。フォローアップも、古典的な書き込み数の記事の複雑な問題を解決するために、動的プログラミング手法を使用する方法を模索します。

まず、第1の高速は腐ったとえば、フィボナッチ列を解除されました。読者はあなたが何らかの形でこれらの詳細あいまいアウトされることなく、アルゴリズムの背後にある一般的なアイデアや技術に完全に集中集中するために簡単な例ので、簡単なこの例を軽蔑ないように注意してください。フォローアップ、困難のいくつかの例を。

ステップ1、暴力再帰アルゴリズム

int fib(int N) {
    if (N == 1 || N == 2) return 1;
    return fib(N - 1) + fib(N - 2);
}

これは、再帰が、この例を取るように見えるとき、学校の教師は言うまでもないです。我々はまたものの、コードを書くことを理解しやすい知っているが、非常に非効率的、効果がないとどこ?し、N = 20、再帰的なツリーを描画します。

PSは:発生した問題は、再帰を必要とするたびに、最高のは非効率的なアルゴリズムが大きな助けを持っている原因を探して、あなたのためのアルゴリズムの複雑さを分析し、再帰的なツリーを、描かれています。

どのようにこの再帰的なツリーを理解しますか?それは私が最初に(18)は、fサブ質問に計算しなければならない、最初のFサブ問題(19)とf(18)を計算し、その後、F(19)を計算するために元の問題のF(20)、私が持っているを計算したいとF(17)など。最後に遭遇したとき、F(1)またはf(2)、結果が結果を直接下方再帰的ツリーを成長しない、戻すことができ、知られています。

どのように計算するための再帰アルゴリズムの時間複雑?子供が時間を必要とする問題を解決するために、サブ問題の数を乗じました。

副問題、再帰的にツリー内のノード、すなわち合計数の数。明確ノードのバイナリ指数数を平準化、副問題の数がO(2 ^ N)です。

サブタイムの問題を解決するため、この方法では、何の循環が存在しない、唯一の(N - 1)F + F(N - 2)加算演算、時間はO(1)です。

したがって、アルゴリズムの時間複雑度は、O(2 ^ n)は、インデックスレベル、爆発です。

ダブルカウントの多くは、このような(18)Fとして、ある二回カウントされ、そしてあなたは、この金額再帰的なツリーのルートとしてF(18)に多大見ることができます:観察再帰的なツリーは、どうやら原因は非効率的なアルゴリズムを発見しました、マルチオペレータ再び、それは巨大な時間がかかります。また、ノードを計算する(18)fよりも、よりは、アルゴリズムと非効率的なので、繰り返されます。

これは、動的プログラミングの問題の最初のプロパティです:重複部分問題ここでは、この問題を解決するための方法を考えます。

メモとステップ2、再帰的なソリューション

クリア、実際には、すでに半分に問題を解決していました。これは、時間のかかる二重カウントされ、その後、私たちは、「メモ」を構築することができますもちろんの理由で、それぞれが子供の質問に答えるして計算しては後ろに「覚書」を覚えておくことは、返すように急いではありませんが、それぞれの時間は、それは問題のある子に遭遇します「覚書」以前に計算するのではない時間のかかる操作を行い、解答アウトと直接この問題を解決してきたが判明した場合、そこまで見に行きます。

一般的には「覚書」として機能する配列を使用し、もちろん、あなたはまた、ハッシュテーブル(辞書)を使用することができ、アイデアは同じです。

int fib(int N) {
    if (N < 1) return 0;
    // 备忘录全初始化为 0
    vector<int> memo(N + 1, 0);
    // 初始化最简情况
    memo[1] = memo[2] = 1;
    return helper(memo, N);
}

int helper(vector<int>& memo, int n) {
    // 未被计算过
    if (n > 0 && memo[n] == 0) 
        memo[n] = helper(memo, n - 1) + helper(memo, n - 2);
    return memo[n];
}

さて、あなたは何が行われたかを最終的には、「メモ」を知っている、再帰ツリーを描きます。

実際には、冗長性の欠如の再帰的なビューに変換「剪定」によって冗長再帰ツリーの膨大な量の存在の「メモ」と再帰アルゴリズムは、大幅に(すなわち、再帰副問題を減少させますグラフ内のノードの数)です。

カウント方法の再帰アルゴリズムの時間複雑?子供が時間を必要とする問題を解決するために、サブ問題の数を乗じました。

子问题个数,即图中节点的总数,由于本算法不存在冗余计算,子问题就是 f(1), f(2), f(3) ... f(20),数量和输入规模 n = 20 成正比,所以子问题个数为 O(n)。

解决一个子问题的时间,同上,没有什么循环,时间为 O(1)。

所以,本算法的时间复杂度是 O(n)。比起暴力算法,是降维打击。

至此,带备忘录的递归解法的效率已经和动态规划一样了。实际上,这种解法和动态规划的思想已经差不多了,只不过这种方法叫做「自顶向下」,动态规划叫做「自底向上」。

啥叫「自顶向下」?注意我们刚才画的递归树(或者说图),是从上向下延伸,都是从一个规模较大的原问题比如说 f(20),向下逐渐分解规模,直到 f(1) 和 f(2) 触底,然后逐层返回答案,这就叫「自顶向下」。

啥叫「自底向上」?反过来,我们直接从最底下,最简单,问题规模最小的 f(1) 和 f(2) 开始往上推,直到推到我们想要的答案 f(20),这就是动态规划的思路,这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算。

步骤三、动态规划

有了上一步「备忘录」的启发,我们可以把这个「备忘录」独立出来成为一张表,就叫做 DP table 吧,在这张表上完成「自底向上」的推算岂不美哉!

int fib(int N) {
    vector<int> dp(N + 1, 0);
    dp[1] = dp[2] = 1;
    for (int i = 3; i <= N; i++)
        dp[i] = dp[i - 1] + dp[i - 2];
    return dp[N];
}

画个图就很好理解了,而且你发现这个 DP table 特别像之前那个「剪枝」后的结果,只是反过来算而已。实际上,带备忘录的递归解法中的「备忘录」,最终完成后就是这个 DP table,所以说这两种解法其实是差不多的,大部分情况下,效率也基本相同。

这里,引出「动态转移方程」这个名词,实际上就是描述问题结构的数学形式:

为啥叫「状态转移方程」?为了听起来高端。你把 f(n) 想做一个状态 n,这个状态 n 是由状态 n - 1 和状态 n - 2 相加转移而来,这就叫状态转移,仅此而已。

你会发现,上面的几种解法中的所有操作,例如 return f(n - 1) + f(n - 2),dp[i] = dp[i - 1] + dp[i - 2],以及对备忘录或 DP table 的初始化操作,都是围绕这个方程式的不同表现形式。可见列出「状态转移方程」的重要性,它是解决问题的核心。很容易发现,其实状态转移方程直接代表着暴力解法。

千万不要看不起暴力解,动态规划问题最困难的就是写出状态转移方程,即这个暴力解。优化方法无非是用备忘录或者 DP table,再无奥妙可言。

这个例子的最后,讲一个细节优化。细心的读者会发现,根据斐波那契数列的状态转移方程,当前状态只和之前的两个状态有关,其实并不需要那么长的一个 DP table 来存储所有的状态,只要想办法存储之前的两个状态就行了。所以,可以进一步优化,把空间复杂度降为 O(1):

int fib(int n) {
    if (n < 2) return n;
    int prev = 0, curr = 1;
    for (int i = 0; i < n - 1; i++) {
        int sum = prev + curr;
        prev = curr;
        curr = sum;
    }
    return curr;
}

有人会问,动态规划的另一个重要特性「最优子结构」,怎么没有涉及?下面会涉及。斐波那契数列的例子严格来说不算动态规划,以上旨在演示算法设计螺旋上升的过程。当问题中要求求一个最优解或在代码中看到循环和 max、min 等函数时,十有八九,需要动态规划大显身手。

下面,看第二个例子,凑零钱问题,有了上面的详细铺垫,这个问题会很快解决。

题目:给你 k 种面值的硬币,面值分别为 c1, c2 ... ck,再给一个总金额 n,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,则回答 -1 。

比如说,k = 3,面值分别为 1,2,5,总金额 n = 11,那么最少需要 3 枚硬币,即 11 = 5 + 5 + 1 。下面走流程。

一、暴力解法

首先是最困难的一步,写出状态转移方程,这个问题比较好写:

其实,这个方程就用到了「最优子结构」性质:原问题的解由子问题的最优解构成。即 f(11) 由 f(10), f(9), f(6) 的最优解转移而来。

记住,要符合「最优子结构」,子问题间必须互相独立。啥叫相互独立?你肯定不想看数学证明,我用一个直观的例子来讲解。

比如说,你的原问题是考出最高的总成绩,那么你的子问题就是要把语文考到最高,数学考到最高...... 为了每门课考到最高,你要把每门课相应的选择题分数拿到最高,填空题分数拿到最高...... 当然,最终就是你每门课都是满分,这就是最高的总成绩。

得到了正确的结果:最高的总成绩就是总分。因为这个过程符合最优子结构,“每门科目考到最高”这些子问题是互相独立,互不干扰的。

但是,如果加一个条件:你的语文成绩和数学成绩会互相制约,此消彼长。这样的话,显然你能考到的最高总成绩就达不到总分了,按刚才那个思路就会得到错误的结果。因为子问题并不独立,语文数学成绩无法同时最优,所以最优子结构被破坏。

回到凑零钱问题,显然子问题之间没有相互制约,而是互相独立的。所以这个状态转移方程是可以得到正确答案的。

int coinChange(vector<int>& coins, int amount) {
    if (amount == 0) return 0;
    int ans = INT_MAX;
    for (int coin : coins) {
        // 金额不可达
        if (amount - coin < 0) continue;
        int subProb = coinChange(coins, amount - coin);
        // 子问题无解
        if (subProb == -1) continue;
        ans = min(ans, subProb + 1);
    }
    return ans == INT_MAX ? -1 : ans;
}

画出递归树:

时间复杂度分析:子问题总数 x 每个子问题的时间。子问题总数为递归树节点个数,这个比较难看出来,是 O(n^k),总之是指数级别的。每个子问题中含有一个 for 循环,复杂度为 O(k)。所以总时间复杂度为 O(k*n^k),指数级别。

二、带备忘录的递归算法

int coinChange(vector<int>& coins, int amount) {
    // 备忘录初始化为 -2
    vector<int> memo(amount + 1, -2);
    return helper(coins, amount, memo);
}

int helper(vector<int>& coins, int amount, vector<int>& memo) {
    if (amount == 0) return 0;
    if (memo[amount] != -2) return memo[amount];
    int ans = INT_MAX;
    for (int coin : coins) {
        // 金额不可达
        if (amount - coin < 0) continue;
        int subProb = helper(coins, amount - coin, memo);
        // 子问题无解
        if (subProb == -1) continue;
        ans = min(ans, subProb + 1);
    }
    // 记录本轮答案
    memo[amount] = (ans == INT_MAX) ? -1 : ans;
    return memo[amount];
}

不画图了,很显然「备忘录」大大减小了子问题数目,完全消除了子问题的冗余,所以子问题总数不会超过金额数 n,即子问题数目为 O(n)。处理一个子问题的时间不变,仍是 O(k),所以总的时间复杂度是 O(kn)。

三、动态规划

int coinChange(vector<int>& coins, int amount) {
    vector<int> dp(amount + 1, INT_MAX);
    dp[0] = 0;
    for (int i = 0; i < dp.size(); i++) {
        // 内层 for 在求所有子问题 + 1 的最小值
        for (int coin : coins) {
            if (i - coin < 0) continue;
            dp[i] = min(dp[i], 1 + dp[i - coin]);
        }
    }
    return dp[amount] == INT_MAX ? -1 : dp[amount];
}

最后总结

如果你不太了解动态规划,还能看到这里,真得给你鼓掌,相信你已经掌握了这个算法的设计技巧。

计算机解决问题其实没有任何奇技淫巧,它唯一的解决办法就是穷举,穷举所有可能性。算法设计无非就是先思考“如何穷举”,然后再追求“如何聪明地穷举”。

列出动态转移方程,就是在解决“如何穷举”的问题。之所以说它难,一是因为很多穷举需要递归实现,二是因为有的问题本身的解空间复杂,不那么容易穷举完整。

备忘录、DP table 就是在追求“如何聪明地穷举”。用空间换时间的思路,是降低时间复杂度的不二法门,除此之外,试问,还能玩出啥花活?

おすすめ

転載: www.cnblogs.com/kyoner/p/11105280.html