【翻译】链表 | 掘金技术征文-双节特别篇

671 阅读11分钟

第五章 链表

书籍出处: 《Learning JavaScript Data Structures and Algorithm》

作者: Loiane Groner

在第二章,我们已经学习了数组。数组是用来存储一系列数组的简单数据结构。在这一章,我们将学习如何建构和使用一个链表。链表是一个动态的数据结构,这意味着我们可以随意对链表增加元素和删除元素。

数组是用来存储一系列成员最常见的数据结构。如之前所说,每种编程语言都有内置的数组结构。数组方便好用,还为我们提供了简单的语法 [ ] 来访问成员。 但是,数组结构也是有缺陷的——数组的长度大小是固定的(在大多数语言中),并且在数组的顶部或者中间进行插入和删除是很消耗电脑性能的,因为相关元素会被推出(虽然JavaScript的数组大多数方法已经帮我们处理了这些问题,但这些问题在这一场景下还是会发生的)。

链表用于存储一系列成员,但它在内存上与数组不同,成员们并不是放在临近的内存地址上的。每一个链表成员都是由一个保存自身的成员和一个指向下一个一个节点的节点组成的。下面这张图展示了链表的原理:

链表的一个优点是,在添加和删除成员时,我们不再需要将元素推出了。但是,我们在使用链表时要用到指针,因此,在建立链表建构时我们需要投入额外的注意力。另一个不同点是,数组可以直接访问任何位置的数据;而在链表中,如果我们想访问链表中间的一个数据,我们需要从链表的head节点开始遍历,直到找到想要的成员为止。

在现实生活中,也有链表的例子。第一个例子是康佳舞(一种人们手放在前面的人的背上、随着音乐前进的集体舞蹈)。在跳康佳舞时,每个人相当于一个节点,他们的手相当于指向下一个节点的指针。你可以让另一个人加入康佳舞——你只要找到你想要把新人放入的位置,然后把后一个人的手放在新人的背上,再把新人的手放在前一个人的背上即可。

另一个例子是寻宝游戏。在寻宝游戏中,你会有一个前往下一个地点线索,而到了下一个地点,又会有前往下下个地点的线索。在这个游戏中,如果你想到的特定的寻宝地点,就只能从游戏的起点,顺着线索一个一个的往前走了。

我们还有另外一个例子,也是现实生活中最常见的链表的例子——列车。列车是由一些列的车厢和一个车头组成的。每个列车之间都是用链条连着着的。我们可以解开两个车厢,改变它们的位置,移开车厢,或者添加新的车厢。下图展示了一个列车,每一个车厢都是链表的节点,而讲车厢间互相连接的就是指针: 在这一章,我们将会讲到如何使单向用链表和双向链表。当然,还是让我从简单的单项链表开始吧。

构造一个链表

让我们一起来构造一个链表吧:

function LinkedList() {
	var Node = function(element){ // {1}
		this.element = element;
		this.next = null;
};

    var length = 0; // {2}
    var head = null; // {3}

    this.append = function( element) { };
    this.insert = function( position, element) { };
    this.removeAt = function( position ) { };
    this.remove = function( position ) { };
    this.indexOf = function(element) { };
    this.size = function() { };
    this.toString = function() { };
    this.print = function() { };
}

为了构建一个链表结构,我们需要一个叫帮手函数***(行 {1})***。Node函数代表着我们想要加入到链表的元素。Node存着两个变量:一个是存着值的变量,另一个是帮我们指向下一个链表的next变量。

我们还可以用长度属性***(行 {2})***作为内部属性,来帮助我们记录链表成员的个数。

另一个需要注意的地方是,我们需要给链表的第一个成员添加索引。我们可以通过设置head变量来达到这一个目的***(行 {3})***. 之后我们就要设置链表结构到方法了。在我完成每一个方法前,先看看每个方法的作用是什么吧:

  • append(element): 它会把新成员添加到链表的尾部。
  • insert(element):它会把新的成员添加到我们指定的位置。
  • remove(element):它会从链表删除一个成员。
  • indexOf(element):它会返回成员在链表的索引。如果链表中没有这个成员,它会返回-1。
  • removeAt(element):它会把指定位置的链表成员删除了。
  • isEmpty( ):如果链表有成员,它会返回false;反之,则返回true。
  • size( ):它会返回链表内部有多少个元素。它的功能和数组的length类似。
  • toString( ):因为链表使用了Node类,我们需要改写继承自Object对象的toString方法,以输出成员的值。

