JavaScript数据结构——链表

1,063 阅读5分钟

前言

嘿,掘友们!今天我们来了解并实现数据结构 —— 链表。

本文主要内容

  • 单向链表
  • 双向链表
  • 循环链表

链表

要存储多个元素,数组可能是最常用的数据结构。但是这种数据结构有一个缺点,数组的大小是固定的,从数组的起点或中间插入或移除的成本很高,因为需要移动元素。

链表存储有序的元素集合,不同于数组的是,链表中的元素在内存中并不是连续放置的。每个元素由一个存储元素本身的节点和一个指向下一个元素的引用(指针或链接)组成。下图是一个链表的结构。

3461650465188_.pic.jpg

相比数组,链表的一个好处在于,添加或移除元素的时候不需要移动其他元素。然而,链表需要使用指针。在数组中,我们可以直接访问任何位置的任何元素,而想要访问链表中间的一个元素,则需要从起点开始迭代链表知道找到所需要的元素。

在实现链表之前,我们先声明和定义属性和方法。

  • 属性
    • count 存储链表中的元素数量
    • head 头指针
    • equalsFn 比较元素是否相等
  • 方法
    • push(element) 向链表尾部添加一个新元素
    • insert(element, index) 向链表的特定位置插入一个新元素
    • getElementAt(index) 返回链表中的特定位置的元素,如果不存在返回undefined
    • remove(element) 从链表中移除一个元素
    • removeAt(index) 从链表的特定位置移除一个元素
    • indexOf(index) 返回元素在链表中的索引,如果链表中没有该元素,返回-1
    • isEmpty() 如果链表中不包含任何元素,返回true,否则返回false
    • size() 返回链表包含的元素个数
    • print() 返回表示整个链表的字符串

定义defaultEquals函数,作为默认的相等性比较函数。

function defaultEquals(a, b) {
  return a === b
}

单向链表

要表示链表中的元素,我们需要一个助手类,叫做 Node。Node类表示我们想要添加到链表中的项。

class Node {
  constructor(element) {
    this.element = element	// 元素的值
    this.next = undefined	// 下一个元素的指针
  }
}

创建 LinkedList 类的“骨架”

class LinkedList {
  constructor() {
    this.count = 0
    this.head = undefined
  }
}

1. 向链表尾部添加元素

在尾部添加元素可能有两种场景:链表为空,添加的是第一个元素;链表不为空,向其尾部添加元素

class LinkedList {
  constructor() { ... }
  push(element) {
    const node = new Node(element)	// 创建Node项
    if(this.head == null) {
      this.head = node
    } else {
      let current = this.head		// 指向链表的current变量
      while (current.next != null) {
        current = current.next
      }
      current.next = node
    }
    this.count++
  }
}

首先,把 element作为值传入,创建Node项

先实现第一个场景,向空链表添加一个元素,当创建一个 LinkedList 对象时,head会指向 undefined

如果 head 元素为 undefinednull,就意味着在向链表添加第一个元素。因此要做的就是让 head 指向 node 元素。

再来看第二个场景,向一个不为空的链表尾部添加元素。

要在链表的尾部添加元素,就要找到最后一个元素。但我们只有第一个元素的引用,需要循环访问链表,直到最后一项。当 current.next 元素为 undefinednull 时,我们就知道到达链表尾部了。然后让当前元素的 next 指向 node 元素。

this.head == null 相当于 this.head === undefined || this.head === null

current.next !=null 相当于 current.next !== undefined || current.next !== null

2. 循环迭代链表找到目标

循环到目标 index 的代码片段在 LinkedList 类的方法中很常见。将这部分逻辑独立为单独的方法,这样就能在不同的地方复用它。

class LinkedList {
  constructor() { ... }
  push(element) { ... }
  getElementAt(index) {
    if(index >= 0 && index < this.count) {
      let node = this.head
      for(let i = 0; i < index && node != null; i++) {
        node = node.next
      }
      return node
    }
    return undefined
  }
}

为了确保我们能迭代链表知道找到一个合法的位置,需要对传入的 index 参数进行合法性验证。如果传入的位置不合法,返回 undefined,因为这个位置在链表中不存在。

