LeetCode - JavaScript Topic (2 Hard)

tags: JavaScript
category: LeetCode
description: LeetCode - JavaScript Topic (2 Hard)
created_at: 2023/05/05 22:00:00

cover image


前言

很久很久沒有打開 LeetCode 了,這次因為前幾天發現一些開發流程,感覺自己要被取代要失業了,所以就想說打開 LeetCode 來刷一下。

然後剛剛好 LeetCode 好像這陣子在推 JavaScriptTopic,然後看到題目也不多,就花了一天的零碎時間把所有的解了一遍,大多題目都很簡單,而這篇就寫當中唯二的 Hard 題目(雖然我覺得好像沒到 Hard?)

當中第一題 1456. Maximum Number of Vowels in a Substring of Given Length 不屬於這個系列(而是每日題目),但想說要追求全部打勾(強迫症),就順便解了一下。


哪兩題?

  • 2630. Memoize II
  • 2650. Design Cancellable Function

2630. Memoize II

這一題有比較簡單的版本,2623. Memoize,建議可以先從這題去試水溫。

這兩題主要都是在做同樣的事情,只是 II 變得比較彈性,非常適合來造輪子

所以先從簡單的開始看: 2623. Memoize

題目是這個樣子的:

給你一個 function,然後你要回傳一個具有記憶能力的 function,當我使用一樣的參數丟進去的時候,你就不要再重新呼叫了,直接拿之前的結果去用就好。

這其實也很像動態規劃的其中一個重要特性,算過的就不要重複算了浪費時間,用空間換取時間,只是以往解 dp,都是為了特定目的,而這題是要做出一個更共通的解法。

舉個例子,老樣子還是費式數列(fib),因為這個例子最好懂,雖然有些人不認為 fib 屬於 dp 問題,但沒關係,懂意思就好

簡單複習一下 fib 是什麼:

  • if n <= 1: fib(n) = n
  • else: fib(n) = fib(n-1) + fib(n-2)

簡單來說就是當 n 小於等於一,就回傳 n,否則就是前兩項的和,寫成遞迴就像下面這樣

function fib(n) {
    if (n <= 1) {
        return n
    } else {
        return fib(n-1) + fib(n-2)
    }
}

console.log(fib(0)) // 0
console.log(fib(1)) // 1
console.log(fib(2)) // 1
console.log(fib(3)) // 2
console.log(fib(4)) // 3
console.log(fib(5)) // 5
console.log(fib(6)) // 8

你喜歡的話也可以改一下寫法(?)

const fib = n => n <= 1 ? n : fib(n-1) + fib(n-2)

但這樣寫會有個問題,不管是第一種還是精簡過的第二種,都會有重複計算的問題,例如你把過程解開來看:

fib(6) = fib(5) + fib(4)
fib(5) = fib(4) + fib(3)
fib(4) = fib(3) + fib(2)
fib(3) = fib(2) + fib(1)
fib(2) = fib(1) + fib(0)
fib(1) = 1
fib(0) = 0

可以看到 fib(4) 會被計算兩次,你可能沒感覺,但 fib(4) 重新計算的話,那換句話說 fib(2) 也會被計算兩次,這樣就會造成浪費,而且這只是最小估計(還沒實際展開),如果 n 再大一點,那浪費的就更多了。

用一張圖來表示的話,就像下面這樣:

可以看到說有太多重複的地方,基本上大多右子樹都是浪費,或者說藍色節點就是重複計算的地方,沒必要再往下遞迴。

所以這時候就可以用 Memoize 來解決這個問題,把計算過的結果記錄下來,下次再遇到就直接拿來用,不用再重新計算一次。

假設我就簡單以 JavaScript 的物件來記錄,key 就是參數,value 就是結果,那 fib 就會變成這樣:

const dp = { 0: 0, 1: 1} // 先寫死初始狀態(前兩項)
function fib(n) {
    if (typeof dp[n] !== 'undefined') {
        return dp[n]
    }

    dp[n] = fib(n-1) + fib(n-2)
    return dp[n]
}

備註: 你也可以使用陣列當作 dp,只是這邊示意是使用 Object 形式去存。

這樣就可以避免重複計算了, fib(n) 會先檢查 dp 裡面有沒有 n 的結果,如果有就直接回傳,沒有的話就計算一次,並且把結果記錄下來,下次再遇到就可以直接拿來用。

可以先戳戳看舊版的 fib,帶入一個值,不用很大,大概 40 你就會很有感覺他很慢了。

而新版的 fib 你可以帶入 100 甚至更大的值,也都是瞬間出來結果。(但你也不要太故意,會超過遞迴深度)


進入正題

回顧一下上面講到的題目敘述:

給你一個 function,然後你要回傳一個具有記憶能力的 function,當我使用一樣的參數丟進去的時候,你就不要再重新呼叫了,直接拿之前的結果去用就好。

