[LeetCode] 818. Race Car 賽車


 

Your car starts at position 0 and speed +1 on an infinite number line.  (Your car can go into negative positions.)

Your car drives automatically according to a sequence of instructions A (accelerate) and R (reverse).

When you get an instruction "A", your car does the following: position += speed, speed *= 2.

When you get an instruction "R", your car does the following: if your speed is positive then speed = -1 , otherwise speed = 1.  (Your position stays the same.)

For example, after commands "AAR", your car goes to positions 0->1->3->3, and your speed goes to 1->2->4->-1.

Now for some target position, say the length of the shortest sequence of instructions to get there.

Example 1:
Input: 
target = 3
Output: 2
Explanation: 
The shortest instruction sequence is "AA".
Your position goes from 0->1->3.
Example 2:
Input: 
target = 6
Output: 5
Explanation: 
The shortest instruction sequence is "AAARA".
Your position goes from 0->1->3->7->7->6.

 

Note:

  • 1 <= target <= 10000.

 

這道題是關於賽車的題(估計韓寒會比較感興趣吧,從《后會無期》,到《乘風破浪》,再到《飛馳人生》,貌似每一部都跟車有關,正所謂不會拍電影的賽車手不是好作家,哈哈~)。好,不多扯了,來做題吧,以下講解主要參考了 fun4LeetCode 大神的帖子。說是起始時有個小車在位置0,速度為1,有個目標位置 target,是小車要到達的地方。而小車只有兩種操作,第一種是加速操作,首先當前位置加上小車速度,然后小車速度乘以2。第二種是反向操作,小車位置不變,小車速度重置為單位長度,並且反向。問我們最少需要多少個操作才能到達 target。我們首先來看下若小車一直加速的話,都能經過哪些位置,從起點開始,若小車連加五次速,位置的變化為:

0 -> 1 -> 3 -> 7 -> 15 -> 31

有沒有發現這些數字很眼熟,沒有的話,就每個數字都加上個1,那么就應該眼熟了吧,對於信仰 1024 的程序猿來說,不眼熟不行啊,這就變成了2的指數數列啊,那么我們得出了結論,當小車從0開始連加n個速的話,其將會到達位置 2^n - 1。我們可以看出,小車越往后,位置跨度越大,那么當 target 不在這些位置上,很有可能一腳油門就開過了,比如,target = 6 的話,小車在3的位置上,一腳油門,就到7了,這時候就要回頭,回頭后,速度變為 -1,此時正好就到達6了,那么小車的操作如下:

Initial:    pos -> 0,    speed -> 1

A:      pos -> 1,    speed -> 2

A:      pos -> 3,    speed -> 4

A:      pos -> 7,    speed -> 8

R:      pos -> 7,    speed -> -1

A:      pos -> 6,    speed -> -2

所以,我們只需要5步就可以了。但是還有個問題,假如回頭了以后,一腳油門之后,又過站了怎么辦?比如 target = 5 的時候,之前小車回頭之后到達了6的位置,此時速度已經是 -2了,再加個速,就直接干到了位置4,就得再回頭,那么這種方式小車的操作如下:

Initial:    pos -> 0,    speed -> 1

A:      pos -> 1,    speed -> 2

A:      pos -> 3,    speed -> 4

A:      pos -> 7,    speed -> 8

R:      pos -> 7,    speed -> -1

A:      pos -> 6,    speed -> -2

A:      pos -> 4,    speed -> -4

R:      pos -> 4,    speed -> 1

A:      pos -> 5,    speed -> 2

那么此時我們就用了8步,但這是最優的方法么,我們一定要在過了目標才回頭么,不撞南牆不回頭么?其實不必,我們可以在到達 target 之前提前調頭,然后往回走走,再調回來,使得之后能恰好到達 target,比如下面這種走法:

Initial:    pos -> 0,    speed -> 1

A:      pos -> 1,    speed -> 2

A:      pos -> 3,    speed -> 4

R:      pos -> 3,    speed -> -1

A:      pos -> 2,    speed -> -2

R:      pos -> 2,    speed -> 1

A:      pos -> 3,    speed -> 2

A:      pos -> 5,    speed -> 4

