一道題看清動態規劃的前世今生(一)

@author: StormMa
@date 2017-11-11


生命不息,奮鬥不止

前言

本篇文章旨在用通俗簡單的語言來教你入門動態規劃。動態規劃是算法中很重要的一塊內容,在各大公司的筆試算法中佔據大壁江山,所以,掌握動態規劃是你拿到稱心的offer的前提,廢話不多說,讓我們來開始一段算法之旅吧。在開始之前,你要努力忘掉你理解的動態規劃,因爲有可能那些都是錯誤的,會限制你的思路。相信我,讀完這篇文章你肯定會有收穫。

前導技能

  • 遞歸 (熟悉遞歸運行原理)
  • 暴力搜索
  • 在線的智商

問題引入

在開始後續的工作之前,我們先來看一道非常簡單的題目

題目

題目來源Leetcode

You are a professional robber planning to rob houses along a street. Each house has a certain amount of money stashed, the only constraint stopping you from robbing each of them is that adjacent houses have security system connected and it will automatically contact the police if two adjacent houses were broken into on the same night.

Given a list of non-negative integers representing the amount of money of each house, determine the maximum amount of money you can rob tonight without alerting the police.

題意很簡單,我也就不翻譯了。

跟着下面的步驟,仔細思考,在這之前,忘掉你對動態規劃的理解!

暴搜,動態規劃的前生

我假設你具備了我說的那些前導技能,相信你是一個暴搜出奇蹟的天才。

那麼,我們現在用暴力搜索來分析一下這道題目

現在,我們從最後一個商家開始,搜索我們想要的答案

那麼我們現在應該有這樣一個函數,假設它具備暴力搜索的功能。那麼,我們首先返回已經”完成”的暴力搜索到的答案

java版

private int search(int i, int[] nums) {
    ...
}

public int rob(int[] nums) {
    return search(nums.length - 1, nums);
}

python版

def search(self, i, nums):
    pass

def rob(self, nums):
   return self.search(len(nums) - 1, nums)

現在,我們”基本”上已經完成了這道題目,因爲暴搜對你來說很簡單。其實上面這些文字,我只是在教你如何思考題目。接下來,讓我們來完成暴搜的主體部分吧!

依據題意,我們不能盜竊相鄰的商家,那麼問題很簡單了。搜索的狀態只有兩種,我們盜竊當前商家,然後跳過前面一個,繼續判斷前面的前面那家商家,或者我們不盜竊當前商家,往前走,因爲前面有好傢伙!這樣,我們應該很容易完成search裏面的內容

java版

private int search(int i, int[] nums) {
    if (i < 0) { // 沒有商家了,我們要開始銷贓
        return 0;
    }
    return Math.max(search(i - 1, nums), nums[i] + search(i - 2, nums));
}

python版

def search(self, i, nums):
    if i < 0: # 沒有商家了,我們要開始銷贓
        return 0
    return max(self.search(i - 1, nums), nums[i] + self.search(i - 2, nums))

記憶化搜索, 動態規劃的今生

現在,我們已經得到了”正確”的答案。我們先來分析一下暴力搜索的時間複雜度吧

顯然,這道題目,我們每個商家兩個狀態我們都模擬過了,那麼很簡單,時間複雜度就是O(2^n)

天啦嚕,指數級的時間複雜度,你說你能走多遠!

隨便提一句,暴力搜索這類時間複雜度都和狀態有關!

既然,這種暴搜我們”走不遠”,那麼怎麼我們可以走得”更遠呢”?

在此之前,我們先來直觀得模擬一下我們暴搜的過程吧


# 現在我們用s(i)來表示我們上面的search方法

f(5) = max(f(4), nums[5] + f(3))
f(4) = max(f(3), nums[4] + f(2))
f(3) = max(f(2), nums[3] + f(1))
f(2) = max(f(1), nums[2] + f(0))
f(1) = max(f(0), nums[1] + f(-1) <=> nums[1]))

上面這個過程你看到了什麼?

相信你肯定看出了我們重複計算了很多f()函數,因爲你的智商在線!

是的,我們重複計算了前面我們計算過的數據,下面,我們根據這一點來優化一下剛纔的暴力搜索

java版

class Solution {
    public int rob(int[] nums) {
        int[] memo = new int[nums.length];
        for (int i = 0; i < nums.length; i++) {
            memo[i] = -1;
        }
        return search(nums.length - 1, nums, memo);
    }