就有點像是說你寫第一版的 fib ,丟到我這個函數之後,我生出第二版的給你用。

function fib(n) {
    if (n <= 1) {
        return n
    } else {
        return fib(n-1) + fib(n-2)
    }
}

fib = memoize(fib)
console.log(fib(100))

而上面這個 memoize 就是這個題目要實作的部分:

只是這題是簡單版的,只要實現三個函數就可以了:

  • sum: 接收兩個整數 ab,回傳 a + b
  • fib: 接收一個整數 n,回傳 n <= 1 就回傳 n,否則就回傳 fib(n-1) + fib(n-2)
  • factorial: 接收一個整數 n,回傳 n <= 1 就回傳 1,否則就回傳 factorial(n-1) * n

不過我不喜歡寫死,所以還是寫一個共通的版本:

function memoize(fn) {
    const dp = {}
    return function(...args) {
        const key = JSON.stringify(args)
        if (typeof dp[key] !== 'undefined') {
            return dp[key]
        }

        dp[key] = fn(...args)
        return dp[key]
    }
}

只是這是一個很暴力的作法,但是很好懂(?),就是把參數轉成字串當作 key,然後把結果記錄下來,下次再遇到就直接拿來用。

上面這段是 Copilot 幫我寫的,實際上我當初寫法就跟 II 解法很像

這邊也附上我當初解這題的 code

function memoize(fn) {
    const root = {}
    return function(...args) {
        let head = root
        for (let i = 0; i < args.length - 1; i++) {
            const arg = args[i]
            if (typeof head[arg] === 'undefined') {
                head[arg] = {}
            }
            head = head[arg]
        }

        const key = args[args.length-1]
        if (typeof head[key] === 'undefined') {
            head[key] = fn(...args)
        }

        return head[key]
    }
}

有點像是鏈結串列(LinkedList)的解法(雖然實際上是樹Tree),因為我假設他參數都會乖乖丟固定的,不會有一下一個參數一下兩個參數的問題,所以我就把他當作單純鏈結串列來處理,每個參數都是一個節點,然後最後一個參數就是值。

也就是說我假設 sum 這個函數只會 sum(1, 2) 或是 sum(2, 3) 之類的,不會有 sum(1, 2, 3) 的呼叫方式。

假設我照下面的順序去呼叫:

  • sum(1, 2)
  • sum(1, 3)
  • sum(2, 4)
  • sum(2, 3)
  • sum(1, 2)
  • sum(2, 4)

那他的過程會像這樣 []代表結果

sum(1, 2)

root -> 1 -> 2 -> [3]

sum(1, 3)

root -> 1 -> 2 -> [3]
          -> 3 -> [4]

sum(2, 4)

root -> 1 -> 2 -> [3]
          -> 3 -> [4]
     -> 2 -> 4 -> [6]

sum(2, 3)

root -> 1 -> 2 -> [3]
          -> 3 -> [4]
     -> 2 -> 4 -> [6]
          -> 3 -> [5]

sum(1, 2): 已存在,直接用,拿到結果 3

root -> 1 -> 2 -> [3]
          -> 3 -> [4]
     -> 2 -> 4 -> [6]
          -> 3 -> [5]

sum(2, 4): 已存在,直接用,拿到結果 6

root -> 1 -> 2 -> [3]
          -> 3 -> [4]
     -> 2 -> 4 -> [6]
          -> 3 -> [5]

就像上面這個流程去解決這一題。


進入正題 II

這題就是 2623. Memoize 的進階版,題目不再規定你只能處理固定參數的函數,而是可以處理任意參數的函數。

而且參數還不限定型別,只要是 === 就算是一樣的,所以你可以傳 function 進去,也可以傳 object 進去,只要是 === 就好。

這樣的話就不能夠單純使用 Object 來記錄了,因為 Objectkey 只能是可 string 或是 symbol,所以這邊就要使用 Map 來記錄。

而且因為參數不固定,所以也不能使用單純的型態當作節點,而是要記錄當前的節點是不是有值以及下一個節點。

文字上可能有點模糊,但這邊跟字典樹(Trie)有點像,都要去紀錄當前走訪到的節點是不是有資料。

假設下面是一個 Trie,他有著以下資料

  • app
  • apple
  • car
  • cat
  • category

綠色的節點就是有資料的節點,其他都只是過場而已。

而這邊的 Memoize II 就是要做出一個類似的結構,只是他的節點不是字元,而是參數,而且參數可以是任意型別。

