用 JavaScript 刷 LeetCode 的正确姿式【进阶】

本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!html

以前写了篇文章 用JavaScript刷LeetCode的正确姿式,简单总结一些用 JavaScript 刷力扣的基本调试技巧。最近又刷了点题,总结了些数据结构和算法,但愿能对各为 JSer 刷题提供帮助。前端

此篇文章主要想给你们一些开箱即用的 JavaScipt 版本的代码模板,涉及到较复杂的知识点,原理部分可能会省略,有须要的话后面有时间能够给部分知识点单独写一篇详细的讲解。node

走过路过发现 bug 请指出,拯救一个辣鸡(但很帅)的少年就靠您啦!!!git

BigInt

众所周知,JavaScript 只能精确表达 Number.MIN_SAFE_INTEGER(-2^53+1) ~ Number.MAX_SAFE_INTEGER(2^53-1) 的值。程序员

而在一些题目中,经常会有较大的数字计算,这时就会产生偏差。举个栗子:在控制台输入下面的两个表达式会获得相同的结果:web

>> 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 不支持单目 (+) 运算符。markdown

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 被一些算法初学者认为是高难度数据结构,通常遇到直接放弃那种。因此我想了下几句话应该也解释不清,那就跳过原理直接上模板吧。:P

先简单说一下背景,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冲鸭!!!

参考资料

相关文章
相关标签/搜索