然后,初始化 node 变量,该变量会从链表的第一个元素 head 开始,迭代整个链表知道目标 index,结束循环时,node 元素将是 index 位置元素的引用。

3. 从链表中移除元素

我们要实现两种移除元素的方法。第一种是从特定位置移除一个元素(removeAt),第二种是根据元素值移除元素(remove)。

我们先实现第一种移除元素的方法,要移除元素存在两种场景:第一种,移除第一个元素,第二种,移除第一个元素以外的元素。

removeAt(index) {
  if(index >= 0 && index < this.count) {
    if(index === 0) {
      this.head = this.head.next
    } else {
      const previous = this.getElement(index - 1)
      const current = previous.next
      previous.next = current.next
    }
    this.count-- 
    return current.element
  }
  return undefined
}

先看第一种场景:我们从链表中移除第一个元素。想移除第一个元素,让 head 指向链表的第二个元素就实现了。

再看第二种场景:移除除第一个元素以外的元素。我们获取要删除元素的前一个元素。current 引用要删除的元素。将前一个元素的 next 指向要删除元素的 next,就可以实现了。

移除最后一个元素也通用,previous 引用最后元素的前一个元素,最后一个元素的 next 指向 undefined,那么将 previous.next = undefined,就完成了最后一个元素的移除。

我们再来实现移除元素的第二种方法:根据元素值移除元素(remove)。

remove(element) {
  const index = this.getElement(element)
  return this.removeAt(index)
}

我们复用前面的两种方法 getElementremoveAt ,就可以实现。

先获取要删除元素的索引,再根据特定位置删除元素。

4. 在任意位置插入元素

insert(element, index) {
  if(index >= 0 && index < this.count) {
    const node = new Node(element)
    if(index === 0) {
      const current = this.head
      node.next = current
      this.head = node
    } else {
      const previous = this.getElementAt(index-1)
      const current = previous.next
      node.next = current
      previous.next = node
    }
    this.count++
    return true
  }
  return false
}

由于处理的是索引,就需要检查合法性。任意位置插入也有两种场景:在链表起点添加一个元素和在链表中间或尾部添加一个元素。

先看第一种场景,使用 current 变量引用链表中的第一个元素,将 node.next 指向 current(第一个元素),此时 headnode.next 都指向了 current。将 head 指向 node,这样链表就在头部添加了一个元素。

再看第二种场景,首先我们需要迭代链表,找到目标位置的前一个元素。current变量指向插入新元素的位置之后的元素。我们需要在 previouscurrent 之间添加新元素,因此,先把新元素和 current 连接,然后改变 previouscurrent 之间的连接,也就是让 previous.next = node,取代 current

5. 返回一个元素的位置

indexOf 方法接收一个元素的值,在链表中找到它,就返回元素的位置,否则返回 -1。

indexOf(element) {
  let current = this.head
  for(let i = 0; i < this.count && current != null; i++){
    if(this.equalsFn(element, current.element)) {
      return i
    }
    current = current.next
  }
  return -1
}

我们需要一个变量来帮助我们循环访问链表。迭代元素,从 head 开始,知道链表的长度为止。为了确保不会发生运行错误,可以验证下 current 变量是否为 nullundefined

在每次迭代时,验证 current 节点的元素和目标元素是否相等。如果当前位置的元素是我们要找的元素,返回它的位置。如果不是,就迭代下一个链表节点。

如果链表为空或迭代到链表的尾部,循环结束。如果没有找到目标,则返回 -1。

6. isEmpty、size 和 getHead 方法

size() {
  return this.count
}
isEmpty() {
  return this.size === 0
}
getHead() {
  return this.head
}

7. print 方法

print 方法会把 LinkedList 对象转换成一个字符串。

print() {
  if(this.head == null) return ''
  let string = `${this.head.element}`
  let current = this.head.next
  for(let i = 1; i < this.count && current != null; i++) {
    string = `${string}, ${current.element}`
    current = current.next
  }
  return string
}

首先,如果链表为空,我们就返回一个空字符串。这里也可以用 this.isEmpty() 来进行判断。