    private int search(int i, int[] nums, int[] memo) {
        if (i < 0) {
            return 0;
        }
        if (memo[i] != -1) {
            return memo[i];
        }
        return memo[i] = Math.max(search(i - 1, nums, memo), nums[i] + search(i - 2, nums, memo));
    }
}

python版

class Solution:
    def rob(self, nums):
        """
        :type nums: List[int]
        :rtype: int
        """
        return self.search(len(nums) - 1, nums, [-1 for i in range(len(nums))])

    def search(self, i, nums, memo):
        if i < 0:
            return 0

        if memo[i] != -1:
            return memo[i]

        memo[i] = max(self.search(i - 1, nums, memo), nums[i] + self.search(i - 2, nums, memo))
        return memo[i]

上面,我們用一個memo數組來把我們計算過的數據保存一下,然後下一次用到時候直接返回即可!

這種方式的搜索,我們就叫它記憶化搜索!是不是很形象,記憶!

那麼現在時間複雜度是多少呢?很明顯是O(N)

現在我可以告訴你了,現在這種解法其實就是動態規劃!你也許會說什麼?你不是在逗我?但是我真的不是在逗你!

記憶化搜索就是遞歸版的動態規劃,既然有遞歸版的,那麼就有遞推版的,我們仿照這個遞歸版的來寫一下遞推版的記憶化搜索!

java版

class Solution {
    public int rob(int[] nums) {
        int[] memo = new int[nums.length];
        for (int i = 0; i < nums.length; i++) {
            memo[i] = -1;
        }
        return searchBottom2Top(0, nums, memo);
    }

    private int searchBottom2Top(int i, int[] nums, int[] memo) {
        if (i >= nums.length) {
            return 0;
        }
        if (memo[i] != -1) { // 其實這個地方不可能是-1,因爲我們是遞推
            return memo[i];
        }
        return memo[i] = Math.max(searchBottom2Top(i + 1, nums, memo), nums[i] + searchBottom2Top(i + 2, nums, memo));
    }
}

python版

class Solution:
    def rob(self, nums):
        """
        :type nums: List[int]
        :rtype: int
        """
        return self.searchBottom2Top(0, nums, [-1 for i in range(len(nums))])

    def searchBottom2Top(self, i, nums, memo):
        if i >= len(nums):
            return 0
        if memo[i] != -1: # 其實這個地方不可能是-1,因爲我們是遞推
            return memo[i]

        memo[i] = max(self.searchBottom2Top(i + 1, nums, memo), nums[i] + self.searchBottom2Top(i + 2, nums, memo))
        return memo[i]

我想你在想,這和你之前見到的動態規劃的寫法還是不一致!

java版

class Solution {
    public int rob(int[] nums) {
        if (nums.length == 0) {
            return 0;
        }
        int[] dp = new int[nums.length];
        dp[0] = nums[0];
        if (nums.length == 1) {
            return dp[0];
        }
        dp[1] = Math.max(nums[0], nums[1]);
        for (int i = 2; i < nums.length; i++) {
            dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
        }
        return dp[nums.length - 1];
    }
}

python版

class Solution:
    def rob(self, nums):
        """
        :type nums: List[int]
        :rtype: int
        """
        if len(nums) == 0:
            return 0
        dp = [0 for i in range(len(nums))]
        dp[0] = nums[0]
        if len(nums) == 1:
            return dp[0]

        dp[1] = max(nums[0], nums[1])

        for i in range(2, len(nums)):
            dp[i] = max(dp[i - 1], nums[i] + dp[i - 2])

        return dp[-1]

現在是不是很眼熟了,這就是動態規劃,其實前面的也是,只是方式不同罷了!看到這裏是不是思路很清晰了,動態規劃並不難,難的是我們分析問題的出發點。看這道題目,我們從遞歸一步一步到動態規劃!

其實遞歸的本質就是n -> n - 1 -> n

到這裏,我們真正知道了什麼是動態規劃,還有兩個概念,我想你應該知道

  1. 最優子結構

    • 子問題最優決策可導出原問題最優決策
    • 無後效性
  2. 重疊子問題

    • 去冗餘
    • 空間換時間(注意分析時空複雜度)

結尾

一道題看清動態規劃的前世今生,打算用三篇文章來通俗的解釋dp的概念以及思考方式!如果你喜歡這篇文章,歡迎關注我GitHub

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章