JavaScript当中的数据结构与算法

栈(Stack)

概念介绍

栈是一种**后进先出(LIFO - Last In First Out)**的线性数据结构。可以把栈想象成一个只有一个开口的容器,元素只能从顶部进入和离开。

栈的特点

  • 后进先出:最后压入栈的元素最先被弹出
  • 只能在栈顶操作:插入和删除操作都只能在栈顶进行
  • 线性结构:元素之间存在一对一的关系

JavaScript 中的栈实现

在 JavaScript 中没有内置的栈数据结构,但我们可以通过数组来模拟栈的行为:

js 复制代码
// 创建一个空栈
const stack = []

// 入栈操作 - push方法在数组末尾添加元素
stack.push(1)    // [1]
stack.push(2)    // [1, 2]
stack.push(3)    // [1, 2, 3]

// 出栈操作 - pop方法移除并返回数组末尾元素
const top = stack.pop()  // 返回 3,栈变为 [1, 2]

// 查看栈顶元素(不移除)
const peek = stack[stack.length - 1]  // 返回 2

// 检查栈是否为空
const isEmpty = stack.length === 0  // false

// 获取栈的大小
const size = stack.length  // 2

栈的基本操作

js 复制代码
class Stack {
  constructor() {
    this.items = []
  }
  
  // 入栈
  push(element) {
    this.items.push(element)
  }
  
  // 出栈
  pop() {
    if (this.isEmpty()) {
      return undefined
    }
    return this.items.pop()
  }
  
  // 查看栈顶元素
  peek() {
    if (this.isEmpty()) {
      return undefined
    }
    return this.items[this.items.length - 1]
  }
  
  // 检查是否为空
  isEmpty() {
    return this.items.length === 0
  }
  
  // 获取栈大小
  size() {
    return this.items.length
  }
  
  // 清空栈
  clear() {
    this.items = []
  }
}

栈的使用场景

栈在计算机科学和日常编程中有着广泛的应用:

1. 进制转换

  • 十进制转二进制:通过不断除以2,将余数压入栈中,最后依次弹出得到二进制表示
  • 表达式求值:中缀表达式转后缀表达式

2. 括号匹配

  • 有效括号判断:检查括号是否正确配对和嵌套
  • 编译器语法分析:检查代码中的括号、大括号、方括号是否匹配

3. 函数调用栈

  • 递归函数:系统自动管理函数调用和返回
  • 程序执行:保存函数的局部变量和返回地址

4. 浏览器历史记录

  • 前进后退功能:浏览器的历史记录管理
  • 撤销重做操作:文本编辑器的撤销功能

5. 算法应用

  • 深度优先搜索(DFS):图和树的遍历
  • 回溯算法:解决组合、排列等问题
  • 单调栈:解决下一个更大元素等问题

算法题: LeetCode 第20题 - 有效的括号

题目描述

给定一个只包括 '('')''{''}''['']' 的字符串 s,判断字符串是否有效。

有效字符串需满足:

  1. 左括号必须用相同类型的右括号闭合
  2. 左括号必须以正确的顺序闭合
  3. 每个右括号都有一个对应的相同类型的左括号

示例:

ini 复制代码
示例 1:
输入:s = "()"
输出:true

示例 2:
输入:s = "()[]{}"
输出:true

示例 3:
输入:s = "(]"
输出:false

示例 4:
输入:s = "([])"
输出:true

提示:

  • 1 <= s.length <= 10^4
  • s 仅由括号 '()[]{}' 组成

解题思路

这是一个典型的栈应用问题:

  1. 遇到左括号:将其压入栈中
  2. 遇到右括号 :检查栈顶是否为对应的左括号
    • 如果匹配,弹出栈顶元素
    • 如果不匹配或栈为空,返回 false
  3. 遍历结束 :检查栈是否为空
    • 栈为空说明所有括号都匹配
    • 栈不为空说明有未匹配的左括号

代码实现

js 复制代码
/**
 * @param {string} s
 * @return {boolean}
 */
function isValid(s) {
  // 如果字符串长度为奇数,直接返回 false
  if (s.length % 2 === 1) {
    return false
  }
  
  const stack = []
  const map = {
    ')': '(',
    ']': '[',
    '}': '{'
  }
  
  for (let char of s) {
    // 如果是右括号
    if (char in map) {
      // 检查栈是否为空或栈顶元素是否匹配
      if (stack.length === 0 || stack.pop() !== map[char]) {
        return false
      }
    } else {
      // 如果是左括号,压入栈中
      stack.push(char)
    }
  }
  
  // 栈为空说明所有括号都匹配
  return stack.length === 0
}

// 测试用例
console.log(isValid("()"))       // true
console.log(isValid("()[]{}"))   // true
console.log(isValid("(]"))       // false
console.log(isValid("([])"))     // true

复杂度分析

  • 时间复杂度:O(n),其中 n 是字符串的长度,需要遍历字符串一次
  • 空间复杂度:O(n),最坏情况下栈中存储所有的左括号

进阶:十进制转二进制

