设计一个算法找一条从迷宫入口到出口的最短路径。_[算法系列]搞懂DFS(1)——经典例题(数独游戏, 部分和, 水洼数目)图文详解...

fb1705839f97314e0009402961065d41.png

 本文是递归系列的第四篇文章.

在前面的递归相关的设计思路, 例题介绍的基础上, 本文通过图文并茂的方式详细介绍三道比较经典的dfs题的思考方向和解题步骤, 以此介绍dfs的一般思路,以及加深对递归设计的认识. 觉得不错就小赞一下啦~

1. 数独游戏

数独游戏大家一定都玩过吧: 简单来说就如下的格子中, 填上剩余空白处的数字1-9,使得每行每列以及所在的小九宫格的所有数字均不同.

94a41cb8212bad56c1f838b431762521.png

我以前并没有玩过数独..也不知道这类题有什么奇技淫巧没, 下面介绍下大概是普通人能够想到思路 :(1a代表左上第一个格子)

  • 根据规则,1a不能填3,4,5,7,8. 为了体现规律性, 我们对剩下的可选数字排序, 每次选都从小开始往上挑 --- 选1a为1
  • 接下来是1b, 选1b为2,符合; 接下来1e为4; 1f6; 1g为8;1h为7;目前有如下结果:

c399510e4205e67c308ee4e3170bfea1.png
  • 现在1i只剩下9可选了,由于7i已经是9,所以该填法出错了.. 然后我们拿着小橡皮, 将1h上的7 "擦掉", 填上剩下的一种可能--9,现在1i只能填7了, 检查一下,完美. 接下来继续是第二行...第三行..

9a5ca2c71922f467c244bdb99d2e0fbb.png

好了, 现在引出今天的主题: dfs(深度优先搜索), 以及 回溯

dfs通俗来讲, 就像小时走大迷宫一样. 遇到岔路口后, 选择其中一条 ,不撞南墙不回头不回头. 遇到尽头后, 回溯 到之前的岔路的位置, 然后选择另一条路径. 如果所有的岔路都试完了均是死路的话, 就说明我正处的这个岔路所在的路径是走错了, 因而就得再一次 回溯 到前一个岔路口, 选择另一个岔路..

抽理一下:

在上述走迷宫中, 站在每一个岔路口时,我们都定义是一种 状态 Si, 当我们(通常按照一定顺序) 选择 某一条路径时 ,:

  • 要么是死路, 这时我们需要 回到刚刚的状态 Si(回溯), 选择另一条路径
  • 要么达到下一个路口, 就进入了下一个状态 Si+1

而对这个 下一个状态 Si+1 , 我们使用和上述同样的做法 . 这就是DFS的精髓了.

下面继续通过数独题目介绍dfs及其解法思路

输入数独游戏题目, 格式为 9 * 9 的二维数组 ,0 表示未知,其他数字已知
每个零处需填入数字1-9,使得每行 每列 以及 所在的小九宫格 的所有数字均不同.
输入:

005 300 000
800 000 020
070 010 500
400 005 300
010 070 006
003 200 080
060 500 009
004 000 030
000 009 700

下面给出 dfs 思路,

  • 定义状态 : 坐标为(x,y), 且需要填数字的格子
  • 状态转移: 当前位置填好后, 填它右边最近那个需要填数字的格子, 若是最后一个则提行
  • 选择路径顺序(这里是选择数字顺序): 从1~9中选出满足条件的最小的那个, 回溯后, 选倒数第二小的, 依次类推

而通过走迷宫的方法可以看出, 解决Si和解决Si+1的方法相同, 这其实更是个递归问题:

  • 找出口: 当遍历到 x = 9时 , 则说明下标为0-8的9行全部填完, 即可退出.
  • 找重复: 对每一个状态,判断填入数字的合法规则, 以及选择填入数字的顺序是相同的
  • 找变化: 很显然, 每个状态的数组的完成度是不同的, 同时待填入格子的下标也是不同的 .

上述三部曲也是前面提到过的递归设计方法,详情链接: 搞懂递归, 看这篇就够了 !! 递归设计思路 + 经典例题层层递进

好了, 伪代码也能上了:

dfs(table, x, y):  # table为当前的数组, x,y为当前状态所需填的格子坐标
# 出口条件
if x == 9:
    exit(0)

if table[x][y] == 0:  # 如果为0, 表示需要填
    for i in range(1, 10):  # 选1-9之间的数字放进去, 从小的开始选
        flag = checked(table, x, y, i)  # 判断是否符合同行同列等
        if flag:  # 如果满足就填入 i
            table[x][y] = i
            # 然后转移到下一个状态
            dfs(table, x + (y + 1) / 9, (y + 1) % 9)
    table[x][y] = 0  # for循环完了, 都不满足,  先将此处恢复成0
