C++

之前有刷过黑马的基础c++视频,但是实际中远远不够,所以在这里记录自己c++进阶语法的学习过程,仅供参考
参考文献:
Hello 算法学习网页,本文章的主要学习资料
本书建议的 LeetCode 刷题清单

上一次更新时间:2025-10-29
目前看到的部分



学习内容

在这里插入图片描述

提示:这里可以添加本文要记录的大概内容:


提示:以下是本篇文章正文内容,下面案例可供参考

快速温故STL

  1. 顺序容器:vector

特点:动态数组,连续内存。随机访问 O(1)。尾部插入 O(1)。中间插入/删除 O(n)
常见创建方式:
std::vector<int> v; // 空
std::vector<int> v2 = {1,2,3}; // 初始化列表
std::vector<int> v3(5, 0); // 5个元素,每个0

  1. 顺序容器:deque

特点:双端队列,支持两端高效插入/删除(O(1) 均摊),中间操作 O(n)。随机访问 O(1)(比 vector 常数略差)
常见创建方式:创建类似 vector

  1. 顺序容器:list(双向链表) 与 forward_list(单向链表)

特点:链表,插入/删除在任意位置 O(1)(已知位置,需通过迭代器),随机访问 O(n)。splice 可在 O(1) 内移动节点
常见创建方式:
std::list<int> lst = {1,2,3};

  1. 顺序容器:array

特点:固定大小数组

记忆小技巧:

需要随机访问(索引) → vector 或 array
需要两端快 → deque
需要中间频繁插入删除 → list / forward_list


  1. 关联容器:基于红黑树(RB-tree)–set/multiset

特点:元素唯一(set)或允许重复(multiset)。自动按 < 排序
常见创建方式:
std::set<int> s = {3,1,2};

  1. 关联容器:基于红黑树(RB-tree)–map/multimap

特点:键值对,按 key 排序。operator[] 可用(会插入默认值)
常见创建方式:
std::map<std::string,int> m;

记忆技巧:

有序(ordered)+ 红黑树 → O(log n)


  1. 无序关联容器:基于哈希表–unordered_set/unordered_map/unordered_multiset/unordered_multimap

特点:无序、基于 hash。插入/查找平均 O(1)。可自定义 hash 和相等函数
常见创建方式:
std::unordered_map<std::string,int> um;

记忆技巧:

要 O(1) 查找且不关心顺序 → unordered 系列


一、配置window的c++环境

参考资料:
操作指南-下面视频里面有相关软件的下载链接
操作视频
视频博主的知乎链接
按照上面的配置了自己vescode环境

二、算法

1.目标

  1. 解决问题
  2. 优化解决方法
  1. 时间效率
  2. 空间效率

复杂度分析关注的不是运行时间或占用空间的具体值,而是时间或空间增长的“快慢”

1.1 迭代与递归

1.1.1 迭代:自下而上

for
while

1.1.2 递归:自上而下

1.1.2.1普通递归:

调用栈

递:当函数被调用时,系统会在“调用栈”上为该函数分配新的栈帧
归:当函数完成执行并返回时,对应的栈帧会被从“调用栈”上移除,恢复之前函数的执行环境

可以使用一个显式的栈来模拟调用栈的行为

递归函数每次调用本身的时候,系统会为新开启的函数分配内存,来存储局部变量,调用地址等信息

  1. 函数的上下文数据都被存放在称作“栈帧空间”的内存区域,函数结束才被释放,所以比起迭代更费内存空间
  2. 调用函数产生额外开销,所以比起循环的时间效率更低
1.1.2.2尾递归:

如果函数在返回前的最后一步才进行递归调用,可以使得空间效率和迭代相当
许多编译器或解释器并不支持尾递归优化。例如,Python 默认不支持尾递归优化,因此即使函数是尾递归形式,仍然可能会遇到栈溢出问题

1.1.2.3普通递归vs尾递归:

普通

int recur(int n){
	if (n==1)
		return 1;
	int res =recur(n-1);
	return n+res;
	}

在这里插入图片描述

尾

int tailcur(int n,int res){
	if (n==0)
		return res;
	return tailrecur(int n-1,int res+n);
	}

在这里插入图片描述

1.1.2.4 递归树

斐波那契数列 :每个数字是前两个数字的和

分治思想

int fib(int n){
	if (n==1||n==2)
		return n-1;
	int res = fib(n-1)+fib(n-2);
	return res;
	}

在这里插入图片描述

1.2 复杂度分析

时间复杂度分析本质上是计算“操作数量 T(n)”的渐近上界

1.2.1 常数阶

算法运行时间不随着输入增大而增长

1.2.2 线性阶

法运行时间随着输入增大呈线性增长

只要输入数据大小 足够大,复杂度为“常数阶”的算法一定优于“线性阶”的算法
在这里插入图片描述

1.2.2 渐近上界

在这里插入图片描述

T(n)和f(n)相同的增长级别,仅相差一个常数系数c

1.2.3 计算时间复杂度

1.2.3.1 函数渐近上界-最差时间复杂度

完整统计 T(n)

    int a = 1;  // +1
    a = a + n;  // +1
    //  循环 5 * n+1 次
    for (int i = 0; i < 5 * n + 1; i++) {// +1
        cout << 0 << endl; // +1
    }
    
    //外层循环2n次 内层循环n+1次
    for (int i = 0; i < 2 * n; i++) {// +1
        for (int j = 0; j < n + 1; j++) {// +1
            cout << 0 << endl;// +1
        }
    } }
     // T(n)=2+5n+1+2n(n+1)

由渐进上届可以知道,c可以取任意大小,所以我们可以忽略T(n)的各种系数,常数

  1. 忽略T(n)系数,常数
  2. 循环嵌套时使用乘法:总操作数量等于外层循环和内层循环操作数量之积
void algorithm(int n) {
    int a = 1;  // +0(技巧 1) 
    a = a + n;  // +0(技巧 1)
    // +n(技巧 2)
    for (int i = 0; i < 5 * n + 1; i++) {
        cout << 0 << endl;
    }
    // +n*n(技巧 3)
    for (int i = 0; i < 2 * n; i++) {
        for (int j = 0; j < n + 1; j++) {
            cout << 0 << endl;
        }
    }
}
// 渐近上界=n+n(n)

时间复杂度由最高阶的项来决定:

常数项的时间复杂度是O(1)
常见的时间复杂度排序在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心算法等来解决
例子:在这里插入图片描述
此外注意:输入数据大小需根据输入数据的类型来具体确定

例子:

  1. 指数阶:如细胞分裂 1–>2–>4–>…
  2. 对数阶: 与指数阶相反,对数阶反映了“每轮缩减到一半”的情况 n–>n/2–>n/4–>…

由于每轮缩减到一半,因此循环次数,在这里插入图片描述
ps:在这里插入图片描述

  1. 线性对数阶常出现于嵌套循环中

主流排序算法的时间复杂度通常是这个,如快速排序、归并排序、堆排序

  1. 阶乘阶 :对应数学上的“全排列”问题,如给定
    n个互不重复的元素,求其所有可能的排列方案
