扩展一下使用reduce的思路

初学JavaScript时,对数组的reduce方法认知不深,也不经常使用到。对它的印象仍是停留在“数组求和”这样的场景中。不过我一直记得最初它让我惊讶的一点:它的返回值并无固定类型,彷佛能够“定制”。javascript

后来偶然在工做和学习中尝试使用这个方法,发现它的能力原来比我想象的要强大得多,由于不少看似没关联(其实仍是有关联的,至少须要遍历)的问题都用到了它,以致于我以为应该专门写这样一篇总结分享出来。java

虽然它概念看起来很简单,可是不少时候它能简化问题,让代码更简单易懂甚至运行更快(我猜)。本文有些案例中reduce不必定是最优方案,但也值得考虑一下它的实现,但愿能够扩展一下编程思路。git

PS:如下案例题型有一些是实际工做中使用的,有一些来源于CodeWars(如“大数相加”和“金字塔”)、某人的科普(如“位运算”)或平时遇到的习题(如“千位分隔符”)。github

reduce和reduceRight简介

reduceRightreduce是同样的,不过从名字能够看出来reduceRight是从数组最后一项开始向前依次迭代到第一项。假如同一个数组调用这两个方法,能够想象为它们开的是同一辆动车(车箱号都是不变的),可是行驶方向是相反的,:D。正则表达式

归纳地说,它们能够迭代数组的全部项并执行一些操做,构建一个最终返回的值。它们接收两个参数,一个是在每一项上执行的函数和初始值(可选),回调函数能够接收到四个参数:算法

  • accumulator: 可理解为累积器,每次执行回调函数后的返回值,传入下一项中做为此参数;
  • currentValue: 当前迭代元素
  • currentIndex: 当前迭代元素的索引
  • sourceArray: 源数组

这个回调函数在每次迭代时能够获得上次迭代返回的结果和当前元素以及数组的信息,它的返回值也将被传给下一次迭代,直到最后一次迭代完成,返回最后的结果。编程

也就是说,回调函数的返回值类型决定了最终返回值的类型。那第一次迭代如何获取“前一次”迭代的结果呢?这取决于咱们给reducereduceRight传入的第二个参数redux

  • 若是这个参数被忽略了,那么默认会把数组第一项做为“前一次”迭代结果而直接从第二个元素开始迭代;
  • 若是传入了第二个参数,那这个参数就会被做为初始值,从第一个元素开始迭代。 初始值应该和回调函数的返回值相匹配,由于它能够被认为是“第零次迭代”的返回值而在第一次迭代中使用。

另外,它们没有反作用,(若是没有在回调函数中经过引用修改源数组自己的属性或元素的话)没必要担忧源数组会受到影响。数组

数值运算

若是数组中的元素都是数值,那么reduce能够迭代数组并执行一些没有直接操做方法的运算。例如初学这个方法时的第一个例子--数值求和,从它不难想象到其余如数值求积/求平均数,也是同样的道理。另外还能够进行像求最大值或最小值这样的操做,只是还有比它更简单直接的Math.maxMath.min方法,因此这里再也不多说。本质上这些操做都是要对数组中每一个元素遍从来进行对比或整合,并返回最终结果,因此reduce均可以胜任。安全

求和, 求积, 求平均数

假设有以下数组:

const arr = [1,2,3,4,5];
复制代码

求和:

const sum = arr.reduce((pre, cur) => pre + cur);
sum // 15
复制代码

求积:

const prod = arr.reduce((pre, cur) => pre * cur);
prod // 120
复制代码

求平均数:

const avrg = arr.reduce((pre, cur, i, a) => ( // 这里使用大括号{的话,不能省略return关键字
    i < a.length - 1 ? pre + cur : (pre + cur) / a.length
));
avrg // 3 
复制代码

看起来都很是简单。由于reduce就是简单地执行、返回而后继续迭代。而若是这里的arr不是一个数值数组而是一个对象数组,每一个对象包含一个值为数值类型的属性呢?咱们只须要在回调函数中访问对象的对应属性并相加就能够了。须要注意的是初始值须要定义为与回调函数中使用pre参数时的默认类型相匹配,即数值类型的0, 不然可能获得意料以外的结果。

