数据结构与算法

文章之中的内容主要参考:

https://www.hello-algo.com/chapter_array_and_linkedlist/array/

数组

「数组 array」:线性数据结构,将相同类型的元素储存在连续的内存空间。
我们将元素在数组中的位置称为该元素的「索引 index」

数组常用操作

  1. 初始化数组
// 存储在栈上
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 };
  1. 访问元素
  • 通过索引访问。
  • 索引本质上是内存地址的偏移量
  • O(1)时间随机访问数组任意一个元素
  1. 插入元素与删除元素
  • 两个都是要通过将元素逐一移动或者逐一覆盖来进行
  • 可能会造成一部分的丢失,要保证不丢失需要更大的内存空间,又会浪费空间
  • 时间复杂度O(n)
  1. 遍历数组
  • 分为索引遍历和直接遍历
  1. 查找元素
  • 遍历对比匹配
  • 数组是线性数据结构,“线性查找”
  1. 扩容数组
  • 数组长度一般是不可变的
  • 扩容的本质:建立一个更大的数组将原先的数组复制过来,O(n),耗时

链表

「链表 linked list」是一种线性数据结构,每个元素都是一个对象节点,各个节点通过“指针”相连接

  • 指针记录了下一个节点的内存地址,通过它可以从当前节点访问到下一个节点
  • 链表的设计使得各个节点可以分散存储在内存的各处,它们的内存地址无需连续。
  • 每个节点包含两项数据:节点“值”和指向下一个节点的“指针”。
  • 首个节点:“头节点”
  • 最后一个:“尾节点”,指向空nullptr
  • 相同数据下,链表比数组占用更多的内存空间

链表常用操作

  1. 初始化链表
  • 第一:初始化各个节点对象
  • 第二:构建节点之间的引用关系
/* 初始化链表 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
  1. 插入节点
  • 只需要改变两节点引用(指针)即可
  • 时间复杂度O(1)
  1. 删除节点
  • 同样只需改变一个节点的引用(指针)即可
  1. 访问节点
  • 要从头出发,遍历链表
  • 时间复杂度O(n)
  1. 查找节点
  • 遍历链表
  1. 链表类型
  • 单向链表:包含节点值和下一个节点的引用(指针)
  • 环形链表:在单向链表的基础上,让尾链表指向头节点
  • 双向链表:记录两个方向,后记节点和前驱节点

列表(vector)

  • 「列表 list」是一个抽象的数据结构概念,它表示元素的有序集合,支持元素访问、修改、添加、删除和遍历等操作,无需考虑容量的限制问题。
  • 列表可以基于链表或者数组实现。
  • 许多编程语言中的标准库提供的列表是基于动态列表实现的。(长度可变)长度不可变的性质会导致列表的实用性降低

列表常用操作

  1. 初始化列表
/* 初始化列表 */
// 需注意,C++ 中 vector 即是本文描述的 nums
// 无初始值
vector<int> nums1;
// 有初始值
vector<int> nums = { 1, 3, 2, 5, 4 };
  1. 访问元素
  • 索引访问
/* 访问元素 */
int num = nums[1];  // 访问索引 1 处的元素

/* 更新元素 */
nums[1] = 0;  // 将索引 1 处的元素更新为 0
  • 时间复杂度O(1)
  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 处的元素
  1. 遍历列表
  • 可以根据索引遍历
  • 也可以直接遍历元素
  1. 拼接列表
/* 拼接两个列表 */
vector<int> nums1 = { 6, 8, 7, 10, 9 };
// 将列表 nums1 拼接到 nums 之后
nums.insert(nums.end(), nums1.begin(), nums1.end());
  1. 排序列表
/* 排序列表 */
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();

队列实现

  1. 基于链表的实现
  • 将头节点和尾节点视为队首和队尾
  • 队尾仅可添加节点,队首仅可删除节点
  1. 基于数组的实现
  • 由于数组的覆盖机制,删除首元素的时间复杂度为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();

双向队列实现