int factorialRecur(int n) {
    if (n == 0)
        return 1;
    int count = 0;
    for (int i = 0; i < n; i++) {
        count += factorialRecur(n - 1);
    }
    return count;
}
//每一层调用自己 n 次,下一层的 n 减 1
//T(n)=n×T(n−1)
1.2.3.2 函数渐近下界-最佳时间复杂度

假设输入一个长度为n的数组 nums ,其中 nums 由从1 至n 的数字组成,每个数字只出现一次;但元素顺序是随机打乱的,任务目标是返回元素 的索引

当 nums = [?, ?, …, 1] ,即当末尾元素是1时,需要完整遍历数组,达到最差时间复杂度
当 nums = [1, ?, ?, …] ,即当首个元素为1时,无论数组多长都不需要继续遍历,达到最佳时间复杂度

1.2.4 计算空间复杂度

1.2.4.1 内存空间分类
  1. 输入空间:用于存储算法的输入数据。
  2. 暂存空间:用于存储算法在运行过程中的变量、对象、函数上下文等数据。
    1. 暂存数据:用于保存算法运行过程中的各种常量、变量、对象等
    2. 栈帧空间:用于保存调用函数的上下文数据。系统在每次调用函数时都会在栈顶部创建一个栈帧,函数返回后,栈帧空间会被释放
    3. 指令空间:用于保存编译后的程序指令,在实际统计中通常忽略不计
  3. 输出空间:用于存储算法的输出数据

分析一段程序的空间复杂度时,我们通常统计暂存数据、栈帧空间和输出数据三部分

/* 结构体 */
struct Node {
    int val;
    Node *next;
    Node(int x) : val(x), next(nullptr) {}
};

/* 函数 */
int func() {
    // 执行某些操作...
    return 0;
}

int algorithm(int n) {        // 输入数据
    const int a = 0;          // 暂存数据(常量)
    int b = 0;                // 暂存数据(变量)
    Node* node = new Node(0); // 暂存数据(对象)
    int c = func();           // 栈帧空间(调用函数)
    return a + b + c;         // 输出数据
}

常见的空间复杂度类型:
在这里插入图片描述
在这里插入图片描述

1.2.4.2 最差空间复杂度
  1. 以最差输入数据为准:在n<10之前,空间复杂度为O(1),但初始化数组 nums 时占用O(n),所以最差空间复杂度为O(n)
  2. 以算法运行中的峰值内存为准:程序在执行最后一行之前O(1),初始化数组 nums 时,占用O(n),所以最差空间复杂度为O(n)
void algorithm(int n) {
    int a = 0;               // O(1)
    vector<int> b(10000);    // O(1)
    if (n > 10)
        vector<int> nums(n); // O(n)
}

递归函数中,需要注意统计栈帧空间:

函数loop() 和 递归函数recur() 的时间复杂度都是O(n),但是空间复杂度不同
loop() 在循环中调用了n次 function() ,每轮function() 都返回并释放了栈帧空间
recur() 在运行过程中会同时存在 n个未返回的 recur()

int func() {
    // 执行某些操作
    return 0;
}
/* 循环的空间复杂度为 O(1) */
void loop(int n) {
    for (int i = 0; i < n; i++) {
        func();
    }
}
/* 递归的空间复杂度为 O(n) */
void recur(int n) {
    if (n == 1) return;
    recur(n - 1);
}

常见的空间复杂度类型例子

  1. 常数阶:常见于数量与输入数据大小 n无关的常量、变量、对象
  2. 线性阶:常见于元素数量与n成正比的数组、链表、栈、队列等
vector<ListNode> nodes;// 长度为 n 的列表占用 O(n) 空间
unordered_map<int, string> map;//长度为 n 的哈希表占用 O(n) 空间
vector<int> nums(n);// 长度为 n 的数组占用 O(n) 空间

在这里插入图片描述

  1. 平方阶:平方阶常见于矩阵和图
vector<vector<int>> numMatrix;// 二维列表占用 O(n^2) 空间
  1. 指数阶:指数阶常见于二叉树
/* 指数阶(建立满二叉树) */
TreeNode *buildTree(int n) {
    if (n == 0)
        return nullptr;
    TreeNode *root = new TreeNode(0);
    root->left = buildTree(n - 1);
    root->right = buildTree(n - 1);
    return root;
}
  1. 对数阶:对数阶常见于分治算法
  1. 尾递归的空间复杂度是 O(1)?
    A:理论上,尾递归函数的空间复杂度可以优化到 O(1),但是绝大多数编程语言不支持自动优化尾递归,因此通常认为空间复杂度是O(n)
  2. 实际使用场景,选择牺牲时间还是空间?
    A:实际应用中,大部分情况会选择牺牲空间换时间,空间资源宝贵的场景,也会选择牺牲时间换空间

1.3 数据结构

常见的数据结构包括数组、链表、栈、队列、哈希表、树、堆、图,它们可以从“逻辑结构”和“物理结构”两个维度进行分类

1.3.1数据结构分类

逻辑结构:可分为“线性”和“非线性”两大类

  1. 线性数据结构:数组、链表、栈、队列、哈希表,元素之间是一对一的顺序关系。

  2. 非线性数据结构:树、堆、图、哈希表

  • 可以进一步划分为树形结构和网状结构

树形结构:树、堆、哈希表,元素之间是一对多的关系

网状结构:图,元素之间是多对多的关系

在这里插入图片描述

物理结构:连续与分散

当算法程序运行时,正在处理的数据主要存储在内存中。一个计算机内存条,其中每个黑色方块都包含一块内存空间。我们可以将内存想象成一个巨大的 Excel 表格,其中每个单元格都可以存储一定大小的数据。实际内存的工作机制比较复杂,涉及地址空间、内存管理、缓存机制、虚拟内存和物理内存等概念。

系统通过内存地址来访问目标位置的数据。计算机根据特定规则为表格中的每个单元格分配编号,确保每个内存空间都有唯一的内存地址。有了这些地址,程序便可以访问内存中的数据。

  1. 内存是所有程序的共享资源
  2. 在数据结构与算法的设计中,内存资源是一个重要的考虑因素
  3. 物理结构反映了数据在计算机内存中的存储方式
  4. 两种物理结构在时间效率和空间效率方面呈现出互补的特点

在这里插入图片描述

所有数据结构都是基于数组、链表或二者的组合实现的

  • 基于数组可实现:栈、队列、哈希表、树、堆、图、矩阵、张量(维度>=3的数组)等。
  • 基于链表可实现:栈、队列、哈希表、树、堆、图等。

1.3.2 数据结构与数据类型

基本数据类型提供了数据的“内容类型”,而数据结构提供了数据的“组织方式”,例如以下代码,我们用相同的数据结构(数组)来存储与表示不同的基本数据类型

// 使用多种基本数据类型来初始化数组
int numbers[5];
float decimals[5];
char characters[5];
bool bools[5];

1.3.3 字符编码

  1. 首先需要指出,数字是以“补码”的形式存储在计算机中的。在分析这样做的原因之前,首先给出三者的定义。