js 复制代码
/**
 * 使用栈将十进制转换为二进制
 * @param {number} decimal
 * @return {string}
 */
function decimalToBinary(decimal) {
  if (decimal === 0) return '0'
  
  const stack = []
  
  while (decimal > 0) {
    stack.push(decimal % 2)  // 将余数压入栈
    decimal = Math.floor(decimal / 2)
  }
  
  let binary = ''
  while (stack.length > 0) {
    binary += stack.pop()  // 依次弹出得到二进制
  }
  
  return binary
}

// 测试
console.log(decimalToBinary(10))  // "1010"
console.log(decimalToBinary(8))   // "1000"

栈的总结

栈是一种简单而强大的数据结构,其**后进先出(LIFO)**的特性使其在以下场景中特别有用:

  • 函数调用管理:递归和函数调用栈
  • 表达式处理:括号匹配、中缀转后缀
  • 算法实现:深度优先搜索、回溯算法
  • 撤销操作:编辑器的撤销功能
  • 浏览器历史:前进后退功能

💡 记忆口诀:只要遇到"后进先出"的场景,就考虑使用栈!


队列(Queue)

概念介绍

队列是一种**先进先出(FIFO - First In First Out)**的线性数据结构。可以把队列想象成排队买票,先来的人先服务,后来的人排在队尾等待。

队列的特点

  • 先进先出:最先进入队列的元素最先被移除
  • 两端操作:一端进行插入(队尾),另一端进行删除(队头)
  • 线性结构:元素之间存在一对一的关系

JavaScript 中的队列实现

在 JavaScript 中没有内置的队列数据结构,我们可以通过数组来模拟队列的行为:

js 复制代码
// 创建一个空队列
const queue = []

// 入队操作 - push方法在数组末尾添加元素(队尾)
queue.push(1)    // [1]
queue.push(2)    // [1, 2]
queue.push(3)    // [1, 2, 3]

// 出队操作 - shift方法移除并返回数组第一个元素(队头)
const first = queue.shift()  // 返回 1,队列变为 [2, 3]

// 查看队头元素(不移除)
const front = queue[0]  // 返回 2

// 查看队尾元素(不移除)
const rear = queue[queue.length - 1]  // 返回 3

// 检查队列是否为空
const isEmpty = queue.length === 0  // false

// 获取队列大小
const size = queue.length  // 2

队列的基本操作

js 复制代码
class Queue {
  constructor() {
    this.items = []
  }
  
  // 入队(在队尾添加元素)
  enqueue(element) {
    this.items.push(element)
  }
  
  // 出队(移除队头元素)
  dequeue() {
    if (this.isEmpty()) {
      return undefined
    }
    return this.items.shift()
  }
  
  // 查看队头元素
  front() {
    if (this.isEmpty()) {
      return undefined
    }
    return this.items[0]
  }
  
  // 查看队尾元素
  rear() {
    if (this.isEmpty()) {
      return undefined
    }
    return this.items[this.items.length - 1]
  }
  
  // 检查是否为空
  isEmpty() {
    return this.items.length === 0
  }
  
  // 获取队列大小
  size() {
    return this.items.length
  }
  
  // 清空队列
  clear() {
    this.items = []
  }
}

性能优化:双端队列实现

使用数组的 shift() 方法时间复杂度为 O(n),对于大量数据可能性能较差。可以使用对象来实现更高效的队列:

js 复制代码
class OptimizedQueue {
  constructor() {
    this.items = {}
    this.head = 0
    this.tail = 0
  }
  
  enqueue(element) {
    this.items[this.tail] = element
    this.tail++
  }
  
  dequeue() {
    if (this.isEmpty()) {
      return undefined
    }
    const item = this.items[this.head]
    delete this.items[this.head]
    this.head++
    return item
  }
  
  front() {
    return this.items[this.head]
  }
  
  isEmpty() {
    return this.head === this.tail
  }
  
  size() {
    return this.tail - this.head
  }
}

队列的使用场景

队列在计算机科学和实际应用中有着广泛的用途:

1. 任务调度

  • 操作系统进程调度:按照先来先服务的原则调度进程
  • 打印队列:多个打印任务按顺序执行
  • CPU任务调度:多任务系统中的任务管理

2. 前端开发

  • 事件循环:JavaScript 的事件队列机制
  • 微任务队列:Promise、async/await 的执行顺序
  • 宏任务队列:setTimeout、setInterval 等异步任务
  • 请求队列:控制并发请求数量

3. 广度优先搜索(BFS)

  • 图的遍历:层序遍历图的节点
  • 树的层序遍历:按层访问树的节点
  • 最短路径:在无权图中寻找最短路径

4. 缓存和缓冲

  • IO缓冲区:数据的临时存储
  • 网络数据包:按顺序处理网络请求
  • 流媒体缓冲:视频、音频的播放缓冲

5. 实际应用场景

  • 银行排队系统:客户按到达顺序服务
  • 呼叫中心:电话按接入顺序处理
  • 消息队列:系统间异步通信(如 RabbitMQ、Kafka)

算法题: LeetCode 第933题 - 最近请求的次数

