数据结构与算法(九):分治与回溯算法

参考引用

  • Hello 算法
  • Github:hello-algo

1. 分治算法

  • 分治(divide and conquer),全称分而治之,是一种非常重要且常见的算法策略。分治通常基于递归实现,包括 “分” 和 “治” 两个步骤

    • 分(划分阶段):递归地将原问题分解为两个或多个子问题,直至到达最小子问题时终止
    • 治(合并阶段):从已知解的最小子问题开始,从底至顶地将子问题的解进行合并,从而构建出原问题的解
  • “归并排序” 是分治策略的典型应用之一

    • 分:递归地将原数组(原问题)划分为两个子数组(子问题),直到子数组只剩一个元素(最小子问题)
    • 治:从底至顶地将有序的子数组(子问题的解)进行合并,从而得到有序的原数组(原问题的解)

数据结构与算法(九):分治与回溯算法_第1张图片

1.1 如何判断分治问题

  • 一个问题是否适合使用分治解决,通常可以参考以下几个判断依据
    • 问题可以被分解:原问题可以被分解成规模更小、类似的子问题,以及能够以相同方式递归地进行划分
    • 子问题是独立的:子问题之间是没有重叠的,互相没有依赖,可以被独立解决
    • 子问题的解可以被合并:原问题的解通过合并子问题的解得来

1.2 通过分治提升效率

  • 分治不仅可以有效地解决算法问题,往往还可以带来算法效率的提升。在排序算法中,快速排序、归并排序、堆排序相较于选择、冒泡、插入排序更快,就是因为它们应用了分治策略
1.2.1 操作数量优化
  • 以 “冒泡排序” 为例,其处理一个长度为 n n n 的数组需要 O ( n 2 ) O(n^2) O(n2) 时间。假设按下图所示方式,将数组从中点分为两个子数组,则划分需要 O ( n ) O(n) O(n) 时间,排序每个子数组需要 O ( ( n 2 ) 2 ) O((\frac{n}{2})^2) O((2n)2) 时间,合并两个子数组需要 O ( n ) O(n) O(n) 时间,总体时间复杂度为 O ( n + ( n 2 ) 2 × 2 + n ) = O ( n 2 2 + 2 n ) O(n+(\frac{n}{2})^2\times2+n)=O(\frac{n^2}{2}+2n) O(n+(2n)2×2+n)=O(2n2+2n)

数据结构与算法(九):分治与回溯算法_第2张图片

  • 下式中左边和右边分别为划分前和划分后的操作总数

n 2 − n 2 2 − 2 n > 0 n ( n − 4 ) > 0 \begin{aligned}n^2-\frac{n^2}2-2n&>0\\n(n-4)&>0\end{aligned} n22n22nn(n4)>0>0

  • 这意味着当 n > 4 n > 4 n>4 时,划分后的排序效率应该更高
    • 划分后的时间复杂度仍然是平方阶 O ( n 2 ) O(n^2) O(n2) ,只是复杂度中的常数项变小了
  • 如果把子数组不断地再从中点划分为两个子数组,直至子数组只剩一个元素时停止划分,这种思路实际上就是 “归并排序”,时间复杂度为 O ( n l o g n ) O(nlog n) O(nlogn)
  • 如果多设置几个划分点,将原数组平均划分为 k k k 个子数组,这种情况与 “桶排序” 非常类似,它非常适合排序海量数据,理论上时间复杂度可以达到 O ( n + k ) O(n + k) O(n+k)
1.2.2 并行计算优化
  • 分治生成的子问题是相互独立的,因此通常可以并行解决

    • 也就是说,分治不仅可以降低算法的时间复杂度,还有利于操作系统的并行优化
  • 并行优化在多核或多处理器的环境中尤其有效,因为系统可以同时处理多个子问题,更加充分地利用计算资源,从而显著减少总体的运行时间。如下图所示的 “桶排序” 中,将海量数据平均分配到各个桶中,则可将所有桶的排序任务分散到各个计算单元,完成后再进行结果合并

数据结构与算法(九):分治与回溯算法_第3张图片

1.3 分治常见应用

  • 寻找最近点对
    • 该算法首先将点集分成两部分,然后分别找出两部分中的最近点对,最后再找出跨越两部分的最近点对
  • 大整数乘法
    • 例如 Karatsuba 算法,它是将大整数乘法分解为几个较小的整数的乘法和加法
  • 矩阵乘法
    • 例如 Strassen 算法,它是将大矩阵乘法分解为多个小矩阵的乘法和加法
  • 汉诺塔问题
    • 汉诺塔问题可以视为典型的分治策略,通过递归解决
  • 求解逆序对
    • 在一个序列中,如果前面的数字大于后面的数字,那么这两个数字构成一个逆序对。求解逆序对问题可以通过分治的思想,借助归并排序进行求解

