Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50aed8f968 | ||
|
|
2d9f9be19b |
@@ -0,0 +1,429 @@
|
||||
---
|
||||
tags: ["Go", "哈希表"]
|
||||
create time: 2026-05-13 15:00
|
||||
---
|
||||
|
||||
# 2026-05-13
|
||||
|
||||
## Go 中的 Hash Map 与 Hash Set
|
||||
|
||||
> [!question] 💡 思考
|
||||
> Go 标准库中没有像 Python 或 Java 那样的原生 `set` 类型——它是如何实现集合的?
|
||||
|
||||
### Hash Map:`map[K]V`
|
||||
|
||||
Go 的哈希映射通过内建类型 `map[K]V` 实现,K 必须支持 `==` 比较运算符,V 可以是任意类型。
|
||||
|
||||
#### 声明与初始化
|
||||
|
||||
```go
|
||||
// 方式一:make 创建空 map(最常用)
|
||||
mp := make(map[int]int)
|
||||
|
||||
// 方式二:字面量直接初始化
|
||||
ages := map[string]int{
|
||||
"Alice": 30,
|
||||
"Bob": 25,
|
||||
}
|
||||
|
||||
// 方式三:nil map(不可写入!读可以)
|
||||
var nilMap map[string]int // nilMap == nil → true
|
||||
nilMap["key"] = 1 // ❌ panic: assignment to entry in nil map
|
||||
```
|
||||
|
||||
> [!warning] ⚠️ nil vs 空 map
|
||||
> - `var m map[K]V` 声明后值为 `nil`,此时**只能读不能写**,写入会 panic
|
||||
> - `make(map[K]V)` 创建的是真正的空 map,可读可写
|
||||
> - 如果不确定,可以用 `len(m) == 0 && m == nil` 同时判断是否为 nil
|
||||
|
||||
#### 基本操作
|
||||
|
||||
```go
|
||||
mp := make(map[int]string)
|
||||
|
||||
// 插入 / 更新
|
||||
mp[1] = "one"
|
||||
|
||||
// 查询:不存在的 key 返回零值
|
||||
val := mp[999] // val == ""(int→string 的零值)
|
||||
|
||||
// 安全查询:双返回值判断 key 是否存在
|
||||
val, ok := mp[1] // val="one", ok=true
|
||||
_, exists := mp[999] // exists=false
|
||||
|
||||
// 删除
|
||||
delete(mp, 1)
|
||||
delete(mp, 999) // 删除不存在的 key 不会 panic
|
||||
|
||||
// 遍历
|
||||
for k, v := range mp {
|
||||
fmt.Println(k, v)
|
||||
}
|
||||
|
||||
// 元素个数
|
||||
n := len(mp)
|
||||
```
|
||||
|
||||
> [!info] 🐹 零值陷阱
|
||||
> 查找不存在的 key 时,Go 返回该值的**零值**(零值因 V 的类型而异),所以无法直接用 `mp[key] == 0` 判断 key 不存在——如果 key 对应的值恰好就是 0,结果会有歧义。务必使用 `(val, ok)` 双返回值模式。
|
||||
|
||||
| 操作 | 代码 | 说明 |
|
||||
|------|------|------|
|
||||
| 插入/更新 | `mp[key] = value` | key 存在则覆盖 |
|
||||
| 安全读取 | `v, ok := mp[key]` | ok=true 表示 key 存在 |
|
||||
| 不安全读取 | `v := mp[key]` | 返回零值,可能误判 |
|
||||
| 删除 | `delete(mp, key)` | 幂等,删不存在的 key 不 panic |
|
||||
| 判空 | `len(mp) == 0` | 但不能区分 nil 和非 nil 空 map |
|
||||
|
||||
### Hash Set:用 `map[K]struct{}` 实现
|
||||
|
||||
Go 没有内置的 `set`,惯用做法是用 `map[K]struct{}`。`struct{}` 大小为 **0 字节**,比 `bool` 或 `int` 更省内存。
|
||||
|
||||
```go
|
||||
// 方式一:make + struct{}
|
||||
seen := make(map[int]struct{})
|
||||
seen[42] = struct{}{}
|
||||
delete(seen, 42)
|
||||
|
||||
// 方式二:map[int]bool(也可用,但 bool 占 1 字节)
|
||||
seenBool := make(map[int]bool)
|
||||
seenBool[42] = true
|
||||
|
||||
// 查询成员身份(集合的核心操作)
|
||||
if _, exists := seen[42]; exists {
|
||||
fmt.Println("42 in set")
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] 🔑 为什么选 struct{} 而非 bool?
|
||||
>
|
||||
> | 类型 | 单个元素开销 | n 个元素的额外开销 |
|
||||
> |------|-------------|-------------------|
|
||||
> | `map[K]struct{}` | 0 bytes | 零 |
|
||||
> | `map[K]bool` | 1 byte | n bytes |
|
||||
> | `map[K]int` | 8 bytes (amd64) | 8n bytes |
|
||||
>
|
||||
> 当集合规模达到百万级时,`struct{}` 能节省显著内存。日常刷题用 `bool` 也无妨,语义更直观。
|
||||
|
||||
> [!example] 📝 HashSet 实战模板
|
||||
> ```go
|
||||
> func containsDuplicate(nums []int) bool {
|
||||
> seen := make(map[int]struct{})
|
||||
> for _, num := range nums {
|
||||
> if _, exists := seen[num]; exists {
|
||||
> return true
|
||||
> }
|
||||
> seen[num] = struct{}{}
|
||||
> }
|
||||
> return false
|
||||
> }
|
||||
> ```
|
||||
|
||||
## Go 中的字符串与字节切片转换
|
||||
|
||||
> [!question] 💡 思考
|
||||
> Go 的 `string` 是不可变类型,那如何修改其中的字符?这就需要借助 `[]byte`。
|
||||
|
||||
### string → []byte
|
||||
|
||||
```go
|
||||
s := "hello"
|
||||
|
||||
// 方式一:直接转换(创建新的 []byte 副本)
|
||||
b := []byte(s) // b == []byte{'h', 'e', 'l', 'l', 'o'}
|
||||
|
||||
// ⚠️ 重要:这一步完成了「内存拷贝」
|
||||
// s 本身不会改变,b 是独立的副本
|
||||
```
|
||||
|
||||
**底层关系图解:**
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["string s"] -->|"不可变"| B("数据存储在只读区")
|
||||
C["[]byte b = []byte(s)"] -->|"内存拷贝"| D("数据存放在可写的堆/栈上")
|
||||
D --> E["可以修改 b[0] = 'H' ✅"]
|
||||
style B fill:#f9d0d0
|
||||
style D fill:#d0f0d0
|
||||
```
|
||||
|
||||
> [!warning] ⚠️ string → []byte 是深拷贝
|
||||
> - 每次转换都会分配新内存并复制所有字节
|
||||
> - 高频调用时注意性能开销,LeetCode 刷题通常无需担心
|
||||
|
||||
### []byte → string
|
||||
|
||||
```go
|
||||
b := []byte{'h', 'e', 'l', 'l', 'o'}
|
||||
s := string(b) // s == "hello"
|
||||
|
||||
// 双向转换常用于原地修改字符串:
|
||||
s = "abc"
|
||||
b = []byte(s)
|
||||
b[0] = 'X' // 修改的是 []byte,不影响原 string
|
||||
s = string(b) // s == "Xbc"
|
||||
```
|
||||
|
||||
### bytes 包常用工具函数
|
||||
|
||||
刷题中更常见的是用 `bytes` 包替代手动转换,语义更清晰:
|
||||
|
||||
```go
|
||||
import "bytes"
|
||||
|
||||
s := "hello world"
|
||||
b := []byte(s)
|
||||
|
||||
// 判断是否包含子串(比循环快)
|
||||
bytes.Contains(b, []byte("world")) // true
|
||||
bytes.HasPrefix(b, []byte("hello")) // true
|
||||
bytes.HasSuffix(b, []byte("world")) // true
|
||||
|
||||
// 查找子串首次出现的位置
|
||||
idx := bytes.Index(b, []byte("world")) // idx == 6
|
||||
|
||||
// 替换、分割
|
||||
bytes.ReplaceAll(b, []byte("hello"), []byte("hi")) // []byte("hi world")
|
||||
bytes.Split(b, []byte(" ")) // [][]byte{{'h','e','l','l','o'}, {'w','o','r','l','d'}}
|
||||
```
|
||||
|
||||
> [!tip] 🔑 string vs []byte 选择指南
|
||||
>
|
||||
> | 场景 | 推荐类型 | 原因 |
|
||||
> |------|---------|------|
|
||||
> | 只读操作 | `string` | 不可变,零额外开销 |
|
||||
> | 需要修改字符 | `[]byte` | 可写副本 |
|
||||
> | Unicode/UTF-8 | `[]rune` | rune 等价于 int32,正确处理多字节字符 |
|
||||
|
||||
---
|
||||
|
||||
## Go 中的排序
|
||||
|
||||
Go 的排序功能集中在 `sort` 包中,支持对整型切片、字符串切片进行快速排序,也可自定义排序逻辑。
|
||||
|
||||
### 内建排序
|
||||
|
||||
```go
|
||||
import "sort"
|
||||
|
||||
nums := []int{3, 1, 4, 1, 5, 9, 2}
|
||||
strs := []string{"banana", "apple", "cherry"}
|
||||
|
||||
// 升序排列
|
||||
sort.Ints(nums) // [1, 1, 2, 3, 4, 5, 9]
|
||||
sort.Strings(strs) // ["apple", "banana", "cherry"]
|
||||
sort.IntsAreSorted(nums) // true(仅检查,不修改)
|
||||
|
||||
// 对已有切片排序(原地修改)
|
||||
sort.Slice(nums, func(i, j int) bool {
|
||||
return nums[i] < nums[j] // 升序
|
||||
})
|
||||
|
||||
// 降序排列
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(nums)))
|
||||
```
|
||||
|
||||
### 自定义排序
|
||||
|
||||
```go
|
||||
type Pair struct {
|
||||
Key string
|
||||
Value int
|
||||
}
|
||||
|
||||
pairs := []Pair{
|
||||
{"a", 3},
|
||||
{"b", 1},
|
||||
{"c", 2},
|
||||
}
|
||||
|
||||
// 按 Value 升序排序
|
||||
sort.Slice(pairs, func(i, j int) bool {
|
||||
return pairs[i].Value < pairs[j].Value
|
||||
})
|
||||
// 结果: [{"a",3}, {"b",1}, {"c",2}] → [{"b",1}, {"c",2}, {"a",3}]
|
||||
```
|
||||
|
||||
### 排序稳定性 — sort.SliceStable
|
||||
|
||||
```go
|
||||
type Student struct {
|
||||
Name string
|
||||
Score int
|
||||
}
|
||||
|
||||
students := []Student{
|
||||
{"Alice", 90},
|
||||
{"Bob", 85},
|
||||
{"Charlie", 90},
|
||||
}
|
||||
|
||||
// SliceStable 保持相等元素的原始相对顺序
|
||||
sort.SliceStable(students, func(i, j int) bool {
|
||||
return students[i].Score < students[j].Score
|
||||
})
|
||||
// Bob(85), Alice(90), Charlie(90) — Alice 仍在 Charlie 前面
|
||||
```
|
||||
|
||||
> [!tip] 🔑 算法复杂度
|
||||
> - `sort.Ints` / `sort.Strings`:内联实现,使用插排+堆排混合策略,均摊 O(n log n),常数极小
|
||||
> - `sort.Slice`:基于 quicksort,O(n log n),但自定义比较函数有间接调用开销
|
||||
> - `sort.Stable`:内层使用稳定排序(如 merge sort),最坏情况仍 O(n log n)
|
||||
|
||||
> [!info] 🐹 刷题常用排序技巧
|
||||
> - **计数排序思想**:若元素范围有限(如频率统计),用 map 或数组代替 sort 效率更高
|
||||
> - **先排序再双指针**:许多哈希题换个思路可以用「排序 + 双指针」解决,降低空间复杂度到 O(1)
|
||||
> - **字符频次**:`b := []byte(s); sort.Slice(b, func(i,j int) bool { return b[i] < b[j] })` 排序后可线性扫描统计字母异位词或重复字符
|
||||
|
||||
---
|
||||
|
||||
## 数组 / 切片 → 字符串的转换
|
||||
|
||||
### []byte / []rune → string
|
||||
|
||||
```go
|
||||
// []byte → string(最常用)
|
||||
s := string([]byte{'h', 'e', 'l', 'l', 'o'}) // "hello"
|
||||
|
||||
// []rune → string(Unicode 场景)
|
||||
r := []rune("你好世界")
|
||||
s := string(r) // "你好世界"
|
||||
|
||||
// ⚠️ 注意:[]rune 转 string 时不能对每个 rune 单独做 string(),必须整体转换
|
||||
// string([]rune{65, 66}) ✅ → "AB"
|
||||
// string(byte('A')) ❌ 编译错误,rune 不是 byte 的子类型
|
||||
```
|
||||
|
||||
### []string → string(用分隔符拼接)
|
||||
|
||||
```go
|
||||
import "strings"
|
||||
|
||||
fruits := []string{"apple", "banana", "cherry"}
|
||||
|
||||
// strings.Join — Go 标准库中专门处理这种场景
|
||||
result := strings.Join(fruits, ", ") // "apple, banana, cherry"
|
||||
result = strings.Join(fruits, "") // "applebananacherry"
|
||||
```
|
||||
|
||||
> [!tip] 🔑 strings.Join 底层实现
|
||||
> `strings.Join` 内部会先计算总长度,然后一次性分配内存并用 `strings.Builder` 写入——性能与手动循环 + Builder 等效。除非有额外逻辑(如跳过空值),否则直接用 Join 更简洁。
|
||||
|
||||
### fmt.Sprint / strconv 系列 — 数值切片转字符串
|
||||
|
||||
```go
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
nums := []int{1, 2, 3, 4}
|
||||
|
||||
// 方法一:Sprintf(最简单,但性能较差)
|
||||
fmt.Sprintf("%v", nums) // "[1 2 3 4]"
|
||||
fmt.Sprintf("%+v", nums) // "[1 2 3 4]"(带字段名,仅结构体有效)
|
||||
|
||||
// 方法二:手动转换(高效,LeetCode 推荐)
|
||||
func intSliceToString(nums []int) string {
|
||||
strs := make([]string, len(nums))
|
||||
for i, n := range nums {
|
||||
strs[i] = strconv.Itoa(n)
|
||||
}
|
||||
return "[" + strings.Join(strs, ", ") + "]"
|
||||
}
|
||||
// 输出: "[1, 2, 3, 4]"
|
||||
```
|
||||
|
||||
> [!warning] ⚠️ 各类型零值汇总
|
||||
> | 类型 | 零值 |
|
||||
> |------|------|
|
||||
> | `string` | `""` |
|
||||
> | `int` | `0` |
|
||||
> | `bool` | `false` |
|
||||
> | `map[K]V` | `nil` |
|
||||
> | `slice` | `nil` |
|
||||
> | `pointer` | `nil` |
|
||||
|
||||
---
|
||||
|
||||
## Go 中的字符串拼接(StringBuilder)
|
||||
|
||||
> [!question] 💡 思考
|
||||
> Java 有 `StringBuilder` 优化大量字符串拼接,Go 中有哪些方式?它们的性能差异有多大?
|
||||
|
||||
### ⚠️ 为什么不能直接 `+=`?
|
||||
|
||||
```go
|
||||
s := ""
|
||||
for i := 0; i < 1000; i++ {
|
||||
s += "a" // ❌ 每次循环都创建新的 string,O(n²)
|
||||
}
|
||||
// 因为 Go 的 string 是不可变的,+= 等于创建新内存拷贝
|
||||
```
|
||||
|
||||
### strings.Builder — Go 版的 StringBuilder ✅
|
||||
|
||||
```go
|
||||
import "strings"
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// 初始化时可预分配容量,避免多次扩容
|
||||
sb.Grow(100)
|
||||
|
||||
// 写入各种类型
|
||||
sb.WriteString("hello") // 写入字符串
|
||||
sb.WriteByte(' ') // 写入单字节
|
||||
sb.WriteRune('你好') // 写入 Unicode 字符
|
||||
fmt.Fprintf(&sb, "%d", 42) // 格式化写入
|
||||
|
||||
// 获取最终结果(只读转换,不修改内部缓冲区)
|
||||
result := sb.String() // result == "hello 42"
|
||||
|
||||
// 重置复用,无需重新分配
|
||||
sb.Reset()
|
||||
sb.WriteString("new content")
|
||||
```
|
||||
|
||||
**完整实战示例:**
|
||||
|
||||
```go
|
||||
func joinStrings(words []string, sep string) string {
|
||||
if len(words) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 预估总长度,一次分配到位
|
||||
totalLen := 0
|
||||
for _, w := range words {
|
||||
totalLen += len(w) + len(sep)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.Grow(totalLen - len(sep)) // 减去最后一个分隔符
|
||||
|
||||
for i, w := range words {
|
||||
if i > 0 {
|
||||
sb.WriteString(sep)
|
||||
}
|
||||
sb.WriteString(w)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
```
|
||||
|
||||
### 各方案对比
|
||||
|
||||
| 方案 | 时间复杂度 | 空间开销 | 推荐场景 |
|
||||
|------|-----------|---------|---------|
|
||||
| `+=` / `+` | O(n²) | 每轮分配新内存 | **禁止**在循环中使用 |
|
||||
| `[]byte` + `string()` | O(n) | 额外 byte 副本 | 需要原地修改字符时 |
|
||||
| `bytes.Buffer` | O(n) | Buffer 结构体开销 | 需要读写双向操作 |
|
||||
| `strings.Builder` | O(n) | 最小化(Grow 可预分配) | **字符串拼接首选** |
|
||||
|
||||
> [!tip] 🔑 strings.Builder vs bytes.Buffer 怎么选?
|
||||
> - 纯字符串拼接 → `strings.Builder`,接口更简洁,零额外内存拷贝
|
||||
> - 需要同时处理字节级和字符串级操作 → `bytes.Buffer`
|
||||
> - Go 1.10+ 才引入了 `strings.Builder`,刷题环境通常已支持
|
||||
@@ -0,0 +1,242 @@
|
||||
---
|
||||
tags: ["LeetCode", "哈希表", "排序", "中等"]
|
||||
create time: 2026-05-13 14:35
|
||||
---
|
||||
|
||||
# 02-字母异位词分组
|
||||
|
||||
## 题面
|
||||
|
||||
给你一个字符串数组,请你将 **字母异位词** 组合在一起。可以按任意顺序返回结果列表。
|
||||
|
||||
**字母异位词**:字母相同但排列不同的字符串。
|
||||
|
||||
**示例 1:**
|
||||
|
||||
```
|
||||
输入: strs = ["eat", "tea", "tan", "ate", "nat", "bat"]
|
||||
输出: [["bat"],["nat","tan"],["ate","eat","tea"]]
|
||||
解释:
|
||||
- 在 strs 中没有字符串可以通过重新排列来形成 "bat"。
|
||||
- "nat" 和 "tan" 是字母异位词。
|
||||
- "ate"、"eat" 和 "tea" 是字母异位词。
|
||||
```
|
||||
|
||||
**示例 2:**
|
||||
|
||||
```
|
||||
输入: strs = [""]
|
||||
输出: [[""]]
|
||||
```
|
||||
|
||||
**示例 3:**
|
||||
|
||||
```
|
||||
输入: strs = ["a"]
|
||||
输出: [["a"]]
|
||||
```
|
||||
|
||||
**约束:**
|
||||
|
||||
- `1 <= strs.length <= 10^4`
|
||||
- `0 <= strs[i].length <= 100`
|
||||
- `strs[i]` 仅包含小写字母
|
||||
|
||||
---
|
||||
|
||||
## 思路
|
||||
|
||||
> [!question] 💡 思考
|
||||
> 两个字符串互为字母异位词,意味着它们有什么**共同特征**?能否用这个特征作为哈希表的键,把同类字符串归到一起?
|
||||
|
||||
### 方法一:排序作为键 ⭐(推荐入门)
|
||||
|
||||
核心思想:如果两个字符串是字母异位词,那么把它们各自字符**排序后得到的字符串完全一样**。
|
||||
|
||||
做法:遍历每个字符串,将其排序后作为哈希表的键,将原始字符串追加到对应值(切片)中。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["遍历每个字符串 s"] --> B["对 s 的字符排序得到 key"]
|
||||
B --> C{"key 在哈希表中?"}
|
||||
C -->|"否"| D["新建空切片,存入 mp[key]"]
|
||||
C -->|"是"| E["取已有切片"]
|
||||
D --> F["将原始字符串 s 追加到 mp[key]"]
|
||||
E --> F
|
||||
F --> G{"还有下一个字符串?"}
|
||||
G -->|"是"| A
|
||||
G -->|"否"| H["收集所有切片的值并返回"]
|
||||
```
|
||||
|
||||
以 `["eat", "tea", "tan", "ate", "nat", "bat"]` 为例:
|
||||
|
||||
| 字符串 | 排序后 key | 哈希表状态 |
|
||||
|--------|-----------|-----------|
|
||||
| `"eat"` | `"aet"` | `{aet: ["eat"]}` |
|
||||
| `"tea"` | `"aet"` | `{aet: ["eat","tea"]}` |
|
||||
| `"tan"` | `"ant"` | `{aet: [...], ant: ["tan"]}` |
|
||||
| `"ate"` | `"aet"` | `{aet: ["eat","tea","ate"], ant: ["tan"]}` |
|
||||
| `"nat"` | `"ant"` | `{aet: [...], ant: ["tan","nat"]}` |
|
||||
| `"bat"` | `"abt"` | `{aet: [...], ant: [...], abt: ["bat"]}` |
|
||||
|
||||
**关键细节:**
|
||||
|
||||
- Go 中需要先将字符串转为 `[]rune` 或 `[]byte`,调用 `sort.Slice` 排序后再转回字符串作为 key
|
||||
- 注意使用 `make(map[string][]string)` 初始化哈希表
|
||||
- 最终返回值需要把 map 的所有 slice 收集到一个二维切片中
|
||||
|
||||
---
|
||||
|
||||
### 方法二:字符计数作为键 ⭐⭐(最优)
|
||||
|
||||
核心思想:对于只含小写字母的字符串,统计每个字符出现的次数。两个字符串的**字符计数完全相同时**,它们互为字母异位词——无需真正排序。
|
||||
|
||||
做法:用长度为 26 的整数数组记录 'a'~'z' 各出现几次,将其转为字符串作为哈希表键。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["遍历每个字符串 s"] --> B["初始化 [26]int 计次数组"]
|
||||
B --> C["遍历 s 的每个字符 c"]
|
||||
C --> D["count[c - 'a']++"]
|
||||
D --> E{"遍历完 s?"}
|
||||
E -->|"否"| C
|
||||
E -->|"是"| F["将 count 转为字符串 key"]
|
||||
F --> G["将 s 追加到 mp[key]"]
|
||||
G --> H{"还有下一个字符串?"}
|
||||
H -->|"是"| A
|
||||
H -->|"否"| I["收集所有切片的值并返回"]
|
||||
```
|
||||
|
||||
**为什么这个方法更快?**
|
||||
|
||||
- 排序法:每个字符串需 O(k log k),k 为字符串长度
|
||||
- 计次法:每个字符串只需 O(k),k 为字符串长度
|
||||
- 当字符串较长时差异显著
|
||||
|
||||
**两种方法对比:**
|
||||
|
||||
| | 时间复杂度 | 空间复杂度 | 特点 |
|
||||
|--|----------|----------|------|
|
||||
| 排序法 | O(n · k · log k) | O(n · k) | 直观易懂,适合面试入门 |
|
||||
| 计次法 | O(n · k) | O(n · k) | 更优性能,体现对问题的深入理解 |
|
||||
|
||||
---
|
||||
|
||||
## 代码提示
|
||||
|
||||
### 排序法的伪代码模板
|
||||
|
||||
```
|
||||
初始化空哈希表 mp : map[string][]string
|
||||
for 每个字符串 s in strs:
|
||||
key = 对 s 的字符排序后的结果
|
||||
mp[key] = append(mp[key], s)
|
||||
收集 mp 中所有 value 组成结果并返回
|
||||
```
|
||||
|
||||
### 计次法的伪代码模板
|
||||
|
||||
```
|
||||
初始化空哈希表 mp : map[string][]string
|
||||
for 每个字符串 s in strs:
|
||||
创建长度为 26 的计次数组 cnt
|
||||
for 字符 c in s:
|
||||
cnt[c - 'a']++
|
||||
key = 将 cnt 转为唯一字符串表示
|
||||
mp[key] = append(mp[key], s)
|
||||
收集 mp 中所有 value 组成结果并返回
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技巧
|
||||
|
||||
> [!tip] 🔑 核心模式:等价类分组
|
||||
> "找到元素的共同特征 → 作为哈希键 → 同组聚合" 是一个通用范式。本题中「排序结果」和「字符计数」都是等价的签名(signature),可用于识别一组字符串是否互为字母异位词。类似思路还出现在「有效的字母异位词」「赎金信」等问题。
|
||||
|
||||
> [!note] 🐹 Go 中的 HashMap 写法
|
||||
> - Go 的 map 取值时 key 不存在会返回零值(`[]string` 的零值是 `nil`),直接用 `append(mp[key], s)` 是安全的——`append(nil, s)` 会得到 `[s]`
|
||||
> - 利用 Go 这一特性,不需要先判断 key 是否存在再决定插入还是追加,代码更简洁
|
||||
|
||||
> [!info] 📊 复杂度分析(计次法)
|
||||
> - 时间:O(n · k),n 为字符串个数,k 为字符串最大长度。每个字符恰好访问一次
|
||||
> - 空间:O(n · k),哈希表存储所有字符串的副本;计次数组固定 26 个 int,忽略不计
|
||||
|
||||
> [!quote] 💬 面试官追问指南
|
||||
> - Q: 如果字符串包含 Unicode 字符怎么办?A: 计次法需用 map[rune]int,排序法可用 `sort.Slice` 对 []rune 排序
|
||||
> - Q: 能不能不用额外空间?A: 题目要求返回新结构,无法原地完成
|
||||
> - Q: 字符计数如何高效转成 map 的 key?A: 用 `strconv.Itoa` 拼接或用 `bytes.Join` 构造字符串
|
||||
|
||||
---
|
||||
|
||||
## 代码
|
||||
|
||||
### 方法一:排序法
|
||||
|
||||
```go
|
||||
func groupAnagrams(strs []string) [][]string {
|
||||
mp := make(map[string][]string)
|
||||
|
||||
for _, s := range strs {
|
||||
// 将字符串转为 []byte 排序,得到规范化 key
|
||||
b := []byte(s)
|
||||
sort.Slice(b, func(i, j int) bool {
|
||||
return b[i] < b[j]
|
||||
})
|
||||
key := string(b)
|
||||
|
||||
// Go 的 append 对 nil 切片安全,可直接追加
|
||||
mp[key] = append(mp[key], s)
|
||||
}
|
||||
|
||||
// 收集结果
|
||||
result := make([][]string, 0, len(mp))
|
||||
for _, v := range mp {
|
||||
result = append(result, v)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
> _需导入_ `"sort"`
|
||||
|
||||
### 方法二:字符计数法(最优)⭐
|
||||
|
||||
```go
|
||||
func groupAnagrams(strs []string) [][]string {
|
||||
mp := make(map[string][]string)
|
||||
|
||||
for _, s := range strs {
|
||||
// 统计每个小写字母出现次数
|
||||
count := [26]int{}
|
||||
for _, c := range s {
|
||||
count[c-'a']++
|
||||
}
|
||||
|
||||
// 将计次数组转为字符串作为 key,用 "#" 分隔避免歧义
|
||||
// 例如 [1,0,0,...] -> "#1#0#0#..."
|
||||
var sb strings.Builder
|
||||
sb.WriteByte('#')
|
||||
for _, v := range count {
|
||||
sb.WriteString(strconv.Itoa(v))
|
||||
sb.WriteByte('#')
|
||||
}
|
||||
key := sb.String()
|
||||
|
||||
mp[key] = append(mp[key], s)
|
||||
}
|
||||
|
||||
result := make([][]string, 0, len(mp))
|
||||
for _, v := range mp {
|
||||
result = append(result, v)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
> _需导入_ `"strconv"`, `"strings"`
|
||||
|
||||
> [!success] ✅ 运行验证
|
||||
> LeetCode 第 49 题,通过率约 62%。计次法通常能在 5ms 内通过全部测试用例,远超排序法。掌握「等价类分组」思维后,可举一反三解决大量相似问题。
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
---
|
||||
tags: ["LeetCode", "哈希表", "中等"]
|
||||
create time: 2026-05-13 14:30
|
||||
---
|
||||
|
||||
# 03-最长连续序列
|
||||
|
||||
## 题面
|
||||
|
||||
给定一个未排序的整数数组 `nums`,找出数字**连续**的最长序列(不要求序列元素在原数组中连续)的长度。
|
||||
|
||||
请你设计并实现时间复杂度为 **O(n)** 的算法解决此问题。
|
||||
|
||||
**示例 1:**
|
||||
|
||||
```
|
||||
输入:nums = [100,4,200,1,3,2]
|
||||
输出:4
|
||||
解释:最长数字连续序列是 [1, 2, 3, 4]。它的长度为 4。
|
||||
```
|
||||
|
||||
**示例 2:**
|
||||
|
||||
```
|
||||
输入:nums = [0,3,7,2,5,8,4,6,0,1]
|
||||
输出:9
|
||||
解释:最长数字连续序列是 [0, 1, 2, 3, 4, 5, 6, 7, 8]。它的长度为 9。
|
||||
```
|
||||
|
||||
**示例 3:**
|
||||
|
||||
```
|
||||
输入:nums = [1,0,1,2]
|
||||
输出:3
|
||||
解释:最长数字连续序列是 [0, 1, 2]。它的长度为 3。
|
||||
```
|
||||
|
||||
**约束:**
|
||||
|
||||
- `0 <= nums.length <= 10^5`
|
||||
- `-10^9 <= nums[i] <= 10^9`
|
||||
|
||||
---
|
||||
|
||||
## 思路
|
||||
|
||||
> [!question] 💡 思考
|
||||
> 排序可以做到 O(n) 扫描找答案,但排序本身是 O(n log n),不符合要求。如何在无序数据中"瞬间判断"某个数是否存在?
|
||||
|
||||
### 方法一:排序后线性扫描 ⚠️(O(n log n),不可取)
|
||||
|
||||
先排序再扫描的思路虽然直观,但违背了 O(n) 的限制条件,仅作为理解本题的阶梯。
|
||||
|
||||
### 方法二:哈希集合 + 只从起点展开 ⭐(O(n))
|
||||
|
||||
核心洞察:**每个连续序列有且只有一个起点——即 `x-1` 不在数组中的那个 `x`**。
|
||||
|
||||
利用这个性质,我们可以只做一件事:当发现某个数是序列起点时,顺着往后数能走多远。非起点的数直接跳过,不会被重复计数。
|
||||
|
||||
#### 算法流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["将 nums 全部放入哈希集合 s"] --> B["遍历每个数 num"]
|
||||
B --> C{"num - 1 在 s 中?"}
|
||||
C -->|"是"| D["不是起点, 跳过"]
|
||||
C -->|"否"| E["从 num+1 开始, 不断 +1 检查是否在 s 中"]
|
||||
E --> F["记当前序列长度 len"]
|
||||
F --> G["ans = max(ans, len)"]
|
||||
D --> B
|
||||
G --> B
|
||||
B --> H["遍历结束"]
|
||||
H --> I["返回 ans"]
|
||||
```
|
||||
|
||||
#### 为什么仍是 O(n)?
|
||||
|
||||
- **建集合**:O(n)
|
||||
- **外层遍历**:n 次迭代
|
||||
- **内层 while 展开**:每个元素最多被内层访问一次——因为只有它是起点时才会进入 while,而一旦某个数被 while 访问过,后续任何枚举到它时都必然有 `num-1` 也在集合中,不会再次展开。
|
||||
|
||||
两个循环加起来,每个元素的总访问次数是常数次,因此整体 O(n)。
|
||||
|
||||
以 `nums = [100, 4, 200, 1, 3, 2]` 为例:
|
||||
|
||||
| 枚举值 | `num-1` 存在? | 动作 | 连续长度 |
|
||||
|--------|---------------|------|----------|
|
||||
| 100 | 101 ❌ → 实际查 99 ❌ | 不是起点,跳过 | — |
|
||||
| 4 | 3 ✅ | 不是起点,跳过 | — |
|
||||
| 200 | 199 ❌ | **是起点!** 向后展开:200→201(不存在) | 1 |
|
||||
| 1 | 0 ❌ | **是起点!** 向后展开:1→2→3→4(5不存在) | **4** |
|
||||
| 3 | 2 ✅ | 不是起点,跳过 | — |
|
||||
| 2 | 1 ✅ | 不是起点,跳过 | — |
|
||||
|
||||
最终答案:**4**
|
||||
|
||||
**关键细节:**
|
||||
|
||||
- Go 中用 `map[int]struct{}` 作为 set,`struct{}{}` 零内存开销
|
||||
- 需要先处理空数组边界情况:`len(nums) == 0` 时返回 0
|
||||
- 重复元素不影响正确性:集合天然去重,每个数值只保留一份
|
||||
|
||||
---
|
||||
|
||||
## 代码提示
|
||||
|
||||
```
|
||||
// 伪代码模板
|
||||
set = 将 nums 所有元素放入哈希集合
|
||||
ans = 0
|
||||
|
||||
for each num in nums:
|
||||
if (num - 1) NOT in set:
|
||||
// num 是某个连续序列的起点
|
||||
current = num + 1
|
||||
len = 1
|
||||
while current in set:
|
||||
len++
|
||||
current++
|
||||
ans = max(ans, len)
|
||||
|
||||
return ans
|
||||
```
|
||||
|
||||
Go 语言中使用 `struct{}` 构造哈希集合:
|
||||
|
||||
```go
|
||||
s := make(map[int]struct{})
|
||||
for _, v := range nums {
|
||||
s[v] = struct{}{}
|
||||
}
|
||||
|
||||
// 判断 key 是否存在
|
||||
if _, ok := s[key]; !ok {
|
||||
// key 不存在
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技巧
|
||||
|
||||
> [!tip] 🔑 模式:只从起点展开
|
||||
> 这是「集合去重 + 单侧展开」的经典手法。关键点在于理解每个连续段有唯一的左端点,这样能保证每条边(相邻整数对)只在一次展开中被遍历,从而证明总时间复杂度为 O(n)。
|
||||
|
||||
> [!note] 🐹 Go Set 的最佳实践
|
||||
> - `map[T]struct{}` 比 `map[T]bool` 更省内存(`struct{}{}` 大小为 0)
|
||||
> - 查找语法:`_, ok := m[k]`;删除语法:`delete(m, k)`
|
||||
> - 不需要初始化 capacity,但如果已知去重后数量,可以 `make(map[int]struct{}, n)` 减少扩容
|
||||
|
||||
> [!info] 📊 复杂度分析
|
||||
> - 时间:O(n),建集合 O(n) + 外层遍历 O(n) + 内层展开均摊 O(1)(每个元素只被 while 访问一次)
|
||||
> - 空间:O(n),哈希集合存储去重后的所有元素
|
||||
|
||||
> [!danger] ⚠️ 常见陷阱
|
||||
> - **漏掉空数组**:`nums = []` 应返回 0,而非 panic
|
||||
> - **没有从起点展开**:如果不对 `num-1` 做判断,最坏情况下内层 while 会对每个元素都执行,退化为 O(n²)
|
||||
> - **没去重**:如果直接用切片存而不先去重,重复元素会导致长度计算错误(如 `[1,1,2,2,3]` 答案应为 3 而非 5)
|
||||
|
||||
---
|
||||
|
||||
## 代码
|
||||
|
||||
```go
|
||||
func longestConsecutive(nums []int) int {
|
||||
if len(nums) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 构建哈希集合(自动去重)
|
||||
set := make(map[int]struct{})
|
||||
for _, v := range nums {
|
||||
set[v] = struct{}{}
|
||||
}
|
||||
|
||||
ans := 0
|
||||
for _, num := range nums {
|
||||
// 只有 num-1 不在集合中时,num 才是连续序列的起点
|
||||
if _, ok := set[num-1]; !ok {
|
||||
current := num + 1
|
||||
length := 1
|
||||
for ; ; length++ {
|
||||
if _, ok := set[current]; !ok {
|
||||
break
|
||||
}
|
||||
current++
|
||||
}
|
||||
if length > ans {
|
||||
ans = length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ans
|
||||
}
|
||||
```
|
||||
|
||||
> [!success] ✅ 运行验证
|
||||
> 这是 LeetCode 第 128 题,Hard 难度但核心思想简洁。类似变体包括「缺失的第一个正数」(LC 41),两者都利用了哈希集合适配性不同的角度来解决。
|
||||
Reference in New Issue
Block a user