LeetCode 算法題刷題心得

花了十幾天,把《算法》看了一遍然後重新 AC 了一遍 LeetCode 的題,收穫頗豐。這次好好記錄下心得。
我把所有做題的代碼都放在 github 上以供參考。
項目地址:https://github.com/violetjack/LeetCodeACByJS
題目地址:https://leetcode.com/problemset/top-interview-questions/

說來慚愧,之前寫的《LeetCode 邏輯題分享》其實自己動手做的比較少,都是看解決方案。更加關鍵的是我沒有系統地去學習過算法(自學的編程)。所以導致以下幾個問題:

  • 看題不懂方法論,理解他人方案困難。
  • 解題方法通過看別人的方案去歸納,照着抄。(其實都是有系統的算法寫法的)
  • 很多題目看了答案只是知其然而不知其所以然。
  • 很多答案(討論區的方案)是有錯誤的,卻把它當正確答案來發。

之後,我看了《算法(第4版)》一書,重新去做並且試着去 AC 題目,問題又是一堆堆的。所以這次比第一次刷題時間要久不少。

各類題的解決方案

話不多說,系統整理下解題的一些算法和解決方案

二叉樹

二叉樹大多使用遞歸的方式左右兩個元素向下遞歸。比如:

計算二叉樹最大深度

var maxDepth = function (root) {
    if (root == null) return 0
    return 1 + Math.max(maxDepth(root.left), maxDepth(root.right))
};

將二叉樹以二維數組形式表現

var levelOrder = function(root) {
    let ans = []
    helper(root, ans, 0)
    return ans
};

function helper(node, ans, i){
    if (node == null) return
    if (i == ans.length) ans.push([])
    ans[i].push(node.val)

    helper(node.left, ans, i + 1)
    helper(node.right, ans, i + 1)
}

都是通過遞歸方式逐層向下去查找二叉樹數據。

可能性問題

這類題一般是告訴你一組數據,然後求出可能性、最小值或最大值。比如:

給定幾種面額的硬幣和一個總額,使用最少的硬幣湊成這個總額。

var coinChange = function (coins, amount) {
    let max = amount + 1
    let dp = new Array(amount + 1)
    dp.fill(max)
    dp[0] = 0

    for (let i = 1; i < max; i++) {
        for (let j = 0; j < coins.length; j++) {
            if (coins[j] <= i) {
                dp[i] = Math.min(dp[i], dp[i - coins[j]] + 1)
            }
        }
    }
    return dp[amount] > amount ? -1 : dp[amount]
};

使用了動態規劃(DP),將從 0 到目標額度所需的最小硬幣數都列出來。

求出從矩陣左上角走到右下角,且只能向右向下移動,一共有多少種可能性。

var uniquePaths = function (m, n) {
    const pos = new Array(m)
    for (let i = 0; i < m; i++) {
        pos[i] = new Array(n)
    }
    for (let i = 0; i < n; i++) {
        pos[0][i] = 1
    }
    for (let i = 0; i < m; i++) {
        pos[i][0] = 1
    }
    for (let i = 1; i < m; i++) {
        for (let j = 1; j < n; j++) {
            pos[i][j] = pos[i - 1][j] + pos[i][j - 1]
        }
    }
    return pos[m - 1][n - 1]
};

這題就是使用了動態規劃逐步列出每一格的可能性,最後返回右下角的可能性。

獲取給定數組連續元素累加最大值

var maxSubArray = function (nums) {
    let count = nums[0], maxCount = nums[0]
    for (let i = 1; i < nums.length; i++) {
        count = Math.max(count + nums[i], nums[i])
        maxCount = Math.max(maxCount, count)    
    }
    return maxCount
};

上面這題通過不斷對比最大值來保留並返回最大值。

其實,可能性問題使用動態規劃要比使用 DFS、BFS 算法更加簡單而容易理解。(我使用 DFS 經常報 TLE)

查找

一般遇到的查找問題,如查找某個值一般會用到一下方法:

  • 排序算法(排序便於查找)
  • 二分查找
  • 索引移動查找(這個方法名自己想的,大概就這個意思~)