2. 分治搜索策略

  • 搜索算法分为两大类

    • 暴力搜索
      • 通过遍历数据结构实现
      • 时间复杂度为 O ( n ) O(n) O(n)
    • 自适应搜索
      • 利用特有的数据组织形式或先验信息
      • 时间复杂度可达到 O ( l o g n ) O(log n) O(logn) 甚至 O ( 1 ) O(1) O(1)
  • 实际上,时间复杂度为 O ( l o g n ) O(log n) O(logn) 的搜索算法通常都是基于分治策略实现的,例如二分查找和树

    • 二分查找的每一步都将问题(在数组中搜索目标元素)分解为一个小问题(在数组的一半中搜索目标元素),这个过程一直持续到数组为空或找到目标元素为止
    • 树是分治关系的代表,在二叉搜索树、AVL 树、堆等数据结构中,各种操作的时间复杂度皆为 O ( l o g n ) O(log n) O(logn)

分治能够提升搜索效率,本质上是因为暴力搜索每轮只能排除一个选项,而分治搜索每轮可以排除一半选项

基于分治实现二分查找

  • 之前内容中的二分查找是基于递推(迭代)实现,现在基于分治(递归) 来实现

问题:给定一个长度为 n 的有序数组 nums,数组中所有元素都是唯一的,请查找元素 target

  • 将搜索区间 [ i , j ] [i, j] [i,j] 对应的子问题记为 f ( i , j ) f(i, j) f(i,j)。从原问题 f ( 0 , n − 1 ) f(0, n-1) f(0,n1) 为起始点,通过以下步骤进行二分查找
    • 计算搜索区间 [ i , j ] [i, j] [i,j] 的中点 m m m,根据它排除一半搜索区间
    • 递归求解规模减小一半的子问题,可能为 f ( i , m − 1 ) f(i, m-1) f(i,m1) f ( m + 1 , j ) f(m+1, j) f(m+1,j)
    • 循环第 1 和 2 步,直至找到 target 或区间为空时返回
  • 下图展示了在数组中二分查找元素 6 的分治过程

数据结构与算法(九):分治与回溯算法_第4张图片

/* 二分查找:问题 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);
}

3. 构建二叉树问题

给定一个二叉树的前序遍历和中序遍历,请从中构建二叉树,返回二叉树的根节点

数据结构与算法(九):分治与回溯算法_第5张图片

3.1 如何划分子树

  • 前序遍历和中序遍历都可被划分为三个部分

    • 前序遍历:[ 根节点 | 左子树 | 右子树 ] ,如上图的树对应 [ 3 | 9 | 2 1 7 ]
    • 中序遍历:[ 左子树 | 根节点 | 右子树 ] ,如上图的树对应 [ 9 | 3 | 1 2 7 ]
  • 以上图数据为例,可以通过下图所示的步骤得到划分结果

    • 前序遍历的首元素 3 是根节点的值
    • 查找根节点 3 在中序遍历中的索引,利用该索引可将中序遍历划分为 [ 9 | 3 | 1 2 7 ]
    • 根据中序遍历划分结果,易得左子树和右子树的节点数量分别为 1 和 3,从而可将前序遍历划分为 [ 3 | 9 | 2 1 7 ]

数据结构与算法(九):分治与回溯算法_第6张图片

3.2 基于变量描述子树区间

  • 根据以上划分方法,已经得到根节点、左子树、右子树在前序遍历和中序遍历中的索引区间。而为了描述这些索引区间,需要借助几个指针变量

    • 将当前树的根节点在前序遍历中的索引记为 i
    • 将当前树的根节点在中序遍历中的索引记为 m
    • 将当前树在中序遍历中的索引区间记为 [l, r]
  • 如下图所示,通过以上变量即可表示根节点在前序遍历中的索引,以及子树在中序遍历中的索引区间

    • 右子树根节点索引中的 m-l 含义是 “左子树的节点数量”

数据结构与算法(九):分治与回溯算法_第7张图片

数据结构与算法(九):分治与回溯算法_第8张图片

/* 构建二叉树:分治 */
// 时间复杂度: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;
}