const objArr = [{
    name: "A",
    score: 80,
}, {
    name: "B",
    score: 75,
}, {
    name: "C",
    score: 90,
}];

const scoreSum = objArr.reduce((pre, cur) => pre + cur.score, 0);
scoreSum // 245

objArr.reduce((pre, cur) => pre + cur.score); // "[object Object]7590"
复制代码

也能够先对对象数组执行map函数获得数值数组,而后执行reduce求和:

const scoreSum1 = objArr.map(o => o.score).reduce((pre, cur) => pre + cur); // 245
复制代码

公倍数和公约数

“最小公倍数”和“最大公约数”可能在数学或算法题目中才会常常见到,这里引用它们来做为reduce使用的例子之一。

首先明确这两个概念:对于a, b两个非零整数,a和b的最小公倍数(Least Common Multiple)是指能够被a和b整除的最小正整数;a和b的最大公约数(Greatest Common Divisor)是指能同时整除a和b的最大正整数。

通常求多个数之间的最大公约数,能够先求两个数之间的最大公约数,而后用此结果继续与下一个数求最大公约数,直到遍历全部数值;求多个数之间的最小公倍数也是类似的过程。但求两个数之间的最小公倍数,须要先肯定最大公约数后,用它们的乘积除以它获得结果。 (具体理论能够参考最小公倍数最大公约数...英文版,中文版打不开:X)求值的过程依然是迭代计算两个值,将结果传给下一次迭代,因此也可使用reduce来完成迭代过程。

求两个数a, b的最大公约数和最小公倍数能够分别以下简单实现:

// 求两个数的最大公约数(欧几里得算法)
function maxDenom(a, b) {
    return b ? maxDenom(b, a % b) : a;
}

// 求两个数的最小公倍数
function minMulti(a, b) {
    return a * b / maxDenom(a, b);
}
复制代码

求数组中多个数值的最大公约数和最小公倍数:

const data = [12, 15, 9, 6]

const GCD = data.reduce(maxDenom)
CGD // 3

const LCM = data.reduce(minMulti)
LCM // 180
复制代码

字符串处理

在JS中,字符串能够做为可迭代对象执行一些迭代操做。数组的某些方法是能够对其它类数组对象或可迭代对象使用的,因此也能够对字符串使用。但因为数组的方法是从数组原型中继承的,String原型中没有则须要显式绑定this值,通常调用方式为Array.prototype.reduce.call(string, ...arg)[].reduce.call(string, ...arg)。不过在这篇总结里为了表意方便,仍是把字符串转成数组后对数组执行reduce.

“大数”相加

这里的“大数”是我本身的叫法,是指数据自己位数不少,计算机的数值范围没法表示因此表示为字符串的一种“数值”。在Number.MAX_SAFE_INTEGER中保存了JS中能够保证精度的“安全整数”,超过它将可能会被舍入或被表示为科学计数法而损失部分精度。尽管用字符串表示能够完整保留它们每一位的数字,可是若是两个数相加就不能直接字符串相加了。

想象咱们手动运算时,要从末尾开始,逐位相加,超过10的要进位到高位。两个字符串数值相加也能够执行类似的过程。这时能够把它们先转换为数组并倒序排列,而后经过数组reduce方法依次执行运算。

为了保留完整结果,每一位的计算结果依然要做为字符串整合在一块儿,可是当前运算结果是否进位也须要传给下一个迭代,因此能够借助解构赋值,传递两个信息:[digit, tail], digit为1或0,表示后面的值相加后是否进位;tail表示已肯定的各个位的计算结果。为了计算方即可以先把两个字符串倒序排列。

const s1 = '712569312664357328695151392';
const s2 = '8100824045303269669937';

// 将字符串倒序并输出数值数组
function strToArrRvs(str) {
    return str.split("").map(x => +x).reverse();
}

