聽說逆向思維能夠降低時間複雜度?

lucifer 發表於 2022-01-28

以終為始在日常生活中指的是先確定目標,再做好計劃。之前讀管理學的書的時候,學到了這個概念。

而在演算法中,以終為始指的是從結果反向推,直到問題的初始狀態

那麼什麼時候適合反向思考呢?一個很簡單的原則就是:

  1. 正向思考的情況比較多
  2. 程式碼比較難寫或者演算法複雜度過高

這個時候我們可以考慮反向操作。

我的習慣是如果直接求解很難,我會優先考慮使用能力檢測二分,如果不行我則會考慮反向思考。

關於能力檢測二分,可以在我的公眾號中找到,大家可以在《力扣加加》回覆二分獲取。

今天西法通過三道題來給大家聊聊到底怎麼在寫演算法題的時候運用以終為始思想。

機器人跳躍問題

這道題來自於牛客網。地址:nowcoder.com/question/next?pid=16516564&qid=362295&tid=36905981

題目描述

時間限制:C/C++ 1秒,其他語言2秒

空間限制:C/C++ 32M,其他語言64M

機器人正在玩一個古老的基於DOS的遊戲。遊戲中有N+1座建築——從0到N編號,從左到右排列。編號為0的建築高度為0個單位,編號為i的建築的高度為H(i)個單位。

起初, 機器人在編號為0的建築處。每一步,它跳到下一個(右邊)建築。假設機器人在第k個建築,且它現在的能量值是E, 下一步它將跳到第個k+1建築。它將會得到或者失去正比於與H(k+1)與E之差的能量。如果 H(k+1) > E 那麼機器人就失去 H(k+1) - E 的能量值,否則它將得到 E - H(k+1) 的能量值。

遊戲目標是到達第個N建築,在這個過程中,能量值不能為負數個單位。現在的問題是機器人以多少能量值開始遊戲,才可以保證成功完成遊戲?

輸入描述:
第一行輸入,表示一共有 N 組資料.

第二個是 N 個空格分隔的整數,H1, H2, H3, ..., Hn 代表建築物的高度

輸出描述:
輸出一個單獨的數表示完成遊戲所需的最少單位的初始能量

輸入例子1:
5
3 4 3 2 4

輸出例子1:
4

輸入例子2:
3
4 4 4

輸出例子2:
4

輸入例子3:
3
1 6 4

輸出例子3:
3

思路

題目要求初始情況下至少需要多少能量。正向求解會比較困難,因此我的想法是:

  1. 能力檢測二分。比如能量 x 是否可以,如果 x 可以,那麼大於 x 的能量都可以。依此我們不難寫出程式碼。
  2. 反向思考。 雖然我們不知道最開始的能量是多少,但是我們知道最後的能量是 0 才最優,基於此我們也可以寫出程式碼。

這裡我們使用第二種方法。

具體來說:我們從後往前思考。到達最後一級的能量最少是 0 。而由於:

next = pre + (pre - H[i])

因此:

pre = (next + H[i]) / 2

由於除以 2 可能會出現小數的情況,因此需要 ceil。

你可以:

pre = math.ceil((next + H[i]) / 2)

也可以:

pre = (next + H[i] + 1) // 2
// 是地板除,即向下取整

程式碼(Python)

n = input()
H = input().split(" ")
ans = 0
for i in range(len(H) - 1, -1, -1):
    ans = (ans + int(H[i]) + 1) // 2
print(ans)

複雜度分析

  • 時間複雜度:$O(n)$
  • 空間複雜度:$O(1)$

這個題目的關鍵一句話總結就是:我們需要確定最少需要多少初始能量,因此我們是不確定最初的能量的,我們可以確定的是達到最後一個建築能量是 0 才最優。

2139. 得到目標值的最少行動次數

第二道題是 2022.01 月份的一場周賽的第二題,題目還是比較新的。

題目地址

https://leetcode-cn.com/probl...

題目描述

你正在玩一個整數遊戲。從整數 1 開始,期望得到整數 target 。

在一次行動中,你可以做下述兩種操作之一:

遞增,將當前整數的值加 1(即, x = x + 1)。
加倍,使當前整數的值翻倍(即,x = 2 * x)。