我們在未到達 target 的位置3時就直接掉頭了,往后退到2,再調回來,往前走,到達5,此時總共只用了7步,是最優解。那么我們怎么知道啥時候要掉頭?問得好,答案是不知道,我們得遍歷每種情況。但是為了避免計算一些無用的情況,比如小車反向過了起點,或者是超過 target 好遠都不回頭,我們需要限定一些邊界,比如小車不能去小於0的位置,以及小車在超過了 target 時,就必須回頭了,不能繼續往前開了。還有就是小車當前的位置不能超過 target x 2,不過這個限制條件博主還沒有想出合理的解釋,各位看官大神們知道的話可以給博主講講~

對於求極值的題目,根據博主多年與 LeetCode 抗爭的經驗,就是 BFS,帶剪枝的 DFS 解法,貪婪算法,或者動態規划 Dynamic Programming 這幾種解法(帶記憶數組的 DFS 解法也可以歸到 DP 一類中去)。一般來說,貪婪算法比較 naive,大概率會跪。BFS 有時候可以,帶剪枝的 DFS 解法中的剪枝比較難想,而 DP 絕對是神器,基本沒有解決不了的問題,但是代價就是得抓破頭皮想狀態轉移方程,並且一般 DP 只能用來求極值,而想求極值對應的具體情況(比如這道題如果讓求最少個數的指令是什么),有時候可能還得用帶剪枝的 DFS 解法。不過這道題 BFS 也可以,那么我們就先用 BFS 來解吧。

這里的 BFS 解法,跟迷宮遍歷中的找最短路徑很類似,可以想像成水波,一圈一圈的往外擴散,當碰到 target 時候,當前的半徑就是最短距離。用隊列 queue 來輔助遍歷,里面放的是位置和速度的 pair 對兒,將初始狀態位置0速度1先放進 queue,然后需要一個 HashSet 來記錄處理過的狀態,為了節省空間和加快速度,我們將位置和速度變為字符串,並在中間加逗號隔開,這樣 HashSet 中只要保存字符串即可。之后開始 while 循環,此時采用的是層序遍歷的寫法,當前 queue 中所有元素遍歷完了之后,結果 res 才自增1。在 for 循環中,首先取出隊首 pair 對兒的位置和速度,如果位置和 target 相等,直接返回結果 res。否則就要去新的地方了,首先嘗試的是加速操作,此時新的位置 newPos 為之前的位置加速度,新的速度 newSpeed 為之前速度的2倍,然后將 newPos 和 newSpeed 加碼成字符串,若新的狀態沒有處理過,且新位置大於0,小於 target x 2 的話,則將新狀態加入 visited,並排入隊列中。接下來就是轉向的情況,newPos 和原位置保持不變,newSpeed 根據之前 speed 的正負變成 -1 或1,然后將 newPos 和 newSpeed 加碼成字符串,若新的狀態沒有處理過,且新位置大於0,小於 target x 2 的話,則將新狀態加入 visited,並排入隊列中。for循環結束后,結果 res 自增1即可,參見代碼如下:

 

解法一:

class Solution {
public:
    int racecar(int target) {
        int res = 0;
        queue<pair<int, int>> q{{{0, 1}}};
        unordered_set<string> visited{{"0,1"}};
        while (!q.empty()) {
            for (int i = q.size(); i > 0; --i) {
                int pos = q.front().first, speed = q.front().second; q.pop();
                if (pos == target) return res;
                int newPos = pos + speed, newSpeed = speed * 2;
                string key = to_string(newPos) + "," + to_string(newSpeed);
                if (!visited.count(key) && newPos > 0 && newPos < (target * 2)) {
                    visited.insert(key);
                    q.push({newPos, newSpeed});
                }
                newPos = pos; 
                newSpeed = (speed > 0) ? -1 : 1;
                key = to_string(newPos) + "," + to_string(newSpeed);
                if (!visited.count(key) && newPos > 0 && newPos < (target * 2)) {
                    visited.insert(key);
                    q.push({newPos, newSpeed});
                }
            }
            ++res;
        }
        return -1;
    }
};

 