数据结构与算法(九):分治与回溯算法_第9张图片

4. 汉诺塔问题

  • 给定三根柱子,记为 A、B 和 C 。起始状态下,柱子 A 上套着 n 个圆盘,它们从上到下按照从小到大的顺序排列。我们的任务是要把这 n 个圆盘移到柱子 C 上,并保持它们的原有顺序不变。在移动圆盘的过程中,需要遵守以下规则。
    • 圆盘只能从一个柱子顶部拿出,从另一个柱子顶部放入
    • 每次只能移动一个圆盘
    • 小圆盘必须时刻位于大圆盘之上

将规模为 i 的汉诺塔问题记做 f(i)。例如 f(3) 代表将 3 个圆盘从 A 移动至 C 的汉诺塔问题

数据结构与算法(九):分治与回溯算法_第10张图片

4.1 考虑基本问题

  • 对于问题 f(1),即当只有一个圆盘时,将它直接从 A 移动至 C 即可

数据结构与算法(九):分治与回溯算法_第11张图片

  • 对于问题 f(2),即当有两个圆盘时,由于要时刻满足小圆盘在大圆盘之上,因此需要借助 B 来完成移动
    • 先将上面的小圆盘从 A 移至 B
    • 再将大圆盘从 A 移至 C
    • 最后将小圆盘从 B 移至 C

其中,C 称为目标柱、B 称为缓冲柱

数据结构与算法(九):分治与回溯算法_第12张图片

4.2 子问题分解

  • 对于问题 f(3),即当有三个圆盘时,因为已知 f(1) 和 f(2) 的解,所以可从分治角度思考,将 A 顶部的两个圆盘看做一个整体,执行下图所示的步骤。这样三个圆盘就被顺利地从 A 移动至 C 了
    • 令 B 为目标柱、C 为缓冲柱,将两个圆盘从 A 移动至 B
    • 将 A 中剩余的一个圆盘从 A 直接移动至 C
    • 令 C 为目标柱、A 为缓冲柱,将两个圆盘从 B 移动至 C

数据结构与算法(九):分治与回溯算法_第13张图片

  • 本质上看,将问题 f(3) 划分为两个子问题 f(2) 和子问题 f(1)。按顺序解决这三个子问题之后,原问题随之得到解决。这说明子问题是独立的,而且解是可以合并的。至此,可总结下图所示的汉诺塔问题的分治策略:将原问题 f(n) 划分为两个子问题 f(n-1) 和一个子问题 f(1),并按照以下顺序解决这三个子问题
    • 将 n-1 个圆盘借助 C 从 A 移至 B
    • 将剩余 1 个圆盘从 A 直接移至 C
    • 将 n-1 个圆盘借助 A 从 B 移至 C

数据结构与算法(九):分治与回溯算法_第14张图片

对于这两个子问题 f(n-1),可以通过相同的方式进行递归划分,直至达到最小子问题 f(1)。而 f(1) 的解是已知的,只需一次移动操作即可

4.3 代码实现

/* 移动一个圆盘 */
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);
}
  • 如下图所示,汉诺塔问题形成一个高度为 n 的递归树,每个节点代表一个子问题、对应一个开启的 dfs() 函数,因此时间复杂度为 O ( 2 n ) O(2^n) O(2n),空间复杂度为 O ( n ) O(n) O(n)

数据结构与算法(九):分治与回溯算法_第15张图片

5. 回溯算法

  • 回溯算法是一种通过穷举来解决问题的方法,它的核心思想是从一个初始状态出发,暴力搜索所有可能的解决方案,当遇到正确的解则将其记录,直到找到解或者尝试了所有可能的选择都无法找到解为止
    • 回溯算法通常采用 “深度优先搜索” 来遍历解空间
    • 前序、中序和后序遍历都属于深度优先搜索

例题一:给定一个二叉树,搜索并记录所有值为 7 的节点,请返回节点列表

/* 前序遍历:例题一 */
void preOrder(TreeNode *root) {
    if (root == nullptr) {
        return;
    }
    if (root->val == 7) {
        // 记录解
        res.push_back(root);
    }
    preOrder(root->left);
    preOrder(root->right);
}

数据结构与算法(九):分治与回溯算法_第16张图片

5.1 尝试与回退

  • 之所以称之为回溯算法,是因为该算法在搜索解空间时会采用 “尝试” 与 “回退” 策略。当算法在搜索过程中遇到某个状态无法继续前进或无法得到满足条件的解时,它会撤销上一步的选择,回退到之前的状态,并尝试其他可能选择

  • 对于例题一,访问每个节点都代表一次 “尝试”,而越过叶结点或返回父节点的 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();
}

