今天就简单来谈谈这几者之间的关联和区别
递归
一句话,我认为递归的本质就是将原问题拆分成具有相同性质的子问题。
递归的特点:
1、子问题拆分方程式,比如:f(n) = f(n-1) * n
2、终止条件:也就是子问题无法再进一步拆分时,这时可以直接求出解,退出递归。
一个问题能否使用递归求解,就看能不能满足上面两个特征
以数值的整数次方为例
通过分析,我们很容易的知道上面这道题是非常符合递归特征的
1、子问题拆分方程式:f(x, n) = f(x, n-1) * x
2、终止条件:if n < 2: return x
class 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
递归和迭代在二叉树用的非常多,二叉树基本天生就跟递归一起的,因为思路简单,符合人们的正常思维,只要有递归的地方,都可用转换为迭代,大家可以自己使用二叉树的题目来练习练习递归和迭代。
分治
其实,我认为递归算法从本质上来说都是分治算法,无非就是有些问题递归需要将原问题分解成多个子问题,而有的只需要分解成一个子问题,因此有的人将前面那种情况称作分治,将后面那种情况称作递归。
之前说过,递归跟迭代是一一对应的,因此分治有两种解法: 递归和迭代
分治算法的思想:
1、数学归纳法:只要出现可以用数学归纳公式来表示的大规模问题,第一反应就应该想到分治算法,通过特定的函数参数安排,一定可以用同一个函数来表述不同规模的问题,套用递归结构,可迅速解决问题!
2、不一定使用递归结构:递归结构是循环结构的一种,也是分治思想应用最多的一种程序结构,但是不一定要使用它!关键在于能够写出递归公式以及是否有必要使用递归算法。
因此可以知道,分治是一种算法思想,递归是一种技术手段。
分治算法的特征:
1、分解:将原问题拆分成若干个子问题
2、求解子问题:也就是终止条件
3、合并:将各个子问题的解合并,形成原问题的解
下面以斐波那契数列为例:
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]
注:分治算法一般体现在归并排序和快速排序里面
从这道题可以看出:
1、在求解该问题的时候,有很多重复的子问题,这样会导致非常低效,于是我们加了缓存,也就是带备忘录的递归解法,
2、整个解法是自顶向下的,通过画递归树就很容易的看出来。比如f(20),向下逐渐分解,知道f(1)和f(2),然后触底返回。
回溯
回溯其实类似于DFS搜索算法,以二叉树为例:
1、从根节点出发
2、随便选择一个子节点并达到该子节点,并标记该节点
3、然后从该子节点又随便选择一个子节点并到达该子节点,直到到达叶子节点
4、此时我们已经完整的做了一次选择,但是我们发现,在根节点到叶子节点的路径中,我们还有很多选择可以做。因此,我们需要从叶子节点往上一层层的返回,每返回一个节点,都选择一个之前没有走过的节点继续走下去,也就是重复2/3/4步骤,直到遍历完整个二叉树。
说了这么多,那么回溯跟上面讲的几个算法有什么联系呢:
我们来看下回溯算法的几个特征就知道了
回溯算法的特征:
1、深度优先遍历dfs:回溯算法一般采用dfs求解,因此满足递归的一般特征
2、子集:回溯题目一般都要求求解所有的最优解,因此,dfs的终止条件就是判断是否得到了一个最优解,然后直接返回。
3、遍历空间集:在每一轮dfs中都需要遍历空间集,根据题目性质,有的需要从0开始,有的需要从当前位置开始。
4、剪枝:在遍历空间集的时候,需要优先将不符合条件的去除掉,不然会做很多无用的递归调用,导致超时。
5、加入元素:遍历空间集的时候,加入每一个元素,然后再dfs
6、移除元素:当一轮dfs达到终止条件结束的时候,说明当前选择已经完成,需要返回到上一轮做其他选择,因此需要将上一轮选择时加入的元素删除掉。
这很抽象,我们以组合总和为例
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,动态规划的定义是什么,我也不知道,各有各的理解吧
动态规划的特征:
1、最优子结构:在自下而上的递推过程中,我们求得的每个子问题一定是全局最优解,既然它分解的子问题是全局最优解,那么依赖于它们解的原问题自然也是全局最优解。
2、重叠子问题:在求解原问题的时候,我们往往需要依赖其子问题,子问题依赖其子子问题,甚至可能同时依赖多个子问题,因此子问题之间是有重叠关系的。
3、状态初始化:因为动态规划是依赖子问题的,因此需要先初始化已知状态,从而才能自下而上的递推到原问题得解。
4、遍历状态集:首先根据需要求解的结果来确定状态集,然后遍历状态集,更新状态dp
5、状态转移方程:也就是每一级子问题之间的关系式。
下面以最长回文子串为例
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]
这跟前面递归和分治时提到的自顶向下,形成对比。
贪心
贪心算法是指,在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,它所做出的仅仅是在某种意义上的局部最优解。
贪心算法的特征:
1、最优子结构:贪心算法也是将原问题分解成多个性质相同的子问题,每个问题都是局部最优。
2、贪心选择性质:在做贪心选择时,我们直接做出当前子问题中看起来最优的解,这也是贪心算法和动态规划的不同之处。
3、遍历状态集:遍历状态集,做出局部最优选择,更新结果。
4、无后效性:某个状态以后的过程不会影响以前的状态,只与当前状态有关。
下面以为例
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
这里提下
涉及到贪心算法的核心性质:
只适用于求解可行解,不适用于求最值以及所有解
后记
《算法之道》对其中的三种算法进行了归纳总结,如下表所示:
标准分治 | 动态规划 | 贪心算法 |
|
适用类型 |
通用问题 |
优化问题 |
优化问题 |
子问题结构 |
每个子问题不同 |
很多子问题重复 |
只有一个子问题 |
最优子结构 |
不需要 |
必须满足 |
必须满足 |
子问题数 |
全部子问题都要解决 |
全部子问题都要解决 | 只要解决一个子问题 |
子问题在最优解里 |
全部 |
部分 |
部分 |
选择与求解次序 | 先选择后解决子问题 |
先解决子问题后选择 |
先选择后解决子问题 |
作者:tunsuy
链接:https://leetcode-cn.com/circle/article/yXFal5/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
java达人
ID:drjava
(长按或扫码识别)