数组算法不头疼!从二分双指针到边界处理,手把手拿捏 “连续内存” 的小脾气📚

86 阅读14分钟

数组作为 JavaScript 中最基础的数据结构,就像编程世界里的乐高积木,看似简单却能搭建出无限可能。但你是否也曾在面对数组操作时陷入困境:二分查找时左右指针不知道该怎么动?双指针解法总是理不清思路?边界条件处理得一塌糊涂?别担心!今天我们就来系统梳理数组算法的核心技巧,结合 LeetCode 经典题目,让你从此玩转数组操作,代码优雅又高效!

🌱数组基础:看似简单,暗藏玄机

在 JavaScript 中,数组是一种特殊的对象,用于存储有序的元素集合。它具有以下特点:

  • 随机访问:可以通过索引直接访问元素,时间复杂度为 O (1)
  • 动态长度:与许多编程语言不同,JS 数组长度可以动态变化
  • 异质元素:可以同时存储不同类型的数据(虽然实际开发中不推荐)
  • 连续内存:在底层,数组元素在内存中连续存储,这也是其高效访问的基础

数组题的常见需求无非四类:查找目标、原地修改、处理子数组、转化数组,接下来的技巧全是针对这些需求的 “最优解”,咱们逐个拆解。

🔍二分查找:精准定位的艺术

二分查找是数组查找类问题的 "瑞士军刀",其 O (log n) 的时间复杂度使其成为处理有序数组的利器。但想要用好这把利器,可不是件容易的事。

核心思想:逐步缩小搜索范围

二分查找的基本思路很简单:对于一个有序数组,每次通过与中间元素比较,将搜索范围缩小一半,直到找到目标元素或确定目标不存在。

但真正的挑战在于边界条件的处理循环不变量的维护

经典例题:704. 二分查找 - 力扣(LeetCode)

image.png

解法一:左闭右闭区间 [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;
}

二分查找的关键:循环不变量

两种解法的核心区别在于对搜索区间的定义,而保持循环不变量是正确实现二分查找的关键。循环不变量指的是在循环的每一次迭代中,都保持某种条件不变。

在二分查找中,这个不变量就是我们对搜索区间的定义。无论循环进行到哪一步,我们都要确保:

  1. 目标值如果存在,一定在当前的搜索区间内
  2. 搜索区间会不断缩小,最终会变为空(保证循环会结束)

只要严格遵守这个不变量,就能避免大多数边界错误。

👆双指针:高效遍历的利器

双指针是处理数组问题的另一种核心技巧,尤其适用于需要遍历数组并进行元素比较或交换的场景。它通常能将暴力解法的 O (n²) 时间复杂度优化到 O (n)。

类型一:快慢指针

快慢指针通常用于处理数组中的元素移除、寻找环、获取倒数第 k 个元素等问题。两个指针以不同的速度遍历数组。

经典例题:27. 移除元素 - 力扣(LeetCode)

image.png

思路:使用慢指针记录有效元素的位置,快指针遍历整个数组。当快指针遇到不等于 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; // 慢指针的值就是新数组的长度
}

这个解法的巧妙之处在于:

  1. 不需要额外空间,原地修改数组
  2. 只遍历一次数组,时间复杂度 O (n)
  3. 保留了元素的相对顺序

类型二:左右指针

左右指针通常从数组的两端向中间移动

适用场景:有序数组两端对比(比如平方、两数之和),核心是 “从两端向中间遍历,利用有序性减少比较次数”。

经典例题:977. 有序数组的平方 - 力扣(LeetCode)

image.png

思路:数组原本是有序的,但平方后最大的值可能出现在两端(因为负数平方后可能变大)。所以我们可以用左右指针从两端向中间移动,比较平方后的大小,将较大的放入结果数组的末尾。

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)

image.png

思路:使用两个指针表示窗口的左右边界,右指针不断扩大窗口,当窗口内元素和≥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;
}

滑动窗口的关键在于:

  1. 窗口的定义:通常是 [left, right] 的闭区间
  2. 移动右指针扩大窗口,直到满足条件
  3. 移动左指针缩小窗口,以找到最优解
  4. 整个过程只遍历一次数组,时间复杂度 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)

