线性规划是一类问题。目标函数为特定变量的线性函数,约束是这些变量的线性不等式(standard form)或等式(slack form),目的是求目标函数的最大值或最小值。这类动态规划是平时比较常见的一类动态规划问题。
一、钢条切割问题:
假设公司出售一段长度为i英寸的钢条的价格为Pi(i = 1, 2, …单位:美元),下面给出了价格表样例:
长度i | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
价格Pi | 1 | 5 | 8 | 9 | 10 | 17 | 17 | 20 | 24 | 30 |
切割钢条的问题是这样的:给定一段长度为n英寸的钢条和一个价格表Pi,求切割方案,使得销售收益Rn最大。
当然,如果长度为n英寸的钢条价格Pn足够大,最优解可能就是完全不需要切割。
对于上述价格表样例,我们可以观察所有最优收益值Ri及对应的最优解方案:(钢条长度依次从1到10对应的切割方案)
钢条长度1,R1 = 1, 切割方案1 = 1(无切割)
钢条长度2,R2 = 5, 切割方案2 = 2(无切割)
钢条长度3,R3 = 8, 切割方案3 = 3(无切割)
钢条长度4,R4 = 10, 切割方案4 = 2 + 2
钢条长度5,R5 = 13, 切割方案5 = 2 + 3
钢条长度6,R6 = 17, 切割方案6 = 6(无切割)
钢条长度7,R7 = 18, 切割方案7 = 1 + 6或7 = 2 + 2 + 3
钢条长度8,R8 = 22, 切割方案8 = 2 + 6
钢条长度9,R9 = 25, 切割方案9 = 3 + 6
钢条长度10,R10 = 30,切割方案10 = 10(无切割)
更一般地,对于Rn(n >= 1),我们可以用更短的钢条的最优切割收益来描述它:Rn = max(Pn, R1 + Rn-1, R2 + Rn-2,…,Rn-1 + R1)
首先将钢条切割为长度为i和n – i两段,接着求解这两段的最优切割收益Ri和Rn – i(每种方案的最优收益为两段的最优收益之和),由于无法预知哪种方案会获得最优收益,我们必须考察所有可能的i,选取其中收益最大者。如果直接出售原钢条会获得最大收益,我们当然可以选择不做任何切割。
对于上述方案我们可以进行简化,我们将钢条从左边切割下来长度为i的一段,只对右边剩下的长度为n-i的一段进行切割(递归求解),对左边的一段则不用进行切割。即问题分解的方式为:将长度为n的钢条分解为左边开始的一段,以及剩余部分继续分解的结果。这样,不做任何切割的方案就可以描述为:第一段长度为n,收益为Pn,剩余部分长度为0,对应的收益为R0=0。于是我们可以得到公式 的简化版本:Rn=max(Pi+Rn-i)[其中a<=i<=n]
我们可以不用动态规划,直接用自顶向下递归实现:
CUT—ROD(p,n)
if n==01
return 0
q=-oo
for i= 1 to n
q=max(q,p[i]+CUT—ROD(p,n-i))
return q
上述程序的运行时间是指数级的。
我们发现,朴素递归算法之所以效率低下,是因为它反复求解相同的子问题,因此,动态规划的方法仔细安排求解顺序,对每个子问题只求解一次,并将结果保存下来。如果随后再次需要此问题的解,只需查找保存的结果,而不必重新计算。这是典型的空间换时间。动态规划有两种等价的实现方法:
1:带备忘的自顶向下法:
按照自然的递归形式编写过程,但是过程会保存每个子问题的解,当需要子问题的解时,就先检查是否已经保存过此解。保存过就查表返回,否
则计算子问题解。
MEMOIZED-CUT—ROD(p,n) //长度为n的钢条
let r[0..n]be a new array //辅助数组r[n]
for i=0 to n
r[i]=-oo
return MEMOIZED-CUT—AUX(p,n,r)
MEMOIZED-CUT—AUX(p,n,r)
if r[n]>=0
return r[n]
if n==0 //钢条长度为0,收益为0
q=0
else q=-oo
for i=1 to n
q=max(q,p[i]+MEMOIZED-CUT—AUX(p,n-i,r)) //第一段钢条长度为i,价格为p[i](p[i]是价格表),剩下的长度为n-i,最大收益为r[n-i]。长度为i的钢条的最大收益为r[i]
r[n]=q
return q
2:自底向上法:
这种方法一般需要恰当的定义子问题的“规模”的概念,使得任何子问题都只依赖于更小的子问题的求解。因此我们可以将子问题按规模进行排序,按由小到大的顺序进行求解。当求解某个子问题时,它所依赖的那些更小的子问题都已经求解完毕,结果已经保存。每个子问题都只求解一次,当求解这个子问题时,它所依赖的所有前提子问题都已经求解完成。
BOTTOM-UP-CUT—ROD(p,n)
let r[0..n]be a new array
r[0]=0
for j=1 to n //钢条总长度为j
q=-oo
for i=1 to j //第一段长度为i
q=max(q,p[i]+r[j-i])
r[j]=q //长度为j的钢条的最大收益为r[j]
return r[n]
以上两种方法均只返回了最优解的收益值,但并没有返回解本身(一个长度列表,给出切割后每段钢条的长度)。
EXTENDED-BOTTOM-UP-CUT—ROD(p,n)
let r[0..n] and s[0..n] be new array //s保存第一段钢条的切割长度
r[0]=0
for j=1 to n //钢条总长度
q=-oo
for i=1 to j //第一段钢条的切割长度
if q < p[i]+r[j-i]
q=p[i]+r[j-i]
s[j]=i //钢条长度为j时,第一段钢条的切割长度
r[j]=q
return r and s
PRINT-CUT—ROD-SOLUTION(p,n)
(r,s)=EXTENDED-BOTTOM-UP-CUT—ROD(p,n)
while n>0
print s[n]
n=n-s[n]
代码实现:请移步:GitHub
总结:
由以上钢条切割问题我们发现,动态规划有时并不是必须的,但是使用常规方法效率极其低下。而动态规划之所以能提高程序的效率,是因为我们避免了重复子问题的求解(两种方法:一种使用备忘;一种合适的执行顺序)。我们使用动态规划总是在子子问题的基础上求解子问题,再由子问题出求解最终的答案,而这一切的一切关键在于动归表达式的建立。
二、最长上升子序列(最长上升子序列LIS,或者 最长不下降子序列)
1)问题描述:
一个数的序列bi,当b1 < b2 < … < bS的时候,我们称这个序列是上升的。对于给定的一个序列(a1, a2, …, aN),我们可以得到一些上升的子序列(ai1, ai2, …, aiK),这里1 <= i1 < i2 < … < iK <= N。比如,对于序列(1, 7, 3, 5, 9, 4, 8),有它的一些上升子序列,如(1, 7), (3, 4, 8)等等。这些子序列中最长的长度是4,比如子序列(1, 3, 5, 8)。
我们的任务就是对于给定的序列,求出最长上升子序列的长度。
2)解题思路:
设 A[t]表示序列中的第t个数,F[t]表示从1到t这一段中以t结尾的最长上升子序列的长度,初始时设F [t] = 0(t = 1, 2, …, len(A))。
假设 i=1 ,此时F[1]=1;因为A[1]的前面没有元素。那么如果A[2]>A[1],显然可以得到F[2]=F[1]+1; 因为A[1],A[2]是一个上升子序列。如果A[3]<=A[2]且A[3]>=A[1],那么F[3]为2;因为它可以和A[1]组成上升子序列。同理,若A[3]>A[3-1],则F[3]=max{1,F[2]+1}=3。由此我们可以得到递推关系式.
动态规划方程:F[t] = max{1, F[j] + 1} (j = 1, 2, …, t – 1, 且A[j] < A[t])。 【重要:主要就是这个方程】
3)代码实现:
//A[i]为输入序列
//B[i]为序列前i个数中的最长递增序列的长度
#include <iostream>
using namespace std;
//用于保存子问题最优解的备忘录
typedef struct
{
int maxlen; //当前子问题最优解
int prev; //构造该子问题所用到的下一级子问题序号(用于跟踪输出最优队列)
}Memo;
//用于递归输出Memo B中的解
void Display(int* A, Memo* M, int i)
{
if (M[i].prev == -1)
{
cout<<A[i]<<" ";
return;
}
Display(A, M, M[i].prev); //递归调用,按从低到高顺序输出
cout<<A[i]<<" ";
}
void GetBestQuence(int* A, int n)
{
//定义备忘录 并作必要的初始化
Memo *B = new Memo[n]; //B[i]代表从A[0]到A[i]满足升序剔除部分元素后能得到的最多元素个数
B[0].maxlen = 1; //由于B[i]由前向后构造 初始化最前面的子问题 (元素本身就是一个满足升序降序的序列)
for (int i=0; i<n; i++) //为前一个最优子问题序号给定一个特殊标识-1
//用于在跟踪路径时终止递归或迭代(因为我们并不知道最终队列从哪里开始)
{
B[i].prev = -1;
}
////////////////////////////////////////关键程序/////////////////////////////////////////////////////
for (int i=1; i<n; i++) //构造B[n] i为1时就是求B[1]即从A[0]到A[1]满足升序排列的最多元素个数
{
B[i].maxlen = 1;
for (int j=i-1; j>=0; j--) //查看前面的子问题 找出满足条件的最优解 并且记录
{
if (A[j]<=A[i] && B[j].maxlen+1>B[i].maxlen) //A[j]<=A[i]这里符号小于等于表示求出的是最长不下降子序列,若是小于则求得的是最长递增子序列
{
B[i].maxlen = B[j].maxlen+1; //跟踪当前最优解
B[i].prev = j; //跟踪构造路径
}
}
}
/////////////////////////////////////////////////////////////////////////////////////////////////
//遍历i 得到最大的B[i]+C[i]-1(-1是因为我们在B[i]和C[i]中 均加上了A[i]这个数 因此需要减去重复的)
int maxQuence = 0; //记录当前最优解
int MostTall; //记录i 用于跟踪构造路径
for (int i=0; i<n; i++)
{
if (B[i].maxlen> maxQuence)
{
maxQuence = B[i].maxlen;
MostTall = i;
}
}
cout<<"最长不下降子序列: "<<maxQuence<<endl;
//B由前向后构造 因此prev指向前面的元素 需要递归输出
Display( A, B, MostTall); //输出递增序列
cout<<endl;
delete []B;
}
int main()
{
//测试
int *A;
int n;
cout<<"请输入序列个数: "<<endl;
cin>>n;
A = new int[n];
cout<<"依次输入每个序列值 :"<<endl;
for (int i=0; i<n; i++)
{
cin>>A[i];
}
GetBestQuence(A, n);
delete []A;
system("pause");
return 0;
}
三、合唱队形问题
1)问题描述
N位同学站成一排,音乐老师要请其中的(N-K)位同学出列,使得剩下的K位同学排成合唱队形。合唱队形是指这样的一种队形:设K位同学从左到右依次编号为1,2…,K,他们的身高分别
为T1,T2,…,TK, 则他们的身高满足T1<…<Ti>Ti+1>…>TK(1<=i<=K)。你的任务是,已知所有N位同学的身高,计算最少需要几位同学出列,可以使得剩下的同学排成合唱队形。
输入格式 Input Format
输入的第一行是一个整数N(2<=N<=100),表示同学的总数。第一行有n个整数,用空格分隔,第i个整数Ti(130<=Ti<=230)是第i位同学的身高(厘米)。
输出格式 Output Format
输出包括得到的最优队列的同学个数以及最终同学的身高排列
2)解题思路
这个问题可以分解为对于0<=i<=n-1(n为同学数) 我们要求得满足子序列[0, i)的升序排列最优个数p加上子序列[i, n)满足降序最优个数q即p+q最大的i值。
对于升序和降序的最优解可以用动态规划来构造,由上面的最长递增子序列问题我们可以方便的构造出动归方程b[t] = max{1, b[j] + 1} (j = 1, 2, …, t – 1, 且a[j] < a[t])。
数组a[i]是第i个人的身高,b[i]是从左边第一个到a[i]的最长上升子序列,c[i]是从右边第一个到a[i]的最长上升子序列。
最后,可以得到符合合唱队的队列是b[i]+c[i]-1(a[i]被重复计算了一次),而题目要求的合唱队列是:max{b[i]+c[i]-1} 1<=i<=总人数,那么要挑出去多少人也就明白了,即总人数 – max{b[i]+c[i]-1
3)代码实现(完整代码有些长就不在这里贴了)
void GetBestQuence(int* A, int n)
{
//定义备忘录 并作必要的初始化
Memo *B = new Memo[n]; //B[i]代表从A[0]到A[i]满足升序剔除部分元素后能得到的最多元素个数
Memo *C = new Memo[n]; //C[i]代表从A[i]到A[n-1]满足降序剔除部分元素后能得到的最多元素个数
B[0].maxlen = 1; //由于B[i]由前向后构造 初始化最前面的子问题 (元素本身就是一个满足升序降序的序列)
C[n-1].maxlen = 1; //同样C[i]由后向前构造
for (int i=0; i<n; i++) //为前一个最优子问题序号给定一个特殊标识-1
//用于在跟踪路径时终止递归或迭代(因为我们并不知道最终队列从哪里开始)
{
B[i].prev = -1;
C[i].prev = -1;
}
//这里以A[i]为队形的中心!!!
for (int i=1; i<n; i++) //构造B[n] i为1时就是求B[1]即从A[0]到A[1]满足升序排列的最多元素个数
{
int max=1;
for (int j=i-1; j>=0; j--) //查看前面的子问题 找出满足条件的最优解 并且记录
{
if (A[j]<A[i] && B[j].maxlen+1>max) //B[j].maxlen+1>max这里一定是大于
{
max = B[j].maxlen+1; //跟踪当前最优解
B[i].prev = j; //跟踪构造路径
}
}
B[i].maxlen = max; //构造最优解
}
for (int i=n-1; i>=0; i--) //C[i]是从后往前算的
{
int max=1;
for (int j=i; j<n; j++) //从后往前构造 这是为了后面在统筹最终解时可以直接用B[i]+C[i]-1
//否则我们得到的最优解始终为B[n-1]+C[n-1]
{
if (A[j]<A[i] && C[j].maxlen+1>max) //比当前长度更长 记录并构造
{
max = C[j].maxlen+1;
C[i].prev = j;
}
}
C[i].maxlen = max;
}
//遍历i 得到最大的B[i]+C[i]-1(-1是因为我们在B[i]和C[i]中 均加上了A[i]这个数 因此需要减去重复的)
int maxQuence = 0; //记录当前最优解
int MostTall; //记录i 用于跟踪构造路径
for (int i=0; i<n; i++)
{
if (B[i].maxlen+C[i].maxlen-1 > maxQuence)
{
maxQuence = B[i].maxlen+C[i].maxlen-1;
MostTall = i;
}
}
cout<<"最大合唱队形长度: "<<maxQuence<<endl;
//B由前向后构造 因此prev指向前面的元素 需要递归输出
Display( A, B, MostTall); //输出递增序列
//C的prev指向后面元素 直接迭代输出
while (C[MostTall].prev != -1) //输出递减序列
{
MostTall = C[MostTall].prev;
cout<<A[MostTall]<<" ";
}
cout<<endl;
delete []B;
delete []C;
}
完整代码请移步:GitHub
4)程序分析:
例子:8个人以及各自的身高
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
186 | 186 | 150 | 200 | 160 | 130 | 197 | 200 |
求B[i]当i等于3时,j=2,1,0.
j=2时更新 max = B[j].maxlen+1;
则当j=1,0判断B[j].maxlen+1>max时不成立不用更新B[1].maxlen和B[0].maxlen
B[3]意思是从A[0]到A[3]最大的递增序列数
C[3]意思是从A[3]到A[7]最大是递减序列数
maxlen
B[0]=1, prev=-1; 186
B[1]=1, prev=-1; 186
B[2]=1, prev=-1; 150
B[3]=2, B[2]<B[3]; B[2].maxlen+1=2 > max=1; prev=2; 150,200
B[4]=2, B[2]<B[4]; B[2].maxlen+1=2 > max=1; prev=2; 150,160
B[5]=1, prev=-1; 130
B[6]=3, B[5]<B[6]; B[5].maxlen+1=2 > max=1; B[4]<B[6]; B[4].maxlen+1=3 > max=2; prev=4; 150,160,197
B[7]=4, B[6]<B[7]; B[6].maxlen+1=4 > max=1; prev=6; 150,160,197,200
更多内容请关注:麻子来了