查找橫向和縱向都遞增的二維矩陣中的某個值

var searchMatrix = function (matrix, target) {
    if (matrix.length == 0) return false
    let row = 0, col = matrix[0].length - 1
    while (true) {
        if (matrix[row][col] > target && col > 0) {
            col--
        } else if (matrix[row][col] < target && row < matrix.length - 1) {
            row++
        } else if (matrix[row][col] == target) {
            return true
        } else {
            break
        }
    }
    return false
};

先將位置定位在右上角,通過改變位置座標來找到目標值。使用了索引移動查找法來找到結果。

找到數組中最左邊和最右邊的某個數字所在位置

var searchRange = function (nums, target) {
    let targetIndex = binarySearch(nums, target, 0, nums.length - 1)
    if (targetIndex == -1) return [-1, -1]
    let l = targetIndex, r = targetIndex
    while(l > 0 && nums[l - 1] == target){
        l--
    }
    while(r < nums.length - 1 && nums[r + 1] == target){
        r++
    }
    return [l, r]
};

function binarySearch(arr, val, lo, hi) {
    if (hi < lo) return -1
    let mid = lo + parseInt((hi - lo) / 2)

    if (val < arr[mid]) {
        return binarySearch(arr, val, lo, mid - 1)
    } else if (val > arr[mid]) {
        return binarySearch(arr, val, mid + 1, hi)
    } else {
        return mid
    }
}

這題使用二分法來查找到某個目標數字的索引值,然後索引移動法分別向左和向右查找字符。獲取左右兩側的索引值返回。

迴文

所謂迴文,就是正着讀反着讀是一樣的。使用索引兩邊向中間移動的方式來判斷是否爲迴文。

找到給定字符串中某段最長的迴文

var longestPalindrome = function (s) {
    let maxLength = 0, left = 0, right = 0
    for (let i = 0; i < s.length; i++) {
        let singleCharLength = getPalLenByCenterChar(s, i, i)
        let doubleCharLength = getPalLenByCenterChar(s, i, i + 1)
        let max = Math.max(singleCharLength, doubleCharLength)
        if (max > maxLength) {
            maxLength = max
            left = i - parseInt((max - 1) / 2)
            right = i + parseInt(max / 2)
        }
    }
    return s.slice(left, right + 1)
};

function getPalLenByCenterChar(s, left, right) {
    // 中間值爲兩個字符,確保兩個字符相等
    if (s[left] != s[right]){
        return right - left
    }
    while (left > 0 && right < s.length - 1) {
        left--
        right++
        if (s[left] != s[right]){
            return right - left - 1
        }
    }
    return right - left + 1
}

路徑題

路徑題可以使用深度優先(DFS)和廣度優先(BFS)算法來做。我比較常用的是使用 DFS 來做。通過遞歸將走過的路徑進行標記來不斷往前找到目標路徑。如:

通過給定單詞在二維字母數組中查找是否能使用鄰近字母組成這個單詞(212題)

let hasWord = false

var findWords = function (board, words) {
    var ans = []
    for (let word of words) {
        for (let j = 0; j < board.length; j++) {
            for (let i = 0; i < board[0].length; i++) {
                if (board[j][i] == word[0]) {
                    hasWord = false
                    DFS(word, board, 0, j, i, "")
                    if (hasWord) {
                        if (!ans.includes(word))
                            ans.push(word)
                    }
                }
            }
        }
    }
    return ans
};

function DFS(word, board, index, j, i, subStr) {
    if (word[index] == board[j][i]) {
        subStr += board[j][i]
        board[j][i] = "*"
        if (j < board.length - 1)
            DFS(word, board, index + 1, j + 1, i, subStr)
        if (j > 0)
            DFS(word, board, index + 1, j - 1, i, subStr)
        if (i < board[0].length - 1)
            DFS(word, board, index + 1, j, i + 1, subStr)
        if (i > 0)
            DFS(word, board, index + 1, j, i - 1, subStr)
        board[j][i] = word[index]
    }
    if (index >= word.length || subStr == word) {
        hasWord = true
    }
}

