☰
LeetCode 1436题解:图论基础与哈希表应用
2026/11/5 4:44:50 网站建设 项目流程

1. 问题分析与解题思路

今天我们来聊聊LeetCode上一道有趣的图论基础题——1436.旅行终点站。这道题看似简单,但蕴含着图论中"度"的概念,非常适合用来巩固基础数据结构知识。

题目给定一组旅行路线paths,其中paths[i] = [cityA, cityB]表示从cityA直接前往cityB的路线。我们需要找出最终的目的地城市,即没有任何出路的城市。换句话说,这个城市不会作为任何路线的起点出现。

1.1 核心思路解析

这道题的核心在于理解"终点站"的定义:在路线网络中,终点站就是只作为目的地出现,而从不作为出发点的城市。从图论角度看:

  1. 我们可以把每个城市看作图中的一个节点
  2. 每条路线[cityA, cityB]就是一条从cityA指向cityB的有向边
  3. 终点站就是出度为0的节点(没有从它出发的边)

1.2 解题方法选择

基于这个理解,我们可以采用以下两种主要方法:

  1. 哈希表计数法(本文实现的方法):

    • 用一个哈希表记录每个城市作为起点的次数(出度)
    • 另一个集合记录所有出现过的城市
    • 最后找出在集合中但不在哈希表中的城市
  2. 集合差法:

    • 收集所有起点城市到一个集合
    • 收集所有终点城市到另一个集合
    • 终点集合减去起点集合的差就是结果

2. 代码实现与详细解析

让我们深入分析给出的C++解决方案,理解每一行代码的作用和背后的思考。

2.1 完整代码展示

class Solution { public: string destCity(vector<vector<string>>& paths) { string ret; unordered_map<string, int> ump; unordered_set<string> tr; for(int i = 0; i < paths.size(); i++) { ump[paths[i][0]]++; tr.insert(paths[i][0]); tr.insert(paths[i][1]); } for(const string& s : tr) { if(ump.count(s) == 0) return s; } return ""; } };

