数据结构浅析:链表

数据结构浅析:链表

你有修建过戈德堡机械吗?如果没有,也许应该修建过精心构建的多米诺骨牌线?

好的,也许你的童年应该不像我一样书呆子。就这样吧,对于玩过上述两者中任意一样的人,你已经掌握了今天数据结构的本质:链表!

链表是怎么工作的

链表的最简单实现形式—单链表--是一系列的节点,每一个单独的节点都包含一个值和一个指向链表中下一个节点的指针。

添加(Add)通过在表的尾部添加一个项目增加列表的大小。

移除(Remove)通常移除链表中指定位置的元素。

搜索(Contains)将在列表中搜索一个指定的值。

使用例子:

  • 哈希表中为了防止出现冲突,将 values 存储在一个列表中

  • 重拍极速前进(The Amazing Race

为了让这篇文章保持轻松友好一些,我们一起来创建一个 CBS 可用于计划其下一季“极速前进”真人秀拍摄的工具。

在阅读本文时,我希望你能够一直问自己:“链表和数组有什么区别?它们有什么相似之处?”

我们开始吧。

首先,你需要创建一个链表:

class LinkedList{
  constructor(){
    this._head = null;
    this._tail = null;
    this._length = 0;
  }
  size(){
    return this._length;
  }
}

为了跟踪比赛的起点和终点,需要创建 head 和 tail 属性。

然后,为了保证赛程不要太长或者太短,需要创建 length 属性和 size 方法。这样你就总是可以精确的掌握赛程的长度。

现在你有了一种存储数据的方法,你应该创建一个往列表添加数据的方法。问题是,你要添加什么数据呢?

记住,链表是一系列的节点,每一个节点都有一个值和指向列表中下一个节点的指针。了解了这一点,你会意识到节点就是一个存储有 value 和 next 两个属性的对象。

由于每一次往列表添加数据都需要创建一个新的节点,你决定创建一个构造函数,这样每次往列表添加数据时创建节点会更简单一些。

class Node{
  constructor(value){
    this.value = value;
    this.next = null;
  }
}

有了构造函数,就可以创建 add 方法。

class Node {
  constructor(value) {
    this.value = value;
    this.next = null;
  }
}

class LinkedList {
 
  constructor() {
    this._head = null;
    this._tail = null;
    this._length = 0;
  }
  
  add(value) {
    let node = new Node(value);         //we create our node
    if(!this._head && !this._tail){     //If it's the first node
      this._head = node;                //1st node is head & tail
      this._tail = node;
    }else{
    this._tail.next = node;             //add node to the back
    this._tail = this._tail.next;       //reset tail to last node
    }
    this._length++;
  }
  
  size() {
    return this._length;
  }
}

const AmazingRace = new LinkedList();
AmazingRace.add("Colombo, Sri Lanka");
AmazingRace.add("Lagos, Nigeria");
AmazingRace.add("Surat, India");
AmazingRace.add("Suzhou, China");

现在你已经添加了这个方法,你可以往 “AmazingRace” 列表中添加一堆比赛地点。看起来像这样。注意为了更容易理解我添加了一些额外的空格。

{ _head: 
   { value: 'Colombo, Sri Lanka',
     next: { value: 'Lagos, Nigeria', 
             next: { value: 'Surat, India',
                     next: { value: 'Suzhou, China',
                             next: null 
                           }
                   }
           } 
   },
  _tail: { value: 'Suzhou, China', next: null },
  _length: 4 
}

好啦,现在已经创建了列表以及添加内容的方法,你意识到往列表中添加地点还需要一些方法。

你决定将这个链表共享给合作者,Kent,要求他往里面添加更多的比赛地点。唯一的问题是,当你将列表给他时,没有告诉他你已经添加了哪些地点。不幸的是你也忘记了。

当然他可以通过运行 console.log(AmazingRace) 然后逐一检查其输出。但是 Kent 是一个懒人,他需要一种方式来确认某些值是否已经存在列表中,从而避免重复添加。考虑到这一点,你创建了 contains 方法来检查是否包含某些值。

class Node {
  constructor(value) {
    this.value = value;
    this.next = null;
  }
}
class LinkedList {
 
  constructor() {
    this._head = null;
    this._tail = null;
    this._length = 0;
  }
  
  add(value) {
    let node = new Node(value);         
    if(!this._head && !this._tail){     
      this._head = node;                
      this._tail = this._head;
    }else{
    this._tail.next = node;             
    this._tail = this._tail.next;       
    }
    this._length++;
  }
  
  contains(value){
    let node = this._head;
    while(node){
      if(node.value === value){
        return true;
      }
      node = node.next;
    }
    return false;
  }
  
  size() {
    return this._length;
  }
  
}

const AmazingRace = new LinkedList();
AmazingRace.add("Colombo, Sri Lanka");
AmazingRace.add("Lagos, Nigeria");
AmazingRace.add("Surat, India");
AmazingRace.add("Suzhou, China");
//Kent's check
AmazingRace.contains('Suzhou, China'); //true
AmazingRace.contains('Hanoi, Vietnam'); //false
AmazingRace.add('Hanoi, Vietnam');
AmazingRace.contains('Seattle, Washington'); //false
AmazingRace.add('Seattle, Washington');
AmazingRace.contains('North Pole'); // false
AmazingRace.add('North Pole');

很棒,现在 Kent 为了避免重复,他可以在添加之前检查一下是否已经存在。

另一方面,你可能会想为什么不在添加之前进行检查从而避免重复呢?当你在实现一个链表—或者任意的数据结构时— 理论上你可以添加任何额外你想要的功能。

你甚至可以改变已有数据结构中原来的方法。在 REPL 中试一下以下代码:

Array.prototype.push = () => {
 return 'cat';
}
let arr = [];
arr.push('eggs'); // returns 'cat';

我们没有做这些事情的原因是因为约定的标准。基本上,开发者对特定的方法应该如何工作是有预期的。

尽管我们的链表不是 JavaScript 的原生库,我们在实现它时拥有更多的自由,但是这些数据结构如何运作我们仍有基本的期望。链表本身并不保证存储的值唯一。但是它们确实可以提供 contains 这样的方法允许我们进行预检,从而维护我们列表中的值不重复。

Kent 将他修改后的列表再发给你,但是其中一些可能有问题。比如北极可能不是“极速前进”最好的终点。

所以你决定创建一个可以移除某些节点的方法。一定要记住的是,一旦你删除了某一个节点,你就打断了列表,你需要将被删除节点的前后两个节点重新连接起来。

class Node {
  constructor(value) {
    this.value = value;
    this.next = null;
  }
}
class LinkedList {
 
  constructor() {
    this._head = null;
    this._tail = null;
    this._length = 0;
  }
  
  add(value) {
    let node = new Node(value);         
    if(!this._head && !this._tail){     
      this._head = node;                
      this._tail = this._head;
    }else{
    this._tail.next = node;             
    this._tail = this._tail.next;       
    }
    this._length++;
  }
  
  remove(value) {
    if(this.contains(value)){          // see if our value exists
      let current = this._head;           // begin at start of list
      let previous = this._head;
        while(current){                   // check each node
          if(current.value === value){
            if(this._head === current){   // if it's the head
              this._head = this._head.next;  // reset the head
              this._length--;              // update the length
              return;                      // break out of the loop
            }
            if(this._tail === current){   // if it's the tail node
              this._tail = previous;       // make sure to reset it
            }
            previous.next = current.next;  // unlink (see img below)
            this._length--;            // update the length
            return;                    // break out of 
          }
          previous = current;          // look at the next node
          current = current.next;      // ^^
        }
     }  
  }  
  
  contains(value){
    let node = this._head;
    while(node){
      if(node.value === value){
        return true;
      }
      node = node.next;
    }
    return false;
  }
  
  size() {
    return this._length;
  }  
}
const AmazingRace = new LinkedList();
AmazingRace.add("Colombo, Sri Lanka");
AmazingRace.add("Lagos, Nigeria");
AmazingRace.add("Surat, India");
AmazingRace.add("Suzhou, China");
AmazingRace.add('Hanoi, Vietnam');
AmazingRace.add('Seattle, Washington');
AmazingRace.add('North Pole');
//Kent's check
AmazingRace.remove('North Pole');

remove 方法的代码比较长。本质上它按照如下步骤运行:

1、检查待删除的值是否存在。。。

2、遍历列表,并且需要跟踪当前节点和上一个节点

3、然后,如果当前节点就是要删除的点—>

4A、当前节点是链表的头

  • 将链表的头重置为列表中的下一个节点
  • 更新链表长度
  • 跳出循环
    4B、当前节点是链表的尾
  • 将链表的尾设置为链表中的上一个节点
  • 按照下图的方式重新连接节点

  • 4C、如果不是要删除的节点—>继续迭代

  • 将下一个节点设置为当前节点
  • 将当前节点设置为上一个节点

最后要说说明的一件事:你可能已经注意到你并没有真正的删除那个节点。你只是移除了对它的引用。对,这样就可以了,因为一旦对一个对象的所有引用都被移除以后,垃圾回收器会帮助我们将它从内存中移除的。更多关于垃圾回收的信息参考这里

有了你刚才实现的 remove 方法,你只需下面这一行就可以保证参赛者不会冻死、或者打扰在正在准备新年庆典的圣诞老人。

AmazingRace.remove('North Pole');

好了,你已经完成了一个单链表的简单实现。你可通过添加元素来扩充列表,也可以通过移除元素来压缩列表。

如果你想在链表的首部、尾部或者任意节点后插入元素。你需要自己实现这些方法。这些方法的名称和参数应该类似这样:

addHead(value) {
}
insertAfter(target, value){
}

时间复杂度分析

再来看一下完整的代码

class LinkedList {
 
  constructor() {
    this._head = null;
    this._tail = null;
    this._length = 0;
  }
  
  add(value) {
    let node = new Node(value);         
    if(!this._head && !this._tail){     
      this._head = node;                
      this._tail = this._head;
    }else{
    this._tail.next = node;             
    this._tail = this._tail.next;       
    }
    this._length++;
  }
  
  remove(value) {
    if(this.contains(value)){          
      let current = this._head;        
      let previous = this._head;
        while(current){         
          if(current.value === value){
            if(this._head === current){ 
              this._head = this._head.next;
              this._length--;              
              return;                      
            }
            if(this._tail === current){ 
              this._tail = previous;    
            }
            previous.next = current.next;
            this._length--;            
            return;                    
          }
          previous = current;          
          current = current.next;      
        }
     }  
  }  
 
  contains(value){
    let node = this._head;
    while(node){
      if(node.value === value){
        return true;
      }
      node = node.next;
    }
    return false;
  }
  
  size() {
    return this._length;
  }

// To Be Implemented
addHead(value) {
}
insertAfter(target, value){
}

Add 的复杂度是O(1):因为有 tail 属性跟踪队列的尾部,所以你不必遍历整个列表。

Remove 的复杂度是 O(n): 最坏的情况下,你需要遍历完整个列表才能找到你需要移除的节点。尽管真正的移除节点的操作是 O(1)(因为你只需要重置一下指针就可以)。

Contains 的复杂度是 O(n):为了确认列表中是否包含指定的值,你必须遍历整个列表。

addHead 的复杂度是 O(1):和我们的 add 方法相似,我们总是知道列表的头部,所以不需要遍历。

insertAfter 的复杂度是 O(n):和上面讨论的 Remove 方法一样,你需要遍历整个列表找到你需要插入的位置。同样的,实际的插入操作复杂度为 O(1) 。

链表 VS 数组

为什么要使用链表而不是数组?技术上数组可以运行所有链表操作,如 添加、插入、删除。此外, JavaScript 中数组已经具备这些方法。

最大的差别来自插入和删除。因为数组是基于索引,当你在数组的中间插入或者删除元素时,你需要将其后面所有的元素重置到新的索引位置。

想象一下,在一个有 100,000 个元素的头部或者中间插入一个元素!像这样的插入和删除操作是非常耗费资源的。因为这一点,链表通常用于常常被移动的大型数据集。

另一方面,数组在查找方面非常出色,因为它是基于索引的。如果你知道元素的位置,你可以通过 array[position] 在 O(1) 时间内查找到该元素。

链表通常需要顺序遍历列表。基于这一原因,数组常常用于小一些的数据集或者不常移动的数据集。

小节

链表:

  • 1、有 tail 和 head 属性用于跟踪链表的两端
  • 2、有 add, addHead,insertAfter 和 remove 方法用于管理列表内容
  • 3、有 length 属性用过跟踪列表长度

本文译自 :A Gentle Introduction to Data Structures: How Linked Lists Work

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

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,639评论 18 139
  • 链表(Linked-list) 前面我们讨论了如何使用栈、队列进行存数数据,他们其实都是列表的一种,底层存储的数据...
    Cryptic阅读 38,786评论 7 57
  • 1 序 2016年6月25日夜,帝都,天下着大雨,拖着行李箱和同学在校门口照了最后一张合照,搬离寝室打车去了提前租...
    RichardJieChen阅读 5,085评论 0 12
  • 有时候人的眼睛,看世间、看万物、看他人,就是看不到自己; 能看到别人过失,却看不到自己的缺点; 能看到别人的贪婪,...
    医成道人阅读 217评论 0 0
  • 券商:三年不开张,开张吃三年 券商最突出的特征就是与市场行情高度相关,熊市表现一般,牛市则一鸣惊人,正所谓三年不开...
    鹿西西的仙人阅读 22,000评论 1 7