用 JavaScript 刷 LeetCode 的正確姿勢【進階】

wenr發表於2021-08-02

之前寫了篇文章 用JavaScript刷LeetCode的正確姿勢,簡單總結一些用 JavaScript 刷力扣的基本除錯技巧。最近又刷了點題,總結了些資料結構和演算法,希望能對各為 JSer 刷題提供幫助。

此篇文章主要想給大家一些開箱即用的 JavaScipt 版本的程式碼模板,涉及到較複雜的知識點,原理部分可能會省略,有需要的話後面有時間可以給部分知識點單獨寫一篇詳細的講解。

走過路過發現 bug 請指出,拯救一個辣雞(但很帥)的少年就靠您啦!!!

BigInt

眾所周知,JavaScript 只能精確表達 Number.MIN_SAFE_INTEGER(-2^53+1) ~ Number.MAX_SAFE_INTEGER(2^53-1) 的值。

而在一些題目中,常常會有較大的數字計算,這時就會產生誤差。舉個例子:在控制檯輸入下面的兩個表示式會得到相同的結果:

>> 123456789*123456789      // 15241578750190520
>> 123456789*123456789+1    // 15241578750190520

而如果使用 BigInt 則可以精確求值:

>> BigInt(123456789)*BigInt(123456789)              // 15241578750190521n
>> BigInt(123456789)*BigInt(123456789)+BigInt(1)    // 15241578750190522n

可以通過在一個整數字面量後面加 n 的方式定義一個 BigInt ,如:10n,或者呼叫函式 BigInt()。上面的表示式也可以寫成:

>> 123456789n*123456789n       // 15241578750190521n
>> 123456789n*123456789n+1n    // 15241578750190522n

BigInt 只能與 BigInt 做運算,如果和 Number 進行計算需要先通過 BigInt() 做型別轉換。

BigInt 支援運算子,+*-**% 。除 >>>(無符號右移)之外的位操作也可以支援。因為 BigInt 都是有符號的, >>>(無符號右移)不能用於 BigIntBigInt 不支援單目 (+) 運算子。

BigInt 也支援 / 運算子,但是會被向上取整。

const rounded = 5n / 2n; // 2n, not 2.5n

取模運算

在資料較大時,一般沒有辦法直接去進行計算,通常都會給一個大質數(例如,1000000007),求對質數取模後的結果。

取模運算的常用性質:

(a + b) % p = (a % p + b % p) % p
(a - b) % p = (a % p - b % p) % p
(a * b) % p = (a % p * b % p) % p
a ^ b % p = ((a % p) ^ b) % p

可以看出,加/減/乘/乘方,都可直接在運算的時候取模,至於除法則會複雜一些,稍後再講。

舉一個例子,LeetCode 1175. 質數排列

請你幫忙給從 1n 的數設計排列方案,使得所有的「質數」都應該被放在「質數索引」(索引從 1 開始)上;你需要返回可能的方案總數。

讓我們一起來回顧一下「質數」:質數一定是大於 1 的,並且不能用兩個小於它的正整數的乘積來表示。

由於答案可能會很大,所以請你返回答案 模 mod 10^9 + 7 之後的結果即可。

題目很簡單,先求出質數的個數 x,則答案為 x!(n-x)!(不理解的可以去看題解區找題解,這裡就不詳細解釋了)

由於階乘的值很大,所以在求階乘的時候需要在運算時取模,同時這裡用到了上面所說的BigInt

/**
 * @param {number} n
 * @return {number}
 */
var numPrimeArrangements = function(n) {
    const mod = 1000000007n;
    // 先把100以內的質數打表(不想再寫判斷質數的程式碼了
    const prime = [2,3,5,7,11,13,17,19,23,29,31,37,41,43,47,53,59,61,67,71,73,79,83,89,97];
    // 預處理階乘
    const fac = new Array(n + 1);
    fac[0] = 1n; // 要用bigint
    for (let i = 1; i <= n; i++) {
        fac[i] = fac[i - 1] * BigInt(i) % mod;
    }
    // 先求n以內的質數的個數
    const x = prime.filter(i => i <= n).length;
    // x!(n-x)!
    return fac[x] * fac[n - x] % mod;
};

快速冪

快速冪,顧名思義,快速求冪運算。原理也很簡單,比如我們求 x^10 我們可以求 (x^5)^2 可以減少一半的運算。

假設我們求 (x^n)

  • 如果 n 是偶數,變為求 (x^(n/2))^2
  • 如果 n 是奇數,則求 (x^⌊n/2⌋)^2 * x⌊⌋ 是向下取整)