题目描述

写一个 RecentCounter 类来计算特定时间范围内最近的请求。

请你实现 RecentCounter 类:

  • RecentCounter() 初始化计数器,请求数为 0
  • int ping(int t) 在时间 t 添加一个新请求,其中 t 表示以毫秒为单位的某个时间,并返回过去 3000 毫秒内发生的所有请求数(包括新请求)。确切地说,返回在 [t-3000, t] 内发生的请求数
  • 保证每次对 ping 的调用都使用比之前更大的 t 值

示例:

scss 复制代码
输入:
["RecentCounter", "ping", "ping", "ping", "ping"]
[[], [1], [100], [3001], [3002]]
输出:
[null, 1, 2, 3, 3]

解释:
RecentCounter recentCounter = new RecentCounter();
recentCounter.ping(1);     // requests = [1],范围是 [-2999,1],返回 1
recentCounter.ping(100);   // requests = [1, 100],范围是 [-2900,100],返回 2
recentCounter.ping(3001);  // requests = [1, 100, 3001],范围是 [1,3001],返回 3
recentCounter.ping(3002);  // requests = [1, 100, 3001, 3002],范围是 [2,3002],返回 3

提示:

  • 1 <= t <= 10^9
  • 保证每次对 ping 调用所使用的 t 值都严格递增
  • 至多调用 ping 方法 10^4

解题思路

这是一个典型的队列应用问题:

  1. 维护时间窗口:我们需要维护一个 3000 毫秒的滑动时间窗口
  2. 队列特性:由于时间是递增的,可以使用队列来存储请求时间
  3. 清理过期请求:每次新请求到来时,移除队列中超出时间窗口的请求
  4. 返回计数:队列的长度就是时间窗口内的请求数

算法步骤:

  1. 将新请求时间加入队列
  2. 从队头开始移除所有小于 t - 3000 的请求时间
  3. 返回队列的长度

代码实现

js 复制代码
/**
 * 最近请求计数器
 */
class RecentCounter {
  constructor() {
    this.requests = []  // 使用数组模拟队列
  }
  
  /**
   * @param {number} t
   * @return {number}
   */
  ping(t) {
    // 将新请求时间加入队列
    this.requests.push(t)
    
    // 移除超出时间窗口的请求(小于 t - 3000 的请求)
    while (this.requests.length > 0 && this.requests[0] < t - 3000) {
      this.requests.shift()
    }
    
    // 返回时间窗口内的请求数
    return this.requests.length
  }
}

// 测试用例
const recentCounter = new RecentCounter()
console.log(recentCounter.ping(1))     // 1
console.log(recentCounter.ping(100))   // 2
console.log(recentCounter.ping(3001))  // 3
console.log(recentCounter.ping(3002))  // 3

优化版本(使用对象实现队列)

js 复制代码
class OptimizedRecentCounter {
  constructor() {
    this.requests = {}
    this.head = 0
    this.tail = 0
  }
  
  ping(t) {
    // 入队
    this.requests[this.tail] = t
    this.tail++
    
    // 出队过期请求
    while (this.head < this.tail && this.requests[this.head] < t - 3000) {
      delete this.requests[this.head]
      this.head++
    }
    
    return this.tail - this.head
  }
}

复杂度分析

  • 时间复杂度
    • 每次 ping 操作:O(1) 平均情况,O(n) 最坏情况(需要清理大量过期请求)
    • 总体:O(n),其中 n 是总的 ping 调用次数
  • 空间复杂度:O(W),其中 W 是时间窗口内的最大请求数(最多 3000 个)

进阶:用队列实现栈

js 复制代码
/**
 * 用两个队列实现栈
 */
class MyStack {
  constructor() {
    this.queue1 = []
    this.queue2 = []
  }
  
  push(x) {
    this.queue1.push(x)
  }
  
  pop() {
    // 将 queue1 中除最后一个元素外的所有元素移到 queue2
    while (this.queue1.length > 1) {
      this.queue2.push(this.queue1.shift())
    }
    
    // 弹出最后一个元素
    const result = this.queue1.shift()
    
    // 交换两个队列
    [this.queue1, this.queue2] = [this.queue2, this.queue1]
    
    return result
  }
  
  top() {
    // 类似 pop,但不删除元素
    while (this.queue1.length > 1) {
      this.queue2.push(this.queue1.shift())
    }
    
    const result = this.queue1[0]
    this.queue2.push(this.queue1.shift())
    
    [this.queue1, this.queue2] = [this.queue2, this.queue1]
    
    return result
  }
  
  empty() {
    return this.queue1.length === 0
  }
}

队列的总结

队列是一种重要的线性数据结构,其**先进先出(FIFO)**的特性使其在以下场景中不可或缺:

  • 任务调度:操作系统进程调度、打印队列
  • 异步编程:JavaScript 事件循环、Promise 队列
  • 算法实现:广度优先搜索、层序遍历
  • 系统设计:消息队列、缓冲区管理
  • 实际应用:排队系统、请求限流

💡 记忆口诀:只要遇到"先进先出"或"排队"的场景,就考虑使用队列!


