从零搞懂线性数据结构:数组、栈、队列和链表

0 阅读8分钟

如果你刚刚接触编程,可能会觉得“数据结构”四个字听起来像天书。别怕,今天我们就把计算机里最基础的几种线性数据结构彻底讲透。你只要跟着思路,一步一步敲代码,就一定能懂。

什么是线性数据结构?

想象你有一排储物柜,每个柜子只能放一样东西,而且柜子之间按顺序排成一列。每个柜子都有一个“前一个”和“后一个”(除了头尾)。这种一个一个排成线的结构,就叫线性结构

常见的线性数据结构有:数组链表队列。它们就像不同的储物柜整理方式,各有各的脾气和适用场景。


一、数组 —— 最老实的“连续座位”

数组是最基础、最常用的数据结构。它把数据存在一段连续的内存空间里,每个元素都有一个编号(下标) ,从 0 开始。

javascript

// 一个简单的数组
const scores = [95, 87, 92, 88];
console.log(scores[0]); // 95 —— 直接通过下标访问,速度极快

数组的优点

  • 随机访问快:给定下标,一步就能拿到数据,时间复杂度 O(1)。
  • 内存连续,对 CPU 缓存友好。

数组的缺点

  • 插入和删除慢:比如你要在数组中间插一个新元素,那么这个位置之后的所有元素都得往后挪一位。
  • 可能扩容:如果数组满了,再 push 新元素,JS 引擎会重新申请一块更大的连续空间,把旧元素复制过去,再追加新元素。这个操作会消耗时间和内存。

javascript

const arr = [1, 2, 3];
// 在下标 1 的位置插入 99
arr.splice(1, 0, 99);
console.log(arr); // [1, 99, 2, 3] —— 原本的 2 和 3 都往右挪了

// 删除下标 2 的元素
arr.splice(2, 1);
console.log(arr); // [1, 99, 3] —— 3 左移了一位

数组的增删方法(JS 视角)

方法作用对原数组的影响
push(x)末尾添加 x改变原数组
pop()删除末尾元素改变原数组
unshift(x)开头添加 x改变原数组,所有元素后移
shift()删除开头元素改变原数组,所有元素前移
splice(start, deleteCount, ...items)万能增删改改变原数组

示例:

javascript

const arr = [10, 20, 30];

// push / pop —— 只在末尾操作,最快
arr.push(40);
console.log(arr); // [10, 20, 30, 40]
arr.pop();
console.log(arr); // [10, 20, 30]

// unshift / shift —— 在开头操作,所有元素都要动
arr.unshift(5);
console.log(arr); // [5, 10, 20, 30] —— 10,20,30 都往右移了
arr.shift();
console.log(arr); // [10, 20, 30]

// splice —— 任意位置操作
arr.splice(1, 0, 15); // 在下标 1 处插入 15,不删除任何元素
console.log(arr); // [10, 15, 20, 30]
arr.splice(2, 1); // 删除下标 2 的元素(20)
console.log(arr); // [10, 15, 30]

小秘密:JS 的数组不一定是真的“连续数组”

C / Java 里的数组,所有元素类型必须相同,内存严格连续。但 JavaScript 的数组更灵活:

javascript

const mixed = ["hello", 100, { name: "Tom" }, true];
console.log(mixed[2].name); // "Tom"

如果数组里存了不同类型,JS 底层会改用哈希表(类似对象)来存储,下标访问不再是直接内存寻址。这种“伪数组”虽然方便,但性能不如纯同类型数组。不过对于日常开发,你基本不用操心这件事。


二、栈 —— “后进先出”的碟子架

栈是一种操作受限的线性结构。它只允许在同一端(称为栈顶)进行插入和删除,另一端封死。就像叠盘子:你只能从最上面拿盘子,也只能往最上面放盘子。后放进去的盘子,会先被拿出来——这就是 LIFO(Last In First Out)

栈的实现(用数组)

JS 里用数组实现栈极其简单:push 入栈,pop 出栈,stack[stack.length-1] 看一眼栈顶(peek)。

javascript

// 创建一个空栈
const iceStack = [];

// 入栈 —— 往栈顶加元素
iceStack.push("东北大板");
iceStack.push("可爱多");
iceStack.push("冰工厂");
iceStack.push("巧乐兹");

console.log(iceStack); // ["东北大板", "可爱多", "冰工厂", "巧乐兹"]

// 查看栈顶(不取出)
const top = iceStack[iceStack.length - 1];
console.log(`栈顶是:${top}`); // 巧乐兹

// 出栈 —— 一直取到栈空
while (iceStack.length) {
  const current = iceStack.pop();
  console.log(`取出:${current}`);
}
// 输出顺序:巧乐兹 → 冰工厂 → 可爱多 → 东北大板
console.log(iceStack); // []

栈的真实应用

  • 函数调用栈:JS 执行函数时,会把函数信息压栈,函数返回时出栈。
  • 浏览器历史记录(后退按钮)。
  • 括号匹配表达式求值等算法。

三、队列 —— “先进先出”的排队窗口

队列也是操作受限的线性结构,但它的一头只用来入队(添加) ,另一头只用来出队(删除) 。就像你去食堂打饭:新来的同学排到队尾,打饭的同学从队首离开。先来的人先服务——这就是 FIFO(First In First Out)