由於 DFS 是一條路走到黑,如果每個元素都去使用 DFS 來找會出現超時的情況。如果條件允許(如查找遞增數組)可以通過設置緩存來優化 DFS 查找超時問題。

獲取二維矩陣中最大相鄰遞增數組長度。

const dirs = [[0, 1], [1, 0], [0, -1], [-1, 0]]

var longestIncreasingPath = function (matrix) {
    if (matrix.length == 0) return 0
    const m = matrix.length, n = matrix[0].length
    let max = 1

    let cache = new Array(m)
    for (let i = 0; i < m; i++){
        let child = new Array(n)
        child.fill(0)
        cache[i] = child
    }

    for (let i = 0; i < m; i++) {
        for (let j = 0; j < n; j++) {
            let len = dfs(matrix, i, j, m, n, cache)
            max = Math.max(max, len)
        }
    }
    return max
}

function dfs(matrix, i, j, m, n, cache){
    if (cache[i][j] != 0) return cache[i][j]
    let max = 1
    for (let dir of dirs){
        let x = i + dir[0], y = j + dir[1]
        if(x < 0 || x >= m || y < 0 || y >= n || matrix[x][y] <= matrix[i][j]) continue;
        let len = 1 + dfs(matrix, x, y, m, n, cache)
        max = Math.max(max, len)
    }
    cache[i][j] = max
    return max
}

將已使用 DFS 查找過的長度放入緩存,如果有其他元素走 DFS 走到當前值,直接返回緩存最大值即可。

鏈表

鏈表從 JS 的角度來說就是一串對象使用指針連接的數據結構。合理使用 next 指針改變指向來完成對鏈表的一系列操作。如:

鏈表的排序:

var sortList = function (head) {
    if (head == null || head.next == null) return head

    let prev = null, slow = head, fast = head
    while (fast != null && fast.next != null) {
        prev = slow
        slow = slow.next
        fast = fast.next.next
    }

    prev.next = null;

    let l1 = sortList(head)
    let l2 = sortList(slow)

    return merge(l1, l2)
};

function merge(l1, l2) {
    let l = new ListNode(0), p = l;

    while (l1 != null && l2 != null) {
        if (l1.val < l2.val) {
            p.next = l1;
            l1 = l1.next;
        } else {
            p.next = l2;
            l2 = l2.next;
        }
        p = p.next;
    }

    if (l1 != null)
        p.next = l1;

    if (l2 != null)
        p.next = l2;

    return l.next;
}

使用了自上而下的歸併排序方法對鏈表進行了排序。使用 slow.nextfast.next.next 兩種速度獲取鏈表節點,從而獲取中間值。

鏈表的倒序

var reverseList = function(head) {
    let ans = null,cur = head
    while (cur != null) {
        let nextTmp = cur.next
        cur.next = ans
        ans = cur
        cur = nextTmp
    }
    return ans
};

排序

排序和查找算是算法中最重要的問題了。常用的排序算法有:

  • 插入排序
  • 選擇排序
  • 快速排序
  • 歸併排序
  • 計數排序

更多排序算法的知識點可參考《JS家的排序算法》,文章作者圖文並茂的講解了各種排序算法,很容易理解。
舉幾個排序算法的栗子:

求數組中第K大的值

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number}
 */
var findKthLargest = function (nums, k) {
    for (let i = 0; i <= k; i++) {
        let max = i
        for (let j = i; j < nums.length; j++) {
            if (nums[j] > nums[max]) max = j
        }
        swap(nums, i, max)
    }
    return nums[k - 1]
};

function swap(arr, a, b) {
    let tmp = arr[a]
    arr[a] = arr[b]
    arr[b] = tmp
}

使用了選擇排序排列了前 K 個值得到結果。

對有重複值的數組 [2,0,2,1,1,0] 排序

var sortColors = function (nums) {
    sort(nums, 0, nums.length - 1)
};

