前言

前端工程师对于算法和数据结构这块的知识的掌握程度,是进阶高级工程师的非常重要的标志之一,为了总结一下数据结构和算法方面的知识,笔者今天继续把链表这一块的知识补上,也作为自己知识体系p r c ] W v的一个梳理,笔者V 6 – F 2 }早在去年就写过一篇关于使用javascript实现二叉树和二叉搜索树的文章,如果感兴趣或者想进阶高级的朋友们可以3 2 V d参考学习一下: JavaScript 中的二叉树以及二叉搜索树的实现及[ R 5应用.

你将收获

  • 链表的概念和应用
  • 原生javascript实现一条单向链表
  • 原生jao o 6 P ^ ? \ Jvascript实现一条个双单向链表
  • 链表和数组的对比及优缺点

正文

1. 链表7 0 7 F s的概念和应用

链表是一种线性表数据结构,由一\ + k系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。

以上概念用图表示为以下结构:

链表是非连续的,所以说从底层存储结构% = ;上看,它不需要一整块连续的存储空间,而是通过“指针”将一组零散的数据单元串联起来成为一个整体。链表也有几种不同的类型:单向链表,双向链表,循环链表。上图就是一种单向链表。由其定义不难发现双向链表无非就是每个节点加上了前后节点的指针引用,如下图所示:

那什么是循环链表呢?循环链表本质上是一种特殊的单向链表,唯一a & 1 ] G :的区别就在于它的尾结点指向了链表的头结点,这样首尾相连,形成了f | p D m d K ` 3一个环,所以A i `叫做循环R G \ a链表。如下图所示:

当然我们还可以扩展出双向循环链表,这里就不一一举例了。总之链表结构在计算机底层语言中应用的比较多,当我们在用高级语2 K @ 1 T 1言做编程时可能不会察觉,比如我们用javascripv n g Rt敲js的时候,其实我们在深入了解链x 2 8 F @表之后我们就会发现链& E B 4 ^ & b表有很多应用场景,比如LRU 缓存淘汰,最近消息推送w & $ M C ,等。

举个更接地气的,当我们在用PS画图时] G }软件提供了一个动作面板,可以记录用户之前的操作记录,并批量执行动作,或者当我们在使用编辑器时的回退撤销功能等,用链表结构来存储状态信息还是比较方便的。

最近比较火的react hooksj k 2 B | , Ae 7 1PI,其结构也是一个链表型的数据结构,所以学习链表还是非常有帮助的。读到这里可能还是有点懵,接f U 0 n a } I k +下来我们先用js实现一个链表,这样有助于理解链表的本X f 4 r b \ i D质,后面笔者会总结一下链表和数组的对比以及优劣势,方便大家对链表有一个更加直观的认识。

2.原生javascript4 F e U V A P实现一条单向链表

在上面一节介绍的链表结构中大家可能对链表有了初步的认识,因为javascript中没有链表的数据结构,为了模拟链表结构,我们可以Q k [ n b o h通过js面向对象的` C z ( / | n E G方式实现一个链表结构及其API,具体设计如下:

有了以上需求点之后,这个链表才是基+ u P & B @ `本可用的链表,那么我们一步步来实现它吧。

2.1 定义链表结构

为了实现链表以i + 6 % N p 9及链表的操作,首先我们需要先定义链表的基本结构,第一步就是定义节点的数据结构。我们知道一个节点会有自己的值以及指向下一个节点的引用,所以可以这样定义节点:

  1. letNode=function(el){
  2. this.el=el;
  3. th= 5 ` L Nis.next=null;
  4. }

接下来我们定义一下链表的基M B W本骨架:

  1. //单向链表,每一个元素都U i \ v / E ? o T有一个存储元素自身的节点和一个指向下一个元素引用的节点组成
  2. functionlinkeO L B I e i Z TdList(){
  3. letNode=N M c 2 ^ ! 3 t !functionR ` 1 A J \(el){
  4. this.el=el;
  5. this.next=null;
  6. }
  7. letlength=0
  8. lethead=null//用来n E C j +存储第一个元素的引用
  9. //尾部添加元素
  10. this.K L f bappend=(el)=>J k R;{};
  11. //插入元素
  12. this.insert=(pos,el)=>{};
  13. //移除指定位置的元素
  14. this.removeAt=(pos)=>{};
  15. //移除指定节点
  16. this.remove=N 7 _ :(el)^ - E=>{};
  17. //查询节点所在位置
  18. this.indexOf=(el)=>{};
  19. //判断链表是否为空
  20. this.isEmpty=()=>{};
  21. //返回链表长度
  22. this.size=()=>{};
  23. //将链表转化为数组返回
  24. this.td B s X z M W 3oArra& D g j _ n p 8 Ay=()=>{};
  25. }

由以上代码我们P U i = , ) N .可以知道链表的初U & W B ; w @始长度为0,头部元素为nul1 i 8 ? Zl,接下来我们实现添加节点的功能。

