回溯算法 (backtracking)和动态规划(dynamic programming)的思路可以解决很多类型的算法题。特地整理一下两种思路的框架、应用前提和例题分析。
这篇文章的目标不是为某些算法题寻找时间、空间最优的解决方案,而是提供一种分析思路的框架。有了成型的框架之后,我们可以针对具体问题进行优化。
拿过一道算法题,第一步是对它进行建模:
class ProblemExample(object):
def __init__(self, *args):
# initialize the parameters in problem
...
def init_state(self):
# where to start
return ...
def is_end(self, state) -> bool:
# check if this state corresponds to a end state
...
return ...
def is_valid(self, action) -> bool:
# check if this action could satisfy the constraints of pbm
...
return ...
def succ_cost(self, state):
# return (action, new_state, cost), based on the current state
# sometimes, no need to return all this triplets.
...
return ...
以 Leetcode 第46题为例:
给定一个不含重复数字的数组nums
,返回其所有可能的全排列。可以按任意顺序返回答案。
我对这个问题的建模是, state
代表当前的列表;action
代表下一步要加入该列表中的数字;该问题不考虑 cost
。程序如下:
class Permutations(object):
# state: the current list of numbers
# action: the next number to be added into list
# cost: no cost
def __init__(self, nums):
self.nums = nums
def init_state(self):
# empty list as init state
return []
def is_end(self, state):
if len(state) == len(self.nums):
return True
return False
def is_valid(self, action, state):
# if "action" is already in "state", we can not add it.
if action in state:
return False
return True
def succ_cost(self, state):
nexts = []
for i in self.nums:
if self.is_valid(i, state):
nexts.append(state+[i])
return nexts
回溯算法的思路说起来很简单:利用函数的递归调用(自己调用自己),遍历所有的可能性。如果所在的分支已经不满足限制条件,就回溯到母节点,继续寻找。
所以,回溯算法有两个要点:终止条件、选择下一步的action的限制条件。
当前状态如果满足终止条件,取决于具体的问题,要么在solutions里添加一个解;要么比较当前的解与之前最好的解的 cost
,如有必要,更新最好的解。如果状态不满足终止条件,那么在当前状态下继续寻找所有可能的 action
,在 new_state
的基础上递归调用。
下面是一个回溯算法的整体框架:
def backtracking_search(problem):
best = {
'cost': float('+inf'),
'history': None
}
def recurse(state, history, total_cost):
# At state, having undergone history, accumulated totalCost
# Explore the rest of the subtree under state
if problem.is_end(state):
# update
if total_cost < best['cost']:
best['cost'] = total_cost
best['histoty'] = history
return
# recurse on children
for action, new_state, cost in problem.succ_cost(state):
recurse(new_state, history+[(action, new_state, cost)], total_cost+cost)
recurse(problem.init_state(), history=[], total_cost=0)
return (best['cost'], best['history'])
上面的框架看起来有些抽象。实际上,在解决不同问题时,需要对它进行一些调整。比如,要解决 Leetcode 第46题,可以给出如下框架:
def backtracking_search(problem):
solutions = []
def recurse(state):
# At state, having undergone history, accumulated totalCost
# Explore the rest of the subtree under state
if problem.is_end(state):
# update
solutions.append(state)
return
# recurse on children
for new_state in problem.succ_cost(state):
recurse(new_state)
recurse(problem.init_state())
return solutions
好,现在我们只需要实例化一个 Permutations
问题,把该实例传入回溯算法即可:
fc = FullCombination(nums=[1,2,3])
backtracking_search(fc)
>>> [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
当然,如果思路非常清晰,我们完全可以抛开框架,直接解决 Permutations
问题:
def FullCombination_backtracking(nums):
solutions = []
def recurse(state):
if len(state) == len(nums):
solutions.append(state)
return
for i in nums:
if i not in state:
recurse(state+[i])
recurse(state=[])
return solutions
数字 n
代表生成括号的对数,请你设计一个函数,生成所有可能并且有效的括号组合。
该问题没有 cost
;action space
等于 {'(', ')'}
。state
是三元组:当前所剩的左括号个数、当前所剩右括号个数,以及当前字符串。当然,这个state可以简化,由当前字符串长度以及剩余左括号个数,可以推出所剩右括号个数。
is_end
函数返回 True
当且仅当当前字符串长度等于 2n
;
is_valid
函数考虑当前应该选择左括号还是右括号,还是两个都可以选择。 注意,为了满足限制(生成的括号组合是有效的),当剩余左括号数量小于或等于右括号数量时,必须选择左括号;否则两个都可以选择。
程序:
def generate_parentheses(n):
solutions = []
def backTracking(l_remain, r_remain, str_current):
if len(str_current) == 2*n:
solutions.append(str_current)
return
if l_remain > 0:
backTracking(l_remain-1, r_remain, str_current + "(")
if r_remain > l_remain:
backTracking(l_remain, r_remain-1, str_current + ")")
backTracking(n, n, "")
return solutions
给定两个整数 n
和 k
,返回范围 [1, n]
中所有可能的 k
个数的组合。
该问题没有 cost
;
action
可以是 [1, n]
范围内的整数。
state
:选择起始点 start
(当前可以从这个数字开始选择 action
,它是为了避免重复组合,如 (1,2), (2,1
)、当前的列表(它包含已经选择的数字)。
is_end
函数返回 True
当且仅当当前列表长度等于 k
;
is_valid
考虑的是限制条件。题干里没有显式限制,但包含了一个隐形限制:组合不能重复。start
这个状态分量就是用于辅助避免重复的,可选的 action
只能是大于 start
的数。
def KCombination(n, k):
combinations = []
def recurse(start, history):
if len(history) == k:
combinations.append(history)
for i in range(start+1, n+1):
if i not in history:
recurse(i, history+[i])
recurse(start=0, history=[])
return combinations
动态规划的基本思路是,递归地把问题拆分成许多子问题,构建母问题与子问题之间的依赖关系,子问题解决了,母问题也就解决了。
动态规划有一个最基本的前提假设:当前状态能够反映过往所有 action
的信息;因此,知道当前状态就可以优化地选择未来的 action
。
具体来说:
F u t u r e C o s t ( s t a t e ) = m i n a c t i o n { c o s t ( s t a t e , a c t i o n ) + F u t u r e C o s t ( n e w _ s t a t e ) } FutureCost(state) = min_{action} \{ cost(state, action) + FutureCost(new\_state) \} FutureCost(state)=minaction{cost(state,action)+FutureCost(new_state)}
动态规划的框架:
# Framework
def dynamic_programming(problem):
cache = {} # store the result (future cost) of states already seen
def future_cost(state):
if problem.isEnd(state):
return 0
if state in cache:
return cache[state]
result = min(cost + future_cost(newState)
for action, newState, cost in problem.succ_cost(state))
cache[state] = result
return result
return future_cost(problem.initState())