数据结构

31 阅读16分钟

智能指针

什么是智能指针 (Smart Pointer)?

普通指针 (Pointer)  就像一张**“小纸条”**,上面只写着一个门牌号(内存地址)。它很傻,不知道房子里住着谁,也不知道房子什么时候该拆。

智能指针 (Smart Pointer)  就像一个**“私人管家”**。

  1. 它手里也有那张小纸条(包含指针)。

  2. 它有额外的信息(比如数据的长度、容量、引用计数)。

  3. 它有超能力(行为)

    • Deref (解引用) :你要找数据时,管家帮你开门,让你直接操作数据,感觉不到管家的存在。
    • Drop (析构) :当你不需要数据时(变量离开作用域),管家会自动把房子打扫干净、退租(释放内存/释放锁)。

总结:智能指针 = 指针 + 元数据 + 自动管理逻辑。


1. Box<T> —— 搬家公司(把数据搬到堆上)

原理:

Rust 里的变量默认是存在栈 (Stack)  上的。栈很快,但是空间小,且要求数据大小必须固定。 Box<T> 的作用就是:把原本要在栈上的数据,强行搬到 堆 (Heap) 上去,只在栈上留一个只有指针大小的“提货单”。

  • 所有权独占Box 拥有堆上那块数据。Box 死了,堆上的数据也得死。

用法:

Rust

fn main() {
    // 10 这种小整数本来应该在栈上
    // Box::new 把 10 扔到了堆上,x 只是栈上的一个指针
    let x = Box::new(10); 
    
    // *x 解引用,管家带你找到堆上的 10
    println!("x is {}", *x); 
} // x 离开作用域,Box 自动释放堆内存

设计意图与场景:

  1. 逃离栈大小限制:如果你有一个巨大的结构体(几百 MB),放在栈上会把栈撑爆(Stack Overflow)。用 Box 把它扔到堆上,栈上只存个小指针。

  2. 递归类型 (Recursive Type) :这是面试必问。

    • 比如定义一个链表节点:struct Node { next: Node }
    • 编译器疯了:“Node 里有个 Node,那个 Node 里还有个 Node……这一层套一层,这结构体到底多大?我算不出来,不能在栈上分配!”
    • 解决struct Node { next: Box<Node> }。编译器安心了:“哦,Box 我知道,固定 8 个字节(64位系统指针大小)。后面那一串不管多长,反正都在堆上。”
  3. Trait 对象:当你想在一个数组里存不同类型的对象(只要它们实现了同一个接口)时,必须用 Box<dyn Trait>


2. Cow<'a, B> —— 聪明的懒汉(写时克隆)

原理:

全名 Copy owrite(写时复制)。 它是一个枚举 (Enum) ,它有两种状态:

  1. Borrowed (借来的) :手里拿的是个引用 &T(只读,数据不在我这,不用我管)。
  2. Owned (拥有的) :手里拿的是个真值 T(通常是 String 或 Vec,数据归我管)。

它的哲学是:能“借”就绝不“买”。  只要你只是读取数据,我就一直用引用的方式(零成本)。只有当你突然说:“我要修改这个数据!”时,我才会在最后一刻把数据克隆一份给你改,变成“拥有”状态。

用法:

假设你要处理一个字符串,如果字符串里有空格就替换,没有空格就原样返回。

Rust

use std::borrow::Cow;

// 输入是 &str (引用),返回是 Cow (可能是引用,可能是新的 String)
fn remove_spaces(input: &str) -> Cow<str> {
    if input.contains(' ') {
        // 有空格,必须修改!
        // 触发 Clone,生成新的 String,包装成 Owned 返回
        Cow::Owned(input.replace(' ', ""))
    } else {
        // 没空格,不需要改。
        // 直接把传进来的引用原样返回,包装成 Borrowed。
        // 一次内存分配都没发生!极快!
        Cow::Borrowed(input)
    }
}

fn main() {
    let s1 = "hello_world";
    let result1 = remove_spaces(s1); // 这里零拷贝,全是引用
    
    let s2 = "hello world";
    let result2 = remove_spaces(s2); // 这里发生了拷贝
}