function sort(arr, lo, hi) {
    if (hi <= lo) return
    let lt = lo, i = lo + 1, gt = hi;
    let v = arr[lo]
    while (i <= gt) {
        if (arr[i] < v) swap(arr, lt++, i++)
        else if (arr[i] > v) swap(arr, i, gt--)
        else i++
    }
    sort(arr, lo, lt - 1)
    sort(arr, gt + 1, hi)
}

function swap(arr, a, b) {
    let x = arr[a]
    arr[a] = arr[b]
    arr[b] = x
}

這種有重複值的使用三向切分的快速排序是非常好的解決方案。當然,計數排序法可是不錯的選擇。
還有之前提到的鏈表的排序使用的是歸併排序

算術題

算術題看似簡單,但是遇到最大的問題就是:如果使用累加、累成這種常熟級別的增長,遇到很大的數字會出現 TLE (超出時間限制)。所以,我們要用指數級別的增長來找到結果。如:

計算 x 的 n 次方

var myPow = function (x, n) {
    if (n == 0) return 1
    if (n < 0) {
        n = -n
        x = 1 / x
    }
    return (n % 2 == 0) ? myPow(x * x, parseInt(n / 2)) : x * myPow(x * x, parseInt(n / 2));
};

一開始我使用了 x*x 這麼乘上 n 次,但是遇到 n 太大就直接超時了。使用以上方案:29 = 2 * 44 = 2 * 82 = 2 * 64 = 128
直接從常熟級變化變爲指數級變化,這一點在數學運算中是需要注意的。

求 x 的平方根

var mySqrt = function (x) {
    let l = 0, r = x
    while (true) {
        let mid = parseInt(l + (r - l) / 2)
        if (mid * mid > x) {
            r = mid - 1
        } else if (mid * mid < x) {
            if ((mid + 1) * (mid + 1) > x) {
                return mid
            }
            l = mid + 1
        } else {
            return mid
        }
    }
};

這題使用二分法來找到結果。

二進制問題

二進制問題,一般使用按位運算符和二進制轉換 Number.parseInt()Number.prototype.toString()來解決。

將一個32位數字的二進制進行倒序

var reverseBits = function(n) {
    var t = n.toString(2).split("");
    while(t.length < 32) t.unshift("0"); // 插入足夠的 0
    return parseInt(t.reverse().join(""), 2);
};

常用算法

講了這麼多,其實除了常用的排序、搜索,其他最常用的就是 DP、DFS、BFS 這三個算法了。可以這麼說:掌握了排序和這三個算法,可以 AC 大多數的算法問題。這麼牛逼的算法瞭解一下?

簡單說說幾種排序和查找

  • 冒泡排序:遍歷數組,對比元素和後面相鄰元素,如果當前元素大於後面元素,調換位置。這樣從頭遍歷到尾,獲取最後一位排序玩的元素。然後在 1 到 n - 1 中再次重複以上步驟。直到最後第一和第二個元素對比大小。是一種從後往前的排序。
  • 選擇排序:遍歷數組,找到最小的元素位置,與第一個元素調換位置,然後縮小範圍從第二個元素開始遍歷,如此重複到最後一個元素。可以從後往前也可以從前往後排序。
function sort(arr) {
    const len = arr.length
    for (let i = 0; i < len; i++) {
        let min = i
        for (let j = i + 1; j < len; j++) {
            if (arr[j] < arr[min]) min = j
        }
        swap(arr, i, min)
        console.log(arr)
    }
    return arr
}
  • 插入排序:遍歷數組,選中某一個元素,與前面相鄰元素對比,如果當前元素小於之前元素,調換位置,繼續對比直到當前元素前的元素小於當前元素(或者到最前面),如此對所有元素排序一遍。是一種從前往後的排序。
function sort(arr) {
    const len = arr.length
    for (let i = 1; i < len; i++) {
        for (let j = i; j > 0 && arr[j] < arr[j - 1]; j--) {
            swap(arr, j, j - 1)
            console.log(arr)
        }
    }
    return arr
}
  • 希爾排序:類似於插入排序,選中一個元素與元素前 n 個元素進行比大小和調換位置。之後再縮小 n 的值。這種方法可以減少插入排序中最小值在最後面,然後需要一個一個調換位置知道最前面這類問題。減少調換次數。是一種從前往後的排序。
  • 歸併排序:在《算法》中提到了兩種歸併排序:一種是自上而下的歸併排序。將數組不斷二分到最小單位(1到2個元素)將他們進行排序,之後將前兩個和後兩個元素對比,如此往上最後完成整個數組的排序。還有一種自下而上的歸併排序是直接將數組分割爲若干個子數組進行排序然後合併。
