数组算法四题精讲
二分查找 · 双指针 · 滑动窗口 · 螺旋矩阵 LeetCode 704 · 27 · 977 · 209 · 59
01 二分查找(LeetCode 704)
给定一个升序排列的有序数组和目标值 target,返回目标值的下标,找不到返回 -1。
暴力解法从头扫到尾,时间复杂度 O(n)。二分查找利用数组有序的特性,每次把搜索范围缩小一半,效率远高于暴力。
核心思路
用左右两个指针圈定搜索范围,每次取中间值和 target 比较,根据大小决定往左半区还是右半区继续找。
ini
nums = [-1, 0, 3, 5, 9, 12] target = 9
left=0 right=5
↓ ↓
[-1, 0, 3, 5, 9, 12]
↑
mid=2 nums[2]=3 < 9 → 往右找
left=3 right=5
↓ ↓
[ 5, 9, 12]
↑
mid=4 nums[4]=9 == 9 → 找到!返回4
边界条件:left < right 还是 left <= right?
这是二分查找最容易搞混的地方,取决于你定义的区间是左闭右开 还是左闭右闭。
左闭右开 [left, right) 的规则:
right = nums.length(不是 length-1),因为右边是开区间取不到while(left < right),left == right 时区间为空,退出nums[mid] > target时,right = mid(不是 mid-1,因为右边取不到)
java
int left = 0;
int right = nums.length; // 左闭右开,right不包含
while (left < right) { // 相等时区间为空,退出
int mid = (right + left) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1; // mid已判断过,左边从mid+1开始
} else {
right = mid; // 右开区间,right=mid即排除mid
}
}
return -1;
时间复杂度:O(log n) 空间复杂度:O(1)
02 移除元素(LeetCode 27)
给一个数组和值 val,原地移除所有等于 val 的元素,返回剩余元素个数。不能开新数组,要在原数组上操作。
核心思路:快慢双指针
用两个指针 fast 和 slow,fast 负责遍历每个元素,slow 负责指向下一个有效位置。
- 遇到不等于 val 的元素就写入 slow 位置,slow 前进
- 遇到等于 val 的直接跳过,slow 不动
ini
nums = [3, 2, 2, 3] val = 3
fast=0 slow=0
3 == val → 跳过,slow不动,fast++
fast=1 slow=0
2 != val → nums[slow]=2,slow++,fast++
fast=2 slow=1
2 != val → nums[slow]=2,slow++,fast++
fast=3 slow=2
3 == val → 跳过
结果:nums = [2, 2, ...] 返回 slow=2
javascript
let slow = 0
for (let fast = 0; fast < nums.length; fast++) {
if (nums[fast] != val) { // fast找到有效元素
nums[slow] = nums[fast] // 写入slow位置
slow++ // slow往前走
}
// fast不管怎样都会++(for循环自动)
}
return slow // slow就是有效元素个数
关键点:
- fast 每次都走,slow 只有遇到有效元素才走
- slow 同时也是有效元素的计数器,最终返回 slow 即可
- 原地操作,没有开新数组,空间复杂度 O(1)
时间复杂度:O(n) 空间复杂度:O(1)
03 有序数组的平方(LeetCode 977)
给一个按非递减顺序排列的整数数组(包含负数),返回每个数字平方后按非递减顺序排列的新数组。
关键点:数组有负数,负数平方后可能很大。最大的平方一定在数组两端(最左或最右),因此用双指针从两端往中间收。
核心思路:对撞双指针 + 从后往前填
用 i 指向左端,j 指向右端,k 指向结果数组的末尾。每次比较两端的平方,把较大的放入 resultk,然后 k--,对应指针也收缩。
ini
nums = [-4, -1, 0, 3, 10]
i=0 j=4 k=4
(-4)²=16 (10)²=100 → 100更大,result[4]=100,j--,k--
i=0 j=3 k=3
(-4)²=16 (3)²=9 → 16更大,result[3]=16,i++,k--
i=1 j=3 k=2
(-1)²=1 (3)²=9 → 9更大,result[2]=9,j--,k--
i=1 j=2 k=1
(-1)²=1 (0)²=0 → 1更大,result[1]=1,i++,k--
i=2 j=2 k=0
(0)²=0 → result[0]=0
结果:[0, 1, 9, 16, 100] ✅
javascript
const result = []
let k = nums.length - 1 // 结果数组从末尾开始填
for (let i = 0, j = nums.length - 1; i <= j;) {
if (nums[i] * nums[i] < nums[j] * nums[j]) {
result[k] = nums[j] * nums[j] // 右边更大
j--
k--
} else {
result[k] = nums[i] * nums[i] // 左边更大或相等
i++
k--
}
}
return result
时间复杂度:O(n) 空间复杂度:O(n)
04 长度最小的子数组(LeetCode 209)
给定数组和目标值 target,找出总和 ≥ target 的最小连续子数组,返回其长度,不存在则返回 0。
核心思路:滑动窗口
用左右两个指针维护一个窗口:
- right 不断往右扩张,把新元素加进窗口
- 当窗口内的和 ≥ target 时,记录当前长度,然后 left 往右收缩,尝试找更小的满足条件的窗口
ini
nums = [2, 3, 1, 2, 4, 3] target = 7
right扩张:2→5→6→8 ≥ 7
窗口 [2,3,1,2] 长度4 记录minLen=4
left收缩:sum-=2=6 < 7,继续扩张
right扩张:6+4=10 ≥ 7
left收缩:sum-=3=7 ≥ 7,窗口[1,2,4] 长度3 更新minLen=3
left收缩:sum-=1=6 < 7,停止收缩
right扩张:6+3=9 ≥ 7
left收缩:sum-=2=7 ≥ 7,窗口[4,3] 长度2 更新minLen=2
left收缩:sum-=4=3 < 7,停止
最终 minLen = 2
javascript
let left = 0
let sum = 0
let minLen = 10000000 // 初始设一个极大值
for (let right = 0; right < nums.length; right++) {
sum += nums[right] // 扩张右边界
while (sum >= target) { // 满足条件就收缩左边界
minLen = Math.min(minLen, right - left + 1)
sum -= nums[left] // 移出左边元素
left++
}
}
return minLen === 10000000 ? 0 : minLen // 没找到返回0
关键点:
- right 用 for 循环控制,left 用 while 控制,两者不对称
- 用 while 而不是 if:满足条件要持续收缩,直到不满足为止
- 每次收缩都记录一次长度,保证不遗漏更小的窗口
时间复杂度:O(n) 空间复杂度:O(1)
05 螺旋矩阵 II(LeetCode 59)
给定 n,生成一个 n×n 的矩阵,按螺旋顺序填入 1 到 n²。
这道题没有特别的算法,考的是边界控制。关键是坚持"左闭右开"原则,每条边不走最后一个格,留给下一条边的起点,保持一致性。
核心思路
每一圈分四条边:上、右、下、左。用 startx、starty 记录当前圈的起点,end 控制每条边走多远,每走完一圈,起点内移、范围缩小、圈数减少。
ini
n=4 的螺旋顺序(每条边左闭右开):
→ → → ↓
1 2 3 4
↑ ↓
12 13 14 5
↑ ↓
11 16 15 6
↑ ← ← ↓
10 9 8 7
startx=starty=0,end=1,每圈后 start++,end++,test--
javascript
let startx = 0, starty = 0 // 每圈起点
let end = 1 // 每条边结束位置偏移
let count = 1 // 填入的数字
let test = Math.floor(n / 2) // 需要转几圈
while (test) {
// 上边:从左往右(列变,行固定为startx)
for (j = starty; j < n - end; j++) nums[startx][j] = count++
// 右边:从上往下(行变,j停在n-end)
for (i = startx; i < n - end; i++) nums[i][j] = count++
// 下边:从右往左(列变,i停在n-end)
for (; j > starty; j--) nums[i][j] = count++
// 左边:从下往上(行变,j停在starty)
for (; i > startx; i--) nums[i][j] = count++
startx++; starty++ // 下一圈起点内移
end++ // 每条边走的范围缩小
test-- // 圈数减一
}
// n为奇数时,中心格单独处理
if (n % 2 != 0) nums[Math.floor(n/2)][Math.floor(n/2)] = n * n
关键点:
- 坚持左闭右开:每条边不走最后一个格,留给下一条边
- 四条边共用 i 和 j,上一条边结束时 i/j 的位置,正好是下一条边的起点
- n 为奇数时中心格永远走不到,要单独填入 n²
- 转几圈 = Math.floor(n/2),n=4 转2圈,n=5 转2圈+中心
时间复杂度:O(n²) 空间复杂度:O(n²)
总结:解题方法对照
| 题目 | 方法 | 核心思路 |
|---|---|---|
| 二分查找 | 二分查找 | 有序数组每次砍掉一半范围 |
| 移除元素 | 快慢双指针 | fast找有效元素,slow负责写入 |
| 有序数组的平方 | 对撞双指针 | 两端往中间收,从后往前填 |
| 长度最小的子数组 | 滑动窗口 | right扩张,满足条件left收缩 |
| 螺旋矩阵 II | 模拟 | 坚持左闭右开,边界统一不出错 |