function addStr(a, b) {
    const [h, l] = (a.length > b.length ? [a, b] : [b, a]).map(strToArrRvs);
    // 用相对位数更多的字符串调用reduce
    return h.reduce(([digit, tail], cur, idx, arr) => {
        const sum = cur + digit + (l[idx] || 0); 
        // 若是遍历完成 直接输出结果, 不然输出数组用于下一次迭代
        return idx === arr.length - 1
            ? sum + tail
            : [+(sum >= 10), sum % 10 + tail];
    }, [0, ""]);
}

addStr('712569312664357328695151392','8100824045303269669937');
// "712577413488402631964821329"

复制代码

添加千位分隔符或四位空格

千位分隔符应该是比较常见的一个题目,网上见过的答案通常是正则表达式或者for/while循环,一写循环代码必定会比较长并且容易出错(也可能这只是个人感受@_@!)。这里先不说正则表达式,仅就reduce这个方法来考虑实现。我把题目简化为输入参数为有效的整数数值,不考虑小数点和无效输入的状况--看成写一个目标单一的纯函数,另外有小数的状况下也很容易作到整数和小数部分分开处理。

思路比较简单: 一串数字要从末尾开始向前数,每3个数字就加一个逗号,第一个数字前面必定不加逗号。

想到从末尾开始遍历咱们能够直接用reduceRight,注意使用它时每一个元素的对应index还会对应原来的位置而不会由于遍历方向而改变。因此咱们把遍历过的数字字符串做为累积器,遍历时只须要判断当前位置从后面数是不是3的倍数而且不等于0,就给结果字符串前面添加一个逗号,继续迭代直到完成,输出的结果就是添加了分隔符的字符串。

function addSeparator(num) {
    const arr = [...String(num)]; // 数字转为数组
    const len = arr.length;
    return arr.reduceRight((tail, cur, i) => i === 0 || (len -  i) % 3 !== 0 ? `${cur + tail}` : `,${cur + tail}`, "");
}

addSeparator(12345678901) // "12,345,678,901"
复制代码

它的原理其实也是循环,可是写起来更简单也更容易理解。看到这儿可能咱们也能很容易想到相似银行卡号那种每四位数字添加空格的实现了。此次是从头开始遍历,直接用reduce, 另外这样的帐号极可能位数较多超过了安全整数限制,会用字符串保存。 咱们把输入状况简化为都是有效的数字字符串且没有多余空格(这些能够另外处理),能够简单实现以下:

function addSpace(accountStr) {
    const arr = [...accountStr]; // 数字转为数组
    const len = arr.length;
    return arr.reduce((head, cur, i) => (i + 1) === len || (i + 1) % 4 !== 0 ? `${head + cur}` : `${head + cur} `, "");
}
addSpace(`6666000088881111123`); // "6666 0000 8888 1111 123"
复制代码

与位运算结合查找特征项

这里说的位运算包括按位与、按位或、按位异或这种二元运算符。在有一组数的状况下,由于它们知足“交换律”和“结合律”,使用reduce有时能够很方便地求解它们按位运算的结果,根据它们自己所具备的特性可能很容易地找到某些特征元素。

例如,按位异或(对应位相异则返回1,不然返回0)a ^ b运算:

  • 一个数与它本身按位异或将会获得0,由于它们每一个对应位都是相同的,都会返回0,全部位都是0最后也会获得0;
  • 一个数与0按位异或,则会获得这个数自己,由于对应位是0的仍是0,对应位是1的仍是1,至关于把这个数复制了一个。

因此下面这道题就能够很方便地解答:

一个整数数组中,只有一个数出现了奇数次,其余数都出现了偶数次,找到这个出现了奇数次的数。(相似变形题目如 有一个数出现了1次,其余数都出现了2次)

根据交换律和结合律, x ^ y ^ x ^ y ^ n 等于 (x ^ x) ^ (y ^ y) ^ n; 对全部数依次进行按位异或运算,全部出现两次的数运算结果最终仍是0,而那个只出现一次的数和0按位异或获得它自己:

