参考引用
分治(divide and conquer),全称分而治之,是一种非常重要且常见的算法策略。分治通常基于递归实现,包括 “分” 和 “治” 两个步骤
“归并排序” 是分治策略的典型应用之一
n
2
−
n
2
2
−
2
n
>
0
n
(
n
−
4
)
>
0
- 如果把子数组不断地再从中点划分为两个子数组,直至子数组只剩一个元素时停止划分,这种思路实际上就是 “归并排序”,时间复杂度为 O ( n l o g n ) O(nlog n) O(nlogn)
- 如果多设置几个划分点,将原数组平均划分为 k k k 个子数组,这种情况与 “桶排序” 非常类似,它非常适合排序海量数据,理论上时间复杂度可以达到 O ( n + k ) O(n + k) O(n+k)
分治生成的子问题是相互独立的,因此通常可以并行解决
并行优化在多核或多处理器的环境中尤其有效,因为系统可以同时处理多个子问题,更加充分地利用计算资源,从而显著减少总体的运行时间。如下图所示的 “桶排序” 中,将海量数据平均分配到各个桶中,则可将所有桶的排序任务分散到各个计算单元,完成后再进行结果合并
搜索算法分为两大类
实际上,时间复杂度为 O ( l o g n ) O(log n) O(logn) 的搜索算法通常都是基于分治策略实现的,例如二分查找和树
分治能够提升搜索效率,本质上是因为暴力搜索每轮只能排除一个选项,而分治搜索每轮可以排除一半选项
问题:给定一个长度为 n 的有序数组 nums,数组中所有元素都是唯一的,请查找元素 target
/* 二分查找:问题 f(i, j) */
int dfs(vector<int> &nums, int target, int i, int j) {
// 若区间为空,代表无目标元素,则返回 -1
if (i > j) {
return -1;
}
// 计算中点索引 m
int m = (i + j) / 2;
if (nums[m] < target) {
// 递归子问题 f(m+1, j)
return dfs(nums, target, m + 1, j);
} else if (nums[m] > target) {
// 递归子问题 f(i, m-1)
return dfs(nums, target, i, m - 1);
} else {
// 找到目标元素,返回其索引
return m;
}
}
/* 二分查找 */
int binarySearch(vector<int> &nums, int target) {
int n = nums.size();
// 求解问题 f(0, n-1)
return dfs(nums, target, 0, n - 1);
}
给定一个二叉树的前序遍历和中序遍历,请从中构建二叉树,返回二叉树的根节点
前序遍历和中序遍历都可被划分为三个部分
以上图数据为例,可以通过下图所示的步骤得到划分结果
根据以上划分方法,已经得到根节点、左子树、右子树在前序遍历和中序遍历中的索引区间。而为了描述这些索引区间,需要借助几个指针变量
如下图所示,通过以上变量即可表示根节点在前序遍历中的索引,以及子树在中序遍历中的索引区间
/* 构建二叉树:分治 */
// 时间复杂度:O(n)
// 空间复杂度:O(n)
TreeNode *dfs(vector<int> &preorder, unordered_map<int, int> &inorderMap, int i, int l, int r) {
// 子树区间为空时终止
if (r - l < 0)
return NULL;
// 初始化根节点
TreeNode *root = new TreeNode(preorder[i]);
// 查询 m ,从而划分左右子树
int m = inorderMap[preorder[i]];
// 子问题:构建左子树
root->left = dfs(preorder, inorderMap, i + 1, l, m - 1);
// 子问题:构建右子树
root->right = dfs(preorder, inorderMap, i + 1 + m - l, m + 1, r);
// 返回根节点
return root;
}
/* 构建二叉树 */
TreeNode *buildTree(vector<int> &preorder, vector<int> &inorder) {
// 初始化哈希表,存储 inorder 元素到索引的映射
unordered_map<int, int> inorderMap;
for (int i = 0; i < inorder.size(); i++) {
inorderMap[inorder[i]] = i;
}
TreeNode *root = dfs(preorder, inorderMap, 0, 0, inorder.size() - 1);
return root;
}
将规模为 i 的汉诺塔问题记做 f(i)。例如 f(3) 代表将 3 个圆盘从 A 移动至 C 的汉诺塔问题
其中,C 称为目标柱、B 称为缓冲柱
对于这两个子问题 f(n-1),可以通过相同的方式进行递归划分,直至达到最小子问题 f(1)。而 f(1) 的解是已知的,只需一次移动操作即可
/* 移动一个圆盘 */
void move(vector<int> &src, vector<int> &tar) {
// 从 src 顶部拿出一个圆盘
int pan = src.back();
src.pop_back();
// 将圆盘放入 tar 顶部
tar.push_back(pan);
}
/* 求解汉诺塔:问题 f(i) */
// dfs() 作用是将柱 src 顶部的 i 个圆盘借助缓冲柱 buf 移动至目标柱 tar
void dfs(int i, vector<int> &src, vector<int> &buf, vector<int> &tar) {
// 若 src 只剩下一个圆盘,则直接将其移到 tar
if (i == 1) {
move(src, tar);
return;
}
// 子问题 f(i-1) :将 src 顶部 i-1 个圆盘借助 tar 移到 buf
dfs(i - 1, src, tar, buf);
// 子问题 f(1) :将 src 剩余一个圆盘移到 tar
move(src, tar);
// 子问题 f(i-1) :将 buf 顶部 i-1 个圆盘借助 src 移到 tar
dfs(i - 1, buf, src, tar);
}
/* 求解汉诺塔 */
void solveHanota(vector<int> &A, vector<int> &B, vector<int> &C) {
int n = A.size();
// 将 A 顶部 n 个圆盘借助 B 移到 C
dfs(n, A, B, C);
}
例题一:给定一个二叉树,搜索并记录所有值为 7 的节点,请返回节点列表
/* 前序遍历:例题一 */
void preOrder(TreeNode *root) {
if (root == nullptr) {
return;
}
if (root->val == 7) {
// 记录解
res.push_back(root);
}
preOrder(root->left);
preOrder(root->right);
}
之所以称之为回溯算法,是因为该算法在搜索解空间时会采用 “尝试” 与 “回退” 策略。当算法在搜索过程中遇到某个状态无法继续前进或无法得到满足条件的解时,它会撤销上一步的选择,回退到之前的状态,并尝试其他可能选择
对于例题一,访问每个节点都代表一次 “尝试”,而越过叶结点或返回父节点的 return 则表示 “回退”,回退并不仅仅包括函数返回
例题二:在二叉树中搜索所有值为 7 的节点,请返回根节点到这些节点的路径
- 在例题一代码的基础上,需要借助一个列表 path 记录访问过的节点路径。当访问到值为 7 的节点时,则复制 path 并添加进结果列表 res 。遍历完成后,res 中保存的就是所有的解
/* 前序遍历:例题二 */
void preOrder(TreeNode *root) {
if (root == nullptr) {
return;
}
// 尝试
path.push_back(root);
if (root->val == 7) {
// 记录解
res.push_back(path);
}
preOrder(root->left);
preOrder(root->right);
// 回退
path.pop_back();
}
例题三:在二叉树中搜索所有值为 7 的节点,请返回根节点到这些节点的路径,并要求路径中不包含值为 3 的节点
- 为满足以上约束条件,需要添加剪枝操作:在搜索过程中,若遇到值为 3 的节点,则提前返回并停止搜索
/* 前序遍历:例题三 */
void preOrder(TreeNode *root) {
// 剪枝
if (root == nullptr || root->val == 3) {
return;
}
// 尝试
path.push_back(root);
if (root->val == 7) {
// 记录解
res.push_back(path);
}
preOrder(root->left);
preOrder(root->right);
// 回退
path.pop_back();
}
/* 回溯算法框架 */
// state 表示问题的当前状态,choices 表示当前状态下可以做出的选择
void backtrack(State *state, vector<Choice *> &choices, vector<State *> &res) {
// 判断是否为解
if (isSolution(state)) {
// 记录解
recordSolution(state, res);
// 停止继续搜索
return;
}
// 遍历所有选择
for (Choice choice : choices) {
// 剪枝:判断选择是否合法
if (isValid(state, choice)) {
// 尝试:做出选择,更新状态
makeChoice(state, choice);
backtrack(state, choices, res);
// 回退:撤销选择,恢复到之前的状态
undoChoice(state, choice);
}
}
}
问题:根据国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。给定 n 个皇后和一个 n×n 大小的棋盘,寻找使得所有皇后之间无法相互攻击的摆放方案
皇后的数量和棋盘的行数都为 n,因此容易得到一个推论:棋盘每行都允许且只允许放置一个皇后。也就是说,可以采取逐行放置策略:从第一行开始,在每行放置一个皇后,直至最后一行结束
下图所示,为 4 皇后问题的逐行放置过程
为满足列约束,可以利用一个长度为 n 的布尔型数组 cols 记录每一列是否有皇后。在每次决定放置前,通过 cols 将已有皇后的列进行剪枝,并在回溯中动态更新 cols 的状态
那么,如何处理对角线约束呢?
/* 回溯算法:N 皇后 */
// 时间复杂度:O(n!),逐行放置 n 次,考虑列约束,则从第一行到最后一行分别有 n、n-1、...、2、1 个选择
// 空间复杂度:O(n^2),数组 state 使用 O(n^2)空间,数组 cols、diags1 和 diags2 皆使用 O(n) 空间
void backtrack(int row, int n, vector<vector<string>> &state, vector<vector<vector<string>>> &res,
vector<bool> &cols, vector<bool> &diags1, vector<bool> &diags2) {
// 当放置完所有行时,记录解
if (row == n) {
res.push_back(state);
return;
}
// 遍历所有列
for (int col = 0; col < n; col++) {
// 计算该格子对应的主对角线和副对角线
int diag1 = row - col + n - 1;
int diag2 = row + col;
// 剪枝:不允许该格子所在列、主对角线、副对角线存在皇后
if (!cols[col] && !diags1[diag1] && !diags2[diag2]) {
// 尝试:将皇后放置在该格子
state[row][col] = "Q";
cols[col] = diags1[diag1] = diags2[diag2] = true;
// 放置下一行
backtrack(row + 1, n, state, res, cols, diags1, diags2);
// 回退:将该格子恢复为空位
state[row][col] = "#";
cols[col] = diags1[diag1] = diags2[diag2] = false;
}
}
}
/* 求解 N 皇后 */
vector<vector<vector<string>>> nQueens(int n) {
// 初始化 n*n 大小的棋盘,其中 'Q' 代表皇后,'#' 代表空位
vector<vector<string>> state(n, vector<string>(n, "#"));
vector<bool> cols(n, false); // 记录列是否有皇后
vector<bool> diags1(2 * n - 1, false); // 记录主对角线是否有皇后
vector<bool> diags2(2 * n - 1, false); // 记录副对角线是否有皇后
vector<vector<vector<string>>> res;
backtrack(0, n, state, res, cols, diags1, diags2);
return res;
}