在链表的尾部添加成员

在添加新成员到链表时,有两种场景:1.链表为空,此时我们是为链表添加第一个成员;2.链表不为空,此时是在链表的尾部添加成员。

现在,我们开始构建append方法:

this.append = function( element ){
	var node = new Node(element), // {1}
	cuerrent; // {2}

	if (head === null){ // 链表的第一个成员  // {3}
		head = node;
    } else {
        current = head; // {4}
        //循环直至最后一个成员
        while( current.next ) {
            current = current.next;
		}
		// 得到链表的最后一个节点,再将最后一个节点的指针指向新的成员
		current.next = node; // {5}
    }
    length++;  // 更新链表的长度 // {6}
}

我们要做的第一件事是生成Node并为它赋值***(行 {1})***。

让我来实现第一个场景:当链表为空时,为它添加第一个成员。当我们生成一个链表对象后,head的指向为null:

如果head为空***(行 {3})***,这意味着我们要为这个链表添加第一个成员。所以,我们需要做的是让head成员的next指针指向node成员。而下一个node成员默认为空。

Note

链表中最后一个成员的next指针永远为空。

我们已经讲完第一个场景了,我们现在来看看第二个,即在非空链表的尾部添加成员。

在添加新成员前,我们需要找到链表的最后一个成员。记住,我们只有第一个成员的索引***(行 {4}),所以我们要遍历这个链表直至找到最后一个成员。为了达到这一目的,我们需要一个current变量来指向当前成员(行 {2})***。在遍历链表成员时,当得到指向为null的next指针时,我们就得到链表的最后一个成员。之后,我们要将最后一个成员的next指针指向我们要添加的成员。下图展示了这个过程: 并且,我们新生成的node节点,其next指针的指向也为null。这样,我们就得到新的最后一个节点了。

当然,我们不能忘记增加链表的长度,这样我们就可以更好的控制链表并简单地获得链表的长度。

我们可以用以下代码来检验和测试我们生成的链表结构:

var list = new LinkedList();
list.append(15);
list.append(10);

从链表删除成员

现在,让我们看看如何从链表结构中删除成员。删除元素时也有两个场景: 第一个场景是删除链表的第一个成员;第二个场景是删除链表除第一个成员外的任何一个成员。我们将实现两个remove方法:第一个是基于链表成员的下标删除成员;第二个是基于链表的值删除成员(我们会在之后讲第二个remove方法)。

以下为基于成员的位置来删除成员的方法:

this.removeAt = function( position ){ 
	if (position > -1 && position < length){ // {1}
		var current = head, // {2}
			previous, // {3}
			index = 0, // {4}

		//删除第一个成员
		if (position === 0) { // {5}
			head = current.next
		} else {
			while ( index++ > position) { // {6}
              previous = current; // {7}
              current = current.next; // {8}
			}
			previous.next = current.next; // {}9
		}
			length--;  // {10}

			return current.element;
	} else {
		return null; // {11}
}
};

我们将一步步深入这个代码。因为这个方法要接收要被删除成员的下标,所以我们首先要检验这个下标是否合理***(行 {1})***。合理的下标的取值,为0(包括0)到链表的长度(size – 1,因为下标是从0开始的)。如果输入的下标是不合理的,函数会返回null(这意味着链表中没有要删除的那个成员)。

让我们为第一个场景写代码:我们想要删除链表的第一个成员***(行 {5})***。下图展示了这个过程:

所以,如果我们想要删除第一个成员,我们想要做的是让head指针指向链表的第二个成员。我们需要一个current变量,来指向链表的第一个成员(行 {2}——我们也会用它来遍历链表)。所以,current变量就是链表第一个成员的索引。如果我们把current.next的值赋到head上,那么我们就删除了链表的第一个成员了。

