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

之前寫了篇文章 用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衝鴨!!!

參考資料

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