1. 算法刷题进阶指南:LeetCode 51-60题深度解析
作为算法工程师的日常修炼,LeetCode刷题是提升编程能力的必经之路。今天我将分享第51到60题的详细解题思路和优化技巧,这些题目涵盖了回溯、动态规划、贪心算法等多种经典算法范式。不同于简单的题解罗列,我会从问题本质出发,带你理解每种解法的设计哲学和实现细节。
2. 回溯算法实战:N皇后问题
2.1 问题本质与建模思路
N皇后问题是回溯算法的经典案例,要求在N×N的棋盘上放置N个皇后,使其互不攻击。皇后的攻击范围包括同行、同列和两条对角线,这意味着我们需要找到满足以下约束的布局:
- 每行恰好一个皇后
- 每列最多一个皇后
- 每条对角线最多一个皇后
这个问题可以抽象为约束满足问题(CSP),回溯算法通过系统地探索解空间来寻找所有可行解。时间复杂度为O(N!),因为第一行有N种选择,第二行最多N-1种,依此类推。
2.2 位运算优化解法
cpp复制class Solution {
private:
bool checkCol[10], checkDig1[20], checkDig2[20];
vector<vector<string>> ret;
vector<string> path;
int num;
void dfs(int row) {
if (row == num) {
ret.push_back(path);
return;
}
for (int col = 0; col < num; ++col) {
if (!checkCol[col] && !checkDig1[row - col + num] &&
!checkDig2[row + col]) {
path[row][col] = 'Q';
checkCol[col] = checkDig1[row - col + num] = checkDig2[row + col] = true;
dfs(row + 1);
path[row][col] = '.';
checkCol[col] = checkDig1[row - col + num] = checkDig2[row + col] = false;
}
}
}
public:
vector<vector<string>> solveNQueens(int n) {
num = n;
path.resize(n, string(n, '.'));
dfs(0);
return ret;
}
};
关键优化点解析:
- 使用三个布尔数组分别记录列和两条对角线的占用状态
- 对角线索引计算技巧:
- 主对角线:row - col + n(加n避免负数)
- 副对角线:row + col
- 回溯时及时恢复状态,保证不影响其他分支
2.3 坐标验证解法对比
cpp复制class Solution {
private:
bool isValid(vector<pair<int, int>>& solution, int row, int col) {
for (auto& pos : solution) {
if (pos.second == col ||
(pos.first + pos.second == row + col) ||
(pos.first - pos.second == row - col)) {
return false;
}
}
return true;
}
void dfs(vector<vector<pair<int, int>>>& solutions,
vector<pair<int, int>>& solution, int row, int n) {
if (row == n) {
solutions.push_back(solution);
return;
}
for (int col = 0; col < n; ++col) {
if (isValid(solution, row, col)) {
solution.emplace_back(row, col);
dfs(solutions, solution, row + 1, n);
solution.pop_back();
}
}
}
public:
vector<vector<string>> solveNQueens(int n) {
vector<vector<pair<int, int>>> solutions;
vector<pair<int, int>> solution;
dfs(solutions, solution, 0, n);
vector<vector<string>> ret;
for (auto& sol : solutions) {
vector<string> board(n, string(n, '.'));
for (auto& pos : sol) {
board[pos.first][pos.second] = 'Q';
}
ret.push_back(board);
}
return ret;
}
};
两种解法对比分析:
| 特性 | 位运算解法 | 坐标验证解法 |
|---|---|---|
| 时间复杂度 | O(N!) | O(N!) |
| 空间复杂度 | O(N) | O(N^2) |
| 检查冲突速度 | O(1) | O(N) |
| 代码复杂度 | 中等 | 简单 |
| 适用场景 | N较大时性能更好 | 更直观易理解 |
实际工程中选择:当N>10时优先考虑位运算优化,小规模问题可用坐标验证法提高可读性
3. 动态规划经典:最大子数组和
3.1 问题重述与暴力解法
给定整数数组nums,找出具有最大和的连续子数组。例如:
输入:[-2,1,-3,4,-1,2,1,-5,4]
输出:6(子数组[4,-1,2,1]的和)
暴力解法时间复杂度O(N^2),通过枚举所有子数组的起点和终点计算和。显然这在N较大时不可行。
3.2 动态规划解法
cpp复制class Solution {
public:
int maxSubArray(vector<int>& nums) {
int n = nums.size();
vector<int> dp(n);
dp[0] = nums[0];
int maxSum = dp[0];
for (int i = 1; i < n; ++i) {
dp[i] = max(nums[i], dp[i-1] + nums[i]);
maxSum = max(maxSum, dp[i]);
}
return maxSum;
}
};
DP状态转移方程解析:
dp[i]表示以nums[i]结尾的最大子数组和,有两种选择:
- 单独作为新子数组:nums[i]
- 接在前一个子数组后面:dp[i-1] + nums[i]
取两者较大值作为dp[i]的值。最终结果是所有dp[i]中的最大值。
3.3 空间优化版本
cpp复制class Solution {
public:
int maxSubArray(vector<int>& nums) {
int maxSum = INT_MIN, curSum = 0;
for (int num : nums) {
curSum = max(num, curSum + num);
maxSum = max(maxSum, curSum);
}
return maxSum;
}
};
优化要点:
- 发现dp[i]只依赖dp[i-1],因此只需保存前一个状态
- 空间复杂度从O(N)降到O(1)
- 时间复杂度保持O(N)
4. 矩阵遍历技巧:螺旋矩阵
4.1 问题分析与解法设计
螺旋矩阵要求按照顺时针螺旋顺序返回矩阵所有元素。关键在于确定遍历的边界条件和方向切换逻辑。
cpp复制class Solution {
public:
vector<int> spiralOrder(vector<vector<int>>& matrix) {
if (matrix.empty()) return {};
int m = matrix.size(), n = matrix[0].size();
vector<int> res;
int upper = 0, down = m - 1;
int left = 0, right = n - 1;
while (true) {
// 向右
for (int j = left; j <= right; ++j)
res.push_back(matrix[upper][j]);
if (++upper > down) break;
// 向下
for (int i = upper; i <= down; ++i)
res.push_back(matrix[i][right]);
if (--right < left) break;
// 向左
for (int j = right; j >= left; --j)
res.push_back(matrix[down][j]);
if (--down < upper) break;
// 向上
for (int i = down; i >= upper; --i)
res.push_back(matrix[i][left]);
if (++left > right) break;
}
return res;
}
};
边界收缩策略:
- 初始化四个边界:上(upper)、下(down)、左(left)、右(right)
- 每完成一个方向的遍历,就收缩对应边界
- 当边界交叉时终止循环
4.2 复杂度分析
- 时间复杂度:O(M×N),每个元素恰好访问一次
- 空间复杂度:O(1)额外空间(不考虑输出结果)
5. 贪心算法应用:跳跃游戏
5.1 问题理解与解法选择
跳跃游戏要求判断能否从数组起点跳到终点,其中nums[i]表示在位置i最多可以跳跃的步数。贪心算法可以在O(N)时间内解决。
cpp复制class Solution {
public:
bool canJump(vector<int>& nums) {
int maxReach = 0;
int n = nums.size();
for (int i = 0; i < n; ++i) {
if (i > maxReach) return false;
maxReach = max(maxReach, i + nums[i]);
if (maxReach >= n - 1) return true;
}
return true;
}
};
贪心策略解析:
- 维护当前能到达的最远位置maxReach
- 遍历数组,如果当前位置i超过maxReach,说明无法到达
- 否则更新maxReach为max(maxReach, i + nums[i])
- 一旦maxReach超过终点,立即返回成功
5.2 算法正确性证明
贪心选择性质:每次选择能跳得最远的位置不会错过最优解。因为更远的跳跃意味着更多的选择可能性。
最优子结构:问题的最优解包含子问题的最优解。如果能到达终点,那么必然能到达中间的某个关键点。
6. 区间处理技巧:合并与插入区间
6.1 合并区间算法
cpp复制class Solution {
public:
vector<vector<int>> merge(vector<vector<int>>& intervals) {
if (intervals.empty()) return {};
sort(intervals.begin(), intervals.end());
vector<vector<int>> merged;
merged.push_back(intervals[0]);
for (int i = 1; i < intervals.size(); ++i) {
auto& last = merged.back();
if (intervals[i][0] <= last[1]) {
last[1] = max(last[1], intervals[i][1]);
} else {
merged.push_back(intervals[i]);
}
}
return merged;
}
};
关键步骤:
- 按区间起点排序
- 初始化结果集为第一个区间
- 遍历后续区间,与结果集最后一个区间比较:
- 有重叠则合并右端点
- 无重叠则直接加入
6.2 插入区间算法
cpp复制class Solution {
public:
vector<vector<int>> insert(vector<vector<int>>& intervals, vector<int>& newInterval) {
vector<vector<int>> res;
int i = 0, n = intervals.size();
// 添加所有在新区间左侧且无重叠的区间
while (i < n && intervals[i][1] < newInterval[0]) {
res.push_back(intervals[i++]);
}
// 合并所有与新区间重叠的区间
while (i < n && intervals[i][0] <= newInterval[1]) {
newInterval[0] = min(newInterval[0], intervals[i][0]);
newInterval[1] = max(newInterval[1], intervals[i][1]);
i++;
}
res.push_back(newInterval);
// 添加剩余区间
while (i < n) {
res.push_back(intervals[i++]);
}
return res;
}
};
三阶段处理策略:
- 添加所有完全在新区间左侧的区间
- 合并所有与新区间重叠的区间
- 添加剩余区间
7. 字符串处理:最后一个单词长度
7.1 解法实现与边界处理
cpp复制class Solution {
public:
int lengthOfLastWord(string s) {
int i = s.length() - 1;
// 跳过末尾空格
while (i >= 0 && s[i] == ' ') i--;
int len = 0;
while (i >= 0 && s[i] != ' ') {
len++;
i--;
}
return len;
}
};
注意事项:
- 需要先处理字符串末尾的空格
- 从后向前遍历效率更高
- 考虑全空格字符串的特殊情况
8. 螺旋矩阵变体:生成螺旋矩阵
8.1 解法实现与模式识别
cpp复制class Solution {
public:
vector<vector<int>> generateMatrix(int n) {
vector<vector<int>> matrix(n, vector<int>(n));
int num = 1;
int left = 0, right = n - 1;
int top = 0, bottom = n - 1;
while (left <= right && top <= bottom) {
// 向右
for (int j = left; j <= right; ++j) {
matrix[top][j] = num++;
}
top++;
// 向下
for (int i = top; i <= bottom; ++i) {
matrix[i][right] = num++;
}
right--;
// 向左
for (int j = right; j >= left; --j) {
matrix[bottom][j] = num++;
}
bottom--;
// 向上
for (int i = bottom; i >= top; --i) {
matrix[i][left] = num++;
}
left++;
}
return matrix;
}
};
模式识别:
- 与螺旋遍历类似,都是按层处理
- 每层分为四个方向的填充
- 使用num变量递增填充值
9. 算法选择与优化经验
9.1 回溯算法优化技巧
- 剪枝策略:在N皇后问题中,及时排除不符合条件的分支
- 状态压缩:使用位运算或布尔数组代替集合检查
- 对称性利用:某些问题可以通过对称性减少计算量
9.2 动态规划问题分析框架
- 定义子问题
- 确定状态转移方程
- 初始化边界条件
- 确定计算顺序
- 考虑空间优化可能
9.3 贪心算法适用场景
- 问题具有最优子结构
- 贪心选择性质成立
- 不需要回溯或考虑所有可能性
- 典型应用:最短路径、任务调度等
10. 常见错误与调试技巧
10.1 回溯算法常见陷阱
- 忘记恢复状态:回溯后必须撤销当前选择
- 剪枝条件错误:可能导致漏解
- 终止条件不完整:造成无限递归
10.2 动态规划易错点
- 状态定义不准确
- 边界条件处理不当
- 空间优化时覆盖问题
10.3 矩阵遍历边界处理
- 奇数/偶数阶矩阵不同
- 单行/单列特殊情况
- 方向切换条件判断
在实际刷题过程中,我建议先理解问题本质,再选择合适算法,最后考虑优化。对于每道题,至少尝试两种不同解法,比较它们的优缺点。遇到困难时,可以先用小规模测试用例手动模拟算法执行过程,这往往能帮助发现逻辑漏洞。