设计意图与场景:

  1. 极致的性能优化:当你写一个函数,输入是引用。你希望在大多数情况下直接返回引用(省内存、省时间),只有在极少数需要修改的情况下才分配新内存。
  2. 读多写少:比如解析配置文件、URL 处理。99% 的情况可能都是合法的,不需要改动,用 Cow 可以避免无数次无意义的 String::clone()

3. MutexGuard<T> —— 临时门禁卡

原理:

这是多线程编程中 Mutex (互斥锁) 的核心伴侣。 很多人以为 Mutex 是锁,其实 Mutex 是带锁的房间。 而 MutexGuard 才是如果你能进房间,给你的那把临时钥匙

  • 获取:当你调用 mutex.lock().unwrap() 时,如果抢到了锁,你得到的不是原本的数据 T,而是一个 MutexGuard<T>

  • 智能之处

    • Deref:它让你感觉它就是数据 T,你可以直接修改。
    • Drop:这是最关键的!当 MutexGuard 离开作用域(比如函数结束,或者你手动 drop)时,它会在销毁的一瞬间,自动帮你把锁解开 (Unlock)

用法:

Rust

use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5); // 房间里放着 5

    {
        // lock() 成功,返回一个 MutexGuard,命名为 num
        // 此时,门锁上了,别人进不来
        let mut num = m.lock().unwrap(); 
        
        // 这一步使用了 Deref,我们像操作 &mut i32 一样操作 Guard
        *num = 6; 
        
    } // <--- 重点!大括号结束,num (Guard) 离开作用域。
      // Guard 的 Drop 被调用 -> 自动执行 unlock()。
      // 房间门开了。
}

设计意图与场景:

  1. 防止忘记解锁:在 C/C++ 时代,手动 lock() 后,如果中间发生错误提前 return 了,或者发生了异常,程序员经常忘记 unlock(),导致死锁 (Deadlock) ——其他线程永远在等。
  2. RAII (资源获取即初始化) :Rust 利用 MutexGuard 绑定了“锁的生命周期”和“变量的作用域”。只要变量还在,锁就在;变量没了,锁就开了。  彻底杜绝了忘记解锁的问题。
  3. 数据与锁的绑定:你不可能在没有锁的情况下访问数据(因为数据被包在 Mutex 里,想摸到数据必须拿 Guard,想拿 Guard 必须上锁)。

总结:三者的设计哲学

智能指针绰号核心哲学解决的问题
Box<T>搬家公司所有权转移怎么把大东西塞进小栈里?怎么处理未知大小的类型?
Cow<T>懒汉按需分配怎么在“只读”场景下避免无意义的内存复制,节省 CPU?
MutexGuard<T>门禁卡生命周期绑定怎么保证锁一定会被释放?怎么防止数据在没锁时被访问?

这三个指针展示了 Rust 的核心魅力:利用类型系统和生命周期(Deref/Drop),把内存管理和并发安全的负担,从程序员的脑子里,转移到了编译器的检查里。

集合容器

集合容器,顾名思义,就是把一系列拥有相同类型的数据放在一起,统一处理;

切片

切片是描述一组属于同一类型长度不确定的、在内存中连续存放的数据结 构,用 [T] 来表述。因为长度不确定,所以切片是个 DST(Dynamically Sized Type)

fn main() {
    // 1. 定义一个定长数组 (Array)
    // 数据存储在【栈 (Stack)】上,类型是 [i32; 5]
    let arr = [1, 2, 3, 4, 5];

    // 2. 定义一个动态向量 (Vector)
    // 数据存储在【堆 (Heap)】上,类型是 Vec<i32>
    let vec = vec![1, 2, 3, 4, 5];

    // 3. 创建切片 s1
    // 借用了 arr 的前两个元素。s1 的类型是 &[i32]。
    // 底层结构:{ ptr: 指向 arr[0] 的地址, len: 2 }
    let s1 = &arr[..2];

    // 4. 创建切片 s2
    // 借用了 vec 的前两个元素。s2 的类型也是 &[i32]。
    // 底层结构:{ ptr: 指向 vec 堆内存首地址, len: 2 }
    let s2 = &vec[..2];

    // 打印出来都是 [1, 2]
    println!("s1: {:?}, s2: {:?}", s1, s2);

    // 5. 比较两个切片
    // 结果为 true。
    assert_eq!(s1, s2);

    // 6. 交叉比较
    // 切片 vs 向量,切片 vs 数组
    // 结果都为 true。
    assert_eq!(&arr[..], vec);
    assert_eq!(&vec[..], arr)
}

