【數據結構與算法】力扣實戰之移動零、盛最多的水、爬樓梯

練題法則

  1. 5-10分鐘讀題與思考
    • 不要糾結沒有思路就直接看題解;
    • 不要死磕覺得自己很失敗,怎麼我們就想不出來;
    • 基本上這些算法題,讓我們自己想出來是不可能的;
    • 拿跳錶的來說,如果我們能從0-1把它想出來,那我們就可以拿到圖靈獎了;
    • 所以記住!無思路就直接看題解,無思路就直接看題解,無思路就直接看題解
    • 我們只需要知道並且能運用即可!
  2. 有思路
    • 自己開始寫代碼,沒有,就馬上看題解!
  3. 默寫背題,熟練
    • 做完題目後,我們需要記住這種題的思路和有N種解決辦法
    • 重複再**重複的默寫,**直到自己有深刻的影響;
  4. 最後開始自己寫(閉卷)
    • 到了這裏如果我們還需要看別人代碼,那就要回去背題;
    • 能到達這個階段基本這種題你已經開始熟悉的,接下來就是反覆練習;

在哪裏練題?

那肯定是力扣了!沒有賬號的小夥伴,馬上就去註冊個賬號開始日復一日的練習吧!~

283題 - 移動零

283. 移動零難度簡單

題目講解

給定一個數組 nums,編寫一個函數將所有 0 移動到數組的末尾,同時保持非零元素的相對順序。

示例:

輸入: [0,1,0,3,12]
輸出: [1,3,12,0,0]

說明:

  1. 必須在原數組上操作,不能拷貝額外的數組。
  2. 儘量減少操作次數。

這裏需要注意的重點:

  1. 所有 0 移動到數組的末尾;
  2. 保持非零元素的相對順序;
  3. 必須在原數組上操作,不能拷貝額外的數組;

解題思路

思考題解時,使用MECE原則 — 每一個思路都相對獨立的思維,然後想到完全窮盡。首先不要管附加條件,先把有可能解決這個問題的思路都想出來,再評估哪一個辦法是最優解。面試的時候也是一樣,說出你所有可以想到的思路,然後分別講出各自的優點與缺點,最後提出最優答案。

  1. 統計0的個數
    • 循環數組找到0的位置,遇到0就爲0的個數加一;
    • 遇到不是0的時候,把非0的元素值與0的元素交換即可;
  2. 開新數組
    • 給一個指針i從數組的頭部開始遞增;
    • 給一個指針j從數組的尾部開始遞減(也就是原數組的總長度);
    • 遇到零就往j指針的位置放,然後j--
    • 遇到非零就往i指針的位置放,然後i++
    • **缺點:**內存使用會高;
    • **不符合條件:**必須在原數組上操作,所以可以實現但是不符合條件;
  3. 雙指針交換
    • 給兩個指針ij,並且默認都從0開始;
    • i指向的是當前位置;
    • j指針會一直移動,直到找到一個非零元素,然後與i位置的值交換;
    • 如果j的位置與i不是一致的話,就可以給j的值換成0;
  4. 雙指針替換後清零
    • 這個與第三種方法一致,也是雙指針;
    • 唯一的區別是不在i指針掃描的時候替換零;
    • 而是在替換完畢所有非零元素後,把剩餘的全部位數都改爲0;

解題代碼

「方法一」 - 統計0的個數:

  • 時間複雜度:O(n)O(n) - N個元素就需要遍歷N次
  • 空間複雜度:O(1)O(1) - 只對原數組進行替換操作
/**
 * @param {number[]} nums
 * @return {void} Do not return anything, modify nums in-place instead.
 */
var moveZeroes = function(nums) {
  let zeroCount = 0;
  for (let i = 0; i < nums.length; i++) {
    if (nums[i] == 0) {
      zeroCount += 1;
    } else if (zeroCount > 0) {
      nums[i - zeroCount] = nums[i];
      nums[i] = 0;
    }
  }
};

「方法二」 - 雙指針交換:

  • 時間複雜度:O(n)O(n) - N個元素就需要遍歷N次
  • 空間複雜度:O(1)O(1) - 只對原數組進行替換操作
/**
 * @param {number[]} nums
 * @return {void} Do not return anything, modify nums in-place instead.
 */
var moveZeroes = function(nums) {
  let j = 0;
  for (let i = 0; i < nums.length; i++) {
    if (nums[i] !== 0) {
      nums[j] = nums[i];
      if (j !== i) {
        nums[i] = 0;
      }
      j++;
    }
  }
};