2.2 代码逐行解析

  1. 数据结构选择:

    • unordered_map<string, int> ump:记录每个城市作为起点的次数
    • unordered_set<string> tr:记录所有出现过的城市(起点和终点)

    这里选择unordered_map和unordered_set而不是普通map/set,是因为前者平均时间复杂度为O(1),而后者是O(log n),对于这种需要频繁查找的场景更高效。

  2. 第一次遍历构建数据结构:

    for(int i = 0; i < paths.size(); i++) { ump[paths[i][0]]++; // 记录起点城市出现次数 tr.insert(paths[i][0]); // 将起点加入总集合 tr.insert(paths[i][1]); // 将终点加入总集合 }
    • 对每条路线,增加起点城市在ump中的计数
    • 将起点和终点都加入总城市集合tr
  3. 查找终点站:

    for(const string& s : tr) { if(ump.count(s) == 0) return s; }
    • 遍历所有城市,找出在tr中存在但在ump中不存在的城市
    • 这就是我们的终点站(因为它从未作为起点出现)

2.3 复杂度分析

  • 时间复杂度:O(n),其中n是paths的长度。我们进行了两次线性遍历。
  • 空间复杂度:O(n),需要存储所有城市的映射关系。

3. 算法优化与变种思考

虽然上述解法已经足够高效,但我们还可以探讨一些优化和变种解法。

3.1 集合差法实现

class Solution { public: string destCity(vector<vector<string>>& paths) { unordered_set<string> starts, ends; for(auto& path : paths) { starts.insert(path[0]); ends.insert(path[1]); } for(auto& city : ends) { if(starts.count(city) == 0) return city; } return ""; } };

这种方法更直观地体现了数学上的集合差思想,代码也更简洁。

3.2 空间优化版本

如果我们只关心终点站,可以进一步优化空间:

class Solution { public: string destCity(vector<vector<string>>& paths) { unordered_set<string> starts; for(auto& path : paths) { starts.insert(path[0]); } for(auto& path : paths) { if(starts.count(path[1]) == 0) return path[1]; } return ""; } };

这个版本只需要存储起点城市,空间使用更少。

4. 常见错误与调试技巧

在解决这类问题时,新手常会遇到一些典型错误。让我们来看看如何避免它们。

4.1 常见错误类型

  1. 忽略环路情况:

    • 错误假设路线是单链式的,实际上可能存在环路
    • 我们的解法天然避免了这个问题,因为只关心出度
  2. 边界条件处理不足:

    • 空输入情况
    • 只有一个城市的情况
    • 需要确保题目给出的输入总是有效的
  3. 数据结构选择不当:

    • 使用vector而不是unordered_set会导致查找效率低下
    • 使用map而不是unordered_map会带来不必要的log n复杂度

4.2 调试技巧

  1. 打印中间结果:

    // 在第一次遍历后打印ump和tr cout << "起点计数: "; for(auto& p : ump) cout << p.first << ":" << p.second << " "; cout << "\n所有城市: "; for(auto& s : tr) cout << s << " ";
  2. 单元测试用例:

    // 测试用例1:基本案例 vector<vector<string>> paths1 = {{"London","New York"},{"New York","Lima"},{"Lima","Sao Paulo"}}; assert(destCity(paths1) == "Sao Paulo"); // 测试用例2:单一路线 vector<vector<string>> paths2 = {{"A","B"}}; assert(destCity(paths2) == "B"); // 测试用例3:环路(终点站不在环路中) vector<vector<string>> paths3 = {{"A","B"},{"B","C"},{"C","A"},{"A","D"}}; assert(destCity(paths3) == "D");

5. 实际应用与扩展思考

这道题虽然简单,但它所体现的思想在实际开发中有广泛应用。

5.1 实际应用场景

  1. 依赖关系分析:

    • 类似终点站问题,可以用于分析软件包依赖关系中的叶子节点
    • 或者任务调度中没有后续任务的最终任务
  2. 社交网络分析:

    • 识别社交网络中只被关注但不关注他人的用户
    • 或者信息传播路径中的最终接收者
  3. 工作流设计:

    • 在复杂工作流中识别最终状态节点
    • 确保每个流程都有明确的结束点

5.2 扩展思考

  1. 如果存在多个终点站怎么办?

    • 当前题目保证只有一个终点站
    • 如果可能有多个,我们需要返回所有出度为0的城市
  2. 如果路线形成环路怎么办?

    • 当前解法天然处理了这种情况
    • 环路中的城市都会有出度,不会被误认为终点站
  3. 如何找出起点站(入度为0的城市)?

    • 类似思路,只需统计作为终点的次数
    • 找出从未作为终点出现的城市

6. 代码风格与最佳实践

在编写这类算法题解时,保持良好的代码风格很重要。

6.1 现代C++特性应用

我们可以使用更现代的C++特性使代码更简洁:

class Solution { public: string destCity(vector<vector<string>>& paths) { unordered_set<string> starts; for(auto& [from, to] : paths) { starts.insert(from); } for(auto& [_, to] : paths) { if(!starts.count(to)) return to; } return ""; } };

使用了结构化绑定(auto& [from, to])使代码更易读。

6.2 防御性编程

添加输入验证使代码更健壮:

class Solution { public: string destCity(vector<vector<string>>& paths) { if(paths.empty()) return ""; unordered_set<string> starts; for(auto& path : paths) { if(path.size() < 2) continue; // 跳过无效条目 starts.insert(path[0]); } for(auto& path : paths) { if(path.size() < 2) continue; if(starts.count(path[1]) == 0) return path[1]; } return ""; // 理论上题目保证有解,这里为了完整性 } };

6.3 性能优化技巧

  1. 预留空间:

    unordered_set<string> starts; starts.reserve(paths.size()); // 预先分配足够空间
  2. 使用string_view(如果支持):

    unordered_set<string_view> starts; // 减少字符串拷贝
  3. 并行处理(对于极大输入):

    • 可以使用并行算法来加速统计过程
    • 但leetcode题目通常不需要这种优化

7. 不同语言实现对比

虽然我们主要讨论C++实现,但了解其他语言的解法也很有帮助。

7.1 Python实现

def destCity(paths): starts = {path[0] for path in paths} for path in paths: if path[1] not in starts: return path[1] return ""

Python版本非常简洁,得益于其强大的集合操作。

7.2 Java实现

class Solution { public String destCity(List<List<String>> paths) { Set<String> starts = new HashSet<>(); for(List<String> path : paths) { starts.add(path.get(0)); } for(List<String> path : paths) { if(!starts.contains(path.get(1))) { return path.get(1); } } return ""; } }

Java版本略显冗长,但思路一致。

7.3 JavaScript实现

var destCity = function(paths) { const starts = new Set(paths.map(path => path[0])); for(const path of paths) { if(!starts.has(path[1])) return path[1]; } return ""; };

JavaScript版本也很简洁,利用了现代ES6特性。

8. 总结与个人心得

这道旅行终点站问题虽然被标记为简单难度,但它很好地考察了对基础数据结构的理解和应用能力。在实际编码过程中,我有以下几点体会:

  1. 理解问题本质比立即编码更重要:一开始我尝试用复杂的方法跟踪整个路径,后来才意识到只需要统计出度即可。这提醒我先充分理解问题再动手编码。

  2. 选择合适的数据结构是关键:使用unordered_map和unordered_set使得解决方案既高效又简洁。在面试中,能够合理选择数据结构往往比写出完美代码更重要。

  3. 边界条件不容忽视:虽然题目保证了有效输入,但在实际开发中,我们必须考虑各种边界情况,如空输入、无效数据等。

  4. 多种解法拓宽思路:通过探索不同的解题方法(如集合差法),不仅加深了对问题的理解,也丰富了解决问题的工具箱。

  5. 性能优化要有针对性:在leetcode环境中,通常不需要过度优化,但在实际工程中,根据数据规模选择合适的优化策略很重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询