链表(Linked List)

概念介绍

链表是一种非连续存储的线性数据结构,通过指针将各个节点连接起来。与数组不同,链表的元素在内存中不是连续存储的,而是通过指针链接。

链表的特点

  • 动态大小:可以在运行时动态增加或减少节点
  • 非连续存储:节点在内存中可以分散存储
  • 指针连接:通过 next 指针指向下一个节点
  • 插入删除高效:在已知位置插入/删除的时间复杂度为 O(1)

链表的结构

css 复制代码
[数据|指针] -> [数据|指针] -> [数据|指针] -> null
     节点1          节点2          节点3

JavaScript 中的链表实现

在 JavaScript 中没有内置的链表数据结构,我们可以通过对象来模拟:

基础链表操作

js 复制代码
// 定义链表节点
class ListNode {
  constructor(val, next = null) {
    this.val = val
    this.next = next
  }
}

// 创建链表
const node1 = new ListNode(1)
const node2 = new ListNode(2)
const node3 = new ListNode(3)

// 连接节点
node1.next = node2
node2.next = node3
// 链表结构:1 -> 2 -> 3 -> null

// 遍历链表
function traverseList(head) {
  let current = head
  const values = []
  
  while (current !== null) {
    values.push(current.val)
    current = current.next
  }
  
  return values
}

console.log(traverseList(node1))  // [1, 2, 3]

完整的链表类实现

js 复制代码
class LinkedList {
  constructor() {
    this.head = null
    this.size = 0
  }
  
  // 在链表头部插入节点
  prepend(val) {
    const newNode = new ListNode(val, this.head)
    this.head = newNode
    this.size++
  }
  
  // 在链表尾部插入节点
  append(val) {
    const newNode = new ListNode(val)
    
    if (!this.head) {
      this.head = newNode
    } else {
      let current = this.head
      while (current.next) {
        current = current.next
      }
      current.next = newNode
    }
    this.size++
  }
  
  // 在指定位置插入节点
  insert(index, val) {
    if (index < 0 || index > this.size) {
      throw new Error('Index out of bounds')
    }
    
    if (index === 0) {
      this.prepend(val)
      return
    }
    
    const newNode = new ListNode(val)
    let current = this.head
    
    for (let i = 0; i < index - 1; i++) {
      current = current.next
    }
    
    newNode.next = current.next
    current.next = newNode
    this.size++
  }
  
  // 删除指定位置的节点
  removeAt(index) {
    if (index < 0 || index >= this.size) {
      throw new Error('Index out of bounds')
    }
    
    if (index === 0) {
      this.head = this.head.next
      this.size--
      return
    }
    
    let current = this.head
    for (let i = 0; i < index - 1; i++) {
      current = current.next
    }
    
    current.next = current.next.next
    this.size--
  }
  
  // 删除指定值的节点
  remove(val) {
    if (!this.head) return false
    
    if (this.head.val === val) {
      this.head = this.head.next
      this.size--
      return true
    }
    
    let current = this.head
    while (current.next && current.next.val !== val) {
      current = current.next
    }
    
    if (current.next) {
      current.next = current.next.next
      this.size--
      return true
    }
    
    return false
  }
  
  // 查找节点
  find(val) {
    let current = this.head
    let index = 0
    
    while (current) {
      if (current.val === val) {
        return index
      }
      current = current.next
      index++
    }
    
    return -1
  }
  
  // 获取指定位置的值
  get(index) {
    if (index < 0 || index >= this.size) {
      throw new Error('Index out of bounds')
    }
    
    let current = this.head
    for (let i = 0; i < index; i++) {
      current = current.next
    }
    
    return current.val
  }
  
  // 获取链表大小
  getSize() {
    return this.size
  }
  
  // 检查链表是否为空
  isEmpty() {
    return this.size === 0
  }
  
  // 转换为数组
  toArray() {
    const result = []
    let current = this.head
    
    while (current) {
      result.push(current.val)
      current = current.next
    }
    
    return result
  }
  
  // 清空链表
  clear() {
    this.head = null
    this.size = 0
  }
}

// 使用示例
const list = new LinkedList()
list.append(1)
list.append(2)
list.append(3)
list.prepend(0)
console.log(list.toArray())  // [0, 1, 2, 3]

list.insert(2, 1.5)
console.log(list.toArray())  // [0, 1, 1.5, 2, 3]

list.remove(1.5)
console.log(list.toArray())  // [0, 1, 2, 3]

链表的使用场景

链表在计算机科学和实际开发中有着重要的应用:

1. 动态内存管理

  • 内存分配:操作系统的内存管理
  • 垃圾回收:标记清除算法中的对象链接
  • 缓存实现:LRU 缓存的实现

2. 数据结构基础

  • 栈和队列:可以用链表实现栈和队列
  • 图的邻接表:表示图的边关系
  • 哈希表:解决哈希冲突的链地址法

3. 实际应用

  • 音乐播放器:歌曲播放列表的管理
  • 浏览器历史:前进后退功能的实现
  • 文本编辑器:撤销重做功能
  • 区块链:区块之间的链接关系