「方法三」 - 雙指針替換後清零:

  • 時間複雜度:O(n)O(n) - N個元素就需要遍歷N次,加上最後清零是走了n減非零的個數,那就是O(n+n-i),總的來說還是O(n)
  • 空間複雜度:O(1)O(1) - 只對原數組進行替換操作
/**
 * @param {number[]} nums
 * @return {void} Do not return anything, modify nums in-place instead.
 */
var moveZeroes = function(nums) {
    var j = 0;
    for (let i = 0; i < nums.length; i++) {
        if (nums[i] != 0) {
            nums[j] = nums[i];
            j++;
        }
    }

    for (let k = j; k < nums.length; k++) {
        nums[k] = 0;
    }
};

邊界測試用例

[0,1,0,3,12]
[1,2]
[0,0]

題解對比與分析

注意:以下數據都是在力扣中提交後返回的結果,每次提交都有可能不一致。所以相近的方案輸出的結果有所差異也是正常的,最終最優方案要通過分析代碼來確定不能只以力扣輸出的數據爲準,只能供於我們作爲參考

方法 執行時間 內存消耗
「方法一」- 統計0的個數 96 ms(戰勝17.82%) 37.1 MB
「方法二」- 雙指針交換 72 ms(戰勝87.23%) 37.2 MB
「方法三」- 雙指針替換後清零 76 ms(戰勝73.98%) 37.2 MB

分析一下:

  • 第一種方法是通過統計0出現的次數來定位到需要替換0的所在位置,裏面涉及一個i - zeroCount的運算,所以相對其他方法來說運行時間會更長一些;
  • 第二個方法是通過兩個指針一起運行,一個固定在0元素,一個一直走找到非0元素,最後做一個交換,這種方法沒有涉及運算,同時也是一個循環就可以完成,相對來說是最優解;
  • 第三種方法也是用了雙指針,與第二種方法的唯一區別就是先替換掉所有0的元素,最後把剩餘的元素全部一次性替換成0。可讀性來說,個人覺得更容易懂,但是時間和空間複雜度和第二種方法是一致的。

11題 - 盛最多水的容器

283. 盛最多水的容器難度中等

題目講解

給你 n 個非負整數 a1,a2,…,an,每個數代表座標中的一個點 (i, ai) 。在座標內畫 n 條垂直線,垂直線 i 的兩個端點分別爲 (i, ai) 和 (i, 0)。找出其中的兩條線,使得它們與 x 軸共同構成的容器可以容納最多的水。

說明:你不能傾斜容器,且 n 的值至少爲 2。

圖中垂直線代表輸入數組 [1,8,6,2,5,4,8,3,7]。在此情況下,容器能夠容納水(表示爲藍色部分)的最大值爲 49。

示例:

輸入:[1,8,6,2,5,4,8,3,7]
輸出:49

題目重點:

  1. 首先我們的目標是挑選兩條柱子,從而讓兩個柱子之前可以得出最大的面積(面積越大自然可容納的水就越多);
  2. 挑選最長的兩個柱子不等於擁有最大的面積,因爲它們之間的距離也是決定空間的一個維度;
  3. 所以重點是找到高度和寬度比例最大的一對柱子,從而得出最大面積;
  4. 注意在運算面積時,我們只能用一對柱子中最短的一條作爲高度,因爲水只能填滿到最短的那條柱子的高度;
  5. 面積運算公式: 高度 x 寬度 = 面積

解題思路

  1. 枚舉 —— 暴力解法

    • 遍歷左邊和右邊,找出所有面積;
    • 列出所有柱子的組合;
    • 算出所有組合各自的面積;
    • 最後輸出最大的面積的一組;
    • **缺點:**遍歷次數過高,所以時間複雜度會相對偏高
    • 複雜度:時間複雜度 O(n2)O(n^2)、空間複雜度 O(1)O(1)
  2. 雙指針

    • 左右兩邊都往中間移動;

    • 需要移動左右兩頭的問題都可以考慮雙指針;

    • 相同情況下兩遍距離越遠越好;

    • 區域受限於較短邊的高度;

    • 所以讓較矮的那邊的指針往內移動;

    • 一直以上面的規則移動知道兩個指針重合;

解題代碼

「方法一」 - 枚舉(暴力破解):

  • 時間複雜度:O(n2)O(n^2) - 雙循環,所以總計循環了N^2。
  • 空間複雜度:O(1)O(1)
/**
 * @param {number[]} height
 * @return {number}
 */
var maxArea = function(height) {
  let max = 0
  for (let i = 0; i < height.length - 1; i++) {
    for (let j = i + 1; j < height.length; j++) {
      let area = (j - i) * Math.min(height[i], height[j])
      max = Math.max(max, area)
    }
  }
  return max
};