function findOnlyOne(arr) {
    return arr.reduce((pre, cur) => pre ^ cur);
}

const array = [2,2,3,4,5,6,7,6,6,6,3,4,5];
findOnlyOne(array) // 7

复制代码

若是换成有一个数出现了5次,其余数都出现了3次呢?3和5都是奇数,上面的方法在这儿好像不太好用。那就换另外一种思路,若是把每一个数都看做是二进制数字,它们最多不超过32位;若是能肯定出现了3次的那个数在每一个对应位上是0仍是1,那也就肯定了这个数。因此咱们能够从低位到高位依次判断。

这里根据“按位与”运算的特征,两数在某位上都为1,该位返回1,不然返回0. 咱们先肯定一个仅在某位是1,其余位均为0的数做为标识数,而后每一个数与它按位与以后再相加;假如出现了5次的数在这一位上是0,那结果必定是3的倍数(或0);不然对3取余必定为2(即5-3);

// 获得从0到31组成的数组
const iStore = (Array.from(new Array(32), (x, i) => i));
// 求解给定某特定标志数时的结果
function checkBit(flagNum, srcArr) {
    const bitSum = srcArr.reduce((sum, cur) => sum + (cur & flagNum), 0);
    return bitSum % 3 === 0 ? 0 : 1;
}

// 对每一位执行求解
function checkArr(array) {
    const binaryStr = iStore.reduce((str, i) => checkBit(1 << i, array) + str, "");
    return parseInt(binaryStr, 2);
}

checkArr([12,12,12,5,5,5,32,32,32,9,9,9,4,4,4,4,4]);
// 4
复制代码

上面拆成了两个方法,其实主函数执行至关于两个reduce嵌套---外层对从0到31这32个位索引进行迭代, 计算该位对应的标识数; 内层嵌套对源数组每一个元素进行迭代. 得到当前位的结果。

构建数组或对象

平时工做中可能这种状况比较常见,例若有一个包含对象或数据的数组,而咱们只想要部分信息,并构建成一个新数组或新对象。例如如下对象,咱们但愿改形成{name: value}的形式的对象

const info = [
    {
        name: "A",
        value: 4,
    }, {
        name: "B",
        value: 7,
    }, {
        name: "C",
        value: 10,
    }
];

// 指望结果
{
    A: 4,
    B: 7,
    C: 10,
}

复制代码

通常比较常见的用循环的写法好比:

const result = {};
for (let i = 0; i < info.length; i++) {
    result[info[i].name] = info[i].value;
}

result // {A: 4, B: 7, C: 10}
复制代码

使用循环须要新建一个空对象,而后遍历数组把元素信息依次在对象中进行定义。而若是咱们使用reduce,只须要一行就能够完成, 目的也会更明确:

const result = info.reduce((res, cur) => ({...res, [cur.name]: cur.value}), {});
result // {A: 4, B: 7, C: 10}
复制代码

构建一个新数组也是一样的道理,把空数组做为初始值,而后经过迭代向数组中添加元素,最终获得的就是想要的结果数组。

// result为上面获得的{A: 4, B: 7, C: 10}
const arrResult = Object.keys(result).reduce((accu, cur) => [...accu, {key: cur, value: result[cur]}], []);
arrResult // [{key: "A", value: 4}, {key: "B", value: 7}, {key: "C", value: 10}]
复制代码

执行一系列函数

在函数式编程思想中,有函数组合和函数链的概念。函数链比较好理解,数据是被封装在某个类的对象里,该对象每一个方法最后都返回自身,就能够实现其所支持方法的链式调用——直接使用上次调用的结果调用下一个函数,最后使用求值方法获得结果。函数组合则是把上一个函数的返回结果传入下一个函数做为参数。这里涉及到迭代和“获取以前运行的结果”就应该又想到reduce了。

根据我浅显的了解,函数组合是函数式编程的核心内容之一,广为人知的Redux的核心实现就包括compose,除了边缘状况的判断,核心代码只有调用reduce的那一行:

