JavaScript的数组去重是一个老生常谈的话题了。随便搜一搜就能找到非常多不同版本的解法。

细想一下,这样一个看似简单的需求,如果要做到完备,涉及的知识和需要注意的地方着实不少。


定义重复(相等)

要去重,首先得定义,什么叫作“重复”,即具体到代码而言,两个数据在什么情况下可以算是相等的。这并不是一个很容易的问题。

对于原始值而言,我们很容易想到1和1是相等的,'1'和'1'也是相等的。那么,1和'1'是相等的么?

如果这个问题还好说,只要回答“是”或者“不是”即可。那么下面这些情况就没那么容易了。


NaN

初看NaN时,很容易把它当成和null、undefined一样的独立数据类型。但其实,它是数字类型。

// numberconsole.log(typeof NaN);

根据规范,比较运算中只要有一个值为NaN,则比较结果为false,所以会有下面这些看起来略蛋疼的结论:

// 全都是false0 < NaN;0 > NaN;0 == NaN;0 === NaN;

以最后一个表达式0 === NaN为例,在规范中有明确规定():

4. If Type(x) is Number, then

a. If x is NaN, return false.

b. If y is NaN, return false.

c. If x is the same Number value as y, return true.

d. If x is +0 and y is −0, return true.

e. If x is −0 and y is +0, return true.

f. Return false.

这意味着任何涉及到NaN的情况都不能简单地使用比较运算来判定是否相等。比较科学的方法只能是使用isNaN():

var a = NaN;var b = NaN;// trueconsole.log(isNaN(a) && isNaN(b));

原始值和包装对象

看完NaN是不是头都大了。好了,我们来轻松一下,看一看原始值和包装对象这一对冤家。

如果你研究过'a'.trim()这样的代码的话,不知道是否产生过这样的疑问:'a'明明是一个原始值(字符串),它为什么可以直接调用.trim()方法呢?当然,很可能你已经知道答案:因为JS在执行这样的代码的时候会对原始值做一次包装,让'a'变成一个字符串对象,然后执行这个对象的方法,执行完之后再把这个包装对象脱掉。可以用下面的代码来理解:

// 'a'.trim();var tmp = new String('a');tmp.trim();

这段代码只是辅助我们理解的。但包装对象这个概念在JS中却是真实存在的。

var a = new String('a');var b = 'b';

a即是一个包装对象,它和b一样,代表一个字符串。它们都可以使用字符串的各种方法(比如trim()),也可以参与字符串运算(+号连接等)。

但他们有一个关键的区别:类型不同!

typeof a; // objecttypeof b; // string

在做字符串比较的时候,类型的不同会导致结果有一些出乎意料:

var a1 = 'a';var a2 = new String('a');var a3 = new String('a');a1 == a2; // truea1 == a3; // truea2 == a3; // falsea1 === a2; // falsea1 === a3; // falsea2 === a3; // false

同样是表示字符串a的变量,在使用严格比较时竟然不是相等的,在直觉上这是一件比较难接受的事情,在各种开发场景下,也非常容易忽略这些细节。


对象和对象

在涉及比较的时候,还会碰到对象。具体而言,大致可以分为三种情况:纯对象、实例对象、其它类型的对象。

纯对象

纯对象(plain object)具体指什么并不是非常明确,为减少不必要的争议,下文中使用纯对象指代由字面量生成的、成员中不含函数和日期、正则表达式等类型的对象。

如果直接拿两个对象进行比较,不管是==还是===,毫无疑问都是不相等的。但是在实际使用时,这样的规则是否一定满足我们的需求?举个例子,我们的应用中有两个配置项:

// 原来有两个属性// var prop1 = 1;// var prop2 = 2;// 重构代码时两个属性被放到同一个对象中var config = { prop1: 1, prop2: 2};

假设在某些场景下,我们需要比较两次运行的配置项是否相同。在重构前,我们分别比较两次运行的prop1和prop2即可。而在重构后,我们可能需要比较config对象所代表的配置项是否一致。在这样的场景下,直接用==或者===来比较对象,得到的并不是我们期望的结果。