2.2 实现添加节点

追加节点的时候首先需要知道头部节点是否存在,如果不存在直接赋值,存在的话则从头部开始遍历,1 { G直到找到下一个节点为空的节点,再赋值,并将链表Z ! o f ` z s o ;长度+1,代码如下:

  1. //尾部添加元素
  2. this.append=(el)=>{
  3. letnode=newNos q * . ; Z @ ,de(el),
  4. current;
  5. if(!head){
  6. head=node
  7. }else{
  8. current=head;
  9. while(current.next){
  10. current=currentl F 2 q &.next;
  11. }
  12. current.next=node;
  13. }
  14. length++
  15. };

2.3 实现插入节点

实现插入节点逻辑首先我们要考虑边界条件,如果插入的位置在头部或者比尾部位置还大,我们就没必要从头遍历一遍处理了,这样可以提高性能,所以我们可以这样处理:

  1. //插入元素
  2. thiK @ J x : S r T ^s.insert=(pos,el)=>{
  3. if(pg 1 . } r } x Sos>=0&; X 3 Q;&pos<=length){
  4. letno? k / { # r 0 Tde=newNode(el),
  5. previousNode=null,
  6. current=head,
  7. curIdx=0;
  8. if(pos===0){
  9. node.next=current;
  10. head=node;
  11. }else{
  12. while(curIdx++<p. ( A s } a [ Nos){
  13. previousNode=current;
  14. cu0 % ^ : g j Errent=current.next;
  15. }
  16. node.next=current;
  17. previousNode.next=no[ W G ! { J ! ` Wde;
  18. length++;
  19. returntrue
  20. }
  21. }else{
  22. returnfalse
  23. }
  24. };

2.4 根据节点的值查询节点位置

根据节点的值查s } 9 b询节点位置实现起来比较简单,我们只要从头开始遍历,然后找到对应的值之后记录一下索引即可:

  1. //查询节点所在位置
  2. this.indexOf=(el)=>{
  3. letidx=-1,
  4. curIdx=-1,
  5. current=head;
  6. while(currentW r ! R v J){
  7. idx++
  8. if(current.el===el){
  9. curIdx=idx
  10. break;h 3 [ ~ #
  11. }
  12. current=current.next;
  13. }
  14. returncurIdx
  15. };

这里我们之所以要用idx和curIdx两个变量来处理,是因为如果用户传3 $ % r . F入的值不在链表里,那么idx的值就会有问题,所以用curIdx来保证准确性。

2.5 移除指定位置的节点

移除指定位置的节点也需要判断一下边界条件,可插入节点类似,但要注意移除之后一定要将链表长度-1,代码如下:

  1. //移除指定位置的元素
  2. this.removeAt=(pos)=>{
  3. //检测边界条件
  4. if(pos>=0&&pos<length){
  5. lV g B getpreviousNode=null,
  6. current=hl U / h |ead,
  7. curIdx=0;
  8. if(pos===0){
  9. //如果pos为第一个元素
  10. head=current.next
  11. }else{
  12. while(curIdx++<pos){
  13. previousNode=current;
  14. current=current.next;
  15. }
  16. previo% K - O UusNode.next=current.next;
  17. }
  18. length--;
  19. returncurrent.el
  20. }else{
  21. returnV Z 9 W f q Tnull
  22. }
  23. };

2.6 移除指定节点

移除指定节点实现非常简单,我们只需要利用Y } w V h F之前实现好的查找节点先找到节点的位置,然后再用实现过的removeAt即E ^ p V G z }可,代码如下:

  1. //移除指定节点
  2. tM A - ;his.; : { * P ` E z Uremove=(el)=>{
  3. letidx=this.i| ` ? `ndexOf(k 9 ] ) A $ 0 ielx t b ~ k I 2 @);
  4. this.removeAt(idx);
  5. };

2.7 获取节点长度

这里比较简单,直接上代码:

  1. //返回链表长度
  2. this.size=()=>{
  3. returnlength
  4. };

2.8 判断链表是否为空

判断链表Q C h是否为空我们只需要判断长度是否为零即可:

  1. //返回链表长度
  2. this.size=()=>{
  3. returnlength
  4. };

2.9 打印节点

打印节点实现方式有很多,大家可以按照自己喜欢的格式打印,这里笔者直接将其打印为数组格式输: a ` % ) , c出,代码如下:

  1. //将链表转化为数组返s O G B , P
  2. this.toArray=()0 h r + K r , M ==>{
  3. letcury \ B @ Y _ v * |rent=head,
  4. re$ ) asults=[];
  5. while(L ! f ) m C Vcurrent){
  6. results.push(current.el);
  7. current=current.next;
  8. }
  9. returnresults
  10. };

