20. 二叉树的定义与操作
- 二叉树(binary tree)是一种非线性数据结构,代表着祖先与后代之间的派生关系,体现着“一分为二”的分治逻辑
- 与链表类似,二叉树的基本单元是节点,每个节点包含:值、左子节点引用、右子节点引用
/* 二叉树节点结构体 */ struct TreeNode { int val; // 节点值 TreeNode *left; // 左子节点指针 TreeNode *right; // 右子节点指针 TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} };
- 在二叉树中,除叶节点外,其他所有节点都包含子节点和非空子树
- 如果将 “节点 2” 视为父节点,则其左子节点和右子节点分别是 “节点 4” 和 “节点 5”,左子树是 “节点 4 及其以下节点形成的树”,右子树是 “节点 5 及其以下节点形成的树”
- 二叉树常见术语
- 根节点 root node
- 位于二叉树顶层的节点,没有父节点
- 叶节点 leaf node
- 没有子节点的节点,其两个指针均指向 None
- 边 edge
- 连接两个节点的线段,即节点指针
- 节点所在的层 level
- 从顶至底递增,根节点所在层为 1
- 节点的度 degree
- 节点的子节点的数量。在二叉树中,度的取值范围是 0、1、2
- 二叉树的高度 height
- 从根节点到最远叶节点所经过的边的数量
- 节点的深度 depth
- 从根节点到该节点所经过的边的数量
- 节点的高度 height
- 从最远叶节点到该节点所经过的边的数量
- 根节点 root node
- 二叉树常见操作
/* 1、初始化二叉树 */ // 与链表类似,首先初始化节点,然后构建指针 // 初始化节点 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; /* 2、插入与删除节点 */ // 与链表类似,在二叉树中插入与删除节点可以通过修改指针来实现 TreeNode* P = new TreeNode(0); // 在 n1 -> n2 中间插入节点 P n1->left = P; P->left = n2; // 删除节点 P n1->left = n2;
21. 常见二叉树类型
- 完美二叉树(满二叉树)
- 完美二叉树(perfect binary tree)所有层的节点都被完全填满。在完美二叉树中,叶节点的度为 0,其余所有节点的度都为 2;若树高度为 h,则节点总数为 2 h + 1 − 1 2^{h+1}-1 2h+1−1,呈现标准的指数级关系,反映了自然界中常见的细胞分裂现象
- 完全二叉树
- 完全二叉树(complete binary tree)只有最底层的节点未被填满,且最底层节点尽量靠左填充
- 完满二叉树
- 完满二叉树(full binary tree)除了叶节点之外,其余所有节点都有两个子节点
- 平衡二叉树
- 平衡二叉树(balanced binary tree)中任意节点的左子树和右子树的高度之差的绝对值不超过 1
22. 二叉树的退化
- 当二叉树的每层节点都被填满时,达到 “完美二叉树”;而当所有节点都偏向一侧时,二叉树退化为 “链表”
- 完美二叉树是理想情况,可以充分发挥二叉树 “分治” 的优势
- 链表则是另一个极端,各项操作都变为线性操作,时间复杂度退化至 O(n)
23. 二叉树遍历
从物理结构的角度来看,树是一种基于链表的数据结构,因此其遍历方式是通过指针逐个访问节点。然而,树是一种非线性数据结构,这使得遍历树比遍历链表更加复杂,需要借助搜索算法来实现
- 二叉树常见的遍历方式包括层序遍历、前序遍历、中序遍历和后序遍历等
23.1 层序遍历
- 层序遍历(level-order traversal)从顶部到底部逐层遍历二叉树,并在每一层按照从左到右的顺序访问节点
- 层序遍历本质上属于广度优先遍历(breadth-first traversal),它体现了 “一圈一圈向外扩展” 的逐层遍历思想
- 广度优先遍历通常借助 “队列” 来实现。队列遵循 “先进先出” 的规则,而广度优先遍历则遵循 “逐层推进” 的规则,两者背后的思想是一致的
/* 层序遍历 */ // 时间复杂度:所有节点被访问一次,使用 O(n) 时间,其中 n 为节点数量 // 空间复杂度:在最差情况下,即满二叉树时,遍历到最底层之前,队列中最多同时存在 (n+1)/2 个节点,占用 O(n) 空间 vector<int> levelOrder(TreeNode *root) { // 初始化队列,加入根节点 queue<TreeNode *> queue; queue.push(root); // 初始化一个列表,用于保存遍历序列 vector<int> vec; while (!queue.empty()) { TreeNode *node = queue.front(); queue.pop(); // 队列出队 vec.push_back(node->val); // 保存节点值 if (node->left != nullptr) queue.push(node->left); // 左子节点入队 if (node->right != nullptr) queue.push(node->right); // 右子节点入队 } return vec; }
23.2 前序、中序、后序遍历
- 前序、中序和后序遍历都属于深度优先遍历(depth-first traversal),体现 “先走到尽头,再回溯继续” 的思想
- 下图展示了对二叉树进行深度优先遍历的工作原理。深度优先遍历就像是绕着整个二叉树的外围 “走” 一圈,在每个节点都会遇到三个位置,分别对应前序遍历、中序遍历和后序遍历
- 深度优先搜索通常基于递归实现
// 时间复杂度:所有节点被访问一次,使用 O(n) 时间 // 空间复杂度:在最差情况下,即树退化为链表时,递归深度达到 n,系统占用 O(n) 栈帧空间 /* 前序遍历 */ void preOrder(TreeNode *root) { if (root == nullptr) return; // 访问优先级:根节点 -> 左子树 -> 右子树 vec.push_back(root->val); preOrder(root->left); preOrder(root->right); } /* 中序遍历 */ void inOrder(TreeNode *root) { if (root == nullptr) return; // 访问优先级:左子树 -> 根节点 -> 右子树 inOrder(root->left); vec.push_back(root->val); inOrder(root->right); } /* 后序遍历 */ void postOrder(TreeNode *root) { if (root == nullptr) return; // 访问优先级:左子树 -> 右子树 -> 根节点 postOrder(root->left); postOrder(root->right); vec.push_back(root->val); }
前序遍历二叉树的递归过程可分为 “递” 和 “归” 两个逆向的部分
- “递” 表示开启新方法,程序在此过程中访问下一个节点
- “归” 表示函数返回,代表当前节点已经访问完毕
24. 二叉搜索树
- 如下图所示,二叉搜索树(binary search tree)满足以下条件
- 对于根节点,左子树中所有节点的值 < 根节点的值 < 右子树中所有节点的值
- 任意节点的左、右子树也是二叉搜索树,即同样满足上述条件
24.1 二叉搜索树的操作
-
1、查找节点
- 给定目标节点值 num,可以根据二叉搜索树的性质来查找。声明一个节点 cur,从二叉树的根节点 root 出发,循环比较节点值 cur.val 和 num 之间的大小关系
- 若 cur.val < num ,说明目标节点在 cur 的右子树中,因此执行 cur = cur.right
- 若 cur.val > num ,说明目标节点在 cur 的左子树中,因此执行 cur = cur.left
- 若 cur.val = num ,说明找到目标节点,跳出循环并返回该节点
- 给定目标节点值 num,可以根据二叉搜索树的性质来查找。声明一个节点 cur,从二叉树的根节点 root 出发,循环比较节点值 cur.val 和 num 之间的大小关系
-
二叉搜索树的查找操作与二分查找算法的工作原理一致,都是每轮排除一半情况。循环次数最多为二叉树的高度,当二叉树平衡时,使用 O(log n) 时间
/* 查找节点 */ TreeNode *search(int num) { TreeNode *cur = root; // 循环查找,越过叶节点后跳出 while (cur != nullptr) { // 目标节点在 cur 的右子树中 if (cur->val < num) cur = cur->right; // 目标节点在 cur 的左子树中 else if (cur->val > num) cur = cur->left; // 找到目标节点,跳出循环 else break; } // 返回目标节点 return cur; }
-
2、插入节点
- 给定一个待插入元素 num,为保持二叉搜索树 “左子树 < 根节点 < 右子树” 性质,插入操作流程如下图所示
- 查找节点插入位置:与查找操作相似,从根节点出发,根据当前节点值和 num 的大小关系循环向下搜索,直到越过叶节点(遍历至 None)时跳出循环
- 在该位置插入节点:初始化节点 num,将该节点置于 None 的位置
- 给定一个待插入元素 num,为保持二叉搜索树 “左子树 < 根节点 < 右子树” 性质,插入操作流程如下图所示
-
在代码实现中,需要注意以下两点
- 二叉搜索树不允许存在重复节点,否则将违反其定义。因此,若待插入节点在树中已存在,则不执行插入,直接返回
- 为实现插入节点,需要借助节点 pre 保存上一轮循环的节点。这样在遍历至 None 时,可以获取到其父节点,从而完成节点插入操作
// 时间复杂度:O(log n) void insert(int num) { // 若树为空,则初始化根节点 if (root == nullptr) { root = new TreeNode(num); return; } TreeNode *cur = root, *pre = nullptr; // 循环查找,越过叶节点后跳出 while (cur != nullptr) { // 找到重复节点,直接返回 if (cur->val == num) return; pre = cur; // 插入位置在 cur 的右子树中 if (cur->val < num) cur = cur->right; // 插入位置在 cur 的左子树中 else cur = cur->left; } // 插入节点 TreeNode *node = new TreeNode(num); if (pre->val < num) pre->right = node; else pre->left = node; }
-
3、删除节点
- 先在二叉树中查找到目标节点,再将其从二叉树中删除。与插入节点类似,需要保证在删除操作完成后,二叉搜索树的 “左子树 < 根节点 < 右子树” 的性质仍然满足。需要根据目标节点的子节点数量,共分为 0、1 和 2 这三种情况,执行对应的删除节点操作
-
3.1 当待删除节点的度为 0 时,表示该节点是叶节点,可以直接删除
-
3.2 当待删除节点的度为 1 时,将待删除节点替换为其子节点即可
-
3.3 当待删除节点的度为 2 时,无法直接删除它,而需要使用一个节点替换该节点
- 由于要保持二叉搜索树 “左 < 根 < 右” 的性质,因此这个节点可以是右子树的最小节点或左子树的最大节点
- 假设选择右子树的最小节点(即中序遍历的下一个节点),则删除操作流程如下
- 查找 cur 在中序遍历的后继节点 nex
- 在二叉树中递归删除节点 nex
- 将节点 nex 值赋给节点 cur
// 时间复杂度:O(log n)
// 其中查找待删除节点需要 O(log n) 时间,获取中序遍历后继节点需要 O(log n) 时间
void remove(int num) {
// 若树为空,直接提前返回
if (root == nullptr)
return;
TreeNode *cur = root, *pre = nullptr;
// 循环查找,越过叶节点后跳出
while (cur != nullptr) {
// 找到待删除节点,跳出循环
if (cur->val == num)
break;
pre = cur;
// 待删除节点在 cur 的右子树中
if (cur->val < num)
cur = cur->right;
// 待删除节点在 cur 的左子树中
else
cur = cur->left;
}
// 若无待删除节点,则直接返回
if (cur == nullptr)
return;
// 1、子节点数量 = 0 or 1
if (cur->left == nullptr || cur->right == nullptr) {
// 当子节点数量 = 0 / 1 时, child = nullptr / 该子节点
TreeNode *child = cur->left != nullptr ? cur->left : cur->right;
// 删除节点 cur
if (cur != root) {
if (pre->left == cur)
pre->left = child;
else
pre->right = child;
} else {
// 若删除节点为根节点,则重新指定根节点
root = child;
}
// 释放内存
delete cur;
}
// 2、子节点数量 = 2
else {
// 获取中序遍历中 cur 的下一个节点
TreeNode *tmp = cur->right;
while (tmp->left != nullptr) {
tmp = tmp->left;
}
int tmpVal = tmp->val;
// 递归删除节点 tmp
remove(tmp->val);
// 用 tmp 覆盖 cur
cur->val = tmpVal;
}
}
- 4、中序遍历有序
- 二叉树的中序遍历遵循 “左 < 根 < 右” 的遍历顺序,而二叉搜索树满足 “左子节点 < 根节点 < 右子节点” 的大小关系。这意味着在二叉搜索树中进行中序遍历时,总是会优先遍历下一个最小节点,从而得出一个重要性质:二叉搜索树的中序遍历序列是升序的
- 利用中序遍历升序的性质,在二叉搜索树中获取有序数据仅需 O(n) 时间,无须进行额外的排序操作,非常高效
24.2 二叉搜索树的效率
- 二叉搜索树的各项操作的时间复杂度都是对数阶,具有稳定且高效的性能表现。只有在高频添加、低频查找删除的数据适用场景下,数组比二叉搜索树的效率更高
- 在理想情况下,二叉搜索树是 “平衡” 的,这样就可以在 l o g n log n logn 轮循环内查找任意节点。然而,如果在二叉搜索树中不断地插入和删除节点,可能导致二叉树退化为下图所示的链表,这时各种操作的时间复杂度也会退化为 O(n)
- 在完美二叉树中插入两个节点后,树将严重向左倾斜,查找操作的时间复杂度也随之恶化
25. AVL 树
- AVL 树既是二叉搜索树也是平衡二叉树,同时满足这两类二叉树的所有性质,因此也被称为平衡二叉搜索树(balanced binary search tree)
/* AVL 树节点类 */ struct TreeNode { int val{}; // 节点值 int height = 0; // 节点高度 TreeNode *left{}; // 左子节点 TreeNode *right{}; // 右子节点 TreeNode() = default; explicit TreeNode(int x) : val(x){} };
- 节点高度:由于 AVL 树的相关操作需要获取节点高度,因此需要为节点类添加 height 变量
- 节点高度是指从该节点到最远叶节点的距离,即所经过的边的数量
- 需要特别注意的是,叶节点的高度为 0,而空节点的高度为 -1
/* 获取节点高度 */ int height(TreeNode *node) { // 空节点高度为 -1 ,叶节点高度为 0 return node == nullptr ? -1 : node->height; } /* 更新节点高度 */ void updateHeight(TreeNode *node) { // 节点高度等于最高子树高度 + 1 node->height = max(height(node->left), height(node->right)) + 1; }
- 节点平衡因子:节点左子树的高度减去右子树的高度,同时规定空节点的平衡因子为 0
- 设平衡因子为 f,则一棵 AVL 树的任意节点的平衡因子皆满足 -1 < f < 1
/* 获取平衡因子 */ int balanceFactor(TreeNode *node) { // 空节点平衡因子为 0 if (node == nullptr) return 0; // 节点平衡因子 = 左子树高度 - 右子树高度 return height(node->left) - height(node->right); }
26. AVL 树旋转
- AVL 树的特点在于 “旋转” 操作,它能够在不影响二叉树的中序遍历序列的前提下,使失衡节点重新恢复平衡
- 换句话说,旋转操作既能保持 “二叉搜索树” 的性质,也能使树重新变为 “平衡二叉树”
- 将平衡因子绝对值 > 1 的节点称为 “失衡节点”
- 根据节点失衡情况的不同,旋转操作分为四种:右旋、左旋、先右旋后左旋、先左旋后右旋
26.1 右旋
- 如下图所示(节点下方为平衡因子)
- 从底至顶看,二叉树中首个失衡节点是 “节点 3”
- 关注以该失衡节点为根节点的子树,将该节点记为 node,其左子节点记为 child
- 执行 “右旋” 操作:以 child 为原点,将 node 向右旋转
- 右旋完成后,用 child 替代以前 node 的位置,子树已恢复平衡,并仍保持二叉搜索树的特性
- 如下图所示,当节点 child 有右子节点(记为 grandChild)时,需要在右旋中添加一步:将 grandChild 作为 node 的左子节点
/* 右旋操作 */
TreeNode *rightRotate(TreeNode *node) {
TreeNode *child = node->left;
TreeNode *grandChild = child->right;
// 以 child 为原点,将 node 向右旋转
child->right = node;
node->left = grandChild;
// 更新节点高度
updateHeight(node);
updateHeight(child);
// 返回旋转后子树的根节点
return child;
}
26.2 左旋
/* 左旋操作 */
TreeNode *leftRotate(TreeNode *node) {
TreeNode *child = node->right;
TreeNode *grandChild = child->left;
// 以 child 为原点,将 node 向左旋转
child->left = node;
node->right = grandChild;
// 更新节点高度
updateHeight(node);
updateHeight(child);
// 返回旋转后子树的根节点
return child;
}
26.3 先左旋后右旋
- 对于下图中的失衡节点 3,仅使用左旋或右旋都无法使子树恢复平衡
- 此时需要先对 child 执行 “左旋”,再对 node 执行 “右旋”
26.4 先右旋后左旋
26.5 旋转的选择
- 如下表,通过判断失衡节点的平衡因子以及较高一侧子节点的平衡因子的正负号,来确定失衡节点属于上图哪种情况
- 为便于使用,将旋转操作封装成一个函数。通过这个函数就能对各种失衡情况进行旋转,使失衡节点重新恢复平衡
/* 执行旋转操作,使该子树重新恢复平衡 */ TreeNode *rotate(TreeNode *node) { // 获取节点 node 的平衡因子 int _balanceFactor = balanceFactor(node); // 左偏树 if (_balanceFactor > 1) { if (balanceFactor(node->left) >= 0) { // 右旋 return rightRotate(node); } else { // 先左旋后右旋 node->left = leftRotate(node->left); return rightRotate(node); } } // 右偏树 if (_balanceFactor < -1) { if (balanceFactor(node->right) <= 0) { // 左旋 return leftRotate(node); } else { // 先右旋后左旋 node->right = rightRotate(node->right); return leftRotate(node); } } // 平衡树,无须旋转,直接返回 return node; }
27. 堆
- 堆(heap)是一种满足特定条件的完全二叉树,主要可分为下图所示的两种类型
- 小顶堆 min heap:任意节点的值 ≤ 其子节点的值
- 大顶堆 max heap:任意节点的值 ≥ 其子节点的值
堆作为完全二叉树的一个特例,具有以下特性
- 最底层节点靠左填充,其他层的节点都被填满
- 将二叉树的根节点称为 “堆顶”,将底层最靠右的节点称为 “堆底”
- 对于大顶堆(小顶堆),堆顶元素(即根节点)的值分别是最大(最小)的
- 堆常用操作
/* 初始化堆 */ priority_queue<int, vector<int>, greater<int>> minHeap; // 初始化小顶堆 priority_queue<int, vector<int>, less<int>> maxHeap; // 初始化大顶堆 /* 元素入堆 */ maxHeap.push(1); maxHeap.push(3); maxHeap.push(2); maxHeap.push(5); maxHeap.push(4); /* 获取堆顶元素 */ int peek = maxHeap.top(); // 5 /* 堆顶元素出堆 */ // 出堆元素会形成一个从大到小的序列 maxHeap.pop(); // 5 maxHeap.pop(); // 4 maxHeap.pop(); // 3 maxHeap.pop(); // 2 maxHeap.pop(); // 1 /* 获取堆大小 */ int size = maxHeap.size(); /* 判断堆是否为空 */ bool isEmpty = maxHeap.empty(); /* 输入列表并建堆 */ vector<int> input{1, 3, 2, 5, 4}; priority_queue<int, vector<int>, greater<int>> minHeap(input.begin(), input.end());
- 堆常见应用
- 优先队列
- 堆通常作为实现优先队列的首选数据结构,其入队和出队操作的时间复杂度均为 O(log n)
- 堆排序
- 给定一组数据,可以用它们建立一个堆,然后不断地执行元素出堆操作,从而得到有序数据
- 获取最大的 k 个元素(Top-K 问题)
- 例如选择热度前 10 的新闻作为微博热搜,选取销量前 10 的商品等
- 优先队列
28. 建堆操作
使用一个列表的所有元素来构建一个堆的过程被称为 “建堆操作”
28.1 借助入堆操作实现
- 首先创建一个空堆,然后遍历列表,依次对每个元素执行 “入堆操作”,即先将元素添加至堆的尾部,再对该元素执行 “从底至顶” 堆化
- 每当一个元素入堆,堆的长度就加一。由于节点是从顶到底依次被添加进二叉树的,因此堆是 “自上而下” 构建的
- 设元素数量为 n,每个元素的入堆操作使用 O(log n) 时间,因此该建堆方法的时间复杂度为 O(nlog n)
28.2 通过遍历堆化实现
-
实际可以实现一种更为高效的建堆方法,共分为两步
- 将列表所有元素原封不动添加到堆中,此时堆的性质尚未得到满足
- 倒序遍历堆(即层序遍历的倒序),依次对每个非叶节点执行 “从顶至底堆化”
-
每当堆化一个节点后,以该节点为根节点的子树就形成一个合法的子堆。而由于是倒序遍历,因此堆是 “自下而上” 被构建的
- 之所以选择倒序遍历,是因为能保证当前节点之下的子树已经是合法的子堆,这样堆化当前节点才是有效的
叶节点没有子节点,天然就是合法的子堆,因此无需堆化
- 如以下代码所示,最后一个非叶节点是最后一个节点的父节点,从它开始倒序遍历并执行堆化
/* 构造方法,根据输入列表建堆 */
MaxHeap(vector<int> nums) {
// 将列表元素原封不动添加进堆
maxHeap = nums;
// 堆化除叶节点以外的其他所有节点
for (int i = parent(size() - 1); i >= 0; i--) {
siftDown(i);
}
}
29. Top-K 问题
给定一个长度为 n 的无序数组 nums,请返回数组中前 k 大的元素
-
基于堆更加高效地解决 Top-K 问题,步骤如下
- 初始化一个小顶堆,其堆顶元素最小
- 先将数组的前 k 个元素依次入堆
- 从第 k+1 个元素开始,若当前元素大于堆顶元素,则将堆顶元素出堆,并将当前元素入堆
- 遍历完成后,堆中保存的就是最大的 k 个元素
-
总共执行了 n 轮入堆和出堆,堆的最大长度为 k,因此时间复杂度为 O(nlog k)。该方法的效率很高,当 k 较小时,时间复杂度趋向 O(n);当 k 较大时,时间复杂度不会超过 O(nlog n)
- 该方法适用于动态数据流使用场景。在不断加入数据时,可以持续维护堆内的元素,从而实现最大个元素的动态更新
/* 基于堆查找数组中最大的 k 个元素 */ priority_queue<int, vector<int>, greater<int>> topKHeap(vector<int> &nums, int k) { priority_queue<int, vector<int>, greater<int>> heap; // 将数组的前 k 个元素入堆 for (int i = 0; i < k; i++) { heap.push(nums[i]); } // 从第 k+1 个元素开始,保持堆的长度为 k for (int i = k; i < nums.size(); i++) { // 若当前元素大于堆顶元素,则将堆顶元素出堆、当前元素入堆 if (nums[i] > heap.top()) { heap.pop(); heap.push(nums[i]); } } return heap; }
30. 图
- 图(graph)是一种非线性数据结构,由顶点(vertex)和边(edge)组成。可以将图 G 抽象地表示为一组顶点 V 和一组边 E 的集合。以下示例展示了一个包含 5 个顶点和 7 条边的图
- 如果将顶点看作节点,将边看作连接各个节点的指针,就可将图看作是一种从链表拓展而来的数据结构。如下图,相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高,从而更为复杂
30.1 图常见类型
- 根据边是否具有方向,可分为下图所示的无向图和有向图
- 在无向图中,边表示两顶点之间的 “双向” 连接关系
- 例如微信或 QQ 中的 “好友关系”
- 在有向图中,边具有方向性,即 A → B 和 B → A 两个方向的边是相互独立的
- 例如微博或抖音上的 “关注” 与 “被关注” 关系
- 在无向图中,边表示两顶点之间的 “双向” 连接关系
- 根据所有顶点是否连通,可分为下图所示的连通图和非连通图
- 对于连通图,从某个顶点出发,可以到达其余任意顶点
- 对于非连通图,从某个顶点出发,至少有一个顶点无法到达
- 可以为边添加 “权重” 变量,从而得到下图所示的有权图
- 例如在王者荣耀等手游中,系统会根据共同游戏时间来计算玩家之间的 “亲密度”,这种亲密度网络就可以用有权图来表示
30.2 图常见术语
- 邻接 adjacency
- 当两顶点之间存在边相连时,称这两顶点 “邻接”
- 路径 path
- 从顶点 A 到顶点 B 经过的边构成的序列被称为从 A 到 B 的 “路径”
- 度 degree
- 一个顶点拥有的边数
- 对于有向图,入度表示有多少条边指向该顶点,出度表示有多少条边从该顶点指出
30.3 图的表示
30.3.1 邻接矩阵
-
设图的顶点数量为 n,邻接矩阵使用一个 n×n 大小的矩阵来表示图,每一行(列)代表一个顶点,矩阵元素代表边,用 1 或 0 表示两个顶点之间是否存在边
-
如下图所示,设邻接矩阵为 M、顶点列表为 V,那么矩阵元素 M[i, j] = 1 表示顶点 V[i] 到顶点 V[j] 之间存在边,反之,M[i, j] = 0 表示两顶点之间无边
-
邻接矩阵具有以下特性
- 顶点不能与自身相连,因此邻接矩阵主对角线元素没有意义
- 对于无向图,两个方向的边等价,此时邻接矩阵关于主对角线对称
- 将邻接矩阵的元素从 1 和 0 替换为权重,则可表示有权图
-
使用邻接矩阵表示图时,可以直接访问矩阵元素以获取边,因此增删查操作的效率很高,时间复杂度均为 O(1)。然而,矩阵的空间复杂度为 O(n^2),内存占用较多
30.3.2 邻接表
- 邻接表使用 n 个链表来表示图,链表节点表示顶点。第 i 条链表对应顶点 i,其中存储了该顶点的所有邻接顶点(即与该顶点相连的顶点)。下图展示了一个使用邻接表存储的图的示例
- 邻接表仅存储实际存在的边,而边的总数通常远小于 n^2,因此它更加节省空间。然而,在邻接表中需要通过遍历链表来查找边,因此其时间效率不如邻接矩阵
31. 分治
- 分治(divide and conquer,),通常基于递归实现,包括 “分” 和 “治” 两个步骤
- 分(划分阶段):递归地将原问题分解为两个或多个子问题,直至到达最小子问题时终止
- 治(合并阶段):从已知解的最小子问题开始,从底至顶地将子问题的解进行合并,从而构建出原问题的解
- “归并排序” 是分治策略的典型应用之一
- 分:递归地将原数组(原问题)划分为两个子数组(子问题),直到子数组只剩一个元素(最小子问题)
- 治:从底至顶地将有序的子数组(子问题的解)进行合并,从而得到有序的原数组(原问题的解)
-
如何判断分治问题?
- 问题可以被分解:原问题可以被分解成规模更小、类似的子问题,以及能够以相同方式递归地进行划分
- 子问题是独立的:子问题之间是没有重叠的,互相没有依赖,可以被独立解决
- 子问题的解可以被合并:原问题的解通过合并子问题的解得来
-
通过分治提升效率
- 分治不仅可以有效地解决算法问题,往往还可以带来算法效率的提升。在排序算法中,快速排序、归并排序、堆排序相较于选择、冒泡、插入排序更快,就是因为它们应用了分治策略
-
分治常见应用
- 1、寻找最近点对
- 该算法首先将点集分成两部分,然后分别找出两部分中的最近点对,最后再找出跨越两部分的最近点对
- 2、大整数乘法
- 例如 Karatsuba 算法,它是将大整数乘法分解为几个较小的整数的乘法和加法
- 3、矩阵乘法
- 例如 Strassen 算法,它是将大矩阵乘法分解为多个小矩阵的乘法和加法
- 4、汉诺塔问题
- 汉诺塔问题可以视为典型的分治策略,通过递归解决
- 5、求解逆序对
- 在一个序列中,如果前面的数字大于后面的数字,那么这两个数字构成一个逆序对。求解逆序对问题可以通过分治的思想,借助归并排序进行求解
- 1、寻找最近点对
32. 回溯
- 回溯算法是一种通过穷举来解决问题的方法,它的核心思想是从一个初始状态出发,暴力搜索所有可能的解决方案,当遇到正确的解则将其记录,直到找到解或者尝试了所有可能的选择都无法找到解为止
- 回溯算法通常采用 “深度优先搜索” 来遍历解空间
- 前序、中序和后序遍历都属于深度优先搜索
- 回溯算法本质上是一种深度优先搜索算法,它尝试所有可能的解决方案直到找到满足条件的解
- 这种方法的优势在于它能够找到所有可能的解决方案,而且在合理的剪枝操作下,具有很高的效率
- 在处理大规模或者复杂问题时,回溯算法的运行效率可能难以接受
- 时间:回溯算法通常需要遍历状态空间的所有可能,时间复杂度可以达到指数阶或阶乘阶
- 空间:在递归调用中需要保存当前的状态(例如路径、用于剪枝的辅助变量等),当深度很大时,空间需求可能会变得很大
- 常见的效率优化方法
- 剪枝:避免搜索那些肯定不会产生解的路径,从而节省时间和空间
- 启发式搜索:在搜索过程中引入一些策略或者估计值,从而优先搜索最有可能产生有效解的路径
33. 动态规划
-
动态规划将一个问题分解为一系列更小的子问题,并通过存储子问题的解来避免重复计算,从而大幅提升时间效率
- 问题:给定一个共有 n 阶的楼梯,你每步可以上 1 阶或者 2 阶,请问有多少种方案可以爬到楼顶?
- 下图所示,对于一个 3 阶楼梯,共有 3 种方案可以爬到楼顶
-
本题的目标是求解方案数量,可以考虑通过回溯来穷举所有可能性。具体来说,将爬楼梯想象为一个多轮选择的过程:从地面出发,每轮选择上 1 阶或 2 阶,每当到达楼梯顶部时就将方案数量加 1,当越过楼梯顶部时就将其剪枝
-
动态规划是一种 “从底至顶” 的方法:从最小子问题的解开始,迭代地构建更大子问题的解,直至得到原问题的解
- 由于动态规划不包含回溯过程,因此只需使用循环迭代实现,无须使用递归。在以下代码中,初始化一个数组 dp 来存储子问题的解
/* 爬楼梯:动态规划 */ int climbingStairsDP(int n) { if (n == 1 || n == 2) return n; // 初始化 dp 表,用于存储子问题的解 vector<int> dp(n + 1); // 初始状态:预设最小子问题的解 dp[1] = 1; dp[2] = 2; // 状态转移:从较小子问题逐步求解较大子问题 for (int i = 3; i <= n; i++) { dp[i] = dp[i - 1] + dp[i - 2]; } return dp[n]; }
根据以上内容,总结出动态规划的常用术语
- 将数组 d p dp dp 称为 d p dp dp 表, d p [ i ] dp[i] dp[i] 表示状态 i i i 对应子问题的解
- 将最小子问题对应的状态(即第 1 和 2 阶楼梯)称为初始状态
- 将递推公式 d p [ i ] = d p [ i − 1 ] + d p [ i − 2 ] dp[i]=dp[i-1]+dp[i-2] dp[i]=dp[i−1]+dp[i−2] 称为状态转移方程
34. 贪心算法
-
贪心算法是一种常见的解决优化问题的算法,其基本思想是:在问题的每个决策阶段,都选择当前看起来最优的选择,即贪心地做出局部最优的决策,以期望获得全局最优解
-
贪心算法和动态规划都常用于解决优化问题,它们之间的区别如下
- 动态规划会根据之前阶段的所有决策来考虑当前决策,并使用过去子问题的解来构建当前子问题的解
- 贪心算法不会重新考虑过去的决策,而是一路向前地进行贪心选择,不断缩小问题范围,直至问题被解决
问题:给定 n 种硬币,第 i 种硬币的面值为 coins[i-1],目标金额为 amt,每种硬币可以重复选取,问能够凑出目标金额的最少硬币个数,如果无法凑出目标金额则返回 -1
/* 零钱兑换:贪心 */
int coinChangeGreedy(vector<int> &coins, int amt) {
// 假设 coins 列表有序
int i = coins.size() - 1;
int count = 0;
// 循环进行贪心选择,直到无剩余金额
while (amt > 0) {
// 找到小于且最接近剩余金额的硬币
while (i > 0 && coins[i] > amt) {
i--;
}
// 选择 coins[i]
amt -= coins[i];
count++;
}
// 若未找到可行方案,则返回 -1
return amt == 0 ? count : -1;
}
34.1 贪心算法优缺点
- 贪心算法不仅操作直接、实现简单,而且通常效率也很高。在以上代码中,记硬币最小面值为 m i n ( c o i n s ) min(coins) min(coins),则贪心选择最多循环 a m t / m i n ( c o i n s ) amt/min(coins) amt/min(coins) 次,时间复杂度为 O ( a m t / m i n ( c o i n s ) ) O(amt/min(coins)) O(amt/min(coins))。这比动态规划解法的时间复杂度 O ( n ∗ a m t ) O(n*amt) O(n∗amt) 提升了一个数量级
- 然而,对于某些硬币面值组合,贪心算法并不能找到最优解
- 正例 c o i n s = [ 1 , 5 , 10 , 20 , 50 , 100 ] coins = [1, 5, 10, 20, 50, 100] coins=[1,5,10,20,50,100]:在该硬币组合下,给定任意 a m t amt amt,贪心算法都可以找出最优解
- 反例 1 c o i n s = [ 1 , 20 , 50 ] coins = [1, 20, 50] coins=[1,20,50]:假设 a m t = 60 amt = 60 amt=60,贪心算法只能找到 50 + 1 × 10 50 + 1×10 50+1×10 的兑换组合,共计 11 枚硬币,但动态规划可以找到最优解 20 + 20 + 20 20 + 20 + 20 20+20+20,仅需 3 枚硬币
- 反例 2 c o i n s = [ 1 , 49 , 50 ] coins = [1, 49, 50] coins=[1,49,50]:假设 a m t = 98 amt = 98 amt=98,贪心算法只能找到 50 + 1 × 48 50 + 1×48 50+1×48 的兑换组合,共计 49 枚硬币,但动态规划可以找到最优解 49 + 49 49 + 49 49+49,仅需 2 枚硬币
- 一般情况下,贪心算法适用于以下两类问题
- 可以保证找到最优解:贪心算法在这种情况下往往是最优选择,因为它往往比回溯、动态规划更高效
- 可以找到近似最优解:对于很多复杂问题来说,寻找全局最优解是非常困难的,能以较高效率找到次优解也是非常不错的
34.2 贪心典型例题
- 硬币找零问题
- 在某些硬币组合下,贪心算法总是可以得到最优解
- 区间调度问题
- 假设你有一些任务,每个任务在一段时间内进行,你的目标是完成尽可能多的任务。如果每次都选择结束时间最早的任务,那么贪心算法就可以得到最优解
- 分数背包问题
- 给定一组物品和一个载重量,你的目标是选择一组物品,使得总重量不超过载重量,且总价值最大。如果每次都选择性价比最高(价值 / 重量)的物品,那么贪心算法在一些情况下可以得到最优解
- 股票买卖问题
- 给定一组股票的历史价格,你可以进行多次买卖,但如果你已经持有股票,那么在卖出之前不能再买,目标是获取最大利润
- 霍夫曼编码
- 霍夫曼编码是一种用于无损数据压缩的贪心算法。通过构建霍夫曼树,每次选择出现频率最小的两个节点合并,最后得到的霍夫曼树的带权路径长度(即编码长度)最小
- Dijkstra 算法
- 它是一种解决给定源顶点到其余各顶点的最短路径问题的贪心算法