算法思想 - 回溯算法

2020-04-07  本文已影响0人  天命_风流

回溯思想

回溯算法的思想非常好理解,之前我们也使用回溯的思想完成了图的深度优先搜索。简单来说,回溯的思想是这样的:假设你在走迷宫,现在你站在一个分岔口,你不知道该向哪里走,所以你随机选择一个路口向前走,当你走到死胡同(或者已经走过的路)的时候,你知道这条路不对,于是你退回到上一个路口,选择另一条路再走。这样往复,直到找到出口。

适合使用回溯的问题

回溯算法适合解决这样一类问题:在一组数据中,我们有一个期望得到的解,在求解的过程中,我们可以有规律的枚举所有的解,过程中通过不断地回溯、剪枝,最终得到一个与条件最接近的解。

你能发现,回溯算法的应用场景和贪婪算法的应用场景很像,但是贪婪算法并不一定能求得最优解,而回溯虽然要枚举的量很大,但是可以找到最接近期待的解。

回溯的例子

八皇后(n皇后)问题

八皇后是回溯算法的招牌案例,它的描述如下:

我们有一个 8x8 的棋盘,希望往里放 8 个棋子(皇后),每个棋子所在的行、列、对角线都不能有另一个棋子。你可以看我画的图,第一幅图是满足条件的一种方法,第二幅图是不满足条件的。八皇后问题就是期望找到所有满足这种要求的放棋子方式。 八皇后.jpg

如何利用回溯思想扎到棋盘上的皇后排列呢?
我们把这个问题划分成 8 个阶段,依次将 8 个棋子放到第一行、第二行...第八行中。在放置的过程中,我们不停地检查当前的放法。如果满足要求,进入下一行的放置;如果不满足要求,就换一种放法,继续尝试。

下面是专栏老师给出的代码:

int[] result = new int[8];//全局或成员变量,下标表示行,值表示queen存储在哪一列
public void cal8queens(int row) { // 调用方式:cal8queens(0);
  if (row == 8) { // 8个棋子都放置好了,打印结果
    printQueens(result);
    return; // 8行棋子都放好了,已经没法再往下递归了,所以就return
  }
  for (int column = 0; column < 8; ++column) { // 每一行都有8中放法
    if (isOk(row, column)) { // 有些放法不满足要求
      result[row] = column; // 第row行的棋子放到了column列
      cal8queens(row+1); // 考察下一行
    }
  }
}

private boolean isOk(int row, int column) {//判断row行column列放置是否合适
  int leftup = column - 1, rightup = column + 1;
  for (int i = row-1; i >= 0; --i) { // 逐行往上考察每一行
    if (result[i] == column) return false; // 第i行的column列有棋子吗?
    if (leftup >= 0) { // 考察左上对角线:第i行leftup列有棋子吗?
      if (result[i] == leftup) return false;
    }
    if (rightup < 8) { // 考察右上对角线:第i行rightup列有棋子吗?
      if (result[i] == rightup) return false;
    }
    --leftup; ++rightup;
  }
  return true;
}

private void printQueens(int[] result) { // 打印出一个二维矩阵
  for (int row = 0; row < 8; ++row) {
    for (int column = 0; column < 8; ++column) {
      if (result[row] == column) System.out.print("Q ");
      else System.out.print("* ");
    }
    System.out.println();
  }
  System.out.println();
}

我自己使用python实现了这个功能,同时我还添加了一个扩展功能:将 八皇后 扩展为 n皇后的求解:

def queens(row, n=8, res=None):
    '''
    进行 n 皇后的求解,之后的注释默认 n = 8
    :param row: 当前所在行
    :param n: 输入 n 可求解 n皇后 问题,如果不输入,默认为 8皇后
    :param res: 当前 n皇后的保存,如果不输入,默认构造 8皇后的数组
    :return: None
    '''

    if res == None:
        res = [None for i in range(n)]  # 如果没有参数,默认构造 res[n] 的数组

    if row == n:  # 求解完成
        print_queens(res, n=n)  # 输出棋盘
        return

    for column in range(n):  # 求解没有完成,进行求解,对每个 row 进行 8 次迭代尝试
        if isOK(row, column, res, n=n):  # 如果放置的棋子符合规则,继续进行尝试,如果不符合规则,回溯(在这里是什么也不做)
            res[row] = column
            queens(row + 1, n=n, res=res)


def print_queens(res, n=8):  # 输出棋盘
    for i in res:
        p = [1 if i == j else 0 for j in range(n)]
        print(p)
    print('---------------------------------------------------')


def isOK(row, column, res, n=8):  # 判断当前棋子是否符合规则
    l = column - 1  # 用于判断左上方有没有棋子
    r = column + 1  # 判断右上方
    i = row - 1  # 判断上方

    while i >= 0:
        if res[i] == column:  # 上方有棋子
            return False
        if l >= 0:
            if res[i] == l:  # 左上方有棋子
                return False
        if r < n:
            if res[i] == r:  # 右上方有棋子
                return False

        l -= 1
        r += 1
        i -= 1

    return True