双向队列的实现与队列类似,可以选择链表或数组作为底层数据结构。

  1. 基于双向链表的实现
  • 将双向链表的头节点和尾节点视为双向队列的队首和队尾,同时实现在两端添加和删除节点的功能。
  1. 基于数组的实现
  • 与基于数组实现队列类似,我们也可以使用环形数组来实现双向队列。
  • 入队操作:将输入元素赋值给 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的大小

哈希冲突的解决办法

  1. 改良哈希表的数据结构
    链式地址,开放寻址
  2. 哈希冲突较严重时才进行扩容

链式地址

链式地址 separate chaining」将单个元素转换为链表,将键值对作为链表节点,将所有发生冲突的键值对都存储在同一链表中。

  • 查询元素:输入 key ,经过哈希函数得到桶索引,即可访问链表头节点,然后遍历链表并对比 key 以查找目标键值对。
  • 添加元素:首先通过哈希函数访问链表头节点,然后将节点(键值对)添加到链表中。
  • 删除元素:根据哈希函数的结果访问链表头部,接着遍历链表以查找目标节点并将其删除。
  • 链式地址存在以下局限性。
  1. 占用空间增大:链表包含节点指针,它相比数组更加耗费内存空间。
  2. 查询效率降低:因为需要线性遍历链表来查找对应元素。
    当链表很长时,查询效率很差O(n),此时可以将链表转化为"AVL树"或者"红黑树",将时间复杂度优化至O(logn)

开放寻址

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

线性探测
  • 采用固定步长来进行探索
  • 插入元素:通过哈希函数计算桶索引,若发现桶内已有元素,则从冲突位置向后线性遍历(步长通常为1),直至找到空桶,将元素插入其中。
    有就直接下一个
  • 查找元素:若发现哈希冲突,则使用相同步长向后进行线性遍历,直到找到对应元素,返回 value 即可;如果遇到空桶,说明目标元素不在哈希表中,返回 None
  • 删除元素: 不能真的删除元素,不然会形成一个None,当查询元素时,遇到这个就会返回,剩下的元素就无法被访问到了.采用「懒删除 lazy deletion」机制:它不直接从哈希表中移除元素,而是利用一个常量 TOMBSTONE 来标记这个桶。在该机制下,None 和 TOMBSTONE 都代表空桶,都可以放置键值对。但不同的是,线性探测到 TOMBSTONE 时应该继续遍历,因为其之下可能还存在键值对。
  • 缺点是:标记的次数越多,需要跳过的越多,搜索时间也会增加,删除可能会加速哈希表的性能退化
  • 解决办法 :考虑在线性探测中记录遇到的首个 TOMBSTONE 的索引,并将搜索到的目标元素与该 TOMBSTONE 交换位置。这样做的好处是当每次查询或添加元素时,元素会被移动至距离理想位置(探测起始点)更近的桶,从而优化查询效率。
  • 线性探测容易产生“聚集现象”。具体来说,数组中连续被占用的位置越长,这些连续位置发生哈希冲突的可能性越大,从而进一步促使该位置的聚堆生长,形成恶性循环,最终导致增删查改操作效率劣化。
平方探测
  • 当发生冲突时,平方探测不是简单地跳过一个固定的步数,而是跳过“探测次数的平方”的步数,即1,4,9,...步。
    优势
  1. 试图缓解线性探测的聚焦效应
  2. 会跳过更大的距离来寻找空位,有助于数据分布得更加均匀
    缺点
  3. 仍然存在聚集现象,某些位置比其他位置更容易被占用
  4. 由于平方的增长,平方探测探测可能不会探测整个和哈希表,有些数据可能无法访问到
多次哈希
  • 多次哈希方法使用多个哈希函数f1(x),f2(x),f3(x),...进行探测
  • 插入元素:若哈希函数f1(x)出现冲突,则尝试f2(x),以此类推,直到找到空位后插入元素。
  • 查找元素:在相同的哈希函数顺序下进行查找,直到找到目标元素时返回;若遇到空位或已尝试所有哈希函数,说明哈希表中不存在该元素,则返回 None
    所有的开放寻址哈希表都存在:"不能直接删除元素"的问题

