数据结构与算法
文章之中的内容主要参考:
https://www.hello-algo.com/chapter_array_and_linkedlist/array/
数组
「数组 array」:线性数据结构,将相同类型的元素储存在连续的内存空间。
我们将元素在数组中的位置称为该元素的「索引 index」
数组常用操作
- 初始化数组
// 存储在栈上
int arr[5];//无初始值
int nums[5] = { 1, 3, 2, 5, 4 };//有初始值
// 存储在堆上(需要手动释放空间)
int* arr1 = new int[5];
int* nums1 = new int[5] { 1, 3, 2, 5, 4 };
- 访问元素
- 通过索引访问。
- 索引本质上是内存地址的偏移量
- O(1)时间随机访问数组任意一个元素
- 插入元素与删除元素
- 两个都是要通过将元素逐一移动或者逐一覆盖来进行
- 可能会造成一部分的丢失,要保证不丢失需要更大的内存空间,又会浪费空间
- 时间复杂度O(n)
- 遍历数组
- 分为索引遍历和直接遍历
- 查找元素
- 遍历对比匹配
- 数组是线性数据结构,“线性查找”
- 扩容数组
- 数组长度一般是不可变的
- 扩容的本质:建立一个更大的数组将原先的数组复制过来,O(n),耗时
链表
「链表 linked list」是一种线性数据结构,每个元素都是一个对象节点,各个节点通过“指针”相连接
- 指针记录了下一个节点的内存地址,通过它可以从当前节点访问到下一个节点
- 链表的设计使得各个节点可以分散存储在内存的各处,它们的内存地址无需连续。
- 每个节点包含两项数据:节点“值”和指向下一个节点的“指针”。
- 首个节点:“头节点”
- 最后一个:“尾节点”,指向空
nullptr
- 相同数据下,链表比数组占用更多的内存空间
链表常用操作
- 初始化链表
- 第一:初始化各个节点对象
- 第二:构建节点之间的引用关系
/* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */
// 初始化各个节点
ListNode* n0 = new ListNode(1);
ListNode* n1 = new ListNode(3);
ListNode* n2 = new ListNode(2);
ListNode* n3 = new ListNode(5);
ListNode* n4 = new ListNode(4);
// 构建节点之间的引用
n0->next = n1;
n1->next = n2;
n2->next = n3;
n3->next = n4;
- 通过引用指向
next
一次访问所有节点 - 我们通常将头节点当作链表的代称,如以上代码中的链表可记作链表
n0
- 插入节点
- 只需要改变两节点引用(指针)即可
- 时间复杂度O(1)
- 删除节点
- 同样只需改变一个节点的引用(指针)即可
- 访问节点
- 要从头出发,遍历链表
- 时间复杂度O(n)
- 查找节点
- 遍历链表
- 链表类型
- 单向链表:包含节点值和下一个节点的引用(指针)
- 环形链表:在单向链表的基础上,让尾链表指向头节点
- 双向链表:记录两个方向,后记节点和前驱节点
列表(vector)
- 「列表 list」是一个抽象的数据结构概念,它表示元素的有序集合,支持元素访问、修改、添加、删除和遍历等操作,无需考虑容量的限制问题。
- 列表可以基于链表或者数组实现。
- 许多编程语言中的标准库提供的列表是基于动态列表实现的。(长度可变)长度不可变的性质会导致列表的实用性降低
列表常用操作
- 初始化列表
/* 初始化列表 */
// 需注意,C++ 中 vector 即是本文描述的 nums
// 无初始值
vector<int> nums1;
// 有初始值
vector<int> nums = { 1, 3, 2, 5, 4 };
- 访问元素
- 索引访问
/* 访问元素 */
int num = nums[1]; // 访问索引 1 处的元素
/* 更新元素 */
nums[1] = 0; // 将索引 1 处的元素更新为 0
- 时间复杂度O(1)
- 插入与删除元素
- 尾部添加元素的时间复杂度O(1)
- 插入删除元素的时间复杂度O(n)
/* 清空列表 */
nums.clear();
/* 在尾部添加元素 */
nums.push_back(1);
nums.push_back(3);
nums.push_back(2);
nums.push_back(5);
nums.push_back(4);
/* 在中间插入元素 */
nums.insert(nums.begin() + 3, 6); // 在索引 3 处插入数字 6
/* 删除元素 */
nums.erase(nums.begin() + 3); // 删除索引 3 处的元素
- 遍历列表
- 可以根据索引遍历
- 也可以直接遍历元素
- 拼接列表
/* 拼接两个列表 */
vector<int> nums1 = { 6, 8, 7, 10, 9 };
// 将列表 nums1 拼接到 nums 之后
nums.insert(nums.end(), nums1.begin(), nums1.end());
- 排序列表
/* 排序列表 */
sort(nums.begin(), nums.end()); // 排序后,列表元素从小到大排列
栈
- 「栈 stack」是一种遵循先入后出逻辑的线性数据结构。
(叠猫猫) - 我们把堆叠元素的顶部称为“栈顶”,底部称为“栈底”
- 将把元素添加到栈顶的才做叫作“入栈”,删除栈顶元素的操作叫作“出栈”
栈的常规操作
push()
元素入栈(添加至栈顶),O(1)pop()
栈顶元素出栈,O(1)peek()
访问栈顶元素,O(1)
/* 初始化栈 */
stack<int> stack;
/* 元素入栈 */
stack.push(1);
stack.push(3);
stack.push(2);
stack.push(5);
stack.push(4);
/* 访问栈顶元素 */
int top = stack.top();
/* 元素出栈 */
stack.pop(); // 无返回值
/* 获取栈的长度 */
int size = stack.size();
/* 判断是否为空 */
bool empty = stack.empty();
栈的实现
- 栈遵循先入后出的原则,只能对栈顶进行添加或删除元素
- 数组和链表都可以在在任意位置添加和删出元素,栈可以视为一种受限制的数组或者链表
- 可以屏蔽数组或者链表部分无关操作,使其对外表现逻辑符合栈的特性
基于链表的实现
- 将头节点视为栈顶,尾节点视为栈底
- 入栈:在将元素插入链表头部(头插法)
- 出栈:将头节点删除
基于数组的实现
- 将数组为不作为栈顶
- 入栈和出栈操作:在数组尾部添加元素或者删除元素
栈的应用
- 浏览器的前进与后退,软件中的撤销与恢复
- 程序内存管理
队列
- 「队列 queue」是一种遵循先入先出规则的线性数据结构。
(猫猫排队) - 新来的人不断加入队列尾部,而位于队列头部的人逐个离开
- 队首:队列的头部
- 队尾:队列的尾部
- 入队:把元素加入队尾的操作
- 出队:删除队首元素的操作
队列常用操作
push()
元素入队,即将元素添加至队尾,时间复杂度,O(1)pop()
队首元素出队,O(1)peek()
访问队首元素,O(1)
/* 初始化队列 */
queue<int> queue;
/* 元素入队 */
queue.push(1);
queue.push(3);
queue.push(2);
queue.push(5);
queue.push(4);
/* 访问队首元素 */
int front = queue.front();
/* 元素出队 */
queue.pop();
/* 获取队列的长度 */
int size = queue.size();
/* 判断队列是否为空 */
bool empty = queue.empty();
队列实现
- 基于链表的实现
- 将头节点和尾节点视为队首和队尾
- 队尾仅可添加节点,队首仅可删除节点
- 基于数组的实现
- 由于数组的覆盖机制,删除首元素的时间复杂度为O(n)
- 以使用一个变量 front 指向队首元素的索引,并维护一个变量 size 用于记录队列长度。定义 rear = front + size ,这个公式计算出的 rear 指向队尾元素之后的下一个位置。
- 数组中包含元素的有效区间为 [front, rear - 1]
- 入队操作:将输入元素赋值给 rear 索引处,并将 size 增加 1 。
- 出队操作:只需将 front 增加 1 ,并将 size 减少 1 。
- 在不断进行入队和出队的过程中,front 和 rear 都在向右移动,当它们到达数组尾部时就无法继续移动了。为了解决此问题,我们可以将数组视为首尾相接的“环形数组”。
- 对于环形数组,我们需要让 front 或 rear 在越过数组尾部时,直接回到数组头部继续遍历。这种周期性规律可以通过“取余操作”来实现
队列的应用
- 淘宝订单
- 各类代办事项
双向队列
- 「双向队列 double-ended queue」提供了更高的灵活性,允许在头部和尾部执行元素的添加或删除操作。
双向队列常用操作
push_first()
将元素添加至队尾,O(1)push_last()
将元素添加至队尾,O(1)pop_first()
删除队首元素,O(1)pop_last()
删除队尾元素,O(1)peek_first()
访问队首元素,O(1)peek_last()
访问队尾元素,O(1)
/* 初始化双向队列 */
deque<int> deque;
/* 元素入队 */
deque.push_back(2); // 添加至队尾
deque.push_back(5);
deque.push_back(4);
deque.push_front(3); // 添加至队首
deque.push_front(1);
/* 访问元素 */
int front = deque.front(); // 队首元素
int back = deque.back(); // 队尾元素
/* 元素出队 */
deque.pop_front(); // 队首元素出队
deque.pop_back(); // 队尾元素出队
/* 获取双向队列的长度 */
int size = deque.size();
/* 判断双向队列是否为空 */
bool empty = deque.empty();
双向队列实现
双向队列的实现与队列类似,可以选择链表或数组作为底层数据结构。
- 基于双向链表的实现
- 将双向链表的头节点和尾节点视为双向队列的队首和队尾,同时实现在两端添加和删除节点的功能。
- 基于数组的实现
- 与基于数组实现队列类似,我们也可以使用环形数组来实现双向队列。
- 入队操作:将输入元素赋值给 rear 索引处,并将 size 增加 1 。将front自减1,将元素添加至front处,将size自增1
- 出队操作:只需将 front 增加 1 ,并将 size 减少 1 。将size自减1实现队尾删除
哈希表
「哈希表 hash table」,又称「散列表」,它通过建立键 key 与值 value 之间的映射,实现高效的元素查询。
eg:输入学号key,输出姓名value
链表 | 数组 | 哈希表 | |
---|---|---|---|
查找元素 | O(n) | O(n) | O(1) |
添加元素 | O(1) | O(1) | O(1) |
删除元素 | O(n) | O(n) | O(1) |
哈希表常用操作
初始化,查询操作,添加键值对,删除键值对
/* 初始化哈希表 */
unordered_map<int, string> map;
/* 添加操作 */
// 在哈希表中添加键值对 (key, value)
map[12836] = "小哈";
map[15937] = "小啰";
map[16750] = "小算";
map[13276] = "小法";
map[10583] = "小鸭";
/* 查询操作 */
// 向哈希表中输入键 key ,得到值 value
string name = map[15937];
/* 删除操作 */
// 在哈希表中删除键值对 (key, value)
map.erase(10583);
哈希表常用的三种遍历方式:遍历键值对,遍历键和遍历值
/* 遍历哈希表 */
// 遍历键值对 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;
}
哈希表简单实现
- 运用数组来实现
- 将数组中的每个空位称为「桶 bucket」,每个桶可存储一个键值对
- 查询操作就是找到 key 对应的桶,并在桶中获取 value 。
- 输入一个key
- 通过哈希函数
index = hash(key) % capacity//capacity是数组长度
将一个较大的输入空间(key)映射到一个较小的输出空间(键值对在数组的存储位置) - 输入空间远大于输出空间->导致哈希冲突
哈希冲突与扩容
- 哈希冲突:有时可能存在多个key对应一个输出的情况
- 解决办法:扩容即改变capacity的大小
哈希冲突的解决办法
- 改良哈希表的数据结构
链式地址,开放寻址 - 哈希冲突较严重时才进行扩容
链式地址
链式地址 separate chaining」将单个元素转换为链表,将键值对作为链表节点,将所有发生冲突的键值对都存储在同一链表中。
- 查询元素:输入 key ,经过哈希函数得到桶索引,即可访问链表头节点,然后遍历链表并对比 key 以查找目标键值对。
- 添加元素:首先通过哈希函数访问链表头节点,然后将节点(键值对)添加到链表中。
- 删除元素:根据哈希函数的结果访问链表头部,接着遍历链表以查找目标节点并将其删除。
- 链式地址存在以下局限性。
- 占用空间增大:链表包含节点指针,它相比数组更加耗费内存空间。
- 查询效率降低:因为需要线性遍历链表来查找对应元素。
当链表很长时,查询效率很差O(n),此时可以将链表转化为"AVL树"或者"红黑树",将时间复杂度优化至O(logn)
开放寻址
「开放寻址 open addressing」不引入额外的数据结构,而是通过“多次探测”来处理哈希冲突,探测方式主要包括线性探测、平方探测和多次哈希等。
线性探测
- 采用固定步长来进行探索
- 插入元素:通过哈希函数计算桶索引,若发现桶内已有元素,则从冲突位置向后线性遍历(步长通常为1),直至找到空桶,将元素插入其中。
有就直接下一个 - 查找元素:若发现哈希冲突,则使用相同步长向后进行线性遍历,直到找到对应元素,返回 value 即可;如果遇到空桶,说明目标元素不在哈希表中,返回 None
- 删除元素: 不能真的删除元素,不然会形成一个
None
,当查询元素时,遇到这个就会返回,剩下的元素就无法被访问到了.采用「懒删除 lazy deletion」机制:它不直接从哈希表中移除元素,而是利用一个常量 TOMBSTONE 来标记这个桶。在该机制下,None 和 TOMBSTONE 都代表空桶,都可以放置键值对。但不同的是,线性探测到 TOMBSTONE 时应该继续遍历,因为其之下可能还存在键值对。 - 缺点是:标记的次数越多,需要跳过的越多,搜索时间也会增加,删除可能会加速哈希表的性能退化
- 解决办法 :考虑在线性探测中记录遇到的首个 TOMBSTONE 的索引,并将搜索到的目标元素与该 TOMBSTONE 交换位置。这样做的好处是当每次查询或添加元素时,元素会被移动至距离理想位置(探测起始点)更近的桶,从而优化查询效率。
- 线性探测容易产生“聚集现象”。具体来说,数组中连续被占用的位置越长,这些连续位置发生哈希冲突的可能性越大,从而进一步促使该位置的聚堆生长,形成恶性循环,最终导致增删查改操作效率劣化。
平方探测
- 当发生冲突时,平方探测不是简单地跳过一个固定的步数,而是跳过“探测次数的平方”的步数,即1,4,9,...步。
优势
- 试图缓解线性探测的聚焦效应
- 会跳过更大的距离来寻找空位,有助于数据分布得更加均匀
缺点 - 仍然存在聚集现象,某些位置比其他位置更容易被占用
- 由于平方的增长,平方探测探测可能不会探测整个和哈希表,有些数据可能无法访问到
多次哈希
- 多次哈希方法使用多个哈希函数f1(x),f2(x),f3(x),...进行探测
- 插入元素:若哈希函数f1(x)出现冲突,则尝试f2(x),以此类推,直到找到空位后插入元素。
- 查找元素:在相同的哈希函数顺序下进行查找,直到找到目标元素时返回;若遇到空位或已尝试所有哈希函数,说明哈希表中不存在该元素,则返回 None
所有的开放寻址哈希表都存在:"不能直接删除元素"的问题
哈希算法
- 键值对的分布情况是由哈希函数决定.
index = hash(key) % capacity
- 当哈希表容量 capacity 固定时,哈希算法 hash() 决定了输出值,进而决定了键值对在哈希表中的分布情况。为了降低哈希冲突的发生概率,我们应当将注意力集中在哈希算法 hash() 的设计上。
哈希算法的目的
- 准确性:相同的输入相同的输出
- 效率高:计算过程快
- 均匀分布:键值对均匀分布,越均匀,冲突越低
哈希算法的应用
- 密码储存:储存密码储存的是哈希值,输入密码时计算获得哈希值,进行对比匹配成功则密码正确
- 为了防止从哈希值推导出原始密码等逆向工程,哈希算法需要更高等级的安全性
单向性:无法通过哈希值反推出关于输入数据的任何信息
抗碰撞性:很难找到相同的两个不同的输入,使得哈希值相同
雪崩效应:输入的微小变化应当导致输出显著且不可预测的变化
- 数据完整性检验:发送方计算数据的哈希值,接收方接收到后也计算数据的哈希值,两个数值进行对比,匹配则完整
哈希算法的设计
- 加法哈希:对输入的每个字符的 ASCII 码进行相加,将得到的总和作为哈希值。
- 乘法哈希:利用乘法的不相关性,每轮乘以一个常数,将各个字符的 ASCII 码累积到哈希值中。
- 异或哈希:将输入数据的每个元素通过异或操作累积到一个哈希值中。
- 旋转哈希:将每个字符的 ASCII 码累积到一个哈希值中,每次累积之前都会对哈希值进行旋转操作
!!!使用大质数作为模数,可以最大化地保证哈希值的均匀分布。
!!!只有不可变对象才可作为哈希表的 key
二叉树
- 「二叉树 binary tree」是一种非线性数据结构,代表“祖先”与“后代”之间的派生关系,体现了“一分为二”的分治逻辑。
- 与链表类似,二叉树的基本单元是节点,每个节点包含值、左子节点引用和右子节点引用。
/* 二叉树节点结构体 */
struct TreeNode {
int val; // 节点值
TreeNode *left; // 左子节点指针
TreeNode *right; // 右子节点指针
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};
- 二叉树中,除叶节点外,其他所有节点都包含子节点和非空子树
二叉树常见术语
- 「根节点 root node」:位于二叉树顶层的节点,没有父节点。
- 「叶节点 leaf node」:没有子节点的节点,其两个指针均指向 None 。
- 「边 edge」:连接两个节点的线段,即节点引用(指针)。
- 节点所在的「层 level」:从顶至底递增,根节点所在层为 1 。
- 节点的「度 degree」:节点的子节点的数量。在二叉树中,度的取值范围是 0、1、2 。
- 二叉树的「高度 height」:从根节点到最远叶节点所经过的边的数量。
- 节点的「深度 depth」:从根节点到该节点所经过的边的数量。
- 节点的「高度 height」:从距离该节点最远的叶节点到该节点所经过的边的数量。
二叉树基本操作
初始化二叉树
- 先初始化节点
- 再在节点处加入引用
/* 初始化二叉树 */
// 初始化节点
TreeNode* n1 = new TreeNode(1);
TreeNode* n2 = new TreeNode(2);
TreeNode* n3 = new TreeNode(3);
TreeNode* n4 = new TreeNode(4);
TreeNode* n5 = new TreeNode(5);
// 构建节点之间的引用(指针)
n1->left = n2;
n1->right = n3;
n2->left = n4;
n2->right = n5;
插入与删除节点
- 通过修饰指针实现
常见二叉树类型
- 完美二叉树
- 除了叶节点的度是0,其他节点的度都是
- 若树的高度是h,那么节点总数是2^(h+1)-1
- 标准的指数级关系,反映了细胞分裂现象
- 完全二叉树
- 只有最底层的节点未被填满,且最底层节点尽量靠左填充。
- 完满二叉树
- 除了叶节点之外,其余所有节点都有两个子节点。
- 节点的度只有0或者2
- 平衡二叉树
- 任意节点的左子树和右子树的高度之差绝对值不超过1
- 左- 右
二叉树的退化
- 当所有节点都偏向一侧时,二叉树退化为“链表”
二叉树遍历
层序遍历
- 本质上属于「广度优先搜索 breadth-first search, BFS」
- 实现基础"队列""先进先出"
- 时间复杂度O(n)
- 空间复杂度O(n)
前序中序后序遍历
- 本质上属于「深度优先搜索 depth-first search, DFS」,它体现了一种“先走到尽头,再回溯继续”的遍历方式
- 基于递归实现
- 时间复杂度O(n)
- 空间复杂度O(n)
二叉树数组表示
- 本质:层序遍历
完美二叉树
- “映射公式”:若某节点的索引为i,则该节点的左子节点索引为2i+1 ,右子节点索引为2i+2
- 映射公式的角色相当于链表中的指针
任意二叉树
- 显式地写出所有 None
- 完全二叉树非常适合使用数组来表示。回顾完全二叉树的定义,None 只出现在最底层且靠右的位置,因此所有 None 一定出现在层序遍历序列的末尾
优点与局限性
优点
- 数组存储在连续的内存空间中,对缓存友好,访问与遍历速度较快。
- 不需要存储指针,比较节省空间。
- 允许随机访问节点。
局限性 - 数组存储需要连续内存空间,因此不适合存储数据量过大的树。
- 增删节点需要通过数组插入与删除操作实现,效率较低。
- 当二叉树中存在大量 None 时,数组中包含的节点数据比重较低,空间利用率较低。
二叉搜索树
- 所有节点满足左子树中所有节点的值<根节点的值<右子树中所有节点的值
二叉搜索树的操作
- 封装为一个类
BinarySearchTree
- 并声明一个成员变量root,指向树的根节点
查找节点
- 原理:二分查找
- 目标节点值 num
- 声明一个节点 cur
- 从二叉树的根节点 root 出发,循环比较节点值 cur.val 和 num 之间的大小关系。
- 若 cur.val < num ,说明目标节点在 cur 的右子树中,因此执行 cur = cur.right 。
- 若 cur.val > num ,说明目标节点在 cur 的左子树中,因此执行 cur = cur.left 。
- 若 cur.val = num ,说明找到目标节点,跳出循环并返回该节点。
删除节点
- 先找到再删除,先遍历再删除
- 删除分三种 节点度 0,1,2
- 度是0,叶节点,直接删
- 度是1,直接换成自己的子节点
- 度是2,直接换成自己右子树的最小节点或者左子树的最大节点
中序遍历有序
二叉搜索树的效率
- 在不断的插入删除节点,可能导致二叉树的退化,O(n)
二叉搜索树常见应用
查找、插入、删除操作。
作为某些搜索算法的底层数据结构。
用于存储数据流,以保持其有序状态。
堆
- 「堆 heap」是一种满足特定条件的完全二叉树
- 「小顶堆 min heap」:任意节点的值<=其子节点的值。堆顶值最小
- 「大顶堆 max heap」:任意节点的值>=其子节点的值。堆顶值最大
堆的常用操作
- 常用于实现优先队列
堆的实现
堆的存储与表示
- 数组实现(堆是一个完全二叉树)
- 元素代表节点值
- 索引代表节点在二叉树中的位置
- 节点指针通过映射公式来实现
- 给定索引i,左子节点的索引1为2i+1,右子节点的索引为2i+2,父节点的索引为(i-1)/2
- 当索引越界时,表示空节点或节点不存在
访问堆顶元素
int peek(){return maxHeap[0];}
元素入堆
堆化操作
- 给定元素val,添加到堆底
- 比较插入节点与其父节点的值,插入节点更大则交换它们
- 重复上述操作,直至越过根节点或遇到无需交换的节点时结束
时间复杂度O(logn)
堆顶语速出堆
- 交换堆顶元素与堆底元素
- 将交换完的堆顶元素删除掉
- 从根节点开始,从顶至底执行堆化操作(与出堆操作相反)根节点与子节点进行比较,最大的代替根节点,循环,直到越过叶节点
时间复杂度O(logn)
堆的常见应用
- 优先队列
- 堆排序
- 获取最大的k个元素
建堆操作
借助入堆操作实现
- 创建一空堆
- 借助入堆操作“从堆底至顶”堆化
时间复杂度是O(logn)
通过遍历堆化实现
- 将元素全部添加到堆里
- 自下而上进行堆化,先弄好子堆,再弄根节点
由于叶节点没有子节点,因此它们天然就是合法的子堆,无需堆化
复杂度分析
第二种建堆方法的时间复杂度
- 假设完全二叉树的节点数量为n,则叶节点数量为(n+1)/2,因此要堆化的节点数量为(n - 1)/2
- 从顶至底堆化的过程中,每个节点最多堆化到叶节点,因此最大迭代次数为二叉树高度logn
- 以上相乘,建堆的时间复杂度为O(nlogn)
- 这个接过并不准确,因为没有考虑到二叉树底层节点数量远多于顶层节点的性质
- 精确的结果是,输入列表并建堆的时间复杂度为O(n),非常高效
Top-k问题
给定一个长度为n的无序数组nums请返回数组中最大的k个元素
方法一:遍历选择
- 进行一个选择排序
- 方法很耗时
方法二:排序
- 即寻找最大的K个元素即可,不用对其他元素排序
- 先对数组nums进行排序
- 再返回最右边的k个元素
- O(logn)
方法三:堆
- 先将数组的前k个元素依次入堆
- 从第k + 1个元素开始,若当前元素大于堆顶元素,则将堆顶元素出堆,并将当前元素入堆
- 遍历完成后,堆中保存的就是最大的k个元素
时间复杂度O(nlogn)
图
- 「图 graph」是一种非线性数据结构,由「顶点 vertex」和「边 edge」组成。
- 靠链表来实现
顶点看作节点,将边看作连接各个节点的引用(指针)。 - 相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高,也更复杂
图的常见类型与术语
- 根据边是否具有方向
- 「无向图 undirected graph」:边表示两顶点之间的“双向”连接关系,例如微信或 QQ 中的“好友关系”。
- 「有向图 directed graph」:边具有方向性,即A——>B和A<——B两个方向的边是相互独立的,例如微博或抖音上的“关注”与“被关注”关系。
- 根据所有顶点是否连通
- 「连通图 connected graph」:从某个顶点出发,可以到达其余任意顶点。
- 「非连通图 disconnected graph」:从某个顶点出发,至少有一个顶点无法到达。
- 也可以给边添加权重,从而得到有权图
- 图常用术语
- 「邻接 adjacency」:当两顶点之间存在边相连时,称这两顶点“邻接”。
- 「路径 path」:从顶点 A 到顶点 B 经过的边构成的序列被称为从 A 到 B 的“路径”。
- 「度 degree」:一个顶点拥有的边数。
图的表示
邻接矩阵
1表示有边,0表示无边
- 特性:
- 顶点不能与自身相连,因此对角线没有意义
- 对于无向图,两个方向的边等价,此时邻接矩阵关于主对角线堆成
- 将邻接矩阵的元素从1和0替换为权重,则可表示有权图
时间复杂度O(1),空间复杂度O(n^2)
邻接表
「邻接表 adjacency list」使用n个链表来表示图,链表节点表示顶点。
时间复杂度O(n)
邻接表结构与哈希表中的“链式地址”非常相似,因此我们也可以采用类似的方法来优化效率。
当链表较长时,可以将链表转化为 AVL 树或红黑树,从而将时间效率从O(n)优化至O(logn) ;还可以把链表转换为哈希表,从而将时间复杂度降至O(1)
图的常见应用
图的基础操作
基于邻接矩阵的实现
- 添加或删除边:直接在邻接矩阵中修改指定的边即可,使用O(1)时间,无向图,所以需要同时更新两个方向的边
- 添加顶点:在邻接矩阵的尾部添加一行一列,并全部填0,使用时间O(n)
- 删除顶点:在邻接矩阵中删除一行一列。当删除首行首列时到达最差情况,需将(n - 1)2个元素“向左上移动”,从而使用O(n2)时间
- 初始化:传入n个顶点,初始化长度n的顶点列表vertices,使用O(n)时间,初始化n*n大小的邻接矩阵adjMat,使用O(n^2)时间
基于邻接表的实现
定点总数n,边总数m
- 添加边:在对应顶点末尾添加边即可,使用O(1)时间,双向图就添加两边
- 删除边:对应链表中查找删除O(m)时间
- 添加顶点:将新的顶点作为链表头节点,使用O(1)
- 删除顶点:需遍历整个邻接表,删除包含指定顶点的所有边,使用O(n+m)时间
- 初始化:在邻接表中创建n个顶点和2m条边,使用O(n+m)时间
效率对比
图的遍历
图是更复杂的树,树的遍历方法也适用于图
广度优先搜索
广度优先遍历是一种由近及远的遍历方式,从某个节点出发,始终优先访问距离最近的顶点,并一层层向外扩张。
- 算法实现 借助队列实现
深度优先遍历
- 深度优先遍历是一种优先走到底,无路可走再回头的遍历方式
- 算法实现 递归
搜索
二分查找
- 「二分查找 binary search」是一种基于分治策略的高效搜索算法。它利用数据的有序性,每轮缩小一半搜索范围,直至找到目标元素或搜索区间为空为止。
给定一个长度为n的数组 nums ,元素按从小到大的顺序排列且不重复。请查找并返回元素 target 在该数组中的索引。若数组不包含该元素,则返回-1。
- 左闭右闭
// 版本一
class Solution {
public:
int search(vector<int>& nums, int target) {
int left = 0;
int right = nums.size() - 1; // 定义target在左闭右闭的区间里,[left, right]
while (left <= right) { // 当left==right,区间[left, right]依然有效,所以用 <=
int middle = left + ((right - left) / 2);// 防止溢出 等同于(left + right)/2
if (nums[middle] > target) {
right = middle - 1; // target 在左区间,所以[left, middle - 1]
} else if (nums[middle] < target) {
left = middle + 1; // target 在右区间,所以[middle + 1, right]
} else { // nums[middle] == target
return middle; // 数组中找到目标值,直接返回下标
}
}
// 未找到目标值
return -1;
}
};
- 左闭右开
// 版本二
class Solution {
public:
int search(vector<int>& nums, int target) {
int left = 0;
int right = nums.size(); // 定义target在左闭右开的区间里,即:[left, right)
while (left < right) { // 因为left == right的时候,在[left, right)是无效的空间,所以使用 <
int middle = left + ((right - left) >> 1);
if (nums[middle] > target) {
right = middle; // target 在左区间,在[left, middle)中
} else if (nums[middle] < target) {
left = middle + 1; // target 在右区间,在[middle + 1, right)中
} else { // nums[middle] == target
return middle; // 数组中找到目标值,直接返回下标
}
}
// 未找到目标值
return -1;
}
};
- 时间复杂度O(logn)
- 空间复杂的O(1)
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具