深入分析数组去重

数组去重 是常见的面试考点,所以我就试着深入学习一下。网上也有很多数组去重的文章,但我自己觉得分析地不够深入,其实其中很多的实现都是重复的,可以归为一类,比如 双重循环法 和 indexOf法 的本质都是双重循环,故写下此文,做进一步的总结,同时加深理解。

1. 双重循环

这种方法就很直接,很好理解。那就是创建一个新的空数组,每次我们会从原数组中取出一个元素,拿它和新数组的元素进行一一比较。如果在新数组没发现和取出元素相等的元素,就将其放入这个新数组中;如果发现有和取出元素相等的元素,不放入新数组中。当原数组中的数组全都取出来时,这个新数组就是去重后的所有数据了。

这种数组去重的实现的时间复杂度是 O(n^2)。

const unique = arr => {
    let res = [];
    for (let i = 0, len = arr.length; i < len; i++) {
        let j = 0, len2 = res.length;
        for (; j < len2; j++) {
            if (arr[i] === res[j]) break;
        }
        if (j == len2) res.push(arr[i]);  // j == len2 表示没有执行 break。
    }
    return res;
}

当然这里的第一个循环可以改为 forEach() 方法,第二个 for 循环可以改为使用 includes() 或者 indexOf() 方法,时间复杂度没什么变化,不过代码更简洁。

const unique = arr => {
    let res = [];
    arr.forEach(item => {
        if (!res.includes(item)) res.push(item); 
    })
    return res;
} 

2. 查找元素第一次出现的位置

从后往前遍历数组,检测元素第一次出现的位置是否为当前元素的位置。如果不是,说明有重复,移除当前元素;如果没有,就不移除。

之所以从后往前遍历,是因为我们要搬移元素(其实就是 splice)。当然你也可以选择从前往后遍历,不过这样的话,如果遍历时当前元素被移除了,那么移除元素后的 arr[i] 对应的元素其实是原来 arr[i+1],因此此时 i 不能自增,且结束的条件要改为 i == len-1,就很麻烦。

这种写法不需要创建新的数组,空间复杂度为 O(1)。

const unique = arr => {
    for (let i = arr.length - 1; i >= 0; i--) {
        for (let j = 0; j < i; j++) {
            if (arr[j] === arr[i]) arr.splice(i, 1);
        }
    }
    return arr;
}

这里的代码实现是尽量减少时间复杂度的。说个题外话,其实上面这里还可以再优化一下,因为我们这里的元素搬移并不是一次性搬移到最终的位置上的。优化思路是先标记要所有要删除的元素索引,然后从前往后遍历数组,每遇到第 m 个删除索引,后面的元素就覆盖掉它们往前第 m 位的数组元素,这里就不实现了,也就随便提一下。

如果改为配合使用 filter()includes() 方法的话,我们可以让代码可读性更好一些(性能会稍微下降,因为 incluedes 会遍历整个数组),具体实现就不写了。

3. 排序后去重

排序算法有很多种,我们就用 js 自带的排序算法吧。顺带一说,v8引擎 的 sort() 方法在数组长度小于等于10的情况下,会使用插入排序,大于10的情况下会使用快速排序。

排(guai)好(guai)序(zhanhao)后,检查前后两个相邻元素,如果当前元素和前面的元素不相等,才将当前元素放入新数组中。

const unique = arr => {
    if (arr.length < 2) return arr; 
    arr.sort();
    let r = [arr[0]];
    for (let i = 1, len = arr.length; i < len; i++) {
        if (a[i] !== a[i - 1]) r.push(a[i]);
    }
    return r;
}

这种去重局限性非常大。它不适用于对象,因为对象不适合进行排序。sort() 的默认排序顺序是根据字符串Unicode码点进行排序,貌似会把对象转为字符串再进行排序,一般的对象都会转为 "[object Object]",无法保证两个引用同一个对象的变量能相邻排列。

4. 使用散列表

散列表,在 JavaScript 中是通过对象来实现的。散列表的优点是,一般情况下读取数据的时间复杂度是 O(1)。但 js 的对象的键只能为字符串类型,不过可以考虑使用 ES6 新增的 Map 数据结构,它允许使用任何类型的值作为键。

下面的实现使用的是普通对象作为散列表,有很大的局限性,无法对 js对象 进行去重(对象都会转为类似 [object Object] 的字符串)。另外,对于js对象来说,a['1'] 和 a[1] 是相等的,因为1会转换为'1',这样就无法分辨出 1 和 '1',从而错误地在去重过程中丢弃其中的一个元素,所以我做了简单地改良,键名使用的不是 arr[i] 而是 typeof(arr[i]) + arr[i]

const unique = arr => {
    let r = [];
    let map = {};
    for (let i = 0, len = arr.length; i < len; i++) {
        const item = arr[i];
        if (!map[typeof(item) + item]) {
            r.push(arr[i]);
        }
        map[typeof(item) + item] = true; 
    }
    return r;
} 

这种实现方式,时间复杂度可以达到 O(n)。

如果考虑对象也能去重,可以考虑使用 ES6 的 Map。

5. ES6 的 Set

ES6 提供了新的数据结构。Set 实例会认为两个 NaN 是相等的(尽管 NaN !== NaN),并认为两个对象是不等的(当然这里两个对象的意思,表示的是两个指向不同内存空间的引用类型变量)。

并不太了解 Set 的源码实现,就不分析性能了。

const unique = arr => {
    return Array.from(new Set(arr))
}

非常简洁,如果你的运行环境支持 ES6,或者可以编译成 ES5,我很推荐使用这个去重方案。

考虑 NaN 的去重

如果要考虑 NaN 的去重,就需要稍微对代码进行一些修改。

简单来说就是,判断 item 是否为 NaN,然后检查返回的数组中是否已有 NaN。如果有,放入数组;否则不放入。

const unique = arr => {
    let res = [];
    let hasNaN = false;
    arr.forEach(item => {
        if(!hasNaN && Number.isNaN(item)) {
            res.push(item);
            hasNaN = true
        }else if (!res.includes(item)) {
            res.push(item); 
        }
    })
    return res;
} 

lodash 如何实现去重

简单说下 lodash 的 uniq 方法的源码实现。

这个方法的行为和使用 Set 进行去重的结果一致。

当数组长度大于等于 200 时,会创建 Set 并将 Set 转换为数组来进行去重(Set 不存在情况的实现不做分析)。当数组长度小于 200 时,会使用类似前面提到的 双重循环 的去重方案,另外还会做 NaN 的去重。

总结

一般来说,在开发中,要进行去重的数组并不是很大,不必太考虑性能问题。所以在工程中,为了不把简单的问题复杂化中,建议使用最简洁的 ES6 的 Set 转数组的方案来实现。当然具体问题具体分析,要根据场景选择真正合适的去重方案。

另外,其实 “相等” 有很多种定义,ES6 中就有四种相等算法,这里就不多说了,有兴趣的话可以看看这篇文章:JavaScript 中的相等性判断。依旧是根据场景选择合适的相等算法。

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269