原码:我们将数字的二进制表示的最高位视为符号位,其中 0表示正数,1表示负数,其余位表示数字的值。负数的原码不能直接用于运算–>引入了反码,数字零的原码有 -0和+0,两种表示方式–>引入了补码
反码:正数的反码与其原码相同,负数的反码是对其原码除符号位外的所有位取反。
补码:正数的补码与其原码相同,负数的补码是在其反码的基础上加 1

计算机规定这个特殊的补码 1000 0000代表 -128
计算机内部的硬件电路主要是基于加法运算设计的
通过将加法与一些基本逻辑运算结合,计算机能够实现各种其他的数学运算

1.3.4 小结

Q:为什么哈希表同时包含线性数据结构和非线性数据结构?

哈希表底层是数组,而为了解决哈希冲突,我们可能会使用“链式地址”:数组中每个桶指向一个链表,当链表长度超过一定阈值时,又可能被转化为树(通常为红黑树)。哈希表可能同时包含线性数据结构(数组、链表)和非线性数据结构(树)。

Q:基于数组实现的数据结构也称“静态数据结构” 是否有歧义?栈也可以进行出栈和入栈等操作,这些操作都是“动态”的。

栈确实可以实现动态的数据操作,但数据结构仍然是“静态”(长度不可变)的。尽管基于数组的数据结构可以动态地添加或删除元素,但它们的容量是固定的。如果数据量超出了预分配的大小,就需要创建一个新的更大的数组,并将旧数组的内容复制到新数组中。

Q:在构建栈(队列)的时候,未指定它的大小,为什么它们是“静态数据结构”呢?

在高级编程语言中,我们无须人工指定栈(队列)的初始容量,这个工作由类内部自动完成。

1.4 数组与链表

1.4.1数组

1.4.1.1 数组特点
  1. 数组(array)是一种线性数据结构,
  2. 相同类型的元素存储在连续的内存空间中,这意味着计算数组元素的内存地址非常容易,数组中访问元素非常高效,
    在这里插入图片描述
  3. 我们可以在 O(1)时间内随机访问数组中的任意一个元素
  4. 数组元素在内存中是“紧挨着的”,它们之间没有空间再存放任何数据。如果想在数组中间插入一个元素,则需要将该元素之后的所有元素都向后移动一位,之后再把元素赋值给该索引,由于数组的长度是固定的,因此插入一个元素必定会导致数组尾部元素“丢失”,若想删除索引 i处的元素,则需要把索引i 之后的元素都向前移动一位,数组的插入和删除的平均时间复杂度均为O(n),
    在这里插入图片描述在这里插入图片描述

我们可以初始化一个比较长的数组,只用前面一部分,这样在插入数据时,丢失的末尾元素都是“无意义”的,但这样做会造成部分内存空间浪费

总结

  1. ⭐空间效率高:连续内存块,无需额外结构开销
  2. ⭐ 支持随机访问:时间复杂度O(1)
  3. ⭐缓存局部性:当访问数组元素时,计算机不仅会加载它,还会缓存其周围的其他数据,从而借助高速缓存来提升后续操作的执行速度。
  4. ⚠️插入与删除效率低:当数组中元素较多时,插入与删除操作需要移动大量的元素
  5. ⚠️长度不可变:数组在初始化后长度就固定了,扩容数组需要将所有数据复制到新数组,开销很大。
  6. ⚠️空间浪费:如果数组分配的大小超过实际所需,那么多余的空间就被浪费了
1.4.1.2 数组应用
  1. 随机访问:如果我们想随机抽取一些样本:生成一个随机序列,根据索引实现随机抽样。
  2. 排序和搜索:数组是排序和搜索算法最常用的数据结构。快速排序、归并排序、二分查找等都主要在数组上进行。
  3. 查找表:当需要快速查找一个元素或其对应关系时,可以使用数组作为查找表。可以把值作为索引,对应的元素存放在数组中的对应位置。
  4. 机器学习:神经网络中大量使用了向量、矩阵、张量之间的线性代数运算,这些数据都是以数组的形式构建的。数组是神经网络编程中最常使用的数据结构。
  5. 数据结构实现:数组可以用于实现栈、队列、哈希表、堆、图等数据结构。

1.4.2 链表

1.4.2.1 链表特点

·

  1. 链表是一种线性数据结构,其中的每个元素都是一个节点对象,各个节点通过“引用”相连接。引用记录了下一个节点的内存地址,通过它可以从当前节点访问到下一个节点。在这里插入图片描述
  2. 链表的组成单位是节点(node)对象:每个节点都包含两项数据:节点的“值”和指向下一节点的“引用”,因此在相同数据量下,链表比数组占用更多的内存空间

首个节点被称为“头节点”,最后一个节点被称为“尾节点”
尾节点指向的是“空”,C++ 和 Python 中分别被记为 nullptr 和 None 在C++支持指针的语言中,上述“引用”应被替换为“指针”

/* 链表节点结构体 */
struct ListNode {
    int val;         // 节点值
    ListNode *next;  // 指向下一节点的指针
    // 当你创建一个新的节点时,把节点的 val 赋值为 x,同时让指针 next 指向空(nullptr)。在对象构造时直接初始化成员变量,效率高,尤其是对象类型成员
    ListNode(int x) : val(x), next(nullptr) {}// 构造函数 
    }; 
      // 初始化各个节点
    
ListNode* n0 = new ListNode(1);
ListNode* n1 = new ListNode(3);
ListNode* n2 = new ListNode(2);

// 构建节点之间的引用 
n0->next = n1; 
n1->next = n2;
 
  1. 在链表中插入节点非常容易,则只需改变两个节点引用(指针)即可,时间复杂度为O(1),数组插入的时间复杂度是O(n)在这里插入图片描述
/* 在链表的节点 n0 之后插入节点 P */ 
void insert(ListNode *n0, ListNode *P)
{
    ListNode *n1 = n0->next;
    P->next = n1;
    n0->next = P;
 }
  1. 在链表中删除节点也非常方便,只需改变一个节点的引用(指针)即可,尽管在删除操作完成后节点 P 仍然指向 n1 ,但实际上遍历此链表已经无法访问到 P ,这意味着 P 已经不再属于该链表了
    在这里插入图片描述
    在这里插入图片描述
/* 删除链表的节点 n0 之后的首个节点 */ 
void remove(ListNode *n0) {
    if (n0->next == nullptr)
        return;
    // n0 -> P -> n1
    ListNode *P = n0->next;
    ListNode *n1 = P->next;
    n0->next = n1;
    // 释放内存
    delete P; } 
  1. 在链表中访问节点的效率较低,数组
    时间复杂度为O(1)
    ,但是链表需要从头开始逐渐遍历,时间复杂度为O(n)
int find(ListNode *head,int target){
	int index=0;
	while(head!=nullptr)
	{
		if (head->val==target)
			return index;
		head = head->next;
		index++;
	}
	return -1;
}

