斐波那契数列的定义:
F(0) = 0,
F(1) = 1,
F(n) = F(n-1) + F(n-2), n≥2
在上一章我们曾给出过求斐波那契数列的递归算法。为了方便读者的阅读,将该算法重新列出如下。
intFibonacci(int n)
{
if( n== 0)
{
return 0;
}
else if( n == 1)
{
return 1;
}
else
{
return Fibonacci(n-1) + Fibonacci(n-2);
}
}
仔细考察上面的算法,可以发现:上述递归算法做了许多重复的计算。比如:在计算F(5)时会计算F(3)(因为F(5)=F(4)+F(3)),在计算F(4)时也会计算F(3)。
很容易想到,如果将前面的计算结果保存下来,就可以不用做重复的计算了。
请看下面的算法:
intFibonacci(int n)
{
if( n == 0)
{
return 0;
}
else if( n == 1)
{
return 1;
}
int *f = new int[n+1];
f[0] = 0;
f[1] = 1;
for(int i=2; i<n+1; i++)
{
f[i] = f[i-2] + f[i-1];
}
int temp = f[n];
delete[] f;
return temp;
}
上面的这个算法因为将计算的中间结果保存在一个数组f中,所以避免了重复计算子问题。其时间复杂度为O(n)。
上一章我们给出过一个0-1背包问题的递归算法,其时间复杂度是O(2n)(n为物品的个数)。为了方便读者,下面将0-1背包问题重复如下。
给定N(N≥1)个物品,和一个最多能容纳重量为C(C>0)的物品的背包。这些物品的重量和价值分别为(Wi,Vi)(N≥i≥1)。求一个解,使得在不超过背包容量的情况下,所装物品的价值最大。所谓0-1背包,是指对于任一物品,要么取,要么不取,不能只取该物品的一部分。
受斐波那契数列递推算法的启发,我们可以想一想:是否存在一个多项式级的0-1背包问题的递推算法呢?
要想得到一个递推算法,首先要得到一个递推公式。然后,才能根据此递推公式写出一个递推算法。
经过分析,不难得出下面的公式。
设M[ i ] [ j ]表示当只有i件(i>0)物品,且背包的容量为j(j>0)时背包问题的解。
M[ i -1 ] [ j ], 若Wi > j
M[i ] [ j ] =
max{Vi+ M[ i -1] [ j - Wi], M[ i-1] [ j]}
其中,Vi +M[ i -1] [ j - Wi]表示当将第i件物品放入背包时,用剩下的i-1件物品去填剩下的背包容量;M[ i-1] [ j]表示不将第i件物品放入背包时,用剩下的i-1件物品去填全部的背包容量。
下面是0-1背包问题的递推算法。
typedefstruct
{
int weight;
int value;
}ITEM;
ITEMitems[] = { {7, 42}, {3, 12}, {4, 40}, {5, 25} };
#defineKNAPSACK_CAPACITY ( 10 )
#defineITEM_COUNT ( sizeof(items)/sizeof(items[0]) )
intmostValuableSolution[ITEM_COUNT];
inthightestValue =0;
intm[ITEM_COUNT+1][KNAPSACK_CAPACITY+1];
intselected[ITEM_COUNT+1][KNAPSACK_CAPACITY+1];
intKnapsack_DP()
{
for(int w=0;w<=KNAPSACK_CAPACITY;w++)
{
m[0][w] = 0;
selected[0][w] = 0;
}
for(int i=1;i<=ITEM_COUNT;i++)
{
m[i][0] = 0;
selected[i][0] = 0;
for(int w=1;w<=KNAPSACK_CAPACITY;w++)
{
int wi = items[i-1].weight;
if( wi<=w)
{
// 第i件物品的重量<=w,所以可以取它
int vi= items[i-1].value;
inttemp =vi + m[i-1][w-wi];
if(temp > m[i-1][w] )
{
//取第i件物品的总价值> 不取第i件物品的总价值
m[i][w]=temp;
selected[i][w]= i;
}
else
{
m[i][w]=m[i-1][w];
selected[i][w]= selected[i-1][w];
}
}
else
{
// 第i件物品的重量大于w,所以不能取它
m[i][w]= m[i-1][w];
selected[i][w]= selected[i-1][w];
}//else
} // for(w)
} // for(i)
return m[ITEM_COUNT][KNAPSACK_CAPACITY];
}
int_tmain(int argc, _TCHAR* argv[])
{
printf("hightestValue = %d\n",Knapsack_DP());
printf("Items selected:\n");
int i = ITEM_COUNT;
int j = KNAPSACK_CAPACITY;
int selected_item = selected[i][j];
while( selected_item > 0 )
{
printf("Item %d.\n", selected_item);
i --;
j -=items[selected_item-1].weight;
selected_item = selected[i][j];
}
return 0;
}
上述程序的输出为:
hightestValue= 65
Itemsselected:
Item4.
Item3.
斐波那契数列问题与0-1背包问题都具有下面的特征:
-
最优子结构(Optimal Substructure)
如果一个问题的最优解必然包含子问题的最优解,则称该问题具有最优子结构。
斐波那契数列问题不存在最优解与非最优解,因为它只能有一个解。显然,斐波那契数列问题具有最优子结构,因为F(n)是由其子问题的解F(n-1)和F(n-2)组成的。
0-1背包问题也具有最优子结构,因为一个N个物品、容量为 C的背包问题的最优解是由其子问题N-1个物品、容量为C-Wi(1≤i≤N)的最优解构成的。
这是因为,根据前面的递推公式,M[ i ] [ j ]是由M[ i -1 ] [ j ],M[ i -1] [ j - Wi]构成的。
-
重叠子问题(Overlapping Subproblems)
重叠子问题很容易理解。比如,在计算Fibonacci(n)时,需要计算Fibonacci(n-1)和 Fibonacci(n-2);而计算Fibonacci(n-1)时也需要计算Fibonacci(n-2)。也就是说,子问题Fibonacci(n-2)需要被解决多次。
背包问题也一样会产生重叠的子问题。比如,求解M[ i ] [ j ],需要先求解M[ i -1 ] [ j ]和M[ i -1] [ j - Wi]。对于重量相同的物品,肯定会有重叠子问题;即使没有重量相同的物品,也有可能会产生重叠子问题。读者可自行考虑这样的例子。
实际上,上述的这两个特征也是所有能够使用动态规划算法的问题都必须具有的特征。具备了最优子结构特征,才能够通过子问题的解得出所求问题的解;具备了重叠子问题特征,保存下来的子问题的解才能够被重复利用,以节约结算的时间。动态规划算法只所以快,就是因为每个子问题只被求解一次。
1) 找零钱问题的动态规划算法
找零钱问题:假设只有1分、2分、五分、1角、二角、五角、1元的硬币。在超市结账时,如果需要找零钱,收银员希望将最少的硬币数找给顾客。那么,给定需要找的零钱数目,如何求得最少的硬币数呢?
看一个问题是否具有上述的动态规划问题的两个特征,首先,要看能否写出一个递推公式。只要能写出一个递推公式,就至少说明了两点:(1)如何划分子问题;(2)如何由子问题的解构造该问题的解。其次,通过这个递推公式,就能很容易地看出是否有重叠子问题。
我们看看能否为找零钱问题的写出一个递推公式。
令集合D={1, 2, 5, 10, 20, 50, 100}表示硬币种类的集合,F(n)表示找n分钱需要的最少硬币数。
那么,易知:
F(n)= Min{ 1 + F(n-di ),di∈D且n≥di }
根据此递推公式,我们可以写出程序如下:
#include<LIMITS.H>
#include<memory.h>
int denomination[] ={1, 2, 5, 10, 20, 50, 100 }; // denomination: 货币单位
#defineDENOMINATION_COUNT ( sizeof(denomination) / sizeof(denomination[0] ))
// n: 需要找零的钱数(单位为分)
// c[0..n-1]: 找零j( 0<= j <= n-1 )分钱需要的最少货币数目
// choice[0..n-1]: 找零j( 0<= j <= n-1 )分钱的最优解需要的第一个货币单位
void GiveChange(intc[], int choice[], int n)
{
c[0] = 0;
choice[0]= 0;
for(int j=1; j<n; j++ )
{
c[j] = INT_MAX;
for(inti=0;i<DENOMINATION_COUNT;i++)
{
int denom =denomination[i];
if( ( j>= denom ) && ( c[j] > 1 + c[j - denom]))
{
c[j]= 1 + c[j - denom];
choice[j]= denom;
} // if
} // for(i)
} // for(j)
}
int _tmain(int argc,_TCHAR* argv[])
{
int cents = 123;
int* c = new int[cents+1];
int* choice= new int[cents+1];
memset(c, 0, sizeof(int) *(cents+1));
GiveChange(c, choice, cents+1);
printf("c[%d]=%d\n",cents, c[cents]);
// 打印需要找零的钱
int k = cents;
while( k > 0 )
{
printf("choice[%d]=%d\n",k, choice[k]);
k -= choice[k];
} // while
delete[] c;
delete[] choice;
return 0;
}
上述程序的输出为:
c[123]=4
choice[123]=1
choice[122]=2
choice[120]=20
choice[100]=100
上述程序不仅能计算F(n)的值,还能给出每一步所选择的硬币。
2) 求最大子数组之和问题
- Brute Force算法
问题描述:给定一个整数数组,求其子数组之和的最大值。例如,数组{ 9,-2,-8, 7, 23, -1 },其子数组之和的最大值为30(7+23)。
一个显而易见的算法是枚举出所有的子数组,并依次计算其和,求其和的最大值即可。
对于数组A[0..n][1],其全部子数组的集合为B={ a[i , j] | 0≤ i≤ j≤ n-1}。
对于任意a[i, j]∈B,0≤ i≤ j≤ n-1,显然,
j j i
∑ a[k] = ∑ a[k] - ∑ a[k]
k=i k=0 k=0
所以,我们设一个辅助数组sum[0..n]来保存子数组a[0..-1], a[0..0], a[0..1], a[0..n-1]的和。
这样的话,对于任意a[i , j]∈B,0≤ i≤ j≤ n-1,其各个元素的和为:sum[j]-sum[i]。
下面的程序实现了上述Brute Force算法:
#include<LIMITS.H>
intMaximumSubarray(int a[], int len)
{
if( len < 0 )
{
printf("len must be greater than0.\n");
return INT_MIN;
}
if( len == 1)
{
return a[0];
}
int *sum = new int[len+1];
sum[0] = 0;
for(int i=1;i<len+1;i++)
{
sum[i] = sum[i-1] + a[i-1];
}
int subarraySum = INT_MIN;
for(int start=0; start<len; start++)
{
for(int end=1; end<len+1; end ++)
{
int temp = sum[end] - sum[start];// subarry[start..end]
if(subarraySum < temp)
{
subarraySum = temp;
} // if
} // for
} // for
delete[] sum;
return subarraySum;
}
int _tmain(int argc,_TCHAR* argv[])
{
int a[]={ 9,-2,-8, 7, 23, -1 };
printf("MaximumSubarray=%d\n",MaximumSubarray(a,sizeof(a)/sizeof(a[0])));
return 0;
}
上述程序的输出为:
MaximumSubarray=30
易知,该BruteForce算法的时间复杂度为O(N2)。
- 动态规划算法
我们看看能否为最大子数组之和问题构造一个动态规划算法。让我们先试试能否为该问题写一个递推公式。
假设我们已经得到了数组A[0..i]的解,现在考虑数组A[0..i+1]的解。
显然, A[0..i+1]的解有两种情况:第一种情况,A[0..i+1]的解中不含A[i],那么A[0..i+1]的解就是A[0..i]的解;第二种情况, A[0..i+1]的解是包含了元素A[i]的一个子数组的和。
为了递推地求包含了A[i]的子数组和的最大值,我们可以另外设一个辅助数组max_ending_subarray[0..n],其元素max_ending_subarray[i](0≤i≤n-1)为以A[i]结尾的子数组的和的最大值。那么,
A[0],i=0
max_ending_subarray[i]=
max{ max_ending_subarray[i-1] +A[i], A[i] }
根据上述分析,我们可以写出如下的动态规划算法。
#include <LIMITS.H>
#define max(a, b) ((a) > (b) ? (a) : (b))
int MaximumSubarray_DP(int a[], int len)
{
if( len < 0 )
{
printf("len must begreater than 0.\n");
returnINT_MIN;
}
int *max_subarray = newint[len];
int *max_ending_subarray = newint[len];
for(int i=0; i< len;i++)
{
if( 0 == i )
{
max_subarray[i] = a[i];
max_ending_subarray[i]= a[i];
} // if
else
{
max_ending_subarray[i]= max(a[i], max_ending_subarray[i-1] + a[i]);
max_subarray[i]= max(max_subarray[i-1], max_ending_subarray[i]);
} // else
} // for
int ret_value =max_subarray[len-1];
delete[]max_ending_subarray;
delete[] max_subarray;
return ret_value;
}
int _tmain(int argc, _TCHAR* argv[])
{
int a[]={ 9,-2,-8, 7,23, -1 };
printf("MaximumSubarray=%d\n",MaximumSubarray_DP(a,sizeof(a)/sizeof(a[0])));
return 0;
}
易知,该动态规划算法的时间复杂度为O(N)。
3) 求最长递增子序列的长度问题
问题描述:给定一个整数数组A[0..n],其求最长递增子序列的长度。注意:子序列可以是不连续的。
例如,序列{1, 2, -3, -2,-1, 0, -3, 1 }的最长递增子序列为{-3, -2,-1,1}。
显然,每一个元素都有一个以该元素结尾的最长递增子序列。还以序列{ 1, 2, -3, -2,-1, 0, -3, 1 }为例,以第二个元素结尾的最长递增子序列为{1,2}。以第三个元素结尾的最长递增子序列为{-3}。以第最后一个元素结尾的最长递增子序列为{-3,-2,-1,0,1},这也是该序列的最长递增子序列。
记一个序列的最长递增子序列为LLIS(Length of Longest Increasing Subsequence)。那么,显然有,LLIS=max{以第一个元素结尾的最长递增子序列长度,以第二个元素结尾的最长递增子序列长度,……,以最后一个元素结尾的最长递增子序列长度}。
那么,如何求以第i个元素结尾的最长递增子序列长度呢?记以第i个元素结尾的最长递增子序列长度为LLIS_ETEi(Length of Longest Increasing SubsequenceEnding with This Element)。
我们可以得到下面的求LLIS_ETEi的递推公式。
记集合A=序列中各个元素的集合,集合Bi={aj| ai∈A,aj∈A,j<i且aj<ai}。即集合Bi为集合 A中所有序号小于i且值比A中第i个元素小的元素所组成的集合。
LLIS_ETEi = max {1, max{LLIS_ETEj+1| j∈Bi}}
这是因为,第i个元素自己可以组成一个只有一个元素的递增子序列。第i个元素还可以和集合Bi中的任一元素组成一个长度增加1的且以第i个元素结尾的递增子序列,因为集合Bi中元素的值都比ai小,且都排在第i个元素前面。
根据上述分析,我们可以写出下面的程序。
intLengthOfLongestIncreasingSubquence(int a[], int len)
{
if( len < 1 )
{
printf("len must be greater than1.\n");
return -1;
}
int *length_of_LIS_ending_with_this_element= new int[len];
length_of_LIS_ending_with_this_element[0]= 1;
for(int i=1; i<len; i++)
{
intmax_length_of_LIS_ending_with_this_element = 1;
for(int j=0; j<i;j++)
{
if( a[i]> a[j] )
{
if(length_of_LIS_ending_with_this_element[j] + 1 >max_length_of_LIS_ending_with_this_element)
{
max_length_of_LIS_ending_with_this_element= length_of_LIS_ending_with_this_element[j] + 1 ;
}// if
} // if
} // for
length_of_LIS_ending_with_this_element[i]= max_length_of_LIS_ending_with_this_element;
} // for
int max_length_of_LIS =length_of_LIS_ending_with_this_element[0];
for(int i=1; i<len; i++)
{
if(length_of_LIS_ending_with_this_element[i] > max_length_of_LIS)
{
max_length_of_LIS =length_of_LIS_ending_with_this_element[i];
} // if
} // for
delete[]length_of_LIS_ending_with_this_element;
return max_length_of_LIS;
}
int _tmain(int argc,_TCHAR* argv[])
{
int a[] = { 1, 2, -3, -2,-1, 0, -3,1 };
printf("LengthOfLongestIncreasingSubquence=%d\n",
LengthOfLongestIncreasingSubquence(a,sizeof(a)/sizeof(a[0])));
return 0;
}
上述程序的输出为:
LengthOfLongestIncreasingSubquence=5
易知,其时间复杂度为O(N2)。