草庐IT

通过4种经典应用,带你熟悉回溯算法

华为云开发者社区 2023-04-24 原文
摘要:回溯的处理思想,有点类似枚举搜索。

本文分享自华为云社区《深入浅出回溯算法》,作者:嵌入式视觉。

一,如何理解回溯算法

深度优先搜索算法利用的就是回溯算法思想,但它除了用来指导像深度优先搜索这种经典的算法设计之外,还可以用在很多实际的软件开发场景中,比如正则表达式匹配、编译原理中的语法分析等。

除此之外,很多经典的数学问题都可以用回溯算法解决,比如数独、八皇后、0-1 背包、图的着色、旅行商问题、全排列等等。

回溯的处理思想,有点类似枚举搜索。暴力枚举所有的解,找到满足期望的解。为了有规律地枚举所有可能的解,避免遗漏和重复,我们把问题求解的过程分为多个阶段。每个阶段,我们都会面对一个岔路口,我们先随意选一条路走,当发现这条路走不通的时候(不符合期望的解),就回退到上一个岔路口,另选一种走法继续走。

回溯算法的模板代码总结如下:

void backtracking(参数) {
 if (终止条件) {
 存放结果;
 return;
 }
 for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
 处理节点;
 backtracking(路径,选择列表); // 递归
 回溯,撤销处理结果
 }
}

二,回溯算法的经典应用

2.1,八皇后问题

有一个 8x8 的棋盘,希望往里放 8 个棋子(皇后),每个棋子所在的行、列、对角线都不能有另一个棋子。这里的“对角线”指的是所有的对角线,不只是平分整个棋盘的那两条对角线。

解决思路:可以把这个问题划分成 8 个阶段,依次将 8 个棋子放到第一行、第二行、第三行……第八行,每一行都有 8 中放法(8 列)。在放置的过程中,我们不停地检查当前放法,是否满足要求。如果满足,则跳到下一行继续放置棋子;如果不满足,那就再换一种放法,继续尝试。这里用的是回溯思想,而回溯算法也非常适合用递归代码实现。

// N 皇后问题 leetcode 51 https://leetcode-cn.com/problems/n-queens/
class Solution {
private:
    vector<vector<string>> result;
 void backtracking(int n, int row, vector<string>& chessboard){
 if(row == n) {
 result.push_back(chessboard);
 return;
 }
 for(int column=0; column < n; column++){ // 每一行都有8中放法
 if (isOK(row, column, n, chessboard)){
                chessboard[row][column] = 'Q'; // 放置皇后
 backtracking(n, row+1, chessboard);
                chessboard[row][column] = '.'; // 回溯,撤销处理结果
 }
 }
 }
 // 判断 row 行 column 列放置皇后是否合适
    bool isOK(int row, int column, int n, vector<string>& chessboard){
        int leftup = column - 1; int rightup = column + 1; // 左上角和右上角
 for(int i = row-1; i>=0; i--){ // 逐行网上考察每一行
 // 判断第 i 行的 column 列是否有棋子
 if(chessboard[i][column] == 'Q') {
 return false;
 }
 // 考察左上对角线:判断第i行leftup列是否有棋子 
 if(leftup >=0 ){
 if(chessboard[i][leftup] == 'Q') return false;
 }
 // 考察左上对角线:判断第i行rightup列是否有棋子
 if(rightup < n){
 if(chessboard[i][rightup] == 'Q') return false;
 }
 --leftup;
 ++rightup;
 }
 return true;
 } 
public:
    vector<vector<string>> solveNQueens(int n) {
 result.clear();
 std::vector<std::string> chessboard(n, std::string(n, '.'));
 backtracking(n, 0, chessboard);
 return result;
 }
};

2.2,0-1 背包问题

0-1 背包是非常经典的算法问题。0-1 背包问题有很多变体,这里介绍一种比较基础的。我们有一个背包,背包总的承载重量是 W kg。现在我们有 n 个物品,每个物品的重量不等,并且不可分割,即对于每个物品来说,都有两种选择,装进背包或者不装进背包,对于 n 个物品来说,总的装法就有 2^n 种。