在这样的场景下,我们可能需要自定义一些方法来处理对象的比较。常见的可能是通过JSON.stringify()对对象进行序列化之后再比较字符串,当然这个过程并非完全可靠,只是一个思路。

如果你觉得这个场景是无中生有的话,可以再回想一下断言库,同样是基于对象成员,判断结果是否和预期相符。

实例对象

实例对象主要指通过构造函数(类)生成的对象。这样的对象和纯对象一样,直接比较都是不等的,但也会碰到需要判断是否是同一对象的情况。一般而言,因为这种对象有比较复杂的内部结构(甚至有一部分数据在原型上),无法直接从外部比较是否相等。比较靠谱的判断方法是由构造函数(类)来提供静态方法或者实例方法来判断是否相等。

var a = Klass();var b = Klass();Klass.isEqual(a, b);

其它对象

其它对象主要指数组、日期、正则表达式等这类在Object基础上派生出来的对象。这类对象各有各的特殊性,一般需要根据场景来构造判断方法,决定两个对象是否相等。

比如,日期对象,可能需要通过Date.prototype.getTime()方法获取时间戳来判断是否表示同一时刻。正则表达式可能需要通过toString()方法获取到原始字面量来判断是否是相同的正则表达式。


==和===

在一些文章中,看到某一些数组去重的方法,在判断元素是否相等时,使用的是==比较运算符。众所周知,这个运算符在比较前会先查看元素类型,当类型不一致时会做隐式类型转换。这其实是一种非常不严谨的做法。因为无法区分在做隐匿类型转换后值一样的元素,例如0、''、false、null、undefined等。

同时,还有可能出现一些只能黑人问号的结果,例如:

[] == ![]; //true

Array.prototype.indexOf()

在一些版本的去重中,用到了Array.prototype.indexOf()方法:

既然==和===在元素相等的比较中是有巨大差别的,那么indexOf的情况又如何呢?大部分的文章都没有提及这点,于是只好求助规范。通过规范(),我们知道了indexOf()使用的是严格比较,也就是===。

再次强调:按照前文所述,===不能处理NaN的相等性判断。


Array.prototype.includes()

Array.prototype.includes()是ES2016中新增的方法,用于判断数组中是否包含某个元素,所以上面使用indexOf()方法的第二个版本可以改写成如下版本:

那么,你猜猜,includes()又是用什么方法来比较的呢?如果想当然的话,会觉得肯定跟indexOf()一样喽。但是,程序员的世界里最怕想当然。翻一翻规范,发现它其实是使用的另一种比较方法,叫作“SameValueZero”比较。

1. If Type(x) is different from Type(y), return false.

2. If Type(x) is Number, then

a. If x is NaN and y is NaN, return true.

b. If x is +0 and y is -0, return true.

c. If x is -0 and y is +0, return true.

d. If x is the same Number value as y, return true.

e. Return false.

3. Return SameValueNonNumber(x, y).

注意2.a,如果x和y都是NaN,则返回true!也就是includes()是可以正确判断是否包含了NaN的。我们写一段代码验证一下:

var arr = [1, 2, NaN];arr.indexOf(NaN); // -1arr.includes(NaN); // true

可以看到indexOf()和includes()对待NaN的行为是完全不一样的。


一些方案

从上面的一大段文字中,我们可以看到,要判断两个元素是否相等(重复)并不是一件简单的事情。在了解了这个背景后,我们来看一些前面没有涉及到的去重方案。


遍历

双重遍历是最容易想到的去重方案:

双重遍历还有一个优化版本,但是原理和复杂度几乎完全一样:

这种方案没什么大问题,用于去重的比较部分也是自己编写实现(arr[i] === arr[j]),所以相等性可以自己针对上文说到的各种情况加以特殊处理。唯一比较受诟病的是使用了双重循环,时间复杂度比较高,性能一般。