常见链表类型

  1. 单向链表:单向链表的节点包含值和指向下一节点的引用两项数据。
  2. 环形链表:如果我们令单向链表的尾节点指向头节点(首尾相接),则得到一个环形链表。在环形链表中,任意节点都可以视作头节点。
  3. 双向链表:双向链表记录了两个方向的引用。双向链表的节点定义同时包含指向后继节点(下一个节点)和前驱节点(上一个节点)的引用(指针)。双向链表更具灵活性,可以朝两个方向遍历链表,但相应地也需要占用更多的内存空间。
    在这里插入图片描述

数组 vs 链表

在这里插入图片描述

1.4.2.2 链表应用
  1. 单向链表通常用于实现栈、队列、哈希表和图等数据结构。
  • 栈:插入和删除操作都在链表的一端–先进后出

  • 队列:一端插入(队尾),另一端删除(队头)–先进先出

  • 哈希表:当两个键被哈希到相同的位置时,会发生哈希冲突,使用链式地址把所有冲突的元素都会被放到一个链表中。

  • 图:图可以用“邻接矩阵”或“邻接表”来存储。如邻接表:图的每个顶点都与一个链表相关联,链表中的每个元素都代表与该顶点相连的其他顶点。

  1. 双向链表常用于需要快速查找前一个和后一个元素的场景。
  • 比如在红黑树、B 树中,我们需要访问节点的父节点,这可以通过在节点中保存一个指向父节点的引用来实现,类似于双向链表。

  • 浏览器历史:浏览器需要知道用户访问过的前一个和后一个网页。双向链表的特性使得这种操作变得简单。

  • LRU 算法:在缓存淘汰(LRU)算法中,我们需要快速找到最近最少使用的数据,以及支持快速添加和删除节点。这时候使用双向链表就非常合适。

  1. 环形链表常用于需要周期性操作的场景,比如操作系统的资源调度。
  • 时间片轮转调度算法:在操作系统中,时间片轮转调度算法是一种常见的 CPU 调度算法,它需要对一组进程进行循环。每个进程被赋予一个时间片,当时间片用完时,CPU将切换到下一个进程。这种循环操作可以通过环形链表来实现。

  • 数据缓冲区:在某些数据缓冲区的实现中,也可能会使用环形链表。比如在音频、视频播放器中,数据流可能会被分成多个缓冲块并放入一个环形链表,以便实现无缝播放。

1.4.3 列表

它表示元素的有序集合,支持元素访问、修改、添加、删除和遍历等操作,无须使用者考虑容量限制的问题,列表本质上是数组,因此可以在O(1)时间内访问和更新元素,效率很高;相较于数组,列表可以自由地添加与删除元素。在列表尾部添加元素的时间复杂度为 O(1),但插入和删除元素的效率仍与数组相同,时间复杂度为O(n) ;

  1. 链表天然可以看作一个列表,其支持元素增删查改操作,并且可以灵活动态扩容。
  2. 数组也支持元素增删查改,但由于其长度不可变,因此只能看作一个具有长度限制的列表,可以使用动态数组来实现列表

常见操作:

/* 清空列表 */
nums.clear();

/* 在尾部添加元素 */
nums.push_back(1);
nums.push_back(3);

/* 在中间插入元素 */
nums.insert(nums.begin() + 1, 6);  // 在索引 3 处插入数字 6

/* 删除元素 */
nums.erase(nums.begin() + 1);      // 删除索引 3 处的元素

/* 通过索引遍历列表 */
int count = 0;
for (int i = 0; i < nums.size(); i++) {
    count += nums[i];
}

/* 直接遍历列表元素 */
count = 0;
for (int num : nums) {
    count += num;
}

/* 拼接两个列表 */
vector<int> nums1 = { 6, 8, 7, 10, 9 };
// 将列表 nums1 拼接到 nums 之后
nums.insert(nums.end(), nums1.begin(), nums1.end());

/* 排序列表 */
sort(nums.begin(), nums.end());  // 排序后,列表元素从小到大排列

1.4.4 内存与缓存

硬盘用于长期存储大量数据
内存用于临时存储程序运行中正在处理的数据
缓存则用于存储经常访问的数据和指令
在程序运行时,数据会从硬盘中被读取到内存中,供 CPU 计算使用。缓存可以看作 CPU 的一部分,它通过智能地从内存加载数据,给 CPU 提供高速的数据读取,从而显著提升程序的执行效率,减少对较慢的内存的依赖。
在这里插入图片描述

内存空间利用方面:数组和链表各自具有优势和局限性

  1. 内存是有限的,且同一块内存不能被多个程序共享,尽可能高效地利用空间。数组的空间效率更高。 数组需要一次性分配足够的连续内存空间,这可能导致内存浪费,数组扩容也需要额外的时间和空间成本。
  2. 链表以“节点”为单位进行动态内存分配和回收,提供了更大的灵活性。程序运行时,随着反复申请与释放内存,空闲内存的碎片化程度会越来越高,从而导致内存的利用效率降低,链表的元素是分散存储的,在频繁的插入与删除操作中,更容易导致内存碎片化。

数组和链表对缓存的利用效率是不同的:

占用空间:链表元素比数组元素占用空间更多,导致缓存中容纳的有效数据量更少。
缓存行:链表数据分散在内存各处,而缓存是“按行加载”的,因此加载到无效数据的比例更高。
预取机制:数组比链表的数据访问模式更具“可预测性”,即系统更容易猜出即将被加载的数据。
空间局部性:数组被存储在集中的内存空间中,因此被加载数据附近的数据更有可能即将被访问。
总体而言,数组具有更高的缓存命中率,因此它在操作效率上通常优于链表。这使得在解决算法问题时,基于数组实现的数据结构往往更受欢迎。高缓存效率并不意味着数组在所有情况下都优于链表

1.4.5 小结

问题与答案:

  1. 数组存储在栈上和存储在堆上,对时间效率和空间效率是否有影响?

1.数据操作效率: 存储在栈上和堆上的数组数据操作效率基本一致。
2. 分配和释放效率:栈是一块较小的内存,分配由编译器自动完成;而堆内存相对更大,可以在代码中动态分配,更容易碎片化。因此,堆上的分配和释放操作通常比栈上的慢。
3. 大小限制:栈内存相对较小,堆的大小一般受限于可用内存。因此堆更加适合存储大型数组。
4. 灵活性:栈上的数组的大小需要在编译时确定,而堆上的数组的大小可以在运行时动态确定。

  1. 数组要求相同类型的元素,而在链表中却没有强调相同类型呢?

数组元素通过计算偏移量来获取对应元素位置,则必须是相同类型的
元素内存地址 = 数组内存地址(首元素内存地址) + 元素长度 * 元素索引

  1. 删除节点 P 后,是否需要把 P.next 设为 None 呢?

不修改 P.next 也可以

  1. 在列表末尾添加元素是否时时刻刻都为 O(1)

如果添加元素时超出列表长度,则需要先扩容列表再添加。系统会申请一块新的内存,并将原列表的所有元素搬运过去,这时候时间复杂度就会是 O(n)

  1. “列表的出现极大地提高了数组的实用性,但可能导致部分内存空间浪费”,这里的空间浪费是指额外增加的变量如容量、长度、扩容倍数所占的内存吗?

一方面,列表都会设定一个初始长度,我们不一定需要用这么多;