# 该层代码 完成, 返回上一层调用 ==> 回溯

else:
# 选择下一个需要处理的位置
dfs(table, x + (y + 1) / 9, (y + 1) % 9)

刚开始学的时候可能对其中核心部分还是有些疑惑:

for i in range (1,10):# 选1-9之间的数字放进去, 从小的开始选
    flag = checked(table,x,y,i) # 判断是否符合同行同列等
    if flag:      # 如果满足就
        table[x][y] = i # 填入 i
        dfs(table, x + (y+1) /9 , (y+1) % 9) #递归调用 ,转移到下一个状态
table[x][y] = 0  #for循环完了, 都不满足, 先将此处恢复成0
# 函数执行完成, 返回上一层调用处 ==> 回溯

从1-9中选了一个数字, 如果满足, 则填上此数, 同时考察下一个位置 ;

如果不满足, 即flag = false: 就会对1-9中的下一个数进行考察, 如果全都不满足flag = true, 则说明无路可走(死路), 此时需要先将该处恢复成0 , 然后紧接着函数执行完成, 也就返回到上一次调用的地方, 依然在for循环中, 会重新选择上次的数字(比如:上次选了i=5满足, 递归调用后发现下一个位置是怎么填都是死路, 那么回溯后 i 就会继续遍历得到下个满足的数字)

247f567bc5afbdf99a5c1fa29814c683.png

代码如下:

