本文是递归系列的第四篇文章.
在前面的递归相关的设计思路, 例题介绍的基础上, 本文通过图文并茂的方式详细介绍三道比较经典的dfs题的思考方向和解题步骤, 以此介绍dfs的一般思路,以及加深对递归设计的认识. 觉得不错就小赞一下啦~
数独游戏大家一定都玩过吧: 简单来说就如下的格子中, 填上剩余空白处的数字1-9,使得每行每列以及所在的小九宫格的所有数字均不同.
我以前并没有玩过数独…也不知道这类题有什么奇技淫巧没, 下面介绍下大概是普通人能够想到思路 :(1a代表左上第一个格子)
根据规则,1a不能填3,4,5,7,8. 为了体现规律性, 我们对剩下的可选数字排序, 每次选都从小开始往上挑 — 选1a为1
接下来是1b, 选1b为2,符合; 接下来1e为4; 1f6; 1g为8;1h为7;目前有如下结果:
好了, 现在引出今天的主题: dfs(深度优先搜索), 以及 回溯
dfs通俗来讲, 就像小时走大迷宫一样. 遇到岔路口后, 选择其中一条 ,不撞南墙不回头不回头. 遇到尽头后, 回溯 到之前的岔路的位置, 然后选择另一条路径. 如果所有的岔路都试完了均是死路的话, 就说明我正处的这个岔路所在的路径是走错了, 因而就得再一次 回溯 到前一个岔路口, 选择另一个岔路…
抽理一下:
在上述走迷宫中, 站在每一个岔路口时,我们都定义是一种 状态 Si, 当我们(通常按照一定顺序) 选择 某一条路径时 ,:
而对这个 下一个状态 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 思路,
而通过走迷宫的方法可以看出, 解决Si和解决Si+1的方法相同, 这其实更是个递归问题:
上述三部曲也是前面提到过的递归设计方法,详情链接:
搞懂递归, 看这篇就够了 !! 递归设计思路 + 经典例题层层递进
好了, 伪代码也能上了:
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 就会继续遍历得到下个满足的数字)
代码如下:
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
给定整数序列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)和不取(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]]
'''
很显然此题也是可以用dfs进行求解的, 如下为dfs思路:
和上面数独题类似, 也要考虑递归设计三要素:
下面依然以 arr = [1, 2, 4,7 ] , k =13 为例, 其中cur为当前考虑的arr下标, sum为当前选了的数字和, item用于存放当前选了的数字
蓝色数字是调用顺序, 其中部分调用数省略
代码如下: 这里图方便省去了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]]
'''
有一个大小为N×M的园子,雨后积起了水。
其中: 1代表有水, 0代表没水
八连通的积水被认为是连通在一起的。请求出园子里总共有多少水洼?(八连通指的是下图中相对w的*部分)
***
*W*
***
例如某园子如图:
100000000110
011100000111
000011000110
000000000110
000000000100
001000000100
010100000110
101010000010
010100000010
001000000010
输出3
思路: 寻找8连通的数目. 此题也是一个很经典的题目, 和前面两题的dfs模式小有不同.
抽理一下题意 :
从一个值为1的位置出发, 能够向四周八个方向同样是1的地方走,
假设有如下, 一个"1" 选择了自己右下角的路径:
那么相类似, 这个1 同样有如蓝色的路径可以选择. 但是请注意, 这时刚刚走过的这个1 , 在进入下一步时应当舍弃, 否则就会出现: 左上角的1 进入到右下角的1, 紧接着右下角的1 回到左上角去. 这种循环死局不是我们希望的.
那么可以怎么办? 其实很简单, 到达一个 “1” 时, 先将该处置为"0" , 然后再去找八个方向的1. 下面演示一下过程:
现在回到左上角的1 了 , 然而他已经无路可走, 只能继续返回, 此时, 我们就认为这个水洼遍历完成, count++ 即可.
接下来, 在整个二维数组内遍历寻找下一个 1 , 一个新的故事上演 … 直到整个界面中的1 全部变为0时, 遍历结束, count即为结果
伪代码如下:
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皇后等经典题目
往期回顾: