数组作为 JavaScript 中最基础的数据结构,就像编程世界里的乐高积木,看似简单却能搭建出无限可能。但你是否也曾在面对数组操作时陷入困境:二分查找时左右指针不知道该怎么动?双指针解法总是理不清思路?边界条件处理得一塌糊涂?别担心!今天我们就来系统梳理数组算法的核心技巧,结合 LeetCode 经典题目,让你从此玩转数组操作,代码优雅又高效!
🌱数组基础:看似简单,暗藏玄机
在 JavaScript 中,数组是一种特殊的对象,用于存储有序的元素集合。它具有以下特点:
- 随机访问:可以通过索引直接访问元素,时间复杂度为 O (1)
- 动态长度:与许多编程语言不同,JS 数组长度可以动态变化
- 异质元素:可以同时存储不同类型的数据(虽然实际开发中不推荐)
- 连续内存:在底层,数组元素在内存中连续存储,这也是其高效访问的基础
数组题的常见需求无非四类:查找目标、原地修改、处理子数组、转化数组,接下来的技巧全是针对这些需求的 “最优解”,咱们逐个拆解。
🔍二分查找:精准定位的艺术
二分查找是数组查找类问题的 "瑞士军刀",其 O (log n) 的时间复杂度使其成为处理有序数组的利器。但想要用好这把利器,可不是件容易的事。
核心思想:逐步缩小搜索范围
二分查找的基本思路很简单:对于一个有序数组,每次通过与中间元素比较,将搜索范围缩小一半,直到找到目标元素或确定目标不存在。
但真正的挑战在于边界条件的处理和循环不变量的维护。
经典例题:704. 二分查找 - 力扣(LeetCode)
解法一:左闭右闭区间 [left, right]
这种情况下,我们定义的搜索区间是包含左右端点的,这意味着:
- 初始时 left = 0,right = nums.length - 1(因为最后一个元素是有效的)
- 当 left > right 时,搜索区间为空,循环结束
- 当 nums [mid] > target 时,目标值只能在左半部分,且不包含 mid,所以 right = mid - 1
- 当 nums [mid] < target 时,目标值只能在右半部分,且不包含 mid,所以 left = mid + 1
function search(nums, target) {
let left = 0;
let right = nums.length - 1; // 注意这里是length-1
// 循环条件是left <= right,因为当left === right时,区间[left, right]仍然有效
while (left <= right) {
const mid = left + Math.floor((right - left) / 2); // 防止溢出,等价于(Math.floor((left + right) / 2)
if (nums[mid] === target) {
return mid; // 找到目标,返回索引
} else if (nums[mid] > target) {
right = mid - 1; // 目标在左半部分,缩小右边界
} else {
left = mid + 1; // 目标在右半部分,缩小左边界
}
}
return -1; // 未找到目标
}
解法二:左闭右开区间 [left, right)
这种情况下,我们定义的搜索区间包含左端点但不包含右端点,这意味着:
- 初始时 left = 0,right = nums.length(因为最后一个元素的索引是 length-1,right 是开区间)
- 当 left === right 时,搜索区间为空,循环结束
- 当 nums [mid] > target 时,目标值在左半部分,right = mid(因为 right 是开区间)
- 当 nums [mid] < target 时,目标值在右半部分,left = mid + 1
function search(nums, target) {
let left = 0;
let right = nums.length; // 注意这里是length
// 循环条件是left < right,因为当left === right时,区间[left, right)为空
while (left < right) {
const mid = left + Math.floor((right - left) / 2);
if (nums[mid] === target) {
return mid;
} else if (nums[mid] > target) {
right = mid; // 右边界不包含在搜索区间内
} else {
left = mid + 1;
}
}
return -1;
}
二分查找的关键:循环不变量
两种解法的核心区别在于对搜索区间的定义,而保持循环不变量是正确实现二分查找的关键。循环不变量指的是在循环的每一次迭代中,都保持某种条件不变。
在二分查找中,这个不变量就是我们对搜索区间的定义。无论循环进行到哪一步,我们都要确保:
- 目标值如果存在,一定在当前的搜索区间内
- 搜索区间会不断缩小,最终会变为空(保证循环会结束)
只要严格遵守这个不变量,就能避免大多数边界错误。
👆双指针:高效遍历的利器
双指针是处理数组问题的另一种核心技巧,尤其适用于需要遍历数组并进行元素比较或交换的场景。它通常能将暴力解法的 O (n²) 时间复杂度优化到 O (n)。
类型一:快慢指针
快慢指针通常用于处理数组中的元素移除、寻找环、获取倒数第 k 个元素等问题。两个指针以不同的速度遍历数组。
经典例题:27. 移除元素 - 力扣(LeetCode)
思路:使用慢指针记录有效元素的位置,快指针遍历整个数组。当快指针遇到不等于 val 的元素时,将其赋值给慢指针位置,并移动慢指针。
function removeElement(nums, val) {
let slow = 0; // 慢指针:指向有效元素的下一个位置
// 快指针遍历整个数组
for (let fast = 0; fast < nums.length; fast++) {
// 当快指针指向的元素不等于val时,将其复制到慢指针位置
if (nums[fast] !== val) {
nums[slow] = nums[fast];
slow++; // 慢指针向前移动
}
}
return slow; // 慢指针的值就是新数组的长度
}
这个解法的巧妙之处在于:
- 不需要额外空间,原地修改数组
- 只遍历一次数组,时间复杂度 O (n)
- 保留了元素的相对顺序
类型二:左右指针
左右指针通常从数组的两端向中间移动
适用场景:有序数组两端对比(比如平方、两数之和),核心是 “从两端向中间遍历,利用有序性减少比较次数”。
经典例题:977. 有序数组的平方 - 力扣(LeetCode)
思路:数组原本是有序的,但平方后最大的值可能出现在两端(因为负数平方后可能变大)。所以我们可以用左右指针从两端向中间移动,比较平方后的大小,将较大的放入结果数组的末尾。
function sortedSquares(nums) {
const n = nums.length;
const result = new Array(n);
let left = 0; // 左指针,指向数组开头
let right = n - 1; // 右指针,指向数组末尾
let index = n - 1; // 结果数组的当前索引,从最后开始
// 当左右指针没有相遇时
while (left <= right) {
const leftSquare = nums[left] * nums[left];
const rightSquare = nums[right] * nums[right];
// 比较左右指针的平方值,将较大的放入结果数组
if (leftSquare > rightSquare) {
result[index] = leftSquare;
left++; // 左指针右移
} else {
result[index] = rightSquare;
right--; // 右指针左移
}
index--; // 结果数组索引左移
}
return result;
}
这个解法的时间复杂度是 O (n),空间复杂度是 O (n),比先平方后排序的 O (n log n) 解法更高效。
类型三:滑动窗口
滑动窗口是双指针的一种变体,通常用于解决子数组问题,如寻找满足某种条件的最长 / 最短子数组。
经典例题:209. 长度最小的子数组 - 力扣(LeetCode)
思路:使用两个指针表示窗口的左右边界,右指针不断扩大窗口,当窗口内元素和≥target 时,尝试移动左指针缩小窗口,以找到最小长度。
function minSubArrayLen(target, nums) {
let left = 0; // 窗口左边界
let sum = 0; // 窗口内元素之和
let minLength = Infinity; // 最小长度,初始化为无穷大
// 右指针不断扩大窗口
for (let right = 0; right < nums.length; right++) {
sum += nums[right];
// 当窗口内元素和 >= target时,尝试缩小窗口
while (sum >= target) {
// 更新最小长度
const currentLength = right - left + 1;
minLength = Math.min(minLength, currentLength);
// 移动左指针,缩小窗口
sum -= nums[left];
left++;
}
}
// 如果minLength仍为无穷大,说明没有符合条件的子数组
return minLength === Infinity ? 0 : minLength;
}
滑动窗口的关键在于:
- 窗口的定义:通常是 [left, right] 的闭区间
- 移动右指针扩大窗口,直到满足条件
- 移动左指针缩小窗口,以找到最优解
- 整个过程只遍历一次数组,时间复杂度 O (n)
🔄边界值处理:细节决定成败
在数组操作中,边界条件的处理往往是最容易出错的地方。一个小小的索引错误就可能导致整个程序崩溃。让我们来总结一些常见的边界处理技巧。
1. 数组为空的情况
在处理数组之前,首先要考虑数组为空的情况:
if (nums.length === 0) {
// 处理空数组的逻辑
return []; // 或0,或其他合适的返回值
}
2. 索引越界问题
JavaScript 在访问数组越界索引时不会报错,而是返回 undefined,这可能导致难以发现的 bug。因此,在访问数组元素时一定要确保索引在有效范围内:
// 错误示例
for (let i = 0; i <= nums.length; i++) {
// 当i = nums.length时,nums[i]是undefined
console.log(nums[i]);
}
// 正确示例
for (let i = 0; i < nums.length; i++) {
console.log(nums[i]);
}
3. 循环条件的设置
循环条件的设置直接关系到边界处理的正确性。以二分查找为例,我们已经看到不同的区间定义会导致不同的循环条件。
再来看一个例子:59. 螺旋矩阵 II - 力扣(LeetCode)
这道题的关键在于处理每一层的边界,确保不会重复填充或遗漏元素。
function generateMatrix(n) {
// 初始化n x n的矩阵
const matrix = new Array(n).fill(0).map(() => new Array(n).fill(0));
let top = 0; // 上边界
let bottom = n - 1; // 下边界
let left = 0; // 左边界
let right = n - 1; // 右边界
let num = 1; // 要填充的数字
const target = n * n; // 目标数字
// 当填充的数字小于等于目标数字时,继续循环
while (num <= target) {
// 从左到右填充上边界
for (let i = left; i <= right; i++) {
matrix[top][i] = num++;
}
top++; // 上边界下移
// 从上到下填充右边界
for (let i = top; i <= bottom; i++) {
matrix[i][right] = num++;
}
right--; // 右边界左移
// 如果上边界超过下边界,说明已经填充完毕
if (top > bottom) break;
// 从右到左填充下边界
for (let i = right; i >= left; i--) {
matrix[bottom][i] = num++;
}
bottom--; // 下边界上移
// 如果左边界超过右边界,说明已经填充完毕
if (left > right) break;
// 从下到上填充左边界
for (let i = bottom; i >= top; i--) {
matrix[i][left] = num++;
}
left++; // 左边界右移
}
return matrix;
}
在这个例子中,我们通过不断调整上下左右四个边界来控制填充的范围,每填充完一行或一列就移动相应的边界,直到所有元素都被填充。这种边界处理方式清晰明了,不容易出错。
4. 处理重复元素
当数组中存在重复元素时,边界处理会更加复杂。例如在二分查找中,如果要找到目标元素的第一个出现位置或最后一个出现位置,就需要特殊处理:
// 找到目标元素的第一个出现位置
function findFirstOccurrence(nums, target) {
let left = 0;
let right = nums.length - 1;
let result = -1;
while (left <= right) {
const mid = left + Math.floor((right - left) / 2);
if (nums[mid] === target) {
result = mid; // 记录当前位置
right = mid - 1; // 继续向左寻找,看看是否有更早的出现
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return result;
}
🎯循环不变量:算法正确性的保障
循环不变量是指在循环的开始、执行过程中以及结束时都保持为真的条件。它是确保算法正确性的关键,尤其在处理数组边界时尤为重要。
循环不变量的作用
- 帮助理解算法:明确的循环不变量可以让我们更容易理解算法的执行过程
- 确保算法正确性:只要循环不变量在循环开始时为真,并且每次循环都能保持不变量,那么当循环结束时,我们就可以根据不变量推导出正确的结果
- 指导代码实现:循环不变量可以指导我们设置正确的循环条件和指针移动方式
常见算法中的循环不变量
-
二分查找:
- 不变量:目标值如果存在,一定在 [left, right] 区间内
- 循环开始:left=0, right=n-1,整个数组是搜索区间
- 循环中:每次缩小一半区间,仍保持不变量
- 循环结束:left>right,搜索区间为空,确定目标不存在
-
快速排序:
- 不变量:在分区过程中,小于基准值的元素都在基准值左侧,大于基准值的元素都在基准值右侧
- 每次递归都保持这个不变量,最终整个数组有序
-
滑动窗口:
- 不变量:窗口 [left, right] 内的元素满足某种条件(如和≥target)
- 通过移动左右指针来维护这个不变量
如何设计循环不变量
设计循环不变量的步骤:
- 明确问题目标:我们想要通过循环达到什么目的?
- 定义状态:用哪些变量来描述当前的状态?
- 设计不变量:找到一个在循环过程中始终保持为真的条件
- 验证不变量:确保不变量在循环开始时为真,并且每次循环都能保持不变
- 根据不变量设计循环条件:确保循环能够在适当的时候结束,并且结束时可以根据不变量得到正确结果
🚀进阶技巧:数组算法的优化之道
除了上述基础技巧外,还有一些进阶技巧可以帮助你进一步优化数组算法:
1. 空间换时间
在某些情况下,我们可以通过使用额外的空间来换取时间复杂度的降低。例如:
- 使用哈希表存储数组元素和索引,将两数之和的时间复杂度从 O (n²) 降低到 O (n)
- 使用前缀和数组,将子数组和的计算时间从 O (n) 降低到 O (1)
2. 原地算法
原地算法(In-place algorithm)不需要额外的存储空间,或者只需要常数级别的额外空间。这在处理大型数组时尤为重要,可以节省内存。
例如,快速排序就是一种原地排序算法,它只需要 O (log n) 的递归栈空间。
🌟总结:数组算法的核心思维
通过对上述技巧和例题的分析,我们可以总结出数组算法的核心思维:
- 明确边界:始终清楚你的操作范围是什么,如何定义和维护边界
- 保持不变量:设计并维护循环不变量,这是算法正确性的保障
- 指针妙用:灵活运用各种指针技巧(快慢指针、左右指针、滑动窗口)可以大幅提高效率
- 空间与时间的权衡:根据问题 constraints,选择合适的时间 - 空间复杂度平衡点
- 多练习,多总结:数组算法千变万化,但核心思想是相通的,通过大量练习培养算法直觉
数组作为最基础的数据结构,其算法技巧是整个算法世界的基石。掌握了数组操作的精髓,你会发现许多复杂的数据结构和算法问题都能迎刃而解。
3. 预处理
对数组进行预处理可以简化后续操作。例如:
- 排序:许多问题在有序数组上更容易解决
- 前缀和:可以快速计算任意子数组的和
- 差分数组:可以快速进行区间更新和单点查询
4. 分治思想
将大问题分解为小问题,分别解决后再合并结果。例如:
- 归并排序:将数组分成两半,分别排序后再合并
- 寻找数组中的第 k 大元素:通过快速选择算法,将问题规模不断缩小