引言
算法题是检验程序员基础数据结构和算法掌握程度的重要途径。本文将深入分析 LeetCode 的两道经典题目:1046. 最后一块石头的重量和752. 打开转盘锁。这两道题分别展示了优先队列和**广度优先搜索(BFS)**这两种核心算法思想在实际问题中的精妙应用。
第一题:1046. 最后一块石头的重量
题目解析
这是一个模拟类问题。每次需要选择重量最大的两块石头进行碰撞,直到只剩一块或没有石头为止。核心在于每次都要找到当前重量最大的两块石头。
JavaScript 解法分析
/**
* @param {number[]} stones - 石头重量数组
* @return {number} - 最后剩余石头的重量
*/
var lastStoneWeight = function(stones) {
// 循环直到只剩0或1块石头
while(stones.length > 1) {
// 每次排序,使最大元素在前面
stones.sort((a, b) => b - a);
// 取出最大的两块石头
let a = stones.shift(); // 最大
let b = stones.shift(); // 第二大
// 如果重量不同,产生新的石头
if (a !== b) {
stones.push(a - b);
}
// 如果重量相同,两块都被摧毁,不产生新石头
}
// 返回最后的石头重量,如果没石头了返回0
return stones[0] || 0;
};
算法复杂度:
- 时间复杂度:O(n² log n) - 每次循环都要排序
- 空间复杂度:O(1) - 原地操作
C++ 解法分析(优化版)
#include <vector>
#include <queue>
using namespace std;
class Solution {
public:
int lastStoneWeight(vector<int>& stones) {
// 使用优先队列(最大堆),自动维护最大元素在顶部
priority_queue<int> pq;
// 将所有石头放入优先队列
for(int x : stones) {
pq.push(x);
}
// 当还有至少两块石头时继续
while(pq.size() > 1) {
// 取出两块最大石头
int a = pq.top(); pq.pop(); // 最大
int b = pq.top(); pq.pop(); // 第二大
// 碰撞后的新石头重量
int result = abs(a - b);
// 将新石头放回队列
pq.push(result);
}
// 返回最后的石头重量
return pq.empty() ? 0 : pq.top();
}
};
算法复杂度:
- 时间复杂度:O(n log n) - 每次插入删除操作都是 O(log n)
- 空间复杂度:O(n) - 优先队列存储
核心思想: 优先队列自动维护元素顺序,避免了每次手动排序的开销。
第二题:752. 打开转盘锁
题目解析
这是一个最短路径问题。从初始状态 "0000" 出发,每次可以改变一个数字的一位(向上或向下),目标是到达 target。中间不能经过 deadends 中的状态。这是典型的无权图最短路径问题。
JavaScript 解法分析
/**
* 打开转盘锁
* @param {string[]} deadends - 死亡数字列表
* @param {string} target - 目标数字
* @return {number} - 最小旋转次数
*/
function openLock(deadends, target) {
// 特殊情况:目标就是起点
if (target === "0000") return 0;
// 特殊情况:起点就是死亡数字
if (deadends.includes("0000")) return -1;
// 将死亡数字转换为 Set 以提高查找效率
const deadSet = new Set(deadends);
// BFS 队列和访问标记集合(合并了死亡数字和已访问数字)
const queue = ["0000"];
const forbidden = new Set(deadends);
forbidden.add("0000"); // 起点加入禁止访问集合
let steps = 0;
// BFS 主循环
while (queue.length > 0) {
const size = queue.length; // 当前层的节点数量
steps++; // 步数增加
// 处理当前层的所有节点
for (let i = 0; i < size; i++) {
const current = queue.shift();
// 尝试每一位数字的两种转动方式
for (let j = 0; j < 4; j++) {
// 向上转动
const nextUp = turnUp(current, j);
if (nextUp === target) return steps; // 找到目标
// 如果未访问且不是死亡数字,则加入队列
if (!forbidden.has(nextUp)) {
forbidden.add(nextUp);
queue.push(nextUp);
}
// 向下转动
const nextDown = turnDown(current, j);
if (nextDown === target) return steps; // 找到目标
if (!forbidden.has(nextDown)) {
forbidden.add(nextDown);
queue.push(nextDown);
}
}
}
}
return -1; // 无法到达目标
}
/**
* 向上转动一位数字(0->1, 9->0)
* @param {string} str - 当前数字字符串
* @param {number} index - 要转动的位置
* @return {string} - 转动后的字符串
*/
function turnUp(str, index) {
const chars = str.split('');
const digit = parseInt(chars[index]);
chars[index] = (digit + 1) % 10;
return chars.join('');
}
/**
* 向下转动一位数字(0->9, 1->0)
* @param {string} str - 当前数字字符串
* @param {number} index - 要转动的位置
* @return {string} - 转动后的字符串
*/
function turnDown(str, index) {
const chars = str.split('');
const digit = parseInt(chars[index]);
chars[index] = (digit + 9) % 10; // 等同于 (digit - 1 + 10) % 10
return chars.join('');
}
C++ 解法分析
#include <vector>
#include <string>
#include <unordered_set>
#include <queue>
using namespace std;
class Solution {
public:
int openLock(vector<string>& deadends, string target) {
if(target == "0000") return 0;
// 创建死亡数字集合
unordered_set<string> deadset(deadends.begin(), deadends.end());
if(deadset.count("0000")) return -1;
// BFS
unordered_set<string> visited;
for(const string &dead : deadends) {
visited.insert(dead);
}
int steps = 0;
queue<string> bq;
bq.push("0000");
visited.insert("0000");
while(!bq.empty()) {
int number = bq.size(); // 当前层节点数
steps++; // 步数增加
while(number--) { // 处理当前层所有节点
string result = bq.front();
bq.pop();
// 尝试每一位数字的两种转动方式
for(int i = 0; i < 4; i++) {
string s = turnup(result, i); // 向上转动
if(s == target) return steps; // 找到目标
// 如果未访问,则加入队列
if(visited.find(s) == visited.end()) {
visited.insert(s);
bq.push(s);
}
string y = turndown(result, i); // 向下转动
if(y == target) return steps; // 找到目标
if(visited.find(y) == visited.end()) {
visited.insert(y);
bq.push(y);
}
}
}
}
return -1; // 无法到达
}
private:
// 向上转动
string turnup(const string& s, int index) {
string result = s;
if(result[index] == '9') {
result[index] = '0';
} else {
result[index]++;
}
return result;
}
// 向下转动
string turndown(const string& s, int index) {
string result = s;
if(result[index] == '0') {
result[index] = '9';
} else {
result[index]--;
}
return result;
}
};
算法复杂度:
- 时间复杂度:O(10⁴ × 8) - 最多 10000 个状态,每个状态最多 8 个邻居
- 空间复杂度:O(10⁴) - 存储访问状态和队列
算法思想对比
| 特征 | 1046题(优先队列) | 752题(BFS) |
|---|---|---|
| 核心数据结构 | 优先队列(堆) | 队列 + 哈希集合 |
| 应用场景 | 需要快速获取最大/最小元素 | 最短路径/层次遍历 |
| 时间复杂度优化 | 从 O(n² log n) 优化到 O(n log n) | 保证找到最短路径 |
| 空间复杂度 | 通常较低 | 需要存储访问状态 |
总结与启示
- 选择合适的数据结构:对于需要频繁获取最值的问题,优先队列是理想选择;对于最短路径问题,BFS 是标准解法。
- 算法优化的重要性:JavaScript 版本的排序方法时间复杂度较高,而 C++ 的优先队列版本显著提升了效率。
- BFS 的层次性质:通过逐层扩展,BFS 天然保证了第一次到达目标时路径最短。
- 实际应用价值:这类算法在游戏 AI、路径规划、状态搜索等领域有着广泛的应用。
通过对这两道题的深入分析,我们可以看到基础算法在解决实际问题中的强大力量,也体现了掌握核心数据结构和算法的重要性。