🐮写给零基础的前端算法入门指南,acmer带女友刷80+【双指针与字符串篇】

5,095 阅读10分钟

前言

上一篇的反馈还算不错,原来拜完年后真有一批人开始学习啦,2021春招陆续开始啦,我能给大家帮助的就是尽量在2月份给大家更新完算法入门专栏,能帮助到大家很开心👍~

现在和大家分享一下我们是如何准备算法这一块的,春招即将开启,还能最后准备一下,希望对大家有所帮助。

原本打算通过一篇文章介绍一下,推荐一下自己的刷题方式和刷题路线,得到一些伙伴的反馈:最好还是更加详细,面向零基础,小白这些,还有github访问速度也是一方面问题,可能图片都加载不出来。

因此,我打算分模块出几期文章,这样你只用通过首发在掘金的文章即可了解 Chocolate 同学整体刷题汇总啦。马上就要过年了,希望能够帮助你的春招。打算出的内容计划安排如下:

  • 🐮写给零基础的前端算法入门指南,acmer带女友刷80+【栈与队列与链表篇】(已完成🎉
  • 🐮写给零基础的前端算法入门指南,acmer带女友刷80+【递归与回溯篇】(已完成🎉
  • 🐮写给零基础的前端算法入门指南,acmer带女友刷80+【双指针与字符串篇】(本期已完成🎉)
  • 🐮写给零基础的前端算法入门指南,acmer带女友刷80+【二叉树篇】
  • 🐮写给零基础的前端算法入门指南,acmer带女友刷80+【动态规划DP篇】
  • 🐮写给零基础的前端算法入门指南,acmer带女友刷80+【总结篇】

欢迎访问 GitHub仓库,目前已经有 552 道大厂真题了,涵盖各类前端的真题,祝你春秋招牛气冲天~

算法这一块到底如何准备

首先,我来简单介绍一下自己,在校打过ACM(如果没听过,当我没说,因为没有很大价值的牌牌,铁牌,参赛证以及证书倒是一堆)

如果你知道acm,并且参与过,对于国内前端(注意是说前端)面试的话,应该不需要花费很长的刷题时间,如果大家有想法了解我的acm经历的话,这个后续我会考虑在 B站发布一期视频

那么对于零基础的小白来说,可能需要花10-20天左右时间来准备算法,而对于非科班来说这个周期可能会更长一点。那么,现在我准备来分享我是如何带着女友零基础刷题的。

  • 第一点,明确算法它不是很难的东西,理解了其实就那会事,或许你还会喜欢上做题,当然,对于acm大佬做的题就另当别论了,这篇文章主体与面试水平为准
  • 第二点,前端对于算法这一块的考察相对来说会偏简单一点,我在春秋招过程中遇到的笔试题都是一些常见的题目,比如搜索,贪心,简单动态规划,经典排序算法,都是以 leetcode一些简单以及中等难度的居多,而这些算法对于科班来说的话,应该在学校都学习过,比如算法分析与设计,数据结构与算法这一类课程,那么有这个基础,你的刷题时间又可以进行缩短了
  • 第三点,既然说到要刷题,该如何刷,我在掘金参考了几个大佬(文末有参考处),大家都会推荐分专题来刷,在这里,我也是非常推荐的,在这里,我希望的是将刷算法题的数量再减少一点,带你入门,当你刷完这些专题之后,你就有相关思维能力主动去刷题了,而不是很被动的去刷,这样也很方便自己总结归纳~
  • 其它,可以参考大佬的文章,这里不再赘述...

一份思维导图,让你的刷题路线更简单

开门见山地说,首先提供一份思维导图,让知识由繁到简。

获取高清PDF,请在微信公众号【小狮子前端】回复【LeetCode】,一起刷题或者交流学习可以加企鹅群【666151691】

本仓库刷题路线参考 ssh (给大佬点赞) 仓库地址:github.com/sl1673495/l…

感谢大佬的归纳总结,原本打算在大佬那里打卡学习,后面考虑不太友好,还是自己新建了一个仓库打卡学习。

其次,本仓库解题代码大部分是自己的代码风格,题量也进行了拓展,将会持续更新下去,何不star收藏一下?

仓库介绍

仓库地址:github.com/Chocolate19…

本仓库将全程使用的语言是 JavaScript,是一个纯前端刷题路线,对于前端刷题没有方向的小伙伴简直是福音。解题代码会记录在本仓库的 Issues 中,会按照 label 进行分类。比如想查看 「递归与回溯」 分类下的问题,那么选择标签进行筛选即可。

同时,小伙伴们可以在 Issues 中提交自己的解题代码,🤝 欢迎 Contributing ,可打卡刷题,坚持下来的人最酷!Give a ⭐️ if this project helped you !

刷题路线

下面正式开始我们的刷题之路,给本篇文章点个赞,拿出自己心仪的键盘,开始!

以下专题顺序仅个人以及面试高频点来总结的刷题方式,大家可以根据自己的想法来组合。更多题集请参考本仓库哈~

双指针

15. 三数之和

三数之和原题传送门

题目描述

给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?请你找出所有满足条件且不重复的三元组。

注意:答案中不可以包含重复的三元组。

示例:

给定数组 nums = [-1, 0, 1, 2, -1, -4],

满足要求的三元组集合为:
[
  [-1, 0, 1],
  [-1, -1, 2]
]

解题思路

因为不能有重复的解,为了简化操作,我们先对数组排序,于是判断一个元素是否重复,只需看它和它前面的元素是否相等即可

双指针的移动时,避免出现重复解

得到一个解后,需要左右指针向 “内” 收缩,为了避免指向重复的元素

  • 左指针要在 left < right 的前提下,一直右移,直到指向不重复的元素
  • 右指针要在 left < right 的前提下,一直左移,直到指向不重复的元素

优化点,如果当前元素值大于0了,由于我们事先排好序了,不存在三个数相加为0了,此时直接break就好了。

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var threeSum = function (nums) {
    let len = nums.length;
    if (len < 2) return [];
    let res = [];
    nums.sort((a, b) => a - b); // 从小到大进行排序
    for (let i = 0; i < len - 2; i++) {
        if (nums[i] > 0) break;
        if (i > 0 && nums[i] === nums[i - 1]) continue; // 去掉重复项
        let L = i + 1;
        let R = len - 1;
        while (L < R) {
            let sum = nums[i] + nums[L] + nums[R]; // 三数之和
            if (sum === 0) {
                res.push([nums[i], nums[L], nums[R]]);
                while (L < R && nums[L] == nums[L + 1]) L++; // 去重,直到指向不一样的数
                while (L < R && nums[R] == nums[R - 1]) R--;
                L++;
                R--;
            } else if (sum < 0) {
                L++; // 和小于0,就是左边值太小了,往右移
            } else if (sum > 0) {
                R--; // 和大于0,就是右边值太大了,往左移
            }
        }
    }
    return res;
};

16. 最接近的三数之和

16. 最接近的三数之和传送门

题目描述

给定一个包括 n 个整数的数组 nums 和 一个目标值 target。找出 nums 中的三个整数,使得它们的和与 target 最接近。返回这三个数的和。假定每组输入只存在唯一答案。

示例:

输入:nums = [-1,2,1,-4], target = 1
输出:2
解释:与 target 最接近的和是 2 (-1 + 2 + 1 = 2) 。

提示:

  • 3 <= nums.length <= 10^3
  • -10^3 <= nums[i] <= 10^3
  • -10^4 <= target <= 10^4

解题思路

这道题和15有一点区别,我们只要求最接近 target 的三树之和,那么我们就需要每次更新一下,最接近的和,简单来说就是比较一下,然后本题也没有去重操作,相对来说考虑情况会更少一点。

/**
 * @param {number[]} nums
 * @param {number} target
 * @return {number}
 */
var threeSumClosest = function (nums, target) {
    let len = nums.length;
    nums.sort((a, b) => a - b); // 从小到大进行排序
    let res = nums[0] + nums[1] + nums[len - 1]; // 初始化随机一个res
    for (let i = 0; i < len - 2; i++) {
        let L = i + 1;
        let R = len - 1;
        while (L < R) {
            let sum = nums[i] + nums[L] + nums[R]; // 三数之和
            sum > target ? R-- : L++; // 比目标值大,就往左内缩,小的话,就往右内缩
            if (Math.abs(sum - target) < Math.abs(res - target)) {
                res = sum; // 迭代更新res
            }
        }
    }
    return res;
};

75. 颜色分类

75. 颜色分类原题传送门

题目描述

给定一个包含红色、白色和蓝色,一共 n 个元素的数组,原地对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。

此题中,我们使用整数 0、 1 和 2 分别表示红色、白色和蓝色。

注意: 不能使用代码库中的排序函数来解决这道题。

示例:

输入: [2,0,2,1,1,0]
输出: [0,0,1,1,2,2]

进阶:

一个直观的解决方案是使用计数排序的两趟扫描算法。 首先,迭代计算出0、1 和 2 元素的个数,然后按照0、1、2的排序,重写当前数组。 你能想出一个仅使用常数空间的一趟扫描算法吗?

解题思路

双指针,当前值为2,那么就和右边指针进行交换,反之当前值为0,那么就和左边指针进行交换,为1就不动。

/**
 * @param {number[]} nums
 * @return {void} Do not return anything, modify nums in-place instead.
 */
var sortColors = function (nums) {
    let len = nums.length;
    let L = 0;
    let R = len - 1;
    let i = 0;
    while (i <= R) {
        while (nums[i] == 2 && i < R) { // 当前值为2,那么就和右边指针进行交换
            [nums[i], nums[R]] = [nums[R], nums[i]];
            R--;
        }
        while (nums[i] == 0 && i > L) { // 当前值为0,那么就和左边指针进行交换
            [nums[i], nums[L]] = [nums[L], nums[i]];
            L++;
        }
        i++;
    }
    return nums;
};

我想下面这份代码应该会更好理解一点:

/**
 * @param {number[]} nums
 * @return {void} Do not return anything, modify nums in-place instead.
 */
var sortColors = function (nums) {
    let len = nums.length;
    let L = 0;
    let R = len - 1;
    let i = 0;
    while (i <= R) {
        if (nums[i] == 0) { // 当前值为0,那么就和左边指针进行交换
            [nums[i], nums[L]] = [nums[L], nums[i]];
            L++;
            i++;
        } else if (nums[i] == 2) { // 当前值为2,那么就和右边指针进行交换
            [nums[i], nums[R]] = [nums[R], nums[i]];
            R--;
        } else {
            i++;
        }
    }
    return nums;
};

344. 反转字符串

344. 反转字符串原题传送门

题目描述

编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 char[] 的形式给出。

不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。

你可以假设数组中的所有字符都是 ASCII 码表中的可打印字符。

示例 1:

输入:["h","e","l","l","o"]
输出:["o","l","l","e","h"]

示例 2:

输入:["H","a","n","n","a","h"]
输出:["h","a","n","n","a","H"]

解题思路

方法一:利用JS原生api

/**
 * @param {character[]} s
 * @return {void} Do not return anything, modify s in-place instead.
 */
var reverseString = function (s) {
    return s.reverse();
};

方法二:双指针,头尾交换

/**
 * @param {character[]} s
 * @return {void} Do not return anything, modify s in-place instead.
 */
var reverseString = function (s) {
    let i = 0, j = s.length - 1;
    while (i < j) {
        [s[i], s[j]] = [s[j], s[i]]; // 双指针,交换
        i++ , j--;
    }
    return s;
};

11. 盛最多水的容器

11. 盛最多水的容器原题传送门

题目描述 给你 n 个非负整数 a1,a2,...,an,每个数代表坐标中的一个点 (i, ai) 。在坐标内画 n 条垂直线,垂直线 i 的两个端点分别为 (i, ai) 和 (i, 0)。找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。

说明:你不能倾斜容器,且 n 的值至少为 2。

图中垂直线代表输入数组 [1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49。

示例:

输入:[1,8,6,2,5,4,8,3,7]
输出:49

解题思路

双指针做法,我们需要枚举所有情况,有一点贪心的思想,每次我们得看短的板子让我们容纳的面积。每次都选择左右指针最短的那个板子,计算出当前容纳的最多的水,然后从短的板子指针出发向内缩,这样不断求,最终我们可以枚举所有情况,自然可以枚举出最大容器面积。

/**
 * @param {number[]} height
 * @return {number}
 */
var maxArea = function (height) {
    let len = height.length;
    let L = 0;
    let R = len - 1;
    let res = 0;
    while (L < R) {
        if (height[L] < height[R]) {  // 选择短板效应
            let ans = height[L] * (R - L);
            L++;
            res = Math.max(res, ans); // 求当前容纳最多的水
        } else {
            let ans = height[R] * (R - L);
            res = Math.max(res, ans);
            R--;
        }
    }
    return res;
};

42. 接雨水

42. 接雨水原题传送门

题目描述

给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。

上面是由数组 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度图,在这种情况下,可以接 6 个单位的雨水(蓝色部分表示雨水)。 感谢 Marcos 贡献此图。

示例:

输入: [0,1,0,2,1,0,1,3,2,1,2,1]
输出: 6

解题思路

这个存放水,我们就需要看左边两边指针的柱子看谁的高度小了,当前是看高度小的了。

以左边为例:当前柱子存水量 = 最近最高柱子高度(只看左边到当前柱子) - 当前柱子高度

右边同理。

/**
 * @param {number[]} height
 * @return {number}
 */
var trap = function (height) {
    let len = height.length;
    let L = 0, R = len - 1;
    let leftHeight = 0, rightHeight = 0;
    let res = 0;
    while (L < R) {
        if (height[L] < height[R]) { // 左边高度小,当然看左边
            leftHeight = Math.max(leftHeight, height[L]);
            res += leftHeight - height[L]; // 当前柱子能存放的水
            L++;
        } else { // 右边高度小,看右边
            rightHeight = Math.max(rightHeight, height[R]);
            res += rightHeight - height[R]; // 当前柱子能存放的水
            R--;
        }
    }
    return res;
};

209. 长度最小的子数组

209. 长度最小的子数组

题目描述

给定一个含有 n 个正整数的数组和一个正整数 s ,找出该数组中满足其和 ≥ s 的长度最小的 连续 子数组,并返回其长度。如果不存在符合条件的子数组,返回 0。

示例:

输入:s = 7, nums = [2,3,1,2,4,3]
输出:2
解释:子数组 [4,3] 是该条件下的长度最小的子数组。

进阶:

  • 如果你已经完成了 O(n) 时间复杂度的解法, 请尝试 O(n log n) 时间复杂度的解法。

解题思路

滑动窗口,利用双指针实现,从左到右看,满足条件就把左指针左移,找到最小的长度,然后每次窗口右指针都往右滑动,直到数组末尾。

/**
 * @param {number} s
 * @param {number[]} nums
 * @return {number}
 */
var minSubArrayLen = function (s, nums) {
    let len = nums.length;
    let L = 0, R = 0;
    let res = Infinity, sum = 0;
    while (R < len) {
        sum += nums[R];
        while (sum >= s) { // 滑动窗口
            res = Math.min(res, R - L + 1);
            sum -= nums[L];
            L++;
        }
        R++;
    }
    return res == Infinity ? 0 : res; // 判断合法性
};

925. 长按键入

925. 长按键入

题目描述

你的朋友正在使用键盘输入他的名字 name。偶尔,在键入字符 c 时,按键可能会被长按,而字符可能被输入 1 次或多次。

你将会检查键盘输入的字符 typed。如果它对应的可能是你的朋友的名字(其中一些字符可能被长按),那么就返回 True

示例 1:

输入:name = "alex", typed = "aaleex"
输出:true
解释:'alex' 中的 'a''e' 被长按。

示例 2:

输入:name = "saeed", typed = "ssaaedd"
输出:false
解释:'e' 一定需要被键入两次,但在 typed 的输出中不是这样。

示例 3:

输入:name = "leelee", typed = "lleeelee"
输出:true

示例 4:

输入:name = "laiden", typed = "laiden"
输出:true
解释:长按名字中的字符并不是必要的。

提示:

  • name.length <= 1000
  • typed.length <= 1000
  • nametyped 的字符都是小写字母。

解题思路

显而易见,采用双指针做法,通过 cnt 计数统计字符匹配成功个数,然后通过双指针进行比较匹配,其中有几个地方注意一下:

  • 如果 typedname 的当前索引前一位都不相等的话,那么名字就不对应,直接跳出去,这里算是小小的优化了一下。
  • typed 走完才能跳出去,如果是 i == n 就跳出去的话,这种情况:name:abc | typed:abcd 就会判断出错
/**
 * @param {string} name
 * @param {string} typed
 * @return {boolean}
 */
var isLongPressedName = function (name, typed) {
    let n = name.length; // 求出字符串长度
    let m = typed.length;
    let cnt = 0; // 统计匹配成功个数
    let i = 0, j = 0; // 双指针
    let flag = false; // 判断是否中途遇到不匹配阶段
    while (1) {
        if (name[i] == typed[j]) { // 匹配成功
            i++ , cnt++ , j++;
        } else {
            if (typed[j] == name[i - 1]) {
                j++;
            } else {
                // 如果 typed 和 name 当前索引前一位都不相等的话,那么名字就不对应,直接跳出去
                flag = true;
            }
        }
        if (flag) break;
        if (j == m) break; // 当 typed走完才能跳出去,如果是 i == n  就跳出去的话,这种情况:abc | abcd 就会判断出错
    }
    if (cnt === n && j === m) return true;
    else return false;
};

763. 划分字母区间

763. 划分字母区间原题传送门

题目描述

字符串 S 由小写字母组成。我们要把这个字符串划分为尽可能多的片段,同一个字母只会出现在其中的一个片段。返回一个表示每个字符串片段的长度的列表。

示例 1:

输入:S = "ababcbacadefegdehijhklij"
输出:[9,7,8]
解释:
划分结果为 "ababcbaca", "defegde", "hijhklij"。
每个字母最多出现在一个片段中。
像 "ababcbacadefegde", "hijhklij" 的划分是错误的,因为划分的片段数较少。

提示:

  • S 的长度在 [1, 500] 之间。
  • S 只包含小写字母 'a''z'

解题思路

此题是一个挺有意思的题,既有贪心的味道,又有双指针的味道,下面说一下解题思路:

首先维护一个 map,它用来统计字当前字母的位置,而我们通过遍历就可以记录得到每个字母的最远位置。

然后,再次遍历字符串时,我们既可以得到当前字母的最远位置,根据贪心思想,为了让同一个字母只会出现在其中的一个片段,那么对于这个字母一定要是最远位置,我们就可以得到一个范围区间,即 maxLen

得到了 maxLen 后,我们还需要让 i 指针,即尾指针走到这个地方才算我们可以切分的片段。

(想想,如果不走到 maxLen 的话,这个范围区间内的字母可能会有更远的位置,那么就无法满足让同一个字母只会出现在其中的一个片段这个条件了)

参考 笨猪爆破组 大佬图解。

/**
 * @param {string} S
 * @return {number[]}
 */
var partitionLabels = function (S) {
    let map = {}; // 用来统计当前字母最远位置
    for (let i = 0; i < S.length; i++) {
        map[S[i]] = i; // 存储当前字母当前位置
    }
    let start = 0; // 头指针
    let res = [];
    let maxLen = 0;
    for (let i = 0; i < S.length; i++) {
        let curMaxLen = map[S[i]];
        maxLen = Math.max(maxLen, curMaxLen); // 计算出当前区间范围是否还可以继续扩大区间
        if (i === maxLen) {
            let tmp = i - start + 1;
            start = i + 1;
            res.push(tmp);  // 划分片段
        }
    }
    return res;
};

字符串

459. 重复的子字符串

459. 重复的子字符串原题传送门

题目描述

给定一个非空的字符串,判断它是否可以由它的一个子串重复多次构成。给定的字符串只含有小写英文字母,并且长度不超过10000。

示例 1:

输入: "abab"

输出: True

解释: 可由子字符串 "ab" 重复两次构成。

示例 2:

输入: "aba"

输出: False

示例 3:

输入: "abcabcabcabc"

输出: True

解释: 可由子字符串 "abc" 重复四次构成。 (或者子字符串 "abcabc" 重复两次构成。)

解题思路

对于样例字符串,看是否由字符串的其中一个子串重复构成,我们可以将原字符串与自己拼接一次,然后从原字符串第1位(从0开始)找,看是否会找到拼接之后的字符串起始位,即 s.length 处,那么就不存在重复构成这样情况,否则,就存在,返回 True

/**
 * @param {string} s
 * @return {boolean}
 */
var repeatedSubstringPattern = function(s) {
    return (s+s).indexOf(s,1) !== s.length
};

说明:

我想会有小伙伴疑惑,诶,为啥字符串这一块就这一道题呢?

首先,整理这一系列文章,主要是给大家提供一些刷题路线思路,题是千变万化的,并且一道题可能有很多种解题方式,我列举的题目大多数是我遇到过的原题,一份入门指南。其次,对于字符串而言,大部分都是与其它算法扯上关系,比如:

另外,给大家提及一下,字符串这块考察范围多一点的就是回文,以及相关牵扯到的一系列问题,比如:马拉车算法、最长回文子串问题、如何判断一个回文、最长公共前缀等等,这些在 leetcode 上都是有原题的,而 马拉车 算法在笔试以及面试的时候我经常遇到,犹记得当时是面字节跳动公司遇到的,先从回文考察,最后牵扯到 Manacher 算法,如果你还没有听说过这个算法,挺好的,至少这篇文章帮助到你了,赶快去了解一下吧~

至于电话号码的字母组合这道题在上一篇遗漏掉了,这是我2020年春招腾讯面试的真题,当时就被这道题给卡住了,后面发现其实也不是很难,现在来补充一下:

17. 电话号码的字母组合

17. 电话号码的字母组合原题传送门(回溯、dfs)

题目描述

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。

给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

示例:

输入:"23"
输出:["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"].

说明:

尽管上面的答案是按字典序排列的,但是你可以任意选择答案输出的顺序。

解题思路

采用回溯做法,对于当前选项,我们可以重复选择,所以 for 循环那里从 0 开始,对于字母组合我们做一个 map映射即可。

参考 xiao_ben_zhu 大佬的图解

var letterCombinations = function (digits) {
  if(!digits.length) return [];
  // 直接映射
  const map = { '2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl', '6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz' };
  let res = [];
  let dfs = (cur, start) => {
    if (start >= digits.length) {
      res.push(cur);
      return;
    }
    // 取当前可选的字母组合
    let str = map[digits[start]];
    for (let i = 0; i < str.length; i++) {
      dfs(cur + str[i], start + 1);
    }
  }
  dfs('', 0);
  return res;
};

解法2

这个是没用回溯之前写的一份代码,简单来说就是利用了层次遍历的特性,反正每次取字母都是可以重复的,直接遍历即可,然后进队列。

var letterCombinations = function(digits) {
  if(!digits.length) return []
  const map = { '2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl', '6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz' };
  let queue = []
  queue.push('')
  for(let i=0;i<digits.length;i++){
      let size = queue.length
      while(size--){
          let cur = queue.shift()
          let str = map[digits[i]]
          for(let j=0;j<str.length;j++){
              queue.push(cur+str[j])
          }
      }
  }
  return queue
};

本文参考

结语

❤️关注+点赞+收藏+评论+转发❤️,原创不易,您的支持将会是我最大的动力~

访问超逸の博客,方便小伙伴阅读玩耍~

最后,祝各位新年快乐,牛年大吉,好运++,在准备春招の你,能够早点结束春招,offer拿到手软,希望我的文章能够帮助到你,我们很快会在下期相遇~

快来关注我吧,学习前端虽然很“苦”,但有 一百个Chocolate 的文章会更“甜”~

【作者:一百个Chocolate】juejin.cn/user/298153…