function memoize(fn) {
    const valueKey = Symbol('value')
    const defaultValue = Symbol('undefined')
    const root = new Map()
    root.set(valueKey, defaultValue)

    return function(...args) {
        let head = root
        for (let i = 0; i < args.length; i++) {
            const arg = args[i]
            if (!head.has(arg)) {
                const node = new Map()
                node.set(valueKey, defaultValue)
                head.set(arg, node)
            }
            head = head.get(arg)
        }

        const node = head
        if (node.get(valueKey) === defaultValue) {
            node.set(valueKey, fn(...args))
        }

        return node.get(valueKey)
    }
}

Trie 當中是無根樹,而這邊因為有可能沒帶入參數,所以根節點也有可能有資料。

基本上跟上一題的邏輯沒有差太多,只是剛剛解第二次,所以稍微整理了一下 code

解這題算是滿順利的,跟我以前對 LeetCodeHard 題目印象不太一樣,總感覺沒有到 Hard 的等級(?)


2650. Design Cancellable Function

這題題目是這樣的:

給你一個 generator,然後你要回傳一個陣列,裡面包含兩個值,一個是 cancel 函數,一個是 promise

然後題目有說道,你可以假設 generator 只會 yield promise,而你的函數要負責把 promise 的結果回傳給 generator,如果 promise 回傳 reject,你的函數要把 error 丟回給 generator這邊他範例測資就有 yield 4242 並不是一個 promise,所以你還是得處理

然後如果 cancel 被呼叫,那你的函數要丟出一個錯誤給 generator,錯誤訊息是 Cancelled,如果 errorcatch,那你的函數要回傳下一個 yield 或是 return 的值,否則就是回傳 rejecterror

generator 完成的時候,你的函數要回傳 generator 的結果,如果 generator 丟出 error,那你的函數要回傳 rejecterror

這題看起來就比較有 Hard的感覺了(?),至少題目滿繞的

解這題之前你必須要先了解 generatorpromise 的概念,而 promise 會看這篇的人應該都知道,所以這邊就不多做解釋。至少我覺得 promise 應該是大家都會的

generator 的話,我就稍微講一下,因為我覺得這題的重點就在 generator 上。雖然 promise 也很重要

generator 是一種特殊的函數,他可以被暫停,然後可以被重啟,而且可以傳值給他,也可以從他取值。

舉個例子,假設我有一個 generator,他長這樣:

function* gen() {
    console.log('start')
    const a = yield 1
    console.log('a', a)
    const b = yield 2
    console.log('b', b)
    const c = yield 3
    console.log('c', c)
    return 4
}

然後我要用一個變數去接他:

const g = gen()

這時候他並不會執行,因為他只是一個函數,你必須要呼叫他才會執行,而且他也不會執行到底,他會執行到第一個 yield,然後暫停,等待你傳值給他。

所以你可以這樣呼叫:

const g = gen()
console.log(g.next()) // { value: 1, done: false }

可以看到說他回傳了一個物件,裡面有兩個值,一個是 value,一個是 donevalue 就是 yield 後面的值,而 done 就是說他是否執行完畢,如果 donetrue,那就代表他執行完畢了,否則就是 false

而且他確實只執行到 yield 1,因為你可以看到說他只印出了 start,而沒有印出 a

然後後面的同理,你可以這樣呼叫:

const g = gen()
console.log(g.next()) // { value: 1, done: false }
console.log(g.next()) // { value: 2, done: false }
console.log(g.next()) // { value: 3, done: false }
console.log(g.next()) // { value: 4, done: true }

然後你會看到一堆 console.log,那些 abc 都是 undefined,因為你沒有傳值給他,next() 是可以傳值的

const g = gen()
console.log(g.next(100)) // { value: 1, done: false }
console.log(g.next(200)) // { value: 2, done: false }
console.log(g.next(300)) // { value: 3, done: false }
console.log(g.next(400)) // { value: 4, done: true }

你會得到這樣的輸出

a 200
b 300
c 400

為什麼 a 不是 100?

為了清楚解釋這個,我稍微畫了幾張圖:

首先是斷點的部分(黑色線)

接著是 yield 的值傳的位置

再來是 next

接著把斷點跟 next 一起看,就可以知道為什麼 a200

最後再把全部一起看,可以看出來到藍色線為止是一個批次,然後從紅色線開始。

換做文字描述的話就是以下:

  • g.next(100): 執行到 yield,得到 1,然後暫停,等待傳值
  • g.next(200): 傳值 200a,執行到 yield,得到 2,然後暫停,等待傳值
  • g.next(300): 傳值 300b,執行到 yield,得到 3,然後暫停,等待傳值
  • g.next(400): 傳值 400c,執行到最後且有個 return,所以得到 4

所以 a200b300c400

大概是上面這個運作流程。

Generatorthrow

而這題還需要用到 generatorthrow,他可以丟出一個錯誤給 generator,然後 generator 可以用 try...catch 來接。

舉個例子: (假設我要觸發 yield 2)

