1. 数组算法实战:从二分查找到滑动窗口
最近在刷LeetCode时遇到了几道关于数组操作的经典题目,这些题目涵盖了二分查找、双指针、滑动窗口等常见算法技巧。作为程序员,掌握这些基础算法对提升编码能力至关重要。今天我就来分享这几道题的解题思路和实现细节,希望能帮助到正在准备面试或提升算法能力的你。
2. 判断完全平方数
2.1 问题分析与算法选择
给定一个正整数num,判断它是否是完全平方数。最直观的解法是从1到num逐个尝试,但这样的时间复杂度是O(n)。我们可以利用二分查找将时间复杂度优化到O(log n)。
二分查找的核心思想是:在有序区间[0, num]中,寻找一个整数mid,使得mid²等于num。如果找到则返回true,否则根据mid²与num的大小关系调整查找区间。
2.2 实现细节与注意事项
cpp复制class Solution {
public:
bool isPerfectSquare(int num) {
int l = 0;
int r = num;
while(l <= r){
int mid = l + (r - l)/2; // 防止溢出
if((long long)mid * mid == num){
return true;
}
else if((long long)mid * mid < num){
l = mid + 1;
}
else{
r = mid - 1;
}
}
return false;
}
};
关键点说明:
- 使用
l + (r - l)/2而非(l + r)/2计算mid,避免整数溢出 - 将mid*mid强制转换为long long类型,防止int类型相乘溢出
- 找到匹配后立即返回,避免死循环
- 循环条件是
l <= r,确保能检查到区间内的所有可能值
提示:在处理大数运算时,类型转换和溢出预防是常见陷阱,需要特别注意。
3. 移除数组中的指定元素
3.1 快慢指针法解析
题目要求原地移除数组中所有等于val的元素,并返回新数组的长度。由于需要原地修改,不能使用额外空间,这里采用快慢指针法。
快指针fast用于遍历数组,慢指针slow指向下一个非val元素应该存放的位置。当fast指向的元素不等于val时,将其复制到slow位置,然后两个指针都前进;否则仅fast前进。
3.2 代码实现与优化
cpp复制class Solution {
public:
int removeElement(vector<int>& nums, int val) {
int slow = 0;
for(int fast = 0; fast < nums.size(); fast++){
if(nums[fast] != val){
nums[slow++] = nums[fast];
}
}
return slow;
}
};
算法特点:
- 时间复杂度:O(n),只需遍历一次数组
- 空间复杂度:O(1),没有使用额外空间
- 保持元素相对顺序不变
实际应用中,如果val元素很少,可以采用交换法优化:将val元素与数组末尾元素交换,然后缩小数组范围。这种方法可以减少赋值操作次数。
4. 长度最小的子数组
4.1 滑动窗口原理
给定一个正整数数组和target值,寻找满足子数组和≥target的最小长度。暴力解法需要O(n²)时间复杂度,而滑动窗口可以优化到O(n)。
滑动窗口通过维护一个可变大小的窗口(子数组),根据当前和与target的关系调整窗口大小:
- 窗口和<target:扩大窗口(右指针右移)
- 窗口和≥target:记录长度并缩小窗口(左指针右移)
4.2 实现与边界处理
cpp复制class Solution {
public:
int minSubArrayLen(int target, vector<int>& nums) {
int left = 0;
int sum = 0;
int minLen = INT_MAX;
for(int right = 0; right < nums.size(); right++){
sum += nums[right];
while(sum >= target){
minLen = min(minLen, right - left + 1);
sum -= nums[left++];
}
}
return minLen == INT_MAX ? 0 : minLen;
}
};
注意事项:
- 初始minLen设为INT_MAX,便于后续比较
- 使用while而非if来持续缩小窗口,确保窗口和尽可能接近但不超过target
- 最后检查minLen是否被更新过,若无满足条件的子数组则返回0
5. 水果成篮问题
5.1 问题转化与解法
题目可以抽象为:在一个数组中,找到最长的连续子数组,其中包含不超过两种不同的值。这是典型的滑动窗口应用场景。
我们需要维护一个窗口,记录窗口内两种水果类型及其数量。当遇到第三种水果时,移动左指针直到窗口内只剩一种水果,然后加入新水果。
5.2 代码实现与优化
cpp复制class Solution {
public:
int totalFruit(vector<int>& fruits) {
unordered_map<int, int> basket;
int left = 0, maxLen = 0;
for(int right = 0; right < fruits.size(); right++){
basket[fruits[right]]++;
while(basket.size() > 2){
basket[fruits[left]]--;
if(basket[fruits[left]] == 0){
basket.erase(fruits[left]);
}
left++;
}
maxLen = max(maxLen, right - left + 1);
}
return maxLen;
}
};
优化点:
- 使用哈希表记录水果类型和数量,方便快速查询和更新
- 当哈希表大小超过2时,移动左指针并减少对应水果计数
- 当某种水果计数归零时,从哈希表中移除该键
- 每次循环更新最大窗口大小
6. 螺旋矩阵生成与遍历
6.1 生成螺旋矩阵
给定正整数n,生成一个n×n的螺旋矩阵。解决这类问题需要明确遍历的边界和方向,保持统一的处理规则。
cpp复制class Solution {
public:
vector<vector<int>> generateMatrix(int n) {
vector<vector<int>> matrix(n, vector<int>(n));
int num = 1;
int top = 0, bottom = n-1, left = 0, right = n-1;
while(top <= bottom && left <= right){
// 从左到右填充上边
for(int j = left; j <= right; j++){
matrix[top][j] = num++;
}
top++;
// 从上到下填充右边
for(int i = top; i <= bottom; i++){
matrix[i][right] = num++;
}
right--;
if(top <= bottom){
// 从右到左填充下边
for(int j = right; j >= left; j--){
matrix[bottom][j] = num++;
}
bottom--;
}
if(left <= right){
// 从下到上填充左边
for(int i = bottom; i >= top; i--){
matrix[i][left] = num++;
}
left++;
}
}
return matrix;
}
};
6.2 螺旋遍历矩阵
与生成相反,现在需要按螺旋顺序读取矩阵元素。处理思路类似,但需要注意矩阵可能不是方阵。
cpp复制class Solution {
public:
vector<int> spiralOrder(vector<vector<int>>& matrix) {
vector<int> result;
if(matrix.empty()) return result;
int m = matrix.size(), n = matrix[0].size();
int top = 0, bottom = m-1, left = 0, right = n-1;
while(top <= bottom && left <= right){
// 从左到右
for(int j = left; j <= right; j++){
result.push_back(matrix[top][j]);
}
top++;
// 从上到下
for(int i = top; i <= bottom; i++){
result.push_back(matrix[i][right]);
}
right--;
if(top <= bottom){
// 从右到左
for(int j = right; j >= left; j--){
result.push_back(matrix[bottom][j]);
}
bottom--;
}
if(left <= right){
// 从下到上
for(int i = bottom; i >= top; i--){
result.push_back(matrix[i][left]);
}
left++;
}
}
return result;
}
};
关键点:
- 处理矩形矩阵时,每次缩小边界后需要检查是否越界
- 使用四个变量(top/bottom/left/right)控制当前处理范围
- 按顺时针方向依次处理四条边
- 每次处理完一条边后立即调整对应边界
7. 算法技巧总结
7.1 双指针与滑动窗口
双指针技术是处理数组/链表问题的利器,常见模式包括:
- 快慢指针:解决原地修改、环检测等问题
- 左右指针:处理有序数组的两数和、反转等问题
- 滑动窗口:优化子数组/子串问题的暴力解法
滑动窗口的通用模板:
cpp复制int slidingWindow(vector<int>& nums, int target) {
int left = 0, right = 0;
int sum = 0, result = INT_MAX;
while(right < nums.size()){
sum += nums[right++];
while(满足条件){
result = min(result, right-left);
sum -= nums[left++];
}
}
return result;
}
7.2 边界条件处理
编写算法时,边界条件往往是出错的高发区:
- 数组为空或只有一个元素的情况
- 整数运算的溢出问题
- 循环终止条件的设定
- 指针移动后的越界检查
建议在编写代码前先考虑:
- 输入的最小/最大可能值
- 特殊输入(空数组、全相同元素等)
- 变量可能的取值范围
7.3 调试技巧
当算法出现问题时,可以采用以下调试方法:
- 打印关键变量的中间值
- 使用小规模测试用例手动模拟执行
- 检查循环不变量是否保持
- 对比暴力解法的结果
例如,在滑动窗口问题中,可以打印每次循环后的窗口范围和当前和,帮助理解算法行为。