在整個遊戲過程中,你可以使用 遞增 操作 任意 次數。但是隻能使用 加倍 操作 至多 maxDoubles 次。

給你兩個整數 target 和 maxDoubles ,返回從 1 開始得到 target 需要的最少行動次數。

 

示例 1:

輸入:target = 5, maxDoubles = 0
輸出:4
解釋:一直遞增 1 直到得到 target 。


示例 2:

輸入:target = 19, maxDoubles = 2
輸出:7
解釋:最初,x = 1 。
遞增 3 次,x = 4 。
加倍 1 次,x = 8 。
遞增 1 次,x = 9 。
加倍 1 次,x = 18 。
遞增 1 次,x = 19 。


示例 3:

輸入:target = 10, maxDoubles = 4
輸出:4
解釋:
最初,x = 1 。
遞增 1 次,x = 2 。
加倍 1 次,x = 4 。
遞增 1 次,x = 5 。
加倍 1 次,x = 10 。


 

提示:

1 <= target <= 109
0 <= maxDoubles <= 100

思路

由於剛開始數字為 1,最終狀態為 target。因此正向思考和反向思考都是 ok 的。

而如果正向模擬的話,雖然很容易實現,但是時間複雜度太高。

這是因為從 1 開始我們有兩個選擇(如果仍然可以加倍),接下來仍然有兩個選擇(如果仍然可以加倍)。。。

因此時間複雜度大致為 $O(target * maxDoubles)$。代入題目給的資料範圍顯然是無法通過的。

而正向思考比較困難,我們不妨從反向進行思考。

從 target 開始,如果 target 是奇數,顯然我們只能通過 + 1 而來,即使我們仍然可以加倍。這樣時間複雜度就降低了。不過這還不夠,進而我們發現如果 target 為偶數我們應該選擇加倍到 target(如果仍然可以加倍),而不是 + 1 到 target。這是因為

  1. 我們是反向思考的,如果你現在不選擇加倍而是後面再選擇加倍那麼加倍帶來的收益會更低
  2. 加倍的收益一定大於 + 1,換句話說加倍可以更快達到 target。

基於此,不難寫出如下程式碼。

程式碼

  • 語言支援:Python3

Python3 Code:


class Solution:
    def minMoves(self, target: int, maxDoubles: int) -> int:
        ans = 0
        while maxDoubles and target != 1:
            ans += 1
            if target % 2 == 1:
                target -= 1
            else:
                maxDoubles -= 1
                target //= 2
        ans += (target - 1)
        return ans

複雜度分析

如果 maxDoubles 無限大,那麼時間大概是 $log target$。而如果 target 無限大,那麼時間大概是 maxDoubles。因此時間複雜度為 $O(min(maxDouble, log target))$

  • 時間複雜度:$O(min(maxDouble, log target))$
  • 空間複雜度:$O(1)$

LCP 20. 快速公交

最後一道題是力扣杯的一道題,難度為 hard,我們來看下。

題目地址(20. 快速公交)

https://leetcode-cn.com/probl...

題目描述

小扣打算去秋日市集,由於遊客較多,小扣的移動速度受到了人流影響:

小扣從 x 號站點移動至 x + 1 號站點需要花費的時間為 inc;
小扣從 x 號站點移動至 x - 1 號站點需要花費的時間為 dec。

現有 m 輛公交車,編號為 0 到 m-1。小扣也可以通過搭乘編號為 i 的公交車,從 x 號站點移動至 jump[i]*x 號站點,耗時僅為 cost[i]。小扣可以搭乘任意編號的公交車且搭乘公交次數不限。

假定小扣起始站點記作 0,秋日市集站點記作 target,請返回小扣抵達秋日市集最少需要花費多少時間。由於數字較大,最終答案需要對 1000000007 (1e9 + 7) 取模。

注意:小扣可在移動過程中到達編號大於 target 的站點。

示例 1:

輸入:target = 31, inc = 5, dec = 3, jump = [6], cost = [10]

輸出:33

解釋:
小扣步行至 1 號站點,花費時間為 5;
小扣從 1 號站臺搭乘 0 號公交至 6 * 1 = 6 站臺,花費時間為 10;
小扣從 6 號站臺步行至 5 號站臺,花費時間為 3;
小扣從 5 號站臺搭乘 0 號公交至 6 * 5 = 30 站臺,花費時間為 10;
小扣從 30 號站臺步行至 31 號站臺,花費時間為 5;
最終小扣花費總時間為 33。

