本文是递归系列的第四篇文章.
在前面的递归相关的设计思路, 例题介绍的基础上, 本文通过图文并茂的方式详细介绍三道比较经典的dfs题的思考方向和解题步骤, 以此介绍dfs的一般思路,以及加深对递归设计的认识. 觉得不错就小赞一下啦~
1. 数独游戏
数独游戏大家一定都玩过吧: 简单来说就如下的格子中, 填上剩余空白处的数字1-9,使得每行每列以及所在的小九宫格的所有数字均不同.
我以前并没有玩过数独..也不知道这类题有什么奇技淫巧没, 下面介绍下大概是普通人能够想到思路 :(1a代表左上第一个格子)
- 根据规则,1a不能填3,4,5,7,8. 为了体现规律性, 我们对剩下的可选数字排序, 每次选都从小开始往上挑 --- 选1a为1
- 接下来是1b, 选1b为2,符合; 接下来1e为4; 1f6; 1g为8;1h为7;目前有如下结果:
- 现在1i只剩下9可选了,由于7i已经是9,所以该填法出错了.. 然后我们拿着小橡皮, 将1h上的7 "擦掉", 填上剩下的一种可能--9,现在1i只能填7了, 检查一下,完美. 接下来继续是第二行...第三行..
好了, 现在引出今天的主题: 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 就会继续遍历得到下个满足的数字)
代码如下:
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用于存放当前选了的数字
蓝色数字是调用顺序, 其中部分调用数省略
代码如下: 这里图方便省去了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的地方走,
假设有如下, 一个"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皇后等经典题目
往期回顾:
0xCAFEBABE:[算法系列] 搞懂递归, 看这篇就够了 !! 递归设计思路 + 经典例题层层递进zhuanlan.zhihu.com2. [算法系列] 递归应用: 快速排序+归并排序算法及其核心思想与拓展 .. 附赠 堆排序算法
0xCAFEBABE:[算法系列] 深入递归本质+经典例题解析——如何逐步生成, 以此类推,步步为营zhuanlan.zhihu.com