剪绳子
offer12给出的是一个数学类的问题,现有一段长度为n>1的绳子,要将其剪成m>1段,m,n均为整数,问如何剪才能使得剪断的每一段绳子的长度乘积最大。
比方说,有一段长度为5的绳子,那么有如下的剪法:
[4,1],[3,1,1],[3,2],[2,2,1],[2,1,1,1],[1,1,1,1,1]
其中[3,2]有最大的乘积6.
动态规划
关于动态规划,展开讲的话就没完没了了。我给出了两个知乎上的链接和一个cnblog上的介绍,我觉得这三个地儿讲得足够齐全了。
当然,我们举一个非常简单的例子来说明动态规划的核心:我比较认可的是第一个链接中的一个说法,动态规划就像魔改的数列题,核心是在于写出状态转移方程,也就是将下一个状态写成和上一个状态有关的例子。我们正是拿这个数列说事——斐波那契数列。
我们知道斐波那契数列可以写成通项
F
n
=
(
1
+
5
2
)
n
−
(
1
−
5
2
)
n
5
F_{n}=\frac{\left(\frac{1+\sqrt{5}}{2}\right)^{n}-\left(\frac{1-\sqrt{5}}{2}\right)^{n}}{\sqrt{5}}
Fn=5(21+5)n−(21−5)n
但没多少人会这么写,更一般的形式是:
{
F
(
1
)
=
F
(
2
)
=
1
F
(
n
)
=
F
(
n
−
1
)
+
F
(
n
−
2
)
n
≥
3
\left\{\begin{array}{c} F(1)=F(2)=1 \\ F(n)=F(n-1)+F(n-2) \quad n \geq 3 \end{array}\right.
{F(1)=F(2)=1F(n)=F(n−1)+F(n−2)n≥3
这个公式实际上就是一种状态转移方程。我们将
F
(
n
)
F(n)
F(n)拆分成了
F
(
n
−
1
)
F(n-1)
F(n−1)与
F
(
n
−
2
)
F(n-2)
F(n−2)的组合,这样一来就不用重复地计算
F
(
n
−
1
)
F(n-1)
F(n−1)与
F
(
n
−
2
)
F(n-2)
F(n−2),节省了大量的时间。
在剪绳子的问题上,我们已经有
F
(
2
)
=
1
,
F
(
3
)
=
2
F(2)=1,F(3)=2
F(2)=1,F(3)=2,最关键的一步就是写出
F
(
i
)
F(i)
F(i)的状态转移方程:
F
(
i
)
=
max
[
F
(
j
)
∗
F
(
i
−
j
)
]
F(i)=\max [F(j) * F(i-j)]
F(i)=max[F(j)∗F(i−j)],意思是,对于一个新的长度,我们可以遍历原来的所有的
F
(
i
−
1
)
F(i-1)
F(i−1),找到
max
[
F
(
j
)
∗
F
(
i
−
j
)
]
\max [F(j) * F(i-j)]
max[F(j)∗F(i−j)]。如此一来,只需要一次正向和一次反向的遍历,便以O(n^2)的时间复杂度解决该问题。
代码如下:
# offer12-solution 1
# DP
def MaxProductAfterCut(n):
products = [0, 1, 2, 3]
if n < 2:
print(products[0])
return
elif n == 2:
print(products[1])
return
elif n == 3:
print(products[2])
return
for i in range(4, n + 1):
max = 0
for j in range(1, i // 2 + 1):
product = products[j] * products[i - j]
if product > max:
max = product
products.append(max)
# print(products)
print(products[n])
return
贪心算法
关于贪心算法的参考文献也见参考目录,简单地说,贪心算法是指在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,它所做出的仅仅是在某种意义上的局部最优解。
这里就会存在疑惑:贪心算法不是对所有问题都能得到整体最优解,那么有没有办法证明贪心算法对哪些问题是奏效的呢?对此其实并没有一个定论,但是如果学过随机过程的话,其实可以比较简单的证明:贪心策略必须具备无后效性。也就是说,所有可以在无后效性的前提下分解问题的策略,都可以考虑贪心算法。而剪绳子则是一个无后效性问题——剪完一刀以后剪掉的部分就成了“过去式”,不会再影响以后的裁剪。
在这个前提下,我们思考剪绳子的局部最优策略:
在刚才的动态规划中,我们已经知道:
{
F
(
1
)
=
0
F
(
2
)
=
1
∗
1
=
1
F
(
3
)
=
2
∗
1
=
2
F
(
4
)
=
2
∗
2
=
4
F
(
5
)
=
3
∗
2
=
6
\left\{\begin{array}{c} F(1)=0 \\ F(2)=1 * 1=1 \\ F(3)=2 * 1=2 \\ F(4)=2 * 2=4 \\ F(5)=3 * 2=6 \end{array}\right.
⎩⎪⎪⎪⎪⎨⎪⎪⎪⎪⎧F(1)=0F(2)=1∗1=1F(3)=2∗1=2F(4)=2∗2=4F(5)=3∗2=6
自然我们不能再往后一个一个罗列了,但我们依然可以罗列一个事实:
3
(
n
−
3
)
>
2
(
n
−
2
)
when
n
>
5
3(n-3)>2(n-2) \text { when } n>5
3(n−3)>2(n−2) when n>5
这意味着当绳子长度大于5的时候,截取出一段3要优于截取出一段2。
但这时候又存在一个问题了,有人可能会说,那下面这个也是事实:
p
(
n
−
p
)
>
(
p
−
1
)
(
n
−
p
+
1
)
when
n
>
2
p
−
1
(
p
∈
N
∗
)
p(n-p)>(p-1)(n-p+1) \text { when } n>2 p-1\left(p \in N^{*}\right)
p(n−p)>(p−1)(n−p+1) when n>2p−1(p∈N∗)
诚然,如果考虑上式,我们可以得出结论:当绳子长度大于29的时候,截取一段15比截取一段14要更优秀。但是显然我们不可能对着一段长度为15的绳子无动于衷,因此当我们对
n
=
15
n=15
n=15向下考虑的时候,最终仍然会回到——剪一段3还是剪一段2的问题上。那就回到了第一式了。
因此我们可以依循贪心策略得出结论:
首先先尽量裁剪长度为3的绳子段,直到剩余的绳子长度小于5,然后如果是4,就裁成两个2(不裁也可以),如果是3和2就不动了。根据这个思路,代码如下:
# offer12-solution 2
# Greedy Algorithm
def MaxProductAfterCut2(n):
if n < 2:
print(0)
return
if n == 2:
print(1)
return
if n == 3:
print(2)
return
pow3 = n // 3
if n - pow3 * 3 == 1:
pow3 -= 1 # 如果剩余长度是4,那就要回退一步,不能裁成3+1
pow2 = (n - pow3 * 3) // 2 # 剩余4→2+2,剩余2,不动
print((3 ** pow3) * (2 ** pow2))
return
小结
两个解法的结果是一样的。贪心明显具有更低的时间复杂度,但是DP的适用范围会更广泛一些,贪心策略的严格证明也是比较困难的,实际应用的时候要选择适合的策略和思路。
参考
如何理解动态规划
什么是动态对话
【算法复习】动态规划
《剑指offer》09&13&14、数学类问题:斐波那契数列、二进制数中1的个数、数值的整数次方
五大常用算法之一:贪心算法
漫画:五分钟学会贪心算法