def shudu(table, x, y):
    if x == 9 :     #此时表明x已经将0-8的9行全部搞定了
        print_matrix(table)
        exit(0)     #找到一个解即可退出

    if table[x][y] == 0:
        # 选1-9之间的数字放进去
        for i in range(1, 10):
            flag = checked(table, x, y, i)
            if flag:
                table[x][y] = i
                # 转移到下一个状态
                shudu(table, x + (y+1) // 9 , (y+1)%9)
        table[x][y] = 0  #恢复该位置为0, 并进行回溯回溯
    else:
        # 选择下一个需要处理的位置
        shudu(table, x + (y + 1) // 9, (y + 1) % 9)


def checked(table, x, y, k):
    # 检查同行同列
    for i in range(0, 9):
        if table[x][i] == k:
            return False
        if table[i][y] == k:
            return False

    # 检查小九宫格
    sx = (x // 3) * 3
    ex = (x // 3 + 1)*3

    sy = (y // 3) * 3
    ey = (y // 3 + 1) * 3
    for i in range(sx, ex):
        for j in range(sy, ey):
            if table[i][j] == k:
                return False
    return True

2. 部分和

给定整数序列a1,a2,...,an,判断是否可以从中选出m个数,使它们的和恰好为k 
1<= n <= 20
-10^8 < ai < 10^8
-10^8 < k < 10^8   
输入:    n = 4   
        a=[1,4,2,7]   
         k = 13
输出:   [[4,2,7]]

解法1:

针对每一个数字, 都有取(1)和不取(0)两种可能, 换句话说, 在不考虑元素重复的情况下, 和为 k 的情况一定是从 原序列的子集 中产生. 回忆上一篇文章[算法系列] 深入递归本质+经典例题解析——如何逐步生成, 以此类推,步步为营 中求解全部子集的讨论. 其中提到了一个很强劲的二进制表示法来求解. 下面简述该法:

用一个长度为n的二进制数来表示该序列中某个元素选还是不选的情况, 其中 n= len(arr), 位置 i 上为1表示选上arr[i] , 为0表示arr[i]不选.

比如 arr = [1,2,3] , 0 表示全部不选 ;101表示选出1,3; 111 表全部选出. 那么我们从0开始是遍历二进制数到2^n-1 对每一种情况考察是否和为k 即可.

伪代码

for each binary_num from 0 to 2^n-1: for 每一位i in binary_num : if 该为上为1: k = k-arr[i] 遍历完后,若此时k = 0: return true

实际代码也相当简洁:

def bin_part_sum(arr , k):
    n = len(arr)                # n为arr的长度  
    k_copy = k                  # 备份一个k的值   
    for bin in range(1 , 2**n):         # 遍历0到2^n-1的所有二进制数
        for i  in range(0 ,len(arr) ):      #考察这些数的每一位
            if (bin >> i) & 1 == 1:     #若此位上为1
                k = k - arr[i]          
        if k == 0 :             #k 为0, 表示 选出来的各数字和为k
            return  True
        else:
            k = k_copy              #恢复k
    return False

要是想把所有的解都打印出来也很方便:

def bin_part_sum(arr , k):
    n = len(arr)
    k_copy = k
    res = list()     #结果集
    item = list()
    for bin in range (1, 2 ** n):

        for i in range(0 , len (arr)):
            if (bin >> i) & 1 == 1:
                k = k - arr[i]
                item.append(arr[i])
        if k == 0:
            p_item = item.copy()        # 注意这里item是个引用, 而我们实际上需要放的是他的内容
            item.clear()
            res.append(p_item)
        else:
            item.clear()
        k = k_copy

    print(res)

arr = [1,2,3,4]
bin_part_sum(arr , 6)

'''
[[1, 2, 3], [2, 4]]
'''

解法2:

很显然此题也是可以用dfs进行求解的, 如下为dfs思路:

  • 定义状态 : 当前考察的数字: arr[cur] , 已选择的数字集合
  • 状态转移: 当前已选数字和小于k 时 , 顺次cur +1, 若大于了k, 则为死路, 考虑回退
  • 选择路径顺序 : 对每一个状态时, 先考虑 , 其回溯后考虑 不选

和上面数独题类似, 也要考虑递归设计三要素:

  • 找出口: 要么sum=k, 表示找到一个解, 此时退出, 若要寻找所有解, 则应当返回; 要么sum > k , 则说明此路径错误, 返回
  • 找重复: 对于每一个状态的选择方法一致, 要么选要么不选, 属于同问题不同规模
  • 找变化: sum变化, cur变化, 若需要保留每一组解, 则需引入的最终结果集res, 以及每个结果item 均在变化 (回忆找变化的作用: 往往用来定参数)

下面依然以 arr = [1, 2, 4,7 ] , k =13 为例, 其中cur为当前考虑的arr下标, sum为当前选了的数字和, item用于存放当前选了的数字

f54b4380d305b0f78a3aad7b884acbbe.png

蓝色数字是调用顺序, 其中部分调用数省略

代码如下: 这里图方便省去了sum , 直接在k上进行操作, 即当k减到0时, 也说明当前和是k

# dfs
def dfs_part_sum(arr, k):
    res = list()
    item = list()
    part_sum(arr ,item,res, k , 0)

    print(res)

def part_sum(arr ,  item, res, k ,cur):

    if k == 0  :
        res.append(item)
        return  #这里得到结果也要返回
    if k < 0 or cur == len(arr):
        return


    #为了代码方便,这里先考虑不选这个元素
    c_item_0= item.copy()
    part_sum(arr, c_item_0 , res, k , cur+1)    #item 不变, k不减少, 只是cur++

    #选择这个元素
    item.append(arr[cur]) # 选择, 即加入当前item
    c_item = item.copy() 
    part_sum(arr , c_item , res, k - arr[cur] , cur+1) # 目标k值缩小


arr = [1,2,3,4,5]
dfs_part_sum(arr , 6)

'''res
[[2, 4], [1, 5], [1, 2, 3]]
'''

3. 水洼数目

有一个大小为N×M的园子,雨后积起了水。

其中: 1代表有水, 0代表没水

八连通的积水被认为是连通在一起的。请求出园子里总共有多少水洼?(八连通指的是下图中相对w的*部分)

***
*W*
***

例如某园子如图:

100000000110
011100000111
000011000110
000000000110
000000000100
001000000100
010100000110
101010000010
010100000010
001000000010

输出3

思路: 寻找8连通的数目. 此题也是一个很经典的题目, 和前面两题的dfs模式小有不同.

抽理一下题意 :

从一个值为1的位置出发, 能够向四周八个方向同样是1的地方走,

560d92611a2122fa6afc3539f5342924.png

假设有如下, 一个"1" 选择了自己右下角的路径:

fa473b4e7935a2576c6e11f3dc6451e3.png

那么相类似, 这个1 同样有如蓝色的路径可以选择. 但是请注意, 这时刚刚走过的这个1 , 在进入下一步时应当舍弃, 否则就会出现: 左上角的1 进入到右下角的1, 紧接着右下角的1 回到左上角去. 这种循环死局不是我们希望的.

那么可以怎么办? 其实很简单, 到达一个 "1" 时, 先将该处置为"0" , 然后再去找八个方向的1. 下面演示一下过程:

4b4297a5a7f29a16afbb7e1d00712375.png

现在回到左上角的1 了 , 然而他已经无路可走, 只能继续返回, 此时, 我们就认为这个水洼遍历完成, count++ 即可.

接下来, 在整个二维数组内遍历寻找下一个 1 , 一个新的故事上演 ... 直到整个界面中的1 全部变为0时, 遍历结束, count即为结果

d792471849baf5b05a2641ce770b420c.png

伪代码如下:

def fun():
    对数组arr中的每个位置元素arr[i][j]:
        if arr[i][j] == 1:
            dfs(arr, i ,j)
            count ++ 
    return count

def dfs(arr , i , j):
    if 上方有数字且为1:
        dfs(arr ,i - 1 , j )
    if 左上方有数字且为1:
        dfs(arr ,i - 1 , j - 1 )
    if 右上方有数字且为1:
        dfs(arr ,i - 1 , j + 1 )
    if 左方有数字且为1:
        dfs(arr ,i  , j - 1 )
    ...
    //8个方向均需考虑

下面是实现代码 :

def get_water_num(arr):
    count = 0

    for i in range(0 , len(arr)):
        for j in range(0 , len(arr[0])):
            if arr[i][j] == 1:
                dfs_get_water_num(arr,i,j)
                count +=1
    return count

def dfs_get_water_num(arr,i,j):
    arr[i][j] = 0

    # 更快捷地遍历8个方向
    for k in range(-1 , 2): # -1, 0 ,1  也就是向左1, 不动,向右1
        for l  in range(-1 , 2):    #-1 ,0 ,1
            if k == 0 and l == 0:   #如果没动
                continue
            if i+k >= 0 and i+k <= len(arr) -1 and j+l>= 0 and j+l<=len(arr[0]) - 1:    #不能移动出边界
                if arr[i+k][j+l] == 1:
                    dfs_get_water_num(arr , i + k , j + l)

变式: 在此基础上, 不但要求出水洼数量, 还要求得各个水洼的大小(1的个数)

#变式: 求出水洼数量, 还要求得各个水洼的大小(1的个数)

def get_water_max(arr):
    res = []    #结果list 

    for i in range(0 , len(arr)):
        for j in range(0 , len(arr[0])):
            if arr[i][j] == 1:
                area = 0
                t_a = dfs_get_water_max(arr,i,j,area + 1 )
                res.append(t_a)
    return res

def dfs_get_water_max(arr,i,j ,area):   #area 表示目前的面积大小
    arr[i][j] = 0

    for k in range(-1 , 2):         # -1, 0 ,1
        for l  in range(-1 , 2):    # -1 ,0 ,1
            if k == 0 and l == 0:
                continue
            if i+k >= 0 and i+k <= len(arr) -1 and j+l>= 0 and j+l<=len(arr[0]) - 1:
                if arr[i+k][j+l] == 1:
                    return  dfs_get_water_max(arr , i + k , j + l ,area +1) # area + 1
    return  area    # area 没变化,作为结果返回
print(get_water_max(arr))

在这个问题需要注意的一点就是: area 作为当前水洼面积的大小, 我是设计成作为参数进行传递的, 每当成功调用的时候(发现周围有个1了), 传入area + 1作为下一次的area. 由于最终需要保存每个水洼的area,故也要作为返回值返回.

arr=[
    [1,0,0,0,0,0,0,0,0,1,1,0],
    [0,1,1,1,0,0,0,0,0,1,1,1],
    [0,0,0,0,1,1,0,0,0,1,1,0],
    [0,0,0,0,0,0,0,0,0,1,1,0],
    [0,0,0,0,0,0,0,0,0,1,0,0],
    [0,0,1,0,0,0,1,0,0,1,0,0],
    [0,1,0,1,0,0,1,0,0,1,1,0],
    [1,0,1,0,1,0,0,1,0,0,1,0],
    [0,1,0,1,0,0,0,0,0,0,1,0],
    [0,0,1,0,0,0,0,0,0,0,1,0]
]
print(get_water_max(arr))
'''res
[6, 16, 9, 3]#4
'''

在下一文章中, 将继续介绍DFS概念以及一些诸如n皇后等经典题目

往期回顾:

0xCAFEBABE:[算法系列] 搞懂递归, 看这篇就够了 !! 递归设计思路 + 经典例题层层递进​zhuanlan.zhihu.com
3a5cef4026c383b9234e52ee20664cb8.png

2. [算法系列] 递归应用: 快速排序+归并排序算法及其核心思想与拓展 .. 附赠 堆排序算法

0xCAFEBABE:[算法系列] 深入递归本质+经典例题解析——如何逐步生成, 以此类推,步步为营​zhuanlan.zhihu.com
a2607062b8c02844283d9da9cc148924.png
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值