動態規劃問題的一般形式就是求最值。比如求最長遞增子列,最小編輯距離等等,求解動態規劃的核心問題是窮舉。
首先,動態規劃的窮舉有點特別,這類問題存在「重疊子問題」,窮舉的話效率會極其低下,所以需要快取來優化窮舉過程,避免不必要的計算。
其次,動態規劃問題一般有「最優子結構」,用子問題的最優解獲得整個問題的最優解。
另外雖然動態規劃的核心思想就是窮舉求最值,但是問題可以千變萬化,只有列出正確的「狀態轉移方程」才能正確地窮舉。
斐波那契數列
斐波那契數列的數學形式就是遞迴的,寫成程式碼就是這樣:
public function dp($number)
{
if($number==1||$number==2){
return 1;
}
return $this->dp($number - 1) + $this->dp($number- 2);
}
這樣寫程式碼雖然簡潔易懂,但是十分低效,當N比較大時遞迴層數太深時間複雜度為 O(2^n),很明顯演算法低效的原因:存在大量重複計算,比如 f(n) 被計算了多次,這就是動態規劃問題的第一個性質:重疊子問題。
帶快取的遞迴解法
即然耗時的原因是重複計算,那麼我們可以造一個「備忘錄」,每次算出的答案後別急著返回,先記到「備忘錄」裡再返回;每次遇到一個子問題先去「備忘錄」裡查一查,如果發現之前已經解決過這個問題了,直接把答案拿出來用,不要再耗時去計算了。
class SequenceDP
{
public $array;
public function __construct($number)
{
for ($i = 0; $i < $number + 1; $i++) {
$this->array[$i] = -1;
}
}
public function dp($number)
{
if($number==1||$number==2){
return 1;
}
if ( $this->array[$number] != -1)
return $this->array[$number];
return $this->dp($number - 1) + $this->dp($number- 2);
}
}
$xx = new SequenceDP(10);
$int = $xx->dp(10);
var_dump($int);
細節優化
據斐波那契數列的狀態轉移方程,當前狀態只和之前的兩個狀態有關,其實並不需要那麼長的一個陣列來儲存所有的狀態,只要想辦法儲存之前的兩個狀態就行了。所以,可以進一步優化,把空間複雜度降為 O(1):
function fib($n) {
if ($n == 2 || $n == 1)
return 1;
$prev = 1, $curr = 1;
for ($i = 3;$i <=$n; $i++) {
$sum = $prev + $curr;
$prev = $curr;
$curr = $sum;
}
return $curr;
}
湊零錢的問題
下面我們來深入瞭解湊零錢的問題
給你
n
種面值硬幣,面值分別為a, b ... ,e
,每種硬幣的數量無限,再給一個總金額amount
,問你最少需要幾枚硬幣湊出這個金額 ?
很明顯這個問題是動態規劃問題,因為它具有「最優子結構」。子問題是互相獨立,互不干擾的。
我們首先確定正確的狀態轉移方程
狀態是原問題和子問題中變化的變數,由於硬幣數量無限,所以唯一的狀態就是金額 amount
,所有函式每次變化為dp(amount)
,分析出虛擬碼如下:
# 要湊出金額 n,至少要 dp(n) 個硬幣
function dp(n){
# 遍歷所有面值,選擇需要硬幣最少的那個結果
for coin in coins:
res = min(res, 1 + dp(n - coin))
return res
}
PHP程式碼實現如下:
<?php
class Coin
{
public $values = [1, 5, 10, 50];
public function dp($amount)
{
$return = $amount;
if ($amount < 0) {
$return = -1;
}elseif ($amount = 0)) {
$return = 0;
} elseif (in_array($amount, $this->values)) {
$return = 1;
} else {
foreach ($this->values as $v) {
//能夠湊的面值則湊
if ($v < $amount) {
$return_temp = 1 + $this->dp($amount - $v);
//如果當前湊個數更少則返回
if ($return_temp < $return) {
$return = $return_temp;
}
}
}
}
return $return;
}
}
$xx = new Coin();
$int = $xx->dp(65);
var_dump($int);
至此,狀態轉移方程其實已經完成了,以上演算法已經是暴力解法了,但是效率堪憂,以下是數學形式就是狀態轉移方程:
觀察發現子問題總數是指數級別的。每個子問題中含有一個 for 迴圈,複雜度為 O(k)。所以總時間複雜度是指數級別。我們也可以自底向上使用 DP快取陣列
來消除重疊子問題,DP快取陣列
定義:$array[i] = x
表示當目標金額為 i
時,至少需要 x
枚硬幣。初始化為-1
表示未計算過。
<?php
class Coin
{
public $values = [1, 5, 10, 50];
public $array;
public function __construct($amount)
{
for ($i = 0; $i < $amount + 1; $i++) {
$this->array[$i] = -1;
}
}
public function dp($amount)
{
$return = $amount;
if ($amount < 1) {
$return = 0;
} elseif ($amount = 0)) {
$return = 0;
} elseif ($this->array[$amount] != -1) {
$return = $this->array[$amount];
} elseif (in_array($amount, $this->values)) {
$return = 1;
} else {
foreach ($this->values as $v) {
//能夠湊的面值則湊
if ($v < $amount) {
$return_temp = 1 + $this->dp($amount - $v);
//如果當前湊個數更少則返回
if ($return_temp < $return) {
$return = $return_temp;
}
}
}
}
$this->array[$amount] = $return;
return $return;
}
}
$coin = new Coin(12111);
$int = $coin->dp(12111);
var_dump($int);
規劃
下篇文章講繼續學習最長遞增子列,最小編輯距離
本作品採用《CC 協議》,轉載必須註明作者和本文連結