我们现在期望选择几件物品,装载到背包中。在不超过背包所能装载重量 W 的前提下,如何让背包中物品的总重量最大?

0-1 背包问题为什么不能用贪心算法求解?

因为不可分割,所以无法判断当前情况下,哪种物品对期望值贡献更大,即不存在当前最优的选择,所以就无法使用贪心算法了。

0-1 背包问题的高效解法是动态规划算法,但也可用没那么高效的回溯方法求解。我们可以把物品依次排列,整个问题就分解为了 n 个阶段,每个阶段对应一个物品怎么选择。先对第一个物品进行处理,选择装进去或者不装进去,然后再递归地处理剩下的物品。

int maxW = 0;
// cw 表示当前装进背包的物品的重量和,w 表示背包承载的重量
// items  表示物体的重量数组,n 表示总的物品个数, i 表示考察到第 i 个物品
int f(int i, int cw, vector<int> items, int n, int w){
 // 递归结束条件:cw == w 表示背包已经装满,i==n 表示考察完所有物品
 if(cw == w || i == n){
 if(cw > maxW) maxW = cw;
 return;
 }
 f(i+1, cw, items, n, w); // 不装
 // 剪枝过程,当装入的物品重量大于背包的重量,就不继续执行
 if(cw+items[i] <= w){
 f(i+1, cw+items[i], items, n, w); //
 }
}

要理解 0-1 背包问题回溯解法的关键在于:对于一个物品而言,只有两种情况,不装入背包和装入背包两种情况。对应的就是 f(i+1, cw, items, n, w) 和 f(i+1, cw + items[i], items, n, w) 两个函数。

2.3,通配符匹配

假设正则表达式中只包含 “*” 和 “?” 这两种通配符,并且对这两个通配符的语义稍微做些改变,其中,“*” 匹配任意多个(大于等于 0 个)任意字符,“?” 匹配零个或者一个任意字符。基于以上背景假设,如何用回溯算法,判断一个给定的文本,是否和给定的正则表达式匹配?

如果遇到特殊字符的时候,我们就有多种处理方式了,也就是所谓的岔路口,比如 “*” 有多种匹配方案,可以匹配任意个文本串中的字符,我们就先随意的选择一种匹配方案,然后继续考察剩下的字符。如果中途发现无法继续匹配下去了,我们就回到这个岔路口,重新选择一种匹配方案,然后再继续匹配剩下的字符。

// 暴力递归 --> 记忆化 --> DP --> 状态压缩DP;
class Solution{
private:
    bool matched = false;
 void backtracking(int ti, int pj, string text, string pattern){
 if (matched) return;
 if(pj == pattern.size()){ // 正则表达式到末尾了
 if(ti == text.size())
                matched = true;
 return;
 }
 // *匹配任意个字符
 if(pattern[pj] == '*'){
 for(int k=0; k< text.size()-ti;k++)
 backtracking(ti+k, pj+1, text, pattern);
 }
 // ?匹配0个或者1个字符
 else if(pattern[pj] == '?'){
 backtracking(ti, pj+1, text, pattern);
 backtracking(ti+1, pj+1, text, pattern);
 }
 // 纯字符匹配才行 
 else if(ti < pattern.size() && pattern[pj] == text[ti]) { 
 backtracking(ti+1, pj+1, text, pattern);
 }
 }
public:
    bool isMatch(string text, string pattern){
        matched = false;
 backtracking(0, 0, text, pattern);
 return matched;
 }
};

2.4,leetcode 正则表达式匹配

在 leetcode 也有变形题(leetcode10:正则表达式匹配)如下:

其他变形题:leetcode44-通配符匹配

给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 '.' 和 '*' 的正则表达式匹配。

  • ‘.’ 匹配任意单个字符
  • ‘*’ 匹配零个或多个前面的那一个元素