if __name__ == "__main__":
    r = queens(0, n=5)  # 默认进行 8皇后求解,你可以输入任意一个数 n

如果不理解计算机的工作过程,你很难理解回溯算法在计算机中的执行过程。如果你看不明白这段代码,那你最好自己复制这段代码到 pycharm 中设置程序断点自己看一看程序的函数调用栈。

0-1背包问题

我们有一个背包,背包总的承载重量是 Wkg。现在我们有 n 个物品,每个物品的重量不等,并且不可分割。我们现在期望选择几件物品,装载到背包中。在不超过背包所能装载重量的前提下,如何让背包中物品的总重量最大?

思路:我们可以将这个问题抽象化:总共有 n 个物品,面对每个物品我们有两个选择:放入背包 或者 不放入背包。这样,整个问题的所有可能性就是 2n(也就是这个问题的解空间)。我们尝试将某个物品放入背包:如果背包没有超重,就可以尝试再放一个物品;如果背包超重,就放弃这种操作。(实际上,在放弃这种操作的同时,你一并放弃了在执行这种操作之后的其它各种操作,这种方式叫做剪枝,剪枝是个非常有意思的东西,有机会我会写一下这个操作的含义)

专栏作者给出了如下的代码:

public int maxW = Integer.MIN_VALUE; //存储背包中物品总重量的最大值
// cw表示当前已经装进去的物品的重量和;i表示考察到哪个物品了;
// w背包重量;items表示每个物品的重量;n表示物品个数
// 假设背包可承受重量100,物品个数10,物品重量存储在数组a中,那可以这样调用函数:
// f(0, 0, a, 10, 100)
public void f(int i, int cw, int[] items, int n, int w) {
  if (cw == w || i == n) { // 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);
  }
}

我的python代码:

import random

def bag(cur_index, already_weight, weight, goods, goods_len,res):
    '''

    放物品函数,在这个函数中,会更改 max_w 的值,
    :param cur_index:当前物品的序号
    :param already_weight: 当前背包已经装装载的重量,注意,是已经装载的重量,也就是说不包括下标为 cur_index 的物品
    :param weight: 背包承重
    :param goods:所有物品的重量
    :param goods_len: 物品的数量
    :param res: 记录被放入背包的物品的重量
    :return:
    '''
    global max_w

    if already_weight == weight or cur_index == goods_len:#背包当前重量真好满足条件 或 已经取到了最后一个物品
        if already_weight > max_w :
            max_w = already_weight
            print('背包内有{},重量为{}'.format(res,sum(res)))
        return

    bag(cur_index + 1,already_weight,weight,goods,goods_len,res)#不装当前物品,already_weight不增大

    if already_weight + goods[cur_index] <= weight:#装当前物品,already_weight增加当前物品的重量
        x = res.copy()#记录背包里的物品
        x.append(goods[cur_index])
        bag(cur_index + 1,already_weight+goods[cur_index],weight,goods,goods_len,x)

if __name__ =='__main__':
    goods = [random.randint(0,50) for i in range(10)]
    print('所有物品的重量如下:',goods)
    max_w = max(goods)
    bag(0,0,145,goods,len(goods),[])
    print('背包最多可以装:',max_w)

正则匹配

正则表达式中,最重要的就是通配符,通配符结合在一起,可以表达非常丰富的语义。为了方便讲解,我假设正则表达式中只包含“”和“?”这两种通配符,并且对这两个通配符的语义稍微做些改变,其中,“”匹配任意多个(大于等于 0 个)任意字符,“?”匹配零个或者一个任意字符。基于以上背景假设,我们看下,如何用回溯算法,判断一个给定的文本,能否跟给定的正则表达式匹配?
我们依次考察正则表达式中的每个字符,当是非通配符时,我们就直接跟文本的字符进行匹配,如果相同,则继续往下处理;如果不同,则回溯。
如果遇到特殊字符的时候,我们就有多种处理方式了,也就是所谓的岔路口,比如“*”有多种匹配方案,可以匹配任意个文本串中的字符,我们就先随意的选择一种匹配方案,然后继续考察剩下的字符。如果中途发现无法继续匹配下去了,我们就回到这个岔路口,重新选择一种匹配方案,然后再继续匹配剩下的字符。

专栏作者的代码如下:

public class Pattern {
  private boolean matched = false;
  private char[] pattern; // 正则表达式
  private int plen; // 正则表达式长度

  public Pattern(char[] pattern, int plen) {
    this.pattern = pattern;
    this.plen = plen;
  }

  public boolean match(char[] text, int tlen) { // 文本串及长度
    matched = false;
    rmatch(0, 0, text, tlen);
    return matched;
  }

  private void rmatch(int ti, int pj, char[] text, int tlen) {
    if (matched) return; // 如果已经匹配了,就不要继续递归了
    if (pj == plen) { // 正则表达式到结尾了
      if (ti == tlen) matched = true; // 文本串也到结尾了
      return;
    }
    if (pattern[pj] == '*') { // *匹配任意个字符
      for (int k = 0; k <= tlen-ti; ++k) {
        rmatch(ti+k, pj+1, text, tlen);
      }
    } else if (pattern[pj] == '?') { // ?匹配0个或者1个字符
      rmatch(ti, pj+1, text, tlen);
      rmatch(ti+1, pj+1, text, tlen);
    } else if (ti < tlen && pattern[pj] == text[ti]) { // 纯字符匹配才行
      rmatch(ti+1, pj+1, text, tlen);
    }
  }
}