现在,假设我们想从链表的末尾或中间删除一个成员。如果要达到这个效果,我们需要遍历这个链表,直到到达我们想要的位置为止(行 {6}——我们将用index变量进行内部技术和控制)。current变量会一直指向循环中的当前成员***(行 {8})。当然,我们也需要一个previous变量来指向循环中的前一个成员(行 {7})***。

因此,只要将previous.next指向current.next(行 {9}),就可以删除当前元素了。通过这个方法,当前成员将会失去在计算机内存的位置,而它也将会被垃圾收集机制给清理掉。

通过下图,让我们更好地理解这个过程吧: 在删除最后一个成员的例子,循环会一直循环到链表的最后一个成员,current会指向链表的最后一个成员。此时current.next的值为null。我们也要用previous来记录前一个元素,previous.next指向的是current变量。所以为了删除current成员,我们只要将 previous.next指向 current.next 即可。

现在,让我们看看相同的逻辑在删除链表中间的成员时是否适用: current变量所指向的是我们想删除的成员。previous成员指向的是要删除的成员的前一个成员。所以,要删除current成员,只要previous.next指向 current.next 即可。所以,这个逻辑对这两个场景都适用。

在链表的任意位置插入成员

接下来,我们要实现的是insert方法。这个方法可以让我们在任意位置插入一个成员。让我们看看如何实现这个方法:

this.insert = function( position, element) {
	// 检查超出边界的值
	if (position >= 0 && position <= length) { // {1}
		
		var node = new Node(element),
			current = head,
			previous,
			index = 0;
		if (position === 0) { //在第一位置添加成员

			node.next = current; // {2}
			head = node;
        } else {
          while (index++ < position) {
              previous = current;
              current = current.next;
          }
          node.next = current; // {4}
          previous.next = node; // {5}
        }

		length++; //更新链表的大小

		return true;
    }else {
        return false;
    }
}

正如remove函数要检测成员下标,insert也要把不在范围内的情况排除掉***(行 {1})***.

接下来,我们处理不同的场景。第一个场景是,我们要在链表的head部分添加一个成员,也就是第一个位置。下面的图示解释了这个过程: 我们用current 变量来指向链表的第一个成员。我们需要做的是,将新成员的next指针指向current。现在head变量和node.next指针都指向了current变量。这时,我们需要将head变量的指向改为node节点***(行 {2})***,这样我们就在链表中加入了一个新的成员。

现在,让我来处理第二个场景:在链表的中间或者尾部添加一个成员。首先,我们要遍历这个链表,直到我们得到了相应位置的成员***(行 {3})。当我们推出循环时,current变量会指向的是我们要插入新成员的后面的成员,previous变量会指向我们要插入的新成员的前面的成员。这个情况中,我们现在previous变量和current变量中加入一个新的成员(行 {4}),这时我们要改变current变量和previous变量的next指针的指向。我们要将previous.next指向要加入的节点(行 {5})***。

让我们看看这几行代码的图示吧: 如果我们是要添加一个成员到链表的最后,previous变量将会指向链表的最后一个成员,而current变量的指向为null。在这个情况下,node.next 会指向current变量,previous.next会指向 要加入的node,这样,我们就在链表的尾部加入了一个新的成员。

下图展示了如何在链表的中间加入一个新的成员: 在这个情况下,我们要在current变量和previous变量之间插入一个新的成员。首先,我们要让新成员node的next指针指向current。之后,再让previous变量的next指针指向新成员node。这样,我们就在链表的中间加入了一个新的成员。

实现其他方法

在这个部分,我们将学习如何实现链表的 toString、indexOf、isEmtpy 和 size等方法。

toString 方法

toString方法将链表对象转化为字符串。下面是实现这个方法等代码:

this.toString = function ( ){
	var current = head; // {1}
	var string = ‘’; // {2}

	while (current){
		stirng += current.element; // {4}
		current = current.next; // {5}
    }
    return string; // {6}
}

首先,为了遍历链表所有的成员,我们需要用指向head的current变量来遍历整个链表***(行 {1})***。我们还需要初始化用来存储所有值的string变量。

之后,我们遍遍历这个列表***(行 {3})。我们将使用current变量来检验当前元素是否有值。之后,我们会得到成员的值,并将之添加到string变量上(行 {4})。之后,我们会遍历下一个成员(行 {5})***。