使用对象key来去重

这种方法是利用了对象(tmp)的key不可以重复的特性来进行去重。但由于对象key只能为字符串,因此这种去重方法有许多局限性:

1. 无法区分隐式类型转换成字符串后一样的值,比如1和'1'

2. 无法处理复杂数据类型,比如对象(因为对象作为key会变成[object Object])

3. 特殊数据,比如'__proto__'会挂掉,因为tmp对象的__proto__属性无法被重写

对于第一点,有人提出可以为对象的key增加一个类型,或者将类型放到对象的value中来解决:

该方案也同时解决第三个问题。

而第二个问题,如果像上文所说,在允许对对象进行自定义的比较规则,也可以将对象序列化之后作为key来使用。这里为简单起见,使用JSON.stringify()进行序列化。


Map Key

可以看到,使用对象key来处理数组去重的问题,其实是一件比较麻烦的事情,处理不好很容易导致结果不正确。而这些问题的根本原因就是因为key在使用时有限制。

那么,能不能有一种key使用没有限制的对象呢?答案是——真的有!那就是ES2015中的Map。

Map是一种新的数据类型,可以把它想象成key类型没有限制的对象。此外,它的存取使用单独的get()、set()接口。

var tmp = new Map();tmp.set(1, 1);tmp.get(1); // 1tmp.set('2', 2);tmp.get('2'); // 2tmp.set(true, 3);tmp.get(true); // 3tmp.set(undefined, 4);tmp.get(undefined); // 4tmp.set(NaN, 5);tmp.get(NaN); // 5var arr = [], obj = {};tmp.set(arr, 6);tmp.get(arr); // 6tmp.set(obj, 7);tmp.get(obj); // 7

由于Map使用单独的接口来存取数据,所以不用担心key会和内置属性重名(如上文提到的__proto__)。使用Map改写一下我们的去重方法:


Set

既然都用到了ES2015,数组这件事情不能再简单一点么?当然可以。

除了Map以外,ES2015还引入了一种叫作Set的数据类型。顾名思义,Set就是集合的意思,它不允许重复元素出现,这一点和数学中对集合的定义还是比较像的。

var s = new Set();s.add(1);s.add('1');s.add(null);s.add(undefined);s.add(NaN);s.add(true);s.add([]);s.add({});

如果你重复添加同一个元素的话,Set中只会存在一个。包括NaN也是这样。于是我们想到,这么好的特性,要是能和数组互相转换,不就可以去重了吗?

function unique(arr){ var set = new Set(arr); return Array.from(set);}

我们讨论了这么久的事情,居然两行代码搞定了,简直不可思议。

然而,不要只顾着高兴了。有一句话是这么说的“不要因为走得太远而忘了为什么出发”。我们为什么要为数组去重呢?因为我们想得到不重复的元素列表。而既然已经有Set了,我们为什么还要舍近求远,使用数组呢?是不是在需要去重的情况下,直接使用Set就解决问题了?这个问题值得思考。


小结

最后,用一个测试用例总结一下文中出现的各种去重方法:

测试中没有定义对象的比较方法,因此默认情况下,对象不去重是正确的结果,去重是不正确的结果。

最后的最后:任何脱离场景谈技术都是妄谈,本文也一样。去重这道题,没有正确答案,请根据场景选择合适的去重方法。