function* gen() {
    try {
        yield 1
    } catch {
        yield 2
    }
}

如果你只有一昧的 next,那你會得到 1,因為你沒有丟出錯誤,所以 catch 不會被執行,也就是 yield 2 不會被執行。

const g = gen()
console.log(g.next()) // { value: 1, done: false }
console.log(g.next()) // { value: undefined, done: true }

提示: 為什麼第二個 next 會得到 undefined,因為 yield 1 之後沒有 return 東西,所以他會得到 undefined

如果你要讓 catch 被執行,執行到 yield 2,那麼就要丟出錯誤,這時候就要用到 throw

const g = gen()
console.log(g.throw())

這時會得到錯誤

caught undefined

且這時你在呼叫 next() 也沒有意義了,因為他沒捕捉到錯誤,可是你可能會問,不對阿不是有 catch 嗎? 為什麼沒有執行到 yield 2?

這時回去看看前面的流程,你必須先呼叫一次 next() 到達斷點之後,才會進入 try...catch 的區塊內,所以你必須要這樣呼叫:

const g = gen()
console.log(g.next()) // { value: 1, done: false }
console.log(g.throw()) // { value: 2, done: false }
console.log(g.next()) // { value: undefined, done: true }

這樣才會正常拿到 yield 2 的值。


理解完 generator 基本操作之後,開始解題

先整理一下題目的需求:

  • 寫一個函數,接收一個 generator,回傳一個陣列,裡面包含兩個值,一個是 cancel 函數,一個是 promise
  • 需要把 promise 的結果回傳給 generator,如果 promise 回傳 reject,要把 error 丟回給 generator
  • 如果 cancel 被呼叫,要丟出一個錯誤給 generator,錯誤訊息是 Cancelled,如果 errorcatch,要回傳下一個 yield 或是 return 的值,否則就是回傳 rejecterror
  • generator 完成的時候,要 resolve generator 的結果,如果 generator 丟出 error,要回傳 reject

上面的步驟有點多,所以直接附上整理過的 Code 加上註解解釋吧:

var cancellable = function(generator) {
    let canceled = false  // 定義一個變數,用來判斷是否被取消

    const next = (currentResult) => {
        return new Promise(async (resolve, reject) => {
            try {
                const {value, done} = currentResult  // 抓出當前的 value 跟 done
                try {
                    const val = await Promise.resolve(value) // 把 value 轉成 promise 並解析
                    if (canceled) {  // 如果被取消
                        throw 'Cancelled' // 丟出錯誤
                    } else if (done) { // 如果已經完成
                        resolve(val) // 回傳結果
                    } else { // 如果還沒完成
                        resolve(next(generator.next(val))) // 呼叫 next,並把結果 resolve
                    }
                } catch (e) { // 如果 value 是 reject 或 throw 的話
                    const nextResult = generator.throw(e) // 丟出錯誤給 generator,如果有處理就會往下,沒處理就會跑到 catch
                    if (canceled) { // 如果被取消
                        resolve(await nextResult.value) // 把結果 resolve
                    } else { // 如果沒被取消
                        resolve(next(nextResult)) // 呼叫 next,繼續往下執行
                    }
                }
            } catch (e) { // 如果 generator 丟出錯誤
                reject(e) // 回傳 reject
            }
        })
    }


    return [
        () => canceled = true,
        new Promise((resolve, reject) => {
            return next(generator.next()).then(resolve).catch(reject) // 呼叫 next,並把結果 resolve 或 reject
        })
    ] // 回傳一個陣列,第一個是 cancel 函數,第二個是 promise
};

上面讀起來很單純,但強烈建議親自下去寫一遍,其實沒有那麼簡單,你必須很理解 generatorpromise 的互動,才能夠寫出來。

如果還是不理解,我嘗試用一張圖來解釋這題到底在幹嘛:

就拿他最複雜的 Example 6 來說,他的流程是這樣的: (從星星處開始看)

理解完第一次循環後,後面我一次把他全部展開,你可以看到說他的流程是這樣的:(順序為「黑」、「」、「」、「」)

過程中可能有少一些線,但你懂我意思就好,不太會畫圖

避免不確定 Cancel 的流程,所以拿 Example 5 當作範例,再來畫一張圖:

(因為這可能是比較容易有問題的,為什麼他 Cancel 卻還是 resolved)

線有點多,總之就是按照順序看(從星星處開始,由上往下)。

主要重點是他在第二次的 yield new Promise(res => setTimeout(res, 100)); 解析完之後發現被 Cancel 了,所以拋出錯誤。


總結

雖然平時幾乎不會用到 generator (也可能是我沒用到而已),但這題還是滿有趣的,而且也算是滿有挑戰性的,也可以讓你更了解 generatorpromise 的互動,強烈建議親自下去寫一遍。




最後更新時間: 2023年05月05日.