「前端面試題系列8」陣列去重(10 種濃縮版)

Micherwa發表於2019-03-04

前言

這是前端面試題系列的第 8 篇,你可能錯過了前面的篇章,可以在這裡找到:

前端面試中經常會問到陣列去重的問題。因為在平時的工作中遇到複雜互動的時候,需要知道該如何解決。另外,我在問應聘者這道題的時候,更多的是想考察 2 個點:對 Array 方法的熟悉程度,還有邏輯演算法能力。一般我會先讓應聘者說出幾種方法,然後隨機抽取他說的一種,具體地寫一下。

這裡有一個通用的面試技巧:自己不熟悉的東西,千萬別說!我就碰到過幾個應聘者,想盡可能地表現自己,就說了不少方法,隨機抽了一個,結果就沒寫出來,很尷尬。

ok,讓我們馬上開始今天的主題。會介紹 10 種不同型別的方法,一些類似的方法我做了合併,寫法從簡到繁,其中還會有 loadsh 原始碼中的方法。

10 種去重方法

假設有一個這樣的陣列: let originalArray = [1, '1', '1', 2, true, 'true', false, false, null, null, {}, {}, 'abc', 'abc', undefined, undefined, NaN, NaN];。後面的方法中的源陣列,都是指的這個。

1、ES6 的 Set 物件

ES6 提供了新的資料結構 Set。它類似於陣列,但是成員的值都是唯一的,沒有重複的值。Set 本身是一個建構函式,用來生成 Set 資料結構。

let resultArr = Array.from(new Set(originalArray));

// 或者用擴充套件運算子
let resultArr = [...new Set(originalArray)];

console.log(resultArr);
// [1, "1", 2, true, "true", false, null, {…}, {…}, "abc", undefined, NaN]
複製程式碼

Set 並不是真正的陣列,這裡的 Array.from... 都可以將 Set 資料結構,轉換成最終的結果陣列。

這是最簡單快捷的去重方法,但是細心的同學會發現,這裡的 {} 沒有去重。可是又轉念一想,2 個空物件的地址並不相同,所以這裡並沒有問題,結果 ok。

2、Map 的 has 方法

把源陣列的每一個元素作為 key 存到 Map 中。由於 Map 中不會出現相同的 key 值,所以最終得到的就是去重後的結果。

const resultArr = new Array();

for (let i = 0; i < originalArray.length; i++) {
    // 沒有該 key 值
    if (!map.has(originalArray[i])) {
        map.set(originalArray[i], true);
        resultArr.push(originalArray[i]);
    }
}

console.log(resultArr);
// [1, "1", 2, true, "true", false, null, {…}, {…}, "abc", undefined, NaN]
複製程式碼

但是它與 Set 的資料結構比較相似,結果 ok。

3、indexOf 和 includes

建立一個新的空陣列,遍歷源陣列,往這個空陣列裡塞值,每次 push 之前,先判斷是否已有相同的值。

判斷的方法有 2 個:indexOf 和 includes,但它們的結果之間有細微的差別。先看 indexOf。

const resultArr = [];
for (let i = 0; i < originalArray.length; i++) {
    if (resultArr.indexOf(originalArray[i]) < 0) {
        resultArr.push(originalArray[i]);
    }
}
console.log(resultArr);
// [1, "1", 2, true, "true", false, null, {…}, {…}, "abc", undefined, NaN, NaN]
複製程式碼

indexOf 並不沒處理 NaN

再來看 includes,它是在 ES7 中正式提出的。

const resultArr = [];
for (let i = 0; i < originalArray.length; i++) {
    if (!resultArr.includes(originalArray[i])) {
        resultArr.push(originalArray[i]);
    }
}
console.log(resultArr);
// [1, "1", 2, true, "true", false, null, {…}, {…}, "abc", undefined, NaN]
複製程式碼

includes 處理了 NaN,結果 ok。

4、sort

先將原陣列排序,生成新的陣列,然後遍歷排序後的陣列,相鄰的兩兩進行比較,如果不同則存入新陣列。

const sortedArr = originalArray.sort();

const resultArr = [sortedArr[0]];

for (let i = 1; i < sortedArr.length; i++) {
    if (sortedArr[i] !== resultArr[resultArr.length - 1]) {
        resultArr.push(sortedArr[i]);
    }
}
console.log(resultArr);
// [1, "1", 2, NaN, NaN, {…}, {…}, "abc", false, null, true, "true", undefined]
複製程式碼