let aux = new Array(arr.length)
function sort(arr, lo, hi) {
    if (hi <= lo) return
    let mid = lo + (parseInt((hi - lo) / 2))

    sort(arr, lo, mid)
    sort(arr, mid + 1, hi)
    merge(arr, lo, mid, hi)
}

function merge(arr, lo, mid, hi) {
    let i = lo, j = mid + 1
    for (let k = lo; k <= hi; k++) {
        aux[k] = arr[k]
    }
    for (let k = lo; k <= hi; k++) {
        if (i > mid) arr[k] = aux[j++]
        else if (j > hi) arr[k] = aux[i++]
        else if (aux[j] < aux[i]) arr[k] = aux[j++]
        else arr[k] = aux[i++]
    }
    console.log(arr)
}
  • 快速排序:選定第一個值爲中間值,然後將小於中間值的元素放在中間值的左側而大於中間值的元素放在中間值右側,然後對兩側的元素分別再次切割,直到最小單位。
function sort(arr, lo, hi) {
    if (hi <= lo + 1) return
    let mid = partition(arr, lo, hi) // 切分方法
    sort(arr, lo, mid)
    sort(arr, mid + 1, hi)
}

function partition(arr, lo, hi) {
    let i = lo, j = hi + 1
    let v = arr[lo]
    while(true) {
        while(arr[++i] < v) if (i == hi) break
        while(v < arr[--j]) if (j == lo) break
        if ((i >= j)) break
        swap(arr, i, j)
        console.log(arr)
    }
    swap(arr, lo, j)
    console.log(arr)
    return j
}
  • 三向切分的快速排序:類似於快速排序,優化點在於如果某個元素等於切分元素,元素位置不變。最後小於切分元素的到左邊,等於切分元素的根據數量放在中間,大於切分元素的放在右邊。適用於有大量相同大小元素的數組。
function sort(arr, lo, hi) {
    if (hi <= lo) return
    let lt = lo, i = lo + 1, gt = hi;
    let v = arr[lo]
    while (i <= gt) {
        if (arr[i] < v) swap(arr, lt++, i++)
        else if (arr[i] > v) swap(arr, i, gt--)
        else i++
        console.log(arr)
    }
    sort(arr, lo, lt - 1)
    sort(arr, gt + 1, hi)
}
  • 堆排序:堆排序可以說是一種利用堆的概念來排序的選擇排序。使用優先隊列返回最大值的特性逐個返回當前堆的最大值。
  • 計數排序:就是將數組中所有元素的出現次數保存在一個數組中,然後按照從小到大返回排序後的數組。
  • 桶排序:其實就是字符串排序的 LSD 和 MSD 排序。LSD 使用索引計數法從字符串右邊向左邊移動,根據當前值進行排序。而 MSD 是從左到右使用索引計數法來排序,在字符串第一個字符後,將字符串數組分爲若干個相同首字符串的數組各自進行第二、第三次的 MSD 排序。
  • 二分查找: 對有序數組去中間值與目標值相比對。如果目標值小於中間值,取前一半數組繼續二分。如果目標值大於中間值,取後一半數組繼續二分。如果目標值等於中間值,命中!

DP

關於動態規劃,可以看下詳解動態規劃——鄒博講動態規劃一文,其中講了路徑、硬幣、最長子序列。都是 LeetCode 中有的題目。
我的理解:動態規劃就是下一狀態可以根據上一狀態,或之前幾個狀態獲取到的一種推理過程。

DFS