function compose(...funcs) {
    if (funcs.length === 0) {
        return arg => arg
    }
    if (funcs.length === 1) {
        return funcs[0]
    }
  return funcs.reduce((a, b) => (...args) => a(b(...args)))
}
复制代码

它接收若干个函数做为参数,返回一个将这些函数组合起来造成的函数,组合过程就是返回一个接收多个参数的函数,这个函数返回的是用当前函数接收这些参数并把执行结果传给上次迭代获得的组合函数进行执行的结果。这样把compose全部函数参数遍历完成,最后获得的依然是一个函数。假如以前传给compose的参数是(f, g, h), 那这个组合函数就是(...args) => f(g(h(...args))),函数的实际执行顺序是和参数列表相反的,会先执行h后把结果传给g执行而后再把结果传给f,最后返回f的执行结果。

(深刻下去还有更多的概念和理论,之后专门总结了再补上)

“动态规划”

在某人的熏陶下我对这个算法有了点简单的了解。这种设计思想适合于“问题是由交叠的子问题构成”的状况。reduce恰好适合它的一种“经过记忆化避免对子问题重复求解”。这里先不细说动态规划(这个相关问题会另外总结)而只是想说用reduce能够帮助实现。核心代码要靠本身实现,reduce提供的是获取累积迭代结果的便利条件。

有个比较简单的例子:输入一个二维数组,数组中的元素是数值数组且长度是从1开始递增的,也就是逐行居中打印出来会是金字塔的形状。问题是,从金字塔的顶端到最底层,所通过的数字和最长是多少?

例如输入[[5], [6, 3], [1, 2, 7], [9, 4, 8, 3]], 打印出来能够是这样:

[5]
  [6,3]
 [1,2,7]
[9,4,8,3]
复制代码

看起来像二叉树,也的确像二叉树同样,每一层只能通过一个数字,向下移动时只能向左或向右。

一个思路是:从底层开始,两两相比选出较大者,而后逐层向上对应位置父节点相加,获得每条路径的最大值,直到顶层,最后输出那个惟一元素。

先从简单状况开始思考: 只有一层时,惟一的数字元素即是结果。

只有两层时,也很简单,只要从第二层取出比较大的那个数字和第一层的数字相加就行了;

那么若是有三层呢?二叉树的一个特色就是能够认为每一个节点的结构都是同样的,那就能够把每一个节点和它下面两个子节点当作是一个两层的“小金字塔”,这样问题就能够简化:先把第三层数字每相邻两个看做是“小金字塔”底层而第二层的每一个元素都看做对应的顶端,这样就能够计算出第二层每一个元素到“底层”的最大路径和;而后把第二层看做“底层”向上计算,这样问题就又简化成了两层“小金字塔”。

也就是说,更多层也能够逐层简化直到剩下最后一层获得结果。计算方法也和对前两层的处理同样。

再分解一下:

  1. 若是塔顶是n, 塔底分别是x和y, 塔顶到塔底最大路径就是n + Math.max(x, y);
  2. 对于一个数组(一层),把每一个元素看做塔顶,若是知道它的下层元素(假设下一层数组为next)到底层的最大路径和,可使用map方法,对每一个元素执行上面的计算,获得各个元素到底层的最大路径和:(n, index) => n + Math.max(next[i], next[i + 1]);
  3. 对于一个多层金字塔有多个数组(pyramidArray),那就从倒数第二层开始,执行上面的map获得该层的最大路径和,而后再把结果做为底层向上迭代,这时可使用reduceRight,对从下向上每一层执行上面的map方法: pyramidArray.reduceRight((next, cur) => cur.map(mapFn));

最后综合起来能够是:

function longestPath(pyramid) {
    const getBigerSum = (next) => (n, i) => (n + Math.max(next[i], next[i + 1]));
    return pyramid.reduceRight((next, cur) => cur.map(getBigerSum(next)))[0];
}

longestPath([[5], [6, 3], [1, 2, 7], [9, 4, 8, 3]]) // 23 
复制代码

容易出错的地方

