🚂 链表(LinkedList):数据结构界的小火车,一节连一节!

103 阅读7分钟

"数组是连号的酒店房间,链表是手拉手的小朋友!" 🤝


😊 什么是链表?火车的比喻

还记得小时候玩的玩具火车吗?🚂

每节车厢都有一个挂钩,连接着下一节车厢。想要加一节车厢?很简单,挂上去就行!想要去掉一节?解开挂钩就好!

🚂 单向火车(单向链表):
┌─────┐  ┌─────┐  ┌─────┐  ┌─────┐
│ 车头 │→│ 车厢1│→│ 车厢2│→│ 车尾 │→ null
│[10] │  │[20] │  │[30] │  │[40] │
└─────┘  └─────┘  └─────┘  └─────┘

这就是链表! 每个"车厢"(节点)里装着数据,还有一个"挂钩"(指针)指向下一节车厢。


🎯 链表的三兄弟

1️⃣ 单向链表(Singly Linked List)

特点:只能从头往后走,不能倒退 ➡️

节点结构:
┌─────────┬─────────┐
│  data   │  next   │  ← 每个节点有两部分
│  数据域  │ 指针域  │
└─────────┴─────────┘

完整链表:
   head
    ↓
  ┌───┐   ┌───┐   ┌───┐   ┌───┐
  │ 1 │→  │ 2 │→  │ 3 │→  │ 4 │→ null
  └───┘   └───┘   └───┘   └───┘

生活例子

  • 🎬 看电影的时候,你知道"下一集"是什么,但不知道"上一集"是哪个
  • 📖 翻书只能往后翻,书页上写着"下一页在这里"

2️⃣ 双向链表(Doubly Linked List)

特点:既能往后走,也能往前退 ↔️

节点结构:
┌─────┬──────┬──────┐
│prev │ data │ next │  ← 有三部分
│前驱 │ 数据 │ 后继 │
└─────┴──────┴──────┘

完整链表:
   head
    ↓
  ┌───┐ ⇄ ┌───┐ ⇄ ┌───┐ ⇄ ┌───┐
  │ 1 │   │ 2 │   │ 3 │   │ 4 │
  └───┘   └───┘   └───┘   └───┘
    ↑                          ↓
  null                       null

生活例子

  • 🚇 地铁,你既知道上一站,也知道下一站
  • 📱 浏览器的"前进"和"后退"按钮

3️⃣ 循环链表(Circular Linked List)

特点:首尾相连,形成一个环 🔄

单向循环:
   head
    ↓
  ┌───┐   ┌───┐   ┌───┐   ┌───┐
  │ 1 │→  │ 2 │→  │ 3 │→  │ 4 │→┐
  └───┘   └───┘   └───┘   └───┘ │
    ↑                            │
    └────────────────────────────┘

双向循环:
   head
    ↓
  ┌───┐ ⇄ ┌───┐ ⇄ ┌───┐ ⇄ ┌───┐
  │ 1 │   │ 2 │   │ 3 │   │ 4 │
  └───┘   └───┘   └───┘   └───┘
    ↑                          ↓
    └──────────────────────────┘

生活例子

  • 🎡 摩天轮,转啊转,永远在循环
  • 🔁 音乐播放器的"循环播放"模式

🆚 数组 vs 链表:终极PK

对比项数组🥚链表🚂
内存连续的格子分散的车厢
访问速度⚡超快 O(1)🐌较慢 O(n)
插入删除🐌慢 O(n)⚡快 O(1)
大小😰 固定😊 灵活
空间利用可能浪费按需分配
额外空间需要指针

形象比喻

  • 数组:一排连号的酒店房间(1001, 1002, 1003...),找房间快,但中间换房客很麻烦
  • 链表:手拉手的小朋友,中间插入或离开都很方便,但要找第n个小朋友得从头数

💻 Java代码实现

单向链表实现

// 定义节点
class Node {
    int data;        // 数据域:存储数据
    Node next;       // 指针域:指向下一个节点
    
    Node(int data) {
        this.data = data;
        this.next = null;
    }
}

// 单向链表
public class SinglyLinkedList {
    Node head;  // 头节点
    
    // 1. 在头部插入 - O(1)
    public void insertAtHead(int data) {
        Node newNode = new Node(data);
        newNode.next = head;
        head = newNode;
        System.out.println("✅ 在头部插入: " + data);
    }
    
    // 2. 在尾部插入 - O(n)
    public void insertAtTail(int data) {
        Node newNode = new Node(data);
        
        if (head == null) {
            head = newNode;
            return;
        }
        
        Node current = head;
        while (current.next != null) {
            current = current.next;
        }
        current.next = newNode;
        System.out.println("✅ 在尾部插入: " + data);
    }
    
