寶寶也能看懂的 leetcode 周賽 - 174 - 4

1340. 跳躍遊戲 V

Hi 你們好,我是張小豬。歡迎來到『寶寶也能看懂』系列之 leetcode 周賽題解。node

這裏是第 174 期的第 4 題,也是題目列表中的第 1340 題 -- 『跳躍遊戲 V』git

題目描述

給你一個整數數組 arr 和一個整數 d。每一步你能夠從下標 i 跳到:github

  • i + x,其中 i + x < arr.length 且 0 < x <= d
  • i - x,其中 i - x >= 0 且 0 < x <= d

除此之外,你從下標 i 跳到下標 j 須要知足:arr[i] > arr[j] 且 arr[i] > arr[k],其中下標 k 是全部 i 到 j 之間的數字(更正式的,min(i, j) < k < max(i, j))。shell

你能夠選擇數組的任意下標開始跳躍。請你返回你 最多 能夠訪問多少個下標。segmentfault

請注意,任什麼時候刻你都不能跳到數組的外面。數組

示例 1:緩存

1340-1.jpeg

輸入:arr = [6,4,14,6,8,13,9,7,10,6,12], d = 2
輸出:4
解釋:你能夠從下標 10 出發,而後如上圖依次通過 10 --> 8 --> 6 --> 7 。
注意,若是你從下標 6 開始,你只能跳到下標 7 處。你不能跳到下標 5 處由於 13 > 9 。你也不能跳到下標 4 處,由於下標 5 在下標 4 和 6 之間且 13 > 9 。
相似的,你不能從下標 3 處跳到下標 2 或者下標 1 處。

示例 2:數據結構

輸入:arr = [3,3,3,3,3], d = 3
輸出:1
解釋:你能夠從任意下標處開始且你永遠沒法跳到任何其餘座標。

示例 3:性能

輸入:arr = [7,6,5,4,3,2,1], d = 1
輸出:7
解釋:從下標 0 處開始,你能夠按照數值從大到小,訪問全部的下標。

示例 4:優化

輸入:arr = [7,1,7,1,7,1], d = 2
輸出:2

示例 5:

輸入:arr = [66], d = 1
輸出:1

提示:

  • 1 <= arr.length <= 1000
  • 1 <= arr[i] <= 10^5
  • 1 <= d <= arr.length

官方難度

HARD

解決思路

題目的意思仍是稍微解釋一下吧。首先是給定了一個數組 arr,其中每個值表示當前下標的高度。而後給定了一個每次移動的最遠距離 d。能夠以任何一個點爲出發點,需求是想找到能移動的最大的步數。

每一次移動須要從當前位置開始,向左和右最大爲 d 的範圍內,選擇下一步的位置。其中在移動的時候,咱們的途徑點必定都須要是比當前位置低才行,即咱們沒法到達與當前位置高度想等或者比當前位置高的地方,哪怕還處於最大範圍 d 之中。而且咱們不能移動到數據外面去,即目標地點不能超過下標範圍 [0, arr.length)

若是仍是比較難明白的話,咱們能夠先看一下下面這個圖:

1340-2.png

假設最大的移動範圍爲 2,那麼結合上圖以及題目的要求,能夠總結幾個栗子:

  • 沒法從 A 移動到 D,由於超出了最大距離 2。
  • 沒法從 G 移動到 E,由於中間的 F 高度比 G 高。
  • 沒法從 B 移動到 D,由於中間的 C 高度和 B 同樣。
  • 能夠從 A 移動到 C,由於位於最大範圍以內,而且中間的高度都比 A 要低。
  • 能夠從 C 移動到 D 或者 E。

理完題目以後,接下來看看如何處理這個問題。按照慣例,咱們對於上圖中的狀況,能夠先嚐試寫可能的路線看看。

  • 若是咱們是從 C 點出發,那麼咱們能夠直接到達的下一步是,D 和 E。
  • 若是咱們是從 E 點出發,那麼咱們的下一步只有 D。
  • 若是咱們是從 D 點出發,那麼咱們沒有能夠選擇的下一步了。

