阅读视图

发现新文章,点击刷新页面。

从静态数组到环形数组:手把手实现与底层原理剖析

从静态数组到环形数组:手把手实现与底层原理剖析

数组作为最基础的数据结构,其核心优势是O(1)级随机访问能力,而动态数组与环形数组则是在静态数组基础上的优化与拓展。本文将先拆解静态数组的存储原理与增删查改逻辑,再一步步实现新手友好的闭区间环形数组,帮你吃透数组类结构的底层运行机制。

TL;DR

circle_arr.png

一、数组顺序存储的基本原理

静态数组是一段连续的内存空间,通过首地址和索引即可直接计算目标元素的内存地址,这也是其随机访问高效的核心原因。

1. 静态数组的内存机制

以C++代码为例,静态数组的内存分配与访问逻辑如下:


// 开辟10个int类型的连续内存空间,共40字节(1个int占4字节)
// arr为指针,指向这段空间的首地址
int arr[10];

// 初始化内存,避免二手内存数据干扰
memset(arr, 0, sizeof(arr));

// 给首地址对应的内存写入1
arr[0] = 1;
// 给首地址偏移4字节(1*sizeof(int))的位置写入2
arr[1] = 2;

// 随机访问:通过索引计算地址,时间复杂度O(1)
int a = arr[0];

由于内存寻址时间可视为O(1),静态数组的随机访问(查、改)操作时间复杂度均为O(1),但增删操作受限于连续内存特性,效率会因位置不同而有差异。

2. 基于静态数组的实现动态数组的增删查改操作

数组的核心职责是增删查改,其中查和改基于随机访问特性已实现高效,重点在于增删操作的逻辑与复杂度分析。 通过对静态数组的操作来理解动态数组的API。

(1)增加操作

增加操作分“空间未满”和“空间已满”两种场景,复杂度随插入位置变化。

场景1:空间未满

现有长度为10的数组,前4个元素为0、1、2、3:

  • 尾部追加(push):直接给索引4赋值,时间复杂度O(1),代码为arr[4] = 4

  • 中间插入(insert):需倒序移动元素腾位置,避免覆盖已有数据,时间复杂度O(N)。例如在索引2插入666:


// 倒序移动索引2及后续元素,给新元素腾位置
for (int i = 4; i > 2; i--) {
    arr[i] = arr[i - 1];
}
// 插入新元素
arr[2] = 666;

场景2:空间已满

当数组填满时,需执行“扩容”操作:重新申请更大内存、复制原数据、释放旧内存,整体时间复杂度O(N)。例如给满元素数组追加10:


// 申请容量为20的新数组
int newArr[20];
// 复制原数组数据
for (int i = 0; i < 10; i++) {
    newArr[i] = arr[i];
}
// 释放旧数组内存(避免内存泄漏)
// ...
// 追加新元素
newArr[10] = 10;

(2)删除操作

删除操作同样分尾部和中间位置,核心是数据搬移与标记删除。

现有长度为10的数组,前4个元素为0、1、2、3:

  • 尾部删除:直接标记尾部元素为特殊值(如-1),时间复杂度O(1),代码为arr[3] = -1

  • 中间删除:需正序移动元素覆盖待删除位置,时间复杂度O(N)。例如删除索引1的元素:


// 正序移动元素,覆盖待删除位置
for (int i = 1; i < 4; i++) {
    arr[i] = arr[i + 1];
}
// 标记最后一个位置为删除状态
arr[4] = -1;

(3)时间复杂度总结

  • 增:尾部追加O(1),中间插入O(N),扩容O(N);

  • 删:尾部删除O(1),中间删除O(N);

  • 查/改:随机访问O(1)。

动态数组的本质的是对静态数组的封装,自动处理扩缩容,简化用户操作,而环形数组则是进一步优化了动态数组的空间利用率。