indexOf 方法

接下来要实现的是indexOf方法。IndexOf方法会接收一个值,并返回这个值在链表的位置。如果这个值不在链表中,它会返回-1。

让我们看看实现这一个功能的代码:

this.indexOf = function( element ){
	var current = head; // {1}
	var index = -1;
	while ( current) { // {2}
		if (element === current.element) {
			return index; // {3}
	}
	index++; // {4}
	current = current.next; // {5}
	}
	return -1;
}

和前面的方法一样,我们需要一个current变量帮助我们来遍历所有的成员***(行 {1})。之后,我们会开始遍历(行 {2}),并比对每个节点的值是否与要查找的值相等。如果element等于current.element,则会返回相应的下标(行 {3});反之,则给index加一,然后进入下一个成员(行 {5})***。

这个循环会在链表为空,或者到达链表的末尾时(current = current.next的值为null),停止执行。如果我们没有找到相应的值,函数会返回-1。

有了indexOf方法,我们可以构建remove等其他方法:

this.remove = function (element) {
	var index = this.indexOf(element);
	return this.removeAt( index) ;
}

我们有了一个基于成员下标进行删除的removeAt方法。现在我们已经有了indexOf方法,如果我们输入一个值,可以得到它的下标,再把这个值传入removeAt方法,这样就得到了一个基于值删除成员的remove方法了。

isEmpty、size、getHead方法

isEmpty 和 size方法和之前章节的方法是一样的,但我们还是看看代码实现吧:

this.isEmpty = function () {
	return length === 0;
}

当链表没有成员时,isEmtpy函数会返回true;反之,会返回false。

This.size = function (){
	return length;
}

size方法会返回链表的长度。

最后,让我们看看getHead方法:

this.getHead = function ( ){
	return head;
}

head变量是链表结构的私有变量(这意味着它只能被链表的实例访问,链表外部是无法访问的)。但如果我们需要在链表外部遍历整个链表,我们需要一个方法来提高链表的第一个成员,而这个方法就是getHead了。

双向链表

这一节,为大家介绍双向链表。双向链表与普通链表的不同在于,普通链表只有指向下一个成员的next指针,而双向链表在此基础上还有指向前一个成员的prev指针,如下图所示: 让我们开始构建一个双向链表吧:

function DoublyLinkedList ( ) {
	var Node = function ( element ) {
		this.element = element;
		this.next = null;
		this.prev = null; // NEW
	};

    var length = 0;
    var head = null;
    var tail =null; // NEW 

        // 方法写在这里
}

如代码所示,在双向链表中与普通链表不同的代码,都被NEW标记了。在Node类中我们添加了prev(一个新的指针)属性,在双向链表内部我们用tail来记录链表的最后一个成员。

双向链表为我们提供了两种遍历链表的方法:从链表的head开始,和链表的tail开始。我们也可以访问链表特定成员的前一个成员,或后一个成员。而在单向链表中,当你在遍历链表时忽漏了某个成员时,你得从头开始重新遍历。这也是双向链表的一个优势。

在任意位置插入一个新成员

在双向链表插入成员和在单向链表插入成员是很相似的。两者的不同在于,单向链表只用控制一个next指针,而双向链表需要控制 next指针和prev 指针。

以下为实现双向链表insert功能的算法:

this.insert = function (position , element) {
	// 检测下标是否在有效范围内
	if(position>=0 && position <= length){
	
          var node = new Node(element),
              current = head,
              previous,
              index = 0;

          if (position === 0) { // 加在第一个位置

              if (!head){		// NEW {1}
                  head = node;
                  tail = node;
              }else{
                  node.next = current;
                  current.prev= node;  // NEW {2}
                  head = node;
              }
          }else if (position === length){ //在到最后 //NEW

              current = tail;  // {3}
              current.next = node;
              node.prev = current;
              tail = node;
          }else {
            while (index++ < position) { // {4}
                previous = current;
               current = current.next;
            }
            node.next = current; // {5}
            previous.next = node;

            current.prev = node; // NEW
            node.prev = previous; // NEW
        }
          length++; //升级大小
          return true;
 	 }else{
      	  return false;
  }
};