另一方面,为了防止频繁扩容,扩容一般会乘以一个系数,这样一来,也会出现很多空位,我们通常不能完全填满它们

  1. Python:在 Python 中初始化 n = [1, 2, 3] 后,这 3 个元素的地址是相连的,但是初始化 m = [2, 1, 3] 会发现它们每个元素的 id 并不是连续的,而是分别跟 n 中的相同。这些元素的地址不连续,那么 m 还是数组吗?

Python 中的数字也被包装为对象,列表中存储的不是数字本身,而是对数字的引用。因此,我们会发现两个数组中的相同数字拥有同一个 id ,并且这些数字的内存地址无须连续

  1. C++ STL 里面的 std::list 已经实现了双向链表,但好像一些算法书上不怎么直接使用它,是不是因为有什么局限性呢?

空间开销:由于每个元素需要两个额外的指针(一个用于前一个元素,一个用于后一个元素),所以 std::list 通常比 std::vector 更占用空间。
缓存不友好:由于数据不是连续存放的,因此 std::list 对缓存的利用率较低。一般情况下,std::vector 的性能会更好。
另一方面,必要使用链表的情况主要是二叉树和图。栈和队列往往会使用编程语言提供的 stack 和 queue ,而非链表。

  1. Python:操作 res = [[0]] * n 生成了一个二维列表,其中每一个 [0] 都是独立的吗?

⚠️不是独立的⚠️。此二维列表中,所有的 [0] 实际上是同一个对象的引用。如果我们修改其中一个元素,会发现所有的对应元素都会随之改变。
如果希望二维列表中的每个 [0] 都是独立的,可以使用 res = [[0] for _ in range(n)] 来实现。这种方式的原理是初始化了 n个独立的 [0] 列表对象。

  1. Python:操作 res = [0] * n 生成了一个列表,其中每一个整数 0 都是独立的吗?

在该列表中,所有整数 0 都是同一个对象的引用。这是因为 Python 对小整数(通常是 -5 到 256)采用了缓存池机制,以便最大化对象复用,从而提升性能。

⚠️虽然它们指向同一个对象,但我们仍然可以独立修改列表中的每个元素,这是因为 Python 的整数是“不可变对象”。当我们修改某个元素时,实际上是切换为另一个对象的引用,而不是改变原有对象本身。
⚠️然而,当列表元素是“可变对象”时(例如列表、字典或类实例等),修改某个元素会直接改变该对象本身,所有引用该对象的元素都会产生相同变化。

1.5 栈与队列

1.5.1栈

栈(stack)是一种遵循先入后出逻辑的线性数据结构。后进先出LIFO
在这里插入图片描述

常见操作:

/* 初始化栈 */
stack<int> stack;

/* 元素入栈 */
stack.push(1);

/* 访问栈顶元素 */
int top = stack.top();

/* 元素出栈 */
stack.pop(); // 无返回值

/* 获取栈的长度 */
int size = stack.size();

/* 判断是否为空 */
bool empty = stack.empty();
1.5.1.1 基于链表的实现

使用链表实现栈时,我们可以将链表的头节点视为栈顶,尾节点视为栈底。

  • 基于链表实现的栈可以提供更加稳定的效率表现。
  • 链表节点需要额外存储指针,因此链表节点占用的空间相对较大

入栈:在这里插入图片描述
入栈前:
stackTop → [原栈顶A] → [B] → [C] → nullptr
入栈一个新元素 X 之后执行:
node->next = stackTop;
node(X)->next → [A] → [B] → [C] → nullptr
入栈:
stackTop = node;
stackTop → [X] → [A] → [B] → [C] → nullptr

    /* 入栈 */
    void push(int num) {
    	 //创建一个新的节点 node,其中存放值 num。
        ListNode *node = new ListNode(num);
        node->next = stackTop;
        stackTop = node;
        stkSize++;
    } 

出栈:在这里插入图片描述

  /* 出栈 */
  int pop() {
  		int num = top();
  		ListNode *tmp = stackTop;
       stackTop = stackTop->next;
       delete tmp;
       stkSize--;
       return num;
  } 
1.5.1.2 基于数组的实现

使用数组实现栈时,我们可以将数组的尾部作为栈顶。

  • 数组实现额外支持随机访问,但这已超出了栈的定义范畴,因此一般不会用到
  • 基于数组实现的栈在触发扩容时效率会降低,但由于扩容是低频操作,因此平均效率更高。

入栈:
在这里插入图片描述

 /* 入栈 */
 vector<int> stack;
 void push(int num) {
     stack.push_back(num);
 }

出栈:
在这里插入图片描述

 /* 出栈 */
 int pop() {
     int num = top();
     stack.pop_back();
     return num;
 }

1.5.2队列

队列(queue)是一种遵循先入先出规则的线性数据结构在这里插入图片描述

常见操作:

/* 初始化队列 */
queue<int> queue;

/* 元素入队 */
queue.push(1);

/* 访问队首元素 */
int front = queue.front();

/* 元素出队 */
queue.pop();

/* 获取队列的长度 */
int size = queue.size();

/* 判断队列是否为空 */
bool empty = queue.empty();
1.5.2.1 基于链表的实现

们可以将链表的“头节点”和“尾节点”分别视为“队首”和“队尾”,规定队尾仅可添加节点,队首仅可删除节点。

入队:
在这里插入图片描述

/* 入队 */
 void push(int num) {
    // 在尾节点后添加 num
    ListNode *node = new ListNode(num);
    // 如果队列为空,则令头、尾节点都指向该节点
    if (front == nullptr) {
        front = node;
        rear = node;
    }
    // 如果队列不为空,则将该节点添加到尾节点后
    else {
        rear->next = node;
        rear = node; }
    queSize++; 
    } 

出队:
在这里插入图片描述

 /* 出队 */
 int pop() {
     int num = peek();
     // 删除头节点
    ListNode *tmp = front;
    front = front->next;
     // 释放内存
     delete tmp;
    queSize--;
    return num;
}
1.5.2.2 基于数组的实现

数组中删除首元素的时间复杂度为O(n) ,这会导致出队操作效率较低,

可以使用一个变量 front 指向队首元素的索引,并维护一个变量 size 用于记录队列长度
定义 rear = front + size,这个公式计算出的 rear 指向队尾元素之后的下一个位置
基于此设计,数组中包含元素的有效区间为 [front, rear - 1]
入队和出队操作都只需进行一次操作,时间复杂度均为O(1)

在不断进行入队和出队的过程中,front 和 rear 都在向右移动,当它们到达数组尾部时就无法继续移动了?

为了解决此问题,我们可以将数组视为首尾相接的“环形数组”,我们需要让 front 或 rear 在越过数组尾部时,直接回到数组头部继续遍历。这种周期性规律可以通过“取余操作”来实现
入队:
入队操作:将输入元素赋值给 rear 索引处,并将 size 增加 1
在这里插入图片描述

  /* 入队 */
  void push(int num) {
      if (queSize == queCapacity) {
          cout << "队列已满" << endl;
          return;
      }
      // 计算队尾指针,指向队尾索引 + 1
      // 通过取余操作实现 rear 越过数组尾部后回到头部
      int rear = (front + queSize) % queCapacity;
      // 将 num 添加至队尾
      nums[rear] = num;
     queSize++;
 }