如果链表不为空,我们就用链表第一个元素的值来初始化字符串(string)。然后迭代链表的其他元素,将元素值添加到字符串上。如果链表只有一个元素, current != null将不会执行验证,因为current 变量的值为 undefinednull,算法不会向 string 添加其他值。

最后,返回链表内容的字符串。

完整代码

class LinkedList {
  constructor(equalsFn = defaultEquals) {
    this.count = 0
    this.head = undefined
    this.equalsFn = equalsFn
  }
  push(element) {
    const node = new Node(element)
    if(this.head == null) {
      this.head = node
    } else {
      let current = this.head
      while(current.next != null) {
        current = current.next
      }
      current.next = node
    }
    this.count++
  }
  removeAt(index) {
    if(index>=0 && index<this.count) {
      let current = this.head
      if(index === 0) {
        this.head = current.next
      } else {
        const previous = this.getElementAt(index - 1)
        current = previous.next
        previous.next = current.next
      }
      this.count--
      return current.element
    }
    return undefined
  }
  getElementAt(index) {
    if(index>=0 && index<this.count) {
      let node = this.head
      for(let i = 0; i < index; i++){
        node = node.next
      }
      return node
    }
    return undefined
  }
  insert(element, index) {
    if(index >= 0 && index < this.count) {
      const node = new Node(element)
      if(index === 0) {
        const current = this.head
        node.next = current
        this.head = node
      } else {
        const previous = this.getElementAt(index-1)
        const current = previous.next
        node.next = current
        previous.next = node
      }
      this.count++
      return true
    }
    return false
  }
  indexOf(element) {
    let current = this.head
    for(let i = 0; i < this.count && current != null; i++){
      if(this.equalsFn(element, current.element)) {
        return i
      }
      current = current.next
    }
    return -1
  }
  remove(element) {
    const index = this.indexOf(element)
    return this.removeAt(index)
  }
  size() {
    return this.count
  }
  isEmpty() {
    return this.size() === 0
  }
  getHead() {
    return this.head
  }
  print() {
    if(this.head == null) return ''
    let string = `${this.head.element}`
    let current = this.head.next
    for(let i = 1; i < this.count && current != null; i++) {
      string = `${string}, ${current.element}`
      current = current.next
    }
    return string
  }
}

双向链表

双向链表和单向链表的区别在于,单向链表是一个节点只有链向下一个节点的链接;在双向链表中,链接是双向的,一个链向下一个元素,另一个链向前一个元素。

3451650465128_.pic.jpg

要表示双向链表中的元素,我们对 Node 类添加一个属性 prevprev用来指向前一个元素

class DoublyNode extends Node {
  constructor(element, next, prev) {
    super(element, next)	// 继承 Node 的 element 和 next
    this.prev = prev	// 指向前一个元素
  }
}

创建 DoublyLinkedList 类的“骨架”

class DoublyLinkedList extends LinkedList {
  constructor(equalsFn = defaultEquals) {
    super(equalsFn)
    this.tail = undefined	// 指向最后一个元素
  }
}

DoublyLinkedList 类是一个特殊的 LinkedList 类,我们要扩展 LinkedList 类。这表示 DoublyLinkedList 类将继承(可访问) LinkedList 类中的所有属性和方法。

双向链表提供了两种迭代的方法:从头到尾,或者从尾到头。我们也可以访问一个特定节点的下一个或前一个元素。为了实现这种行为,还需要追踪每个节点的前一个节点。所以除了 Node 类中的 elementnext 属性,DoublyLinkedList 会使用一个特殊的节点,这个名为 DoublyNode 的节点有一个叫做 prev 的属性。DoublyNode 扩展了 Node 类,因此我们可以继承 elementnext 属性。由于使用了继承,我们需要在 DoublyNode 类的构造函数中调用 Node 的构造函数。

1. 在任意位置插入元素

向双向链表中插入一个新元素跟单向链表非常相似。区别在于,链表只需控制一个 next 指针,而双向链表则要同时控制 prevnext 这两个指针。所以,我们要重写 insert 方法,表示我们会使用一个和 LinkedList 类中的方法行为不同的方法