    // 3. 删除指定值的节点 - O(n)
    public void delete(int data) {
        if (head == null) return;
        
        // 删除头节点
        if (head.data == data) {
            head = head.next;
            System.out.println("🗑️ 删除节点: " + data);
            return;
        }
        
        // 删除其他节点
        Node current = head;
        while (current.next != null && current.next.data != data) {
            current = current.next;
        }
        
        if (current.next != null) {
            current.next = current.next.next;
            System.out.println("🗑️ 删除节点: " + data);
        }
    }
    
    // 4. 查找节点 - O(n)
    public boolean search(int data) {
        Node current = head;
        while (current != null) {
            if (current.data == data) {
                return true;
            }
            current = current.next;
        }
        return false;
    }
    
    // 5. 打印链表
    public void printList() {
        if (head == null) {
            System.out.println("链表为空 🈳");
            return;
        }
        
        Node current = head;
        System.out.print("链表: ");
        while (current != null) {
            System.out.print(current.data);
            if (current.next != null) {
                System.out.print(" → ");
            }
            current = current.next;
        }
        System.out.println(" → null");
    }
}

// 测试代码
public class Main {
    public static void main(String[] args) {
        SinglyLinkedList list = new SinglyLinkedList();
        
        list.insertAtHead(10);   // 10 → null
        list.insertAtHead(20);   // 20 → 10 → null
        list.insertAtTail(30);   // 20 → 10 → 30 → null
        list.insertAtTail(40);   // 20 → 10 → 30 → 40 → null
        
        list.printList();
        
        list.delete(10);         // 20 → 30 → 40 → null
        list.printList();
        
        System.out.println("查找30: " + list.search(30));  // true
        System.out.println("查找99: " + list.search(99));  // false
    }
}

双向链表实现

// 双向链表节点
class DoublyNode {
    int data;
    DoublyNode prev;  // 前驱指针
    DoublyNode next;  // 后继指针
    
    DoublyNode(int data) {
        this.data = data;
        this.prev = null;
        this.next = null;
    }
}

// 双向链表
public class DoublyLinkedList {
    DoublyNode head;
    DoublyNode tail;
    
    // 在头部插入
    public void insertAtHead(int data) {
        DoublyNode newNode = new DoublyNode(data);
        
        if (head == null) {
            head = tail = newNode;
        } else {
            newNode.next = head;
            head.prev = newNode;
            head = newNode;
        }
    }
    
    // 在尾部插入
    public void insertAtTail(int data) {
        DoublyNode newNode = new DoublyNode(data);
        
        if (tail == null) {
            head = tail = newNode;
        } else {
            tail.next = newNode;
            newNode.prev = tail;
            tail = newNode;
        }
    }
    
    // 从前往后打印
    public void printForward() {
        DoublyNode current = head;
        System.out.print("正向: null ⇄ ");
        while (current != null) {
            System.out.print(current.data);
            if (current.next != null) {
                System.out.print(" ⇄ ");
            }
            current = current.next;
        }
        System.out.println(" ⇄ null");
    }
    
    // 从后往前打印
    public void printBackward() {
        DoublyNode current = tail;
        System.out.print("反向: null ⇄ ");
        while (current != null) {
            System.out.print(current.data);
            if (current.prev != null) {
                System.out.print(" ⇄ ");
            }
            current = current.prev;
        }
        System.out.println(" ⇄ null");
    }
}

🎬 链表操作动画演示

插入操作

在中间插入新节点:

步骤1:原链表
  head
   ↓
  [1][2][4] → null

步骤2:创建新节点 [3]
  newNode = [3]

步骤3:调整指针(在2和4之间插入3)
  [2].next = [3]
  [3].next = [4]

步骤4:完成!
  head
   ↓
  [1][2][3][4] → null

删除操作

删除中间节点:

步骤1:原链表
  head
   ↓
  [1][2][3][4] → null

步骤2:找到要删除节点[3]的前一个节点[2][1][2][3][4] → null

步骤3:跳过节点[3]
  [2].next = [4]

步骤4:完成!([3]被回收)
  head
   ↓
  [1][2][4] → null

🏆 链表的经典面试题

1. 反转链表(LeetCode 206)⭐⭐⭐

输入: 12345null
输出: 54321null
public Node reverseList(Node head) {
    Node prev = null;
    Node current = head;
    
    while (current != null) {
        Node nextTemp = current.next;  // 保存下一个节点
        current.next = prev;           // 反转指针
        prev = current;                // 往前移动
        current = nextTemp;
    }
    
    return prev;  // 新的头节点
}

图解

原始:   null ← [1][2][3] → null
              prev  curr  next

步骤1:  null ← [1]   [2][3] → null
                    prev  curr

步骤2:  null ← [1][2]   [3] → null
                          prev  curr

步骤3:  null ← [1][2][3]
                                prev

2. 环形链表检测(快慢指针)⭐⭐⭐

问题:判断链表是否有环 🔄

