初學JavaScript時,對數組的reduce
方法認知不深,也不經常使用到。對它的印象仍是停留在「數組求和」這樣的場景中。不過我一直記得最初它讓我驚訝的一點:它的返回值並無固定類型,彷佛能夠「定製」。javascript
後來偶然在工做和學習中嘗試使用這個方法,發現它的能力原來比我想象的要強大得多,由於不少看似沒關聯(其實仍是有關聯的,至少須要遍歷)的問題都用到了它,以致於我以爲應該專門寫這樣一篇總結分享出來。java
雖然它概念看起來很簡單,可是不少時候它能簡化問題,讓代碼更簡單易懂甚至運行更快(我猜)。本文有些案例中reduce
不必定是最優方案,但也值得考慮一下它的實現,但願能夠擴展一下編程思路。git
PS:如下案例題型有一些是實際工做中使用的,有一些來源於CodeWars(如「大數相加」和「金字塔」)、某人的科普(如「位運算」)或平時遇到的習題(如「千位分隔符」)。github
reduceRight
和reduce
是同樣的,不過從名字能夠看出來reduceRight
是從數組最後一項開始向前依次迭代到第一項。假如同一個數組調用這兩個方法,能夠想象爲它們開的是同一輛動車(車箱號都是不變的),可是行駛方向是相反的,:D。正則表達式
歸納地說,它們能夠迭代數組的全部項並執行一些操做,構建一個最終返回的值。它們接收兩個參數,一個是在每一項上執行的函數和初始值(可選),回調函數能夠接收到四個參數:算法
這個回調函數在每次迭代時能夠獲得上次迭代返回的結果和當前元素以及數組的信息,它的返回值也將被傳給下一次迭代,直到最後一次迭代完成,返回最後的結果。編程
也就是說,回調函數的返回值類型決定了最終返回值的類型。那第一次迭代如何獲取「前一次」迭代的結果呢?這取決於咱們給reduce
或reduceRight
傳入的第二個參數。redux
另外,它們沒有反作用,(若是沒有在回調函數中經過引用修改源數組自己的屬性或元素的話)沒必要擔憂源數組會受到影響。數組
若是數組中的元素都是數值,那麼reduce
能夠迭代數組並執行一些沒有直接操做方法的運算。例如初學這個方法時的第一個例子--數值求和,從它不難想象到其餘如數值求積/求平均數,也是同樣的道理。另外還能夠進行像求最大值或最小值這樣的操做,只是還有比它更簡單直接的Math.max
和Math.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
運算:
因此下面這道題就能夠很方便地解答:
一個整數數組中,只有一個數出現了奇數次,其餘數都出現了偶數次,找到這個出現了奇數次的數。(相似變形題目如 有一個數出現了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]
複製代碼
看起來像二叉樹,也的確像二叉樹同樣,每一層只能通過一個數字,向下移動時只能向左或向右。
一個思路是:從底層開始,兩兩相比選出較大者,而後逐層向上對應位置父節點相加,獲得每條路徑的最大值,直到頂層,最後輸出那個惟一元素。
先從簡單狀況開始思考: 只有一層時,惟一的數字元素即是結果。
只有兩層時,也很簡單,只要從第二層取出比較大的那個數字和第一層的數字相加就行了;
那麼若是有三層呢?二叉樹的一個特色就是能夠認爲每一個節點的結構都是同樣的,那就能夠把每一個節點和它下面兩個子節點當作是一個兩層的「小金字塔」,這樣問題就能夠簡化:先把第三層數字每相鄰兩個看做是「小金字塔」底層而第二層的每一個元素都看做對應的頂端,這樣就能夠計算出第二層每一個元素到「底層」的最大路徑和;而後把第二層看做「底層」向上計算,這樣問題就又簡化成了兩層「小金字塔」。
也就是說,更多層也能夠逐層簡化直到剩下最後一層獲得結果。計算方法也和對前兩層的處理同樣。
再分解一下:
n + Math.max(x, y)
;next
)到底層的最大路徑和,可使用map
方法,對每一個元素執行上面的計算,獲得各個元素到底層的最大路徑和:(n, index) => n + Math.max(next[i], next[i + 1])
;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
循環「通常狀況下」更簡潔也更清晰,但它們也有本身的執行規則,使用時不注意到一些小細節可能就容易得不到正確結果。對於reduce
或reduceRight
來講,可能易出錯的地方如:
若是數組能夠用第一個元素做爲初始值而從第二個元素開始迭代,那麼能夠忽略初始值;但若是數組首元素與累積器類型不兼容或不能直接做爲初始值,那就須要手動傳入正確的初始值;
這個我也常出錯,若是過於關注數據處理邏輯而忘了return
或者諸如array.push(...items)
以後經常誤覺得返回了數組(實際上是個數值),那一次迭代後累積器就變成了其餘類型,下一步迭代每每會出錯。
不少狀況下能使用循環解決的問題也能夠考慮下是否reduce
解決更簡單,但循環有一個便利之處是它們能夠在第任何次循環中經過continue
或break
減小沒必要要的代碼執行;reduce
對於給定的數組老是會遍歷完成。數組的方法中some
和every
有這樣的特性,也許它們能夠幫助處理相似的任務。
例如["北京", "上海", "深圳", "廣東"]
這樣的數組,想要把城市名用頓號分隔獲得一串字符串,下面的方法也能實現:
["北京", "上海", "深圳", "廣東"].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