insert(element, index) {
  if(index >= 0 && index <= this.count) {
    const node = new DoublyNode(element)
    let current = this.head
    if(index === 0) {
      // 头部插入
      if(this.head == null) {
        this.head = node
        this.tail = node
      } else {
        node.next = this.head
        current.prev = node
        this.head = node
      }
    } else if (index == this.count) {
      // 尾部插入
      current = this.tail
      current.next = node
      node.prev = current
      this.tail = node
    } else {
      // 中间插入
      const previous = this.getElementAt(index - 1)
      current = previous.next
      node.next = current
      previous.next = node
      current.prev = node
      node.prev = previous
    }
    this.count++
    return true
  }
  return false
}

我们先分析第一种场景:在头部插入一个新元素。如果双向链表为空,只需要让 headtail 都指向 node 节点。如果不为空,current 变量将是对双向链表的第一个元素的引用。先将 node 和 头节点链接(node.next = this.head),然后让 head 指向 node。和单向链表不同的是,还需要为指向上一个元素的指针设一个值,将 current.prev 指针将由指向 undefined 变为 node。而 node.prev 指针已经是 undefined,因此无须更新。

下面看第二种场景:在尾部插入一个新元素。这是一个特殊情况,因为我们控制着指向最后一个元素的指针。首先,让 current 引用最后一个元素,然后建立链接,current.next 指向 nodenode.prev 引用 current。最后,更新 tail,将由指向 current 变为指向 node

还有第三种场景:在中间插入一个新元素。这里和之前的方法很相似,迭代双向链表,直到要找的位置。由于 getElement 方法是从 LinkedList 类中继承的,不需要重写。

我们将在 previouscurrent 之间插入新元素。首先,将 node.next 指向 currentprevious.next 指向 node。这样就不会丢失节点之间的链接。然后处理向前的链接,将 current.prev 指向 nodenode.prev 指向 previous

2. 从任意位置移除元素

双向链表移除元素和单向链表差不多,唯一的区别就是还需要设置前一个位置的指针。

 removeAt(index) {
   if(index >= 0 && index < this.count) {
     let current = this.head
     if(index === 0) {
       // 头部删除
       this.head = current.next
       if(this.count === 1) {
         this.tail = undefined
       } else {
         this.head.prev = undefined
       }
     } else if(index === this.count - 1) {
       // 尾部删除
       current = this.tail
       this.tail = current.prev
       this.tail.next = undefined
     } else {
       // 中间删除
       const previous = this.getElementAt(index - 1)
       current = previous.next
       previous.next = current.next
       current.next.prev = previous
     }
     this.count--
     return current.element
   }
   return undefined
 }

我们需要处理三种场景:头部删除、尾部删除和中间删除。

我们先看移除第一个元素。current 作为对第一个节点的引用。我们想要移除第一个元素,要做的就是改变 head 的引用。我们先让 head 指向下一个元素,如果双向链表的长度为 1,说明链表内为空,此时 head = current.next,而 current.next 就是 undefined,无须处理。将 tail 指向由 current 变为 undefined。如果不为空,此时 head 指向了下一个元素,但这个元素的 prev 还指向之前的元素,将 this.head.prevcurrent 变为 undefined

下一种场景是从最后一个位置移除元素。先让 current 引用最后一个元素,将 tail 指向前面的元素,但是前面的元素的 next 指向要删除的元素,将这个元素的 next 变为 undefined

第三种场景是从中间移除元素。首先需要迭代双向链表,知道要到的位置。current 变量所引用的就是要移除的元素。我们通过更新 previous.nextcurrent.next.prev 的引用,在双向链表中跳过他。因此 previous.next 指向 current.next,而 current.next.prev 指向 previous

3. 在链表尾部添加元素

双向链表在尾部添加元素和单向链表也很相似,不同的是,当链表不为空时,因为存在 tail 指向最后一个节点,可以直接使用 tail 引用最后一个元素,不需要像单向链表迭代到最后一个元素再处理。最后,要将 tail 指向新增的 node 节点

push(element) {
  const node = new DoublyNode(element)
  if(this.head == null) {
    this.head = node
  } else {
    let current = this.tail
    current.next = node
    node.prev = current
  }
  this.tail = node
  this.count++
}

4. 循环迭代链表直到目标位置

