動態規劃 - 鋼條切割
前言:動態規劃的概念
動態規劃(dynamic programming)是通過組合子問題的解而解決整個問題的。分治算法是指將問題劃分為一些獨立的子問題,遞歸的求解各個問題,然後合並子問題的解而得到原問題的解。例如歸並排序,快速排序都是采用分治算法思想。本書在第二章介紹歸並排序時,詳細介紹了分治算法的操作步驟,詳細的內容請參考:http://www.cnblogs.com/Anker/archive/2013/01/22/2871042.html。而動態規劃與此不同,適用於子問題不是獨立的情況,也就是說各個子問題包含有公共的子問題。如在這種情況下,用分治算法則會重復做不必要的工作。采用動態規劃算法對每個子問題只求解一次,將其結果存放到一張表中,以供後面的子問題參考,從而避免每次遇到各個子問題時重新計算答案。
動態規劃與分治法之間的區別:
(1)分治法是指將問題分成一些獨立的子問題,遞歸的求解各子問題
(2)動態規劃適用於這些子問題不是獨立的情況,也就是各子問題包含公共子問題
動態規劃通常用於最優化問題(此類問題一般有很多可行解,我們希望從這些解中找出一個具有最優(最大或最小)值的解)。動態規劃算法的設計分為以下四個步驟:
(1)描述最優解的結構
(2)遞歸定義最優解的值
(3)按自低向上的方式計算最優解的值
(4)由計算出的結果構造一個最優解
動態規劃最重要的就是要找出最優解的子結構。
一 鋼條切割
鋼條切割問題描述:給定一段長度為n英寸的鋼條和一個價格表Pi(i=1,2,3,...,n),求切割鋼鐵方案,使得銷售收益rn最大。註意,如果長度為n英寸的鋼條的價格Pn足夠大,最優解可能就是完全不需要切割。
假設一個最優解將鋼條切割為k段(對某個1<=k<=n),那麽最優切割方案
n=i1+i2+...+ik
將鋼條切割的長度分別為i1,i2,...ik的小段,得到的最大收益
rn=pi1+pi2+...+pik
更一般地,對於rn(n>=1),我們可以用更短的最優切割收益來描述它:
rn=max(pn,r1+r(n-1),r2+r(n-2),...,r(n-1)+r1)
第一個參數pn對應不切割,直接出售長度為n英寸的鋼條的方案。其他n-1個參數對應另外n-1種方案:對每個i=1,2,...n-1,首先將鋼條切割為長度為i和n-i的兩端,接著求解這兩段的最優切割收益ri和r(n-i)(每種方案的最優收益為兩段的最優收益之和)。由於無法預知哪種方案會獲得最大收益,我們必須考察所有可能的i,選取其中收益最大者。如果直接出售原鋼條會獲得最大收益,我們當然可以選擇不做任何切割。
自頂向下遞歸實現
下面是一種直接的自頂向下的遞歸方法。
CUT-ROD(p,n)
if n==0
return 0
q=-∞
for i=1 to n
q=max(q,p[i]+CUT-ROD(p,n-i))
return q
C++實現代碼:
#include<iostream>
using namespace std;
int cut_rod(int p[],int n)
{
if(n==1)
return 0;
int q=-1;
int i;
for(i=1;i<n;i++)
q=max(q,p[i]+cut_rod(p,n-i));
return q;
}
int main()
{
int p[11]={0,1,5,8,9,10,17,17,20,24,30};
int i;
for(i=0;i<11;i++)
cout<<cut_rod(p,i+1)<<endl;
}
運行結果:
使用動態規劃方法求解最優鋼條切割問題
動態規劃有兩種等價的實現方法,下面以鋼條切割問題為例展示這兩種方法。
第一種方法稱為帶備忘的自頂向下法。此方法仍按自然的遞歸形式編寫過程,但過程會保存每個子問題的解(通常保存在一個數組或散列表中)。當需要一個子問題的解時,過程首先檢查是否已經保存過此解。如果是,則直接返回保存的值,從而節省了計算時間;否則,按通常方式計算這個子問題。我們稱這個遞歸過程是帶備忘的,因為它“記住”了之前已經計算出的結果。
第二種方法稱為自底向上法。這種方法一般需要恰當定義子問題“規模”的概念,使得任何子問題的求解都只依賴於“更小的”子問題的求解。因而我們可以將子問題按規模排序,按由小到大的順序進行求解。當求解某個子問題時,它所依賴的那些更小的子問題都已求解完畢,結果已經保存。每個子問題只需求解一次,當我們求解它(也是第一次遇到它)時,它的所有前提子問題都已經求解完成。
下面給出的是自頂向下CUT-ROD過程的偽代碼,加入了備忘機制:
MEMOIZED-CUT-ROD(p,n)
let r[0...n] be a new array
for i=0 to n
r[i]=-∞
return MEMOIZED-CUT-ROD-AUX(p,n,r)
MEMOIZED-CUT-ROD-AUX(p,n,r)
if r[n]>=0
return r[i]
if n==0
q=0
else
q=-∞
for i=1 to n
q=max(q,p[i]+MEMOIZED-CUT-ROD-AUX(p,n-i,r))
r[n]=q
return q
自底向上版本:
BOTTOM-UP-CUT-ROD(p,n)
let r[0..n] be a new array
r[0]=0
for j=1 to n
q=-∞
for i=1 to j
q=max(q,p[i]+r[j-i])
r[j]=q
return r[n]
C++代碼:
#include<iostream>
using namespace std;
int cut_rod(int p[],int n)
{
if(n==0)
return 0;
int q=-1;
int i;
for(i=1;i<=n;i++)
q=max(q,p[i]+cut_rod(p,n-i));
return q;
}
//自頂向下
int memoized_cut_rod_aux(int p[],int n,int r[])
{
int q=-1;
int i;
if(r[n]>=0)
return r[n];
if(n==0)
return 0;
for(i=1;i<=n;i++)
q=max(q,p[i]+memoized_cut_rod_aux(p,n-i,r));
r[n]=q;
return q;
}
int memoized_cut_rod(int p[],int n)
{
int i;
int r[n];
for(i=0;i<=n;i++)
r[i]=-1;
return memoized_cut_rod_aux(p,n,r);
}
//自底向上
int cutrod(int p[],int n)
{
int r[n];
int i,j;
for(i=0;i<=n;i++)
r[i]=0;
int q;
for(j=1;j<=n;j++)
{
q=-1;
for(i=1;i<=j;i++)
q=max(q,p[i]+r[j-i]);
r[j]=q;
}
return r[n];
}
int main()
{
int p[11]={0,1,5,8,9,10,17,17,20,24,30};
int i;
cout<<"遞歸:"<<endl;
for(i=0;i<11;i++)
cout<<cut_rod(p,i)<<endl;
cout<<"自頂向下:"<<endl;
for(i=0;i<11;i++)
{
cout<<memoized_cut_rod(p,i)<<endl;
}
cout<<"自底向上:"<<endl;
for(i=0;i<11;i++)
{
cout<<cutrod(p,i)<<endl;
}
}
運行結果:
本章通過介紹插入排序和歸並排序兩種常見的排序算法來說明算法的過程及算法分析,在介紹歸並排序算法過程中引入了分治(divide-and-conquer)算法策略。
1、插入排序
輸入:n個數(a1,a2,a3,...,an)
輸出:輸入序列的一個排列(a1‘,a2‘,a3‘,...an‘)使得(a1‘≤a2‘≤a3‘≤...≤an‘)。
插入排序的基本思想是:將第i個元素插入到前面i-1個已經有序的元素中。具體實現是從第2個元素開始(因為1個元素是有序的),將第2個元素插入到前面的1個元素中,構成兩個有序的序列,然後從第3個元素開始,循環操作,直到把第n元素插入到前面n-1個元素中,最終使得n個元素是有序的。該算法設計的方法是增量方法。書中給出了插入排序的為代碼,並采用循環不變式證明算法的正確性。我采用C語言實插入排序,完整程序如下:
1 void insert_sort(int *datas,int length)
2 {
3 int i,j;
4 int key,tmp;
5 //判斷參數是否合法
6 if(NULL == datas || 0==length)
7 {
8 printf("Check datas or length.\n");
9 exit(1);
10 }
11 //數組下標是從0開始的,從第二個元素(對應下標1)開始向前插入
12 for(j=1;j<length;j++)
13 {
14 key = datas[j]; //記錄當前要插入的元素
15 i = j-1; //前面已經有序的元素
16 //尋找待插入元素的位置,從小到到排序,如果是從大到小改為datas[i]<key
17 while(i>=0 && datas[i] > key)
18 {
19 /×tmp = datas[i+1];
20 datas[i+1] = datas[i];
21 datas[i] = tmp;×/ 這個過程不需要進行交換,因為要插入的值保存在key中,沒有被覆蓋掉,在此感謝”兩生花“指出問題所在
datas[i+1] = datas[i];
22 i--; //向前移動
23 }
24 datas[i+1] = key; //最終確定待插入元素的位置
25 }
26 }
插入排序算法的分析
算法分析是對一個算法所需的資源進行預測,資源是指希望測度的計算時間。插入排序過程的時間與輸入相關的。插入排序的最好情況是輸入數組開始時候就是滿足要求的排好序的,時間代價為θ(n),最壞情況下,輸入數組是按逆序排序的,時間代價為θ(n^2)。
2、歸並排序
歸並排序采用了算法設計中的分治法,分治法的思想是將原問題分解成n個規模較小而結構與原問題相似的小問題,遞歸的解決這些子問題,然後再去合並其結果,得到原問題的解。分治模式在每一層遞歸上有三個步驟:
分解(divide):將原問題分解成一系列子問題。
解決(conquer):遞歸地解答各子問題,若子問題足夠小,則直接求解。
合並(combine):將子問題的結果合並成原問題的解。
歸並排序(merge sort)算法按照分治模式,操作如下:
分解:將n個元素分解成各含n/2個元素的子序列
解決:用合並排序法對兩個序列遞歸地排序
合並:合並兩個已排序的子序列以得到排序結果
在對子序列排序時,長度為1時遞歸結束,單個元素被視為已排序好的。歸並排序的關鍵步驟在於合並步驟中的合並兩個已經有序的子序列,引入了一個輔助過程,merge(A,p,q,r),將已經有序的子數組A[p...q]和A[q+1...r]合並成為有序的A[p...r]。書中給出了采用哨兵實現merge的偽代碼,課後習題要求不使用哨兵實現merge過程。在這個兩種方法中都需要引入額外的輔助空間,用來存放即將合並的有序子數組,總的空間大小為n。現在用C語言完整實現這兩種方法,程序如下:
1 //采用哨兵實現merge
2 #define MAXLIMIT 65535
3 void merge(int *datas,int p,int q,int r)
4 {
5 int n1 = q-p+1; //第一個有序子數組元素個數
6 int n2 = r-q; //第二個有序子數組元素個數
7 int *left = (int*)malloc(sizeof(int)*(n1+1));
8 int *right = (int*)malloc(sizeof(int)*(n2+1));
9 int i,j,k;
10 //將子數組復制到臨時輔助空間
11 for(i=0;i<n1;++i)
12 left[i] = datas[p+i];
13 for(j=0;j<n2;++j)
14 right[j] = datas[q+j+1];
15 //添加哨兵
16 left[n1] = MAXLIMIT;
17 right[n2] = MAXLIMIT;
18 //從第一個元素開始合並
19 i = 0;
20 j = 0;
21 //開始合並
22 for(k=p;k<=r;k++)
23 {
24 if(left[i] < right[j])
25 {
26 datas[k] = left[i];
27 i++;
28 }
29 else
30 {
31 datas[k] = right[j];
32 j++;
33 }
34 }
35 free(left);
36 free(right);
37 }
不采用哨兵實現,需要考慮兩個子數組在合並的過程中哪一個先合並結束,剩下的那個子數組剩下部分復制到數組中,程序實現如下:
1 int merge(int *datas,int p,int q,int r)
2 {
3 int n1 = q-p+1;
4 int n2 = r-q;
5 int *left = (int*)malloc(sizeof(int)*(n1+1));
6 int *right = (int*)malloc(sizeof(int)*(n2+1));
7 int i,j,k;
8 memcpy(left,datas+p,n1*sizeof(int));
9 memcpy(right,datas+q+1,n2*sizeof(int));
10 i = 0;
11 j = 0;
12 for(k=p;k<=r;++k)
13 {
14 if(i <n1 && j< n2) //歸並兩個子數組
15 {
16 if(left[i] < right[j])
17 {
18 datas[k] = left[i];
19 i++;
20 }
21 else
22 {
23 datas[k] = right[j];
24 j++;
25 }
26 }
27 else
28 break;
29 }
30 //將剩下的合並到數組中
31 while(i != n1)
32 datas[k++] = left[i++];
33 while(j != n2)
34 datas[k++] = right[j++];
35 free(left);
36 free(right);
37 }
merge過程的運行時間是θ(n),現將merge過程作為歸並排序中的一個子程序使用,merge_sort(A,p,r),對數組A[p...r]進行排序,實例分析如下圖所示:
C語言實現如下:
1 void merge_sort(int *datas,int p,int r)
2 {
3 int q;
4 if(p < r)
5 {
6 q = (p+r)/2; //分解,計算出子數組的中間位置 7 merge_sort(datas,p,q); //對第一個子數組排序;
8 merge_sort(datas,q+1,r); //對第二個子數組排序
9 merge(datas,p,q,r); //合並;
10 }
11 }
歸並排序算法分析:
算法中含有對其自身的遞歸調用,其運行時間可以用一個遞歸方程(或遞歸式)來表示。歸並排序算法分析采用遞歸樹進行,遞歸樹的層數為lgn+1,每一層的時間代價是cn,整棵樹的代價是cn(lgn+1)=cnlgn+cn,忽略低階和常量c,得到結果為θ(nlg n)。
3、課後習題
有地道題目比較有意思,認真做了做,題目如下:
方法1:要求運行時間為θ(nlgn),對於集合S中任意一個整數a,設b=x-a,采用二分查找算法在S集合中查找b是否存在,如果b存在說明集合S中存在兩個整數其和等於x。而二分查找算起的前提是集合S是有序的,算法時間為θ(lgn),因此先需要采用一種時間最多為θ(nlgn)的算法對集合S進行排序。可以采用歸並排序算法,這樣總的運行時間為θ(nlgn),滿足題目給定的條件。
具體實現步驟:
1、采用歸並排序算法對集合S進行排序
2、對集合S中任意整數a,b=x-a,采用二分查找算法b是否在集合S中,若在則集合S中存在兩個整數其和等於x,如果遍歷了S中所有的元素,沒能找到b,即集合S中不存在兩個整數其和等於x。
采用C語言實現如下:
1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <string.h>
4
5 //非遞歸二叉查找
6 int binary_search(int *datas,int length,int obj)
7 {
8 int low,mid,high;
9 low = 0;
10 high = length;
11 while(low < high)
12 {
13 mid = (low + high)/2;
14 if(datas[mid] == obj)
15 return mid;
16 else if(datas[mid] > obj)
17 high = mid;
18 else
19 low = mid+1;
20 }
21 return -1;
22 }
23
24 //遞歸形式二分查找
25 int binary_search_recursive(int *datas,int beg,int end,int obj)
26 {
27 int mid;
28 if(beg < end)
29 {
30 mid = (beg+end)/2;
31 if(datas[mid] == obj)
32 return mid;
33 if(datas[mid] > obj)
34 return binary_search_recursive(datas,beg,mid,obj);
35 else
36 return binary_search_recursive(datas,mid+1,end,obj);
37
38 }
39 return -1;
40 }
41 //合並子程序
42 int merge(int *datas,int p,int q,int r)
43 {
44 int n1 = q-p+1;
45 int n2 = r-q;
46 int *left = (int*)malloc(sizeof(int)*(n1+1));
47 int *right = (int*)malloc(sizeof(int)*(n2+1));
48 int i,j,k;
49 memcpy(left,datas+p,n1*sizeof(int));
50 memcpy(right,datas+q+1,n2*sizeof(int));
51 i = 0;
52 j = 0;
53 for(k=p;k<=r;++k)
54 {
55 if(i <n1 && j< n2)
56 {
57 if(left[i] < right[j])
58 {
59 datas[k] = left[i];
60 i++;
61 }
62 else
63 {
64 datas[k] = right[j];
65 j++;
66 }
67 }
68 else
69 break;
70 }
71 while(i != n1)
72 datas[k++] = left[i++];
73 while(j != n2)
74 datas[k++] = right[j++];
75 free(left);
76 free(right);
77 }
78 //歸並排序
79 void merge_sort(int *datas,int beg,int end)
80 {
81 int pos;
82 if(beg < end)
83 {
84 pos = (beg+end)/2;
85 merge_sort(datas,beg,pos);
86 merge_sort(datas,pos+1,end);
87 merge(datas,beg,pos,end);
88 }
89 }
90
91 int main(int argc,char *argv[])
92 {
93 int i,j,x,obj;
94 int datas[10] = {34,11,23,24,90,43,78,65,90,86};
95 if(argc != 2)
96 {
97 printf("input error.\n");
98 exit(0);
99 }
100 x = atoi(argv[1]);
101 merge_sort(datas,0,9);
102 for(i=0;i<10;i++)
103 {
104 obj = x - datas[i];
105 j = binary_search_recursive(datas,0,10,obj);
106 //j = binary_search(datas,10,obj);
107 if( j != -1 && j!= i) //判斷是否查找成功
108 {
109 printf("there exit two datas (%d and %d) which their sum is %d.\n",datas[i],datas[j],x);
110 break;
111 }
112 }
113 if(i==10)
114 printf("there not exit two datas whose sum is %d.\n",x);
115 exit(0);
116 }
程序執行結果如下:
方法2:網上課後習題答案上面給的一種方法,具體思想如下:
1、對集合S進行排序,可以采用歸並排序算法
2、對S中每一個元素a,將b=x-a構造一個新的集合S‘,並對S’進行排序
3、去除S和S‘中重復的數據
4、將S和S‘按照大小進行歸並,組成新的集合T,若幹T中有兩隊及以上兩個連續相等數據,說明集合S中存在兩個整數其和等於x。
例如:S={7,10,5,4,2,5},設x=11,執行過程如下:
對S進行排序,S={2,4,5,5,7,10}。
S‘={9,7,6,6,4,1},排序後S’={1,4,6,6,7,9}。
去除S和S‘中重復的數據後S={2,4,5,7,10},S‘={1,4,6,7,9}
歸納S和S‘組成新集合T={1,2,4,4,5,6,7,7,9,10},可以看出集合T中存在兩對連續相等數據4和7,二者存在集合S中,滿足4+7=11。
動態規劃 - 鋼條切割