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

1320. 二指輸入的的最小距離

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

這裏是第 171 期的第 4 題,也是題目列表中的第 1320 題 -- 『二指輸入的的最小距離』github

題目描述

1320-1.png

二指輸入法定製鍵盤在 XY 平面上的佈局如上圖所示,其中每一個大寫英文字母都位於某個座標處,例如字母 A 位於座標 (0,0),字母 B 位於座標 (0,1),字母 P 位於座標 (2,3) 且字母 Z 位於座標 (4,1)算法

給你一個待輸入字符串 word,請你計算並返回在僅使用兩根手指的狀況下,鍵入該字符串須要的最小移動總距離。座標 (x1,y1)(x2,y2) 之間的距離是 |x1 - x2| + |y1 - y2|shell

注意,兩根手指的起始位置是零代價的,不計入移動總距離。你的兩根手指的起始位置也沒必要從首字母或者前兩個字母開始。segmentfault

示例 1:數組

輸入:word = "CAKE"
輸出:3
解釋:
使用兩根手指輸入 "CAKE" 的最佳方案之一是:
手指 1 在字母 'C' 上 -> 移動距離 = 0
手指 1 在字母 'A' 上 -> 移動距離 = 從字母 'C' 到字母 'A' 的距離 = 2
手指 2 在字母 'K' 上 -> 移動距離 = 0
手指 2 在字母 'E' 上 -> 移動距離 = 從字母 'K' 到字母 'E' 的距離  = 1
總距離 = 3

示例 2:佈局

輸入:word = "HAPPY"
輸出:6
解釋:
使用兩根手指輸入 "HAPPY" 的最佳方案之一是:
手指 1 在字母 'H' 上 -> 移動距離 = 0
手指 1 在字母 'A' 上 -> 移動距離 = 從字母 'H' 到字母 'A' 的距離 = 2
手指 2 在字母 'P' 上 -> 移動距離 = 0
手指 2 在字母 'P' 上 -> 移動距離 = 從字母 'P' 到字母 'P' 的距離 = 0
手指 1 在字母 'Y' 上 -> 移動距離 = 從字母 'A' 到字母 'Y' 的距離 = 4
總距離 = 6

示例 3:優化

輸入:word = "NEW"
輸出:3

示例 4:spa

輸入:word = "YEAR"
輸出:7

提示:code

  • 2 <= word.length <= 300
  • 每一個 word[i] 都是一個大寫英文字母。

官方難度

HARD

解決思路

題目的內容很是簡單,就是用兩根手指,輸入一串給定的字符串,要求返回手指移動的最短距離。其中鍵盤佈局已經經過圖片給出來了。

看完題目以後,小豬蹄子一蹬,尾巴一翹,由於一般描述很簡單的題目,多是真的特別簡單,又或者可能就是思考起來比較複雜。而這道題的難度是 HARD...是時候再玩幾盤吃雞放鬆一下了 是時候認真了呢,哼唧 >.<

先不思考題目邏輯,單看需求,第一個很是明顯的就是咱們須要知道手指在鍵盤上從一個字母移動到另外一個字母的開銷。那咱們就先來實現這一個 helper 方法吧。

因爲英文字母是按照順序排列的,因此咱們能夠很容易的想到取 char code 來作計算。結合題目給出的移動距離公式 |x1 - x2| + |y1 - y2|,咱們能夠很天然的想到,把距離拆解成橫向和縱向兩個方向計算,最後再求和。因爲每一行的字母數量是固定的,因此對於縱向距離來講,咱們能夠分別求出兩個字母所在的行數,而後相減便可。而對於橫向距離,一樣由於每一行的字符數量固定,咱們能夠直接進行取模運算,而後相減便可。

這裏須要注意的一點就是別忘了減完以後要取絕對值。具體代碼可能相似下面這樣:

function distance(a, b) {
  const x = word.charCodeAt(a) - 65;
  const y = word.charCodeAt(b) - 65;
  return Math.abs((x % 6) - (y % 6)) + Math.abs(((x / 6) << 0) - ((y / 6) << 0));
}