双向链表由两种迭代方式,从头到尾,或者从尾到头,我们重写 getElementAt 方法,根据查找的位置,优化查找速度。如果索引 <= 链表长度 - 索引,我们就从头开始迭代,如果索引 > 链表长度 - 索引,我们从尾向头迭代。

getElementAt(index) {
  if(index >= 0 && index <= this.count) {
    if(index <= this.count - index) {
      let node = this.head
      for (let i = 0; i < index && node != null; i++) {
        node = node.next
      }
      return node
    } else {
      let node = this.tail
      for(let i = this.count - 1; i > index && node != null; i--) {
        node = node.prev
      }
      return node
    }
  }
  return undefined
}

5. 获取尾部节点(新增)

因为双向链表提供 tail 节点,他指向链表的最后一个元素。我们新增一个 getTail 方法,来直接获取最后一个节点

getTail() {
  return this.tail
}

其他方法,直接继承 LinkedList 类中的方法。

完整代码

class DoublyLinkedList extends LinkedList {
  constructor(equalsFn = defaultEquals) {
    super(equalsFn)
    this.tail = undefined
  }
  insert(element, index) {
    if(index >= 0 && index <= this.count) {
      const node = new DoublyNode(element)
      let current = this.head
      if(index === 0) {
        // 头部插入
        if(this.head == null) {
          this.head = node
          this.tail = node
        } else {
          node.next = this.head
          current.prev = node
          this.head = node
        }
      } else if (index == this.count) {
        // 尾部插入
        current = this.tail
        current.next = node
        node.prev = current
        this.tail = node
      } else {
        // 中间插入
        const previous = this.getElementAt(index - 1)
        current = previous.next
        node.next = current
        previous.next = node
        current.prev = node
        node.prev = previous
      }
      this.count++
      return true
    }
    return false
  }
  removeAt(index) {
    if(index >= 0 && index < this.count) {
      let current = this.head
      if(index === 0) {
        // 头部删除
        this.head = current.next
        if(this.count === 1) {
          this.tail = undefined
        } else {
          this.head.prev = undefined
        }
      } else if(index === this.count - 1) {
        // 尾部删除
        current = this.tail
        this.tail = current.prev
        this.tail.next = undefined
      } else {
        // 中间删除
        const previous = this.getElementAt(index - 1)
        current = previous.next
        previous.next = current.next
        current.next.prev = previous
      }
      this.count--
      return current.element
    }
    return undefined
  }
  push(element) {
    const node = new DoublyNode(element)
    if(this.head == null) {
      this.head = node
    } else {
      let current = this.tail
      current.next = node
      node.prev = current
    }
    this.tail = node

    this.count++
  }
  getElementAt(index) {
    if(index >= 0 && index <= this.count) {
      if(index <= this.count - index) {
        let node = this.head
        for (let i = 0; i < index && node != null; i++) {
          node = node.next
        }
        return node
      } else {
        let node = this.tail
        for(let i = this.count - 1; i > index && node != null; i--) {
          node = node.prev
        }
        return node
      }
    }
    return undefined
  }
  // 新增
  getTail() {
    return this.tail
  }
  /**
   *  剩下的方法从父类继承
   *  size()
   *  isEmpty()
   *  getHead()
   *  print()
   *  remove(element)
   *  indexOf(element)
   */ 
}

循环链表

循环链表可以像链表一样只有单向引用,也可以像双向链表一样有双向引用。循环链表和链表之间的却别在于,最后一个元素指向下一个元素的指针(tail.next) 不是引用 undefined,而是指向第一个元素(head)。

3441650465079_.pic.jpg

这里我就不多说了,感兴趣的掘友可以自己去实现一下,我在下面附上循环单向链表和循环双向链表的实现。

单向循环链表

