老程序员回炉补基础(二):手写链表、栈、队列------不依赖任何集合框架
很多程序员用了多年
ArrayList、LinkedList,却说不出链表插入一个节点需要几步操作。我用 Java 从零实现了三种基础数据结构,没有用java.util里的任何一个集合类。
一、双向链表(Doubly Linked List)
学习时间:2023年8月10日~12日
这是我补基础时写的第一个数据结构。
节点设计
java
public class mNode<T> {
private T data;
private String name;
private mNode<T> pre; // 前驱指针
private mNode<T> next; // 后继指针
public mNode(T data) {
this.data = data;
}
public mNode(String name, T data) {
this.name = name;
this.data = data;
}
// getter/setter 省略...
}
我选择了双向链表 而不是单链表,因为双向链表的插入和删除操作更完整,也更贴近实际应用。JDK 的 LinkedList 底层也是双向链表。
链表实现
java
public class mLink<T> {
private mNode<T> header = null; // 头节点
private mNode<T> curr = null; // 当前节点(游标)
public mLink(T data) {
header = new mNode<T>(data);
curr = header;
}
// 追加节点
public void append(mNode<T> n) {
curr.setNext(n);
n.setPre(curr);
curr = n; // 游标移动到新节点
}
// 在当前节点前插入
public void insert(mNode<T> n) {
mNode<T> pre = curr.getPre();
if (pre == null) {
header = n;
n.setNext(curr);
curr.setPre(n);
} else {
pre.setNext(n);
n.setNext(curr);
n.setPre(pre);
curr.setPre(n);
}
}
// 删除当前节点
public void remove() {
mNode<T> pre = curr.getPre();
mNode<T> next = curr.getNext();
if (next != null) {
next.setPre(pre);
curr = next;
} else {
curr = pre;
}
if (pre != null) {
pre.setNext(next);
} else {
header = curr;
}
}
}
设计上的"架构师习惯"
我引入了一个 curr 游标,记录当前操作位置。这样链表就有了"当前位置"的概念:
append()在当前节点后面追加insert()在当前节点前面插入remove()删除当前节点
这不是教科书上的标准实现,但更贴近实际使用场景。很多时候我们需要一个"光标"在链表中移动,而不是每次都从头开始遍历。
核心操作图解
删除节点(最复杂的操作):
删除前: [A] <-> [B] <-> [C]
↑ curr
删除后: [A] <-> [C]
↑ curr(移向 next)
需要处理三种边界情况:
- 删除的是中间节点(最普通)
- 删除的是头节点(
pre == null,需要更新header) - 删除的是尾节点(
next == null,curr回退到pre)
二、栈(Stack)
学习时间:2023年10月11日
节点设计
java
public class sNote<T> {
private T data;
private sNote<T> next;
public sNote(T data) {
this.data = data;
}
// getter/setter 省略...
}
栈实现
java
public class mStack<T> {
private sNote<T> top = null;
// 入栈
public void push(sNote<T> n) {
if (top == null) {
top = n;
} else {
n.setNext(top);
top = n;
}
}
// 出栈
public sNote<T> pop() {
sNote<T> temp = top;
if (top != null) top = top.getNext();
temp.setNext(null);
return temp;
}
// 判空
public boolean isNull() {
return top == null;
}
}
我的理解
栈是**后进先出(LIFO)**的数据结构,用单链表就能实现,而且只需要维护一个 top 指针:
push:新节点指向当前 top,top 更新为新节点pop:取出 top,top 后移一位
实现非常简洁,但栈的威力在于它的应用场景:
- 函数调用栈
- 表达式求值
- 浏览器的前进/后退
- 在后续的树遍历中,我用栈来模拟递归(非递归中序遍历)
三、队列(Queue)
学习时间:2023年8月11日
节点设计
java
public class qNode<T> {
private T data;
private qNode<T> next;
public qNode(T data) {
this.data = data;
}
// getter/setter 省略...
}
队列实现
java
public class mQueue<T> {
private qNode<T> header = null; // 队头(出队端)
private qNode<T> tail = null; // 队尾(入队端)
private Long maxSize = Long.MAX_VALUE;
private Long currSize = 0L;
// 入队
public void inQueue(qNode<T> n) throws Exception {
if (currSize == maxSize) {
throw new Exception("队列已满!");
}
if (header == null) {
header = n;
tail = n;
} else {
tail.setNext(n);
tail = n;
}
currSize++;
}
// 出队
public qNode<T> outQueue() throws Exception {
if (header == null) {
throw new Exception("空队列!");
}
qNode<T> temp = header;
header = header.getNext();
currSize--;
temp.setNext(null);
return temp;
}
public Long getCurrSize() {
return currSize;
}
}
我的理解
队列是**先进先出(FIFO)**的数据结构,需要维护两个指针 header 和 tail:
inQueue:在 tail 端追加,tail 后移outQueue:从 header 端取出,header 后移
我加了一个 maxSize 限制,可以初始化为有界队列。这是一个"架构师职业病"------在生产环境中,无界队列是 OOM 的常见元凶,所以习惯性地做了容量限制。
队列在后续代码中的复用
这个手写的队列在后面的代码中被大量复用:
- 图的 BFS(广度优先搜索):用队列逐层遍历顶点
- 树的层序遍历:用队列按层输出节点
- 拓扑排序:入度为0的顶点入队,逐个出队处理
这验证了一个道理:基础数据结构是上层算法的基石。
三种数据结构对比
| 结构 | 操作特点 | 核心指针 | 典型应用 |
|---|---|---|---|
| 双向链表 | 双向遍历、插入、删除 | header + curr | LRU缓存、浏览器历史 |
| 栈 | 后进先出(LIFO) | top | 函数调用栈、递归模拟 |
| 队列 | 先进先出(FIFO) | header + tail | BFS、任务调度 |
从这些基础结构看架构选型
作为架构师,理解这些底层结构直接影响技术选型:
- 为什么
ArrayList的随机访问是 O(1),而LinkedList是 O(n)? 因为数组有连续内存+索引计算,链表需要从头遍历。 - 为什么
LinkedList的头插入是 O(1),而ArrayList是 O(n)? 因为链表只需要改指针,数组需要移动后面所有元素。 - 为什么消息队列用"队列"而不是"栈"? 因为消息需要按顺序消费,后进先出会导致老消息饿死。
- 为什么递归会 StackOverflow? 因为函数调用栈是有限的,每次递归都会压栈,不退出就会溢出。
这些问题的答案,不在任何框架的文档里,而在数据结构的基础知识里。
下一篇预告 :《老程序员回炉补基础(三):二叉树------从递归遍历到非递归实现》