KingOfEgg
首页项目归档照片墙音乐灵境说说杂谈友链关于

云端杂谈

“ 代码、学术、提瓦特与泰拉大陆的碎片记录 ”

2026-09-28 21:56:14

Leetcode hot128 最长连续序列:排序解法杂谈

题目:给定一个未排序的整数数组 `nums`,找出数字连续的最长序列的长度(序列元素在原数组中不要求连续)。要求时间复杂度为 O(n)(进阶要求)。 本文讨论的解法不满足进阶要求,时间复杂度为 O(n log n),但实现简洁、细节清晰,值得作为分析的样本。 ```cpp class Solution { public: int longestConsecutive(vector<int>& nums) { if (nums.empty()) return 0; sort(nums.begin(), nums.end()); int ans = 1; int cnt = 1; for (int i = 1; i < nums.size(); ++i) { // 重复数字,跳过 if (nums[i] == nums[i - 1]) continue; // 连续 if (nums[i] == nums[i - 1] + 1) ++cnt; // 断开,重新开始 else cnt = 1; ans = max(ans, cnt); } return ans; } }; ``` 这道题的原始形态是"无序集合中找连续数字",直接处理需要反复在集合中判断"某个数的邻居是否存在",自然导向哈希表。而排序解法选择了另一条路径:先花 O(n log n) 把数组排好序,最长的连续序列就坍缩成数组中一段相邻差为 1 的连续区间,问题随之退化为一次线性扫描中的计数。 这类"先预处理、再简化问题"的思路在算法题中极为常见。排序、前缀和、单调栈本质上都是预处理手段:投入一定的代价,换取后续问题的结构化。此处用 O(n log n) 的排序换来了 O(n) 且逻辑极简的扫描,这笔交换在多数场景下是划算的。 ## 一个容易缺失的分支:重复数字 解法中最容易被忽略的是 `if (nums[i] == nums[i - 1]) continue;` 这一行。 考虑 `{1, 2, 2, 3}`:排序后遇到第二个 `2` 时,它既不等于前值(非重复分支),也不等于前值加 1(非连续分支),会落入"断开"分支,把正在计数的序列错误地重置为 1,最终得到错误答案。重复元素的语义应当是"对序列长度无影响",因此正确处理方式是跳过,而非重置。 一个值得注意的细节是分支顺序:必须先判断重复、再判断连续。若顺序颠倒,重复元素会先落入"断开"分支,同样的错误仍然会发生。这类细节没有高深的原理,却直接决定代码的正确性,也往往是面试官区分"写得出"与"写得对"的观察点。 ## 空数组:一个一行代码的边界 `if (nums.empty()) return 0;` 同样不可省略。若缺少该判断,空数组会走完排序与初始化,返回初始值 `ans = 1`,而正确答案是 0。边界条件的初始值设定与特殊情况的处理,往往比主体逻辑更容易出错——主体逻辑在测试中通常覆盖充分,边界则不然。 ## 与哈希解法的对比 满足进阶要求的标准做法是哈希集合:所有数字入 `unordered_set`,只从"不存在 x−1 的数字"(即每段连续序列的起点)开始向后枚举 x+1、x+2……由于每个数字至多被访问两次,总体 O(n)。 两种解法的取舍可以从三个角度看: - **时间复杂度**:排序解法 O(n log n) 不满足进阶要求;哈希解法 O(n) 达标。 - **常数与缓存**:排序解法对连续内存做顺序扫描,缓存局部性极佳;哈希解法的 `unordered_set` 存在哈希开销与指针跳转,实际运行中两者差距常小于理论差距。此外 `unordered_set` 在最坏情况下(哈希冲突严重)会退化到 O(n²),这是面试中可以主动提及的加分点。 - **实现难度**:排序解法十几行、无复杂数据结构;哈希解法需要正确处理"只从段起点开始枚举"的剪枝逻辑,出错概率更高。 ## 面试策略 基于上述对比,面试中的合理顺序是:先给出排序解法——它正确、简洁,能快速建立讨论基础;随后主动指出其不满足 O(n) 的进阶要求,并给出哈希解法;最后补充哈希冲突退化的讨论。这一流程展示了"先交付正确解,再优化"的工程习惯,比一上来就追求最优解、却在细节上出错的做法更为稳妥。 ## 结语 排序解法本身并无精妙之处,它的价值在于完整地呈现了一个小型解题范本:通过预处理化归问题主体、通过 `continue` 分支处理重复这一语义细节、通过提前返回处理空数组这一边界条件、并在复杂度上留下明确的优化方向。刷题的意义未必在于记住每道题的最优解,而在于这类范本的积累。
#LeetCode#排序#杂谈
2026-09-28 20:53:11

Leetcode hot100 字母异位词分组