出队:
在这里插入图片描述

 /* 出队 */
 int pop() {
     int num = peek();
    // 队首指针向后移动一位,若越过尾部,则返回到数组头部
     front = (front + 1) % queCapacity;
     queSize--;
     return num;
}

1.5.3 双向队列

允许在头部和尾部执行元素的添加或删除操作
在这里插入图片描述
常见操作:

/* 初始化双向队列 */
deque<int> deque;

/* 元素入队 */
deque.push_back(2);   // 添加至队尾
deque.push_front(3);  // 添加至队首

/* 访问元素 */
int front = deque.front(); // 队首元素
int back = deque.back();   // 队尾元素

/* 元素出队 */
deque.pop_front();  // 队首元素出队
deque.pop_back();   // 队尾元素出队

/* 获取双向队列的长度 */
int size = deque.size();

/* 判断双向队列是否为空 */
bool empty = deque.empty();
1.5.3.1 基于双向链表的实现

我们将双向链表的头节点和尾节点视为双向队列的队首和队尾,同时实现在两端添加和删除节点的功能

入队:
在这里插入图片描述
在这里插入图片描述

    /* 入队操作 */
    void push(int num, bool isFront) {
        DoublyListNode *node = new DoublyListNode(num);
        // 若链表为空,则令 front 和 rear 都指向 node
        if (isEmpty())
            front = rear = node;
        // 队首入队操作
        else if (isFront) {
            // 将 node 添加至链表头部
            front->prev = node;
            node->next = front;
            front = node; // 更新头节点
        // 队尾入队操作
        } else {
            // 将 node 添加至链表尾部
            rear->next = node;
            node->prev = rear;
            rear = node; // 更新尾节点
        }
        queSize++; // 更新队列长度
    }
    /* 队首入队 */
   void pushFirst(int num) {
       push(num, true);
   }
   /* 队尾入队 */
   void pushLast(int num) {
       push(num, false);
   }

出队:
在这里插入图片描述
在这里插入图片描述

    /* 出队操作 */
    int pop(bool isFront) {
        if (isEmpty())
            throw out_of_range("队列为空");
        int val;
        // 队首出队操作
        if (isFront) {
            val = front->val; // 暂存头节点值
            // 删除头节点
            DoublyListNode *fNext = front->next;
            if (fNext != nullptr) {
                fNext->prev = nullptr;
                front->next = nullptr;
            }
            delete front;
            front = fNext; // 更新头节点
        // 队尾出队操作
        } else {
            val = rear->val; // 暂存尾节点值
            // 删除尾节点
            DoublyListNode *rPrev = rear->prev;
            if (rPrev != nullptr) {
                rPrev->next = nullptr;
                rear->prev = nullptr;
            }
            delete rear;
            rear = rPrev; // 更新尾节点
        }
        queSize--; // 更新队列长度
        return val;
    }

    /* 队首出队 */
    int popFirst() {
        return pop(true);
    }

    /* 队尾出队 */
    int popLast() {
        return pop(false);
    }

1.5.3.1 基于数组的实现

我们也可以使用环形数组来实现双向队列

入队:
在这里插入图片描述
在这里插入图片描述

    /* 计算环形数组索引 */
    int index(int i) {
        // 通过取余操作实现数组首尾相连
        // 当 i 越过数组尾部后,回到头部
        // 当 i 越过数组头部后,回到尾部
        return (i + capacity()) % capacity();
    }
    /* 队首入队 */
    void pushFirst(int num) {
        if (queSize == capacity()) {
            cout << "双向队列已满" << endl;
            return;
        }
        // 队首指针向左移动一位
        // 通过取余操作实现 front 越过数组头部后回到尾部
        front = index(front - 1);
        // 将 num 添加至队首
        nums[front] = num;
        queSize++;
    }

    /* 队尾入队 */
    void pushLast(int num) {
        if (queSize == capacity()) {
            cout << "双向队列已满" << endl;
            return;
        }
        // 计算队尾指针,指向队尾索引 + 1
        int rear = index(front + queSize);
        // 将 num 添加至队尾
        nums[rear] = num;
        queSize++;
    } 

出队:
在这里插入图片描述
在这里插入图片描述

    /* 队首出队 */
    int popFirst() {
        int num = peekFirst();
        // 队首指针向后移动一位
        front = index(front + 1);
        queSize--;
        return num;
    }

    /* 队尾出队 */
    int popLast() {
        int num = peekLast();
        queSize--;
        return num;
    } 

1.5.4 小结

  1. 双向队列像是两个栈拼接在了一起,它的用途是什么?

双向队列就像是栈和队列的组合或两个栈拼在了一起。它表现的是栈 + 队列的逻辑,因此可以实现栈与队列的所有应用,并且更加灵活。

  1. 撤销(undo)和反撤销(redo)具体是如何实现的?

使用两个栈,栈 A 用于撤销,栈 B 用于反撤销。

  1. 每当用户执行一个操作,将这个操作压入栈 A ,并清空栈 B 。
  2. 当用户执行“撤销”时,从栈 A 中弹出最近的操作,并将其压入栈 B 。
  3. 当用户执行“反撤销”时,从栈 B 中弹出最近的操作,并将其压入栈 A 。

1.6 哈希表

向哈希表中输入一个键 key ,则可以在 O(1)时间内获取对应的值 value

  • 添加:加在末尾
  • 查询:由于数组(链表)是乱序的,因此需要遍历其中的所有元素
  • 删除:需要先查询到元素,再从数组(链表)中删除
    在这里插入图片描述
    常见操作:
/* 初始化哈希表 */
unordered_map<int, string> map;

/* 添加操作 */
// 在哈希表中添加键值对 (key, value)
map[12836] = "小哈";
map[15937] = "小啰";
/* 查询操作 */
// 向哈希表中输入键 key ,得到值 value
string name = map[15937];

/* 删除操作 */
// 在哈希表中删除键值对 (key, value)
map.erase(12836);

/* 遍历哈希表 */
// 遍历键值对 key->value
for (auto kv: map) {
    cout << kv.first << " -> " << kv.second << endl;
}
// 使用迭代器遍历 key->value
for (auto iter = map.begin(); iter != map.end(); iter++) {
    cout << iter->first << "->" << iter->second << endl;
}

1.6.1 基于数组实现哈希表

在哈希表中,输入空间是所有 key ,输出空间是所有桶(数组索引)。

通过哈希函数得到该 key 对应的键值对在数组中的存储位置步骤

  1. 通过某种哈希算法 hash() 计算得到哈希值。
  2. 将哈希值对桶数量(数组长度)capacity 取模,从而获取该 key 对应的桶(数组索引)index 。

index = hash(key) % capacity

我们将 key 和 value 封装成一个类 Pair ,以表示键值对


/* 键值对 */
struct Pair {
  public:
    int key;
    string val;
    Pair(int key, string val) {
        this->key = key;
        this->val = val;
    }
};

