今天就简单来谈谈这几者之间的关联和区别
一句话,我认为递归的本质就是将原问题拆分成具有相同性质的子问题。
一个问题能否使用递归求解,就看能不能满足上面两个特征
以数值的整数次方为例
通过分析,我们很容易的知道上面这道题是非常符合递归特征的
f(x, n) = f(x, n-1) * xif n < 2: return xclass Solution(object):
def myPow(self, x, n):
"""
:type x: float
:type n: int
:rtype: float
"""
# 因为题目n有可能为负数,因此这里需要判断
if n == 0:
return 1
if n > 0:
return self.abs_pow(x, n)
if n < 0:
abs_val = self.abs_pow(x, -n)
return 1 / abs_val
def abs_pow(self, x, n):
# 该递归函数只处理n为正数,n为负数时,由调用方反除一下即可。
if n < 2:
return x
rst = self.abs_pow(x, n-1)
return x * rst因为递归的调用栈时有深度限制的,因此这道题是没法AC的
有时候我们会遇到很多这样的情况,那么怎么解决调用深度的问题呢,很容易想到递归的兄弟迭代。
我们知道递归和迭代其实是天生一对的,本质是一样的,迭代只是我们自己模拟了递归的调用栈而已,因此迭代一般会用到栈这样的数据结构
当然由于这道题比较简单,用不到栈,只是一个数的叠乘而已
def abs_pow(self, x, n):
rst = 1
while n > 0:
rst *= x
n -= 1
return rst递归和迭代在二叉树用的非常多,二叉树基本天生就跟递归一起的,因为思路简单,符合人们的正常思维,只要有递归的地方,都可用转换为迭代,大家可以自己使用二叉树的题目来练习练习递归和迭代。
其实,我认为递归算法从本质上来说都是分治算法,无非就是有些问题递归需要将原问题分解成多个子问题,而有的只需要分解成一个子问题,因此有的人将前面那种情况称作分治,将后面那种情况称作递归。
之前说过,递归跟迭代是一一对应的,因此分治有两种解法: 递归和迭代
因此可以知道,分治是一种算法思想,递归是一种技术手段。
下面以斐波那契数列为例:
class Solution(object):
def fib(self, n):
"""
:type n: int
:rtype: int
"""
if n <= 0:
return 0
# 备忘录
memo = [0] * (n+1)
return self.f(n, memo)
def f(self, n, memo):
# 终止条件
if n in [1, 2]:
return 1
if memo[n] != 0:
return memo[n]
# 分解
n1 = self.f(n-1, memo)
n2 = self.f(n-2, memo)
# 合并
memo[n] = (n1 + n2) % 1000000007
return memo[n]
注:分治算法一般体现在归并排序和快速排序里面
从这道题可以看出:
在求解该问题的时候,有很多重复的子问题,这样会导致非常低效,于是我们加了缓存,也就是带备忘录的递归解法,自顶向下的,通过画递归树就很容易的看出来。比如f(20),向下逐渐分解,知道f(1)和f(2),然后触底返回。回溯其实类似于DFS搜索算法,以二叉树为例:
标记该节点我们需要从叶子节点往上一层层的返回,每返回一个节点,都选择一个之前没有走过的节点继续走下去,也就是重复2/3/4步骤,直到遍历完整个二叉树。说了这么多,那么回溯跟上面讲的几个算法有什么联系呢:
我们来看下回溯算法的几个特征就知道了
终止条件就是判断是否得到了一个最优解,然后直接返回。从0开始,有的需要从当前位置开始。优先将不符合条件的去除掉,不然会做很多无用的递归调用,导致超时。返回到上一轮做其他选择,因此需要将上一轮选择时加入的元素删除掉。这很抽象,我们以组合总和为例
class Solution(object):
def combinationSum(self, candidates, target):
"""
:type candidates: List[int]
:type target: int
:rtype: List[List[int]]
"""
rst = []
# 先对列表排序,便于剪枝
candidates.sort()
self.dfs(rst, [], candidates, target, 0)
return rst
def dfs(self, rst, sub_rst, candidates, target, start):
# 终止条件:一般就是判断是否找到了一个最优解
if sum(sub_rst) == target:
rst.append(sub_rst[:])
return
# 遍历空间集:从当前位置出发
for index in range(start, len(candidates)):
# 加入每一个元素
sub_rst.append(candidates[index])
# 判断是否超过了总和,是则直接返回(因为前面我们已经排过序了)
if sum(sub_rst) > target:
sub_rst.pop()
break
# 开始dfs下一轮:因为这道题目要求每一个数字可以重复使用,因此下一轮的start也是当前位置
# 有的题目要求不能重复使用,那么这里的index应该改为 index+1
self.dfs(rst, sub_rst, candidates, target, index)
# 已经完成一轮dfs,开始返回上一轮做其他选择,先将本轮的选择去掉
sub_rst.pop()动态规划也简称dp,动态规划的定义是什么,我也不知道,各有各的理解吧
下面以最长回文子串为例
class Solution(object):
def longestPalindrome(self, s):
"""
:type s: str
:rtype: str
"""
len_s = len(s)
if len_s < 2:
return s
# 因为是判断子串,根据子串的性质s[i:j],因此必然涉及到两层循环,也就需要定义二维数组dp
# 状态初始化:默认为True,可以省去很多计算
dp = [[True for _ in range(len_s)] for _ in range(len_s)]
max_len = 0
start = 0
# 状态转移方程:表示i到j的子串是否是回文子串
# dp[i][j] = (s[i] == s[j] and dp[i+1][j-1])
# 遍历状态集:因为需要知道j-1,因此j需要从1开始
for j in range(1, len_s):
# 因为是子串,i一定是要小于j
for i in range(j):
if s[i] == s[j]:
# 如果收尾字符相等,那么直接取决于去掉该收尾的子串
dp[i][j] = dp[i+1][j-1]
else:
# 如果不相等,不管子串如何,肯定不是回文
dp[i][j] = False
# 更新最大长度
if dp[i][j] and max_len < j - i:
max_len = j - i
start = i
return s[start:start+max_len+1]从这道题可以看出:
整个解法是自底向上的,也就是先得到dp[0][0],然后逐渐的扩大范围,最终得到dp[0][n]
这跟前面递归和分治时提到的自顶向下,形成对比。
贪心算法是指,在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,它所做出的仅仅是在某种意义上的局部最优解。
下面以为例
class Solution(object):
def canJump(self, nums):
"""
:type nums: List[int]
:rtype: bool
"""
if not nums:
return False
len_nums = len(nums)
max_pos = 0
# 遍历状态集
for i in range(len_nums):
if i > max_pos:
return False
# 对每一个状态选择都做局部最优解
max_pos = max(max_pos, i + nums[i])
if max_pos >= len_nums-1:
return True
return False这里提下
只适用于求解可行解,不适用于求最值以及所有解
| - | 标准分治 | 动态规划 | 贪心算法 |
|---|---|---|---|
| 适用类型 | 通用问题 | 优化问题 | 优化问题 |
| 子问题结构 | 每个子问题不同 | 很多子问题重复 | 只有一个子问题 |
| 最优子结构 | 不需要 | 必须满足 | 必须满足 |
| 子问题数 | 全部子问题都要解决 | 全部子问题都要解决 | 只要解决一个子问题 |
| 子问题在最优解里 | 全部 | 部分 | 部分 |
| 选择与求解次序 | 先选择后解决子问题 | 先解决子问题后选择 | 先选择后解决子问题 |