虽然使用数组迭代和归并方法比写for/while循环“通常状况下”更简洁也更清晰,但它们也有本身的执行规则,使用时不注意到一些小细节可能就容易得不到正确结果。对于reducereduceRight来讲,可能易出错的地方如:

没有正确的初始值类型

若是数组能够用第一个元素做为初始值而从第二个元素开始迭代,那么能够忽略初始值;但若是数组首元素与累积器类型不兼容或不能直接做为初始值,那就须要手动传入正确的初始值;

回调函数没有返回值

这个我也常出错,若是过于关注数据处理逻辑而忘了return或者诸如array.push(...items)以后经常误觉得返回了数组(实际上是个数值),那一次迭代后累积器就变成了其余类型,下一步迭代每每会出错。

或许它不是最简单的方案

短路操做:

不少状况下能使用循环解决的问题也能够考虑下是否reduce解决更简单,但循环有一个便利之处是它们能够在第任何次循环中经过continuebreak减小没必要要的代码执行;reduce对于给定的数组老是会遍历完成。数组的方法中someevery有这样的特性,也许它们能够帮助处理相似的任务。

字符串拼接

例如["北京", "上海", "深圳", "广东"]这样的数组,想要把城市名用顿号分隔获得一串字符串,下面的方法也能实现:

["北京", "上海", "深圳", "广东"].reduce((str, cur) => str + "、" + cur)
复制代码

但直接用数组的join("、")方法便可,相比之下reduce反而显得繁琐了。

数组元素去重

这个也是我见过的一种使用方式,遍历时用一个对象保存是否出现过,而后构造一个每一个元素只出现一次的数组:

const obj = {};
const sample = ["a", "b", "c", "a", "b", "d", "c"];
sample.reduce((accu, cur) => {
    if (!obj[cur]) {
        obj[cur] = true;
        accu.push(cur);
    }
    return accu;
}, []); // ["a", "b", "c", "d"]
复制代码

ES6有了Set对象,这个用来去重就很是方便了。可是不要把Set对象放在reduce迭代中去逐一添加元素(那就又走弯路啦),而是把数组做为初始值传入Set构造函数,直接获得去重的Set对象,再经过扩展运算符就能还原为数组:

[...new Set(sample)] // ["a", "b", "c", "d"]
复制代码

(其余待补充)

最后,用想象总结

在个人想象中,reduce就像一个小调查员,我只须要告诉他————去访问哪一条有连续住户(元素)的街道(数组或可迭代对象),去挨家挨户搜集什么信息并作什么处理,而后以什么样的方式记录下来————他就会彻彻底底完成工做最后把记录好的结果给我。

我知道他有能力在访问每一户人家的同时,经过以前已访问过的记录去作一些本身的判断,好比有重复的能够不记录,类似的状况能够分到一组中,等等;也能够根据当前房屋所处位置去决定是否进行某些处理。具体怎样作取决于个人命令(回调函数)和我给的模板(初始值),假如没有模板他会直接把第一家住户拿来做为模板。

他尽职尽责,必定会遍历完整个街道而不会偷懒(非短路操做),因此像“是否全部”(every)或“是否有任何”(some)这样的判断我不会请他来作。而若是有更专门的小兵能够作的简单工做我也不会请他来作,好比把住户名字拼接成字符串(join)或过滤出符合条件的住户(filter)或只是简单对每一个住户获取某些信息后简单处理后以一一对应的形式记录下来(map)给我。有时候这些专门的小兵也能够分担一部分工做,简化他的工做,但他彻底有能力作他们能作的事。

哦对了,他还有一个亲弟弟,叫reduceRight,简直像他的镜面复刻……惟一不一样就是 reduce习惯左手而reduceRight习惯右手,因此reduce的工做从街道的开头开始,而reduceRight则会从另外一端开始。

那么,你是否会像我同样喜欢他们呢?


感谢阅读,我的经验和水平有限,欢迎你们提出建议,有些深入的概念可能理解还不够全面,不足之处还望指正。谢谢 :)

2019-4-21

相关文章
相关标签/搜索