示例 2:

輸入:target = 612, inc = 4, dec = 5, jump = [3,6,8,11,5,10,4], cost = [4,7,6,3,7,6,4]

輸出:26

解釋:
小扣步行至 1 號站點,花費時間為 4;
小扣從 1 號站臺搭乘 0 號公交至 3 * 1 = 3 站臺,花費時間為 4;
小扣從 3 號站臺搭乘 3 號公交至 11 * 3 = 33 站臺,花費時間為 3;
小扣從 33 號站臺步行至 34 站臺,花費時間為 4;
小扣從 34 號站臺搭乘 0 號公交至 3 * 34 = 102 站臺,花費時間為 4;
小扣從 102 號站臺搭乘 1 號公交至 6 * 102 = 612 站臺,花費時間為 7;
最終小扣花費總時間為 26。

提示:

1 <= target <= 10^9
1 <= jump.length, cost.length <= 10
2 <= jump[i] <= 10^6
1 <= inc, dec, cost[i] <= 10^6

思路

由於起點是 0,終點是 target。和上面一樣,正向思考和反向思考難度差不多。

那麼我們可以正向思考麼? 和上面一樣正向思考情況太多,複雜度過高。

那麼如何反向思考呢?反向思考如何優化複雜度的呢?

由於題目可以在移動過程中到達編號大於 target 的站點,因此正向思考過程中座標大於 target 的很多點我們都需要考慮。

而如果反向思考,我們是不能在移動過程中到達編號大於 0 的站點的,因此情況就大大減少了。而達編號大於 target 的站點只需要思考向右移動後再乘坐公交返回 target 的情況即可(也就是說我們是做了公交然後往回走的情況)

對於每一個位置 pos,我們都思考:

  1. 全部走路
  2. 直接乘公交
  3. 走幾步再乘公交

在這三種情況取最小值即可。

問題的關鍵是情況 3,我們如何計算是走幾步再乘公交呢?如果反向思考,我們可以很簡單地通過 pos % jump[i] 算出來,而開始乘公交的點則是 pos // jump。

程式碼

  • 語言支援:Python3

Python3 Code:


class Solution:
    def busRapidTransit(self, target: int, inc: int, dec: int, jumps: List[int], cost: List[int]) -> int:
        @lru_cache(None)
        def dfs(pos):
            if pos == 0: return 0
            if pos == 1: return inc
            # 最壞的情況是全部走路,不乘公交,這種情況時間為 pos * inc
            ans = pos * inc
            for i, jump in enumerate(jumps):
                pre_pos, left = pos // jump, pos % jump
                if left == 0: ans = min(ans, cost[i] + dfs(pre_pos))
                else: ans = min(ans, cost[i] + dfs(pre_pos) + inc * left, cost[i] + dfs(pre_pos + 1) + dec * (jump - left))
            return ans
        return dfs(target) % 1000000007

複雜度分析

令 T 為 jump 陣列的長度。

  • 時間複雜度:$O(target * T)$
  • 空間複雜度:$O(target)$

總結

反向思考往往可以達到降維打擊的效果。有時候可以使得求解思路更簡單,程式碼更好寫。有時候可以使得情況更少,複雜度降低。

回顧一下什麼時候使用反向思考呢?一個很簡單的原則就是:

  1. 正向思考的情況比較多
  2. 程式碼比較難寫或者演算法複雜度過高

我給大家舉了三個例子來說明如何運用反向思考技巧。其中

  • 第一題正向思考只能使用逐一列舉的方式,當然我們可以使用二分降低複雜度,但是複雜度仍然不及反向思考。
  • 第二題反向思考情況大大減少,複雜度指數級降低,真的是降維打擊了。
  • 第三題利用無法超過 0 的位置這點,反向思考降低複雜度。

這些題還是冰山一角,實際做題過程中你會發現反向思考很常見,只是主流的演算法劃分沒有對應的專題罷了 。我甚至還有想法將其加入91 天學演算法中,就像後期加列舉章節一樣,我認為反向思考也是一個基礎的演算法思考,請諸君務必掌握!