深度優先搜索(DFS)就是選中某條從條件1到條件2的某條可能性進行搜索,之後返回搜索其他一條可能性,如此一條條升入。舉個栗子,如果有5條路,那麼 DFS 算法就是隻排出一個斥候先走一條路走到底去偵察,如果走不通那麼返回走下一條路徑。

DFS(頂點v)
{
  標記v爲已遍歷;
  for(對於每一個鄰接v且未標記遍歷的點u)
      DFS(u);
}

DFS 使用的是遞歸的方式進行搜索的。

示例:在二維字母矩陣中查找是否能夠使用相鄰字母組成目標單詞。

var exist = function (board, word) {
    for (let y = 0; y < board.length; y++) {
        for (let x = 0; x < board[0].length; x++) {
            if (find(board, word, y, x, 0)) return true
        }
    }
    return false
};

function find(board, word, y, x, d) {
    if (d == word.length) return true
    if (y < 0 || x < 0 || y == board.length || x == board[y].length) return false;
    if (board[y][x] != word[d]) return false
    let tmp = board[y][x]
    board[y][x] = "*"
    let exist = find(board, word, y, x + 1, d + 1)
        || find(board, word, y, x - 1, d + 1)
        || find(board, word, y + 1, x, d + 1)
        || find(board, word, y - 1, x, d + 1)
    board[y][x] = tmp
    return exist
}

BFS

廣度優先搜索(BFS)就是將從條件1到條件2的所有可能性都列出來同步搜索的過程。適用於查找最短路徑。舉個栗子,如果有5條路,那麼 BFS 算法就是分別向5條路排出斥候去偵察。

BFS()
{
  輸入起始點;
  初始化所有頂點標記爲未遍歷;
  初始化一個隊列queue並將起始點放入隊列;

  while(queue不爲空)
  {

    從隊列中刪除一個頂點s並標記爲已遍歷; 
    將s鄰接的所有還沒遍歷的點加入隊列;
  }
}

BFS是使用數組存儲下一頂點的方式。

示例:每次改變一次字母,通過給定數組中的單詞,從單詞 A 變爲單詞 B。(127題

/**
 * @param {string} beginWord
 * @param {string} endWord
 * @param {string[]} wordList
 * @return {number}
 */
var ladderLength = function (beginWord, endWord, wordList) {
    if (!wordList.includes(endWord)) return 0
    let set = new Set(),
        visited = new Set(),
        len = 1

    set.add(beginWord)
    visited.add(beginWord)
    while (set.size != 0) {
        let tmp = new Set([...set])

        for (let w of tmp) {
            visited.add(w)
            set.delete(w)

            if (changeOneChar(w, endWord))
                return len + 1
            
            for (let word of wordList){
                if (changeOneChar(w, word) && !visited.has(word)){
                    set.add(word)
                }
            }
        }
        len++
    }
    return 0
};

function changeOneChar(a, b) {
    let count = 0
    for (let i = 0; i < a.length; i++)
        if (a[i] != b[i])
            count++
    return count == 1
}

最後

寫下 AC 一遍題目之後的收穫。

  • 知道了方法論,做起題來輕鬆了不少。
  • 遇到問題多找輪子,一定有某種方法論可以用。
  • 不要耍小聰明用一些奇巧淫技,思路不對再怎麼繞都是浪費時間。
  • 不要想着自己造輪子(特別是算法方面),絕大多數問題前輩一定有更好更完善的方案在。自己造輪子費時費事又沒太大意義。
  • 看答案和自己做是兩回事,自己動手實現了才能算是會了。
  • 算法之所以存在,就是用來適應某些場景、解決某類問題的。在對的場景選擇對的算法才能體現算法的價值,不要濫用算法。
  • 沒必要把所有算法都精通,但起碼在遇到問題時可以找到最優算法解決問題。即知道算法的存在及其用途,按需深入學習。

其實刷算法題還是很有趣的事情,之後計劃把 LeetCode 題庫中的所有問題都刷一遍~

PS:本文以及相關項目中有任何錯誤或者可以改進的地方,還請提出。共同進步~



作者:VioletJack
鏈接:https://www.jianshu.com/p/8876704ea9c8
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯繫作者獲得授權並註明出處。

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