队列的实现(用数组)

用数组实现队列:push 入队(队尾),shift 出队(队首)。

javascript

const queue = []; // 空队列

// 入队
queue.push("徐同学");
queue.push("叶同学");
queue.push("戴同学");

console.log(queue); // ["徐同学", "叶同学", "戴同学"]

// 出队 —— 一直服务到没人
while (queue.length) {
  const next = queue.shift(); // 从队首取出
  console.log(`${next} 取餐`);
}
// 输出顺序:徐同学 → 叶同学 → 戴同学
console.log(queue); // []

⚠️ 注意shift() 会删除数组第一个元素,同时让后面所有元素向前移动一位。如果队列非常大,频繁 shift 会有性能问题。实际开发中,可以用“循环队列”或“双端队列”优化,但作为基础理解,用数组足够了。

队列的真实应用

  • 任务队列(JS 事件循环中的宏任务、微任务)
  • 打印机任务排队
  • 广度优先搜索(BFS)

四、链表 —— “手拉手”的不连续队伍

数组要求内存连续,就像全班同学必须坐在同一排连续的座位上。但如果中途有人离开,空出来的位置很难再利用,插班生来了也可能没连续座位。

链表解决了这个问题:它让每个元素(称为节点只关心下一个元素在哪里,节点本身可以在内存的任何角落。每个节点包含:

  • val:存储的数据
  • next:指向下一个节点的引用(指针)

多个节点通过 next 串成一条链。只要知道头节点(head) ,就能顺着链找到所有节点。最后一个节点的 next 为 null

链表节点的 JS 表示

javascript

// 定义节点构造函数
function ListNode(val) {
  this.val = val;
  this.next = null;
}

// 创建第一个节点,值为 1
const node1 = new ListNode(1);
// 创建第二个节点,值为 2,并挂到 node1 后面
node1.next = new ListNode(2);
// 再创建一个节点 3,挂到 2 后面
node1.next.next = new ListNode(3);

console.log(node1);
// 输出结构:
// ListNode {
//   val: 1,
//   next: ListNode { val: 2, next: ListNode { val: 3, next: null } }
// }

// 遍历链表
let current = node1;
while (current !== null) {
  console.log(current.val);
  current = current.next;
}
// 输出 1 2 3

链表的优点与缺点

对比项数组链表
内存连续,可能需要扩容复制不连续,按需分配,无扩容成本
访问直接下标 O(1)必须从头遍历 O(n)
插入/删除(已知位置)需移动后续元素 O(n)只需改指针 O(1)
额外内存几乎没有每个节点多存一个 next 指针

链表的插入(在某个节点后插入新节点)

javascript

// 假设我们已经有 node1 -> node2
// 要在 node1 和 node2 之间插入 newNode
const newNode = new ListNode(99);
newNode.next = node1.next; // 新节点指向 node1 原来的下一个
node1.next = newNode;      // node1 指向新节点
// 现在:node1 -> newNode(99) -> node2

链表的删除(删除某个节点的下一个节点)

javascript

// 删除 node1 后面的那个节点(即 newNode)
if (node1.next !== null) {
  node1.next = node1.next.next;
}
// 现在:node1 -> node2,node2 被跳过了,JS 垃圾回收会自动清理

什么时候用链表?

  • 需要频繁插入、删除,且数据量较大时。
  • 无法预估数据规模,不希望数组扩容带来的开销。
  • 对随机访问没要求,主要是按顺序遍历。

五、对比总结一张表

数据结构存储方式访问速度插入/删除速度适用场景
数组连续内存O(1) 极快O(n) 较慢(移动元素)频繁读取,较少增删
链表离散节点 + 指针O(n) 慢O(1) 快(已知前驱)频繁增删,较少随机读取
数组或链表实现只能访问栈顶仅栈顶操作 O(1)函数调用、回溯、括号匹配
队列数组或链表实现只能访问队首队尾入队 O(1),队首出队 O(1)(需优化)排队、任务调度、BFS

六、最后的小彩蛋:数组排序的坑

很多新手用 sort() 不传参数,结果排序结果匪夷所思:

javascript

let arr = [10, 2, 5, 100];
arr.sort(); // 默认按字符串排序
console.log(arr); // [10, 100, 2, 5] —— 并不是数值大小!

// 正确打开方式:传入比较函数
arr.sort((a, b) => a - b); // 升序
console.log(arr); // [2, 5, 10, 100]

原理:sort() 默认把所有元素转成字符串,然后按 UTF-16 码点排序。所以 "10" 排在 "2" 前面。传入 (a,b)=>a-b 后,排序算法会根据返回值决定顺序(负数则 a 在前,正数则 b 在前)。


总结

今天我们学了:

  • 数组:连续存储,下标访问,增删慢,但查改快。
  • :后进先出,只在栈顶操作,用 push/pop 实现。
  • 队列:先进先出,队尾入队、队首出队,用 push/shift 实现。
  • 链表:非连续存储,每个节点指向下一个,增删快,访问慢。

这些线性结构是算法世界的基石。只要你动手把上面的每一段代码都敲一遍、改一遍、跑一遍,它们就会变成你顺手的好工具。下一篇文章,我们将进入树和图的非线性世界,那会是另一片精彩的天地。

保持好奇,保持敲击。