阅读 668

扩展一下使用reduce的思路

初学JavaScript时,对数组的reduce方法认知不深,也不常用到。对它的印象还是停留在“数组求和”这样的场景中。不过我一直记得最初它让我惊讶的一点:它的返回值并没有固定类型,似乎可以“定制”。

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

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

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

reduce和reduceRight简介

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

概括地说,它们可以迭代数组的所有项并执行一些操作,构建一个最终返回的值。它们接收两个参数,一个是在每一项上执行的函数和初始值(可选),回调函数可以接收到四个参数:

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

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

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

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

另外,它们没有副作用,(如果没有在回调函数中通过引用修改源数组本身的属性或元素的话)不必担心源数组会受到影响。

数值运算

如果数组中的元素都是数值,那么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

关注下面的标签,发现更多相似文章
评论