因為快速冪涉及到的題目一般資料都很大,需要取模,所以加了取模運算。其中,程式碼中 n>>=1 相當於 n=n/2if(n&1)是在判斷n是否為奇數。

程式碼如下:

// x ^ n % mod
function pow(x, n, mod) {
    let ans = 1;
    while (n > 0) {
        if (n & 1) ans = ans * x % mod;
        x = x * x % mod;
        n >>= 1;
    }
    return ans;
}

乘法逆元(數論倒數)

上面說了除法的取模會複雜一些,其實就是涉及了乘法逆元

當我們求 (a/b)%p 你以為會是簡單的 ((a%p)/(b%p))%p?當然不是!(反例自己想去Orz

假設有 (a*x)%p=1 則稱 ax關於p互為逆元(ax 關於 p 的逆元,xa 關於 p 的逆元)。比如:2*3%5=123 關於 5 互為逆元。

我們把 a 的逆元用 inv(a) 表示。那麼:

(a/b) % p
= ( (a/b) * (b*inv(b)) ) % p // 因為(b*inv(b))為1
= (a * inv(b)) % p
= (a%p * inv(b)%p) % p

現在通過逆元神奇的把除法運算變沒了~~~

問題在於怎麼求乘法逆元。有兩種方式,費馬小定理擴充套件歐幾里德演算法

不求甚解的我只記了一種解法,即費馬小定理:a^(p-1) ≡ 1 (mod p)

由費馬小定理我們可以推論:a^(p-2) ≡ inv(a) (mod p)

數學家的事我們程式設計師就不要想那麼多啦,記結論就好了。即:

a關於p的逆元為a^(p-2)

好了,現在可以通過快速冪求出 a 的逆元了。

function inv(a, p) {
    return pow(a, p - 2, p); // pow是上面定義的快速冪函式
}

(P.S.其實我數論很爛= =,平時都是直接記結論,所以此處講解可能存在不準確的情況。僅供參考。

二分答案

解題的時候往往會考慮列舉答案然後檢驗列舉的值是否正確。若滿足單調性,則滿足使用二分法的條件。把這裡的列舉換成二分,就變成了“二分答案”。二分答案的時間複雜度是O(logN * (單次驗證當前值是否滿足條件的複雜度))

很多同學在邊界問題上經常出bug,也會不小心寫個死迴圈什麼的,我總結了一個簡單清晰不會出錯的二分模板:

// isValid 判斷某個值是否合法 根據題目要求實現
// 假設 如果x合法則大於x一定合法 如果x不合法則小於x一定不合法
// 求最小合法值
function binaryCalc() {
    let l = 0, r = 10000;   // 答案可能出現的最小值l和最大值r 根據題目設定具體值
    let ans;    // 最終答案
    while (l <= r) {
        let mid = (l + r) >> 1; // 位運算取中間值 相當於 floor((l+r)/2)
        if (isValid(mid)) {
            // 如果 mid 合法 則 [mid, r] 都是合法的
            // 我們先把ans設定為當前獲取的合法值的最小值 mid
            ans = mid;
            // 然後再去繼續去求[l,mid-1]裡面是否有合法值
            r = mid - 1;
        } else {
            // 如果mid不合法 則[l,mid]都是不合法的
            // 我們去[mid+1,r]中找答案
            l = mid + 1;
        }
    }
    return ans;
}

舉一個簡單的例子,LeetCode 69. x 的平方根 是一個二分模板題。題目要求是,給一個數字 x 求平方小於等於 x的最大整數。此處求的是最大值,和模板中對lr的處理剛好相反。

/**
 * @param {number} x
 * @return {number}
 */
 var mySqrt = function(x) {
    let l = 0, r = x; // 根據題目要求 答案可能的值最小為0 最大為x
    let ans = 0;      // 最終答案
    
    function isValid(v) {       // 判斷一個數是否合法
        return v * v <= x;
    }

    while (l <= r) {
        let mid = (l + r) >> 1; // 取中間值
        if (isValid(mid)) {
            ans = mid;
            l = mid + 1;
        } else {
            r = mid - 1;
        }
    }
    return ans;
};

並查集

個人覺得並查集是非常精妙且簡潔優雅的資料結構,推薦學習。

並查集應用場景為,存在一些元素,分別包含在不同集合中,需要快速合併兩個集合,同時可快速求出兩個元素是否處於同一集合。

簡單的理解並查集的實現,就是把每一個集合都當做一棵樹,每個節點都有一個父節點,每棵樹都有一個根節點(根節點的父節點為其本身)。

判斷是否同一集合:我們可以順著節點的父節點找到該節點所在集合的根節點。當我們確定兩個集合擁有同一個根節點,則證明兩個節點處於同一個集合。

合併操作:分別取得兩個節點所在集合的根節點,把其中一個根節點的父節點設定為另一個根節點即可。

可能說的比較抽象,想詳細瞭解的同學可以自己深入學習,這裡直接給出程式碼模板。

class UnionFind {
    constructor(n) {
        this.n = n; // 節點個數
        // 記錄每個節點的父節點 初始時每個節點自己為一個集合 即每個節點的父節點都是其本身
        this.father = new Array(n).fill().map((v, index) => index);
    }
    // 尋找一個節點的根節點
    find(x) {
        // 如果父節點為其本身 則證明是根節點
        if (x == this.father[x]) {
            return x;
        }
        // 遞迴查詢
        // 此處進行了路徑壓縮 即將x的父節點直接設定為根節點 下一次查詢的時候 將減少遞迴次數
        return this.father[x] = this.find(this.father[x]);
    }
    // 合併x和y所在的兩個集合
    merge(x, y) {
        const xRoot = this.find(x); // 找到x的根節點
        const yRoot = this.find(y); // 找到y的根節點
        this.father[xRoot] = yRoot; // 將xRoot的父節點設定為yRoot 即可將兩個集合合併
    }
    // 計算集合個數
    count() {
        // 其實就是查詢根節點的個數
        let cnt = 0;
        for (let i = 0; i < this.n; i++) {
            if (this.father[i] === i) { // 判斷是否為根節點
                cnt++;
            }
        }
        return cnt;
    }
}

找一個並查集的題目,方便大家理解並查集的妙處。並查集的題目可以出得非常靈活,可能不會輕易看出是並查集。 LeetCode 947. 移除最多的同行或同列石頭

n 塊石頭放置在二維平面中的一些整數座標點上。每個座標點上最多隻能有一塊石頭。

如果一塊石頭的 同行或者同列 上有其他石頭存在,那麼就可以移除這塊石頭。

給你一個長度為 n 的陣列 stones ,其中 stones[i] = [xi, yi] 表示第 i 塊石頭的位置,返回 可以移除的石子 的最大數量。

此處參考了官方的題解

把二維座標平面上的石頭想象成圖的頂點,如果兩個石頭橫座標相同、或者縱座標相同,在它們之間形成一條邊。

image.png

根據可以移除石頭的規則:如果一塊石頭的 同行或者同列 上有其他石頭存在,那麼就可以移除這塊石頭。可以發現:一定可以把一個連通圖裡的所有頂點根據這個規則刪到只剩下一個頂點。

我們遍歷所有的石頭,發現如果有兩個石頭的橫座標或者縱座標相等,則證明這兩塊石頭應該在同一個集合(即上面說的連通圖)裡。那麼最後每個集合只留一塊石頭,剩下的則全部可以被移除。

AC程式碼:

// 定義 UnionFind 相關程式碼
/**
 * @param {number[][]} stones
 * @return {number}
 */
 var removeStones = function(stones) {
    let n = stones.length;
    let uf = new UnionFind(n);
    for (let i = 0; i < n; i++) {
        for (let j = i + 1; j < n; j++) {
            // 有兩個石頭的橫座標或者縱座標相等 則合併
            if (stones[i][0] == stones[j][0] || stones[i][1] == stones[j][1]) {
                uf.merge(i, j);
            }
        }
    }
    // 石頭總數減去集合的個數就是答案
    return n - uf.count();
};

KMP

KMP 被一些演算法初學者認為是高難度資料結構,一般遇到直接放棄那種。所以我想了下幾句話應該也解釋不清,那就跳過原理直接上模板吧。?

先簡單說一下背景,KMP 解決的是子串查詢的問題。給兩個字串ST,求T是否是S的子串。解決方法是先預處理T,求出Tnext陣列,其中next[i]代表T的子串T[0...i-1](即T.substring(0, i)最長相等的字首字尾 的長度。

嘛,最長相等的字首字尾,就是說,比如字串"abcuuabc"最長相等的字首字尾就是abc,那麼其長度就應該是3

然後藉助next陣列,可以線上性時間複雜度內求出T是否為S的子串,首次出現下標,以及出現次數。

模板程式碼:

// 求字串 s 的 next 陣列
function getNext(s) {
    let len = s.length;
    let next = new Array(len + 1);
    let j = 0, k = -1;
    next[0] = -1;
    while (j < len) {
        if (k == -1 || s[j] === s[k]) next[++j] = ++k;
        else k = next[k];
    }
    return next;
}
// 求字串 t 在字串 s 中第一次出現的下標 不存在則返回 -1
function findIndex(s, t) {
    let i = 0, j = 0;
    let next = getNext(t);
    let slen = s.length, tlen = t.length;
    while (i < slen && j < tlen) {
        if (j === -1 || s[i] === t[j]) ++i, ++j;
        else j = next[j];
    }
    return j === tlen ? i - tlen : -1;
}
// 求字串 t 在字串 s 出現的次數
function findCount(s, t) {
    let ans = 0;
    let i = 0, j = 0;
    let next = getNext(t);
    let slen = s.length, tlen = t.length;
    while (i < slen && j < tlen) {
        if (j === -1 || s[i] === t[j]) ++i, ++j;
        else j = next[j];
        if (j === tlen) {
            ++ans;
            j = next[j];
        }
    }
    return ans;
}

如果多次計運算元串相同的話,next陣列可以預處理,不需要每次在求index時再計算。

舉個例子吧,LeetCode 1392. 最長快樂字首

「快樂字首」是在原字串中既是 非空 字首也是字尾(不包括原字串自身)的字串。

給你一個字串 s,請你返回它的 最長快樂字首

如果不存在滿足題意的字首,則返回一個空字串。

我們會發現這不就是 next 陣列麼,所以我記得這次周賽會 KMP 的同學直接 copy 就得分了.....

AC程式碼;

// getNext 定義參考上面模板
/**
 * @param {string} s
 * @return {string}
 */
var longestPrefix = function(s) {
    let len = s.length;
    let next = getNext(s);
    let ansLen = next[len] == len ? len - 1 : next[len]; // 不包含原字串 需要特殊判斷下
    return s.substring(0, ansLen);
};

再來一個 LeetCode 28. 實現 strStr() 求一個字串在另一個字串中首次出現的位置,就是indexOf的實現,其實也就是模板中的 findIndex 函式。

AC程式碼:

// findIndex 定義參考模板
/**
 * @param {string} haystack
 * @param {string} needle
 * @return {number}
 */
var strStr = function(haystack, needle) {
    return findIndex(haystack, needle);
};

優先佇列(堆)

優先佇列,我們給每個元素定義優先順序,每次取佇列中的值都取的是優先順序最大的數。

其他的語言中都自帶優先佇列的實現,JSer就只能QAQ……所以我自己寫了一個優先佇列,就是通過堆來實現。(原理就不講啦,學過堆排序的應該懂~(趴

class PriorityQueue {
    /**
     * 建構函式 可以傳入比較函式自定義優先順序 預設是最小值排在最前
     * @param {function} compareFunc 比較函式 compareFunc(a, b) 為 true 表示 a 的優先順序 > b
     */
    constructor(compareFunc) {
        this.queue = [];
        this.func = compareFunc || ((a, b) => a < b);
    }
    /**
     * 向優先佇列新增一個元素
     */
    push(ele) {
        this.queue.push(ele);
        this.pushup(this.size() - 1)
    }
    /**
     * 彈出最小值並返回
     */
    pop() {
        let { queue } = this;
        if (queue.length <= 1) return queue.pop();
        
        let min = queue[0];
        queue[0] = queue.pop();
        this.pushdown(0);
        return min;
    }
    /**
     * 返回最小值
     */
    top() {
        return this.size() ? this.queue[0] : null;
    }
    /**
     * 返回佇列中元素的個數
     */
    size() {
        return this.queue.length;
    }
    /**
     * 初始化堆
     */
    setQueue(queue) {
        this.queue = queue;
        for (let i = (this.size() >> 1); i >= 0; i--) {
            this.pushdown(i);
        }
    }
    /**
     * 調整以保證 queue[index] 是子樹中最小的
     * */
    pushdown(index) {
        let { queue, func } = this;
        let fa = index;
        let cd = index * 2 + 1;
        let size = queue.length;
        while (cd < size) {
            if (cd + 1 < size && func(queue[cd + 1], queue[cd])) cd++;
            if (func(queue[fa], queue[cd])) break;
            // 交換 queue[fa] 和 queue[cd]
            [queue[fa], queue[cd]] = [queue[cd], queue[fa]];
            // 繼續處理子樹
            fa = cd;
            cd = fa * 2 + 1;
        }
    }
    /**
     * 調整 index 到合法位置
     */
    pushup(index) {
        let { queue, func } = this;
        while (index) {
            const fa = (index - 1) >> 1;
            if (func(queue[fa], queue[index])) {
                break;
            }
            [queue[fa], queue[index]] = [queue[index], queue[fa]];
            index = fa;
        }
    }
}

舉個例子,LeetCode 23. 合併K個升序連結串列 一道困難題目哦~

給你一個連結串列陣列,每個連結串列都已經按升序排列。

請你將所有連結串列合併到一個升序連結串列中,返回合併後的連結串列。

做法很簡單,把連結串列都放到優先佇列裡,每次取值最小的連結串列就行。具體實現看程式碼。

/**
 * @param {ListNode[]} lists
 * @return {ListNode}
 */
var mergeKLists = function(lists) {
    let queue = new PriorityQueue((a, b) => a.val < b.val);

    lists.forEach(list => {
        list && queue.push(list);
    });

    const dummy = new ListNode(0);
    let cur = dummy;

    while (queue.size()) {
        let node = queue.pop();
        if (node.next) queue.push(node.next);
        cur.next = new ListNode(node.val);
        cur = cur.next;
    }

    return dummy.next;
};

Trie(字典樹/字首樹)

字典樹應該算是一個比較簡單而且直觀的資料結構~字典樹模板題可以看 LeetCode 208. 實現 Trie (字首樹)

/**
 * Initialize your data structure here.
 */
var Trie = function() {
    this.nodes = [];
};

/**
 * Inserts a word into the trie. 
 * @param {string} word
 * @return {void}
 */
Trie.prototype.insert = function(word) {
    let nodes = this.nodes;
    for (let w of word) {
        if (!nodes[w]) {
            nodes[w] = {};
        }
        nodes = nodes[w];
    }
    nodes.end = true;
};

/**
 * Returns if the word is in the trie. 
 * @param {string} word
 * @return {boolean}
 */
Trie.prototype.search = function(word) {
    let nodes = this.nodes;
    for (let w of word) {
        if (!nodes[w]) {
            return false;
        }
        nodes = nodes[w];
    }
    return !!nodes.end;
};

/**
 * Returns if there is any word in the trie that starts with the given prefix. 
 * @param {string} prefix
 * @return {boolean}
 */
Trie.prototype.startsWith = function(prefix) {
    let nodes = this.nodes;
    for (let w of prefix) {
        if (!nodes[w]) {
            return false;
        }
        nodes = nodes[w];
    }
    return true;
};

字典樹的變種應用,LeetCode 421. 陣列中兩個數的最大異或值 參考:題解

我們也可以將陣列中的元素看成長度為 31 的字串,字串中只包含 01。如果我們將字串放入字典樹中,那麼在字典樹中查詢一個字串的過程,恰好就是從高位開始確定每一個二進位制位的過程。對於一個數求異或和的最大值,就是從最高位開始,每一位都找異或和最大的那個分支。

var Trie = function() {
    this.nodes = [];
};
Trie.prototype.insert = function(digit) {
    let nodes = this.nodes;
    for (let d of digit) {
        if (!nodes[d]) {
            nodes[d] = [];
        }
        nodes = nodes[d];
    }
};
Trie.prototype.maxXor = function(digit) {
    let xor = 0;
    let nodes = this.nodes;
    for (let i = 0; i < digit.length; i++) {
        let d = digit[i];
        if (nodes[d ^ 1]) {
            xor += 1 << (digit.length - i - 1);
            nodes = nodes[d ^ 1];
        } else {
            nodes = nodes[d];
        }
    }
    return xor;
};

/**
 * @param {number[]} nums
 * @return {number}
 */
var findMaximumXOR = function(nums) {
    let trie = new Trie();
    let maxXor = 0;
    for (let x of nums) {
        let binaryX = x.toString(2);
        // 因為 0 <= nums[i] <= 2^31 - 1 所以最多為31位
        // 補字首0統一變成31位
        binaryX = ('0'.repeat(31) + binaryX).substr(-31);
        // 插入Trie
        trie.insert(binaryX);
        maxXor = Math.max(maxXor, trie.maxXor(binaryX));
    }
    return maxXor;
};

總結

暫時就想到這麼多比較常見的資料結構。如果有其他的可以在評論區補充,如果我會的話會後續加上的。

JSer衝鴨!!!

參考資料

相關文章