我的python代码:

class Pattern:
    def __init__(self, pattern):
        '''
        初始化模式串
        :param pattern: 模式串
        '''
        self.pattern = pattern
        self.plen = len(pattern)

    def match(self, text):
        '''
        匹配接口
        :param text: 主串
        :return:
        '''
        print('模式串为:{},等待匹配的字符串为{}'.format(self.pattern,text))
        self.matched = False
        self.rmatch(0, 0, text, len(text))
        return self.matched

    def rmatch(self, cur_text, cur_pattern, text, tlen):
        '''
        进行匹配
        :param cur_text: 当前主串的下标
        :param cur_pattern: 当前模式串的下标
        :param text: 主串
        :param tlen: 主串长度
        :return:
        '''
        # print('匹配中,主串:{},模式串:{}'.format(text[cur_text],self.pattern[cur_pattern]))
        # print(cur_text,cur_pattern,'-----',tlen,self.plen)
        if self.matched == True:#匹配已经完成,跳出函数
            return
        if cur_pattern == self.plen:#模式串匹配结束,注意这段代码需要放在匹配行为之前,因为此时的 cur_pattern 已经溢出了字符串
            if cur_text == tlen:#如果此时主串也匹配结束,匹配成功
                self.matched = True
            return

        if self.pattern[cur_pattern] == '*':#匹配到 * :把之后所有的主串位置都匹配一遍
            for i in range(tlen - cur_text):
                print(cur_text + i,cur_pattern + 1)
                self.rmatch(cur_text + i, cur_pattern + 1, text, tlen)

        elif self.pattern[cur_pattern] == '?':#匹配到 ?:主串移动 一步 或 零步
            self.rmatch(cur_text + 1, cur_pattern+1, text, tlen)
            self.rmatch(cur_text, cur_pattern+1, text, tlen)

        elif self.pattern[cur_pattern] == text[cur_text] and cur_text < tlen:#匹配到非通配符:向后移动
            self.rmatch(cur_text + 1, cur_pattern+1, text, tlen)

if __name__ == '__main__':
    x = Pattern('???b?d')
    r1 = x.match('aaabcd')
    print(r1)
    r2 = x.match('abcd')
    print(r2)

走迷宫问题

下面有一段代码,是我在以前学习中使用栈完成的深度优先走迷宫,由于年代久远,我就不做解释,想要了解的朋友可以自行百度。
注意,这段代码没有使用递归,如果你不理解,直接跳过也无妨。

maze = [
    [1,1,1,1,1,1,1,1,1,1],
    [1,0,0,1,0,0,0,1,0,1],
    [1,0,0,1,0,0,0,1,0,1],
    [1,0,0,0,0,1,1,0,0,1],
    [1,0,1,1,1,0,0,0,0,1],
    [1,0,0,0,1,0,0,0,0,1],
    [1,0,1,0,0,0,1,0,0,1],
    [1,0,1,1,1,0,1,1,0,1],
    [1,1,0,0,0,0,0,1,0,1],
    [1,1,1,1,1,1,1,1,1,1]
]

dirs = [lambda x, y: (x + 1, y),
        lambda x, y: (x - 1, y),
        lambda x, y: (x, y - 1),
        lambda x, y: (x, y + 1)]

def mpath(x1, y1, x2, y2):
    stack = []
    stack.append((x1, y1))
    while len(stack) > 0:
        curNode = stack[-1]
        if curNode[0] == x2 and curNode[1] == y2:
            #到达终点
            for p in stack:
                print(p)
            return True
        for dir in dirs:
            nextNode = dir(curNode[0], curNode[1])
            if maze[nextNode[0]][nextNode[1]] == 0:
                #找到了下一个
                stack.append(nextNode)
                maze[nextNode[0]][nextNode[1]] = -1  # 标记为已经走过,防止死循环
                break
        else:#四个方向都没找到
            maze[curNode[0]][curNode[1]] = -1  # 死路一条,下次别走了
            stack.pop() #回溯
    print("没有路")
    return False

mpath(1,1,8,8)

总结

回溯算法的思想非常简单,在大部分情况下,可以用来解决广义的搜索问题。即从一组可能的解中,找到一个最免租要求的解。
回溯算法有一个 探索-碰壁-回溯 的过程,所以可以利用栈实现,但是最简版的方法还是使用递归。
在回溯过程中,对解空间进行剪枝操作是一个非常有趣的操作,它可以提高搜索效率。
我们在上面已经举了多个利用回溯完成的代码,你可以细细琢磨一下。


以上就是回溯算法的全部内容

注:本文章的主要内容来自我对极客时间app的《数据结构与算法之美》专栏的总结,我使用了大量的原文、代码和截图,如果想要了解具体内容,可以前往极客时间

上一篇下一篇

猜你喜欢

热点阅读