所谓匹配,是要涵盖整个字符串 s 的,而不是部分字符串。

方法一:回溯(分阶段分情况讨论,暴力搜索和剪枝)

首先,考虑特俗字符只有 '.' 的情况。这种情况会很简单:我们只需要从左到右依次判断 s[i] 和 p[i] 是否匹配。

def isMatch(self,s:str, p:str) -> bool:
 """字符串 s 和字符规律 p"""
 if not p: return not s # 边界条件
 first_match = s and p[0] in {s[0],'.'} # 比较第一个字符是否匹配
 return first_match and self.isMatch(s[1:], p[1:])

最后,考虑有 ’*' 的情况,它会出现在 p[1] 的位置,匹配过程中会出现两种情况:

  • 星号代表匹配 0 个前面的元素。如 '##' 和 a*##,这时我们直接忽略 p 的 a*,比较 ## 和 ##,也就是继续递归比较 s 和 p[i + 2:];
  • 星号代表匹配一个或多个前面的元素。如 aaab 和 a*b,这时我们将忽略 s 的第一个元素,比较 aab 和 a*b,也就是继续递归比较 s[i + 1:] 和 p。(这里默认要检查 s[0] 和 p[0] 是否相等)。

Python3 代码如下:

class Solution:
 def isMatch(self, s: str, p: str) -> bool:
 if not p: return not s
 first_match = bool(s and p[0] in {s[0],'.'}) # 比较第一个字符是否匹配
 if len(p) >=2 and p[1] == '*':
 # * 匹配前面一个字符 0 次或者多次
 return self.isMatch(s, p[2:]) or first_match and self.isMatch(s[1:], p)
 else:
 return first_match and self.isMatch(s[1:], p[1:])

C++ 代码如下:

// letcode10 正则表达式匹配
#include <vector>
#include <string>
using namespace std;
class Solution{
public:
    bool isMatch(string s, string p){
 // 如果正则串 p 为空字符串,s 也为空,则匹配成功
 if(p.empty()) return (s.empty());
 // 判断 s 和 p 的首字符是否匹配,注意要先判断 s 不为空
        bool match = (!s.empty()) && (s[0] == p[0] || p[0] == '.');
 // 如果p的第一个元素的下一个元素是 *,则分别对两种情况进行判断
 if(p.size() >= 2 && p[1] == '*'){
 // * 匹配前面一个字符 0 次或者多次
 return isMatch(s, p.substr(2)) || (match && isMatch(s.substr(1), p));
 }
 else{ // 单个匹配
 return match && isMatch(s.substr(1), p.substr(1));
 }
 }
};

直接递归时间复杂度太大(指数级),可以把之前的递归过程记录下来,用空间换时间。记忆化递归的 C++ 代码如下:

class Solution{
public:
    bool isMatch(string s, string p){
 unordered_map<int, bool> memo;
 return backtracking(s, 0, p, 0, memo);
 }
    bool backtracking(string s, int i, string p, int j, unordered_map<int, bool> & memo){
 // # 检查 s[i] 是否能被匹配,注意要先判断 s 不为空
        bool match = (i < s.size()) && (s[i] == p[j] || p[j] == '.');
 if(j >= p.size()) return i >= s.size(); // p 和 s 同时遍历完
        int key = i * (p.size() + 1) + j; // 哈希键
 if (memo.find(key) != memo.end()) // 这个状态之前经历过,可以返回结果
 return memo[key];
 else if (i == s.size() && j == p.size()) // 如果s和p同时用完,匹配成功
 return memo[key] = true;
 else if((p.size()-j) >= 2 && p[j+1] == '*'){
 // * 匹配前面一个字符 0 次或者多次
 if(backtracking(s, i, p, j+2, memo) || match && backtracking(s, i+1, p, j, memo))
 return memo[key] = true;
 }
 else { // 单个匹配
 if(match && backtracking(s, i+1, p, j+1, memo))
 return memo[key] = true;
 }
 return memo[key] = false; // 没辙了,匹配失败
 }
};