class CircularLinkedList extends LinkedList {
  constructor(equalsFn = defaultEquals) {
    super(equalsFn)
  }
  insert(element, index) {
    if(index >= 0 && index <= this.count) {
      const node = new Node(element)
      let current = this.head
      if(index === 0) {
        if(this.head == null) {
          this.head = node
          node.next = this.head
        } else {
          node.next = current
          current = this.getElementAt(index - 1)
          this.head = node
          current.next = this.head
        }
      } else {
        const previous = this.getElementAt(index - 1)
        node.next = previous.next
        previous.next = node
      }
      this.count++
      return true
    }
    return false
  }
  removeAt(index) {
    if(index >= 0 && index < this.count) {
      let current = this.head
      if(index === 0) {
        if(this.size() === 1) {
          this.head = undefined
        } else {
          const removed = this.head
          current = this.getElementAt(this.size()-1)  // 最后一个节点
          this.head = this.head.next
          current.next = this.head
          current = removed
        }
      } else {
        const previous = this.getElementAt(index-1)
        current = previous.next
        previous.next = current.next
      }
      this.count--
      return current.element
    }
    return undefined
  }
  push(element) {
    const node = new Node(element)
    if(this.head == null) {
      this.head = node
      node.next = this.head
    } else {
      let current = this.head
      while (current.next != this.head) {
        current = current.next
      }
      current.next = node
      node.next = this.head
    }
    this.count++
  }
  indexOf(element) {
    let current = this.head
    for(let i = 0; i < this.count; i++) {
      if(this.equalsFn(element, current.element)) return i
      current = current.next
    }
    return -1
  }
  /**
   * 剩下的方法从父类继承
   * size()
   * isEmpty()
   * getHead()
   * print()
   * remove(element)
   * getElementAt(index)
   */ 
}

双向循环链表

// 循环双向链表
class CircularDoublyLinkedList extends DoublyLinkedList {
  constructor(equalsFn = defaultEquals) {
    super(equalsFn)
  }
  insert(element, index) {
    if(index >= 0 && index <= this.count) {
      const node = new DoublyNode(element)
      let current = this.head
      if(index === 0) {
        if(this.head == null) {
          this.head = node
          this.tail = node 
          node.next = this.head // 新增
          node.prev = this.head
        } else {
          node.next = this.head
          current.prev = node
          this.head = node
          this.tail.next = this.head  // 新增
          node.prev = this.tail
        }
      } else if(index === this.count) {
        current = this.tail
        current.next = node
        node.prev = current
        node.next = this.head
        this.tail = node
        this.head.prev = this.tail
      } else {
        console.log('shang');
        const previous = this.getElementAt(index - 1)
        current = previous.next
        previous.next = node
        node.next = current
        node.prev = previous
        current.prev = node
      }
      this.count++
      return true
    }
    return false
  }
  removeAt(index) {
    if(index >= 0 && index < this.count) {
      let current = this.head
      if(index === 0) {
        if(this.count === 1) {
          this.head = undefined
          this.tail = undefined
        } else {
          this.head = current.next
          this.tail.next = this.head
          this.head.prev = this.tail
        }
      } else if (index === this.count - 1) {
        current = this.tail
        this.tail = current.prev
        this.tail.next = this.head
        this.head.prev = this.tail
      } else {
        current = this.getElementAt(index)
        const previous = current.prev
        previous.next = current.next
        current.next.prev = previous
      }
      this.count--
      return current.element
    }
    return undefined
  }
  push(element) {
    const node = new DoublyNode(element)
    if(this.head == null) {
      this.head = node
      this.tail = node
      node.next = this.head
      node.prev = this.tail
    } else {
      let current = this.tail
      current.next = node
      node.prev = current
      node.next = this.head
      this.tail = node
    }
    this.count++
  }
  /**
   * 剩下的方法从父类继承
   * indexOf(element)
   * size()
   * isEmpty()
   * getHead()
   * print()
   * remove(element)
   * getElementAt(index)
   */ 
}

结语

链表还有一种结构叫有序链表,有序链表是保持元素有序的链表结构。除了使用排序算法之外,我们还可以将元素插入到正确的位置来保证链表的有序性。要实现也很简单,只需要添加一个活的插入元素的正确位置的方法,使用 insert 方法就可以实现啦。

我们还可以用链表作为内部的数据结构来创建其他数据结构,例如:栈、队列和双向队列。

链表相比数组最重要的优点,就是无需移动链表中的元素,就能轻松地添加和删除元素。因此,当你需要频繁地做添加和删除操作时,最好的选择是链表,而非数组。

* 图片来源:《学习JavaScript数据结构与算法》