4. 算法应用

  • 递归算法:树和图的遍历
  • 动态规划:状态转移的表示
  • 字符串处理:模式匹配算法

链表 vs 数组对比

特性 数组 链表
内存存储 连续存储 分散存储
访问元素 O(1) 随机访问 O(n) 顺序访问
插入删除 O(n) 需要移动元素 O(1) 已知位置
内存开销 较小 较大(需要存储指针)
缓存友好
大小限制 固定大小 动态大小

算法题: LeetCode 第2题 - 两数相加

题目描述

给你两个非空 的链表,表示两个非负的整数。它们每位数字都是按照逆序 的方式存储的,并且每个节点只能存储一位数字。

请你将两个数相加,并以相同形式返回一个表示和的链表。

你可以假设除了数字 0 之外,这两个数都不会以 0 开头。

示例:

ini 复制代码
示例 1:
输入:l1 = [2,4,3], l2 = [5,6,4]
输出:[7,0,8]
解释:342 + 465 = 807

示例 2:
输入:l1 = [0], l2 = [0]
输出:[0]

示例 3:
输入:l1 = [9,9,9,9,9,9,9], l2 = [9,9,9,9]
输出:[8,9,9,9,0,0,0,1]
解释:9999999 + 9999 = 10009998

提示:

  • 每个链表中的节点数在范围 [1, 100]
  • 0 <= Node.val <= 9
  • 题目数据保证列表表示的数字不含前导零

解题思路

这道题模拟了我们手工计算加法的过程:

  1. 从低位开始:由于链表是逆序存储,正好从个位开始计算
  2. 处理进位:当两位相加大于等于10时,需要向高位进位
  3. 处理不同长度:两个链表长度可能不同,需要特殊处理
  4. 最终进位:计算完成后可能还有最后一位进位

算法步骤:

  1. 初始化进位变量 carry = 0
  2. 同时遍历两个链表,计算当前位的和
  3. 创建新节点存储结果的个位数
  4. 更新进位值
  5. 处理剩余的进位

代码实现

js 复制代码
/**
 * Definition for singly-linked list.
 * function ListNode(val, next) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.next = (next===undefined ? null : next)
 * }
 */

/**
 * @param {ListNode} l1
 * @param {ListNode} l2
 * @return {ListNode}
 */
function addTwoNumbers(l1, l2) {
  const dummy = new ListNode(0)  // 哨兵节点,简化边界处理
  let current = dummy
  let carry = 0  // 进位
  
  // 当还有节点需要处理或还有进位时继续循环
  while (l1 !== null || l2 !== null || carry !== 0) {
    // 获取当前位的值,如果节点为空则为0
    const val1 = l1 ? l1.val : 0
    const val2 = l2 ? l2.val : 0
    
    // 计算当前位的和
    const sum = val1 + val2 + carry
    
    // 创建新节点存储结果的个位数
    current.next = new ListNode(sum % 10)
    current = current.next
    
    // 更新进位
    carry = Math.floor(sum / 10)
    
    // 移动到下一个节点
    if (l1) l1 = l1.next
    if (l2) l2 = l2.next
  }
  
  return dummy.next  // 返回结果链表(跳过哨兵节点)
}

// 辅助函数:数组转链表
function arrayToList(arr) {
  const dummy = new ListNode(0)
  let current = dummy
  
  for (const val of arr) {
    current.next = new ListNode(val)
    current = current.next
  }
  
  return dummy.next
}

// 辅助函数:链表转数组
function listToArray(head) {
  const result = []
  let current = head
  
  while (current) {
    result.push(current.val)
    current = current.next
  }
  
  return result
}

// 测试用例
const l1 = arrayToList([2, 4, 3])  // 表示数字 342
const l2 = arrayToList([5, 6, 4])  // 表示数字 465
const result = addTwoNumbers(l1, l2)
console.log(listToArray(result))   // [7, 0, 8] 表示数字 807

复杂度分析

  • 时间复杂度:O(max(m, n)),其中 m 和 n 分别是两个链表的长度
  • 空间复杂度:O(max(m, n)),新链表的长度最多为较长链表长度 + 1

进阶:反转链表

js 复制代码
/**
 * 反转链表 - 迭代方法
 * @param {ListNode} head
 * @return {ListNode}
 */
function reverseList(head) {
  let prev = null
  let current = head
  
  while (current !== null) {
    const next = current.next  // 保存下一个节点
    current.next = prev        // 反转指针
    prev = current             // 移动 prev
    current = next             // 移动 current
  }
  
  return prev  // prev 成为新的头节点
}

/**
 * 反转链表 - 递归方法
 * @param {ListNode} head
 * @return {ListNode}
 */
function reverseListRecursive(head) {
  // 基础情况
  if (head === null || head.next === null) {
    return head
  }
  
  // 递归反转剩余部分
  const newHead = reverseListRecursive(head.next)
  
  // 反转当前连接
  head.next.next = head
  head.next = null
  
  return newHead
}