现在我们来分析第二个场景,我们想把新的成员加入到链表的末尾。因为我们有指向链表末尾的指针prev,所以有些技术细节需要处理。此时,current变量会指向链表的最后一个成员***(行 {3})***。之后,我们要搭建第一个链接:将node.prev指向current变量。再将current.next(原先指向null)指针指向node(基于构造函数的定义,node.next会指向null)。最后需要做的事,便是让tail变量指向node,而非指向current。

下图展示了这个过程: 之后,是第三个场景:在链表中插入一个成员。就像我们之前做的那样,我们要遍历这个链表,直到我们到达我们想要的位置为止***(行 {4})***。我们将在current变量和previous变量之前插入一个对象。首先,node.next 将会指向 current变量,previous.next将会指向node,这样就建立起了链表间的链接。之后,我们需要配置所有的链接:current.prev 将指向node,node.prev将指向previous。下图展示了这个过程: Tip

我们可以对insert和remove方法做一些改进。如果position参数为负数,我们可以将这个成员放在链表的尾部。还有一个方法可以提升链表的性能:如果position参数比链表长度的一半还大,那么从链表的尾部开始遍历链表要比从链表的头部开始要更好(因为这样要遍历的成员就变少了)。

从任意位置删除一个新成员

在删除成员上,双向链表和单向链表也是非常像的。唯一的不同在于,我们要配置previous指针。让我们看看这个方法的实现吧:

this.removeAt = function (position){
	
		// 查看position是否在合法范围内
	if (position > -1 && position < length) {
		
		var current = head,
			previous,
			index = 0;
	// 删除第一个成员
	if(positon === 0){
		
		head = current.next; // {1}

		// 如果链表只有一个成员,则升级tail //NEW
		if (length === 1) { // {2}
			tail = null;
		} else {
			head.prev = null // {3}
		}
	} else if (positon === lengt -1 ){  // 最后一个成员 //NEW
	
        current = tail; // {4}
        tail = current.prev;
        tail.next = null’
    }else{
        while (index++ < positon) { // {5}
          previous = current;
          current = current.next;
        }
        previous.next = current.next; // {6}
        current.next.prev = previous; //NEW
	}

      length --;
      return current.element;
    } else {
        return null;
    }
};

我们需要处理三种情形:从链表的头部删除、从链表的尾部删除、从链表的中间删除。

让我们看看如何删除第一个成员。current变量指向的是链表的第一个成员,也就是第一个场景下要删除的成员。我们只要只有一个,就是改变head变量的指向:不再指向current,而是指向current.next***(行 {1})。但我们还要升级current.next的previous指针(因为第一个成员的prev指向为null)。所以,我们要把head.prev的指向变为null(行 {3})。我们还需要控制tail变量的指向:如果我们要删除的变量为第一个,我们还需要将tail变量设为null(行 {2})***。

下图展示了删除链表第一个成员的过程:

第二个场景是从链表的末尾删除成员。因为我们已经有了指向最后一个成员的tail变量,我们就不用在从头遍历了。所以我们可以将tail赋值到current变量上***(行 {4})***。之后,我们要更新tail的指向为链表的倒数第二个成员(current.prev 或者 tail.prev)。当tail的指向为链表的倒数第二个成员后,我们要做的就是更改next指针的指向为null(tail.next = null)。下图展示了这个过程:

接下来是第三个场景,从链表之中删除一个成员。首先,我们要遍历链表,直至达到要删除的位置***(行 {5})***。此时current指向的变量就是我们想要删除的变量。所以,只要通过改变previous.next 和 current.next.prev来跳过current变量,就可以删除它了。因此,previous.next会指向current.next,而current.next.prev会指向current.previous,如下图所示:

Note

如果想要了解双向链表的其他方法,可以从本书提供的网址下载相关的源代码。

环形链表

环形链表可以以单向链表为基础,或者以双向链表为基础。这两种配置方式的不同在于,在单向链表中,链表最后一个成员的next指针不是null,而是head变量,如下图所示: 而双向链表的tail.next会指向head变量,而head.prev会指向tail:

注:本文翻译自Loiane Groner的《Learning JavaScript Data Structures and Algorithm》

🏆 掘金技术征文|双节特别篇