好,既然說了 DP 是神器,那么就來用用這傳說中的神器吧。首先來定義 dp 數組吧,就用一個一維的 dp 數組,長度為 target+1,其中 dp[i] 表示到達位置i,所需要的最少的指令個數。接下來就是推導最難的狀態轉移方程了,這里我們不能像 BFS 解法一樣對每個狀態都無腦嘗試加速和反向操作,因為狀態轉移方程是要跟之前的狀態建立聯系的。根據之前的分析,對於某個位置i,我們有兩種操作,一種是在到達該位置之前,回頭兩次,另一種是超過該位置后再回頭,我們就要模擬這兩種情況。

首先來模擬位置i之前回頭兩次的情況,那么這里我們就有正向加速,和反向加速兩種可能。我們假設正向加速能到達的位置為j,正向加速次數為 cnt1,反向加速能到達的位置為k,反向加速的次數為 cnt2。那么正向加速位置j從1開始遍歷,不能超過i,且根據之前的規律,j每次的更新應該是 2^cnt1 - 1,然后對於每個j位置,我們都要反向跑一次,此時反向加速位置k從0開始遍歷,不能超過j,k每次更新應該是 2^cnt2 - 1,那么到達此時的位置時,我們正向走了j,反向走了k,即可表示為正向走了 (j - k),此時的指令數為 cnt1 + 1 + cnt2 + 1,加的2個 ‘1’ 分貝是反向操作的兩次計數,當我們第二次反向后,此時的方向就是朝着i的方向了,此時跟i之間的距離可以直接用差值在 dp 數組中取,為 dp[i - (j - k)],以此來更新 dp[i]。

接下來模擬超過i位置后才回頭的情況,此時 cnt1 是剛好能超過或到達i位置的加速次數,我們可以直接使用,此時我們比較i和j,若相等,則直接用 cnt1 來更新 dp[i],否則就反向操作一次,然后距離差為 j-i,從 dp 數組中直接調用 dp[j-i],然后加上反向操作1次,用來更新 dp[i],最終返回 dp[target] 即為所求,參見代碼如下:

 

解法二:

class Solution {
public:
    int racecar(int target) {
        vector<int> dp(target + 1);
        for (int i = 1; i <= target; ++i) {
            dp[i] = INT_MAX;
            int j = 1, cnt1 = 1;
            for (; j < i; j = (1 << ++cnt1) - 1) {
                for (int k = 0, cnt2 = 0; k < j; k = (1 << ++cnt2) - 1) {
                    dp[i] = min(dp[i], cnt1 + 1 + cnt2 + 1 + dp[i - (j - k)]);
                }
            }
            dp[i] = min(dp[i], cnt1 + (i == j ? 0 : 1 + dp[j - i]));
        }
        return dp[target];
    }
};

 

下面是 DP 的遞歸寫法,跟上面的迭代寫法並沒有太大的區別,只是將直接從 dp 數組中取值的過程變成了調用遞歸函數,參見代碼如下:

 

解法三:

class Solution {
public:
    int racecar(int target) {
        vector<int> dp(target + 1, -1);
        dp[0] = 0;
        return helper(target, dp);
    }
    int helper(int i, vector<int>& dp) {
        if (dp[i] >= 0) return dp[i];
        dp[i] = INT_MAX;
        int j = 1, cnt1 = 1;
        for (; j < i; j = (1 << ++cnt1) - 1) {
            for (int k = 0, cnt2 = 0; k < j; k = (1 << ++cnt2) - 1) {
                dp[i] = min(dp[i], cnt1 + 1 + cnt2 + 1 + helper(i - (j - k), dp));
            }
        }
        dp[i] = min(dp[i], cnt1 + (i == j ? 0 : 1 + helper(j - i, dp)));
        return dp[i];
    }
};

 

Github 同步地址:

https://github.com/grandyang/leetcode/issues/818

 

參考資料:

https://leetcode.com/problems/race-car/

https://leetcode.com/problems/race-car/discuss/123834/C%2B%2BJavaPython-DP-solution

https://leetcode.com/problems/race-car/discuss/124326/Summary-of-the-BFS-and-DP-solutions-with-intuitive-explanation

 

LeetCode All in One 題目講解匯總(持續更新中...)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM