搜索算法
图
图Graph的概念
图是一种由节点和边构成的数据结构,实际上树也是一种特殊的图。图可以用来表示现实世界中的很多事物:道路交通系统、航班线路、互联网连接等等。
顶点Vertex (节点Nond):是图的基本组成部分,顶点具有名称标识key,也可以携带数据项payload。
边Edge(弧Arc):作为两个顶点之间关系的表示,边连接两个顶点;边可以是无向或有向的,相应的图称为“无向图”和“有向图”。
权重Weight:为了表达从一个顶点到另一个顶点的代价,可以给边赋权;例如公交网中的两个站点之间的“距离”、“通行时间”和“票价”都可以作为权重。
路径Path:图中的路径,是由边依次连接起来的顶点序列;无权路径的长度为边的数量;带权路径的长度为所有边权重的和;如下图的一条路径(V3,V4,V0,V1),其边为{(V3,V4,7),(V4,V0,1),(V0,V1,5)}。
圈Cycle:圈是首尾顶点相同的路径,如下图中(V5,V2,V3,V5V)是一个圈。如果有向图中不存在任何圈,则称为“有向无圈图”(directed acylic graph:DAG)。
图的抽象数据类型
邻接矩阵
优点:简单。
缺点:空间复杂度太高。
邻接列表
class Node(object):
def __init__(self, key):
self.key = key
self.connected_to = {}
# 添加一个边
def add_nbr(self, nbr, weight=0):
self.connected_to[nbr] = weight
# 获取当前节点所有连接
def get_connections(self):
return self.connected_to.keys()
# 获取当前节点的key
def get_key(self):
return self.key
# 获取指定边的权重
def get_weight(self, nbr):
return self.connected_to[nbr]
# 打印当前节点连接的所有节点信息
def __str__(self):
return str(self.key) + ' connected to: ' + str([x.key for x in self.connected_to])
class Graph(object):
def __init__(self):
self.nodes = {}
self.node_num = 0
# 添加节点
def add_node(self, key):
self.node_num += 1
new_node = Node(key)
self.nodes[key] = new_node
return new_node
# 通过key获取节点
def get_node(self, key):
return self.nodes.get(key)
# 添加一条边
def add_edge(self, from_node, to_node, weight=0):
if from_node not in self.nodes:
self.add_node(from_node)
if to_node not in self.nodes:
self.add_node(to_node)
self.nodes[from_node].add_nbr(self.nodes[to_node], weight)
# 获取图中所有节点
def get_nodes(self):
return self.nodes.keys()
def __iter__(self):
return iter(self.nodes.values())
# in操作符
def __contains__(self, key):
return key in self.nodes
图的搜索算法
广度优先BFS
给定图G,已经开始搜索的起始顶点S。BFS搜索所有从S可到达其他顶点的边,并且在到达更远的距离k+1的顶点之前,BFS会找到全部距离为k的顶点。
# 标准的广度优先的基本流程
def bfs():
定义队列;
定义备忘录,用于记录已经访问的位置;
判断边界条件,是否能直接返回结果的;
将起始位置加入到队列中,同时更新备忘录;
while 队列不为空:
获取当前队列中的元素个数。
for (元素个数):
取出一个位置节点。
判断是否到达终点位置。
获取它对应的下一个所有的节点。
for (每个节点)
条件判断,过滤掉不符合条件的位置。
新位置重新加入队列。
深度优先DFS
深度优先于广度优先算法流程基本一致,只是将队列改为了栈存储检查的节点。
# 标准的广度优先的基本流程
def bfs():
定义栈;
定义备忘录,用于记录已经访问的位置;
判断边界条件,是否能直接返回结果的;
将起始位置加入到栈中,同时更新备忘录;
while 栈不为空:
获取当前栈中的元素个数。
for (元素个数):
取出一个位置节点。
判断是否到达终点位置。
获取它对应的下一个所有的节点。
for (每个节点)
条件判断,过滤掉不符合条件的位置。
新位置重新加入栈。
LeetCode
BFS
1091. 二进制矩阵中的最短路径
题目链接
解法:广度优先搜索
def shortestPathBinaryMatrix(grid):
# 边界情况
if len(grid) == 0 or len(grid[0]) == 0 \
or grid[0][0] == 1 or grid[-1][-1] == 1:
return -1
if len(grid) == 1:
return 1
row = len(grid)
cow = len(grid[0])
queue = [(0, 0)]
ans = 0
while len(queue) > 0:
node_num = len(queue)
for i in range(node_num):
node = queue.pop(0)
# 到达右下角节点时返回结果
if node == [cow-1, row-1]:
return ans + 1
# 八个方向偏移量:左上、左、左下、下、右下、右、右上、上
offsets = [[-1, -1], [-1, 0], [-1, 1], [0, 1],
[1, 1], [1, 0], [1, -1], [0, -1]]
# 判断八个方向的节点是否满足要求
for offset in offsets:
x = node[0] + offset[0]
y = node[1] + offset[1]
# 越界
if not 0 <= x <= cow-1:
continue
# 越界
if not 0 <= y <= row-1:
continue
# 非零值
if grid[x][y] != 0:
continue
# 满足要求时加入队列,并且把当前节点置为1
queue.append([x, y])
grid[x][y] = 1
# 遍历一次结束后,步数+1
ans += 1
return -1
如下图,因为广度优先是一层一层遍历,如果子节点找不到满足条件的子子节点,会走到相邻的兄弟节点,兄弟节点再去找合适的子节点。所以最终能找到到达目的地址的最短路径。
127. 单词接龙
题目链接
解法:广度优先搜索
def ladderLength(beginWord, endWord, wordList):
if beginWord not in wordList:
wordList.append(beginWord)
"""
1. 4个字母的单子总共右5000+,如果wordList有5000个字母,
循环对比每个字母是否只差1个字符,至少需要5000^2 * 4 = 2500W
2. 采用建桶的方式,例如: 单词dot,可以建立3个桶,*ot、d*t、do*,*号作为通配符匹配所有字母,
然后遍历所有单词,如果满足条件的都加入同一个桶中。时间复杂度:O(n*length)
"""
buckets = {}
for word in wordList:
for i in range(len(word)):
bucket = word[:i] + "*" + word[i+1:]
if bucket in buckets:
buckets[bucket].append(word)
else:
buckets[bucket] = [word]
# 通过临接数组 构建无向图,相同桶中的单词添加相邻边
graph = {}
for key, words in buckets.items():
for word in words:
for word1 in words:
if word != word1:
if word not in graph:
graph[word] = [word1]
else:
graph[word].append(word1)
if not graph or beginWord not in graph:
return 0
# 以下为BFS的标准流程
queue = [beginWord]
seen = set()
seen.add(beginWord)
ans = 1
while queue:
for i in range(len(queue)):
node = queue.pop(0)
# 遍历相邻节点
for nbr_node in graph.get(node, []):
if nbr_node == endWord:
return ans + 1
if nbr_node in seen:
continue
queue.append(nbr_node)
seen.add(nbr_node)
ans += 1
return 0
DFS
695. 岛屿的最大面积
题目链接
解法:深度优先搜索
def ladderLength(grid):
row = len(grid)
cow = len(grid[0])
ans = 0
for i in range(row):
for j in range(cow):
cur = 0
# 深度优先搜索使用栈
stack = [(i, j)]
# 如果当前节点值为1,则cur+1,并且把当前节点值改为0,代表已经搜索过
if grid[i][j] == 1:
cur += 1
grid[i][j] = 0
# 如果当前节点值为0,因为值能查看上下左右4个方向,所以为0时,4个方向无法连接起来,
# 直接continue
else:
continue
while stack:
node = stack.pop()
# 遍历4个方向
offsets = ((-1, 0), (0, 1), (1, 0), (0, -1))
for offset in offsets:
x = node[0] + offset[0]
y = node[1] + offset[1]
if x < 0 or x >= row:
continue
if y < 0 or y >= cow:
continue
if grid[x][y] != 1:
continue
stack.append((x, y))
grid[x][y] = 0
cur += 1
ans = max(ans, cur)
return ans
200. 岛屿数量
题目链接
当前题目与695题类似。
解法:深度优先搜索
def numIslands(grid):
row = len(grid)
cow = len(grid[0])
ans = 0
for i in range(row):
for j in range(cow):
# 深度优先搜索使用栈
stack = [(i, j)]
# 如果当前节点值为1,则cur+1,并且把当前节点值改为0,代表已经搜索过
if grid[i][j] == "1":
grid[i][j] = "0"
# 如果当前节点值为0,因为值能查看上下左右4个方向,所以为0时,4个方向无法连接起来,
# 直接continue
else:
continue
while stack:
node = stack.pop()
# 遍历4个方向
offsets = ((-1, 0), (0, 1), (1, 0), (0, -1))
for offset in offsets:
x = node[0] + offset[0]
y = node[1] + offset[1]
if x < 0 or x >= row:
continue
if y < 0 or y >= cow:
continue
if grid[x][y] != "1":
continue
stack.append((x, y))
grid[x][y] = "0"
ans += 1
return ans
130. 被围绕的区域
题目链接
解法:深度优先搜索
def solve(self, board: List[List[str]]) -> None:
"""
Do not return anything, modify board in-place instead.
"""
row = len(board)
cow = len(board[0])
seen = set()
for i in range(row):
for j in range(cow):
if (i, j) in seen:
continue
# 深度优先搜索使用栈
stack = [(i, j)]
seen.add((i, j))
temp = set()
edge_o_flag = False
if board[i][j] == "O":
if judge_edge(i, j, row, cow):
edge_o_flag = True
temp.add((i, j))
else:
continue
while stack:
node = stack.pop()
# 遍历4个方向
offsets = ((-1, 0), (0, 1), (1, 0), (0, -1))
for offset in offsets:
x = node[0] + offset[0]
y = node[1] + offset[1]
if (x, y) in seen:
continue
if x < 0 or x >= row:
continue
if y < 0 or y >= cow:
continue
seen.add((x, y))
if board[x][y] != "O":
continue
if judge_edge(x, y, row, cow) and board[x][y] == "O":
edge_o_flag = True
stack.append((x, y))
temp.add((x, y))
if not edge_o_flag:
for (x, y) in temp:
board[x][y] = "X"
def judge_edge(x, y, row, cow):
return (x == 0 or x == row - 1 or y == 0 or y == cow - 1)
回溯(Backtracking)
Backtracking(回溯)属于 DFS的一种。
普通 DFS 主要用在 可达性问题 ,这种问题只需要执行到特点的位置然后返回即可。
而 Backtracking 主要用于求解 排列组合 问题,例如有 { ‘a’,‘b’,‘c’ } 三个字符,求解所有由这三个字符排列得到的字符串,这种问题在执行到特定的位置返回之后还会继续执行求解过程。
因为 Backtracking 不是立即返回,而要继续求解,因此在程序实现时,需要注意对元素的标记问题:
在访问一个新元素进入新的递归调用时,需要将新元素标记为已经访问,这样才能在继续递归调用时不用重复访问该元素;
但是在递归返回时,需要将元素标记为未访问,因为只需要保证在一个递归链中不同时访问一个元素,可以访问已经访问过但是不在当前递归链中的元素。
回溯算法能解决如下问题:
组合问题:N个数按一定规则找出k个数的集合;
排列问题:N个数按一定规则全排列,有几种排列方式;
切割问题:一个字符串按一定规则有几种切割方式;
子集问题:一个N个数的集合有多少符合条件的子集;
棋盘问题:N皇后,解数独等等。
def backtracking(参数):
if 终止条件:
保存结果
return
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)):
处理节点
backtracking(路径,选择列表) // 递归
回溯,撤销处理结果
全排列问题
输出自然数1到n所有不重复的排列,要求每个数字只能使用一次。
例如: 输入n=3, 输出 [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]。
def find_all_pacificAtlantic(n):
def dfs(x):
# 如果当前要选的下标x > 总的可选数字个数,说明已经选择完毕,将结果加入到result中
if x > n:
result.append(copy.deepcopy(ans))
return
# 每一位的数字范围为 1-n
for i in range(1, n + 1):
# 判断当前数字是否使用过
if seen.get(i, 0) == 0:
# 当前下标置为 i
ans[x-1] = i
# 已经使用过的数字加入到seen,其他下标不能再使用
seen[i] = 1
# 递归搜索下标为x+1的位置可选数字
dfs(x + 1)
# 一轮搜索结束要把使用的数据恢复为可选状态(回溯)
seen[i] = 0
# 由于不能重复使用数字,所以用seen记录每个数字是否在其他位置上已经使用过,
# 1代表使用过,0代表未使用
seen = {}
# 用于保存排列组合数字
ans = [0, 0, 0]
# 保存最终结果
result = []
# 从第1位开始搜索,x代表第几位
dfs(1)
return result
- 从第1位开始选择,首选选择1,并把1置为已用状态;
- 递归调用dfs,第2位选择1时发现已经被使用,所以选择了2,并把2置为已用状态;
- 递归调用dfs,第3位选择1和2时发现已经被使用,所以选择了3,并把3置为已用状态;
- 递归调用dfs,选择第4位时已经超出n,所以递归退出,把结果[1,2,3]加入到result中;
- 递归退出后,回到选择第3位时的调用栈,将3置为可以用状态;
- 再次回到选择第2位时的调用栈,将2置为可用状态;
- 重新选择第2位,上次选到了2,这次循环选到3,将3置为已用状态;
- 选择第3位,由于1、3已经被使用,所以第3位只能使用2,将结果[1,3,2]加入到result中。
…
77. 组合
题目链接
解法:回溯
def combine(n, k):
# start代表从x开始
def backtracking(start):
if len(temp) == k:
result.append(copy.deepcopy(temp))
return
#for i in range(start, n+1):
# 当前已经选择的元素len(temp); 还需要选择的元素k-len(temp)
# 还可以选择的元素n-(k-len(temp))+1,如果小于start,则不需要再循环递归
for i in range(start, (n-(k-len(temp))+1)+1):
temp.append(i)
# 因为是找组合,元素不能重复,当前选到了i,下次递归要从i+1开始选
backtracking(i+1)
# 回溯
temp.pop()
# 保存临时数据
temp = []
result = []
backtracking(1)
return result
第216题.组合总和III
题目链接
解法:回溯
def backtracking(start):
# 如果temp中元素个数大于k,退出递归
if len(temp) > k:
return
# 如果temp中元素个数等于k,并且和为n
if sum(temp) == n and len(temp) == k:
result.append(copy.deepcopy(temp))
return
for i in range(start, 10):
temp.append(i)
backtracking(i+1)
temp.pop()
temp = []
result = []
backtracking(1)
return result
17. 电话号码的字母组合
题目链接
解法:回溯
def letterCombinations(digits):
mapping = {
"2": "abc",
"3": "def",
"4": "ghi",
"5": "jkl",
"6": "mno",
"7": "pqrs",
"8": "tuv",
"9": "wxyz"
}
if digits == "":
return []
def backtracking(index):
# 如果temp中元素个数等于k,并且和为n
if len(temp) == len(digits):
result.append("".join(temp))
return
for i in mapping[digits[index]]:
temp.append(i)
# 注意这里是index+1,不是i+1
# 不同位置选择的元素属于不同集合,互不影响,所以只需要用index标记要给哪一位选
backtracking(index+1)
temp.pop()
temp = []
result = []
backtracking(0)
return result
39. 组合总和
题目链接
解法:回溯
def combinationSum(candidates, target):
def backtracking(start):
if sum(temp) > target:
return
# 如果temp中元素个数等于k,并且和为n
if sum(temp) == target:
result.append(copy.deepcopy(temp))
return
for i in range(start, len(candidates)):
temp.append(candidates[i])
# 注意这里是i,不是i+1
# 同一个集合中选n个可以重复的数字时,不需要i+1,下次递归还可以选当前选择的元素
backtracking(i)
temp.pop()
temp = []
result = []
backtracking(0)
return result
注意:
- 同一个集合中选n个数并且不允许重复时,需要通过start标记当前选到了哪个数,下次递归要从start+1开始选
- 同一个集合中选n个数并且允许重复时,需要通过start标记当前选到了哪个数,下次递归还是从start开始选,可以重复选当前元素。
- 从n个集合中选n个数,每个数字选择互不影响,只需标记当前给第几位选数字即可。(例如:17. 电话号码的字母组合)
40. 组合总和 II
题目链接
解法:回溯
def combinationSum2(candidates, target):
def backtracking(start):
if sum(temp) > target:
return
if sum(temp) == target:
result.append(copy.deepcopy(temp))
return
for i in range(start, len(candidates)):
# 排序后,如果当前数字等于前一个数字,说明存在重复数字
# 如果used用于记录同一树层前一个重复数字有没有被使用过,如果为False则被使用过
# 例如:nums=[1,1,2] target=3 第一层选了1,递归选第二个数字时,再次选到了1,
# 但是used[1-1]=True,可以继续选1,即temp=[1,1],同一树枝可以重复选择数字
# 例如:第一个树枝已经选到了temp=[1,2]满足条件;选第二条树枝时,第一层选到了1,
# 等于前一个数字1,但是used[1-1]=False,即前一个1已经用过了。
if i > 0 and candidates[i] == candidates[i-1] and not used[i-1]:
continue
used[i] = True
temp.append(candidates[i])
backtracking(i+1)
used[i] = False
temp.pop()
used = [0] * len(candidates)
temp = []
result = []
# 1.先对原数据排序
candidates.sort()
backtracking(0)
return result
注意:这道题与前面的题主要区别是,原数据存在重复数字,最终结果不允许右重复集合,所以通过排序 + used记录同一树层已经使用过的数字。
131. 分割回文串
题目链接
解法:回溯
def partition(s):
def backtracking(start):
"""
:param start: 标记截取字符串的起始位置
:return:
"""
# 如果起始位置大于等于len(s),说明已经遍历完字符串
if start >= len(s):
result.append(copy.deepcopy(temp))
return
for i in range(start, len(s)):
# 判断s[start,i+1]是否是回文串
if check_palindromic(s, start, i):
# 由于截取字符串是左闭右开区间,所以这里是i+1,实际上截取到了i
temp.append(s[start:i+1])
backtracking(i+1)
temp.pop()
def check_palindromic(exp, left, right):
"""
判断回文串
:param exp:
:return: Boolean
"""
if left > right or exp == "":
return False
while left < right:
if exp[left] == exp[right]:
left += 1
right -= 1
else:
return False
return True
temp = []
result = []
backtracking(0)
return result
93. 复原 IP 地址
题目链接
解法:回溯
def restoreIpAddresses(s):
def backtracking(start):
"""
:param start: 标记截取字符串的起始位置
:return:
"""
if len(temp) > 4:
return
# 如果起始位置大于等于len(s),说明已经遍历完字符串
if start >= len(s) and len(temp) == 4:
result.append(".".join(temp))
return
for i in range(start, len(s)):
# 值不大于255,并且首位不为0
# TODO 剪枝
if (len(s[start:i+1]) == 1 or s[start:i+1][0] != "0") and int(s[start:i+1]) <= 255:
temp.append(s[start:i+1])
backtracking(i+1)
temp.pop()
temp = []
result = []
backtracking(0)
return result
78. 子集
题目链接
解法:回溯
def subsets(nums):
def backtracking(start):
"""
:param start: 标记截取字符串的起始位置
:return:
"""
# 子集是求所有节点,不是叶子节点,所以不需要判断条件
result.append(copy.deepcopy(temp))
for i in range(start, len(nums)):
temp.append(nums[i])
backtracking(i+1)
temp.pop()
temp = []
result = []
backtracking(0)
return result
**注意:**如果把 子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,求子集问题是找树的所有节点!
491.递增子序列
题目链接
解法:回溯
def findSubsequences(self, nums: List[int]) -> List[List[int]]:
def backtracking(start):
"""
:param start: 标记截取字符串的起始位置
:return:
"""
# 判断是否已经存在
if len(temp) >= 2 and temp not in result:
result.append(copy.deepcopy(temp))
for i in range(start, len(nums)):
if len(temp) == 0 or nums[i] >= temp[-1]:
temp.append(nums[i])
backtracking(i+1)
temp.pop()
temp = []
result = []
backtracking(0)
return result
排列问题
46.全排列
题目链接
解法:回溯
def permute(nums):
def backtracking():
if len(temp) == len(nums):
result.append(copy.deepcopy(temp))
for i in range(0, len(nums)):
# 因为组合问题是从下标0开始遍历nums,并且没有重复原元素,
# 需要判断是否当前元素已经加入过temp
if nums[i] not in temp:
temp.append(nums[i])
backtracking()
temp.pop()
temp = []
result = []
backtracking()
return result
注意:组合问题遍历nums时需要从0开始遍历;例如:nums=[1,2,3], [1,2,3]和[2,1,3]都是结果,第一个元素从2开始时,需要能获取到1,所以需要从下标0开始遍历,同时为了保证不会重复拿到2,所以判断nums[i]是否已经在temp中。
47.全排列 II
题目链接
解法:回溯
def permuteUnique(nums):
def backtracking():
if len(temp) == len(nums):
result.append(copy.deepcopy(temp))
return
for i in range(0, len(nums)):
# 同一树层去重
if i > 0 and nums[i] == nums[i-1] and not used[i-1]:
continue
# 同一树枝去重
if not used[i]:
used[i] = True
temp.append(nums[i])
backtracking()
temp.pop()
used[i] = False
nums.sort()
used = [False] * len(nums)
temp = []
result = []
backtracking()
return result
注意:当前题目和 40. 组合总和 II 去重条件相似,区别就是40题是求组合,每次递归nums下标从start+1开始,同一树枝不会重复遇到同一个元素。当前题目是组合,递归遍历nums是从下标0开始,每次都会遇到当前元素,需要通过同一树层去重和同一树枝去重来避免重复的解。
332. 重新安排行程
题目链接
解法:回溯
def findItinerary(tickets):
def backtracking(start):
if len(temp) == len(tickets)+1:
return True
for index, end in enumerate(ticket_dict.get(start, [])):
if not end.endswith("_use"):
ticket_dict[start][index] += "_use"
temp.append(end)
if backtracking(end):
return True
temp.pop()
ticket_dict[start][index] = ticket_dict[start][index][:-4]
# 当前树枝的叶子节点,在ticket_dict中找不到对应的key时,for循环不会执行,直接返回Fasle,此路不通
return False
# 1. 起点和终点映射关系
ticket_dict = {}
for ticket in tickets:
if ticket[0] not in ticket_dict:
ticket_dict[ticket[0]] = [ticket[1]]
else:
ticket_dict[ticket[0]].append(ticket[1])
# 2. 因为最终结果与字符顺序有关,所以先升序排序,每次获取从第0个元素获取,即能保证最小路径
for key, value in ticket_dict.items():
value.sort()
# 3. 起点为"JFK"
temp = ["JFK"]
backtracking("JFK")
return temp
例如:
- tickets=[[“JFK”, “KUL”], [“JFK”, “MUC”], [“MUC”, “JFK”]],整理起点和终点并排序后为:ticket_dict={‘JFK’: [‘KUL’, ‘MUC’], ‘MUC’: [‘JFK’]}。
- 进入第一次递归起点为’JFK’,其对应的两个终点为[‘KUL’, ‘MUC’];先从"KUL"开始,temp=[“JFK”, “KUL”], ticket_dict={‘JFK’: [‘KUL_use’, ‘MUC’], ‘MUC’: [‘JFK’]}进入第二次递归,ticket_dict中没有以"KUL"为起点的信息,所以直接返回False。
- 从temp中删除"KUL",temp=[“JFK”],并且将ticket_dict恢复为ticket_dict={‘JFK’: [‘KUL’, ‘MUC’], ‘MUC’: [‘JFK’]}。
- 从"MUC"进入第二层递归,此时temp=[“JFK”, “MUC”], ticket_dict={‘JFK’: [‘KUL’, ‘MUC_use’], ‘MUC’: [‘JFK’]};“MUC"对应的终点为"JFK”,temp=[“JFK”, “MUC”, “JFK”], ticket_dict={‘JFK’: [‘KUL’, ‘MUC_use’], ‘MUC’: [‘JFK_use’]}。
- 进入第三层递归,起点为"JFK",此时"JFK"对应的终点为[‘KUL’, ‘MUC_use’],只有"KUL"可以用,所以最终结果就是temp=[“JFK”, “MUC”, “JFK”, “KUL”]。
51. N 皇后
题目链接
解法:回溯
待完成
37. 解数独
题目链接
解法:回溯
待完成