擴充套件一下使用reduce的思路

AnneBai發表於2019-04-21

初學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

相關文章