目录
前言
开篇语
准备工作
递归
概念
形式
优缺点
案例
数组求和
斐波那契数列
递归查找数据
柯里化
概念
形式
什么时候使用柯里化?
多维数组扁平化
多维数组
扁平化
利用flat()
与字符串相互转化
与JSON字符串相互转化
some(),concat()和扩展运算符
结束语
前言
开篇语
本系列博客主要分享JavaScript的进阶语法知识,本期为第七期,依然围绕ES6的语法进行展开。
本期内容为:递归、柯里化和多维数组扁平化。
与基础部分的语法相比,ES6的语法进行了一些更加严谨的约束和优化,因此,在之后使用原生JS时,我们应该尽量使用ES6的语法进行代码编写。
准备工作
软件:【参考版本】Visual Studio Code
插件(扩展包):Open in browser, Live Preview, Live Server, Tencent Cloud AI Code Assistant, htmltagwrap
提示:在不熟练的阶段建议关闭AI助手
浏览器版本:Chrome
系统版本: Win10/11/其他非Windows版本
递归
概念
递归(recursion)就是方法内部调用自身,它通常把一个大规模复杂的问题转化为一个与原问题相似的但是规模更小的问题来求解。
形式
首先,递归方法内部需要调用自身,一般来说,我们的递归需要返回值,所以一开始可以这么写——
function fn() {return 含有fn()的表达式}
但是,如果仅仅这么写,显然fn()在调用自身之后,还会一直不断调用自身,以至于无限调用。
这样一来,最终就会和一个退不出的while循环一样,导致栈内存溢出,即爆栈。
所以,我们还需要有一个终止条件,相当于while循环的判定条件,用于退出递归过程。
终止时,不再调用自身。
function fn() {if (终止条件) return 最终结果return 含有fn()的表达式}
其中,所谓含有fn()的表达式也就是递归调用的递推公式,即由此前的递归结果经过某种处理得到当前递归结果的表达式。
由此,我们得到了递归的所有必要条件——
- 终止条件
- 最终结果
- 递推公式
优缺点
优点:使用少量的代码完成大量重复的过程,即复杂问题简单化。
缺点:①需要退出条件,否则会导致栈内存溢出;
②多次递归调用,不断开辟栈空间,致使代码的执行效率低。
案例
数组求和
要想熟悉递归的思想,首先还是要理解递推公式,可能有点抽象,我们结合案例来理解。
最简单的案例就是数组求和,比如现在有下面这个数组——
let arr = [1, 2, 3, 4, 5];
在之前我们求该数组的和,是使用for循环进行遍历,就像下面这样——
let sum = 0;
for (let item of arr) {sum += item; // 1console.log(sum); // 2
}
console.log(sum); // 3
事实上,我们就是在重复+item这一过程(代码1),而每一次循环中的sum(代码2)就是当次item与上一次求和的结果的和。
如果我们把求和这一过程封装成一个方法sum(n),n是我们的当前项。这个方法的返回值为求和的结果,那么代码1实质上就是下面这样——
sum(n) = sum(n - 1) + item
而sum(n - 1),又可以进一步拆为sum(n - 2) + item,而这两次拆分在形式上完全一致。
因此,这就是我们需要找的递推公式的基本雏形。
那么,在不遍历的前提下,如何获得当前的item呢?
我们知道,数组的pop()方法删除数组的最后一个数据,并返回删除的数据。
所以,item就可以写成——
arr.pop()
而每次arr在pop之后长度也会减一,正好也满足n-1的条件。
退出条件就是arr的长度为1,最终结果为数组最后剩下的一项,即arr[0]。
所以,使用递归求数组和的步骤如下——
function sum(arr) {if (arr.length == 1) {return arr[0];}return sum(arr) + arr.pop();
}
斐波那契数列
在了解完递归之后,我们来看一些更加复杂的问题。
斐波那契数列的形式是,数组的前两项都为1,其他项为前两项之和,就像下面这样——
1, 1, 2, 3, 5, 8, 13, ......
如何求斐波那契数列的第n项?
我们可以封装fei()方法,用于求斐波那契数列的第n项。
当前项为fei(n),拆分之后就是fei(n - 1) + fei(n - 2),即前两项和。
那么,最简单的fei()的形式如下——
function fei(n) {if (n == 1 || n == 2) {return 1;}return fei(n - 1) + fei(n - 2);
}
那么,这个方法合理吗?
分析一下方法的执行过程,每执行一次fei(),在return中都要递归调用两次fei(),即求了两次和。
这就意味着,求第n项时,整个方法总共需要执行2^n次,即时间复杂度为O(2^n),这是效率极其低下,且具有爆栈风险的。
所以,我们需要对此做出优化。
回到数列上,当前项为前两项之和,比如第三项为第一项和第二项的和,第四项为第二项和第三项的和。
实际上,第三项的值在上一步已经算出来了,我们可以在这里减少一次递归。
在fei()的参数列表中,传入三个参数——
fei(n, pre, next)
n为当前项数 - 2(不包含第一项和第二项),pre为前一项,next为当前项。
首次调用时,为pre和next设置初始值1。
在递推公式中,原pre处传入next,原next处传入pre + next,实现每一次只递归求一次和的效果。
当n = 1时,返回最后一次pre + next的结果。
优化后的完整的fei()方法如下——
function fei(n, pre = 1, next = 1) {if (n == 1) {return pre + next;}return fei(n - 1, next, pre + next);
}
该方法只运行n次,时间复杂度为O(n)。注意求第n项时实际上需要输入n - 2。
递归查找数据
下面,我们来看一些偏向实际一些的应用,递归常常用于查找数据并返回。
我们存储数据的结构,比如说文件夹,常常有很多层。
我们在查找时,判断当前项是否是存储结构,如果是,则继续查找;如果已经是需要查找的数据,则返回(或输出)数据。
由于继续查找使用的也是当前的查找方法,所以这里用到的也是递归。
现在,我们有下面的数据存储结构——
var data = [{name: "所有物品",children: [{name: "水果",children: [{ name: "苹果"}]},{name: '主食',children: [{ name: "米饭", children: [{ name: '北方米饭' }, { name: '南方米饭' }] }]},{name: '生活用品',children: [{ name: "电脑类", children: [{ name: '联想电脑' }, { name: '苹果电脑' }] },{ name: "工具类", children: [{ name: "锄头" }, { name: "锤子" }] },{ name: "生活用品", children: [{ name: "洗发水" }, { name: "沐浴露" }] }]}]}]
我们需要获取最终的children下的name值,也就是数据,拼接为字符串输出。
输出示例如下——
观察存储结构,它是由数组和对象组成的,对象中有至多两种属性——name和children。显然,我们需要获取最后一层children下的name值。
首先,我们可以使用forEach方法拿到数组中存放的对象。
然后,看该对象是否包含children属性,如果没有,则说明是最后一层,直接获取name的值;反之,继续查找children。
这里我们需要一个全局的str,用于拼接找到的结果。
由此,可以得到完整的搜索方法——
// 结果字符串 let str = ''// 搜索方法function search(child) {child.forEach(item => {// 判定是否为后的数据层if (!item.children) {str += item.name + ';';return; // 终止递归}search(item.children);})}search(data);console.log(str);
柯里化
概念
柯里化(Currying)是一种关于函数的高阶技术,实质是将多个参数输入的方法转化为单个输入的方法,其他的参数在返回的方法中传入,重复该过程,并在最后返回的方法中输出结果。
形式
比如,我们现在有一个方法,它的作用是求三个参数的和,大概是下面这个样子——
function sum(a, b, c) {return a + b + c;
}
我们只保留第一个参数,让剩下的参数在返回值中的方法里传入——
function sum(a) {return function(b, c) {return a + b + c;
}
重复柯里化的过程,直到每一个方法都仅有一个参数——
function sum(a) {return function(b) {return function(c) {return a + b + c;}}
}
原本我们求和的过程是这样——
sum(a, b, c);
而现在则变成了这样——
sum(a)(b)(c);
什么时候使用柯里化?
看起来,柯里化似乎让方法的形式变得更加复杂了,然而,柯里化真的是一种麻烦的方式吗?
要想了解柯里化的好处,不妨设想下面的情况:
现在有一个商场,全场的商品为88折。假设现在有一个顾客,她需要购买一件原价为1000的大衣,顾客在终端输入商品价格之后,程序返回待支付的金额。可以怎么实现?
假设有一个用于求折扣价的函数,那么求最终支付金额的方式应当类似下面这样——
getPrice(1000, 0.88);
在这个方法中,我们需要输入两个值——原价和折扣,但是,折扣对于当前的任意商品而言都是完全一致的,我们输入折扣的过程发生了重复。
而且,这个折扣难道是由顾客来决定的吗?
实际上,柯里化可以帮助我们解决上述存在的问题。对于需要重复输入且可以预设的参数,我们使用柯里化将其分离出来——
let forSale = getPrice(0.88) ;
forSale(1000);
多维数组扁平化
多维数组
多维数组就是层数超过一层的数组,就像下面这样——
let arr = [1, [2, 3]]
由于上面的数组最高有两层,即最内层的[]距离外部有2层[],所以该数组为二维数组。
同理,最高层数为n层的数组就是n维数组。
扁平化
扁平化实质上就是数组降维的过程,比如把三维数组降低成二维,或者再降低成一维,就是扁平化的过程。
注意,降维是由外层向内部进行的。
在本节中,我们所说的扁平化就是指把n维的数组降到最低的一维数组。
一般来说,常见的必须掌握的数组扁平化的方法有四种——
利用flat()
数组为我们提供了扁平化的方法——flat(),其中文释义就是扁、平。该方法传入一个参数,代表扁平化降维的层数。
比如有下面这个数组——
let arr = [1, [2, [3, [4, [5, 6]]]]]
使用flat()降低一维——
arr.flat(1)
那么数组就会变成下边这样——
[1, 2, [3, [4, [5, 6]]]]
在不知道数组有几维的前提下,我们可以为flat()传入参数Infinity,代表无限降维,直到不再可降(一维)。
arr.flat(Infinity) // [1, 2, 3, 4, 5, 6]
与字符串相互转化
我们知道,使用toString()可以将数组以逗号进行分割,转化为字符串。
还是用上面的arr——
arr.toString();
那么数组就会变成下面的字符串——
显然,这个字符串中没有任何[],因为它们不会被toString()保留下来。
那么,我们直接使用split()方法,以逗号为分隔符将字符串转回数组——
arr.toString().split(',');
得到下面的字符(串)数组——
然后,我们需要将这些字符串类型的数据转回数字类型,可以使用map()进行遍历(forEach()没有返回值),利用Number()进行包装转化——
arr.toString().split(',').map(item => Number(item));
得到的结果就是我们要的一维数组了——
与JSON字符串相互转化
我们知道,JSON也提供了一种字符串的转化方法stringify(),该方法将完全保留原数据的结构。
JSON.stringify(arr);
此时,我们得到了一个和原数组长得一模一样但是完全由字符串组成的n维数组——
接下来,我们需要把这个字符串中的 [ 和 ] 剔除,可以使用replace(),传入正则表达式匹配(注意使用g进行全局匹配)删除——
JSON.stringify(arr).replace(/\[|\]/g, '');
得到剔除[]之后的字符串——
然后就和之前处理字符串的方法一致了,这里不再赘述。
some(),concat()和扩展运算符
我们知道,多维数组中有很多元素,它们有的是数字,有的是数组。而我们的目的就是找到这些数组,将它们进行降维。
我们想要的就是遍历多维数组,如果还能找到子数组,就接着找,并降一次维;
如果完全找不到了,则说明已经是最低维,不需要再找了。
很明显,我们可以使用while()循环来完成降维,循环条件是可以找到子数组。
那么怎么给这个循环条件呢?
ES6中数组有一个API,叫做some(),可以用来遍历数组,只要数组中有一个元素符合条件,some()就会返回true。
显然,我们只需要让some()的回调函数返回值为判定是否为数组的boolean值即可。
while(arr.some(item => item.constructor == Array)) {}
那么,如何实现只降低一维呢?
为了避免与flat(1)重复,这里我们就不再使用flat(),那样的确是有些画蛇添足了。
ES6为我们提供了扩展运算符,它的作用是把数据集合从语法层面展开,而这个特性恰好可以帮助我们去除最外层的[],也就是降低一维。
举个例子,来看看...[1, [2]]的结果——
可以看到,最外层的[]被去除了,原数组变成了一串单独的数据。
现在,我们可以利用concat(),将展开后的数据以参数的形式拼接到一个空数组中,并覆盖原数组。
而我们知道,数组参数 [2] 拼接之后实际上不再具有[],即arr0.concat([2])实际上往arr0中拼接的是2而非[2],这说明concat()帮助我们实现了一次降维。
所以,整个扁平化的操作步骤就十分明朗了——
while(arr.some(item => item.constructor == Array)) {
arr = [].concat(...arr);
}
结束语
本期内容到此结束。关于本系列的其他博客,可以查看我的JS进阶专栏。
在全栈领域,博主也只不过是一个普通的萌新而已。本系列的博客主要是记录一下自己学习的一些经历,然后把自己领悟到的一些东西总结一下,分享给大家。
文章全篇的操作过程都是笔者亲自操作完成的,一些定义性的文字加入了笔者自己的很多理解在里面,所以仅供参考。如果有说的不对的地方,还请谅解。
==期待与你在下一期博客中再次相遇==
——临期的【H2O2】