1. 数组基础与核心操作解析
数组作为最基础的数据结构之一,在算法面试和实际开发中占据着不可替代的地位。我们先从内存布局的角度理解数组的本质——它是一块连续的内存空间,通过索引实现O(1)时间复杂度的随机访问。这种特性使得数组在需要频繁按位置访问元素的场景下表现优异。
关键认知:数组的"连续性"特性既是优势也是限制。它带来了高效的访问性能,但也导致插入/删除操作需要移动元素,时间复杂度为O(n)
在JavaScript中,数组的实现方式比较特殊。V8引擎对小数组(长度小于10万)使用连续内存存储,而对大数组或存在空洞的数组会转换为哈希表存储。这解释了为什么在某些情况下JS数组的访问性能会出现波动:
javascript复制// 创建优化存储的连续数组
const denseArr = new Array(100).fill(0)
// 可能被转换为哈希表存储的稀疏数组
const sparseArr = []
sparseArr[1000000] = 1
1.1 数组去重的高效实践
数组去重是常见的面试考点,不同方案的时间复杂度差异显著。这里比较几种典型实现:
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 双重循环 | O(n²) | O(1) | 小规模数据 |
| 排序+相邻比较 | O(nlogn) | O(1) | 允许修改原数组 |
| 哈希表(Set) | O(n) | O(n) | 最通用方案 |
| 位图法 | O(n) | O(1) | 数据范围已知且有限 |
ES6的Set方案是目前最优雅的实现:
javascript复制const unique = arr => [...new Set(arr)]
但对于特殊场景,位图法可能更高效。假设要处理0-1000范围内的整数去重:
javascript复制function uniqueByBitmap(arr) {
const bitmap = new Uint8Array(125) // 1000/8=125字节
return arr.filter(num => {
const bytePos = num >> 3
const bitPos = num & 0b111
if (bitmap[bytePos] & (1 << bitPos)) return false
bitmap[bytePos] |= (1 << bitPos)
return true
})
}
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 二分查找的深度剖析
二分查找看似简单,但实际写出无bug的代码需要深刻理解其边界条件。我们以最基本的搜索升序数组为例,分析三个关键变种:
2.1 标准二分查找实现
javascript复制function binarySearch(nums, target) {
let left = 0, right = nums.length - 1
while (left <= right) {
const mid = left + ((right - left) >> 1)
if (nums[mid] === target) return mid
if (nums[mid] < target) left = mid + 1
else right = mid - 1
}
return -1
}
这里有几个关键点:
- 循环条件
left <= right确保区间有效 mid计算使用位运算避免溢出- 边界更新必须
±1以避免死循环
2.2 查找左边界变种
当数组包含重复元素时,找到target第一次出现的位置:
javascript复制function leftBound(nums, target) {
let left = 0, right = nums.length - 1
while (left <= right) {
const mid = left + ((right - left) >> 1)
if (nums[mid] < target) {
left = mid + 1
} else {
right = mid - 1
}
}
return left < nums.length && nums[left] === target ? left : -1
}
2.3 查找右边界变种
类似地,找到target最后一次出现的位置:
javascript复制function rightBound(nums, target) {
let left = 0, right = nums.length - 1
while (left <= right) {
const mid = left + ((right - left) >> 1)
if (nums[mid] <= target) {
left = mid + 1
} else {
right = mid - 1
}
}
return right >= 0 && nums[right] === target ? right : -1
}
实战经验:在ACM竞赛中,二分查找常与"最大值最小化"问题结合。例如"将数组分成m段,求最大段和的最小值",这类问题需要结合贪心算法进行二分搜索。
3. 双指针技术的精妙运用
双指针是处理数组问题的利器,特别是在需要原地修改数组时。我们通过几个典型案例来掌握其精髓。
3.1 移除指定元素
LeetCode第27题要求原地移除所有等于val的元素,返回新长度:
javascript复制function removeElement(nums, val) {
let slow = 0
for (let fast = 0; fast < nums.length; fast++) {
if (nums[fast] !== val) {
nums[slow++] = nums[fast]
}
}
return slow
}
这个解法中:
- fast指针遍历所有元素
- slow指针标记有效位置
- 时间复杂度O(n),空间复杂度O(1)
3.2 有序数组的平方排序
LeetCode第977题要求将已排序数组的每个元素平方后,仍然保持排序:
javascript复制function sortedSquares(nums) {
const result = []
let left = 0, right = nums.length - 1
for (let i = nums.length - 1; i >= 0; i--) {
if (Math.abs(nums[left]) > Math.abs(nums[right])) {
result[i] = nums[left] * nums[left]
left++
} else {
result[i] = nums[right] * nums[right]
right--
}
}
return result
}
这个解法利用了原数组已排序的特性:
- 数组平方后的最大值只可能出现在两端
- 使用对向双指针比较两端绝对值大小
- 从后向前填充结果数组
3.3 滑动窗口进阶应用
双指针的另一种形态是滑动窗口,特别适合解决子数组/子串问题。例如寻找满足和≥target的最短子数组:
javascript复制function minSubArrayLen(target, nums) {
let left = 0, sum = 0
let minLen = Infinity
for (let right = 0; right < nums.length; right++) {
sum += nums[right]
while (sum >= target) {
minLen = Math.min(minLen, right - left + 1)
sum -= nums[left++]
}
}
return minLen === Infinity ? 0 : minLen
}
窗口滑动过程中:
- right指针扩展窗口
- left指针收缩窗口
- 实时更新最小长度
4. 多维数组的处理技巧
在实际工程中,二维数组的处理需要特别注意内存访问模式对性能的影响。
4.1 行优先与列优先访问
现代CPU的缓存机制使得顺序内存访问比随机访问快得多。对比以下两种遍历方式:
javascript复制// 行优先访问(推荐)
function rowMajor(arr) {
let sum = 0
for (let i = 0; i < arr.length; i++) {
for (let j = 0; j < arr[i].length; j++) {
sum += arr[i][j] // 连续内存访问
}
}
return sum
}
// 列优先访问(不推荐)
function colMajor(arr) {
let sum = 0
for (let j = 0; j < arr[0].length; j++) {
for (let i = 0; i < arr.length; i++) {
sum += arr[i][j] // 跳跃式内存访问
}
}
return sum
}
实测表明,在1000x1000的数组上,行优先访问可比列优先快5-10倍。
4.2 螺旋矩阵生成
LeetCode第59题要求生成n×n的螺旋矩阵:
javascript复制function generateMatrix(n) {
const matrix = Array(n).fill().map(() => Array(n).fill(0))
let left = 0, right = n - 1, top = 0, bottom = n - 1
let num = 1
while (num <= n * n) {
for (let i = left; i <= right; i++) matrix[top][i] = num++
top++
for (let i = top; i <= bottom; i++) matrix[i][right] = num++
right--
for (let i = right; i >= left; i--) matrix[bottom][i] = num++
bottom--
for (let i = bottom; i >= top; i--) matrix[i][left] = num++
left++
}
return matrix
}
这个解法通过维护四个边界指针,模拟顺时针填充过程。关键在于:
- 每完成一个方向的填充就收缩对应边界
- 循环继续条件
num <= n*n确保填充完整
5. 算法优化实战案例
结合树状数组解决动态区间求和问题。传统前缀和在数组频繁更新时效率低下,树状数组可以在O(logn)时间内完成单点更新和前缀查询:
javascript复制class FenwickTree {
constructor(size) {
this.tree = Array(size + 1).fill(0)
}
update(index, delta) {
while (index < this.tree.length) {
this.tree[index] += delta
index += index & -index
}
}
query(index) {
let sum = 0
while (index > 0) {
sum += this.tree[index]
index -= index & -index
}
return sum
}
}
// 使用示例
const arr = [1, 3, 5, 7, 9]
const ft = new FenwickTree(arr.length)
arr.forEach((val, idx) => ft.update(idx + 1, val))
console.log(ft.query(3)) // 输出1+3+5=9
ft.update(2, 2) // 将第二个元素增加2
console.log(ft.query(3)) // 输出1+5+5=11
树状数组的核心在于:
- 利用二进制低位特性快速跳转
index & -index获取最低位的1- 适用于频繁更新的动态前缀和场景
在处理二维数组区间和时,可以扩展为二维树状数组,将两个维度的操作嵌套实现。
