1. 为什么数组/循环的边界总是让人头疼?
每次看到算法题里那个该死的i <= n还是i < n,你是不是也和我一样想摔键盘?作为一个在LeetCode上刷了800+题的过来人,我敢说这是新手最容易栽跟头的地方之一。上周帮学弟debug时,他的二分查找死活跑不对,最后发现就是把while(left < right)写成了while(left <= right)——就这一个等号,浪费了我们两小时。
数组和循环的边界问题之所以难搞,本质上是因为计算机的索引从0开始,而人类习惯从1开始计数。这种认知差异导致我们在写for(int i=0; i<?; i++)时,大脑需要额外做一次"索引转换"。更可怕的是,不同场景下的边界规则还不一样:
- 二分查找的
mid计算有时要+1 - 动态规划的dp数组大小经常是
n+1 - 字符串处理时末尾可能要留一位给终止符
- 二维数组遍历时行列的边界可能不对称
血泪教训:我曾在面试时因为把
dp[n+1]写成dp[n]直接挂掉了一道动态规划题。面试官说:"边界条件都处理不好,怎么相信你能写出健壮的代码?"
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 必须死记的四种经典场景
2.1 动态规划的dp数组大小
做动态规划时,90%的情况需要定义dp[n+1]而不是dp[n]。这是因为:
- 我们需要
dp[0]表示初始状态或空集合的情况 - 当问题规模为n时,实际需要n步操作,对应
dp[1]到dp[n] - 以经典的爬楼梯问题为例:
python复制def climbStairs(n):
dp = [0] * (n + 1) # 这里必须是n+1
dp[0], dp[1] = 1, 1
for i in range(2, n + 1): # 注意这个n+1
dp[i] = dp[i-1] + dp[i-2]
return dp[n]
如果定义成dp[n],当n=1时会越界。记住这个规律:动态规划先无脑写n+1,除非有特殊说明。
2.2 二分查找的mid计算
二分查找的边界堪称算法界的"薛定谔的猫"——你永远不知道下次会不会死循环。关键记住:
- 当使用
while(left < right)时:mid = left + (right - left) // 2(标准写法)- 左边界更新要
left = mid + 1 - 右边界更新要
right = mid
- 当使用
while(left <= right)时:mid计算同上- 但左右边界都要
±1:left = mid + 1,right = mid - 1
java复制// 正确写法示例
int binarySearch(int[] nums, int target) {
int left = 0, right = nums.length - 1; // 注意-1
while(left <= right) {
int mid = left + (right - left) / 2;
if(nums[mid] == target) return mid;
else if(nums[mid] < target) left = mid + 1;
else right = mid - 1; // 这里必须-1
}
return -1;
}
实测技巧:先用
left < right的模板,如果发现漏判边界元素,再改成left <= right并调整边界更新逻辑。
2.3 循环处理数组时的终止条件
遍历数组时最安全的写法是:
python复制for i in range(len(arr)): # Python
for(int i=0; i<arr.length; i++) // Java
但遇到这些情况要小心:
- 需要访问
arr[i+1]时,循环应终止于len(arr)-1 - 滑动窗口问题时,右指针可能要到
len(arr)位置 - 处理环形数组时,可能需要取模运算
特殊案例——寻找峰值元素:
cpp复制int findPeak(vector<int>& nums) {
int left = 0, right = nums.size() - 1; // 这里-1
while(left < right) { // 不是<=
int mid = left + (right - left)/2;
if(nums[mid] < nums[mid+1]) left = mid + 1;
else right = mid; // 这里不-1
}
return left;
}
注意这个例子中right的更新没有-1,否则会错过某些峰值。
2.4 字符串处理的预留空间
处理字符串时经常需要额外的一位:
- C/C++中字符串以
\0结尾 - KMP算法中的next数组需要
len+1大小 - 某些DP问题中空字符串对应
dp[0]
典型例子——字符串分割问题:
python复制def wordBreak(s, wordDict):
n = len(s)
dp = [False] * (n + 1) # dp[0]表示空字符串
dp[0] = True
for i in range(1, n + 1): # 注意是1到n
for j in range(i):
if dp[j] and s[j:i] in wordDict:
dp[i] = True
break
return dp[n]
这里dp[n]对应整个字符串,而dp[0]是基础状态。
3. 避坑检查清单(建议收藏)
每次写完代码后,按这个清单检查边界条件:
-
数组声明大小是否足够?
- 动态规划:n → n+1
- 字符串处理:len → len+1
- 二维数组:行列是否都需要+1?
-
循环终止条件是否正确?
- 是否需要=?
- 访问i+1时是否越界?
- 二分查找的left/right更新是否匹配while条件?
-
特殊值处理:
- 空数组输入时会不会崩溃?
- 单个元素时逻辑是否成立?
- 最大值/最小值边界是否测试过?
-
索引计算:
- mid计算是否可能溢出?
- 负数索引是否有处理?
- 环形数组的取模是否正确?
4. 实战训练:修复这些经典错误
4.1 案例一:二分查找的死循环
错误代码:
java复制while(left < right) {
int mid = (left + right)/2;
if(nums[mid] < target) left = mid;
else right = mid;
}
问题分析:
- 当
left和right相邻时,mid永远等于left - 如果进入
nums[mid]<target分支,left被赋值为mid,区间没有缩小 - 导致无限循环
修复方案:
java复制while(left < right) {
int mid = left + (right - left)/2;
if(nums[mid] < target) left = mid + 1; // 关键+1
else right = mid;
}
4.2 案例二:动态规划的数组越界
错误代码:
python复制def coinChange(coins, amount):
dp = [float('inf')] * amount # 错误!
dp[0] = 0
for coin in coins:
for i in range(coin, amount):
dp[i] = min(dp[i], dp[i-coin]+1)
return dp[amount-1] if dp[amount-1] != float('inf') else -1
问题分析:
dp数组大小应该是amount+1,因为要考虑金额0到amount- 当
amount=0时直接越界 - 最后返回的是
dp[amount]而不是dp[amount-1]
正确版本:
python复制def coinChange(coins, amount):
dp = [float('inf')] * (amount + 1) # 修正
dp[0] = 0
for coin in coins:
for i in range(coin, amount + 1): # 这里也要+1
dp[i] = min(dp[i], dp[i-coin]+1)
return dp[amount] if dp[amount] != float('inf') else -1
4.3 案例三:字符串处理的off-by-one错误
错误代码:
cpp复制string longestPalindrome(string s) {
int n = s.length();
vector<vector<bool>> dp(n, vector<bool>(n));
for(int i=0; i<n; i++) dp[i][i] = true;
for(int len=2; len<=n; len++) {
for(int i=0; i<n-len; i++) { // 错误!
int j = i + len - 1;
if(s[i]==s[j] && (len==2 || dp[i+1][j-1])) {
dp[i][j] = true;
}
}
}
// ...
}
问题点:
i的终止条件应该是i<=n-len而不是i<n-len- 会漏掉最后一个可能的子串
修复后:
cpp复制for(int len=2; len<=n; len++) {
for(int i=0; i<=n-len; i++) { // 修正为<=
int j = i + len - 1;
// ...
}
}
5. 高级技巧:边界条件的防御性编程
5.1 哨兵值技巧
在数组头尾添加哨兵可以简化边界判断:
python复制# 寻找下一个更大元素问题
def nextGreaterElement(nums):
n = len(nums)
res = [-1] * n
stack = []
# 循环数组处理技巧:扩展数组
extended = nums + nums
for i in range(len(extended)):
while stack and extended[stack[-1]] < extended[i]:
idx = stack.pop()
if idx < n: # 只处理原数组部分
res[idx] = extended[i]
stack.append(i)
return res
5.2 双指针的同步移动
处理子数组问题时,保持左右指针的同步变化:
java复制// 滑动窗口最大值
public int[] maxSlidingWindow(int[] nums, int k) {
if(nums == null || k <= 0) return new int[0];
int n = nums.length;
int[] result = new int[n - k + 1]; // 注意数组大小
Deque<Integer> q = new ArrayDeque<>();
for(int i=0, ri=0; i<n; i++) {
// 移除超出窗口的元素
while(!q.isEmpty() && q.peek() < i - k + 1) {
q.poll();
}
// 维护单调队列
while(!q.isEmpty() && nums[q.peekLast()] < nums[i]) {
q.pollLast();
}
q.offer(i);
// 当i达到窗口大小时开始记录结果
if(i >= k - 1) {
result[ri++] = nums[q.peek()];
}
}
return result;
}
5.3 预处理边界条件
在算法开始前显式处理特殊情况:
python复制def mergeIntervals(intervals):
if not intervals:
return []
# 按起始点排序
intervals.sort(key=lambda x: x[0])
merged = []
for interval in intervals:
# 如果列表为空,或当前区间与上一个不重叠
if not merged or merged[-1][1] < interval[0]:
merged.append(interval)
else:
# 合并区间
merged[-1][1] = max(merged[-1][1], interval[1])
return merged
最后分享一个我自己的检查习惯:写完算法后,先用空输入、单元素、两个元素这三种情况快速验证边界。比如二分查找就测试:
- 空数组 []
- 单元素 [5] 找5和找3
- 双元素 [1,3] 找1、3、0、2、4
这套组合拳能抓住90%的边界错误。记住,算法能力的差距往往就体现在这些+1、-1的细节处理上。