在 Rust 中,切片(类型标记为 &[T])是一个动态大小的视图(View) ,它指向一段连续的内存序列。

核心概念如下:

  • 不拥有数据:切片只是“借用”了数据,它不拥有底层数据的所有权。

  • 胖指针 (Fat Pointer) :在底层,切片实际上由两部分组成:

    1. 指针 (ptr) :指向数据的起始位置。
    2. 长度 (len) :切片包含的元素个数。
  • 屏蔽底层差异:无论数据是存放在(如 Array)上,还是(如 Vector)上,一旦变成了切片 &[T],它们看起来就是一模一样的。

形象的比喻:  你可以把 arr(数组)和 vec(向量)看作两本不同的书。

  • arr 是一本只有 5 页的硬皮书(固定大小)。
  • vec 是一本活页夹,可以随时加页(动态大小)。
  • 切片 s1 和 s2 则是“复印件”或“扫描件” 。虽然原件来源不同(一本是硬皮书,一本是活页夹),但只要扫描出来的内容都是 "第1页和第2页",那么这两份扫描件就是一样的。

把切片(Slice)和迭代器(Iterator)称为“孪生兄弟”非常贴切。

为了让你这个“小白”彻底理解,我们用一个**“MP3 播放列表”**的例子来打比方。


1. 极其通俗的类比

想象你有一张长长的歌单(比如包含 100 首歌)。

切片 (Slice): “一张写着歌名的静态纸条”

  • 本质:它是静态的视图。
  • 动作:你截取了歌单里的第 1 首到第 5 首。这就是一个切片。
  • 状态:它就在那里,静静地躺着。它知道“开头在哪里”以及“一共有几首”。
  • 你可以做什么:你可以一眼看到所有这 5 首歌的名字。但它自己不会动,不会自动放歌。

迭代器 (Iterator): “播放器里的进度条”

  • 本质:它是动态的逻辑工具。
  • 动作:你把刚才那个“切片”(那 5 首歌)塞进了播放器。
  • 状态:它有一个**“当前指针”**(或者叫游标)。刚开始指向第 1 首。
  • 你可以做什么:你必须按一下“下一首”(next()),它才会吐出第 1 首歌给你听,然后指针自动跳到第 2 首。如果你不按,它就永远不动。

2. 为什么说是“孪生兄弟”?

因为在 Rust 中,绝大多数迭代器都是由切片变出来的

  1. 你先有了数据(数组或 Vector)。
  2. 你切了一刀,拿到了切片(确定了要处理的数据范围)。
  3. 你对切片喊了一声 .iter(),它就变身成了迭代器(准备好一个一个处理数据)。

技术上的转换过程:

  • 切片拥有数据的所有权或借用权,但它是一整块肉。
  • 迭代器通常会在内部“持有”这个切片(或者指向它的指针),然后通过内部记录位置,一点一点把切片里的肉“喂”给你。

3. 代码实战:从切片到迭代器

看着段代码,我们来“慢动作”回放:

Rust