/* 基于数组实现的哈希表 */
class ArrayHashMap {
  private:
    vector<Pair *> buckets;

  public:
    ArrayHashMap() {
        // 初始化数组,包含 100 个桶
        //buckets 是一个 vector(动态数组),里面存的是 Pair* 指针,也就是存储每个键值对的地址。
        //buckets[index] 取出来的东西是一个 指针,类型是 Pair*
        buckets = vector<Pair *>(100);
    }

    ~ArrayHashMap() {
        // 释放内存
        //对于每个指针 bucket,如果它指向了 Pair 对象,需要 delete 释放内存
        for (const auto &bucket : buckets) {
            delete bucket;
        }
        //最后 buckets.clear() 清空整个数组
        buckets.clear();
    }

    /* 哈希函数 */
    int hashFunc(int key) {
        int index = key % 100;
        return index;
    }

    /* 查询操作 */
    string get(int key) {
        int index = hashFunc(key);
        Pair *pair = buckets[index];
        if (pair == nullptr)
            return "";
        return pair->val;
    }

    /* 添加操作 */
    void put(int key, string val) {
        Pair *pair = new Pair(key, val);
        int index = hashFunc(key);
        buckets[index] = pair;
    }

    /* 删除操作 */
    void remove(int key) {
        int index = hashFunc(key);
        // 释放内存并置为 nullptr
        //之前用 new Pair(key, val) 动态创建了对象
        //delete 只是释放了内存,但指针本身还存在,指向一个已经被释放的内存区域
        delete buckets[index];
        buckets[index] = nullptr;
    }

    /* 获取所有键值对 */
    vector<Pair *> pairSet() {
        vector<Pair *> pairSet;
        for (Pair *pair : buckets) {
            if (pair != nullptr) {
                pairSet.push_back(pair);
            }
        }
        return pairSet;
    }

    /* 获取所有键 */
    vector<int> keySet() {
        vector<int> keySet;
        for (Pair *pair : buckets) {
            if (pair != nullptr) {
                keySet.push_back(pair->key);
            }
        }
        return keySet;
    }

    /* 获取所有值 */
    vector<string> valueSet() {
        vector<string> valueSet;
        for (Pair *pair : buckets) {
            if (pair != nullptr) {
                valueSet.push_back(pair->val);
            }
        }
        return valueSet;
    }

    /* 打印哈希表 */
    void print() {
        for (Pair *kv : pairSet()) {
            cout << kv->key << " -> " << kv->val << endl;
        }
    }
};


注意

  1. 哈希函数的作用是将所有 key 构成的输入空间映射到数组所有索引构成的输出空间,而输入空间往往远大于输出空间。因此,理论上一定存在“多个输入对应相同输出”的情况。

  2. 容易想到,哈希表容量 越大,多个 key 被分配到同一个桶中的概率就越低,冲突就越少。因此,我们可以通过扩容哈希表来减少哈希冲突

  3. 类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时;并且由于哈希表容量 capacity 改变,我们需要通过哈希函数来重新计算所有键值对的存储位置,这进一步增加了扩容过程的计算开销。为此,编程语言通常会预留足够大的哈希表容量,防止频繁扩容。

/* 链式地址哈希表 */
class HashMapChaining {
  private:
    int size;                       // 键值对数量
    int capacity;                   // 哈希表容量
    double loadThres;               // 触发扩容的负载因子阈值
    int extendRatio;                // 扩容倍数
    vector<vector<Pair *>> buckets; // 桶数组

  public:
    /* 构造方法 */
    HashMapChaining() : size(0), capacity(4), loadThres(2.0 / 3.0), extendRatio(2) {
        buckets.resize(capacity);
    }

    /* 析构方法 */
    ~HashMapChaining() {
        for (auto &bucket : buckets) {
            for (Pair *pair : bucket) {
                // 释放内存
                delete pair;
            }
        }
    }

    /* 哈希函数 */
    int hashFunc(int key) {
        return key % capacity;
    }

    /* 负载因子 */
    double loadFactor() {
        return (double)size / (double)capacity;
    }

    /* 查询操作 */
    string get(int key) {
        int index = hashFunc(key);
        // 遍历桶,若找到 key ,则返回对应 val
        for (Pair *pair : buckets[index]) {
            if (pair->key == key) {
                return pair->val;
            }
        }
        // 若未找到 key ,则返回空字符串
        return "";
    }

    /* 添加操作 */
    void put(int key, string val) {
        // 当负载因子超过阈值时,执行扩容
        if (loadFactor() > loadThres) {
            extend();
        }
        int index = hashFunc(key);
        // 遍历桶,若遇到指定 key ,则更新对应 val 并返回
        //key 已存在 → 找到原节点,直接 用新的 val 覆盖原来的 val,不新增节点。
        for (Pair *pair : buckets[index]) {
            if (pair->key == key) {
                pair->val = val;
                return;
            }
        }
        // 若无该 key ,则将键值对添加至尾部
        buckets[index].push_back(new Pair(key, val));
        size++;
    }

    /* 删除操作 */
    void remove(int key) {
        int index = hashFunc(key);
        auto &bucket = buckets[index];
        // 遍历桶,从中删除键值对
        for (int i = 0; i < bucket.size(); i++) {
            if (bucket[i]->key == key) {
                // ① 先保存指针
                //保存指针:因为一会儿要 delete,不能直接用 bucket[i],否则 erase 会把它移除,指针就找不到了。
                Pair *tmp = bucket[i];
                // ② 从 vector 中删除元素
                bucket.erase(bucket.begin() + i); // 从中删除键值对
                // ③ 释放堆上的 Pair 对象
                delete tmp;                       // 释放内存
                size--;
                return;
            }
        }
    }

    /* 扩容哈希表 */
    void extend() {
        // 暂存原哈希表
        vector<vector<Pair *>> bucketsTmp = buckets;
        // 初始化扩容后的新哈希表
        capacity *= extendRatio;
        buckets.clear();
        buckets.resize(capacity);
        size = 0;
        // 将键值对从原哈希表搬运至新哈希表
        for (auto &bucket : bucketsTmp) {
            for (Pair *pair : bucket) {
                put(pair->key, pair->val);
                // 释放内存
                delete pair;
            }
        }
    }

    /* 打印哈希表 */
    void print() {
        for (auto &bucket : buckets) {
            cout << "[";
            for (Pair *pair : bucket) {
                cout << pair->key << " -> " << pair->val << ", ";
            }
            cout << "]\n";
        }
    }
};


1.6.2 哈希冲突

通常情况下哈希函数的输入空间远大于输出空间,进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低

改良哈希表数据结构,使得哈希表可以在出现哈希冲突时正常工作。

仅在必要时,即当哈希冲突比较严重时,才执行扩容操作。

1.6.2.1 哈希冲突-链式地址

原始哈希表中,每个桶仅能存储一个键值对。链式地址(separate chaining)将单个元素转换为链表,将键值对作为链表节点,将所有发生冲突的键值对都存储在同一链表中

