用 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冲鸭!!!

参考资料

posted @ 2021-08-02 10:15  我不吃饼干呀  阅读(1600)  评论(0编辑  收藏  举报