// 测试反转链表
 const originalList = arrayToList([1, 2, 3, 4, 5])
 console.log('原链表:', listToArray(originalList))  // [1, 2, 3, 4, 5]
 
 const reversedList = reverseList(originalList)
 console.log('反转后:', listToArray(reversedList))  // [5, 4, 3, 2, 1]

链表的总结

链表是一种灵活的动态数据结构,其指针连接的特性使其在以下场景中特别有用:

  • 动态内存管理:大小可变,内存利用率高
  • 高效插入删除:在已知位置操作时间复杂度为 O(1)
  • 实现其他数据结构:栈、队列、图的基础
  • 处理大数据:避免数组的内存限制
  • 算法应用:递归、回溯、动态规划

链表的权衡:

  • 随机访问慢:访问第 n 个元素需要 O(n) 时间
  • 额外内存开销:需要存储指针
  • 缓存不友好:节点在内存中分散存储

💡 选择建议:当需要频繁插入删除且很少随机访问时,选择链表;当需要频繁随机访问时,选择数组。


集合(Set)

概念介绍

集合是一种不允许重复元素的数据结构,它基于数学中集合的概念。集合中的每个元素都是唯一的,常用于去重、成员检测和集合运算。

集合的特点

  • 元素唯一性:不允许重复元素
  • 无序性:元素没有固定的顺序(在某些实现中)
  • 快速查找:检查元素是否存在的时间复杂度通常为 O(1)
  • 集合运算:支持并集、交集、差集等数学运算

JavaScript 中的集合实现

JavaScript 提供了内置的 Set 对象,同时我们也可以用其他方式模拟集合:

使用 ES6 Set

js 复制代码
// 创建集合
const set = new Set()

// 添加元素
set.add(1)
set.add(2)
set.add(3)
set.add(2)  // 重复元素,不会被添加
console.log(set)  // Set(3) {1, 2, 3}

// 从数组创建集合(自动去重)
const setFromArray = new Set([1, 2, 2, 3, 3, 4])
console.log(setFromArray)  // Set(4) {1, 2, 3, 4}

// 检查元素是否存在
console.log(set.has(2))    // true
console.log(set.has(5))    // false

// 删除元素
set.delete(2)
console.log(set.has(2))    // false

// 获取集合大小
console.log(set.size)      // 2

// 遍历集合
for (const value of set) {
  console.log(value)
}

// 转换为数组
const array = [...set]     // [1, 3]
const array2 = Array.from(set)  // [1, 3]

// 清空集合
set.clear()
console.log(set.size)      // 0

自定义集合类实现

js 复制代码
class MySet {
  constructor(iterable = []) {
    this.items = {}
    this.count = 0
    
    // 从可迭代对象初始化
    for (const item of iterable) {
      this.add(item)
    }
  }
  
  // 添加元素
  add(element) {
    const key = this._getKey(element)
    if (!this.items.hasOwnProperty(key)) {
      this.items[key] = element
      this.count++
      return true
    }
    return false
  }
  
  // 删除元素
  delete(element) {
    const key = this._getKey(element)
    if (this.items.hasOwnProperty(key)) {
      delete this.items[key]
      this.count--
      return true
    }
    return false
  }
  
  // 检查元素是否存在
  has(element) {
    const key = this._getKey(element)
    return this.items.hasOwnProperty(key)
  }
  
  // 获取集合大小
  get size() {
    return this.count
  }
  
  // 清空集合
  clear() {
    this.items = {}
    this.count = 0
  }
  
  // 获取所有值
  values() {
    return Object.values(this.items)
  }
  
  // 转换为数组
  toArray() {
    return this.values()
  }
  
  // 并集
  union(otherSet) {
    const unionSet = new MySet()
    
    // 添加当前集合的所有元素
    for (const value of this.values()) {
      unionSet.add(value)
    }
    
    // 添加另一个集合的所有元素
    for (const value of otherSet.values()) {
      unionSet.add(value)
    }
    
    return unionSet
  }
  
  // 交集
  intersection(otherSet) {
    const intersectionSet = new MySet()
    
    for (const value of this.values()) {
      if (otherSet.has(value)) {
        intersectionSet.add(value)
      }
    }
    
    return intersectionSet
  }
  
  // 差集
  difference(otherSet) {
    const differenceSet = new MySet()
    
    for (const value of this.values()) {
      if (!otherSet.has(value)) {
        differenceSet.add(value)
      }
    }
    
    return differenceSet
  }
  
  // 子集判断
  isSubsetOf(otherSet) {
    for (const value of this.values()) {
      if (!otherSet.has(value)) {
        return false
      }
    }
    return true
  }
  
  // 生成键值(处理不同数据类型)
  _getKey(element) {
    if (typeof element === 'string') {
      return `string:${element}`
    } else if (typeof element === 'number') {
      return `number:${element}`
    } else if (typeof element === 'boolean') {
      return `boolean:${element}`
    } else if (element === null) {
      return 'null'
    } else if (element === undefined) {
      return 'undefined'
    } else {
      return `object:${JSON.stringify(element)}`
    }
  }
  