上面是以從 C 點出發開始,展開的一個栗子。其實咱們也能夠嘗試一下,從其餘的點出發,會發現均可以展開成相似的栗子。即基於當前的位置咱們能夠嘗試判斷條件後找出下一步可能的位置,而且不斷輪迴。直到咱們沒有下一步了,就能獲得一條完整的路線了,從而也就知道了這條路線的長度。

若是咱們知道了從一個點出發的全部路線的長度,那麼其實也就知道了以這個點爲出發點的最大步數。若是咱們要寫成相似公式的話其實就是:dp[i] = 1 + Math.max(dp[j]: j in range[i])

而若是咱們知道了全部點的最大步數,也就能獲得最終題目的需求了。

下面小豬爆肝了 5 種方案,但願喜歡的小夥伴們多多三連支持!(等等,好像這不是 B 站啊喂 >.<

直接方案

回看上面的分析過程,其實會發現,若是放在程序裏的話,這就是一個不斷遞歸的過程。而整個尋找的方案,其實就是咱們熟悉的深度優先遍歷。那麼咱們來嘗試整理一下流程吧,千萬別忘了用緩存,不然性能會差不少。

  1. 初始化緩存。
  2. 嘗試以每個點都做爲起點,尋找它的下一步,並經過比較求得最大值。
  3. 基於最遠範圍和高度,判斷可能的下一步。
  4. 不斷的遞歸尋找,直到沒有下一步了,則結束遞歸。

基於這個流程,咱們能夠實現相似下面的代碼:

const maxJumps = (arr, d) => {
  const cache = new Uint16Array(arr.length);
  return Math.max(...arr.map((v, i) => helper(i)));

  function helper(cur) {
    if (cache[cur] === 0) {
      let max = 0;
      for (let i = cur + 1; i <= cur + d && i < arr.length && arr[i] < arr[cur]; ++i) {
        max = Math.max(helper(i), max);
      }
      for (let i = cur - 1; i >= cur - d && i >= 0 && arr[i] < arr[cur]; --i) {
        max = Math.max(helper(i), max);
      }
      cache[cur] = 1 + max;
    }
    return cache[cur];
  }
};

換個思路

上面那個思路咱們能夠認爲是一種自頂向下的分解方式,即咱們把一個大的任務拆分紅許多小的任務,而後再根據這些小任務的結果來獲得大任務的結果。那麼咱們是否能夠換一個方向,自底向上的來解決這個問題呢?

這樣處理的話,意味着咱們每次都須要知道當前能夠完成的小任務,完成以後再去尋找下一批能夠由當前情況組合推斷的大任務。直到咱們的大任務覆蓋了全部內容,也就完成了最終的目標。那麼問題來了,挖掘機 咱們如何知道目前的小任務呢?

這裏咱們能夠回頭看一下題目的條件,即只能從比較高的地方走到比較低的地方。那麼對於全部位置中,高度最低的地方,其實它的值已經肯定了,由於到這裏以後便無路可走。

那麼順着這個思路,對高度第二低的地方呢?這時候它的最大值其實仍是不肯定的,由於它有可能可以去到最低的地方,那麼最大值即是 2;也有可能沒法去到最低的地方,那麼最大值即是 1。因此咱們須要作相關的判斷才能獲得結果。

那麼再繼續,以這樣從低到高的順序,咱們如何求得任意一個位置的最大值呢?因爲全部的比它低的位置已經有最大值了,因此其實很簡單,就是找到那些可能走到而且有值的位置裏面的最大值,而後加 1 便可。

基於這個思路,咱們能夠整理出以下流程:

  1. 按照高度把原始數據進行排序。
  2. 從低到高開始,更新以當前點爲出發點時的最大步數:

    1. 尋找當前點有值的下一步。
    2. 取最大值並加 1 作爲當前點的最大步數。
  3. 比較並記錄最終的最大步數。

基於這個流程,咱們能夠實現相似下面的代碼:

const maxJumps = (arr, d) => {
  const LEN = arr.length;
  const sortedHeights = arr.map((val, idx) => [val, idx]).sort((a, b) => a[0] === b[0] ? a[1] - b[1] : a[0] - b[0]);
  const steps = new Uint16Array(LEN);
  let ret = 0;
  for (const [height, cur] of sortedHeights) {
    let max = 0;
    for (let i = cur + 1; i <= cur + d && i < LEN && arr[i] < height; ++i) {
      steps[i] > max && (max = steps[i]);
    }
    for (let i = cur - 1; i >= cur - d && i >= 0 && arr[i] < height; --i) {
      steps[i] > max && (max = steps[i]);
    }
    steps[cur] = max + 1;
    steps[cur] > ret && (ret = steps[cur]);
  }
  return ret;
};

又換個思路

這個思路的前戲會比較多一些,由於咱們須要藉助一個數據結構 -- 線段樹,例以下圖就是一個線段樹。這裏作一個簡單的介紹吧。

1340-3.png

首先,線段樹是一種二叉搜索樹,至於什麼是二叉搜索樹這裏就不作過多展開了,不太清楚的小夥伴們能夠想象一下對於有序數組進行二分查找的過程。不過線段樹的節點並不僅是記錄了值,並且還標識了以當前節點爲頂點的子二叉樹的範圍,例如上圖中的根節點的範圍就是 [1, 10]。而全部的葉節點必定是一個長度爲 1 的範圍。

那麼咱們這裏要用它來作什麼呢?咱們能夠現象一下,在上圖中的樹裏,咱們若是知道了 1 的值,知道了 2 的值,是否是就能知道 1 和 2 的最大值,也就是節點 1~2 的值。若是咱們再知道 3 的值,那麼範圍 [1, 3] 的最大值,即節點 1~3 的值,也就知道了。以此類推,咱們能夠很容易的獲得整個範圍 [1, 10] 的最大值。

而後咱們再來看,假如我如今想獲得範圍 [2, 7] 的最大值,其實也是能夠輕鬆的獲取的。即咱們根據目標範圍和當前範圍的拆分點,從根節點開始向下搜索這個二叉樹。最終咱們所須要的最大值會被以這樣的方式計算 Math.max(val(2), val(3), val(4~5), val(6~7))

這時候可能會有小夥伴有疑問,若是經過一次遍歷,也能獲得一個範圍的最大值呀。爲何須要使用線段樹呢?其實確實是的,只不過一次遍歷的時間複雜度是 O(n),而線段樹的查找會是 O(logn)。而且,若是某個葉節點的值產生了變化,咱們也能夠方便的在 O(logn) 的時間裏更新這棵樹。

不過因爲 JS 沒有直接的內置相似的數據結構,因此咱們須要手動實現一下,而且這個 SegmentTree 類也不是一個通用類,它爲了這道題作了一點調整。

另外,這個思路還用到一種基於棧的處理數據的方式,而這裏咱們用到的棧,即稱爲單調棧。這種處理數據的方式核心思路就是,保持棧內的數據是單調遞增或者單調遞減的,從而方便結合後續新來的數進行邏輯處理。這裏咱們舉個栗子,以輸入數據 [6, 4, 14, 6, 8, 13, 9] 來看,咱們保持棧的單調遞減,整個過程以下圖:

1340-4.png

相信看完這個過程,小夥伴們應該能明白其中的邏輯了吧。不過咱們這裏爲何須要使用單調棧呢?其實咱們能夠看看它的性質保留的都是距離當前位置最近的一個更大的值。因此基於此,咱們能夠經過一次遍歷就獲得全部的點在一側的下一個更大的值。那麼左右各來一次,咱們就能獲得每個點左右兩個方向的下一個更大的值。

而這個更大的值有什麼用呢?能夠回想一下題目的條件 -- 咱們只能去到比當前位置更低的位置,那麼這個更大的值的做用也就浮出水面啦。

這裏關於線段樹和單調棧的更多內容,就不作過多的展開和說明了,可能會在後續數據結構的專題裏詳細的說。下面咱們來整理一下流程:

  1. 基於單調棧獲取到每一個點的左右下一個更大的值。
  2. 按照高度排序。
  3. 初始化線段樹,並按照順序更新每個葉節點。
  4. 返回全範圍的查詢值便可。

基於這個流程,咱們能夠實現相似下面的代碼:

class SegmentTree {
  constructor(len) {
    this.data = new Array(len * 4);
    this.build(0, 0, len - 1);
  }
  build(cur, left, right) {
    if (left === right) { this.data[cur] = [left, right, 0]; return; }
    const mid = Math.floor((left + right) / 2);
    this.data[cur] = [left, right, 0];
    this.build(cur * 2 + 1, left, mid),
    this.build(cur * 2 + 2, mid + 1, right)
  }
  query(left, right, cur = 0) {
    const node = this.data[cur];
    if (node[0] === left && node[1] === right) return node[2];
    const mid = Math.floor((node[0] + node[1]) / 2);
    if (left > mid) return this.query(left, right, cur * 2 + 2);
    if (right <= mid) return this.query(left, right, cur * 2 + 1);
    return Math.max(
      this.query(left, mid, cur * 2 + 1),
      this.query(mid + 1, right, cur * 2 + 2),
    );
  }
  update(idx, value, cur = 0) {
    const node = this.data[cur];
    if (node[0] === node[1] && node[0] === idx) { node[2] = value; return; }
    const mid = Math.floor((node[0] + node[1]) / 2);
    this.update(idx, value, idx > mid ? cur * 2 + 2 : cur * 2 + 1);
    value > node[2] && (node[2] = value);
  }
}

const maxJumps = (arr, d) => {
  const LEN = arr.length;
  const tree = new SegmentTree(LEN);
  const sortedHeights = arr.map((val, idx) => [val, idx]).sort((a, b) => a[0] === b[0] ? a[1] - b[1] : a[0] - b[0]);
  const leftTops = new Int16Array(LEN);
  const rightTops = new Int16Array(LEN);

  for (let i = 0, j = LEN - 1, lstack = [], ltop = -1, rstack = [], rtop = -1; i < LEN; ++i, --j) {
    while (ltop >= 0 && arr[lstack[ltop]] < arr[i]) { lstack.pop(); --ltop; }
    leftTops[i] = ltop === -1 ? -1 : lstack[ltop];
    lstack[++ltop] = i;
    while (rtop >= 0 && arr[rstack[rtop]] < arr[j]) { rstack.pop(); --rtop; }
    rightTops[j] = rtop === -1 ? LEN: rstack[rtop];
    rstack[++rtop] = j;
  }

  for (const item of sortedHeights) {
    const idx = item[1];
    tree.update(idx, 1 + tree.query(
      Math.max(leftTops[idx] + 1, idx - d),
      Math.min(rightTops[idx] - 1, idx + d)
    ));
  }

  return tree.query(0, LEN - 1);
};

雙換個思路

不知道小夥們們看到這裏的話會不會已經累了,哈哈哈哈。咱們換個姿式,再來一次!

看完前面的內容以後,相信對於這種思路會很是容易理解,由於它其實就是以前一些方案的融合。

咱們這裏一樣是用到了單調棧來獲取每一個節點兩側的最近的大值,只不事後續處理的方式替換爲基於緩存 + 深度優先遍從來實現,能夠認爲是前面深度優先遍歷方案的分支優化版本。順便也把上面代碼裏的單調棧的部分寫的更好看了一些。

那麼流程這裏就不寫啦,直接給出代碼:

const maxJumps = (arr, d) => {
  const LEN = arr.length;
  const cache = new Uint16Array(LEN);
  const map = Array.from({ length: LEN }, () => []);

  for (let left = 0, right = LEN - 1, ltop = -1, rtop = -1, lstack = new Uint16Array(LEN), rstack = new Uint16Array(LEN); left < LEN; ++left, --right) {
    ltop = upStack(lstack, ltop, left);
    rtop = upStack(rstack, rtop, right);
  }
  return Math.max(...arr.map((v, i) => helper(i)));

  function upStack(stack, top, i) {
    while (top >= 0 && arr[stack[top]] < arr[i]) {
      const idx = stack[top--];
      Math.abs(idx - i) <= d && map[i].push(idx);
    }
    stack[++top] = i;
    return top;
  }

  function helper(cur) {
    cache[cur] === 0 && (
      cache[cur] = 1 + (map[cur].length && Math.max(...map[cur].map(helper)))
    );
    return cache[cur];
  }
};

叒換個思路

小豬答應你,這真的是最後一個了 >.<

這裏咱們拋棄了前面全部思路中,對於每個值去嘗試它兩側可能的下一步這個核心思路。轉而基於單調棧的方式直接推出結果。可能初看起來會比較繞,不過放心,有小豬在,神馬都是紙腦撫 >.<

1340-2.png

如上圖,咱們首先看回最初的這個栗子,按照以前單調棧的思路,在執行過程當中,咱們會遇到如下幾種狀況:

  1. 當咱們執行到 E 的時候,D 會出棧,因而咱們能夠根據遞推公式更新 E 的值。同理當執行到 F 的時候,也是 E 出棧,因而基於 E 的值來對 F 進行更新。
  2. 當咱們執行到 G 的時候,因爲後面沒有值了,因此咱們沒法觸發基於 G 產生的更新。這個問題,咱們能夠在最初的時候在末尾插入一個最大值,從而保證 G 可以被正常觸發更新。
  3. 當咱們執行到 C 的時候,因爲 B 和 C 的值是同樣的,因此這裏須要進行特殊的處理。這是一個很是容易漏掉的地方,下面咱們詳細看看這裏的處理邏輯。

首先,按照題目要求,咱們沒法從 B 移動到 C,也沒法從 C 移動到 B。這也就意味着,對於存在相等值的遞減狀況,咱們不能一味的向上更新。例如這裏從 D 到 C 到 A 這樣更新 A 的值,若是範圍只有 1,那麼實際上是行不通的。再換一種狀況,那麼對於 C 以後的值,是否就能夠無視 B 了呢?也不必定。例如若是範圍夠大,F 是能夠跳過 C 直接走到 B 的。

那麼這裏應該怎麼處理這些狀況呢?其實咱們能夠在出棧遇到相同值的時候,把它們全都取出來,並對觸發該次出棧的位置和上一個非相同值的位置進行判斷和更新便可。

那麼好啦,到這裏咱們就已經有了處理思路了,接下來整理一下流程吧:

  1. 末尾加入極大值(題目的數據範圍是 [1, 10^5])。
  2. 基於單調棧的方式遍歷原始數組。
  3. 在觸發出棧的時候更新之前的值,須要注意對於相同高度的特殊處理。
  4. 移除末尾的值。
  5. 取結果數組中的最大值。

基於這個流程,咱們能夠實現相似下面的代碼:

const maxJumps = (arr, d) => {
  arr.push(10 ** 5 + 1);
  const LEN = arr.length;
  const dp = new Uint16Array(LEN).fill(1);
  for (let i = 1, top = 0, stack = new Uint16Array(LEN); i < LEN; ++i) {
    while (top >= 0 && arr[stack[top]] < arr[i]) {
      let prevNoneSame = top;
      const height = arr[stack[top]];
      while (arr[stack[prevNoneSame]] === height) --prevNoneSame;
      while (arr[stack[top]] === height) {
        const idx = stack[top--];
        i - idx <= d && dp[idx] + 1 > dp[i] && (dp[i] = dp[idx] + 1);
        prevNoneSame >= 0 && idx - stack[prevNoneSame] <= d && dp[idx] + 1 > dp[stack[prevNoneSame]] && (dp[stack[prevNoneSame]] = dp[idx] + 1);
      }
    }
    stack[++top] = i;
  }
  dp[LEN - 1] = 0;
  return Math.max(...dp);
};

這段代碼跑了 52ms,暫時 beats 100%。

總結

終於結束啦,小豬長舒了一口氣。對於這個問題,小豬一下爆肝了 5 種方案,忽然以爲,這仍是那個懶懶小豬麼,必定是找人代寫了,哼 >.<

這道題嘗試給出這些不一樣的解決方案,其實主要就是想給小夥伴們提供不一樣的思考方向和可能性,也算是對小豬本身的一個小小的練習吧。

要是小夥伴們喜歡的話,不要忘了三連哦~ 小豬愛大家鴨,麼麼嗒 >.<

相關連接

qrcode_green.jpeg

相關文章
相關標籤/搜索