数据结构与算法(九):分治与回溯算法_第17张图片

5.2 剪枝

  • 复杂的回溯问题通常包含一个或多个约束条件,约束条件通常可用于 “剪枝”

例题三:在二叉树中搜索所有值为 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();
}

数据结构与算法(九):分治与回溯算法_第18张图片

5.3 框架代码

/* 回溯算法框架 */
// 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);
        }
    }
}

5.4 常用术语

数据结构与算法(九):分治与回溯算法_第19张图片

5.5 优缺点

  • 回溯算法本质上是一种深度优先搜索算法,它尝试所有可能的解决方案直到找到满足条件的解
    • 这种方法的优势在于它能够找到所有可能的解决方案,而且在合理的剪枝操作下,具有很高的效率
    • 在处理大规模或者复杂问题时,回溯算法的运行效率可能难以接受
      • 时间:回溯算法通常需要遍历状态空间的所有可能,时间复杂度可以达到指数阶或阶乘阶
      • 空间:在递归调用中需要保存当前的状态(例如路径、用于剪枝的辅助变量等),当深度很大时,空间需求可能会变得很大
    • 常见的效率优化方法
      • 剪枝:避免搜索那些肯定不会产生解的路径,从而节省时间和空间
      • 启发式搜索:在搜索过程中引入一些策略或者估计值,从而优先搜索最有可能产生有效解的路径

6. N 皇后问题

问题:根据国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。给定 n 个皇后和一个 n×n 大小的棋盘,寻找使得所有皇后之间无法相互攻击的摆放方案

  • 下图所示:当 n = 4 时,共可以找到两个解。从回溯算法的角度看,n×n 大小的棋盘共有 n 2 n^2 n2 个格子,给出了所有的选择 choices 。在逐个放置皇后的过程中,棋盘状态在不断地变化,每个时刻的棋盘就是状态 state

数据结构与算法(九):分治与回溯算法_第20张图片

  • 下图展示了本题的三个约束条件:多个皇后不能在同一行、同一列、同一对角线
    • 值得注意的是,对角线分为主对角线 \ 和次对角线 / 两种

数据结构与算法(九):分治与回溯算法_第21张图片

6.1 逐行放置策略

  • 皇后的数量和棋盘的行数都为 n,因此容易得到一个推论:棋盘每行都允许且只允许放置一个皇后。也就是说,可以采取逐行放置策略:从第一行开始,在每行放置一个皇后,直至最后一行结束

  • 下图所示,为 4 皇后问题的逐行放置过程

    • 受画幅限制,下图仅展开了第一行的其中一个搜索分支,并且将不满足列约束和对角线约束的方案都进行了剪枝
    • 本质上看,逐行放置策略起到了剪枝的作用,它避免了同一行出现多个皇后的所有搜索分支

数据结构与算法(九):分治与回溯算法_第22张图片

6.2 列与对角线剪枝

  • 为满足列约束,可以利用一个长度为 n 的布尔型数组 cols 记录每一列是否有皇后。在每次决定放置前,通过 cols 将已有皇后的列进行剪枝,并在回溯中动态更新 cols 的状态

  • 那么,如何处理对角线约束呢?

    • 设棋盘中某个格子的行列索引为 (row, col),选定矩阵中的某条主对角线,发现该对角线上所有格子的行索引减列索引都相等,即对角线上所有格子的 row - col 为恒定值
    • 也就是说,如果两个格子满足 r o w 1 − c o l 1 = r o w 2 − c o l 2 row_1 - col_1 = row_2 - col_2 row1col1=row2col2,则它们一定处在同一条主对角线上。利用该规律,可以借助下图所示的数组 diag1,记录每条主对角线上是否有皇后
    • 同理,次对角线上的所有格子的 row + col 是恒定值,同样也可以借助数组 diag2 来处理次对角线约束

6.3 代码实现

  • n 维方阵中 row - col 的范围是 [-n+1, n-1],row + col 的范围是 [0, 2n-2],所以主对角线和次对角线的数量都为 2n-1,即数组 diag1 和 diag2 的长度都为 2n-1
    /* 回溯算法: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;
    }
    

你可能感兴趣的:(数据结构与算法,算法,数据结构,c++,学习,笔记,分治,回溯)