接下來就到了核心問題,若是肯定解法的邏輯。這裏若是一時之間以爲無從下手的話,能夠先舉幾個例子看看:

  • 若是有一個字符,那麼咱們的開銷是 distance(a, a),也就是 0
  • 若是有兩個字符,那麼咱們的開銷是 distance(a, a) + distance(b, b),也是 0
  • 若是有三個字符,那麼咱們的開銷有多是如下幾種狀況:

    • distance(a, a) + distance(b, b) + distance(b, c)
    • distance(a, a) + distance(b, b) + distance(a, c)
    • distance(a, a) + distance(a, b) + distance(c, c)
  • 若是有四個字符,那麼咱們的開銷有多是如下幾種狀況:

    • distance(a, a) + distance(b, b) + distance(b, c) + distance(c, d)
    • distance(a, a) + distance(b, b) + distance(b, c) + distance(a, d)
    • distance(a, a) + distance(b, b) + distance(a, c) + distance(c, d)
    • distance(a, a) + distance(b, b) + distance(a, c) + distance(b, d)
    • distance(a, a) + distance(a, b) + distance(c, c) + distance(c, d)
    • distance(a, a) + distance(a, b) + distance(c, c) + distance(b, d)
    • distance(a, a) + distance(a, b) + distance(b, c) + distance(d, d)
  • 若是有五個字符...等等,你想累死小豬嗎,雖然豬肉貴了...