java数组去重_再谈JavaScript数组去重相关推荐

  1. java list数组排序_浅谈对象数组或list排序及Collections排序原理

    常需要对list进行排序,小到List,大到对自定义的类进行排序.不需要自行归并或堆排序.简单实现一个接口即可. 本文先会介绍利用Collections对List进行排序,继而讲到Collection ...

  2. java归还线程_再谈java线程

    什么是等待唤醒机制? 这是多个线程间的一种协作机制. 就是一个线程进行规定协作后,就进入到了等待状态'wait()',等待其他线程执行完他们的指定代码后,再将其唤醒'notify()'; 在有多个线程 ...

  3. java javascript数组_浅谈javascript和java中的数组

    javascript中的数组 数组的创建 直接创建方式  var str = ['java', 'js']; 使用new创建方式: var a = new Array(10);  //  定义长度为1 ...

  4. splice方法_[7000字]JavaScript数组所有方法基础总结

    基础决定一个人的上限,很多时候我们感叹别人在实现一个功能时使用方法的精妙,并且反思,为什么别人想的出来自己却想不出来?我觉得主要是因为对于基础的掌握上有很大的差距.本文总结数组的所有方法的基础使用,希 ...

  5. js跟php增加删除信息,浅谈JavaScript数组的添加和删除

    本文给大家浅谈一下JavaScript数组的添加和删除 ,有一定的参考价值,有需要的朋友可以参考一下,希望对你们有所帮助. 1.添加 (1)最简单的方法:为新索引赋值 (2)使用push()和unsh ...

  6. java 数组 算法_常见算法总结 - 数组篇

    1.给定一个数值在1-100的整数数组,请找到其中缺少的数字. 找到丢失的数字 利用byte数组的1或0标记该数字是否被删除,例如byte数组下标为0的数值为1的话,代表数字1存在 public st ...

  7. 数组中某个元素相同的去重_几种去除数组中重复元素的方法、数组去重

    工作中遇到的一个问题,就是去除数组中重复的元素,记录一下几种有效的方法: 第一种思路:遍历要删除的数组arr, 把元素分别放入另一个数组tmp中,在判断该元素在arr中不存在才允许放入tmp中. 去除 ...

  8. 程序员谈 JavaScript 数组 Array 的学习

     JavaScript Array 教程            作为一个 前端开发,JS 数组的熟练使用显得非常重要,ECMAScript数组的大小是可以动态调整的,可以随着数据的添加自动增长长度 ...

  9. java 修改源码_再谈给应用程序diy启动画面和java源代码补丁修改

    再谈给应用程序diy启动画面和java源代码补丁修改 2006-8-21 16:18 6365 再谈给应用程序diy启动画面和java源代码补丁修改 2006-8-21 16:18 6365 搞diy ...

最新文章

  1. python之线程,不得不了解的硬知识!
  2. Linux学习之VirtualBox安装Linux
  3. 算法63----丑数【动态规划】
  4. Luogu1640 连续攻击游戏
  5. matlab R2012a in ubuntu12.04
  6. UNIX/Linux系统管理技术手册(1)----脚本和shell
  7. 设计模式 C++外观者模式
  8. smartconfig配置模式
  9. job历史执行记录查询 oracle_oracle job 查询 存储过程
  10. Android 系统分析工具:Systrace
  11. Zemax OpticsViewer
  12. Ant Design Vue
  13. AI(adobe illustrator)怎么设置导出图片的像素尺寸
  14. php传奇发布站,传奇发布网站php源码
  15. 核电工程能源行业案例 | 达索系统百世慧®
  16. linux内核态延时函数及头文件,Linux内核延时函数
  17. Hololens开发学习笔记-4
  18. nmbd samba中文
  19. UNet语义分割模型的使用-Pytorch
  20. Java 帝国之Java bean上

热门文章

  1. 计算机应用专业综合理论试卷2009,2009年湖南对口升学计算机应用专业综合试卷121...
  2. linux看php安装路径,linux下查找php安装路径的方法是什么
  3. hdfs mv命令_大数据入门:HDFS文件管理系统简介
  4. Jmeter性能测试之Switch控制器使用
  5. Java常用的设计模式总结
  6. Linux下的USB总线驱动 mouse
  7. 扫描全能王文件上传不了服务器,扫描全能王如何备份JPG 文件备份JPG办法
  8. Linux系统ssh无法启动,Linux系统上SSH无法启动
  9. opencv 图像识别 e语言_openCV-特征点匹配算法介绍一:理解特征
  10. c#获取父类_C#——父类中的this的指向,及用反射获取当前类所在的Type | 学步园...