二、手把手实现闭区间环形数组(

环形数组通过“首尾衔接”的逻辑复用数组空间,闭区间版本的核心是让startend均指向有效元素(start为第一个,end为最后一个),逻辑更贴合直觉,下面从0到1分步实现。

1. 核心规则前置(必记)

所有操作均围绕以下规则展开,避免指针混乱和逻辑错误:

核心概念 定义/规则
capacity 物理容量,底层数组的长度,支持动态扩缩容
count 有效元素个数,判断空/满的核心依据(优先使用,不依赖指针)
start 闭区间起点,指向第一个有效元素的索引
end 闭区间终点,指向最后一个有效元素的索引
空数组 count === 0(禁止用start === end判断,满数组也可能出现该情况)
满数组 count === capacity(唯一判断标准)
新增元素 先移动指针,再赋值(尾部增移end,头部增移start)
删除元素 先清空值,再移动指针(尾部删移end,头部删移start)

2. 第一步:初始化类与基础辅助方法

先搭建类的骨架,实现判断空/满、获取有效个数、遍历等基础方法,为后续核心操作铺垫。


/**
 * 闭区间环形数组(新手友好版)
 * 核心规则:[start, end] 均为有效元素,start=第一个,end=最后一个
 */
class CycleArrayClosed {
  // 构造函数:初始化物理容量(默认1)
  constructor(initSize = 1) {
    this.capacity = initSize; // 物理容量
    this.arr = new Array(initSize); // 底层存储数组
    this.start = 0; // 有效元素起始索引(闭区间)
    this.end = 0; // 有效元素结束索引(闭区间)
    this.count = 0; // 有效元素个数(核心判断依据)
  }

  // 辅助方法1:判断数组是否为空
  isEmpty() {
    return this.count === 0;
  }

  // 辅助方法2:判断数组是否已满
  isFull() {
    return this.count === this.capacity;
  }

  // 辅助方法3:获取有效元素个数(对外暴露)
  getCount() {
    return this.count;
  }

  // 辅助方法4:遍历所有有效元素(调试/展示用)
  traverse() {
    const result = [];
    if (this.isEmpty()) return result;

    // 分两种场景遍历,避免环形场景漏元素
    if (this.start <= this.end) {
      // 场景1:线性区间(未绕圈)
      for (let i = this.start; i <= this.end; i++) {
        result.push(this.arr[i]);
      }
    } else {
      // 场景2:环形区间(已绕圈),分两段遍历
      for (let i = this.start; i < this.capacity; i++) {
        result.push(this.arr[i]);
      }
      for (let i = 0; i <= this.end; i++) {
        result.push(this.arr[i]);
      }
    }
    return result;
  }
}

测试基础方法


// 初始化容量为3的环形数组
const arr = new CycleArrayClosed(3);
console.log("是否为空:", arr.isEmpty()); // true
console.log("有效个数:", arr.getCount()); // 0
console.log("遍历结果:", arr.traverse()); // []

3. 第二步:实现扩缩容(resize)核心方法

环形数组扩容时,需将旧数组的有效元素“平铺”到新数组开头,重置指针使新数组回归线性状态,避免后续操作复杂。缩容则在有效元素过少时触发,优化空间利用率。


/**
 * 扩容/缩容方法
 * @param {number} newCapacity - 新的物理容量
 */
resize(newCapacity) {
  const newArr = new Array(newCapacity);
  // 复制旧数组有效元素到新数组
  for (let i = 0; i < this.count; i++) {
    // 核心公式:环形遍历旧数组有效元素
    // (start + i) % capacity 可适配线性/环形两种场景
    const oldIndex = (this.start + i) % this.capacity;
    newArr[i] = this.arr[oldIndex];
  }
  // 替换底层数组,重置指针和容量
  this.arr = newArr;
  this.start = 0; // 新数组有效元素从0开始
  this.end = this.count - 1; // 闭区间终点为最后一个有效元素索引
  this.capacity = newCapacity;
}

测试扩容逻辑


const arr = new CycleArrayClosed(3);
// 手动模拟填充元素
arr.arr[0] = 1;
arr.arr[1] = 2;
arr.arr[2] = 3;
arr.start = 0;
arr.end = 2;
arr.count = 3;

console.log("扩容前遍历:", arr.traverse()); // [1,2,3]
arr.resize(5); // 扩容到5
console.log("扩容后容量:", arr.capacity); // 5
console.log("扩容后遍历:", arr.traverse()); // [1,2,3]
console.log("扩容后指针:start=", arr.start, "end=", arr.end); // start=0, end=2

4. 第三步:实现尾部添加(addLast)

尾部添加遵循“先移指针再赋值”规则,空数组需特殊处理,满数组先扩容。


/**
 * 尾部添加元素(时间复杂度O(1))
 * 规则:满了先扩容 → 空数组特殊处理 → 非空先移end再赋值
 */
addLast(val) {
  // 满数组先扩容(扩容2倍为行业通用策略,平衡效率与空间)
  if (this.isFull()) {
    this.resize(this.capacity * 2);
  }

  // 空数组:直接赋值到0位置,指针均指向0
  if (this.isEmpty()) {
    this.arr[0] = val;
    this.start = 0;
    this.end = 0;
  } else {
    // 非空数组:右移end指针(取模实现环形衔接),再赋值
    this.end = (this.end + 1) % this.capacity;
    this.arr[this.end] = val;
  }

  this.count++; // 有效元素个数+1
}

测试尾部添加


const arr = new CycleArrayClosed(3);
// 填充3个元素(填满容量)
arr.addLast(1);
arr.addLast(2);
arr.addLast(3);
console.log("添加3个元素后遍历:", arr.traverse()); // [1,2,3]
console.log("是否满:", arr.isFull()); // true

// 追加第4个元素(触发扩容到6)
arr.addLast(4);
console.log("扩容后容量:", arr.capacity); // 6
console.log("扩容后遍历:", arr.traverse()); // [1,2,3,4]

5. 第四步:实现头部添加(addFirst)

头部添加遵循“先移指针再赋值”规则,空数组可复用addLast逻辑,左移指针时需加capacity避免负数。


/**
 * 头部添加元素(时间复杂度O(1))
 * 规则:满了先扩容 → 空数组调用addLast → 非空先移start再赋值
 */
addFirst(val) {
  if (this.isFull()) {
    this.resize(this.capacity * 2);
  }

  // 空数组复用尾部添加逻辑,避免重复代码
  if (this.isEmpty()) {
    this.addLast(val);
  } else {
    // 左移start指针(+capacity确保索引非负)
    this.start = (this.start - 1 + this.capacity) % this.capacity;
    this.arr[this.start] = val;
    this.count++;
  }
}

测试头部添加


const arr = new CycleArrayClosed(3);
arr.addFirst(1);
console.log("头部加1后遍历:", arr.traverse()); // [1]
console.log("start指针:", arr.start); // 2((0-1+3)%3=2)

// 再添加2个元素(填满容量)
arr.addFirst(2);
arr.addFirst(3);
console.log("填满后遍历:", arr.traverse()); // [3,2,1]

// 追加第4个元素(触发扩容)
arr.addFirst(4);
console.log("扩容后遍历:", arr.traverse()); // [4,3,2,1]

6. 第五步:实现尾部删除(removeLast)

尾部删除遵循“先清空值再移指针”规则,只剩1个元素时需重置指针,有效元素过少时触发缩容。


/**
 * 尾部删除元素(时间复杂度O(1))
 * 规则:空数组抛错 → 清空end值 → 移指针 → 缩容判断
 */
removeLast() {
  if (this.isEmpty()) {
    throw new Error("CycleArray is empty, cannot remove last element");
  }

  // 清空当前end位置的值,避免内存泄漏
  this.arr[this.end] = null;

  // 只剩1个元素:删除后变为空数组,重置指针
  if (this.count === 1) {
    this.start = 0;
    this.end = 0;
  } else {
    // 左移end指针(+capacity确保索引非负)
    this.end = (this.end - 1 + this.capacity) % this.capacity;
  }

  this.count--;

  // 缩容:有效元素为容量1/4时缩容到1/2,避免频繁缩容
  if (this.count > 0 && this.count === this.capacity / 4) {
    this.resize(Math.floor(this.capacity / 2));
  }
}

7. 第六步:实现头部删除(removeFirst)

头部删除遵循“先清空值再移指针”规则,只剩1个元素时复用removeLast逻辑,缩容逻辑与尾部删除一致。


/**
 * 头部删除元素(时间复杂度O(1))
 * 规则:空数组抛错 → 只剩1个元素调用removeLast → 清空start值 → 移指针 → 缩容
 */
removeFirst() {
  if (this.isEmpty()) {
    throw new Error("CycleArray is empty, cannot remove first element");
  }

  // 只剩1个元素,复用尾部删除逻辑
  if (this.count === 1) {
    this.removeLast();
    return;
  }

  // 清空当前start位置的值
  this.arr[this.start] = null;
  // 右移start指针
  this.start = (this.start + 1) % this.capacity;
  this.count--;

  // 缩容判断
  if (this.count > 0 && this.count === this.capacity / 4) {
    this.resize(Math.floor(this.capacity / 2));
  }
}

8. 第七步:实现首尾元素获取(getFirst/getLast)

直接通过start/end指针取值,只需判断空数组避免报错。


/**
 * 获取头部元素
 */
getFirst() {
  if (this.isEmpty()) {
    throw new Error("CycleArray is empty, no first element");
  }
  return this.arr[this.start];
}

/**
 * 获取尾部元素
 */
getLast() {
  if (this.isEmpty()) {
    throw new Error("CycleArray is empty, no last element");
  }
  return this.arr[this.end];
}

9. 完整代码与综合测试

完整代码


class CycleArrayClosed {
  constructor(initSize = 1) {
    this.capacity = initSize;
    this.arr = new Array(initSize);
    this.start = 0;
    this.end = 0;
    this.count = 0;
  }

  isEmpty() {
    return this.count === 0;
  }

  isFull() {
    return this.count === this.capacity;
  }

  getCount() {
    return this.count;
  }

  traverse() {
    const result = [];
    if (this.isEmpty()) return result;
    if (this.start <= this.end) {
      for (let i = this.start; i <= this.end; i++) {
        result.push(this.arr[i]);
      }
    } else {
      for (let i = this.start; i < this.capacity; i++) {
        result.push(this.arr[i]);
      }
      for (let i = 0; i <= this.end; i++) {
        result.push(this.arr[i]);
      }
    }
    return result;
  }

  resize(newCapacity) {
    const newArr = new Array(newCapacity);
    for (let i = 0; i< this.count; i++) {
      const oldIndex = (this.start + i) % this.capacity;
      newArr[i] = this.arr[oldIndex];
    }
    this.arr = newArr;
    this.start = 0;
    this.end = this.count - 1;
    this.capacity = newCapacity;
  }

  addLast(val) {
    if (this.isFull()) {
      this.resize(this.capacity * 2);
    }
    if (this.isEmpty()) {
      this.arr[0] = val;
      this.start = 0;
      this.end = 0;
    } else {
      this.end = (this.end + 1) % this.capacity;
      this.arr[this.end] = val;
    }
    this.count++;
  }

  addFirst(val) {
    if (this.isFull()) {
      this.resize(this.capacity * 2);
    }
    if (this.isEmpty()) {
      this.addLast(val);
    } else {
      this.start = (this.start - 1 + this.capacity) % this.capacity;
      this.arr[this.start] = val;
      this.count++;
    }
  }

  removeLast() {
    if (this.isEmpty()) {
      throw new Error("CycleArray is empty, cannot remove last element");
    }
    this.arr[this.end] = null;
    if (this.count === 1) {
      this.start = 0;
      this.end = 0;
    } else {
      this.end = (this.end - 1 + this.capacity) % this.capacity;
    }
    this.count--;
    if (this.count > 0 && this.count === this.capacity / 4) {
      this.resize(Math.floor(this.capacity / 2));
    }
  }

  removeFirst() {
    if (this.isEmpty()) {
      throw new Error("CycleArray is empty, cannot remove first element");
    }
    if (this.count === 1) {
      this.removeLast();
      return;
    }
    this.arr[this.start] = null;
    this.start = (this.start + 1) % this.capacity;
    this.count--;
    if (this.count > 0 && this.count === this.capacity / 4) {
      this.resize(Math.floor(this.capacity / 2));
    }
  }

  getFirst() {
    if (this.isEmpty()) {
      throw new Error("CycleArray is empty, no first element");
    }
    return this.arr[this.start];
  }

  getLast() {
    if (this.isEmpty()) {
      throw new Error("CycleArray is empty, no last element");
    }
    return this.arr[this.end];
  }
}

综合测试用例


// 初始化数组
const arr = new CycleArrayClosed(3);

// 测试新增操作
arr.addLast(1);
arr.addLast(2);
arr.addFirst(0);
console.log("新增后遍历:", arr.traverse()); // [0,1,2]
console.log("首尾元素:", arr.getFirst(), arr.getLast()); // 0 2

// 测试删除操作
arr.removeFirst();
arr.removeLast();
console.log("删除后遍历:", arr.traverse()); // [1]
console.log("有效个数:", arr.getCount()); // 1

// 测试扩容与环形遍历
arr.addLast(3);
arr.addLast(4);
arr.addFirst(5);
console.log("环形遍历:", arr.traverse()); // [5,1,3,4]

// 测试缩容
arr.removeFirst();
arr.removeLast();
arr.removeLast();
console.log("缩容后容量:", arr.capacity); // 3
console.log("缩容后遍历:", arr.traverse()); // [1]

三、核心易错点总结(新手必避坑)

  1. 忘记更新count:增删操作后必须同步增减count,否则空/满判断会完全失效。

  2. 指针移动顺序颠倒:新增需“先移指针再赋值”,删除需“先清空再移指针”,顺序错会导致数据覆盖或丢失。

  3. 左移指针未加capacity:直接start-1可能得到负数索引,需通过(start-1 + capacity) % capacity确保索引合法。

  4. 用指针判断空/满:start === end既可能是空数组,也可能是满数组,唯一可靠的判断是count===0(空)、count===capacity(满)。

  5. 遍历漏环形场景:当start > end时,需分[start, capacity-1][0, end]两段遍历,否则会漏元素。

四、总结与拓展

闭区间环形数组通过指针逻辑复用空间,解决了静态数组中间增删效率低、空间利用率不足的问题,其核心优势是首尾增删均能达到O(1)时间复杂度,仅扩缩容和遍历(环形场景)需O(N)时间。

本文从静态数组原理铺垫,到分步实现环形数组,核心是帮大家理解“封装”与“优化”的思路——动态数组封装了扩缩容,环形数组则进一步优化了指针逻辑。实际开发中,JavaScript的Array本质是动态数组,而环形数组可用于实现队列、循环缓冲区等场景。

若需拓展功能,可基于本文代码实现按索引访问、修改元素等操作,核心是通过(start + index) % capacity计算目标元素索引。

五、练习

手把手实现链表:单链表与双链表的完整实现

手把手实现链表:单链表与双链表的完整实现

链表是数据结构的基础,也是面试高频考点。很多初学者会卡在“指针操作混乱”“边界条件处理不当”等问题上。本文将从设计思路出发,拆解单链表实现的核心逻辑,同时补充双向链表(双链表)的实现方法,帮你彻底掌握链表的实现技巧。

一、为什么需要手动实现链表?

编程语言(如JavaScript)没有内置链表结构,但链表的“动态扩容”“非连续存储”特性使其在插入/删除操作中比数组更高效(尤其是头部/中部操作)。手动实现链表的核心目标是:

  • 掌握指针(引用)操作的核心逻辑;

  • 理解虚拟头/尾节点等技巧解决边界问题;

  • 规避“空指针操作”“状态不同步”等高频报错;

  • 区分单链表与双链表的设计差异,适配不同场景需求。

二、单链表实现

1. 单链表核心设计思路

链表的最小单元是“节点(Node)”,每个节点包含两部分:

  • val:节点存储的值;

  • next:指向下一个节点的指针(引用),默认null

链表类(MyLinkedList)需维护核心属性,且遵守状态同步约束

属性名 作用 核心约束
dummyHead(虚拟头节点) 统一头节点操作逻辑,避免单独处理头节点 始终存在,next指向真实头节点
tail(尾节点) 优化尾插效率(从O(n)→O(1)) size=0tail=nullsize>0tail指向最后一个节点
size(链表长度) 简化边界判断,避免冗余遍历 增/删操作必须同步更新,与tail状态严格一致

实现步骤(从0开始设计)

第一步:定义节点类

class LinkedNode {
  constructor(val) {
    this.val = val;   // 节点值
    this.next = null; // 指向下一个节点的指针
  }
}

第二步:设计链表类结构

  1. 初始化虚拟头节点(dummyHead):统一头节点操作,避免边界判断
  2. 初始化尾节点(tail):初始为null,空链表时无尾节点
  3. 初始化长度(size):初始为0,记录链表节点数量

第三步:实现基础查询方法

  • isEmpty():判断链表是否为空(size === 0
  • get(index):获取指定索引的节点值
    • 边界校验:index < 0 || index >= size 返回 -1
    • dummyHead.next开始遍历到目标位置

第四步:实现插入方法(核心:先连后断)

  • addAtHead(val):头部插入

    1. 创建新节点
    2. 新节点next指向原头节点(dummyHead.next
    3. dummyHead.next指向新节点
    4. 更新size,若size === 1则更新tail
  • addAtTail(val):尾部插入

    1. 边界处理:空链表时调用addAtHead
    2. 创建新节点
    3. tail.next指向新节点
    4. tail更新为新节点
    5. 更新size
  • addAtIndex(index, val):指定位置插入

    1. 边界处理:index <= 0调用addAtHeadindex > size直接返回
    2. 遍历到插入位置的前驱节点
    3. 新节点next指向原节点,前驱节点next指向新节点
    4. 若插入到尾部,更新tail
    5. 更新size

第五步:实现删除方法(核心:先连后断)

  • deleteAtIndex(index):删除指定位置节点
    1. 边界校验:index < 0 || index >= size || isEmpty() 直接返回
    2. 遍历到删除位置的前驱节点
    3. 前驱节点next指向待删除节点的next(跳过待删除节点)
    4. 待删除节点next置为null(释放引用)
    5. 更新size,若删除的是尾节点,更新tail

关键设计要点:

  • ✅ 使用虚拟头节点统一边界处理
  • ✅ 维护tail指针优化尾插操作
  • sizetail状态必须严格同步
  • ✅ 所有指针操作前先校验边界条件
  • ✅ 遵循"先连后断"原则:先建立新连接,再断开旧连接
  • ✅ 使用虚拟头节点统一边界处理
  • ✅ 维护tail指针优化尾插操作
  • sizetail状态必须严格同步
  • ✅ 所有指针操作前先校验边界条件

2. 单链表完整实现


/**
 * 单链表节点类
 * @param {number} val - 节点存储的值
 */
class LinkedNode {
  constructor(val) {
    this.val = val;       // 节点值
    this.next = null;     // 指向下一个节点的指针
  }
}

/**
 * 单链表实现
 */
class MySinglyLinkedList {
  constructor() {
    this.dummyHead = new LinkedNode('_dummy'); // 虚拟头节点
    this.tail = null;  // 尾节点
    this.size = 0;     // 链表长度
    // 约束:size=0 时 tail=null;size>0 时 tail 指向最后一个节点
  }

  /**
   * 判断链表是否为空
   * @returns {boolean}
   */
  isEmpty() {
    return this.size === 0;
  }

  /**
   * 获取指定索引的节点值
   * @param {number} index - 目标索引(从0开始)
   * @returns {number} 节点值,索引无效返回-1
   */
  get(index) {
    if (index < 0 || index >= this.size) return -1;

    let pointer = this.dummyHead.next;
    for (let i = 0; i < index; i++) {
      pointer = pointer.next;
    }
    return pointer.val;
  }

  /**
   * 头部插入节点
   * @param {number} val - 要插入的值
   */
  addAtHead(val) {
    const newNode = new LinkedNode(val);
    newNode.next = this.dummyHead.next;
    this.dummyHead.next = newNode;

    this.size++;
    // 空链表插入,尾节点同步更新
    if (this.size === 1) {
      this.tail = newNode;
    }
  }

  /**
   * 尾部插入节点
   * @param {number} val - 要插入的值
   */
  addAtTail(val) {
    // 双重兜底校验:避免tail为null但size>0的异常
    if (this.isEmpty() || this.tail === null) {
      this.addAtHead(val);
      return;
    }

    const newNode = new LinkedNode(val);
    this.tail.next = newNode;
    this.tail = newNode;
    this.size++;
  }

  /**
   * 指定索引插入节点
   * @param {number} index - 插入位置
   * @param {number} val - 要插入的值
   */
  addAtIndex(index, val) {
    if (index <= 0) {
      this.addAtHead(val);
      return;
    }
    if (index > this.size) return;

    let pointer = this.dummyHead;
    for (let i = 0; i < index; i++) {
      pointer = pointer.next;
    }

    const newNode = new LinkedNode(val);
    newNode.next = pointer.next;
    pointer.next = newNode;

    // 插入到尾部时更新tail
    if (index === this.size) {
      this.tail = newNode;
    }
    this.size++;
  }

  /**
   * 删除头部节点
   */
  deleteAtHead() {
    if (this.isEmpty()) return;

    const oldHead = this.dummyHead.next;
    this.dummyHead.next = oldHead.next;
    oldHead.next = null;

    this.size--;
    // 同步更新tail
    if (this.size === 0) {
      this.tail = null;
    } else if (oldHead === this.tail) {
      this.tail = this.dummyHead.next;
    }
  }

  /**
   * 删除尾部节点
   */
  deleteAtTail() {
    if (this.isEmpty()) return;

    if (this.size === 1) {
      this.deleteAtHead();
      return;
    }

    let pointer = this.dummyHead;
    while (pointer.next.next) {
      pointer = pointer.next;
    }

    pointer.next.next = null;
    this.tail = pointer.next;
    this.size--;
  }

  /**
   * 删除指定索引节点
   * @param {number} index - 要删除的索引
   */
  deleteAtIndex(index) {
    if (index < 0 || index >= this.size || this.isEmpty()) {
      return;
    }

    let pointer = this.dummyHead;
    for (let i = 0; i < index; i++) {
      pointer = pointer.next;
    }

    const nodeToDel = pointer.next;
    pointer.next = nodeToDel.next;
    nodeToDel.next = null;

    this.size--;
    // 同步更新tail
    if (this.size === 0) {
      this.tail = null;
    } else if (nodeToDel === this.tail) {
      this.tail = pointer.next || pointer;
    }
  }
}

// 单链表测试用例
const singlyList = new MySinglyLinkedList();
singlyList.addAtHead(1);
singlyList.addAtTail(3);
singlyList.addAtIndex(1, 2);
console.log("单链表get(1):", singlyList.get(1)); // 输出2
singlyList.deleteAtIndex(1);
console.log("单链表get(1):", singlyList.get(1)); // 输出3

3. 单链表核心易错点

易错点 错误表现 修复方案
空指针操作 Cannot set properties of null (setting 'next') 所有指针操作前先校验null,使用isEmpty()size判断
tail状态不同步 删除节点后tail仍指向已删除节点 删除操作后同步更新tailsize=0tail=null
边界条件遗漏 index=0index=size时操作失败 使用虚拟头节点统一处理,特殊位置单独判断
指针操作顺序错误 先断开原链表导致节点丢失 遵循"先连后断"原则:先建立新连接,再断开旧连接
size未同步更新 size与实际节点数不一致 每次增/删操作必须同步更新size

调试技巧:

// 添加调试方法:打印链表结构
toString() {
  const values = [];
  let current = this.dummyHead.next;
  while (current) {
    values.push(current.val);
    current = current.next;
  }
  return `[${values.join(' -> ')}] (size: ${this.size}, tail: ${this.tail?.val ?? 'null'})`;
}

三、双向链表(双链表)实现

1. 双链表核心实现逻辑

(1)双链表与单链表的核心差异

单链表的节点只有next指针(指向后继节点),只能“单向遍历”;双链表的节点新增prev指针(指向前驱节点),支持“双向遍历”,核心优势:

  • 删除节点时,无需遍历找前驱节点(时间复杂度从O(n)→O(1));

  • 支持从尾部反向遍历,适配“逆序操作”场景;

  • 插入/删除操作更灵活,边界处理可通过“虚拟头+虚拟尾”进一步简化。

(2)双链表核心设计要点
  • 节点结构:每个节点包含val(值)、prev(前驱指针)、next(后继指针);

  • 虚拟节点:同时维护dummyHead(虚拟头)和dummyTail(虚拟尾),彻底统一头尾节点的操作逻辑;

  • 状态同步:维护size(长度),且每个节点的prev/next指针必须成对更新(避免指针悬空);

  • 操作原则:插入/删除时,先更新新节点的prev/next,再更新原链表的指针(先连后断)。

实现步骤(基于单链表扩展)

前提:已掌握单链表实现,在此基础上扩展为双链表。

第一步:扩展节点类(新增prev指针)

class DoublyLinkedNode {
  constructor(val) {
    this.val = val;   // 节点值
    this.prev = null; // 指向前驱节点的指针(新增)
    this.next = null; // 指向后继节点的指针
  }
}

第二步:扩展链表类结构(新增虚拟尾节点)

  1. 保留虚拟头节点(dummyHead):与单链表相同
  2. 新增虚拟尾节点(dummyTail:统一尾节点操作,避免边界判断
  3. 初始化连接:dummyHead.next = dummyTaildummyTail.prev = dummyHead
  4. 初始化长度(size):初始为0

第三步:实现辅助方法(优化查找)

  • getNode(index):根据索引获取节点(优化版)
    • 边界校验:index < 0 || index >= size 返回 null
    • 优化策略:索引在前半段从头遍历,在后半段从尾遍历(最坏O(n/2))

第四步:实现插入方法(核心:prevnext成对更新)

  • addAtHead(val):头部插入

    1. 创建新节点
    2. 获取原头节点(dummyHead.next
    3. 成对更新指针
      • 新节点:prev指向dummyHeadnext指向原头节点
      • 原头节点:prev指向新节点
      • dummyHeadnext指向新节点
    4. 更新size
  • addAtTail(val):尾部插入

    1. 创建新节点
    2. 获取原尾节点(dummyTail.prev
    3. 成对更新指针
      • 新节点:prev指向原尾节点,next指向dummyTail
      • 原尾节点:next指向新节点
      • dummyTailprev指向新节点
    4. 更新size
  • addAtIndex(index, val):指定位置插入

    1. 边界处理:index <= 0调用addAtHeadindex > size直接返回,index === size调用addAtTail
    2. 使用getNode(index)找到插入位置的后继节点(nextNode
    3. 获取前驱节点(nextNode.prev
    4. 成对更新指针
      • 新节点:prev指向prevNodenext指向nextNode
      • prevNodenext指向新节点
      • nextNodeprev指向新节点
    5. 更新size

第五步:实现删除方法(核心优势:O(1)删除)

  • deleteAtIndex(index):删除指定位置节点
    1. 边界校验:使用getNode(index)获取待删除节点,无效则返回
    2. 核心优势:直接获取前驱(nodeToDel.prev)和后继(nodeToDel.next),无需遍历
    3. 成对更新指针
      • 前驱节点:next指向后继节点
      • 后继节点:prev指向前驱节点
      • 待删除节点:prevnext置为null(释放引用)
    4. 更新size

第六步:实现扩展功能(双链表特有)

  • reverseTraverse():逆序遍历
    1. dummyTail.prev开始
    2. 通过prev指针向前遍历
    3. 直到dummyHead结束

关键设计要点(相比单链表的升级):

  • 双指针维护:每个节点的prevnext必须成对更新
  • 虚拟头+虚拟尾:彻底统一边界处理,无需维护tail指针
  • O(1)删除优势:删除任意节点无需遍历找前驱
  • 双向遍历优化:根据索引位置选择遍历方向(优化查找效率)
  • 指针释放:删除节点后必须将prevnext置为null

2. 双链表完整实现


/**
 * 双链表节点类
 * @param {number} val - 节点存储的值
 */
class DoublyLinkedNode {
  constructor(val) {
    this.val = val;       // 节点值
    this.prev = null;     // 指向前驱节点的指针
    this.next = null;     // 指向后继节点的指针
  }
}

/**
 * 双向链表实现(优化版:虚拟头+虚拟尾)
 */
class MyDoublyLinkedList {
  constructor() {
    this.dummyHead = new DoublyLinkedNode('_dummyHead'); // 虚拟头节点
    this.dummyTail = new DoublyLinkedNode('_dummyTail'); // 虚拟尾节点
    this.size = 0;                                       // 链表长度

    // 初始化:虚拟头的next指向虚拟尾,虚拟尾的prev指向虚拟头
    this.dummyHead.next = this.dummyTail;
    this.dummyTail.prev = this.dummyHead;
    // 约束:真实节点始终在dummyHead和dummyTail之间
  }

  /**
   * 判断链表是否为空
   * @returns {boolean}
   */
  isEmpty() {
    return this.size === 0;
  }

  /**
   * 辅助方法:根据索引找到对应节点(优化:判断索引位置,选择从头/尾遍历)
   * @param {number} index - 目标索引
   * @returns {DoublyLinkedNode|null} 找到的节点/索引无效返回null
   */
  getNode(index) {
    if (index < 0 || index >= this.size) return null;

    let current;
    // 优化:索引在前半段,从头遍历;索引在后半段,从尾遍历
    if (index < this.size / 2) {
      current = this.dummyHead.next;
      for (let i = 0; i < index; i++) {
        current = current.next;
      }
    } else {
      current = this.dummyTail.prev;
      for (let i = this.size - 1; i > index; i--) {
        current = current.prev;
      }
    }
    return current;
  }

  /**
   * 获取指定索引的节点值
   * @param {number} index - 目标索引
   * @returns {number} 节点值,索引无效返回-1
   */
  get(index) {
    const node = this.getNode(index);
    return node ? node.val : -1;
  }

  /**
   * 头部插入节点
   * @param {number} val - 要插入的值
   */
  addAtHead(val) {
    const newNode = new DoublyLinkedNode(val);
    const nextNode = this.dummyHead.next; // 虚拟头的后继节点(原真实头)

    // 步骤1:新节点的prev指向虚拟头,next指向原真实头
    newNode.prev = this.dummyHead;
    newNode.next = nextNode;

    // 步骤2:原真实头的prev指向新节点
    nextNode.prev = newNode;

    // 步骤3:虚拟头的next指向新节点
    this.dummyHead.next = newNode;

    this.size++; // 长度+1
  }

  /**
   * 尾部插入节点
   * @param {number} val - 要插入的值
   */
  addAtTail(val) {
    const newNode = new DoublyLinkedNode(val);
    const prevNode = this.dummyTail.prev; // 虚拟尾的前驱节点(原真实尾)

    // 步骤1:新节点的prev指向原真实尾,next指向虚拟尾
    newNode.prev = prevNode;
    newNode.next = this.dummyTail;

    // 步骤2:原真实尾的next指向新节点
    prevNode.next = newNode;

    // 步骤3:虚拟尾的prev指向新节点
    this.dummyTail.prev = newNode;

    this.size++; // 长度+1
  }

  /**
   * 指定索引插入节点
   * @param {number} index - 插入位置
   * @param {number} val - 要插入的值
   */
  addAtIndex(index, val) {
    // 边界处理:index<=0插头部,index>size不插入
    if (index <= 0) {
      this.addAtHead(val);
      return;
    }
    if (index > this.size) return;
    // index===size 插尾部
    if (index === this.size) {
      this.addAtTail(val);
      return;
    }

    // 找到插入位置的目标节点(新节点的后继节点)
    const nextNode = this.getNode(index);
    const prevNode = nextNode.prev; // 目标节点的前驱(新节点的前驱)
    const newNode = new DoublyLinkedNode(val);

    // 步骤1:新节点的prev指向prevNode,next指向nextNode
    newNode.prev = prevNode;
    newNode.next = nextNode;

    // 步骤2:prevNode的next指向新节点
    prevNode.next = newNode;

    // 步骤3:nextNode的prev指向新节点
    nextNode.prev = newNode;

    this.size++; // 长度+1
  }

  /**
   * 删除指定索引节点
   * @param {number} index - 要删除的索引
   */
  deleteAtIndex(index) {
    const nodeToDel = this.getNode(index);
    if (!nodeToDel) return; // 索引无效直接返回

    // 步骤1:获取待删除节点的前驱和后继
    const prevNode = nodeToDel.prev;
    const nextNode = nodeToDel.next;

    // 步骤2:跳过待删除节点,连接前驱和后继
    prevNode.next = nextNode;
    nextNode.prev = prevNode;

    // 步骤3:释放待删除节点的指针(避免内存泄漏)
    nodeToDel.prev = null;
    nodeToDel.next = null;

    this.size--; // 长度-1
  }

  /**
   * 扩展方法:逆序遍历链表(双链表核心优势)
   * @returns {number[]} 逆序的节点值数组
   */
  reverseTraverse() {
    const result = [];
    let current = this.dummyTail.prev; // 从虚拟尾的前驱开始遍历
    while (current !== this.dummyHead) {
      result.push(current.val);
      current = current.prev;
    }
    return result;
  }
}

// 双链表测试用例
const doublyList = new MyDoublyLinkedList();
doublyList.addAtHead(1);
doublyList.addAtTail(3);
doublyList.addAtIndex(1, 2);
console.log("双链表get(1):", doublyList.get(1)); // 输出2
console.log("双链表逆序遍历:", doublyList.reverseTraverse()); // 输出[3,2,1]
doublyList.deleteAtIndex(1);
console.log("双链表get(1):", doublyList.get(1)); // 输出3
console.log("双链表逆序遍历:", doublyList.reverseTraverse()); // 输出[3,1]

3. 双链表核心易错点

易错点 错误表现 修复方案
指针更新顺序错误 先修改原链表指针,导致新节点指针丢失 先更新新节点的prev/next,再修改原链表的指针(先连后断)
虚拟头尾未初始化 dummyHead.next/dummyTail.prev为null,操作时报错 初始化时必须让dummyHead.next = dummyTaildummyTail.prev = dummyHead
遍历方向选择不当 无论索引位置都从头遍历,效率低 判断索引是否小于size/2,选择从头/尾遍历(优化时间复杂度)
仅更新单向指针 只更新next不更新prev,导致链表断裂 插入/删除时,prevnext必须成对更新
未释放删除节点的指针 节点删除后仍有prev/next引用,导致内存泄漏(JS中影响小,但不规范) 删除后将节点的prev/next置为null

四、实战应用场景

1. LeetCode 经典题目

2. 实际应用场景

  • LRU缓存:使用双链表维护访问顺序,O(1)时间删除任意节点
  • 浏览器历史记录:双链表支持前进/后退操作
  • 撤销/重做功能:双链表维护操作历史
  • 音乐播放列表:单链表实现顺序播放
  • 任务队列:单链表实现FIFO队列

3. 面试高频考点

  1. 指针操作:如何正确更新next/prev指针
  2. 边界处理:空链表、单节点、头尾节点的特殊处理
  3. 状态同步sizetail等状态的维护
  4. 时间复杂度优化:双链表的删除优势、虚拟节点的作用
  5. 内存管理:指针释放、避免内存泄漏

五、总结

1. 单链表核心

  • 核心属性:dummyHead(虚拟头)+ tail(尾节点)+ size(长度);

  • 修复关键:sizetail同步更新,对null敏感操作增加兜底校验;

  • 避坑原则:先校验边界,再执行核心逻辑,指针操作“先连后断”。

2. 双链表核心

  • 核心升级:节点新增prev指针,新增dummyTail(虚拟尾);

  • 效率优势:删除节点无需找前驱,支持双向遍历;

  • 操作原则:prev/next成对更新,遍历方向按需选择。

掌握单链表和双链表的实现逻辑后,不仅能应对链表等基础题,还能扩展到环形链表、LRU缓存(双链表+哈希表)等进阶场景。建议结合测试用例反复调试,重点关注指针操作和状态同步,形成肌肉记忆。

回溯算法:选→钻→退,掌握穷举的艺术

回溯算法:选→钻→退,掌握穷举的艺术

回溯算法是算法领域的核心思想之一,尤其在处理「穷举所有可能解」的问题时堪称"神器"。本文将从核心思路出发,通过"选一个数→钻到底→退回来删掉这个数→选下一个数→再钻到底"这个固定节奏,带你彻底掌握回溯算法。

一、回溯核心原理:选→钻→退的固定节奏

一句话记牢回溯的执行过程

选一个数→钻到底→退回来删掉这个数→选下一个数→再钻到底,直到所有数都试完,最后收集所有符合条件的路径。

选了就往下钻,走不通就退回来擦脚印,换条路再试。很多人一开始都会被递归的多层调用绕晕,但只要抓住 push(选数)→ 递归(钻深层)→ pop(擦脚印) 这个固定节奏,再结合剪枝提前止损,所有回溯题都能套模板解决~

核心四要素

任何回溯问题都能拆解为以下4个核心部分:

要素 作用 示例(组合总和)
路径 已经做出的选择(比如选了哪些数) path = [2,3](表示选了2和3)
选择列表 当前步骤可选择的选项(比如还能选哪些数) nums = [2,3,6,7],已选2则可选3/6/7
终止条件 什么时候停止探索(找到解/走到底) 路径和等于目标值,或路径和超过目标值
剪枝 提前排除无效路径(核心优化) 路径和超过目标值,直接停止当前路径

关键理解

  • 递归是"往下钻":每选一个数,就递归调用backtrack,相当于"往下走一步"
  • return 是"往回退":触发终止条件(找到解/和超了),就结束当前递归,回到上一层
  • pop 是"擦脚印":回到上一层后,必须执行path.pop(),把刚才选的数删掉,才能"换另一个数选"
  • for 循环是"选岔路":每一层的 for 循环,就是在当前位置选不同的数(岔路),试完一条再试下一条

通用模板

// 回溯核心函数
function backtrack(路径, 选择列表, 其他参数) {
  // 1. 终止条件:找到解,记录结果并返回
  if (满足终止条件) {
    结果列表.push(路径的拷贝); // 注意:要拷贝,否则会被后续修改
    return;
  }

  // 2. 遍历所有可选选项
  for (const 选项 of 选择列表) {
    // 剪枝:提前排除无效选项(关键优化)
    if (选项无效) continue;

    // 3. 做选择:把当前选项加入路径(选数)
    路径.push(选项);

    // 4. 递归探索:基于当前选择,继续往下走(钻到底)
    backtrack(路径, 新的选择列表, 其他参数);

    // 5. 撤销选择(回溯核心):回到上一步,换选项(退回来→删掉这个数)
    路径.pop();
  }
}

// 主函数
function solveProblem(参数) {
  const 结果列表 = []; // 存储所有符合条件的解
  backtrack([], 初始选择列表, 初始参数); // 初始路径为空
  return 结果列表;
}

二、入门示例:组合总和(可视化理解)

为了让你快速理解回溯的核心节奏,我们先从组合总和这个经典入门题入手,通过可视化打印完整展示「选→探→撤」的全过程。

题目描述

给定一个无重复元素的数组 candidates 和一个目标数 target,找出 candidates 中所有可以使数字和为 target 的组合(数字可以无限制重复被选取)。

  • 示例:输入 candidates = [2,3,7]target = 7,输出 [[2,2,3],[7]]

完整代码实现

/**
 * 组合总和:找所有和为目标值的组合(可视化打印版)
 * @param {number[]} candidates - 候选数组
 * @param {number} target - 目标和
 * @returns {number[][]} - 所有符合条件的组合
 */
function combinationSum(candidates, target) {
  const result = []; // 存储最终结果
  candidates.sort((a, b) => a - b); // 排序便于剪枝
  let recursionLevel = 0; // 标记递归层级,用于可视化缩进

  // 回溯函数:path=当前路径,sum=当前路径和,start=起始索引(避免重复组合)
  function backtrack(path, sum, start) {
    // 层级+1,生成缩进(每级2个空格)
    recursionLevel++;
    const indent = '  '.repeat(recursionLevel - 1);
    const levelTag = `【层级${recursionLevel}】`;

    // 🟢 调用阶段:打印当前层级初始状态
    console.log(
      `${indent}🟢 ${levelTag} 调用阶段 → path=${JSON.stringify(path)}, sum=${sum}, start=${start}`
    );

    // 终止条件1:路径和等于目标值,记录结果
    if (sum === target) {
      result.push([...path]); // 拷贝路径,避免后续修改
      console.log(
        `${indent}${levelTag} 找到有效组合 → ${JSON.stringify(path)},result=${JSON.stringify(result)}`
      );
      // 层级-1,准备返回
      recursionLevel--;
      console.log(`${indent}🔴 ${levelTag} 返回阶段 → 找到解,回到上一层`);
      return;
    }
    // 终止条件2:路径和超过目标值,直接返回(剪枝)
    if (sum > target) {
      console.log(`${indent}🚫 ${levelTag} 剪枝 → sum=${sum} > target=${target},直接返回`);
      recursionLevel--;
      console.log(`${indent}🔴 ${levelTag} 返回阶段 → 剪枝返回,回到上一层`);
      return;
    }

    // 遍历选择列表(从start开始,避免重复组合)
    for (let i = start; i < candidates.length; i++) {
      const num = candidates[i];
      console.log(`${indent}🔍 ${levelTag} 遍历i=${i} → 尝试选数字${num},当前sum=${sum}`);

      // 排序剪枝:当前数+已选和>目标值,后续数更大,无需继续
      if (sum + num > target) {
        console.log(
          `${indent}🚫 ${levelTag} 排序剪枝 → ${sum}+${num}=${sum + num} > ${target},break循环`
        );
        break;
      }

      // 做选择:加入当前数(选数)
      path.push(num);
      console.log(
        `${indent}${levelTag} 选数字${num} → path=${JSON.stringify(path)}, sum=${sum + num}`
      );

      // 🟡 暂停阶段:调用下一层,当前层级暂停
      console.log(`${indent}🟡 ${levelTag} 暂停阶段 → 调用下一层backtrack,等待返回`);
      // 递归探索:数字可重复选,所以start仍为i(钻到底)
      backtrack(path, sum + num, i);
      console.log(`${indent}🔴 ${levelTag} 返回阶段 → 从下一层返回,继续执行`);

      // 撤销选择:回溯核心(退回来→删掉这个数)
      path.pop();
      console.log(
        `${indent}🔵 ${levelTag} 撤销阶段 → 撤销数字${num} → path=${JSON.stringify(path)}, sum=${sum}`
      );
    }

    // 层级-1,准备返回
    recursionLevel--;
    console.log(`${indent}🔴 ${levelTag} 返回阶段 → for循环结束,回到上一层`);
  }

  // 初始调用:空路径、和为0、从索引0开始
  console.log('========== 开始执行组合总和 ==========');
  backtrack([], 0, 0);
  console.log('========== 执行结束 ==========');
  return result;
}

// 测试:输入 [2,3,7], 7 → 输出 [[2,2,3],[7]]
console.log('最终结果:', combinationSum([2, 3, 7], 7));

各个图标的含义:

  • 🟢 调用阶段:进入递归时的状态
  • 🔍 遍历阶段:尝试选择数字
  • ✅ 选择阶段:成功选择数字
  • 🟡 暂停阶段:调用下一层递归
  • 🔴 返回阶段:从下一层返回
  • 🔵 撤销阶段:删除数字(擦脚印)
  • 🚫 剪枝阶段:提前终止无效路径

控制台输出示例:

========== 开始执行组合总和 ==========
🟢 【层级1】 调用阶段 → path=[], sum=0, start=0
🔍 【层级1】 遍历i=0 → 尝试选数字2,当前sum=0
✅ 【层级1】 选数字2 → path=[2], sum=2
🟡 【层级1】 暂停阶段 → 调用下一层backtrack,等待返回
  🟢 【层级2】 调用阶段 → path=[2], sum=2, start=0
  🔍 【层级2】 遍历i=0 → 尝试选数字2,当前sum=2
  ✅ 【层级2】 选数字2 → path=[2,2], sum=4
  🟡 【层级2】 暂停阶段 → 调用下一层backtrack,等待返回
    🟢 【层级3】 调用阶段 → path=[2,2], sum=4, start=0
    🔍 【层级3】 遍历i=0 → 尝试选数字2,当前sum=4
    ✅ 【层级3】 选数字2 → path=[2,2,2], sum=6
    🟡 【层级3】 暂停阶段 → 调用下一层backtrack,等待返回
      🟢 【层级4】 调用阶段 → path=[2,2,2], sum=6, start=0
      🔍 【层级4】 遍历i=0 → 尝试选数字2,当前sum=6
      🚫 【层级4】 排序剪枝 → 6+2=8 > 7,break循环
      🔴 【层级4】 返回阶段 → for循环结束,回到上一层
    🔴 【层级3】 返回阶段 → 从下一层返回,继续执行
    🔵 【层级3】 撤销阶段 → 撤销数字2 → path=[2,2], sum=4
    🔍 【层级3】 遍历i=1 → 尝试选数字3,当前sum=4
    ✅ 【层级3】 选数字3 → path=[2,2,3], sum=7
    🟡 【层级3】 暂停阶段 → 调用下一层backtrack,等待返回
      🟢 【层级4】 调用阶段 → path=[2,2,3], sum=7, start=1
      ✅ 【层级4】 找到有效组合 → [2,2,3],result=[[2,2,3]]
      🔴 【层级4】 返回阶段 → 找到解,回到上一层
    🔴 【层级3】 返回阶段 → 从下一层返回,继续执行
    🔵 【层级3】 撤销阶段 → 撤销数字3 → path=[2,2], sum=4
    🔍 【层级3】 遍历i=2 → 尝试选数字7,当前sum=4
    🚫 【层级3】 排序剪枝 → 4+7=11 > 7,break循环
    🔴 【层级3】 返回阶段 → for循环结束,回到上一层
  🔴 【层级2】 返回阶段 → 从下一层返回,继续执行
  🔵 【层级2】 撤销阶段 → 撤销数字2 → path=[2], sum=2
  🔍 【层级2】 遍历i=1 → 尝试选数字3,当前sum=2
  ✅ 【层级2】 选数字3 → path=[2,3], sum=5
  🟡 【层级2】 暂停阶段 → 调用下一层backtrack,等待返回
    🟢 【层级3】 调用阶段 → path=[2,3], sum=5, start=1
    🔍 【层级3】 遍历i=1 → 尝试选数字3,当前sum=5
    🚫 【层级3】 排序剪枝 → 5+3=8 > 7,break循环
    🔴 【层级3】 返回阶段 → for循环结束,回到上一层
  🔴 【层级2】 返回阶段 → 从下一层返回,继续执行
  🔵 【层级2】 撤销阶段 → 撤销数字3 → path=[2], sum=2
  🔍 【层级2】 遍历i=2 → 尝试选数字7,当前sum=2
  🚫 【层级2】 排序剪枝 → 2+7=9 > 7,break循环
  🔴 【层级2】 返回阶段 → for循环结束,回到上一层
🔴 【层级1】 返回阶段 → 从下一层返回,继续执行
🔵 【层级1】 撤销阶段 → 撤销数字2 → path=[], sum=0
🔍 【层级1】 遍历i=1 → 尝试选数字3,当前sum=0
✅ 【层级1】 选数字3 → path=[3], sum=3
🟡 【层级1】 暂停阶段 → 调用下一层backtrack,等待返回
  🟢 【层级2】 调用阶段 → path=[3], sum=3, start=1
  🔍 【层级2】 遍历i=1 → 尝试选数字3,当前sum=3
  ✅ 【层级2】 选数字3 → path=[3,3], sum=6
  🟡 【层级2】 暂停阶段 → 调用下一层backtrack,等待返回
    🟢 【层级3】 调用阶段 → path=[3,3], sum=6, start=1
    🔍 【层级3】 遍历i=1 → 尝试选数字3,当前sum=6
    🚫 【层级3】 排序剪枝 → 6+3=9 > 7,break循环
    🔴 【层级3】 返回阶段 → for循环结束,回到上一层
  🔴 【层级2】 返回阶段 → 从下一层返回,继续执行
  🔵 【层级2】 撤销阶段 → 撤销数字3 → path=[3], sum=3
  🔍 【层级2】 遍历i=2 → 尝试选数字7,当前sum=3
  🚫 【层级2】 排序剪枝 → 3+7=10 > 7,break循环
  🔴 【层级2】 返回阶段 → for循环结束,回到上一层
🔴 【层级1】 返回阶段 → 从下一层返回,继续执行
🔵 【层级1】 撤销阶段 → 撤销数字3 → path=[], sum=0
🔍 【层级1】 遍历i=2 → 尝试选数字7,当前sum=0
✅ 【层级1】 选数字7 → path=[7], sum=7
🟡 【层级1】 暂停阶段 → 调用下一层backtrack,等待返回
  🟢 【层级2】 调用阶段 → path=[7], sum=7, start=2
  ✅ 【层级2】 找到有效组合 → [7],result=[[2,2,3],[7]]
  🔴 【层级2】 返回阶段 → 找到解,回到上一层
🔴 【层级1】 返回阶段 → 从下一层返回,继续执行
🔵 【层级1】 撤销阶段 → 撤销数字7 → path=[], sum=0
🔴 【层级1】 返回阶段 → for循环结束,回到上一层
========== 执行结束 ==========
最终结果: [ [ 2, 2, 3 ], [ 7 ] ]

执行流程解析

candidates = [2,3,7], target = 7 为例:

  1. 选2 → path=[2], sum=2 → 钻到底(递归)
  2. 选2 → path=[2,2], sum=4 → 钻到底(递归)
  3. 选2 → path=[2,2,2], sum=6 → 钻到底(递归)
  4. 选2 → sum=8 > 7,剪枝,退回来
  5. 删掉2 → path=[2,2], sum=6 → 选下一个数3
  6. 选3 → path=[2,2,3], sum=7 ✅ 找到解,退回来
  7. 删掉3 → path=[2,2], sum=6 → 选下一个数7
  8. 剪枝,退回来 → path=[2], sum=2 → 继续尝试...
  9. 最终收集到 [[2,2,3],[7]]

三、回溯算法常见题型及解题方法

1. 组合问题

核心特征
  • 不考虑元素顺序,每个组合唯一(如[2,3][3,2]算同一个)
  • start参数控制"不回头选",避免生成重复组合
  • 数字可重复选(组合总和)/不可重复选(组合),仅需调整start参数(重复选传i,不重复选传i+1
LeetCode 题目详解
39. 组合总和

题目描述:

给定一个无重复元素的整数数组 candidates 和一个目标整数 target,找出 candidates 中可以使数字和为目标数 target所有不同组合,并以列表形式返回。你可以按任意顺序返回这些组合。

candidates 中的同一个数字可以无限制重复被选取。如果至少一个数字的被选数量不同,则两种组合是不同的。

示例 1:

输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]]
解释:
2 和 3 可以形成一组候选,2 + 2 + 3 = 7。注意 2 可以使用多次。
7 也是一个候选, 7 = 7。
所以这两种组合是唯一的答案。

示例 2:

输入: candidates = [2,3,5], target = 8
输出: [[2,2,2,2],[2,3,3],[3,5]]

解决方案:

//**
 * 组合总和:找出候选数组中所有和为目标值的组合(数字可重复选取)
 * @param candidates 无重复元素的候选数字数组
 * @param target 目标和
 * @returns 所有符合条件的组合数组
 */
function combinationSum(candidates: number[], target: number): number[][] {
  // 结果数组:存储所有符合条件的组合(需深拷贝,避免引用污染)
  const res: number[][] = [];
  // 候选数组长度:避免循环中重复计算
  const len = candidates.length;

  // 【易错点1】排序是剪枝的前提!无序数组无法用break有效剪枝
  candidates.sort((x, y) => x - y);

  /**
   * 回溯核心函数
   * @param path 当前已选数字的路径(引用类型,需注意回溯撤销)
   * @param sum 当前路径的数字和(避免重复计算,提升效率)
   * @param start 遍历起始索引(控制不回头选,避免重复组合)
   */
  function backtrack(path: number[], sum: number, start: number) {
    // 终止条件1:找到有效组合
    if (sum === target) {
      // 【易错点2】必须深拷贝!直接push(path)会因引用导致后续pop修改结果
      res.push([...path]);
      return;
    }

    // 终止条件2:前置剪枝(sum超过目标值,直接终止当前递归)
    // 可选优化:可合并为 if (sum >= target) { ... return }
    if (sum > target) {
      return;
    }

    // 遍历候选数组(从start开始,避免重复组合)
    for (let i = start; i < len; i++) {
      const cur = candidates[i];

      // 【执行顺序优化点】剪枝逻辑应放在push之前(当前代码push后判断,会多一次无效push/pop)
      // 排序后,若当前数+已选和>目标值,后续数更大,直接终止循环
      if (sum + cur > target) {
        break;
      }

      // 做选择:将当前数字加入路径
      path.push(cur);

      // 【易错点3】递归参数传i而非start!
      // 传i:允许重复选当前数字(组合总和核心需求)
      // 传start:无限递归(永远从0开始选),传i+1:不允许重复选(变成组合问题)
      backtrack(path, sum + cur, i);

      // 撤销选择:回溯核心,恢复路径状态
      path.pop();
    }
  }

  // 初始调用:空路径、和为0、从索引0开始遍历
  backtrack([], 0, 0);
  return res;
}

// 测试用例
console.log(combinationSum([2, 3, 6, 7], 7)); // 输出:[[2,2,3],[7]]
console.log(combinationSum([2, 3, 5], 8));   // 输出:[[2,2,2,2],[2,3,3],[3,5]]

易错点:

  • 忘记排序导致剪枝失效
  • start参数传错(重复选传i,不重复选传i+1
  • 忘记拷贝路径([...path]

40. 组合总和 II

题目描述:

给定一个候选人编号的集合 candidates 和一个目标数 target,找出 candidates 中所有可以使数字和为 target 的组合。

candidates 中的每个数字在每个组合中只能使用一次

**注意:**解集不能包含重复的组合。

示例 1:

输入: candidates = [10,1,2,7,6,1,5], target = 8,
输出:
[
[1,1,6],
[1,2,5],
[1,7],
[2,6]
]

解决方案:

/**
 * 组合总和II:找出候选数组中所有和为目标值的组合(数字不可重复选取,且结果无重复组合)
 * 核心区别于combinationSum:
 * 1. 候选数组可能包含重复数字
 * 2. 每个数字在每个组合中只能使用一次
 * 3. 最终结果不能有重复组合
 * @param candidates 可能包含重复元素的候选数字数组
 * @param target 目标和
 * @returns 所有符合条件的不重复组合数组
 */
function combinationSum2(candidates: number[], target: number): number[][] {
  // 结果数组:存储所有符合条件的组合(需深拷贝,避免引用污染)
  const res: number[][] = [];
  // 候选数组长度:避免循环中重复计算
  const len = candidates.length;

  // 【易错点1】排序是去重+剪枝的前提!无序数组无法有效去重和剪枝
  candidates.sort((x, y) => x - y);

  /**
   * 回溯核心函数
   * @param path 当前已选数字的路径(引用类型,需注意回溯撤销)
   * @param sum 当前路径的数字和(避免重复计算,提升效率)
   * @param start 遍历起始索引(控制不回头选,避免重复组合)
   */
  function backtrack(path: number[], sum: number, start: number) {
    // 终止条件1:找到有效组合
    if (sum === target) {
      // 【易错点2】必须深拷贝!直接push(path)会因引用导致后续pop修改结果
      res.push([...path]);
      return;
    }

    // 终止条件2:前置剪枝(sum超过目标值,直接终止当前递归)
    if (sum > target) {
      return;
    }

    // 遍历候选数组(从start开始,避免重复组合)
    for (let i = start; i < len; i++) {
      const cur = candidates[i];

      // 【核心优化1】先剪枝(sum+cur>target),再处理去重,避免无效操作
      // 排序后,若当前数+已选和>目标值,后续数更大,直接终止循环
      if (sum + cur > target) {
        break;
      }

      // 【易错点3】去重剪枝必须放在push之前!且判断条件是i>start
      // 作用:过滤同一层递归中重复的数字(比如[1,1,2],3的话,第一个[1,2],然后path变成[]之后,走到i=1,此时又是1和上一个相同,如果不跳过,则又会有一个[1,2],所以这里需要跳过)
      if (i > start && candidates[i] === candidates[i - 1]) {
        continue; // 跳过当前层的重复数字
      }

      // 做选择:将当前数字加入路径
      path.push(cur);

      // 【易错点4】递归参数传i+1而非i!
      // 传i+1:每个数字只能选一次(组合总和II核心要求)
      // 传i:允许重复选(变成combinationSum),传start:无限递归
      backtrack(path, sum + cur, i + 1);

      // 撤销选择:回溯核心,恢复路径状态
      path.pop();
    }
  }

  // 初始调用:空路径、和为0、从索引0开始遍历
  backtrack([], 0, 0);
  return res;
}

// 测试用例(重点验证去重)
console.log(combinationSum2([10, 1, 2, 7, 6, 1, 5], 8));
// 正确输出:[[1,1,6],[1,2,5],[1,7],[2,6]]
console.log(combinationSum2([2, 5, 2, 1, 2], 5));
// 正确输出:[[1,2,2],[5]]

易错点:

  • 去重逻辑错误:应该是i > start而不是i > 0(允许不同层选相同数字)
  • 忘记排序导致去重失效

77. 组合

题目描述:

给定两个整数 nk,返回范围 [1, n] 中所有可能的 k 个数的组合。

你可以按任何顺序返回答案。

示例 1:

输入:n = 4, k = 2
输出:
[
  [2,4],
  [3,4],
  [2,3],
  [1,2],
  [1,3],
  [1,4],
]

解决方案:

/**
 * 组合:从 1~n 的数字中选出 k 个数字的所有组合(不考虑顺序,无重复组合)
 * 核心规则:
 * 1. 组合不考虑顺序(如[1,2]和[2,1]算同一个,需通过startNum控制不回头选)
 * 2. 每个数字只能选一次
 * @param n 数字范围上限(1~n)
 * @param k 组合的长度
 * @returns 所有符合条件的组合数组
 */
function combine(n: number, k: number): number[][] {
  // 结果数组:存储所有符合条件的组合(需深拷贝,避免引用污染)
  const res: number[][] = [];

  /**
   * 回溯核心函数
   * @param path 当前已选数字的路径(引用类型,需注意回溯撤销)
   * @param startNum 遍历起始数字(控制不回头选,避免重复组合,如选1后只能选2/3...n)
   */
  function backtrack(path: number[], startNum: number) {
    // 终止条件:当前路径长度等于k,找到有效组合
    if (path.length === k) {
      // 【易错点1】必须深拷贝!直接push(path)会因引用导致后续pop修改结果
      res.push([...path]);
      return;
    }

    // 【核心剪枝】剩余可选数字不足以凑够k个,直接终止递归(优化效率)
    // 剩余可选数字数 = n - startNum + 1
    // 当前路径长度 + 剩余可选数字数 < k → 不可能凑够k个,剪枝
    if (path.length + (n - startNum + 1) < k) {
      return;
    }

    // 遍历数字:从startNum开始到n(避免回头选,生成重复组合)
    for (let i = startNum; i <= n; i++) {
      // 【易错点2】核心错误:原代码push(startNum),正确应push(i)
      // 做选择:将当前遍历的数字i加入路径
      path.push(i);

      // 【易错点3】递归参数传i+1而非startNum+1!
      // 传i+1:下一层从当前数字的下一位开始,保证不回头选
      backtrack(path, i + 1);

      // 撤销选择:回溯核心,恢复路径状态
      path.pop();
    }
  }

  // 初始调用:空路径、从数字1开始遍历
  backtrack([], 1);
  return res;
}

// 测试用例
console.log(combine(4, 2));
// 正确输出:[[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]
console.log(combine(3, 3));
// 正确输出:[[1,2,3]]
console.log(combine(5, 1));
// 正确输出:[[1],[2],[3],[4],[5]]

易错点:

  • 范围错误:应该是[1, n],循环条件是i <= n而不是i < n
  • 忘记剪枝优化导致超时

216. 组合总和 III

题目描述:

找出所有相加之和为 nk 个数的组合,且满足下列条件:

  • 只使用数字1到9
  • 每个数字最多使用一次

返回所有可能的有效组合的列表。该列表不能包含相同的组合两次,组合可以以任何顺序返回。

示例 1:

输入: k = 3, n = 7
输出: [[1,2,4]]
解释:
1 + 2 + 4 = 7
没有其他符合的组合了。

解决方案:

/**
 * 组合总和III:找出所有相加之和为n的k个正整数组合(仅使用数字1-9,每个数字最多使用一次)
 * 核心规则:
 * 1. 组合长度固定为k;
 * 2. 数字范围1~9,且不重复选取;
 * 3. 组合和为n,且组合不考虑顺序(如[1,2]和[2,1]算同一个,需通过startNum控制不回头选)。
 * @param k 组合的固定长度
 * @param n 组合的目标和
 * @returns 所有符合条件的组合数组
 */
function combinationSum3(k: number, n: number): number[][] {
  // 结果数组:存储所有符合条件的组合(需深拷贝,避免引用污染)
  const res: number[][] = [];
  // 目标和(可直接用n,此处保留target仅为语义清晰)
  const target = n;

  /**
   * 回溯核心函数
   * @param path 当前已选数字的路径(引用类型,需注意回溯撤销)
   * @param sum 当前路径的数字和(避免重复计算,提升效率)
   * @param startNum 遍历起始数字(控制不回头选,避免重复组合,如选1后只能选2~9)
   */
  function backtrack(path: number[], sum: number, startNum: number) {
    // 【易错点1】终止条件1:组合长度达到k(核心终止条件)
    if (path.length === k) {
      // 仅当和等于目标值时,记录有效组合
      if (sum === target) {
        res.push([...path]);
      }
      // 无论sum是否等于target,长度到k都要终止(sum>target也无需单独判断,直接return)
      return;
    }

    // 【核心剪枝1】剩余可选数字不足以凑够k个,直接终止递归
    // 剩余可选数字数 = 9 - startNum + 1(1~9共9个数字)
    // 当前路径长度 + 剩余可选数字数 < k → 不可能凑够k个,剪枝
    if (path.length + (9 - startNum + 1) < k) {
      return;
    }

    // 【核心剪枝2】提前预判:若当前sum + 最小剩余数字 > target,后续无需遍历(原代码仅在循环内剪枝,此处可选)
    // 如sum=7, target=8, k=2, path.length=1 → 剩余1个数字最小是startNum,若7+startNum>8则break
    // (原代码无此剪枝,不影响结果,仅优化效率)

    // 遍历数字:从startNum开始到9(避免回头选,保证数字不重复)
    for (let i = startNum; i <= 9; i++) {
      // 【易错点2】循环内剪枝:sum+i>target时,后续数字更大,直接终止循环(1~9已天然排序)
      if (sum + i > target) {
        break;
      }

      // 做选择:将当前数字i加入路径
      path.push(i);

      // 【易错点3】递归参数传i+1而非startNum+1!
      // 传i+1:下一层从当前数字的下一位开始,保证每个数字只选一次
      backtrack(path, sum + i, i + 1);

      // 撤销选择:回溯核心,恢复路径状态
      path.pop();
    }
  }

  // 初始调用:空路径、和为0、从数字1开始遍历
  backtrack([], 0, 1);
  return res;
}

// 测试用例
console.log(combinationSum3(3, 7));
// 正确输出:[[1,2,4]]
console.log(combinationSum3(3, 9));
// 正确输出:[[1,2,6],[1,3,5],[2,3,4]]
console.log(combinationSum3(4, 1));
// 正确输出:[](无符合条件的组合)
console.log(combinationSum3(2, 18));
// 正确输出:[[9,9]] → 错误?不,1~9数字不重复,所以正确输出是[](原代码会正确返回[])

2. 排列问题

核心特征
  • 考虑元素顺序,每个排列唯一(如[1,2][2,1]算不同排列)
  • used数组控制"不重复选",循环从0开始(允许选任意未选过的数字)
  • 含重复数字的排列(全排列II)需增加"去重剪枝"
LeetCode 题目详解
46. 全排列

题目描述:

给定一个不含重复数字的数组 nums,返回其所有可能的全排列。你可以按任意顺序返回答案。

示例 1:

输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

解决方案:

/**
 * 全排列:生成无重复数字数组的所有排列(考虑顺序,如[1,2]和[2,1]是不同排列)
 * 核心规则:
 * 1. 排列考虑顺序,每个数字在排列中仅出现一次;
 * 2. 用used数组标记已选数字,避免重复选取;
 * 3. 无需start参数(排列需要遍历所有未选数字,而非从某一位置开始)。
 * @param nums 无重复元素的数字数组
 * @returns 所有可能的排列数组
 */
function permute(nums: number[]): number[][] {
  // 结果数组:存储所有排列(需深拷贝,避免引用污染)
  const res: number[][] = [];
  // 数组长度:避免循环中重复计算
  const len = nums.length;
  // 【核心】used数组:标记索引i的数字是否已被选入当前路径,初始全为false
  const used = new Array(len).fill(false);

  /**
   * 回溯核心函数
   * @param path 当前已选数字的路径(引用类型,需注意回溯撤销)
   */
  function backtrack(path: number[]) {
    // 终止条件:当前路径长度等于数组长度,找到一个完整排列
    if (path.length === len) {
      // 【易错点1】必须深拷贝!直接push(path)会因引用导致后续pop修改结果
      res.push([...path]);
      return;
    }

    // 遍历所有数字(排列需遍历全部,而非从start开始)
    for (let i = 0; i < len; i++) {
      // 【易错点2】跳过已选数字:used[i]为true时,当前数字已在path中,避免重复选取
      if (used[i]) {
        continue;
      }

      // 1. 标记当前数字为已选
      used[i] = true;
      // 2. 做选择:将当前数字加入路径
      const cur = nums[i];
      path.push(cur);

      // 递归:继续构建排列(无需传start,因为要遍历所有未选数字)
      backtrack(path);

      // 3. 撤销选择:回溯核心,恢复状态(先pop路径,再取消used标记)
      path.pop();
      used[i] = false;
    }
  }

  // 初始调用:空路径开始构建排列
  backtrack([]);
  return res;
}

// 测试用例
console.log(permute([1, 2, 3]));
// 正确输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
console.log(permute([0, 1]));
// 正确输出:[[0,1],[1,0]]
console.log(permute([1]));
// 正确输出:[[1]]

易错点:

  • 忘记使用used数组导致重复选同一个数字
  • 循环应该从0开始,不是从start开始(排列需要考虑所有位置)

47. 全排列 II

题目描述:

给定一个可包含重复数字的序列 nums按任意顺序返回所有不重复的全排列。

示例 1:

输入:nums = [1,1,2]
输出:
[[1,1,2],
 [1,2,1],
 [2,1,1]]

解决方案:

/**
 * 全排列II:生成含重复数字数组的所有不重复排列
 * 核心目标:
 * 1. 生成所有排列(考虑顺序,如[1,2]≠[2,1]);
 * 2. 过滤重复排列(如[1,1,2]仅保留唯一的3种排列)。
 * @param nums 可能包含重复元素的数字数组
 * @returns 所有不重复的排列数组
 */
function permuteUnique(nums: number[]): number[][] {
  // 结果数组:存储最终不重复的排列(深拷贝避免引用污染)
  const res: number[][] = [];
  // 数组长度:避免循环中重复计算
  const len = nums.length;
  // used数组:标记索引i的数字是否已被选入当前路径,初始全为未选(false)
  const used = new Array(len).fill(false);

  // 【核心前置操作】排序:让重复数字相邻,为后续去重剪枝做准备
  // 例:[1,2,1] → [1,1,2],保证重复数字挨在一起
  nums.sort((x, y) => x - y);

  /**
   * 回溯核心函数:递归构建排列路径
   * @param path 当前已选数字的路径(引用类型,需回溯撤销)
   */
  function backtrack(path: number[]) {
    // 终止条件:路径长度等于数组长度 → 找到一个完整排列
    if (path.length === len) {
      // 深拷贝path:避免后续pop修改已存入res的数组
      res.push([...path]);
      return;
    }

    // 遍历所有数字(排列需遍历全部,而非从start开始)
    for (let i = 0; i < len; i++) {
      // 剪枝1:跳过已选数字(避免同一排列中重复选同一数字)
      if (used[i]) {
        continue;
      }

      // 【核心去重剪枝】过滤同一层的重复数字
      // 条件1:i>0 → 避免i=0时i-1越界
      // 条件2:nums[i] === nums[i-1] → 当前数字和前一个数字重复
      // 条件3:!used[i-1] → 前一个重复数字未被选(说明是同一层的重复),谁先被选,谁就占了这个 “重复数字开头” 的坑,后面的重复数字不用再选(没被选走→跳过);
      // 作用:仅跳过「同一层」的重复数字,允许「不同层」选相同数字
      if (i > 0 && nums[i] === nums[i - 1] && !used[i - 1]) {
        continue;
      }

      // 1. 标记当前数字为已选
      used[i] = true;
      const cur = nums[i];
      // 2. 做选择:将当前数字加入路径
      path.push(cur);

      // 递归:继续构建下一层的排列
      backtrack(path);

      // 3. 撤销选择(回溯核心):恢复路径和used标记
      path.pop();
      used[i] = false;
    }
  }

  // 初始调用:从空路径开始构建排列
  backtrack([]);
  return res;
}

// 测试用例(验证去重和完整性)
console.log(permuteUnique([1, 1, 2]));
// 正确输出:[[1,1,2],[1,2,1],[2,1,1]]
console.log(permuteUnique([2, 2, 1, 1]));
// 正确输出:[[1,1,2,2],[1,2,1,2],[1,2,2,1],[2,1,1,2],[2,1,2,1],[2,2,1,1]]
console.log(permuteUnique([1, 2, 3]));
// 正确输出:和permute一致 → [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

易错点:

  • 去重逻辑错误:应该是!used[i - 1]而不是used[i - 1]
    • !used[i - 1]:前一个相同数字未被使用,说明我们在同一层尝试重复数字,应该跳过
    • used[i - 1]:前一个相同数字已被使用,说明我们在不同层,可以使用

3. 子集问题

核心特征
  • 找出所有可能的子集(包括空集)
  • start参数控制不回头选,无需严格终止条件(每次递归都记录路径)
  • 含重复数字的子集(子集II)需增加"去重剪枝"
LeetCode 题目详解
78. 子集

题目描述:

给你一个整数数组 nums,数组中的元素互不相同。返回该数组所有可能的子集(幂集)。

解集不能包含重复的子集。你可以按任意顺序返回解集。

示例 1:

输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

解决方案:

/**
 * 子集:生成数组的所有子集(包括空集和数组本身,子集不考虑顺序)
 * 核心规则:
 * 1. 子集不考虑顺序(如[1,2]和[2,1]是同一个子集,通过startIndex控制不回头选);
 * 2. 每个子集是原数组的任意元素组合(元素不重复选取);
 * 3. 无需去重(若nums无重复元素),无需排序。
 * @param nums 无重复元素的数字数组
 * @returns 所有子集组成的二维数组
 */
function subsets(nums: number[]): number[][] {
  // 结果数组:存储所有子集(深拷贝避免引用污染)
  const res: number[][] = [];
  // 数组长度:避免循环中重复计算
  const len = nums.length;

  /**
   * 回溯核心函数:递归构建子集路径
   * @param path 当前子集路径(引用类型,需回溯撤销)
   * @param startIndex 遍历起始索引(控制不回头选,避免生成重复子集)
   */
  function backtrack(path: number[], startIndex: number) {
    // 【核心】每进入一层递归,先记录当前路径(包括空集)
    // 区别于排列/组合:子集问题无终止条件(所有路径都是有效子集),每一步都要保存
    res.push([...path]);

    // 遍历数组:从startIndex开始,避免回头选(如选1后只选2/3,不回头选1)
    for (let i = startIndex; i < len; i++) {
      const cur = nums[i];
      // 做选择:将当前数字加入子集路径
      path.push(cur);
      // 递归:下一层从i+1开始(保证元素不重复选取)
      backtrack(path, i + 1);
      // 撤销选择:回溯核心,恢复路径状态
      path.pop();
    }
  }

  // 初始调用:空路径开始,从索引0遍历
  backtrack([], 0);
  return res;
}

// 测试用例
console.log(subsets([1, 2, 3]));
// 正确输出:[[],[1],[1,2],[1,2,3],[1,3],[2],[2,3],[3]]
console.log(subsets([0]));
// 正确输出:[[],[0]]

易错点:

  • 忘记在每次递归开始时记录路径(子集问题需要在每个节点都记录,不只是叶子节点)

90. 子集 II

题目描述:

给你一个整数数组 nums,其中可能包含重复元素,请你返回该数组所有可能的子集(幂集)。

解集不能包含重复的子集。返回的解集中,子集可以按任意顺序排列。

示例 1:

输入:nums = [1,2,2]
输出:[[],[1],[1,2],[1,2,2],[2],[2,2]]

解决方案:

/**
 * 子集II:生成含重复数字数组的所有不重复子集(包括空集和数组本身)
 * 核心目标:
 * 1. 生成所有子集(子集不考虑顺序);
 * 2. 过滤重复子集(如nums=[1,2,2]时,避免生成两个[1,2])。
 * @param nums 可能包含重复元素的数字数组
 * @returns 所有不重复子集组成的二维数组
 */
function subsetsWithDup(nums: number[]): number[][] {
  // 结果数组:存储所有不重复子集(深拷贝避免引用污染)
  const res: number[][] = [];
  const len = nums.length;

  // 【核心前置操作】排序:让重复数字相邻,为去重剪枝做准备
  // 例:[1,2,2]排序后仍为[1,2,2],[2,1,2]排序后为[1,2,2],保证重复数字挨在一起
  nums.sort((x, y) => x - y);

  /**
   * 回溯核心函数:递归构建子集路径
   * @param path 当前子集路径(引用类型,需回溯撤销)
   * @param startIndex 遍历起始索引(控制不回头选,避免生成重复子集)
   */
  function backtrack(path: number[], startIndex: number) {
    // 子集问题核心:每进入一层递归,先保存当前路径(包括空集)
    // 所有路径都是有效子集,无需等待“长度达标”,这是子集和组合/排列的核心区别
    res.push([...path]);

    // 遍历数组:从startIndex开始,避免回头选(如选1后只选2/2,不回头选1)
    for (let i = startIndex; i < len; i++) {
      // 【核心去重剪枝】过滤同一层的重复数字
      // 条件1:i > startIndex → 跳过“当前层第一个数字”(避免误过滤跨层重复)
      // 条件2:nums[i] === nums[i - 1] → 当前数字和前一个数字重复
      // 作用:仅跳过同一层的重复数字,保留不同层的重复数字(如[2]和[1,2]都是有效子集)
      if (i > startIndex && nums[i] === nums[i - 1]) {
        continue;
      }

      const cur = nums[i];
      // 做选择:将当前数字加入子集路径
      path.push(cur);
      // 递归:下一层从i+1开始(保证元素不重复选取)
      backtrack(path, i + 1);
      // 撤销选择:回溯核心,恢复路径状态
      path.pop();
    }
  }

  // 初始调用:空路径开始,从索引0遍历
  backtrack([], 0);
  return res;
}

// 测试用例(验证去重效果)
console.log(subsetsWithDup([1, 2, 2]));
// 正确输出:[[],[1],[1,2],[1,2,2],[2],[2,2]]
console.log(subsetsWithDup([0]));
// 正确输出:[[],[0]]
console.log(subsetsWithDup([2, 1, 2]));
// 排序后为[1,2,2],输出和上面一致 → [[],[1],[1,2],[1,2,2],[2],[2,2]]

易错点:

  • 去重逻辑和组合总和II相同,但容易忘记排序

四、总结

分类 题号&名称 核心规则 关键操作 终止/保存条件 去重&剪枝技巧
组合类 39. 组合总和 1. 数字可重复选
2. 候选数组无重复
3. 组合和为target
1. 递归参数传i(允许重复选当前数)
2. 用startIndex控制不回头选
1. sum === target → 保存路径
2. sum > target → 剪枝返回
1. 排序(nums.sort)是剪枝前提
2. sum + cur > targetbreak(后续数更大)
40. 组合总和 II 1. 数字不可重复选
2. 候选数组有重复
3. 组合和为target
1. 递归参数传i+1(不可重复选)
2. 用startIndex控制不回头选
1. sum === target → 保存路径
2. sum > target → 剪枝返回
1. 先排序(让重复数字相邻)
2. 同层去重:i > startIndex && nums[i] === nums[i-1]continue
3. sum + cur > targetbreak
77. 组合 1. 从 1~nk 个数字
2. 无重复组合
3. 不考虑顺序
1. 递归参数传i+1
2. 用startIndex控制不回头选
path.length === k → 保存路径 剪枝:path.length + (n - startIndex + 1) < k → 剪枝(剩余数不够凑k个)
216. 组合总和 III 1. 从 1~9k 个数字
2. 数字不可重复选
3. 组合和为n
1. 递归参数传i+1
2. 用startIndex控制不回头选
path.length === k sum === n → 保存路径 1. 1~9天然有序,无需排序
2. sum + cur > nbreak
3. 剩余数剪枝(同77题)
排列类 46. 全排列 1. 候选数组无重复
2. 生成所有排列(考虑顺序)
1. 用used数组标记已选数字
2. 遍历范围0~len-1(无startIndex
path.length === len → 保存路径 无需去重,仅用used[i]continue(跳过已选数字)
47. 全排列 II 1. 候选数组有重复
2. 生成无重复排列(考虑顺序)
1. 用used数组标记已选数字
2. 遍历范围0~len-1(无startIndex
path.length === len → 保存路径 1. 先排序(让重复数字相邻)
2. 同层去重:i > 0 && nums[i] === nums[i-1] && !used[i-1]continue
3. used[i]continue(跳过已选数字)
子集类 78. 子集 1. 候选数组无重复
2. 生成所有子集(含空集)
3. 不考虑顺序
1. 递归参数传i+1
2. 用startIndex控制不回头选
进入递归就保存路径(所有路径都是有效子集) 无需去重,无剪枝(子集无长度/和限制)
90. 子集 II 1. 候选数组有重复
2. 生成无重复子集(含空集)
3. 不考虑顺序
1. 递归参数传i+1
2. 用startIndex控制不回头选
进入递归就保存路径(所有路径都是有效子集) 1. 先排序(让重复数字相邻)
2. 同层去重:i > startIndex && nums[i] === nums[i-1]continue
  • 排序:只要涉及“去重/剪枝”,第一步必排序(让重复数字相邻、让数字递增便于sum剪枝);
  • 深拷贝:所有题都需要res.push([...path]),直接push(path)会因引用导致结果错误;
  • 回溯闭环:选数字(push)→ 递归 → 撤销选(pop),心是"选→探→撤",缺一不可。
  • 模板复用
    • 组合/子集:用start参数避免重复,循环从start开始
    • 排列:用used数组避免重复选,循环从0开始=
  • 去重剪枝的“黄金公式”
    • 组合/子集去重(有重复数字):i > startIndex && nums[i] === nums[i-1]
    • 排列去重(有重复数字):i > 0 && nums[i] === nums[i-1] && !used[i-1]
    • sum剪枝(所有求和类问题):sum + cur > target → break(需先排序)。

记住“组合看start、排列看used、子集全保存”的口诀,就能快速适配所有这类回溯问题~

❌