public boolean hasCycle(Node head) {
    if (head == null) return false;
    
    Node slow = head;  // 慢指针:每次走1步 🐢
    Node fast = head;  // 快指针:每次走2步 🐰
    
    while (fast != null && fast.next != null) {
        slow = slow.next;           // 慢指针走1步
        fast = fast.next.next;      // 快指针走2步
        
        if (slow == fast) {         // 相遇了!有环!
            return true;
        }
    }
    
    return false;  // 快指针到终点了,无环
}

生活比喻: 两个人在操场跑圈🏃,一个跑得快,一个跑得慢。如果操场是环形的,快的人迟早会追上慢的人(套圈)。如果是直线跑道,快的人就跑到终点了,不会相遇。

3. 链表的中间节点

public Node findMiddle(Node head) {
    Node slow = head;
    Node fast = head;
    
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    
    return slow;  // 慢指针刚好在中间
}

4. 合并两个有序链表

public Node mergeTwoLists(Node l1, Node l2) {
    if (l1 == null) return l2;
    if (l2 == null) return l1;
    
    if (l1.data < l2.data) {
        l1.next = mergeTwoLists(l1.next, l2);
        return l1;
    } else {
        l2.next = mergeTwoLists(l1, l2.next);
        return l2;
    }
}

📊 链表的时间复杂度总结

操作单向链表双向链表数组(对比)
访问第i个元素O(n) 🐌O(n) 🐌O(1) ⚡
头部插入O(1) ⚡O(1) ⚡O(n) 🐌
尾部插入O(n) 🐌O(1) ⚡(有tail)O(1) ⚡
头部删除O(1) ⚡O(1) ⚡O(n) 🐌
中间插入O(n)O(n)O(n)
查找元素O(n)O(n)O(n)

🎯 什么时候用链表?

适合用链表的场景:

  1. 频繁插入删除 - 比如音乐播放列表 🎵

    // 添加歌曲、删除歌曲很频繁
    LinkedList<Song> playlist = new LinkedList<>();
    
  2. 不知道数据量 - 数据量动态变化 📈

  3. 不需要随机访问 - 只需要顺序遍历 ➡️

  4. 实现栈和队列 - LinkedList可以当栈和队列用 📚

不适合用链表的场景:

  1. 需要快速随机访问 - 用数组 🎯
  2. 内存紧张 - 链表的指针占用额外空间 💾
  3. 数据量小且固定 - 数组更简单 📦

🌟 Java的LinkedList类

Java提供了现成的LinkedList类(双向链表实现):

import java.util.LinkedList;

LinkedList<Integer> list = new LinkedList<>();

// 添加元素
list.add(10);           // 尾部添加
list.addFirst(5);       // 头部添加
list.addLast(20);       // 尾部添加

// 删除元素
list.removeFirst();     // 删除头部
list.removeLast();      // 删除尾部
list.remove(1);         // 删除索引为1的元素

// 获取元素
int first = list.getFirst();   // 获取第一个
int last = list.getLast();     // 获取最后一个
int element = list.get(2);     // 获取索引为2的元素

// 遍历
for (int num : list) {
    System.out.println(num);
}

LinkedList vs ArrayList:

LinkedList: 🚂🚂🚂 (插删快,访问慢)
ArrayList:  📦📦📦 (访问快,插删慢)

💡 实战应用场景

1. 浏览器的前进后退

用双向链表实现:

class BrowserHistory {
    DoublyNode current;
    
    // 访问新页面
    void visit(String url) { ... }
    
    // 后退
    String back() { return current.prev.data; }
    
    // 前进
    String forward() { return current.next.data; }
}

2. 音乐播放器

用循环链表实现循环播放:

class MusicPlayer {
    CircularNode currentSong;
    
    // 下一首
    void next() { currentSong = currentSong.next; }
    
    // 上一首
    void previous() { currentSong = currentSong.prev; }
}

3. LRU缓存

用双向链表 + HashMap实现:

// 最近使用的在头部,最久未用的在尾部
// 达到容量上限时,删除尾部节点

📝 总结

🎓 记忆口诀

链表像火车,节节相连,
单向往前走,双向能倒退,
循环首尾接,永远在转圈。
插入删除快,访问有点慢,
空间很灵活,指针占空间。
头尾操作易,中间需遍历!

📊 核心特点

特性说明表情
内存不连续,灵活分配🧩
插删头尾快O(1),中间O(n)
访问慢O(n),需遍历🐌
空间需要额外指针📍

🚀 下一步学习

掌握了链表,接下来可以学习:

  1. 跳表(Skip List) - 链表的升级版,Redis用它! 🎯
  2. 栈(Stack) - 可以用链表实现 📚
  3. 队列(Queue) - 也可以用链表实现 🎫

恭喜你!🎉 你已经掌握了链表这个灵活的数据结构!

记住:数组是连号房间,链表是手拉手的小朋友! 各有千秋,选对场景最重要!💪


📌 小练习:尝试手写一个单向链表,实现插入、删除、反转三个操作!

🤔 思考题:为什么Java的LinkedList要用双向链表而不是单向链表?

(答案:双向链表可以从两端操作,实现Deque接口更高效!)