fn main() {
    // 1. 源数据
    let data = [10, 20, 30, 40, 50];

    // 2. 切片 (Slice)
    // s1 是一个切片,它看到了 [20, 30, 40]
    // 此时 s1 是静态的,它知道自己长为 3,起点在 20
    let s1 = &data[1..4]; 
    println!("切片长这样: {:?}", s1);

    // 3. 迭代器 (Iterator)
    // 重点来了!我们对 s1 调用了 .iter()
    // 此时 iter 是一个“准备好干活的机器人”
    let mut iter = s1.iter();

    // 4. 迭代器开始工作 (调用 next())
    // 第一次按按钮:
    println!("第一口: {:?}", iter.next()); // 输出 Some(20)
    
    // 第二次按按钮:
    println!("第二口: {:?}", iter.next()); // 输出 Some(30)

    // 第三次按按钮:
    println!("第三口: {:?}", iter.next()); // 输出 Some(40)

    // 第四次按按钮:
    println!("第四口: {:?}", iter.next()); // 输出 None (没啦!)
}

这里的核心区别:

  • 切片 s1:一直都在,内容一直是 [20, 30, 40],不会变。
  • 迭代器 iter:它是有状态的(所以必须声明为 mut)。每调用一次 next(),它内部的指针就往后移一格。它会“消耗”自己的进度。

4. 深入一点点:迭代器是如何工作的?

这部分能帮你打通任督二脉。

虽然我们说切片是 (ptr, len),那迭代器长什么样呢? 在 Rust 标准库中,切片的迭代器(Use slice::Iter)内部结构大致逻辑如下(简化版):

Rust

// 伪代码:切片迭代器的心理活动
struct SliceIterator<'a, T> {
    ptr: *const T, // 当前指向哪里
    end: *const T, // 终点在哪里
}

当你对切片 &[1, 2, 3] 调用 .iter() 时:

  1. 迭代器记住了切片的开始位置结束位置

  2. 当你调用 next()

    • 它返回 ptr 指向的值(比如 1)。
    • 它把 ptr 往后挪一位(现在指向 2)。
  3. 直到 ptr == end,它就返回 None

所以:迭代器就是一个不断“蚕食”切片范围的小机器。


5. 懒惰的迭代器 (Lazy Evaluation)

这是小白最容易晕的地方。

切片是现成的,迭代器是懒惰的。

Rust

let vec = vec![1, 2, 3];
let slice = &vec[..];

// 这行代码什么都不会发生!
// 就像你买了个跑步机放在家里,但你如果不上去跑,它就只是个摆设。
let iter = slice.iter().map(|x| x + 1); 

// 只有当你开始“收集”或者“遍历”它时,它才开始动:
let new_vec: Vec<_> = iter.collect(); // 这一步才会真正执行 x + 1

总结

特性切片 (Slice) &[T]迭代器 (Iterator)
比喻菜单服务员
核心能力展示一段连续的数据一个接一个地提供数据
状态静态的(除非你改数据)动态的(有个内部指针在走)
核心方法len()get()is_empty()next()
关系是迭代器的数据源是切片的消费方式

一句话记住:切片是地图,告诉你路全貌;迭代器是导航,一步一步告诉你怎么走。

HashMap,也就是哈希表

如果数据结构的输入和输出能一一对应,那么可以使用列表,如果无法一一对应,那么就需要使用哈希表

二次探查(quadratic probing)和SIMD 查表(SIMD lookup)

第一部分:直觉理解 —— 为什么需要哈希表?

你那句话总结得很对,但我们可以说得更精确一点:

  1. 列表 (Array/Vec):  适用于**“钥匙是连续整数”**的情况。

    • 比喻:这是一个只有编号的储物柜(0号,1号,2号...)。
    • 如果你拿着 0 号牌,你不需要思考,直接走到第 1 个柜子就行。速度极快 (O(1))。
    • 局限:如果你拿着一把钥匙叫 "张三",你没法把“张三”塞进整数编号里。或者你的钥匙是 1000000,你不可能为了存一个东西开一百万个柜子(浪费空间)。
  2. 哈希表 (HashMap):  适用于**“钥匙是任意东西”**的情况。

    • 比喻:这是一个带前台接待的储物柜。
    • 你拿着 "张三" 去存包。你不知道存哪里。
    • 哈希函数 (Hash Function)  就是那个前台接待员。他看一眼 "张三",拿出计算器算了一下,说:“张三啊,你去 5号 柜子”。
    • 下次你来取包,报上 "张三",接待员一算,还是 5号,你就取到了。

