原文:
1、链表的概括
虽然动态数组靠resize方法解决静态数组固定容量的问题,但依旧摆脱不了仍是静态数组的事实,而链表则与上述线性数据结构都不同,是一种真正的动态数据结构。
2、链表为何如此重要
- 最简单的动态数据结构
- 更深入的理解引用(或者指针)
- 更深入的理解递归
- 辅助组成其他数据结构
注:链表本身也是有它非常清晰的递归结构的,由于它天身这种递归性质,可以帮助大家更加深入的理解递归机制相应的数据结构。
3、 具体来看看什么是链表
- 链表,通常数据存储在“节点”(Node)中。
- 对链表的节点来说只有两部分,一是存储真正的数据,而另一部分是node类型的对象next(next本身又是一个节点,连接起了下一个节点)。
类比火车,每个节点是一节车厢,在车厢中存储真正的数据。而车厢和车厢还要进行连接,以使得所有数据是整合在一起的。用户可以方便地在对这些数据上查询等进行其他操作,而数据和数据之间连接就是由这个next来完成的。
-
简单的图示一下
- 比如,第一个节点存放了元素1,同时它有一个指向next(也就是用一个箭头来表示),指向了下一节点(node)
- 第二个节点存放了元素2,以此类推……
- 到最后个节点的next存储的是NULL
4、链表的优缺点
优点
真正的动态,不需要处理固定容量的问题
不必像静态数组一样,需要考虑开辟多少空间出来,同时还要考虑这个空间是否够用。对于链表来说,你需要存储多少个数据,你就可以生成多少个节点,把它们挂接起来,这便是动态的含义
缺点
-
丧失了随机访问的能力
链表不能像数组那样,从数组的索引中直接取出元素。在底层机制上,数组所开辟的空间,在内存中是连续分布的,所以可以直接寻找这个索引对应的偏移,直接计算出相应的数据所存储的内存地址,用O(1)的复杂度把这个元素取出来
但是链表不同。链表是靠next一层层连接,所以在计算机的底层,每一个节点所在的内存位置是不同的。因此必须通过遍历,一层一层找到这个元素,这便是链表最大的缺点。
5、 数组和链表的对比
- 数组支持快速查询
- 链表便是支持动态
- 如果一个节点的next是NULL,那就说明这个节点一定是最后一个节点,这就是链表。
- 数组和链表的对比如下图所示:
6、链表的实现
对于链表来说,我们想要访问这个链表中所有的节点,就必须把链表的头(head)存储起来。
代码实现
public class LinkedList<E> {
private class Node {
public E e;
public Node next;
public Node(E e, Node next) {
this.e = e;
this.next = next;
}
public Node(E e) {
this(e, null);
}
public Node() {
this(null, null);
}
@Override
public String toString() {
return e.toString();
}
}
private Node head;
private int size;
public LinkedList(){
head = null;
size = 0;
}
}
6.1、在链表头添加元素
下面我们来看下链表最重要的操作–如何为 链表头 添加元素(数组中的size -1跟踪数组尾部,故在数组尾部添加元素较为方便
,链表中有head跟踪链表头部,而没有指针跟踪尾部,故在头部添加元素较为方便)
- 假设,要将666这个元素添加到链表中,
- 相应的需要在node节点里存放666这个元素,以及相应的next(指向)
- 然后node节点的next指向链表的头,即
node.next=head
- 最后head也指向存放666的node节点,即
head=node
注: 整个过程在一个函数中执行,函数结束之后,相应node变量的块作用域也就结束了
代码实现
public void addFirst(E e){
Node node = new Node(e);
node.next = head;
head = node;
}
6.2、 在链表的中间添加新的元素
现在来处理稍微复杂一点的问题,在链表的中间添加新的元素
- 对于这个链表,要在这个链表索引(链表是无索引概念,只是借用索引这个概念来阐述)为2的地方添加一个新的元素666
- 首先遍历找到索引为2的前一个节点prev,
- 然后
prev.next
指向存放2元素的节点,同时存放666节点node.next
也指向它,因此得到node.next=prev.next
- 之后存放666节点node挂接起下一个节点,即
prev.next=node
- 经过这样的操作,完成了对在链表中间添加新的元素
代码实现
// 在链表的index(0-based)位置添加新的元素e
// 在链表中不是一个常用的操作,通常仅供练习
public void add(int index, E e){
if(index < 0 || index > size){
throw new IllegalArgumentException("Add failed. Illegal index.");
}
if(index == 0){
addFirst(e);
}else{
Node prev = head;
//i=0 prev指向1;i=1,prev指向2
for(int i = 0; i < index - 1; i++){
// 把当前prev存的这个节点的下一个节点放进prev这个变量中
// prev这个变量在链表中会一直向前移动,直到移动到index-1这个位置
// 最后就找到了待插入的那个节点的前一个节点
prev = prev.next;
}
Node node = new Node(e);
node.next = prev.next;
prev.next = node;
size++;
}
}
6.3、在链表的尾部添加新的节点
//在链表尾部添加新的运算e
public void addLast(E e) {
add(size ,e);
}
6.4、为链表设立虚拟头节点
在链表添加元素的过程中,我们遇到了在链表任意位置添加元素和在链表头添加元素,逻辑上有所不同。究其原因,是在链表添加过程中需要找到相应的前一个节点。因此,需要在链表中造一个虚拟头节点(dummy head)。
代码实现
// 虚拟头节点
private Node dummyHead;
private int size;
public LinkedList() {
dummyHead = new Node(null, null);
size = 0;
}
// 在链表的index(0-based)位置添加新的元素e
// 在链表中不是一个常用的操作,通常练习用
public void add(int index, E e) {
if (index < 0 || index > size) {
throw new IllegalArgumentException("Add failed. Illegal index.");
}
Node prev = dummyHead;
//i=0,prev指向0;i=1,prev指向1
for (int i = 0; i < index; i++) {
// 把当前prev存的这个节点的下一个节点放进prev这个变量中
// prev这个变量在链表中会一直向前移动,直到移动到index-1这个位置
// 最后就找到了待插入的那个节点的前一个节点
prev = prev.next;
}
Node node = new Node(e);
node.next = prev.next;
prev.next = node;
// 或者三面三行改成 perv.next= new Node(e, prev.next);
size++;
}
// 在链表头添加新的元素e
public void addFirst(E e) {
add(0, e)
}
// 在链表末尾添加新的元素e
public void addLast(E e) {
add(size, e);
}
6.5、链表的查询、更新与遍历
继续为我们的链表添加更多的操作。那么,首先是获得链表的第index个元素。
代码实现
// 获取在链表的index(0-based)位置的元素e
// 在链表中不是一个常用的操作,通常练习用
public E get(int index){
if(index < 0 || index >= size){
throw new IllegalArgumentException("Get failed. Illegal index.");
}
Node cur = dummyHead.next;
for(int i = 0; i < index; i++){
cur.next = cur;
}
return cur.e;
}
// 获得链表的第一个元素
public E getFirst(){
return get(0);
}
// 获得链表的最后一个元素
public E getLast(){
return get(size - 1);
}
// 修改在链表的index(0-based)位置的元素e
// 在链表中不是一个常用的操作,通常练习用
public void set(int index, E e){
if(index < 0 || index >= size){
throw new IllegalArgumentException("Update failed. Illegal index.");
}
Node cur = dummyHead.next;
// 需要遍历到index节点
for(int i = 0; i < index; i++){
cur = cur.next;
}
// 再对index节点进行赋值
cur.e = e;
}
// 查找链表是否含有元素e
public boolean contains(E e){
Node cur = dummyHead.next;
// 判断cur节点是否为空,就意味着cur节点为有效节点
while (cur != null){
if(cur.e.equals(e)){
return true;
}
cur = cur.next;
}
return false;
}
6.6、链表元素的删除
介绍了为链表添加元素,查询、更新元素,现在就插最后一个从链表中删除元素
- 要想删除索引为2的元素,需要找到索引为2的上一个节点prev,而
prev.next
便是待删除的节点(可称为delNode) - 让prev.next指向delNode.next,即prev.next=delNode.next。也就是跳过了delNode节点
- 为了使得能够回收delNode节点的空间,因此需要将delNode.next置空,即delNode=null
- 这样一来,就完成了整个链表元素的删除操作
代码实现
// 从链表中删除index(0-based)位置的元素,返回删除的元素
// 在链表中不是一个常用的操作,通常仅供练习
public E remove(int index){
if(index < 0 || index >= size){
throw new IllegalArgumentException("Remove failed. Illegal index.");
}
Node prev = dummyHead;
for(int i = 0; i < index; i++){
prev = prev.next;
}
Node retNode = prev.next;
prev.next = retNode.next;
// 将retNode.next节点置空以便回收
retNode.next = null;
size--;
return retNode.e;
}
// 从链表中删除第一个元素,返回删除的元素
public E removeFirst(){
return remove(0);
}
// 从链表中删除最后一个元素,返回删除的元素
public E removeLast(){
return remove(size - 1);
}
6.7、链表的时间复杂度分析
最后简单的分析一下,这个链表的时间复杂度。
- 首先我们来看添加操作O(n)。
- 如果向链表尾添加一个元素addLast(e),则必须从链表头开始遍历每一个元素,因此是O(n)的时间复杂度。
- 但是如果向链表头添加一个元素addFirst(e),它是O(1)的时间复杂度。
- 如果在链表任意位置添加元素add(index,e),平均看是在链表中间插入元素即O(n/2)=O(n)的时间复杂度。
- 对于删除操作来说是基本一样的分析过程O(n)。
- 如果想要删除最后一个元素,就需要链表头遍历一次,因此时间复杂度是O(n)。
- 而删除第一个元素,需要O(1)的时间可以搞定了。
- 但是如果想要删除任意位置节点的话,平均来看是O(n/2)~O(n)。
- 由于链表不支持随机访问,所以要想修改某个位置的元素set(index, e),就必须从头遍历,所以这个修改操作的时间复杂度是O(n)
- 对于查找操作 O(n)。
- 所以要查询某个位置的元素set(index, e),就必须从头遍历,所以这个修改操作get(index)、contains(e)的时间复杂度是O(n)
- 总体来说,链表的增、删、改、查的时间复杂度都是O(n)。如图所示:
总体来说,链表的增、删、改、查的时间复杂度都是O(n),比数组整体复杂度差。其中对于数组来说,如果有索引的话,可以快速访问,而链表没有这种操作。但是如果进对链表头进行操作,时间复杂度是O(1),对于只查链表头元素,时间复杂度是O(1),由于整体是动态的,不会大量浪费内存空间具有优势。
6.8、链表的完整实现
- 实现链表的业务逻辑如下:
public class LinkedList<E> {
private class Node {
public E e;
public Node next;
//构造函数
public Node(E e, Node next) {
this.e = e;
this.next = next;
}
//只传了参数e的构造函数
public Node(E e) {
this(e, null);
}
//不传递参数的构造函数
public Node() {
this(null, null);
}
//方便打印测试
@Override
public String toString() {
return e.toString();
}
}
private Node dummyHead;
private int size;
//构造函数
public LinkedList() {
dummyHead = new Node(null, null); //虚拟头节点
size = 0;
}
//获取链表中的元素个数
public int getSize() {
return size;
}
//判断链表是否为空
public boolean isEmpty() {
return size == 0;
}
//在链表的index(0-based)位置添加新的元素e
//在链表中不是一个常用操作
public void add(int index, E e) {
if (index < 0 || index > size) {
throw new IllegalArgumentException("Add failed. Illegal index.");
}
Node prev = dummyHead;
for (int i = 0; i < index; i++) {
prev = prev.next;
}
prev.next = new Node(e, prev.next);
size++;
}
//在链表头添加新元素e
public void addFirst(E e) {
add(0, e);
}
//在链表末尾添加新的元素e
public void addLast(E e) {
add(size, e);
}
//获取链表的index(0-based)位置的元素
//在链表中也不是一个常用操作
public E get(int index) {
if (index < 0 || index > size - 1) {
throw new IllegalArgumentException("Get failed. Illegal index.");
}
Node cur = dummyHead.next;
for (int i = 0; i < index; i++) {
cur = cur.next;
}
return cur.e;
}
//获得链表的第一个元素
public E getFirst() {
return get(0);
}
//获得链表的最后一个元素
public E getLast() {
return get(size - 1);
}
//修改链表的index(0-based)位置的元素为e
//在链表中也不是一个常用操作
public void set(int index, E e) {
if (index < 0 || index > size - 1) {
throw new IllegalArgumentException("Set failed. Illegal index.");
}
Node cur = dummyHead.next;
for (int i = 0; i < index; i++) {
cur = cur.next;
}
cur.e = e;
}
//查找链表中是否存在元素e
public boolean contains(E e) {
Node cur = dummyHead.next;
while (cur != null) {
if (cur.e.equals(e)) {
return true;
}
cur = cur.next;
}
return false;
}
//删除链表的index(0-based)位置的元素,并返回该元素
//在链表中也不是一个常用操作
public E remove(int index) {
if (index < 0 || index > size - 1) {
throw new IllegalArgumentException("Remove failed. Illegal index.");
}
Node prev = dummyHead;
for (int i = 0; i < index; i++) {
prev = prev.next;
}
Node retNode = prev.next;
prev.next = retNode.next;
retNode.next = null;
size--;
return retNode.e;
}
//删除链表中的第一个元素,并返回该元素
public E removeFirst() {
return remove(0);
}
//删除链表中的最后一个元素,并返回该元素
public E removeLast() {
return remove(size - 1);
}
// 从链表中删除元素e
public void removeElement(E e){
Node prev = dummyHead;
while(prev.next != null){
if(prev.next.e.equals(e))
break;
prev = prev.next;
}
if(prev.next != null){
Node delNode = prev.next;
prev.next = delNode.next;
delNode.next = null;
size --;
}
}
//方便打印测试
@Override
public String toString() {
StringBuilder res = new StringBuilder();
// Node cur = dummyHead.next;
// while (cur != null) {
// res.append(cur + "->");
// cur = cur.next;
// }
for (Node cur = dummyHead.next; cur != null; cur = cur.next) {
res.append(cur + "->");
}
res.append("NULL");
return res.toString();
}
}
-
测试的业务逻辑如下:
-
public class Main { public static void main(String[] args) { LinkedList<Integer> linkedList = new LinkedList<>(); for (int i = 0; i < 5; i++) { linkedList.addFirst(i); System.out.println(linkedList); } linkedList.add(2, 666); System.out.println(linkedList); linkedList.remove(2); System.out.println(linkedList); linkedList.removeFirst(); System.out.println(linkedList); linkedList.removeLast(); System.out.println(linkedList); } }
- 输出结果:
-
0->NULL 1->0->NULL 2->1->0->NULL 3->2->1->0->NULL 4->3->2->1->0->NULL 4->3->666->2->1->0->NULL 4->3->2->1->0->NULL 3->2->1->0->NULL 3->2->1->NULL
7、 使用链表来实现一个"栈"
如果我们只对链表的头进行添加和删除操作,那么时间复杂度是O(1),如果我们只查链表头的元素,那么时间复杂度也是O(1),满足这些条件的数据结构,我们很容易就会想到"栈",对于"栈"而言,遵循后进先出的原则,只对栈的一端,也就是"栈顶"进行操作,无论是添加、删除还是查看元素,都在栈顶进行。所以,我们就可以把链表头当作栈顶,用链表来作为栈的底层实现,来实现出一个栈。
- 链表栈的实现及测试的业务逻辑如下:
public class LinkedListStack<E> implements Stack<E> {
private LinkedList<E> list;
//构造函数
public LinkedListStack() {
list = new LinkedList<>();
}
//实现getSize方法
@Override
public int getSize() {
return list.getSize();
}
//实现isEmpty方法
@Override
public boolean isEmpty() {
return list.isEmpty();
}
//实现push方法
@Override
public void push(E e) {
list.addFirst(e);
}
//实现pop方法
@Override
public E pop() {
return list.removeFirst();
}
//实现peek方法
@Override
public E peek() {
return list.getFirst();
}
//方便打印测试
@Override
public String toString() {
StringBuilder res = new StringBuilder();
res.append("Stack: top ");
res.append(list);
return res.toString();
}
//测试
public static void main(String[] args) {
LinkedListStack<Integer> stack = new LinkedListStack<>();
//测试入栈push
for (int i = 0; i < 5; i++) {
stack.push(i);
System.out.println(stack);
}
//测试出栈
stack.pop();
System.out.println(stack);
}
}
- 输出结果:
-
Stack: top 0->NULL Stack: top 1->0->NULL Stack: top 2->1->0->NULL Stack: top 3->2->1->0->NULL Stack: top 4->3->2->1->0->NULL Stack: top 3->2->1->0->NULL
8、数组栈与链表栈的性能比较
- 测试的业务逻辑如下:
-
import java.util.Random; public class Main { //测试使用stack运行opCount个push和pop操作所需的时间,单位:秒 private static double testStack(Stack<Integer> stack, int opCount) { long startTime = System.nanoTime(); Random random = new Random(); for (int i = 0; i < opCount; i++) { stack.push(random.nextInt(Integer.MAX_VALUE)); } for (int i = 0; i < opCount; i++) { stack.pop(); } long endTime = System.nanoTime(); return (endTime - startTime) / 1000000000.0; } public static void main(String[] args){ int opCount = 10000; ArrayStack<Integer> arrayStack = new ArrayStack<>(); double time1 = testStack(arrayStack,opCount); System.out.println("ArrayStack, time: " + time1 + " s"); LinkedListStack<Integer> linkedListStack = new LinkedListStack<>(); double time2 = testStack(linkedListStack,opCount); System.out.println("LinkedListStack, time: " + time2 + " s"); // 这二者的时间比较很复杂,ArrayStack会在扩容和缩容操作上面耗费时间,LinkedListStack则会在创建新的Node上面耗费时间 }
- 这两种栈的时间复杂度基本处于相同的水平
9、使用链表实现一个"队列"
- 针对链表,添加一个尾指针tail,tail端添加元素容易,删除元素不容易(需要找到待删除元素的前一个元素),而在head添加和删除一个元素都比较容易。
- 故使用tail端插入元素,作为队尾,在head端删除元素,作为队首。此时我们不使用虚拟头结点,因为不涉及对链表中间元素的插入和删除,只针对对首和队尾插入和删除。故不需要统一。
- 由于没有dummyNode,当队列为空时,head和tail都指向空节点。
- 链表队列的实现及测试的业务逻辑如下
-
public class LinkListQueue<E> implements Queue<E> { private class Node { public E e; public Node next; public Node(E e, Node next) { this.e = e; this.next = next; } public Node(E e) { this(e, null); } public Node() { this(null, null); } @Override public String toString() { return e.toString(); } } private Node head, tail; private int size; public LinkListQueue() { head = null; tail = null; size = 0; } //实现getSize @Override public int getSize() { return size; } //实现isEmpty @Override public boolean isEmpty() { return size == 0; } //实现enqueue @Override public void enqueue(E e) { if (tail == null) { tail = new Node(e); head = tail; } else { tail.next = new Node(e); tail = tail.next; } size++; } //实现dequeue @Override public E dequeue() { if (isEmpty()) { throw new IllegalArgumentException("Cannot dequeue from an empty queue."); } Node retNode = head; head = head.next; retNode.next = null; if (head == null) { tail = null; } size--; return retNode.e; } //实现getFront public E getFront() { if (isEmpty()) { throw new IllegalArgumentException("Queue is empty."); } return head.e; } //方便打印测试 public String toString() { StringBuilder res = new StringBuilder(); res.append("Queue: front "); Node cur = head; while (cur != null) { res.append(cur + "->"); cur = cur.next; } res.append("NULL"); return res.toString(); } //测试 public static void main(String[] args) { LinkListQueue<Integer> queue = new LinkListQueue<>(); for (int i = 0; i < 6; i++) { queue.enqueue(i); System.out.println(queue); if (i % 3 == 2) { queue.dequeue(); System.out.println(queue); } } }
- 输出结果:
-
Queue: front 0->NULL Queue: front 0->1->NULL Queue: front 0->1->2->NULL Queue: front 1->2->NULL Queue: front 1->2->3->NULL Queue: front 1->2->3->4->NULL Queue: front 1->2->3->4->5->NULL Queue: front 2->3->4->5->NUL
10、 数组队列、循环队列和链表队列的性能比较
- 测试的业务逻辑如下:
-
import java.util.Random; public class Main { // 测试使用q运行opCount个enqueue和dequeue操作所需要的时间,单位:秒 private static double testQueue(Queue<Integer> q, int opCount) { long startTime = System.nanoTime(); Random random = new Random(); for (int i = 0; i < opCount; i++) { q.enqueue(random.nextInt(Integer.MAX_VALUE)); } for (int i = 0; i < opCount; i++) { q.dequeue(); } long endTime = System.nanoTime(); return (endTime - startTime) / 1000000000.0; } public static void main(String[] args) { int opCount = 100000; ArrayQueue<Integer> arrayQueue = new ArrayQueue<>(); double time1 = testQueue(arrayQueue, opCount); System.out.println("ArrayQueue, time: " + time1 + " s"); LoopQueue<Integer> loopQueue = new LoopQueue<>(); double time2 = testQueue(loopQueue, opCount); System.out.println("LoopQueue, time: " + time2 + " s"); LinkListQueue<Integer> linkListQueue = new LinkListQueue<>(); double time3 = testQueue(linkListQueue, opCount); System.out.println("LinkListQueue, time: " + time3 + " s"); } }
输出结果:
-
ArrayQueue, time: 3.069366801 s LoopQueue, time: 0.010702659 s LinkListQueue, time: 0.007079073 s
11、小练习,删除掉链表中所有值为val的节点
- 不使用dummyHead的实现方法
-
class Solution { public ListNode removeElements(ListNode head, int val) { while (head != null && head.val == val) { //head = head.next; ListNode delNode = head; head = head.next; delNode.next = null; } if (head == null) { return null; } ListNode prev = head; while (prev.next != null) { if (prev.next.val == val) { //prev.next = prev.next.next; ListNode delNode = prev.next; prev.next = delNode.next; delNode.next = null; } else { prev = prev.next; } } return head; } }
- 使用dummyHead的实现方法:
-
class Solution2 { public ListNode removeElements(ListNode head, int val) { ListNode dummyHead = new ListNode(-1); dummyHead.next = head; ListNode prev = dummyHead; while (prev.next != null) { if (prev.next.val == val) { //prev.next = prev.next.next; ListNode delNode = prev.next; prev.next = delNode.next; delNode.next = null; } else { prev = prev.next; } } return dummyHead.next; } }
- 使用dummyHead之后,代码变得更加简洁
12、递归
-
从本质上讲,递归,就是将原来的问题转化为更小的同一个问题;
-
递归举例,数组求和:
-
Sum(arr[0...n-1]) = arr[0] + Sum(arr[1...n-1]) <-- 转化为更小的同一个问题 Sum(arr[1...n-1]) = arr[1] + Sum(arr[2...n-1]) <-- 转化为更小的同一个问题 ...... Sum(arr[n-1...n-1]) = arr[n-1] + Sum([]) <-- 最基本的问题
- 代码简单实现:
-
public class Sum { public static int sum(int[] arr) { return sum(arr, 0); } //计算arr[l...n)这个区间内所有数的和 private static int sum(int[] arr, int l) { if (l == arr.length) { // <-- 求解最基本问题 return 0; } return arr[l] + sum(arr, l + 1); // <-- 将原问题简化为更小的问题 } //测试 public static void main(String[] args) { int[] arr = {1, 2, 3, 4, 5, 6, 7, 8}; System.out.println(sum(arr)); } }
13、 链表的天然递归性
-
通过下图,很容易理解为什么链表具有天然的递归性
- 用递归的思想解决删除链表中的节点的问题,原理示意图:
- 用递归实现删除链表中所有包含指定元素的节点的业务逻辑:
-
class Solution3 { public ListNode removeElements(ListNode head, int val) { if (head == null) { return null; } head.next = removeElements(head.next, val); //return head.val == val ? head.next : head; if (head.val == val) { return head.next; } else { return head; } } }