「方法二」 - 雙指針:

  • 時間複雜度:O(n)O(n) - 雙指針總計最多遍歷整個數組一次。
  • 空間複雜度:O(1)O(1) - 只需要額外的常數級別的空間。
/**
 * @param {number[]} height
 * @return {number}
 */
var maxArea = function(height) {
    let max = 0

    for (let i = 0, j = height.length - 1; i < j; ) {
        let minHeight = height[i] < height[j] ? height[i ++] : height[j --]
        let area = (j - i + 1) * minHeight
        max = Math.max(max, area)
    }

    return max
};

題解對比與分析

方法 執行時間(毫秒) 內存消耗
枚舉(暴力破解) 984 ms (戰勝9.99%) 35.9 MB
雙指針 56 ms(戰勝99.88%) 36 MB

分析一下

  • 通過使用第二種方法,我們從O(n2)O(n^2)的時間複雜度降到O(n)O(n),總的執行時間大概是快了17倍

70題 - 爬樓梯

283. 移動零難度簡單

題目講解

假設你正在爬樓梯。需要 n 階你才能到達樓頂。

每次你可以爬 1 或 2 個臺階。你有多少種不同的方法可以爬到樓頂呢?

注意:給定 n 是一個正整數。

示例 1:

輸入: 2
輸出: 2
解釋: 有兩種方法可以爬到樓頂。

  1. 1 階 + 1 階
  2. 2 階

示例 2:

輸入: 3
輸出: 3
解釋: 有三種方法可以爬到樓頂。

  1. 1 階 + 1 階 + 1 階
  2. 1 階 + 2 階
  3. 2 階 + 1 階

題解重點

其實題目本身並不難,在力扣(LeetCode)是屬於“簡單”級別的題目,但是如果沒有思路,或者對這個題目完全不瞭解的話,一點頭緒都沒有也是正常的,這種題目也就是屬於套路題。如果我們是不知道的話,我們自然會難到不知道怎麼做。我們要是知道了的話,那就變得相當容易了。

這裏講一下解題的思想:

首先我們解題時最大的誤區是什麼?

  • 做題只做了一遍
  • 至少要做五遍

然後我們優化的思想是什麼?

  • 空間換時間
  • 升維思想(升級到二維)

看題時懵了怎麼辦?

  • 首先我們能不能暴力破解?
  • 最基本的情況我們應該怎麼解決?能否化繁爲簡?

破解所有問題的法則:

  • 找最近重複的子問題
  • 爲什麼?因爲寫程序我們只能寫ifelseforwhilerecursion(遞歸)
  • 計算機是人類發明的,計算機肯定是沒有人腦那麼強的,它其實就是一個簡單的重複式機器
  • 那麼計算機運行的程序也是同理,它是用重複的東西來解決問題的
  • 如果我們遇到算法題的時候,就是需要我們用程序去解決的問題,那問題的本身就是可重複的
  • 無論是算法中的回述、分治、動態規劃、遞歸等,全部都是在找重複性的原理
  • 所以重點都是“找規律

深度分析題目:

首先我們使用化繁爲簡的思維來分析:

要到達第一個臺階,我們只能爬1個臺階,所以只有一種方法的可能性,所以 n = 1 的時候,只有1種可能。

那如果我們要到達第二個臺階,我們要不就是連續爬2次1個跨度,要不就是一次性爬兩個臺階到達第二個臺階。所以有2種可能性。

那如果是需要到達第三個臺階呢

這裏有個小技巧,要到達第三個臺階我們可以換一種思維去想,如果我們還是像第一個和第二個臺階的方式去列出可以到達第三個臺階的所有可能性,那如果n很大的時候,我們只靠人的大腦去想,那真的是太費勁了。但是這裏有一個很巧妙的思維方式。


返過來想,我們想到達第三個臺階,只有兩種可能可以到達:
  1. 要不就是從第二個臺階爬1個臺階到達
  2. 要不就是從第一個臺階爬2個臺階到達

那其實如果是第四個臺階是不是也是一樣的?
  1. 要不就是從第三個臺階爬1個臺階到達
  2. 要不就是從第二個臺階爬2個臺階到達

這裏就有一個`規律`了。要到達第`n`個臺階我們需要知道:
  1. 到達第n-1的臺階有多少種可能
  2. 到達第n-2的臺階有多少種可能
  3. 然後這兩個相加就是到達第n的臺階有多少種可能

那其實這裏就是老生常談的斐波拉次數列:

f(n)=f(n1)+f(n2)f(n) = f(n-1) + f(n-2)