  // 迭代器支持
  [Symbol.iterator]() {
    const values = this.values()
    let index = 0
    
    return {
      next() {
        if (index < values.length) {
          return { value: values[index++], done: false }
        } else {
          return { done: true }
        }
      }
    }
  }
}

// 使用示例
const set1 = new MySet([1, 2, 3])
const set2 = new MySet([3, 4, 5])

console.log('set1:', set1.toArray())  // [1, 2, 3]
console.log('set2:', set2.toArray())  // [3, 4, 5]

// 集合运算
const union = set1.union(set2)
console.log('并集:', union.toArray())  // [1, 2, 3, 4, 5]

const intersection = set1.intersection(set2)
console.log('交集:', intersection.toArray())  // [3]

const difference = set1.difference(set2)
 console.log('差集:', difference.toArray())  // [1, 2]

集合的使用场景

集合在实际开发和算法中有着广泛的应用:

1. 数据去重

  • 数组去重:快速移除重复元素
  • 用户去重:避免重复的用户ID或邮箱
  • 标签系统:文章标签的唯一性管理

2. 成员检测

  • 权限验证:检查用户是否有特定权限
  • 黑白名单:IP地址或用户的访问控制
  • 关键词过滤:敏感词检测

3. 集合运算

  • 数据分析:用户群体的交集、并集分析
  • 推荐系统:基于共同兴趣的推荐
  • A/B测试:实验组和对照组的用户管理

4. 算法优化

  • 图算法:已访问节点的记录
  • 动态规划:状态去重
  • 回溯算法:避免重复搜索

集合的实际应用示例

数组去重的多种方法

js 复制代码
// 方法1:使用 Set(最简洁)
function uniqueWithSet(arr) {
  return [...new Set(arr)]
}

// 方法2:使用 filter + indexOf
function uniqueWithFilter(arr) {
  return arr.filter((item, index) => arr.indexOf(item) === index)
}

// 方法3:使用 reduce
function uniqueWithReduce(arr) {
  return arr.reduce((unique, item) => {
    return unique.includes(item) ? unique : [...unique, item]
  }, [])
}

// 方法4:使用 Map(保持插入顺序)
function uniqueWithMap(arr) {
  const map = new Map()
  arr.forEach(item => map.set(item, true))
  return [...map.keys()]
}

// 测试
const testArray = [1, 2, 2, 3, 4, 4, 5]
console.log(uniqueWithSet(testArray))     // [1, 2, 3, 4, 5]
console.log(uniqueWithFilter(testArray))  // [1, 2, 3, 4, 5]
console.log(uniqueWithReduce(testArray))  // [1, 2, 3, 4, 5]
console.log(uniqueWithMap(testArray))     // [1, 2, 3, 4, 5]

权限管理系统

js 复制代码
class PermissionManager {
  constructor() {
    this.userPermissions = new Map()  // 用户ID -> Set(权限)
    this.rolePermissions = new Map()  // 角色 -> Set(权限)
  }
  
  // 为用户分配权限
  grantPermission(userId, permission) {
    if (!this.userPermissions.has(userId)) {
      this.userPermissions.set(userId, new Set())
    }
    this.userPermissions.get(userId).add(permission)
  }
  
  // 撤销用户权限
  revokePermission(userId, permission) {
    if (this.userPermissions.has(userId)) {
      this.userPermissions.get(userId).delete(permission)
    }
  }
  
  // 检查用户是否有权限
  hasPermission(userId, permission) {
    const userPerms = this.userPermissions.get(userId)
    return userPerms ? userPerms.has(permission) : false
  }
  
  // 获取用户所有权限
  getUserPermissions(userId) {
    const userPerms = this.userPermissions.get(userId)
    return userPerms ? [...userPerms] : []
  }
  
  // 获取有特定权限的所有用户
  getUsersWithPermission(permission) {
    const users = []
    for (const [userId, permissions] of this.userPermissions) {
      if (permissions.has(permission)) {
        users.push(userId)
      }
    }
    return users
  }
}

// 使用示例
const pm = new PermissionManager()
pm.grantPermission('user1', 'read')
pm.grantPermission('user1', 'write')
pm.grantPermission('user2', 'read')

console.log(pm.hasPermission('user1', 'write'))  // true
console.log(pm.hasPermission('user2', 'write'))  // false
console.log(pm.getUsersWithPermission('read'))   // ['user1', 'user2']

算法题: LeetCode 相关集合问题

1. 两个数组的交集

js 复制代码
/**
 * 给定两个数组,编写一个函数来计算它们的交集
 * @param {number[]} nums1
 * @param {number[]} nums2
 * @return {number[]}
 */
function intersection(nums1, nums2) {
  const set1 = new Set(nums1)
  const set2 = new Set(nums2)
  
  return [...set1].filter(num => set2.has(num))
}

// 或者使用集合运算
function intersectionWithSet(nums1, nums2) {
  const set1 = new Set(nums1)
  const result = new Set()
  
  for (const num of nums2) {
    if (set1.has(num)) {
      result.add(num)
    }
  }
  
  return [...result]
}

