我们总是听到见到动态规划这个词,到底怎么定义动态规划呢?来自国外的一个编程大师指出:
In a nutshell, dynamic programming is recursion without repetition.
In a nutshell是一句英语俗语,就是“简单地说”。这句话翻译过来就是,动态规范,就是没有重复的递归。动态规划分为三步:
第一步 定义子问题
第二步 写出与子问题的递归方式
第三步 解决不能继续拆分的基本问题
动态规划,不过是把问题拆分为子问题,去除重复的运算。说起来非常简单,但是分好几种场景,常见的是以下几种场景
我举个一维动态规划的例子:
一个数字,写成1,3,4的和,有多少种方式?
例如5,可以写成以下几种方式:
5
=
1
+
1
+
1
+
1
+
1
=
1
+
1
+
3
=
1
+
3
+
1
=
1
+
4
=
3
+
1
+
1
=
4
+
1
5\\ = 1 + 1 + 1 + 1 + 1\\ = 1 + 1 + 3\\ = 1 + 3 + 1\\ = 1 + 4\\ = 3 + 1 + 1\\ = 4 + 1
5=1+1+1+1+1=1+1+3=1+3+1=1+4=3+1+1=4+1
总共6种方式。
那这个问题怎么解决呢?
首先定义子问题,5的种类可以分为1 + 4,3+2,4+1三种形式。也就是5可以拆分为4的种类,2的种类,1的种类三个子问题。定义函数f(x)为某个数字写成1,3,4的和的种类,那么这是个分段函数,分段函数分可递归部分定义域和不可递归部分定义域。可递归部分也就是f(x) = f(x-1)+f(x-3)+f(x-4)。但是递归公式里有x-4,所以x至少为5,这个是可递归部分定义域。所以x为1, 2, 3, 4的四种情况就需要我们手动计算,这就叫不能继续递归的子问题,这也是不可递归部分的定义域。这四个不能递归的子问题答案我已经手动算出来了:
f
(
1
)
=
1
f
(
2
)
=
1
f
(
3
)
=
2
f
(
4
)
=
2
+
1
+
1
=
4
f(1)=1\\ f(2)=1\\ f(3)=2\\ f(4)=2+1+1=4\\
f(1)=1f(2)=1f(3)=2f(4)=2+1+1=4
那么代码就简单了,存入数组要错一位,因为数组从0开始嘛。于是python代码如下:
def f(n):
data = [1, 1, 2, 4]
if (n < 5):
return data[n - 1]
for i in range(4, n):
data.append(data[i - 4] + data[i - 3] + data[i - 1])
return data[n - 1]
当然,这个代码执行起来空间复杂度非常高。可以优化为只用长度为5的数组来缓存,因为后面的计算最多只用到了前面第四个数组项。
二维动态规划,最常见的是面试题,最长子字符串问题。这里的最长子串是这样的规则,举个例子:
x: ABCBDAB
y: BDCABC
那么最长字串是加粗的BCAB,长度为4。
这个问题,我在数据结构中讲过,所以我不能抄袭自己,就直接抛个链接出来吧:最长子串问题。
而另一个经典的二维动态规划问题是回文问题,举个例子,回文palindrome是指正反都起来都一样的文字,比如pop,上海自来水来自海上。现在请问一个不是回文的字符串,如何插入最少的字符,使之变成回文。
举个例子:Ab3bd,插入d和A,就变成了dAb3bAd或者Adb3bdA。
按照斯坦福大学定义的动态规范步骤,我们一步一步地解决这个问题。
第一步是定义子问题:
D
i
j
D_{ij}
Dij函数是使
x
i
x_i
xi~
x
j
x_j
xj变成回文的最少字符串数量的函数。当然,动态规划大部分场景下,都是用一维数组或多维数组存储函数值,所以也可以把这个函数当成数组。
第二步是寻找递归:
假设一个最短的回文
y
1
∼
k
y_{1\sim k}
y1∼k包含了子字符串
x
i
x_i
xi~
x
j
x_j
xj,那只有三种情况,要么左边补字母,要么右边补字母,要么x首尾相等(但中间不一定是回文哦)。
也就是
y
1
=
x
i
y_1=x_i
y1=xi 或
y
k
=
x
j
y_k = x_j
yk=xj,如果从左边补齐,那么就有这个例子:
y=abcdedcba x=edcba
如果从右边补齐,那么就有这个例子:
y= abcdedcba x=abcde
如果从中间插入,那么有这个例子:
y= abcdedcba x=abcdea
那么y去掉头尾,就是一个新的回文,可能存在三种情况:
一是
x
i
+
1
,
j
x_{i+1,j}
xi+1,j的解比如:
x
i
+
1
,
j
=
b
c
d
e
y
2
,
k
−
1
=
b
c
d
e
d
c
b
x_{i+1,j}=bcde ~ y_{2,k-1}=bcdedcb
xi+1,j=bcde y2,k−1=bcdedcb
二是
x
i
,
j
−
1
x_{i,j-1}
xi,j−1的解,比如:
x
i
,
j
−
1
=
e
d
c
b
y
2
,
k
−
1
=
b
c
d
e
d
c
b
x_{i,j-1}=edcb ~ y_{2,k-1}=bcdedcb
xi,j−1=edcb y2,k−1=bcdedcb
还可能是
x
i
+
1
,
j
−
1
x_{i+1,j-1}
xi+1,j−1的解,比如xy完全相等,x首尾相等(但中间不一定是回文哦)。所以就有了递归公式:
D
i
j
=
{
1
+
m
i
n
(
D
i
+
1
,
j
,
D
i
,
j
−
1
)
x
i
≠
x
j
D
i
+
1
,
j
−
1
x
i
=
x
j
D_{ij}={1+min(Di+1,j,Di,j−1)xi≠xjDi+1,j−1xi=xj
Dij={1+min(Di+1,j,Di,j−1)Di+1,j−1xi=xjxi=xj
递归的终点是一个字符串或两个相同的字符串,因为一个字符串和两个相同的字符串本身是回文。
那么就是循环代码了啊,因为j比i大,所以只要填充上三角矩阵就行了,先初始化一个二维数组,假如是长度5的字符串:
[
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
]
[0000000000000000000000000]
⎣
⎡0000000000000000000000000⎦
⎤
然后按这个循环,按左上到右下的斜方向扫描就行了。所以代码很简单啊:
# _*_ coding:utf-8 _*_
def palindrome(s):
n = len(s)
d = [[0 for _ in range(0, n)] for _ in range(0, n)]
# t代表斜向扫描的初始列
for t in range(1, n):
# i 代表行号
for i in range(0, n - t):
# j代表扫描列
# 寻找相邻想等的情况
j = i + t
if t == 1 and s[i] == s[j]:
d[i][j] = 0
elif s[i] == s[j]:
d[i][j] = d[i + 1][j - 1]
else:
d[i][j] = 1 + min(d[i + 1][j], d[i][j - 1])
return d[0][n - 1]
测试代码:
from com.youngthing.mathalgorithm.palindrome import palindrome
if __name__ == '__main__':
#测试数据
print(palindrome('abcde'))
print(palindrome('我是12是我'))
print(palindrome('Ab3bd'))
测试结果:
4
1
2
这个代码可以继续优化,因为二维数组的下半部分没有用,所以可以不要,以节省内存。