1. 问题解析:理解题目需求
LeetCode 1334题"阈值距离内邻居最少的城市"是一个典型的图论问题。题目要求我们找到一个城市,使得在给定距离阈值内,该城市能够到达的其他城市数量最少。如果有多个这样的城市,则返回编号最大的那个。
这个问题在实际生活中有很多应用场景。比如规划快递配送中心时,我们希望选择一个位置,使得在一定配送范围内覆盖的居民区最少(可能是为了平衡各配送中心的工作量);或者在社交网络分析中,找出那个在一定"社交距离"内朋友最少的人。
2. 算法选择:为什么是Dijkstra?
2.1 算法对比分析
对于这类"最短路径"问题,我们通常会考虑以下几种算法:
| 算法 | 适用场景 | 时间复杂度 | 本题适用性 |
|---|---|---|---|
| Floyd-Warshall | 所有节点对的最短路径 | O(n³) | 适合,但n≤100时效率尚可 |
| Dijkstra | 单源最短路径 | O(E + VlogV) | 需要运行n次,总体O(nE + nVlogV) |
| Bellman-Ford | 含负权边的最短路径 | O(VE) | 本题无负权边,不必要 |
2.2 选择Dijkstra的原因
本题选择Dijkstra算法的主要考虑:
- 图中边权均为正数(距离不可能为负)
- 需要计算每个城市到其他所有城市的最短距离
- 城市数量n的上限是100,使用优先队列优化的Dijkstra算法效率足够
注意:虽然Floyd-Warshall算法实现更简单,但在n=100时,Dijkstra的实际运行效率通常会更好,特别是当图比较稀疏时。
3. 代码实现详解
3.1 数据结构设计
cpp复制vector<vector<int>> tr; // 邻接表存储图结构
int thresh, id, mi = INT_MAX; // 阈值、结果ID、最小邻居数
unordered_set<int> te; // 用于DFS的临时集合(注释掉的方案)
unordered_map<int, int> ump; // 边权映射
这里使用邻接表tr存储图结构,相比邻接矩阵更节省空间。ump使用位运算将两个节点编号编码为一个整数作为键,存储边权值。
3.2 核心算法流程
cpp复制for(int i = n - 1; i >= 0; i--){
vector<int> dis(n, INT_MAX); // 存储到各点的最短距离
vector<bool> status(n, false); // 标记是否已确定最短距离
dis[i] = 0; // 起点到自身距离为0
// 使用最小堆优化Dijkstra
priority_queue<pair<int, int>, vector<pair<int, int>>,
decltype(greater<pair<int, int>>())> qe;
qe.push({0, i});
// Dijkstra主循环
while(!qe.empty()) {
auto [d, now] = qe.top(); qe.pop();
if(status[now]) continue;
status[now] = true;
for(int next : tr[now]) {
int key = (now << 10) + next;
if(!status[next] && d + ump[key] < dis[next]) {
dis[next] = d + ump[key];
qe.push({dis[next], next});
}
}
}
// 统计阈值范围内的城市数
int cnt = 0;
for(int j = 0; j < n; j++) {
if(dis[j] <= thresh) cnt++;
}
// 更新最优解(从大到小遍历,自动处理并列情况)
if(mi > cnt) {
mi = cnt;
id = i;
}
}
3.3 关键点解析
-
优先队列的使用:
priority_queue默认是最大堆,这里通过greater改为最小堆,确保每次取出当前距离最小的节点。 -
节点编号处理:从n-1到0逆序遍历,这样当遇到相同cnt值时,较大的i会自动覆盖较小的i,满足题目要求。
-
状态标记:
status数组避免重复处理同一节点,这是Dijkstra算法的关键。 -
边权存储:使用位运算
(now << 10) + next将两个节点编号编码为一个整数作为键,确保快速查找边权。
4. 复杂度分析与优化
4.1 时间复杂度
- 每个节点执行一次Dijkstra:O(n)
- 每次Dijkstra:O(E + VlogV)(使用优先队列优化)
- 总体:O(n(E + VlogV)) ≈ O(n³)在最坏情况下
对于n≤100,这个复杂度是可接受的。
4.2 空间复杂度
- 邻接表存储:O(E)
- 距离数组:O(n)
- 优先队列:O(n)
- 总体:O(E + n)
4.3 可能的优化方向
-
提前终止:当发现某个节点的邻居数已经等于当前最小值时,可以提前终止后续计算。
-
并行计算:各节点的Dijkstra计算相互独立,可以并行处理。
-
A*启发式搜索:如果有额外信息,可以考虑使用A*算法,但在本题中可能帮助不大。
5. 常见问题与调试技巧
5.1 典型错误
-
未初始化距离数组:忘记将起点距离设为0,或初始值不够大。
-
优先队列使用错误:错误地使用最大堆而非最小堆。
-
边权存储错误:双向边只存储了一个方向。
-
节点编号处理不当:没有从大到小遍历,导致无法正确处理并列情况。
5.2 调试建议
-
小规模测试:先用n=2或3的小例子验证基本逻辑。
-
打印中间结果:输出各节点的最短距离数组,检查是否正确。
-
边界测试:
- 所有城市相互不可达
- 所有城市相互可达
- 阈值非常大或非常小
-
性能测试:当n=100时检查运行时间,确保不会超时。
6. 实际应用与变种思考
6.1 实际应用场景
-
物流中心选址:选择覆盖需求点最少的中心位置,平衡各中心工作量。
-
社交网络分析:找出社交影响力最局限的用户。
-
网络基础设施规划:确定网络节点的部署位置。
6.2 问题变种
-
加权版本:每个城市有不同的重要性权重,找总权重最小的。
-
动态阈值:阈值可能随时间变化,需要高效更新结果。
-
增量式查询:需要频繁查询不同阈值的结果。
-
有向图版本:城市之间的道路可能是单向的。
7. 个人实现心得
在实际实现这道题时,有几个关键点值得注意:
-
优先队列的使用技巧:使用
pair<int,int>时,注意第一个元素应该是距离,这样默认的排序才会正确。我曾因为顺序反了而调试了很久。 -
边界条件处理:当阈值很小时,可能没有城市满足条件(除了自己),这时结果应该是城市本身。
-
编码技巧:使用
(now << 10) + next来编码边是一个实用技巧,但要注意节点编号不能太大(本题n≤100,10位足够)。 -
性能考量:在n=100时,O(n³)的算法在LeetCode上是可以接受的,但如果n更大,就需要考虑更高效的算法或优化手段了。
最后,这类图论问题在面试中经常出现,掌握Dijkstra等经典算法的实现细节非常重要。建议不仅要会写代码,还要理解其背后的原理和适用场景。