哈希算法

  • 键值对的分布情况是由哈希函数决定.index = hash(key) % capacity
  • 当哈希表容量 capacity 固定时,哈希算法 hash() 决定了输出值,进而决定了键值对在哈希表中的分布情况。为了降低哈希冲突的发生概率,我们应当将注意力集中在哈希算法 hash() 的设计上。

哈希算法的目的

  • 准确性:相同的输入相同的输出
  • 效率高:计算过程快
  • 均匀分布:键值对均匀分布,越均匀,冲突越低

哈希算法的应用

  1. 密码储存:储存密码储存的是哈希值,输入密码时计算获得哈希值,进行对比匹配成功则密码正确
  • 为了防止从哈希值推导出原始密码等逆向工程,哈希算法需要更高等级的安全性
    单向性:无法通过哈希值反推出关于输入数据的任何信息
    抗碰撞性:很难找到相同的两个不同的输入,使得哈希值相同
    雪崩效应:输入的微小变化应当导致输出显著且不可预测的变化
  1. 数据完整性检验:发送方计算数据的哈希值,接收方接收到后也计算数据的哈希值,两个数值进行对比,匹配则完整

哈希算法的设计

  • 加法哈希:对输入的每个字符的 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」:从距离该节点最远的叶节点到该节点所经过的边的数量。

二叉树基本操作

初始化二叉树

  1. 先初始化节点
  2. 再在节点处加入引用
/* 初始化二叉树 */
// 初始化节点
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;

插入与删除节点

  • 通过修饰指针实现

常见二叉树类型

  1. 完美二叉树
  • 除了叶节点的度是0,其他节点的度都是
  • 若树的高度是h,那么节点总数是2^(h+1)-1
  • 标准的指数级关系,反映了细胞分裂现象
  1. 完全二叉树
  • 只有最底层的节点未被填满,且最底层节点尽量靠左填充。
  1. 完满二叉树
  • 除了叶节点之外,其余所有节点都有两个子节点。
  • 节点的度只有0或者2
  1. 平衡二叉树
  • 任意节点的左子树和右子树的高度之差绝对值不超过1
  • 左- 右

二叉树的退化

  • 当所有节点都偏向一侧时,二叉树退化为“链表”
    image

二叉树遍历

层序遍历

image

  • 本质上属于「广度优先搜索 breadth-first search, BFS」
  • 实现基础"队列""先进先出"
  • 时间复杂度O(n)
  • 空间复杂度O(n)

前序中序后序遍历

image

  • 本质上属于「深度优先搜索 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 之间的大小关系。
  1. 若 cur.val < num ,说明目标节点在 cur 的右子树中,因此执行 cur = cur.right 。
  2. 若 cur.val > num ,说明目标节点在 cur 的左子树中,因此执行 cur = cur.left 。
  3. 若 cur.val = num ,说明找到目标节点,跳出循环并返回该节点。

删除节点

  • 先找到再删除,先遍历再删除
  • 删除分三种 节点度 0,1,2
  • 度是0,叶节点,直接删
  • 度是1,直接换成自己的子节点
  • 度是2,直接换成自己右子树的最小节点或者左子树的最大节点

中序遍历有序

image

二叉搜索树的效率

image

  • 在不断的插入删除节点,可能导致二叉树的退化,O(n)

二叉搜索树常见应用

查找、插入、删除操作。
作为某些搜索算法的底层数据结构。
用于存储数据流,以保持其有序状态。

  • 「堆 heap」是一种满足特定条件的完全二叉树
  • 「小顶堆 min heap」:任意节点的值<=其子节点的值。堆顶值最小
  • 「大顶堆 max heap」:任意节点的值>=其子节点的值。堆顶值最大

堆的常用操作

  • 常用于实现优先队列
    image

堆的实现