小夥伴們能夠稍微仔細的看一下上面的幾個例子,我把順序寫的挺故意的,就是想更輕易的展現其中的信息。下面羅列一下咱們能夠發現的一些事情:

  • 第一個信息就是,咱們的手指必定是先從第一個字符開始的。(啊,不要白眼...
  • 第二個信息就是,咱們的一根手指最終必定會落在結尾處。(啊,不要打我...
  • 第三個信息就是,咱們若是已經使用了兩根手指(嗯?怎麼怪怪的...),那麼繼續下一步的最優解可能會有兩種狀況:

    • 移動第一根手指到最後。
    • 移動第二根手指到最後。
  • 第四個信息就是,咱們若是尚未使用第二根手指,那麼繼續下一步的最優解必定是用上第二根手指。

上面四個信息可能看起來很是的不值一提,不過這是咱們後續推導的根基。接下來咱們進行正式的邏輯部分。

動態規劃

咱們上面獲得了一些步驟之間手指的基本信息,那麼這時候可能會有小夥伴提出這樣的設想。咱們直接把第一根手指放在開始,第二根手指放在離它最遠的地方。而後每次字符移動的時候用離目標最近的一根手指移動,這樣是否是就解決了呢?有了這種設想以後,咱們能夠用題目給的例子來試一下,例如 "CAKE", "HAPPY" 等,咱們會發現能獲得正確的結果。那麼這樣看起來,這道題彷佛很簡單鴨。

彆着急,咱們再來看另一個例子 "ZKNBZ"。按照上面的思路,咱們會把手指放在 "Z" 和離它最遠的 "K",而後根據離目標最近的原則,繼續作如下的移動 "Z" => "N", "N" => "B", "B" => "Z"。最終移動的總距離爲 8。但是,咱們回頭看看這個方案,仍舊把手指放在 "Z" 和 "K",而後進行如下的移動 "K" => "N", "N" => B","Z" => "Z"。這樣最終的總移動距離爲 6。這也就證實了,咱們前面的那種設想是有問題的。

那麼問題出在哪裏呢?這裏引入兩個概念,局部最優解和全局最優解。前者指的是在當前情況下咱們獲得的最優解,例如上面設想中的每一次移動都使用距離最近的手指,這樣在當前這一步咱們確實獲得了最優解。然後者指的是在總體流程結束後咱們獲得的最優解,例如上面例子中最後獲得了 6 這個解。那麼很顯然,咱們獲得了局部最優解,但是沒有獲得全局最優解。

相信有的小夥伴這時候已經反應過來了,上面的那種設想不就是一種貪心算法的實現麼,也就是企圖用局部最優解最終推導出全局最優解。但是這是有條件的,對於咱們這裏的題目,局部最優解就不能推導出全局最優解。因此,咱們應該用動態規劃的方式嘗試基於步驟之間的關係來推導出全局最優解。

那麼接下來咱們來看看步驟之間的關係吧。這裏先解釋後面會用到的幾個數據的意思:

  • 假設當前兩根手指所處的位置分別是 xy,其中 x < y,那麼對於咱們從位置 0 開始一直到 y 這一段字符串,當在一根手指放在 x 的條件下時,最優解值稱爲 dp[x][y]
  • 一根手指從位置 x 移動到 y 的距離稱爲 distance(x, y)
  • 一根手指從位置 0 移動到位置 1,並持續移動到位置 x,所須要的距離的總和稱爲 sum(x)

那麼接下來咱們來看看 dp[x][y] 的狀況,咱們能夠先舉幾個具體的例子方便思考:

  • 對於 dp[3][6],它的來源只多是 dp[3][5] + distance(5, 6)
  • 對於 dp[5][6],它的來源就比較複雜了,可能來自於 dp[0][5] + distance(0, 6),也多是 dp[1][5] + distance(1, 6),一直到 dp[4][5] + distance(4, 6),另外千萬別忘了還多是 sum(5) + distance(6, 6)

看到這裏小夥伴們有沒有發現咱們最開始舉的幾個例子和獲得的那幾個信息的意義啦。正所謂古語有云:重要信息,不要錢 4 個,嘿嘿 >.<

那麼把上面這個例子再抽象成 dp[x][y],咱們能夠獲得如下計算方法:

dp[x][y] = x !== y - 1
  ? dp[x][y - 1] + distance(y - 1, y)
  : Math.min(sum(x), dp[0][x] + distance(0, y), dp[1][x] + distance(1, y), ..., dp[x - 1][x] + distance(x - 1, y))

這時候咱們看看題目所須要求的那個結果是什麼,若是用上面咱們的幾個值來表達,其實就是 Math.min(sum(n - 1), dp[0][n - 1], dp[1][n - 1], ..., dp[n - 2][n - 1])

如今咱們有了遞推公式,也有了最終結果的取值,咱們只須要用代碼實現其中的計算便可。具體流程以下:

  1. 初始化 dp 數組和 sum 數組。
  2. 根據遞推公式,推導出 dp 數組中咱們所須要的值。
  3. 根據結果的取值,計算出結果。

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

const minimumDistance = word => {
  const LEN = word.length;
  const dp = new Array(LEN - 1);
  const sum = new Uint16Array(LEN);

  for (let i = 1; i < LEN; ++i) {
    dp[i - 1] = new Uint16Array(LEN);
    sum[i] += sum[i - 1] + distance(i - 1, i);
  }

  for (let j = 2; j < LEN; ++j) {
    let min = sum[j - 1];
    for (let i = 0; i < j - 1; ++i) {
      const min2 = dp[i][j - 1] + distance(i, j);
      if (min2 < min) min = min2;
      dp[i][j] = dp[i][j - 1] + distance(j - 1, j);
    }
    dp[j - 1][j] = min;
  }

  let min = sum[LEN - 1];
  for (let i = 0; i < LEN - 1; ++i) {
    if (dp[i][LEN - 1] < min) min = dp[i][LEN - 1];
  }

  return min;

  function distance(a, b) {
    const x = word.charCodeAt(a) - 65;
    const y = word.charCodeAt(b) - 65;
    return Math.abs((x % 6) - (y % 6)) + Math.abs(((x / 6) << 0) - ((y / 6) << 0));
  }
};

優化

按照慣例,咱們仍是嘗試把 dp 這個二維數組優化爲一個一維數組。順便再吐槽一下 JS 中申明多維數組真是麻煩。

這裏的優化就很是簡單了,咱們上面的 dp[x][y] 中,y 表示的是當前右側手指的位置。這個位置其實也就是當前推演進行到了哪一位字符。而這個推演實際上是一輪一輪進行的,與咱們的循環直接綁定。而且咱們後續的計算中再也不須要更早期的推演值了。說到這裏,相信小夥伴們也發現了,咱們其實根本不須要這一維度的值,由於咱們只須要記錄最新的值便可。

因而瓜熟蒂落的,咱們能夠獲得相似下面的代碼:

const minimumDistance = word => {
  const LEN = word.length;
  const dp = new Uint16Array(LEN - 1);
  const sum = new Uint16Array(LEN);

  for (let i = 1; i < LEN; ++i) {
    sum[i] += sum[i - 1] + distance(i - 1, i);
  }

  for (let j = 2; j < LEN; ++j) {
    let min = sum[j - 1];
    for (let i = 0; i < j - 1; ++i) {
      const min2 = dp[i] + distance(i, j);
      dp[i] = dp[i] + distance(j - 1, j);
      if (min2 < min) min = min2;
    }
    dp[j - 1] = min;
  }

  return Math.min(...dp, sum[LEN - 1]);

  function distance(a, b) {
    const x = word.charCodeAt(a) - 65;
    const y = word.charCodeAt(b) - 65;
    return Math.abs((x % 6) - (y % 6)) + Math.abs(((x / 6) << 0) - ((y / 6) << 0));
  }
};

這段代碼目前跑出了 56ms,暫時 beats 100%。

總結

在上面的分析過程當中,咱們提到了局部最優解、全局最優解、貪心算法、動態規劃。不過這裏咱們並無作詳細的展開,只是點到爲止。待往後具體的專題內容時候,咱們再詳細的說吧。小夥伴們要是期待的話就多催更喲,催多了小豬纔有動力爆肝鴨,哈哈哈哈 >.<

另外,其實動態規劃的思路能夠有不少種,例如自上而下、自下而上其實均可以。而且遞推的內容和具體的邏輯也和咱們設置的 dp 數組有關。歡迎小夥伴們積極補充其餘的方案喲。Yo~ Put your hands up~

相關連接

qrcode_green.jpeg

相關文章
相關標籤/搜索