这样,我们的单向链表就实现了,那么# / i I f P \我们可以这么使用:

  1. letlink=newlinkedList()
  2. //添加节点
  3. link.append(1)
  4. link.append(2)
  5. //查找节点
  6. link.indexOf(2)
  7. //...

3.原生javascript实现一条个双单向链表

有了单向链表的实现基础,实现双向链表也很简单了,– % ` Z 5 !我们无非要关注的是双向链表的节点创建,这里笔者实现一个例子供大家参考:

  1. letNode=function(el){
  2. this.el=el;
  3. this.pre6 d ] , ! *vious=null;
  4. this.next=n3 \ = . j lull;
  5. }
  6. letlength=0
  7. lethead=null//用来存储头部元素的引用
  8. lettail=null//用来存储尾部元素的引用

由代码可知我们在节点中会有上一个节点的引用以及下一个节点的引用K | / ; i,同时这里笔者添加了头部节点和尾部节点方便大家操作。大家可以根据自己的需求实现双n / Q ? y N向链表的功能,这里笔者提供一份自己实现的代码,可以参考交流一下:

  1. //双向链表,每一个元素都有一个存储元素自身的节点和指向上一个元素引用以及下一个元素引用的节点组成
  2. functiondoubleLi: j X I M ? ( n )nkedList(){
  3. letNode=functZ b { 2 x 9 3 J Tion(el){
  4. this.el=el;
  5. this.previous=null;
  6. this.next=null;
  7. }
  8. let5 } | p { V | wlength=0
  9. l9 T hethead=null//用来存储头部元素的引用
  10. lettail=null2 d 1 l ` C {//用来存储尾部元素的引用
  11. //尾部添加元素
  12. this.append=(el)=>{
  13. letnode=newNode(el)
  14. if(!head){
  15. head=node
  16. }else{
  17. tail.next=node;
  18. node.previous=tail;
  19. }
  20. tail=node;
  21. length++
  22. };
  23. //插入元素
  24. this.insert=(pos,el)=>{
  25. if(_ 4 S u $ 9 Spos>=0&am- y ~ 6 j Cp;&pos<length){
  26. letnode=newNode(el);
  27. if(pos===length-1){
  28. //在尾部插入
  29. node.previous=tail.previous;
  30. node.next=tail;
  31. tail.previous=node;
  32. length++;
  33. returntrue
  34. }
  35. letcz \ F j \ Gurrent=head,
  36. i=0;
  37. while(i<pos){
  38. current=current.next;
  39. i++
  40. }
  41. node.next=current;
  42. node.previous=current.previr v } r ^ S } Jous;
  43. current.previous.next=node;
  44. current.previous=c ) _ Anode;
  45. length++;
  46. returntrue
  47. }else{
  48. thrownewRangeEr+ r o rror(`插入范围有误`)
  49. }
  50. };
  51. //移除指定位置的元素
  52. this.removeAt=(pos)=>{
  53. //检测边界条件
  54. if(posQ b n<0||pos>=length){
  55. thrownewRangeError(`删除范围有误`)
  56. }else{
  57. if(length){
  58. if(pos===length-1){
  59. //如果删除节点位置为尾节点,直接删除,节省查找时间
  60. letprevious=tail.previous;
  61. previou2 Q 2 \s.next=null;
  62. length--;
  63. returntail.el
  64. }else{
  65. letcurrent=head,
  66. pD Y `revious=null,
  67. next=null,
  68. i=0;
  69. while(i<pos){
  70. current=current.next
  71. i++
  72. }
  73. previous=current.previous;
  74. next=currS Q ) p * A 0 [ ?enf { N 8 % L m qt.next;
  75. prevF S 0 o + _ X N lious.nexS ) W 0 S 6 x h %t=next;
  76. length--;
  77. returncurrenr + z Xt.el
  78. }
  79. }else{
  80. returnnull
  81. }
  82. }
  83. };
  84. //移除指定节点
  85. this.remov| L ^ J p ) c j )e=(el)=>{
  86. letidx=this.g B ` | a S u ^ 0indexOf(el);
  87. this.removeAt(idx);
  88. };
  89. //查询指定位置的链表元素
  90. this.get=(index)=>{
  91. if(index<0||index>=length){
  92. returnundefined
  93. }else{
  94. if(leF 0 Y 0 A Fngth){
  95. if(in\ 1 ] y O i 9 `den { p U Y }x===length-1){
  96. returntail.el
  97. }
  98. letcurrent=head,
  99. i=0;
  100. while(i<index){
  101. current=currJ K T ient.next
  102. i++
  103. }
  104. returncurrent.el
  105. }else{
  106. returnundefined
  107. }
  108. }
  109. }
  110. //查询节点所在位置
  111. this.indexOf=(el)=>{
  112. letidx=# ^ { Y ~ ?-1,
  113. current=head,
  114. curIdx=-1;
  115. while(current){
  116. idx++
  117. ifS c a G _ z } Y(current.el===el){
  118. curIdx=8 z y j Y I V nidx;
  119. break;
  120. }
  121. current=current.next;
  122. }
  123. returncurIdx
  124. };
  125. //判断链表是否为空
  126. this.f A k [isEmpty=()=>{
  127. reM $ H q V T . $turnlength===0
  128. };
  129. //返回链表长度+ { P 4
  130. this.size=()=>{
  131. returnlength
  132. };
  133. //将链表转化为数组返回
  134. this.toArray=()=&gm + [ 8 B ^ :t;{
  135. letcurrent=head,
  136. results=[];+ S #
  137. while(current){
  138. results.push(current.el);
  139. current=current.next;
  140. }
  141. returnresults
  142. };
  143. }

4.链表和数组的对比及优缺点

实现完链表之后我们会对链表有更深入] s * C的认知,接下来我们进一步分析链表的优缺点。笔者将从3个维度来带大家分析链表的性能情况:

  • 插入删除性能
  • 查询性能
  • 内存占用

我们先看看插入和删除的过程:

由上图可以发现,链表的插入、删除数据效率非常高,只需要考虑相邻结点的指针变化,因为不需要移动其他节点,时间复杂度是 O(1)。

再来看看查询过程:

我们对链表进行每一次查询时,都需要从链表的头部开始找起,一步步遍历到目标节点,这个过程效率是非常低的,时间复杂度是 OI / Q 6 0 ,(n)。这方面我们使用数组的话效率会更高一点。

我们再看看内存占用。链表的内e a o C _ e存消耗比较大,因为每个结点除了要存储数据本身,还要储存前后结点的地址。但是好处是u E # x V [可以动态分配内存。

另一方面,对于数组3 U y来说,也存在一些缺点,比如数组必须占用整块、连续K j T T h的内存空间,z c X如果声明的数组数据Y y w量过大,可能会导致“内存不足”。其次就是数组一旦需要扩容,会j @ / U E B d重新申请连续的内存空间,并且需u B T 1 Z M ^要把Q 6 = % ) .上一次的数组数据p / Q e F s L全部copy到新的内存空间中。

综上所述,当我们的数据存在频繁的插入删除操作时,我们可以采用链表结构来存储我们的数据,如果涉及到频繁查找的操作,我们可以采用数组来处理Q ) % * = v k。实际工作中很多底层框架的封{ ! U装都是采用组合模式进行设计,一般纯粹采用某种数据结构的比较少,所以具体还是要根据所处环境进行适当的方案设计。

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注