image.png

这道题的关键在于处理每一层的边界,确保不会重复填充或遗漏元素。

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;
}

🎯循环不变量:算法正确性的保障

循环不变量是指在循环的开始、执行过程中以及结束时都保持为真的条件。它是确保算法正确性的关键,尤其在处理数组边界时尤为重要。

循环不变量的作用

  1. 帮助理解算法:明确的循环不变量可以让我们更容易理解算法的执行过程
  2. 确保算法正确性:只要循环不变量在循环开始时为真,并且每次循环都能保持不变量,那么当循环结束时,我们就可以根据不变量推导出正确的结果
  3. 指导代码实现:循环不变量可以指导我们设置正确的循环条件和指针移动方式

常见算法中的循环不变量

  1. 二分查找

    • 不变量:目标值如果存在,一定在 [left, right] 区间内
    • 循环开始:left=0, right=n-1,整个数组是搜索区间
    • 循环中:每次缩小一半区间,仍保持不变量
    • 循环结束:left>right,搜索区间为空,确定目标不存在
  2. 快速排序

    • 不变量:在分区过程中,小于基准值的元素都在基准值左侧,大于基准值的元素都在基准值右侧
    • 每次递归都保持这个不变量,最终整个数组有序
  3. 滑动窗口

    • 不变量:窗口 [left, right] 内的元素满足某种条件(如和≥target)
    • 通过移动左右指针来维护这个不变量

如何设计循环不变量

设计循环不变量的步骤:

  1. 明确问题目标:我们想要通过循环达到什么目的?
  2. 定义状态:用哪些变量来描述当前的状态?
  3. 设计不变量:找到一个在循环过程中始终保持为真的条件
  4. 验证不变量:确保不变量在循环开始时为真,并且每次循环都能保持不变
  5. 根据不变量设计循环条件:确保循环能够在适当的时候结束,并且结束时可以根据不变量得到正确结果

🚀进阶技巧:数组算法的优化之道

除了上述基础技巧外,还有一些进阶技巧可以帮助你进一步优化数组算法:

1. 空间换时间

在某些情况下,我们可以通过使用额外的空间来换取时间复杂度的降低。例如:

  • 使用哈希表存储数组元素和索引,将两数之和的时间复杂度从 O (n²) 降低到 O (n)
  • 使用前缀和数组,将子数组和的计算时间从 O (n) 降低到 O (1)

2. 原地算法

原地算法(In-place algorithm)不需要额外的存储空间,或者只需要常数级别的额外空间。这在处理大型数组时尤为重要,可以节省内存。

例如,快速排序就是一种原地排序算法,它只需要 O (log n) 的递归栈空间。

🌟总结:数组算法的核心思维

通过对上述技巧和例题的分析,我们可以总结出数组算法的核心思维:

  1. 明确边界:始终清楚你的操作范围是什么,如何定义和维护边界
  2. 保持不变量:设计并维护循环不变量,这是算法正确性的保障
  3. 指针妙用:灵活运用各种指针技巧(快慢指针、左右指针、滑动窗口)可以大幅提高效率
  4. 空间与时间的权衡:根据问题 constraints,选择合适的时间 - 空间复杂度平衡点
  5. 多练习,多总结:数组算法千变万化,但核心思想是相通的,通过大量练习培养算法直觉

数组作为最基础的数据结构,其算法技巧是整个算法世界的基石。掌握了数组操作的精髓,你会发现许多复杂的数据结构和算法问题都能迎刃而解。

3. 预处理

对数组进行预处理可以简化后续操作。例如:

  • 排序:许多问题在有序数组上更容易解决
  • 前缀和:可以快速计算任意子数组的和
  • 差分数组:可以快速进行区间更新和单点查询

4. 分治思想

将大问题分解为小问题,分别解决后再合并结果。例如:

  • 归并排序:将数组分成两半,分别排序后再合并
  • 寻找数组中的第 k 大元素:通过快速选择算法,将问题规模不断缩小