從結果可以看出,對源陣列進行了排序。但同樣的沒有處理 NaN

5、雙層 for 迴圈 + splice

雙層迴圈,外層遍歷源陣列,內層從 i+1 開始遍歷比較,相同時刪除這個值。

for (let i = 0; i < originalArray.length; i++) {
    for (let j = (i + 1); j < originalArray.length; j++) {
        // 第一個等於第二個,splice去掉第二個
        if (originalArray[i] === originalArray[j]) {
            originalArray.splice(j, 1);
            j--;
        }
    }
}

console.log(originalArray);
// [1, "1", 2, true, "true", false, null, {…}, {…}, "abc", undefined, NaN, NaN]
複製程式碼

splice 方法會修改源陣列,所以這裡我們並沒有新開空陣列去儲存,最終輸出的是修改之後的源陣列。但同樣的沒有處理 NaN

6、原始去重

定義一個新陣列,並存放原陣列的第一個元素,然後將源陣列一一和新陣列的元素對比,若不同則存放在新陣列中。

let resultArr = [originalArray[0]];
for(var i = 1; i < originalArray.length; i++){
    var repeat = false;
    for(var j=0; j < resultArr.length; j++){
        if(originalArray[i] === resultArr[j]){
            repeat = true;
            break;
        }
    }

    if(!repeat){
       resultArr.push(originalArray[i]);
    }
}
console.log(resultArr);
// [1, "1", 2, true, "true", false, null, {…}, {…}, "abc", undefined, NaN, NaN]
複製程式碼

這是最原始的去重方法,很好理解,但寫法繁瑣。同樣的沒有處理 NaN

7、ES5 的 reduce

reduce 是 ES5 中方法,常用於值的累加。它的語法:

arr.reduce(callback[, initialValue])
複製程式碼

reduce 的第一個引數是一個 callback,callback 中的引數分別為: Accumulator(累加器)、currentValue(當前正在處理的元素)、currentIndex(當前正在處理的元素索引,可選)、array(呼叫 reduce 的陣列,可選)。

reduce 的第二個引數,是作為第一次呼叫 callback 函式時的第一個引數的值。如果沒有提供初始值,則將使用陣列中的第一個元素。

利用 reduce 的特性,再結合之前的 includes(也可以用 indexOf),就能得到新的去重方法:

const reducer = (acc, cur) => acc.includes(cur) ? acc : [...acc, cur];

const resultArr = originalArray.reduce(reducer, []);

console.log(resultArr);
// [1, "1", 2, true, "true", false, null, {…}, {…}, "abc", undefined, NaN]
複製程式碼

這裡的 [] 就是初始值(initialValue)。acc 是累加器,在這裡的作用是將沒有重複的值塞入新陣列(它一開始是空的)。 reduce 的寫法很簡單,但需要多加理解。它可以處理 NaN,結果 ok。

8、物件的屬性

每次取出原陣列的元素,然後在物件中訪問這個屬性,如果存在就說明重複。

const resultArr = [];
const obj = {};
for(let i = 0; i < originalArray.length; i++){
    if(!obj[originalArray[i]]){
        resultArr.push(originalArray[i]);
        obj[originalArray[i]] = 1;
    }
}
console.log(resultArr);
// [1, 2, true, false, null, {…}, "abc", undefined, NaN]
複製程式碼

但這種方法有缺陷。從結果看,它貌似只關心值,不關注型別。還把 {} 給處理了,但這不是正統的處理辦法,所以 不推薦使用

9、filter + hasOwnProperty

filter 方法會返回一個新的陣列,新陣列中的元素,通過 hasOwnProperty 來檢查是否為符合條件的元素。

const obj = {};
const resultArr = originalArray.filter(function (item) {
    return obj.hasOwnProperty(typeof item + item) ? false : (obj[typeof item + item] = true);
});

console.log(resultArr);
// [1, "1", 2, true, "true", false, null, {…}, "abc", undefined, NaN]
複製程式碼