核心矛盾:如果 "李四" 来了,接待员一算,发现也是 5号,这就叫哈希冲突 (Collision) 。5号柜子已经满了,李四去哪?

这时候,二次探查就登场了。


第二部分:解决冲突 —— 什么是“二次探查”?

当“5号柜子”被张三占了,李四需要找下一个空位。找空位的方法主要有两种:

1. 线性探查 (Linear Probing) —— 笨办法

  • 规则:5号满了?看看6号。6号满了?看看7号... 一步一步挪(步长为 1)。
  • 缺点:这会导致**“拥堵” (Clustering)**。如果5、6、7都被占了,新来一个想去5号的人,得一路走到8号。数据会像堵车一样连成一大片,查询越来越慢。

2. 二次探查 (Quadratic Probing) —— 聪明的跳跃

  • 定义:并不是一步一步挪,而是大步跳跃

  • 规则

    • 第一次尝试:Hash (比如 5号) -> 满了
    • 第二次尝试:Hash+12 (5 + 1 = 6号)
    • 第三次尝试:Hash+22 (5 + 4 = 9号)
    • 第四次尝试:Hash+32 (5 + 9 = 14号)
    • ...
  • 为什么这么做?

    • 李四如果被5号挤走了,他会迅速跳到很远的地方(9号、14号)。
    • 好处:这就像停车场,如果你发现门口满了,你不会一个个车位看,你会直接把车开到后面空旷的区域。这有效地避免了数据“扎堆”拥堵,保持了查找的高速。

第三部分:极致速度 —— 什么是“SIMD 查表”?

这是现代哈希表(如 Rust 的 HashMap)快到飞起的秘密武器。

1. 背景

传统的查找逻辑是:

“是这个吗?不是。是那个吗?不是。是下一个吗?不是...”

哪怕用了二次探查,你还是一次只看一个柜子。CPU 很不喜欢这种一次做一件事的节奏。

2. SIMD 是什么?

  • 全称:Single Instruction, Multiple Data(单指令流多数据流)。

  • 通俗解释一次动作,并行处理一批数据

  • 比喻

    • 普通查找:老师点名找人,一个一个问:“你是张三吗?” “不是”。“你是张三吗?” “不是”。
    • SIMD 查找:老师拿个大喇叭喊一声,同时盯着16个学生的脸。只要其中有一个人眼神对上了,老师瞬间就锁定了位置。

3. 在哈希表中怎么用?

Rust 的 HashMap (基于 Google 的 SwissTable 设计) 把哈希表分成了两部分:

  1. Control Bytes (元数据区) :存哈希值的缩略图(比如只存哈希值的后7位),每个只有 1 字节大小。
  2. Slots (数据区) :存真正的 Key-Value。

SIMD 查表过程:  当你查找 "张三" 时:

  1. CPU 拿到张三哈希值的缩略图(比如是 0x7F)。

  2. CPU 使用 SIMD 指令,一口气抓取 16 个柜子的缩略图(128位宽的寄存器正好放下 16 个字节)。

  3. 在一个 CPU 周期内,并行比较这 16 个缩略图里有没有 0x7F

    • 如果有,算出它是第几个,直接去取数据。
    • 如果没有,再用二次探查跳到下一组 16 个。

总结:三者的关系

如果我们要设计一个完美的哈希表:

  1. 宏观架构:我们用哈希表而不是列表,是为了解决 Key 不是整数的问题。
  2. 解决冲突:我们用二次探查,是为了防止数据扎堆(Clustering),让数据在内存中分布得更均匀。
  3. 微观加速:我们用SIMD 查表,是为了在寻找空位或寻找目标时,能一次看一群(比如一次看16个位置),而不是一个一个看,从而把 CPU 的性能榨干。

这就是为什么 Rust 的 HashMap 即使在数据量很大的情况下,依然快得惊人的原因。它结合了数学上的策略(二次探查)和硬件上的暴力美学(SIMD)。