// 测试
console.log(intersection([1, 2, 2, 1], [2, 2]))     // [2]
console.log(intersection([4, 9, 5], [9, 4, 9, 8, 4])) // [9, 4]

2. 快乐数

js 复制代码
/**
 * 判断一个数是否为快乐数
 * 快乐数:对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和,
 * 然后重复这个过程直到这个数变为1,或是无限循环但始终变不到1
 * @param {number} n
 * @return {boolean}
 */
function isHappy(n) {
  const seen = new Set()  // 记录已经出现过的数字
  
  while (n !== 1 && !seen.has(n)) {
    seen.add(n)
    n = getSquareSum(n)
  }
  
  return n === 1
}

function getSquareSum(n) {
  let sum = 0
  while (n > 0) {
    const digit = n % 10
    sum += digit * digit
    n = Math.floor(n / 10)
  }
  return sum
}

// 测试
console.log(isHappy(19))  // true (1^2 + 9^2 = 82, 8^2 + 2^2 = 68, ...)
console.log(isHappy(2))   // false

3. 存在重复元素

js 复制代码
/**
 * 给定一个整数数组,判断是否存在重复元素
 * @param {number[]} nums
 * @return {boolean}
 */
function containsDuplicate(nums) {
  const seen = new Set()
  
  for (const num of nums) {
    if (seen.has(num)) {
      return true
    }
    seen.add(num)
  }
  
  return false
}

// 更简洁的写法
function containsDuplicateSimple(nums) {
  return new Set(nums).size !== nums.length
}

// 测试
console.log(containsDuplicate([1, 2, 3, 1]))     // true
console.log(containsDuplicate([1, 2, 3, 4]))     // false
console.log(containsDuplicateSimple([1, 1, 1, 3, 3, 4, 3, 2, 4, 2])) // true

集合的总结

集合是一种强大的数据结构,其元素唯一性快速查找的特性使其在以下场景中不可或缺:

  • 数据去重:快速移除重复元素,时间复杂度 O(n)
  • 成员检测:O(1) 时间复杂度检查元素是否存在
  • 集合运算:并集、交集、差集等数学运算
  • 算法优化:避免重复计算,提高算法效率
  • 权限管理:用户权限、角色管理的理想选择

集合的性能特点:

  • 查找快速:平均 O(1) 时间复杂度
  • 插入删除高效:平均 O(1) 时间复杂度
  • 自动去重:无需手动处理重复元素
  • 无序性:元素没有固定顺序(ES6 Set 保持插入顺序)
  • 额外内存:需要额外的哈希表空间

💡 使用建议:当需要快速查找、去重或进行集合运算时,优先考虑使用集合!


总结

本文详细介绍了四种重要的数据结构:栈、队列、链表和集合。每种数据结构都有其独特的特性和适用场景:

数据结构对比表

数据结构 主要特性 时间复杂度 适用场景
后进先出(LIFO) 插入/删除: O(1) 函数调用、表达式求值、撤销操作
队列 先进先出(FIFO) 插入/删除: O(1) 任务调度、BFS、事件处理
链表 指针连接、动态大小 插入/删除: O(1), 查找: O(n) 动态内存管理、实现其他数据结构
集合 元素唯一、快速查找 查找/插入/删除: O(1) 去重、成员检测、集合运算

选择指南

  • 🔄 需要撤销/回退功能 → 选择栈
  • 📋 需要排队/调度功能 → 选择队列
  • 🔗 需要频繁插入删除 → 选择链表
  • 🎯 需要去重/快速查找 → 选择集合

学习建议

  1. 理解原理:掌握每种数据结构的核心特性
  2. 动手实现:自己编写代码实现这些数据结构
  3. 刷题练习:通过 LeetCode 等平台巩固理解
  4. 实际应用:在项目中合理选择和使用数据结构
  5. 性能分析:了解不同操作的时间和空间复杂度

掌握这些基础数据结构是成为优秀程序员的必经之路,它们不仅是算法的基础,也是解决实际问题的重要工具。希望这份总结能帮助你更好地理解和应用这些数据结构!

相关推荐
阿珊和她的猫1 小时前
组件之间的双向绑定:v-model
前端·javascript·vue.js·typescript
爱分享的程序员1 小时前
Node.js 实训专栏规划目录
前端·javascript·node.js
星垂野2 小时前
JavaScript 执行栈和执行上下文详解
前端·javascript
水冗水孚2 小时前
express使用node-schedule实现定时任务,比如定时清理文件夹中的文件写入日志功能
javascript·node.js·express
天平2 小时前
使用https-proxy-agent下载墙外资源
前端·javascript
sirius星夜3 小时前
鸿蒙开发实践:深入使用 AppGallery Connect 提升应用开发效率
javascript
sirius星夜4 小时前
鸿蒙功效:"AbilitySlice"的远程启动和参数传递
javascript
彬师傅4 小时前
JSAPITHREE-自定义瓦片服务加载
前端·javascript
梦语花4 小时前
深入探讨前端本地存储方案:Dexie.js 与其他存储方式的对比
javascript