编程思想
1. 原理
基础的数据结构与算法中,有几块非常难懂,贪心,分治,回溯和动态规划这四个编程思想应该算是"名列前茅"了。本文希望通过详细解答几个经典示例来帮助大家搞懂他们。在进入实战之前,我们先来看看他们的区别。
1.1 动态规划
动态规划适合解决的问题可以概括为“一个模型三个特征”。
- 一个模型: 多阶段决策最优解模型。动态规划通常被用来解决最优问题,而解决问题的过程,需要经历多个决策阶段。每个决策阶段都对应着一组状态。然后我们寻找一组决策序列,经过这组决策序列,能够产生最终期望求解的最优值。
- 三个特征:
- 最优子结构: 我们可以通过子问题的最优解,推导出问题的最优解
- 无后效性: 某阶段状态一旦确定,就不受之后阶段的决策影响
- 重复子问题: 不同的决策序列,到达某个相同的阶段时,可能会产生重复的状态
了解了动态,接下我们就以动态为标杆,看看其他编程思想有什么不同
1.2 分治
贪心、回溯、动态规划可以归为一类,都可以抽象成我们今天讲的那个多阶段决策最优解模型,而分治算法解决的问题尽管大部分也是最优解问题,但是,大部分都不能抽象成多阶段决策模型。
在重复子问题这一点上,动态规划和分治算法的区分非常明显。分治算法要求分割成的子问题,不能有重复子问题,而动态规划正好相反,动态规划之所以高效,就是因为回溯算法实现中存在大量的重复子问题。
1.3 贪心
它能解决的问题需要满足三个条件,最优子结构、无后效性和贪心选择性。贪心选择性的意思是,通过局部最优的选择,能产生全局的最优选择。每一个阶段,我们都选择当前看起来最优的决策,所有阶段的决策完成之后,最终由这些局部最优解构成全局最优解。贪心算法实际上是动态规划算法的一种特殊情况。
1.4 回溯
回溯算法是个“万金油”。基本上能用的动态规划、贪心解决的问题,我们都可以用回溯算法解决。回溯算法相当于穷举搜索。不过,回溯算法的时间复杂度非常高,是指数级别的,只能用来解决小规模数据的问题。
它们之间的区别讲完了,接下来我们就来看看如何用它们来解决我们的编程问题。
2. 实战
2.1 最少加油次数
leecode 871
回溯
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
class Solution(object):
def minRefuelStops(self, target, startFuel, stations):
"""
:type target: int
:type startFuel: int
:type stations: List[List[int]]
:rtype: int
"""
self.min_station = len(stations) + 1
if startFuel >= target:
return 0
self.refuel_stop(target, stations, 0, startFuel, 0)
self.min_station = self.min_station \
if self.min_station <= len(stations) else -1
return self.min_station
def refuel_stop(self, target, station, i, fuel, c):
"""
:param target: 距离目标地点还有多远
:param station:
:param i: 表示到达第 i 个加油站,i 从 0 开始计数
:param fuel: 能走多远
:param c: 加油次数
:return:
"""
# 已经到最有一个加油站了
if i == len(station):
if fuel >= target:
self.min_station = min(c, self.min_station)
return
# 剩下的油走不到下一站了,也无法到达 target
if fuel < min(station[i][0], target):
return
# 已经足够达到终点
if fuel >= target:
self.min_station = min(c, self.min_station)
return
# 没到达终点,油够到下一站
# 不加油
self.refuel_stop(target, station, i + 1, fuel, c)
# 加油
if fuel >= station[i][0]:
self.refuel_stop(target, station, i + 1, fuel + station[i][1], c + 1)
|
动态规划
如果我们仔细思考上面的回溯代码,我们会发现递归调用过程中如下几个变化的量:
- self.min_station: 加油次数,这是我们要求的结果变量
- i: 第 i 个加油站
- fuel: 能够达到的最远距离
看起来我们可以创建一个二维状态转移表,y 轴方向表示第 i 个加油站,x 轴方向表示加了几次油,二维表中的值表示能达到的最远距离。这里动态规划的解法需要我们稍微专变一下思路,求到达指定距离的最少加油次数,跟求指定加油次数能达到的最远距离是同一问题。
整个决策过程设计多个阶段,我们要决定在每个加油站是否加油,并求出能达到的最远距离。第二次加油能走的最远距离显然由上次能走的最远距离决定。符合多阶段最优解模型。
最后根据回溯算法的代码实现,我们可以画出递归树,看是否存在重复子问题。假设输入参数分别是:
- target = 100,
- startFuel = 20,
- stations = [[10,60],[20,30],[30,30],[60,40]]
1
2
3
|
f(0, 20)
f(1, 20) f(1, 80)
f(2, 0) f(2, 50) f(2, 80) f(2, 110)
|
递归树中的每个节点表示一种状态,我们用(i, fuel)来表示。其中,i 表示是否在第 i 个加油站加油,fuel 表示能达到的最远距离。看起来没有重复子问题,但是我们要计算的是加油一次能够达到的最远距离,所以上面的 f(1, 80) 和 f(2, 50) 表示我在第 1 个加油站加油最远可以走 80 ,在第 2 个加油站加油最远可以走 50 公里。显示我们会在第 1 个加油站。当然不容易想到。
接下来我们就可以画出状态转移表,并写出代码。在写代码的过程中,你就会发现二维表在这里并没有用,如果我们计算出了第 i 次油能走的最远距离dp[i],那么 dp[i+1] 加油能达到的最远距离,就取决于 dp[i] 能达到的加油站以及每个加油站能加的油即: if dp[t] >= station[i+1][0]; dp[t+1]=dp[t]+station[i+1][1]。下面就是代码实现:
1
2
3
4
5
6
7
8
9
10
11
|
class Solution(object):
def minRefuelStops(self, target, startFuel, stations):
dp = [startFuel] + [0] * len(stations)
for i, (location, capacity) in enumerate(stations):
for t in xrange(i, -1, -1):
if dp[t] >= location:
dp[t+1] = max(dp[t+1], dp[t] + capacity)
for i, d in enumerate(dp):
if d >= target: return i
return -1
|
注意 xrange(i, -1, -1) 不能改成 xrange(0, x + 1)。
2.2 最长递增子序列长度
leecode 300
回溯
下面是最长递增子序列长度回溯的非完整实现。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
class Solution(object):
def lengthOfLIS(self, nums):
"""
:type nums: List[int]
:rtype: int
"""
self.nums = nums
self.len = 1
self.get_lis(0, 1, self.nums[0])
def get_lis(self, i, l, m):
"""
:param i: 表示这是第几个数
:param l: l 当前的最长子序列长度
:param m: m 子序列中最大的书
:return:
"""
self.len = max(l, self.len)
p = self.nums[i]
if p >= m:
self.get_lis(i + 1, l + 1, p)
else:
self.get_lis(i + 1, 1, p)
self.get_lis(i + 1, l, m)
|
动态规划实现
回溯过程显示了以下几个递归变化量:
- i:第 i 个数
- l: 最长子序列的长度
- m:最长子序列中的最大值
同样的,我们可以通过一个二维数组,画出上面三个变量的状态转移表:
- y轴方向:数组中的第 i 个数
- x轴方向: 标识最长子序列的长度
- 二维数组的值记录了对应位置最长子序列中的最大值
当我们找不到解题思路时,一定要画出上面上面的状态转移表,这样能帮我我们找到解题思路,写出状态转移方程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
class Solution(object):
def lengthOfLIS(self, nums):
"""
:type nums: List[int]
:rtype: int
"""
if not nums:
return 0
n = len(nums)
status = [None] * (n)
status[0] = nums[0]
end = 0
for i in range(1, n):
end = max(end, self.binary_search(nums[i], status, end))
return end + 1
def binary_search(self, v, status, end, start=0):
m = start
if status[end] < v:
end += 1
status[end] = v
return end
while start <= end:
mid = start + ((end - start) > 1)
if status[mid] == v:
return end
elif status[mid] < v:
start = mid + 1
else:
if mid == m or status[mid-1] < v:
status[mid] = v
return end
else:
end = mid - 1
|