解題思路

  1. 斐波拉次(Fibonacci)- “傻遞歸“
    • 直接使用遞歸循環使用斐波拉次公式即可
    • 但是時間複雜度就很高 - O(2n)O(2^n)
  2. 動態規劃
    • 用上面講到的原理,到達第n個臺階只需要:爬上 n1n-1 臺階的方式數 + 爬上 n2n - 2 臺階的方法數 = 爬上第 nn 個臺階的方式數
    • 所以得出的公式是 dp[n]=dp[n1]+dp[n2]dp[n] = dp[n-1] + dp[n-2]
    • 同時需要初始化: dp[0]=1dp[0]=1dp[1]=1dp[1] = 1
    • 使用這種方式時間複雜度降到 O(n)O(n)
  3. 動態規劃2 - 只記錄最後3個的方法量
    • 與上面的動態規劃的方法一樣,但是這裏我們只記錄最後3個的臺階的爬樓方法數
    • 使用f1f2f3作爲儲存變量
    • 默認 f1=1f1 = 1f2=2f2 = 2 即可
  4. 通項公式(Binet’s Formular )
    • 有觀察數學規律的同學,或者數學比較好的同學,會發現本題是斐波那次數列,那麼我們也可以用斐波那次的“通項公式”
    • 公式是:Fn=15[(1+52)n(152)n]F_n = \frac{1}{\sqrt{5}}[(\frac{1+\sqrt{5}}{2})^n - (\frac{1-\sqrt{5}}{2})^n]
    • 時間複雜度:O(logn)O(logn)

解題代碼

「方法一」斐波那次

  • 時間複雜度:O(2n)O(2^n)
  • 空間複雜度:O(1)O(1)
/**
 * @param {number} n
 * @return {number}
 */
var climbStairs = function(n) {
    if (n <= 2) return n
    return climbStairs(n-1) + climbStairs(n-2)
};

「方法二」動態規劃

  • 時間複雜度:O(n)O(n)
  • 空間複雜度:O(n)O(n)
/**
 * @param {number} n
 * @return {number}
 */
var climbStairs = function(n) {
    const dp = [];
    dp[0] = 1;
    dp[1] = 1;
    for(let i = 2;  i <= n;  i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    return dp[n];
};

「方法三」動態規劃2

  • 時間複雜度:O(n)O(n)
  • 空間複雜度:O(1)O(1)
/**
 * @param {number} n
 * @return {number}
 */
var climbStairs = function (n) {
    if (n <= 2) {
        return n
    }
    let f1 = 1, f2 = 2, f3
    for (let i = 3; i <= n; i++) {
        f3 = f1 + f2
        f1 = f2
        f2 = f3
    }
    return f3
};

「方法四」通項公式

  • 時間複雜度:O(logn)O(logn)
  • 空間複雜度:O(1)O(1)
/**
 * @param {number} n
 * @return {number}
 */
var climbStairs = function(n) {
    const sqrt_5 = Math.sqrt(5);
    const fib_n = Math.pow((1 + sqrt_5) / 2, n + 1) - Math.pow((1 - sqrt_5) / 2,n + 1);
    return Math.round(fib_n / sqrt_5);
};

題解對比與分析

方法 執行時間(毫秒) 內存消耗
「方法一」斐波那次 超出時間限制 N/A
「方法二」動態規劃 68 ms 32.4 MB
「方法三」動態規劃2 53 ms 32.3 MB
「方法三」通項公式 67 ms 32.4 MB

分析一下

  • 按照時間複雜度來說,應該“通項公式”是性能最優的,但是力扣的執行時間不是很靠譜,這一點我在上面也說到,就不多解釋了。
  • 所以最優解還是第三種方法“通項公式
  • 接着就是“動態規劃2”,因爲只儲存了3個變量,第二種方法需要用到數組。在空間複雜度上就佔了優勢。
  • 而最後輸一下傻瓜式的斐波那次遞歸,這種方法還沒有執行完就已經被淘汰了。時間複雜度過高。

推薦專欄

小夥伴們可以查看或者訂閱相關的專欄,從而集中閱讀相關知識的文章哦。

  • 📖 《數據結構與算法》 — 到了如今,如果想成爲一個高級開發工程師或者進入大廠,不論崗位是前端、後端還是AI,算法都是重中之重。也無論我們需要進入的公司的崗位是否最後是做算法工程師,前提面試就需要考算法。

  • 📖 《FCC前端集訓營》 — 根據FreeCodeCamp的學習課程,一起深入淺出學習前端。穩固前端知識,一起在FreeCodeCamp獲得證書

  • 📖 《前端星球》 — 以實戰爲線索,深入淺出前端多維度的知識點。內含有多方面的前端知識文章,帶領不懂前端的童鞋一起學習前端,在前端開發路上童鞋一起燃起心中那團火🔥

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