貌似 是目前看來最完美的解決方案了。這裡稍加解釋一下:

  • hasOwnProperty 方法會返回一個布林值,指示物件自身屬性中是否具有指定的屬性。
  • typeof item + item 的寫法,是為了保證值相同,但型別不同的元素被保留下來。例如:第一個元素為 number1,第二第三個元素都是 string1,所以第三個元素就被去除了。
  • obj[typeof item + item] = true 如果 hasOwnProperty 沒有找到該屬性,則往 obj 裡塞鍵值對進去,以此作為下次迴圈的判斷依據。
  • 如果 hasOwnProperty 沒有檢測到重複的屬性,則告訴 filter 方法可以先積攢著,最後一起輸出。

看似 完美解決了我們源陣列的去重問題,但在實際的開發中,一般不會給兩個空物件給我們去重。所以稍加改變源陣列,給兩個空物件中加入鍵值對。

let originalArray = [1, '1', '1', 2, true, 'true', false, false, null, null, {a: 1}, {a: 2}, 'abc', 'abc', undefined, undefined, NaN, NaN];
複製程式碼

然後再用 filter + hasOwnProperty 去重。

然而,結果竟然把 {a: 2} 給去除了!!!這就不對了。

所以,這種方法有點去重 過頭 了,也是存在問題的。

10、lodash 中的 _.uniq

靈機一動,讓我想到了 lodash 的去重方法 _.uniq,那就嘗試一把:

console.log(_.uniq(originalArray));

// [1, "1", 2, true, "true", false, null, {…}, {…}, "abc", undefined, NaN]
複製程式碼

用法很簡單,可以在實際工作中正確處理去重問題。

然後,我在好奇心促使下,看了它的原始碼,指向了 baseUniq 檔案,它的原始碼如下:

function baseUniq(array, iteratee, comparator) {
  let index = -1
  let includes = arrayIncludes
  let isCommon = true

  const { length } = array
  const result = []
  let seen = result

  if (comparator) {
    isCommon = false
    includes = arrayIncludesWith
  }
  else if (length >= LARGE_ARRAY_SIZE) {
    const set = iteratee ? null : createSet(array)
    if (set) {
      return setToArray(set)
    }
    isCommon = false
    includes = cacheHas
    seen = new SetCache
  }
  else {
    seen = iteratee ? [] : result
  }
  outer:
  while (++index < length) {
    let value = array[index]
    const computed = iteratee ? iteratee(value) : value

    value = (comparator || value !== 0) ? value : 0
    if (isCommon && computed === computed) {
      let seenIndex = seen.length
      while (seenIndex--) {
        if (seen[seenIndex] === computed) {
          continue outer
        }
      }
      if (iteratee) {
        seen.push(computed)
      }
      result.push(value)
    }
    else if (!includes(seen, computed, comparator)) {
      if (seen !== result) {
        seen.push(computed)
      }
      result.push(value)
    }
  }
  return result
}
複製程式碼

有比較多的干擾項,那是為了相容另外兩個方法,_.uniqBy 和 _.uniqWith。去除掉之後,就會更容易發現它是用 while 做了迴圈。當遇到相同的值得時候,continue outer 再次進入迴圈進行比較,將沒有重複的值塞進 result 裡,最終輸出。

另外,_.uniqBy 方法可以通過指定 key,來專門去重物件列表。

_.uniqBy([{ 'x': 1 }, { 'x': 2 }, { 'x': 1 }], 'x');
// => [{ 'x': 1 }, { 'x': 2 }]
複製程式碼

_.uniqWith 方法可以完全地給物件中所有的鍵值對,進行比較。

var objects = [{ 'x': 1, 'y': 2 }, { 'x': 2, 'y': 1 }, { 'x': 1, 'y': 2 }];

_.uniqWith(objects, _.isEqual);
// => [{ 'x': 1, 'y': 2 }, { 'x': 2, 'y': 1 }]
複製程式碼

這兩個方法,都還挺實用的。

總結

從上述的這些方法來看,ES6 開始出現的方法(如 Set、Map、includes),都能完美地解決我們日常開發中的去重需求,關鍵它們還都是原生的,寫法還更簡單。

所以,我們提倡擁抱原生,因為它們真的沒有那麼難以理解,至少在這裡我覺得它比 lodash 裡 _.uniq 的原始碼要好理解得多,關鍵是還能解決問題。

PS:歡迎關注我的公眾號 “超哥前端小棧”,交流更多的想法與技術。

「前端面試題系列8」陣列去重(10 種濃縮版)

相關文章