题目:给你一个字符串数组,将字母异位词(字母相同、排列不同的单词,如 `eat` 与 `tea`)组合在一起,返回分组结果。分组内顺序任意,输出顺序任意。 思路:互为异位词的单词,其字符重排后相同。据此可以为每个单词构造一个"规范键"(排序后的字符串,或 26 个字母的计数),将键相同的单词归入同一组,用哈希表完成分组。 ### Python ```python class Solution: def groupAnagrams(self, strs: List[str]) -> List[List[str]]: groups = defaultdict(list) for s in strs: key = ''.join(sorted(s)) groups[key].append(s) return list(groups.values()) ``` 逐行解释: | 行 | 代码 | 解释 | |---|---|---| | 3 | `groups = defaultdict(list)` | 哈希表,键为异位词的规范键,值为同组单词列表。`defaultdict(list)` 在访问不存在的键时自动创建空列表,省去键是否存在的判断 | | 4 | `for s in strs:` | 遍历每个单词 | | 5 | `key = ''.join(sorted(s))` | `sorted(s)` 将字符排序,`''.join(...)` 拼回字符串。排序结果相同者互为异位词 | | 6 | `groups[key].append(s)` | 将单词追加到对应分组;键首次出现时自动建组 | | 7 | `return list(groups.values())` | 取出全部分组,转为列表返回 | ### C++ ```cpp class Solution { public: vector<vector<string>> groupAnagrams(vector<string>& strs) { unordered_map<string, vector<string>> groups; for (const string& s : strs) { string key = s; sort(key.begin(), key.end()); groups[key].push_back(s); } vector<vector<string>> res; for (auto& [key, group] : groups) { res.push_back(group); } return res; } }; ``` 逐行解释: | 行 | 代码 | 解释 | |---|---|---| | 4 | `unordered_map<string, vector<string>> groups;` | 哈希表:键为排序后的字符串,值为同组单词。基于哈希实现,单次操作平均 O(1) | | 5 | `for (const string& s : strs)` | 范围 for 遍历,常量引用避免拷贝 | | 6 | `string key = s;` | 拷贝一份用于排序,不能排序原单词(其为 const,且原数据仍需保留) | | 7 | `sort(key.begin(), key.end());` | 原地按字典序排序,如 `"eat"` → `"aet"` | | 8 | `groups[key].push_back(s);` | 键不存在时 `operator[]` 自动默认构造空桶,再执行 `push_back` | | 10 | `vector<vector<string>> res;` | 结果数组 | | 11 | `for (auto& [key, group] : groups)` | C++17 结构化绑定遍历键值对,`group` 即某一组 | | 12 | `res.push_back(group);` | 逐组加入结果。C++ 不像 Python 可直接取 `values()`,须手动遍历拼装 | ## 解法二:字符计数作为键(避免排序) ### Python ```python class Solution: def groupAnagrams(self, strs: List[str]) -> List[List[str]]: groups = defaultdict(list) for s in strs: count = [0] * 26 for c in s: count[ord(c) - ord('a')] += 1 groups[tuple(count)].append(s) return list(groups.values()) ``` 关键行解释: | 行 | 代码 | 解释 | |---|---|---| | 5 | `count = [0] * 26` | 长度 26 的计数数组,对应 a~z 出现次数 | | 7 | `count[ord(c) - ord('a')] += 1` | `ord(c) - ord('a')` 将字符映射为 0~25 下标,计数加一 | | 8 | `groups[tuple(count)].append(s)` | 列表不可哈希,须转元组作为键;同组异位词计数元组相同 | ### C++ ```cpp class Solution { public: vector<vector<string>> groupAnagrams(vector<string>& strs) { // 自定义哈希函数:将 26 个计数映射为一个 size_t auto arrayHash = [fn = hash<int>{}](const array<int, 26>& a) -> size_t { return accumulate(a.begin(), a.end(), 0ull, [&](size_t acc, int x) { return (acc << 1) ^ fn(x); }); }; unordered_map<array<int, 26>, vector<string>, decltype(arrayHash)> groups(0, arrayHash); for (const string& s : strs) { array<int, 26> count{}; for (char c : s) { count[c - 'a']++; } groups[count].push_back(s); } vector<vector<string>> res; for (auto& [key, group] : groups) { res.push_back(group); } return res; } }; ``` 关键行解释: | 行 | 代码 | 解释 | |---|---|---| | 5 | `auto arrayHash = [fn = hash<int>{}](const array<int, 26>& a) -> size_t` | C++ 的 `array<int, 26>` 无默认哈希,须自定义。lambda 以初始化捕获引入标准库的 int 哈希器 `fn` | | 6 | `return accumulate(...)` | 对 26 个计数逐项混合:`(acc << 1) ^ fn(x)` 是常见哈希合并手法,使不同计数组合尽量映射到不同值 | | 10 | `unordered_map<array<int, 26>, vector<string>, decltype(arrayHash)> groups(0, arrayHash);` | 键类型为计数数组,第三模板参数指定哈希器,构造时传入 | | 14 | `array<int, 26> count{};` | `{}` 保证 26 个元素全部初始化为 0 | | 16 | `count[c - 'a']++;` | 字符映射为下标,计数加一 | | 18 | `groups[count].push_back(s);` | `array` 支持逐元素 `==` 比较,计数相同者归入同组 | ## 复杂度对比 | 解法 | 时间复杂度 | 空间复杂度 | 说明 | |---|---|---|---| | 排序键 | O(n·k log k) | O(n·k) | n 为单词数,k 为单词最大长度;每词排序耗时 k log k | | 计数键 | O(n·k) | O(n·k) | 省去排序的 log 因子 | 结论:计数法理论复杂度更优,排序法键为短字符串、实现简洁。词长较短时二者实际差距很小,面试可先写排序法,口头补充计数法作为优化;若面试官明确要求避免排序,再给出计数法实现。
#LeetCode#哈希表#字符串