在这里插入图片描述

  • 查询元素:输入 key ,经过哈希函数得到桶索引,即可访问链表头节点,然后遍历链表并对比 key 以查找目标键值对。

  • 添加元素:首先通过哈希函数访问链表头节点,然后将节点(键值对)添加到链表中。

  • 删除元素:根据哈希函数的结果访问链表头部,接着遍历链表以查找目标节点并将其删除。

  • 局限性

    • 占用空间增大:链表包含节点指针,它相比数组更加耗费内存空间。
    • 查询效率降低:因为需要线性遍历链表来查找对应元素

当链表很长时,查询效率O(n)很差。此时可以将链表转换为“AVL 树”或“红黑树”,从而将查询操作的时间复杂度优化至O(logn)

1.6.2.1 哈希冲突-开放寻址

开放寻址(open addressing)不引入额外的数据结构,而是通过“多次探测”来处理哈希冲突,探测方式主要包括线性探测、平方探测和多次哈希等。

  • 插入元素:通过哈希函数计算桶索引,若发现桶内已有元素,则从冲突位置向后线性遍历(步长通常为1),直至找到空桶,将元素插入其中。

  • 查找元素:若发现哈希冲突,则使用相同步长向后进行线性遍历,直到找到对应元素,返回 value 即可;如果遇到空桶,说明目标元素不在哈希表中,返回 None 。

在这里插入图片描述

  • 线性探测容易产生“聚集现象”。具体来说,数组中连续被占用的位置越长,这些连续位置发生哈希冲突的可能性越大,从而进一步促使该位置的聚堆生长,形成恶性循环,最终导致增删查改操作效率劣化。
  • 我们不能在开放寻址哈希表中直接删除元素。这是因为删除元素会在数组内产生一个空桶 None ,而当查询元素时,线性探测到该空桶就会返回,因此在该空桶之下的元素都无法再被访问到,程序可能误判这些元素不存在
    • 懒删除机制:它不直接从哈希表中移除元素,而是利用一个常量 TOMBSTONE 来标记这个桶
    • 懒删除可能会加速哈希表的性能退化。这是因为每次删除操作都会产生一个删除标记,随着 TOMBSTONE 的增加,搜索时间也会增加,因为线性探测可能需要跳过多个 TOMBSTONE 才能找到目标元素
    • 记录遇到的首个 TOMBSTONE 的索引,并将搜索到的目标元素与该 TOMBSTONE 交换位置,每次查询或添加元素时,元素会被移动至距离理想位置(探测起始点)更近的桶
    • 为了更加充分地使用哈希表的空间,我们将哈希表看作一个“环形数组”,当越过数组尾部时,回到头部继续遍历
/* 开放寻址哈希表 */
class HashMapOpenAddressing {
  private:
    int size;                             // 键值对数量
    int capacity = 4;                     // 哈希表容量
    const double loadThres = 2.0 / 3.0;     // 触发扩容的负载因子阈值
    const int extendRatio = 2;            // 扩容倍数
    vector<Pair *> buckets;               // 桶数组
    Pair *TOMBSTONE = new Pair(-1, "-1"); // 删除标记

  public:
    /* 构造方法 */
    HashMapOpenAddressing() : size(0), buckets(capacity, nullptr) {
    }

    /* 析构方法 */
    ~HashMapOpenAddressing() {
        for (Pair *pair : buckets) {
            if (pair != nullptr && pair != TOMBSTONE) {
                delete pair;
            }
        }
        delete TOMBSTONE;
    }

    /* 哈希函数 */
    int hashFunc(int key) {
        return key % capacity;
    }

    /* 负载因子 */
    double loadFactor() {
        return (double)size / capacity;
    }

    /* 搜索 key 对应的桶索引 */
    int findBucket(int key) {
        int index = hashFunc(key);
        int firstTombstone = -1;
        // 线性探测,当遇到空桶时跳出
        while (buckets[index] != nullptr) {
            // 若遇到 key ,返回对应的桶索引
            if (buckets[index]->key == key) {
                // 若之前遇到了删除标记,则将键值对移动至该索引处
                if (firstTombstone != -1) {
                    buckets[firstTombstone] = buckets[index];
                    buckets[index] = TOMBSTONE;
                    return firstTombstone; // 返回移动后的桶索引
                }
                return index; // 返回桶索引
            }
            // 记录遇到的首个删除标记
            if (firstTombstone == -1 && buckets[index] == TOMBSTONE) {
                firstTombstone = index;
            }
            // 计算桶索引,越过尾部则返回头部
            index = (index + 1) % capacity;
        }
        // 若 key 不存在,则返回添加点的索引
        return firstTombstone == -1 ? index : firstTombstone;
    }

    /* 查询操作 */
    string get(int key) {
        // 搜索 key 对应的桶索引
        int index = findBucket(key);
        // 若找到键值对,则返回对应 val
        if (buckets[index] != nullptr && buckets[index] != TOMBSTONE) {
            return buckets[index]->val;
        }
        // 若键值对不存在,则返回空字符串
        return "";
    }

    /* 添加操作 */
    void put(int key, string val) {
        // 当负载因子超过阈值时,执行扩容
        if (loadFactor() > loadThres) {
            extend();
        }
        // 搜索 key 对应的桶索引
        int index = findBucket(key);
        // 若找到键值对,则覆盖 val 并返回
        if (buckets[index] != nullptr && buckets[index] != TOMBSTONE) {
            buckets[index]->val = val;
            return;
        }
        // 若键值对不存在,则添加该键值对
        buckets[index] = new Pair(key, val);
        size++;
    }

    /* 删除操作 */
    void remove(int key) {
        // 搜索 key 对应的桶索引
        int index = findBucket(key);
        // 若找到键值对,则用删除标记覆盖它
        if (buckets[index] != nullptr && buckets[index] != TOMBSTONE) {
            delete buckets[index];
            buckets[index] = TOMBSTONE;
            size--;
        }
    }

    /* 扩容哈希表 */
    void extend() {
        // 暂存原哈希表
        vector<Pair *> bucketsTmp = buckets;
        // 初始化扩容后的新哈希表
        capacity *= extendRatio;
        buckets = vector<Pair *>(capacity, nullptr);
        size = 0;
        // 将键值对从原哈希表搬运至新哈希表
        for (Pair *pair : bucketsTmp) {
            if (pair != nullptr && pair != TOMBSTONE) {
                put(pair->key, pair->val);
                delete pair;
            }
        }
    }

    /* 打印哈希表 */
    void print() {
        for (Pair *pair : buckets) {
            if (pair == nullptr) {
                cout << "nullptr" << endl;
            } else if (pair == TOMBSTONE) {
                cout << "TOMBSTONE" << endl;
            } else {
                cout << pair->key << " -> " << pair->val << endl;
            }
        }
    }
};


1.6.2.2 哈希冲突- 平方探测

当发生冲突时,平方探测不是简单地跳过一个固定的步数,而是跳过“探测次数的平方”的步数

1.6.2.3 哈希冲突- 多次哈希

若哈希函数 出现冲突,则尝试其他哈希函数以此类推,直到找到空位后插入元素。

Python 采用开放寻址。字典 dict 使用伪随机数进行探测


1.6 哈希表

总结

提示:这里对文章进行总结:

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