堆的存储与表示

  • 数组实现(堆是一个完全二叉树)
  • 元素代表节点值
  • 索引代表节点在二叉树中的位置
  • 节点指针通过映射公式来实现
  • 给定索引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个元素

方法一:遍历选择

  • 进行一个选择排序
    image
  • 方法很耗时

方法二:排序

  • 即寻找最大的K个元素即可,不用对其他元素排序
  1. 先对数组nums进行排序
  2. 再返回最右边的k个元素
  • O(logn)

方法三:堆

  1. 先将数组的前k个元素依次入堆
  2. 从第k + 1个元素开始,若当前元素大于堆顶元素,则将堆顶元素出堆,并将当前元素入堆
  3. 遍历完成后,堆中保存的就是最大的k个元素
    时间复杂度O(nlogn)

  • 「图 graph」是一种非线性数据结构,由「顶点 vertex」和「边 edge」组成。
  • 靠链表来实现
    顶点看作节点,将边看作连接各个节点的引用(指针)。
  • 相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高,也更复杂
    image

图的常见类型与术语

  1. 根据边是否具有方向
  • 「无向图 undirected graph」:边表示两顶点之间的“双向”连接关系,例如微信或 QQ 中的“好友关系”。
  • 「有向图 directed graph」:边具有方向性,即A——>B和A<——B两个方向的边是相互独立的,例如微博或抖音上的“关注”与“被关注”关系。
    image
  1. 根据所有顶点是否连通
  • 「连通图 connected graph」:从某个顶点出发,可以到达其余任意顶点。
  • 「非连通图 disconnected graph」:从某个顶点出发,至少有一个顶点无法到达。
    image
  • 也可以给边添加权重,从而得到有权图
    image
  1. 图常用术语
  • 「邻接 adjacency」:当两顶点之间存在边相连时,称这两顶点“邻接”。
  • 「路径 path」:从顶点 A 到顶点 B 经过的边构成的序列被称为从 A 到 B 的“路径”。
  • 「度 degree」:一个顶点拥有的边数。

图的表示

邻接矩阵

image
1表示有边,0表示无边

  • 特性:
  1. 顶点不能与自身相连,因此对角线没有意义
  2. 对于无向图,两个方向的边等价,此时邻接矩阵关于主对角线堆成
  3. 将邻接矩阵的元素从1和0替换为权重,则可表示有权图
    时间复杂度O(1),空间复杂度O(n^2)

邻接表

「邻接表 adjacency list」使用n个链表来表示图,链表节点表示顶点。
image
时间复杂度O(n)
邻接表结构与哈希表中的“链式地址”非常相似,因此我们也可以采用类似的方法来优化效率。
当链表较长时,可以将链表转化为 AVL 树或红黑树,从而将时间效率从O(n)优化至O(logn) ;还可以把链表转换为哈希表,从而将时间复杂度降至O(1)

图的常见应用

image

图的基础操作

基于邻接矩阵的实现

  • 添加或删除边:直接在邻接矩阵中修改指定的边即可,使用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)时间

效率对比

image

图的遍历

图是更复杂的树,树的遍历方法也适用于图

广度优先搜索

广度优先遍历是一种由近及远的遍历方式,从某个节点出发,始终优先访问距离最近的顶点,并一层层向外扩张。
image

  • 算法实现 借助队列实现

深度优先遍历

  • 深度优先遍历是一种优先走到底,无路可走再回头的遍历方式
    image
  • 算法实现 递归

搜索

二分查找

  • 「二分查找 binary search」是一种基于分治策略的高效搜索算法。它利用数据的有序性,每轮缩小一半搜索范围,直至找到目标元素或搜索区间为空为止。
    给定一个长度为n的数组 nums ,元素按从小到大的顺序排列且不重复。请查找并返回元素 target 在该数组中的索引。若数组不包含该元素,则返回-1。
  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;
    }
};
  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)

二分查找插入点

posted @   777CC  阅读(24)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具
点击右上角即可分享
微信分享提示