方法二:动态规划法

  • [ ] 算法思路
  • [ ] 代码

三,总结

回溯算法的思想非常简单,大部分情况下,都是用来解决广义的搜索问题,也就是,从一组可能的解中,选择出一个满足要求的解。回溯算法非常适合用递归来实现,在实现的过程中,剪枝操作是提高回溯效率的一种技巧。利用剪枝,我们并不需要穷举搜索所有的情况,从而提高搜索效率。

尽管回溯算法的原理非常简单,但是却可以解决很多问题,比如我们开头提到的深度优先搜索、八皇后、0-1 背包问题、图的着色、旅行商问题、数独、全排列、正则表达式匹配等等。

回溯算法能解决的问题,基本用动态规划也能解决,其时间复杂度更低,空间复杂度更高,用空间换时间。

参考资料

  • leetcode 8皇后问题题解
  • 回溯算法:从电影《蝴蝶效应》中学习回溯算法的核心思想
  • 腐烂的橘子题解-回溯和动态规划

 

点击关注,第一时间了解华为云新鲜技术~

有关通过4种经典应用,带你熟悉回溯算法的更多相关文章

  1. ruby - 将差异补丁应用于字符串/文件 - 2

    对于具有离线功能的智能手机应用程序,我正在为Xml文件创建单向文本同步。我希望我的服务器将增量/差异(例如GNU差异补丁)发送到目标设备。这是计划:Time=0Server:hasversion_1ofXmlfile(~800kiB)Client:hasversion_1ofXmlfile(~800kiB)Time=1Server:hasversion_1andversion_2ofXmlfile(each~800kiB)computesdeltaoftheseversions(=patch)(~10kiB)sendspatchtoClient(~10kiBtransferred)Cl

  2. ruby - 通过 rvm 升级 ruby​​gems 的问题 - 2

    尝试通过RVM将RubyGems升级到版本1.8.10并出现此错误:$rvmrubygemslatestRemovingoldRubygemsfiles...Installingrubygems-1.8.10forruby-1.9.2-p180...ERROR:Errorrunning'GEM_PATH="/Users/foo/.rvm/gems/ruby-1.9.2-p180:/Users/foo/.rvm/gems/ruby-1.9.2-p180@global:/Users/foo/.rvm/gems/ruby-1.9.2-p180:/Users/foo/.rvm/gems/rub

  3. ruby-on-rails - Rails 应用程序之间的通信 - 2

    我构建了两个需要相互通信和发送文件的Rails应用程序。例如,一个Rails应用程序会发送请求以查看其他应用程序数据库中的表。然后另一个应用程序将呈现该表的json并将其发回。我还希望一个应用程序将存储在其公共(public)目录中的文本文件发送到另一个应用程序的公共(public)目录。我从来没有做过这样的事情,所以我什至不知道从哪里开始。任何帮助,将不胜感激。谢谢! 最佳答案 无论Rails是什么,几乎所有Web应用程序都有您的要求,大多数现代Web应用程序都需要相互通信。但是有一个小小的理解需要你坚持下去,网站不应直接访问彼此

  4. ruby - 无法运行 Rails 2.x 应用程序 - 2

    我尝试运行2.x应用程序。我使用rvm并为此应用程序设置其他版本的ruby​​:$rvmuseree-1.8.7-head我尝试运行服务器,然后出现很多错误:$script/serverNOTE:Gem.source_indexisdeprecated,useSpecification.Itwillberemovedonorafter2011-11-01.Gem.source_indexcalledfrom/Users/serg/rails_projects_terminal/work_proj/spohelp/config/../vendor/rails/railties/lib/r

  5. ruby - 通过 erb 模板输出 ruby​​ 数组 - 2

    我正在使用puppet为ruby​​程序提供一组常量。我需要提供一组主机名,我的程序将对其进行迭代。在我之前使用的bash脚本中,我只是将它作为一个puppet变量hosts=>"host1,host2"我将其提供给bash脚本作为HOSTS=显然这对ruby​​不太适用——我需要它的格式hosts=["host1","host2"]自从phosts和putsmy_array.inspect提供输出["host1","host2"]我希望使用其中之一。不幸的是,我终其一生都无法弄清楚如何让它发挥作用。我尝试了以下各项:我发现某处他们指出我需要在函数调用前放置“function_”……这

  6. ruby-on-rails - Rails 应用程序中的 Rails : How are you using application_controller. rb 是新手吗? - 2

    刚入门rails,开始慢慢理解。有人可以解释或给我一些关于在application_controller中编码的好处或时间和原因的想法吗?有哪些用例。您如何为Rails应用程序使用应用程序Controller?我不想在那里放太多代码,因为据我了解,每个请求都会调用此Controller。这是真的? 最佳答案 ApplicationController实际上是您应用程序中的每个其他Controller都将从中继承的类(尽管这不是强制性的)。我同意不要用太多代码弄乱它并保持干净整洁的态度,尽管在某些情况下ApplicationContr

  7. ruby - 通过 ruby​​ 进程共享变量 - 2

    我正在编写一个gem,我必须在其中fork两个启动两个webrick服务器的进程。我想通过基类的类方法启动这个服务器,因为应该只有这两个服务器在运行,而不是多个。在运行时,我想调用这两个服务器上的一些方法来更改变量。我的问题是,我无法通过基类的类方法访问fork的实例变量。此外,我不能在我的基类中使用线程,因为在幕后我正在使用另一个不是线程安全的库。所以我必须将每个服务器派生到它自己的进程。我用类变量试过了,比如@@server。但是当我试图通过基类访问这个变量时,它是nil。我读到在Ruby中不可能在分支之间共享类变量,对吗?那么,还有其他解决办法吗?我考虑过使用单例,但我不确定这是

  8. ruby - 通过 RVM (OSX Mountain Lion) 安装 Ruby 2.0.0-p247 时遇到问题 - 2

    我的最终目标是安装当前版本的RubyonRails。我在OSXMountainLion上运行。到目前为止,这是我的过程:已安装的RVM$\curl-Lhttps://get.rvm.io|bash-sstable检查已知(我假设已批准)安装$rvmlistknown我看到当前的稳定版本可用[ruby-]2.0.0[-p247]输入命令安装$rvminstall2.0.0-p247注意:我也试过这些安装命令$rvminstallruby-2.0.0-p247$rvminstallruby=2.0.0-p247我很快就无处可去了。结果:$rvminstall2.0.0-p247Search

  9. ruby-on-rails - Enumerator.new 如何处理已通过的 block ? - 2

    我在理解Enumerator.new方法的工作原理时遇到了一些困难。假设文档中的示例:fib=Enumerator.newdo|y|a=b=1loopdoy[1,1,2,3,5,8,13,21,34,55]循环中断条件在哪里,它如何知道循环应该迭代多少次(因为它没有任何明确的中断条件并且看起来像无限循环)? 最佳答案 Enumerator使用Fibers在内部。您的示例等效于:require'fiber'fiber=Fiber.newdoa=b=1loopdoFiber.yieldaa,b=b,a+bendend10.times.m

  10. ruby-on-rails - 如何在我的 Rails 应用程序 View 中打印 ruby​​ 变量的内容? - 2

    我是一个Rails初学者,但我想从我的RailsView(html.haml文件)中查看Ruby变量的内容。我试图在ruby​​中打印出变量(认为它会在终端中出现),但没有得到任何结果。有什么建议吗?我知道Rails调试器,但更喜欢使用inspect来打印我的变量。 最佳答案 您可以在View中使用puts方法将信息输出到服务器控制台。您应该能够在View中的任何位置使用Haml执行以下操作:-puts@my_variable.inspect 关于ruby-on-rails-如何在我的R

随机推荐