算法:连续邮资问题(回溯+动态规划+剪枝)

北京工业大学的算法设计与分析课要做个大作业,就选了这个题目。上网找了一些资料,感觉效率有些慢,所以自己又稍微改进了一下。写了好几个版本,不同的实现方法,下面的这个是目前效率最高的。关于两个方向的动态规划的最优子结构性质的证明,如果有疑问的可以邮箱联系[email protected]

问题描述

假设国家发行了n种不同面值的邮票,并且规定每张信封上最多只允许m张邮票。连续邮资问题要求对于给定的n和m的值,给出邮票面值的最佳设计,即在1张信封上可贴出从邮资1开始,增量为1的最大连续邮资区间。例如,当n=5和m=4时,面值为(1,3,11,15,32)的5种邮票可以贴出邮资的最大连续邮资区间是1-70。

问题分析

对于连续邮资问题,用n元组x[1:n]表示n种不同的邮票面值,并约定它们从小到大排列。x[1]=1是惟一的选择。此时的最大连续邮资区间是[1:m]。x[2]的可取值范围是[2:m+1]。在一般情况下,已选定x[1:i-1],最大连续邮资区间是[1:r],则x[i]的可取值范围是[x[i-1]+1:r+1]。

解题思路

由上述的问题分析可知,该问题需要使用深度优先搜索,搜索的对象是每种邮票的面值,第i层对应的是对第i种邮票取值的搜索。从其子结点中找到构成最大连续面值的组合,并与本层结点的面值,共同构成本层结点的结果。
对于搜索过程中的状态,采用二维数组进行存储。C[i][j]表示前i种邮票,构成总面值j时,所需的最少张数,整个搜索的过程就是不断对二维数组进行更新的过程。每次搜索到第n层时,便比较当前邮票组合下的最大连续邮资区间的上限是否大于已搜到结果。若是,则记录该值及对应的邮票面值组合。

int knd = 0;//邮票种类
int lim = 0;//限制张数

int x[NUM];//当前邮票面值
int cnt = 0;//当前邮票种类
int r[NUM];//结果
int max = 0;//最大值

int C[NUM][LEN] = {};//记录搜索结点状态

inline int findMax();//计算当前邮票面值的最大连续邮资区间

void dfs() {
    int tmp = findMax();
    if (cnt == knd) {
    //达到第n层结点,即找到一种可能面值组合
        if (tmp > max) {
    //若比记录的最大连续邮资区间上限大,则更新
            max = tmp;
            for (int i = 1; i <= knd; i++)//记录新的邮票面值组合
                r[i] = x[i];
        }
    }
    else {
        for (int i = x[cnt] + 1; i <= tmp + 1; i++) {
    //下一层结点的面值的可能取值
            x[++cnt] = i;//将可能面值加入当前面值组合中
            dfs();
            cnt--;
        }
    }
}

findMax()函数的设计

因为findMax()函数在每一个搜索结点中都要调用,所以该函数的效率将直接影响算法的复杂度。
这里为了尽可能少的进行元素的更新,采用了两个方向的DP:
向下DP:若C[i][1]到C[i][j]是前i种邮票构成对应面值所需的最少张数,则可以利用该数据,找到C[i+1][1]到C[i+1][j]对应取值。
向右DP:若C[i][1]到C[i][j]是前i种邮票构成对应面值所需的最少张数,则可以利用该数据,对数组向右进行动态规划,找到其连续邮资区间上限。

为了对该方法有个直观的理解,举例如下所示:
题目:邮票种类3种,邮票张数上限为3


1.第1种邮票的面值一定是1,因为第0种邮票不存在,所以在第一行对面值1的邮票做向右DP,更新的数据为下表中标为粗体的部分。

value 1 2 3 4 5 6 7 8 9 10 11
1 1 2 3
2
3

2.则第2种邮票的面值的取值范围为[2,4],这里先取其面值为2。因为前1种邮票在DP矩阵中已经存在总面值为1到3的数据,所以可以直接利用这些数据,做向下DP,更新的数据为下表中标为粗体的部分。

value 1 2 3 4 5 6 7 8 9 10 11
1 1 2 3
2 1 1 2
3

3.为了找出前2种邮票的最大邮资区间上限,利用前2种邮票在总面值为1到3的最少张数,做向右DP,这个过程只利用到当前行的数据,更新的数据为下表中标为粗体的部分。下表中,第二行第五列的,即前2种邮票构成总面值为4时的最少张数为2,是这样子计算出来的:若构成总面值为4时的最后一张邮票的面值为1,则需要的邮票的张数为“前2种邮票构成总面值为4-1=3时的最少张数2”加上1,即3张;若构成总面值为4时的最后一张邮票的面值为2,则需要的邮票的张数为“前2种邮票构成总面值为4-2=2时的最少张数1”加上1,即2张。则“前2种邮票构成总面值为4时的最少张数”为min{2,3}=2张。
显然,这样的表述过程过于啰嗦,不过为了方便理解,这是值得的。

value 1 2 3 4 5 6 7 8 9 10 11
1 1 2 3
2 1 1 2 2 3 3
3

4.则第3种邮票的面值的取值范围为[3,7],这里先取其面值为3。因为前2种邮票在DP矩阵中已经存在总面值为1到6的数据,所以可以直接利用这些数据,做向下DP,更新的数据为下表中标为粗体的部分。

value 1 2 3 4 5 6 7 8 9 10 11
1 1 2 3
2 1 1 2 2 3 3
3 1 1 1 2 2 2

5.为了找出前3种邮票的最大邮资区间上限,利用前3种邮票在总面值为1到6的最少张数,做向右DP,这个过程只利用到当前行的数据,更新的数据为下表中标为粗体的部分。此时取得了原问题的一个解,三种邮票面值为(1,2,3)时的连续邮资区间上限为9。

value 1 2 3 4 5 6 7 8 9 10 11
1 1 2 3
2 1 1 2 2 3 3
3 1 1 1 2 2 2 3 3 3

6.回溯法返回上一层结点,选择第3张邮票的下一面值为4。因为前2种邮票在DP矩阵中已经存在总面值为1到6的数据,所以可以直接利用这些数据,做向下DP,更新的数据为下表中标为粗体的部分。

value 1 2 3 4 5 6 7 8 9 10 11
1 1 2 3
2 1 1 2 2 3 3
3 1 1 2 1 2 2

7.为了找出前3种邮票的最大邮资区间上限,利用前3种邮票在总面值为1到6的最少张数,做向右DP,这个过程只利用到当前行的数据,更新的数据为下表中标为粗体的部分。此时取得了原问题的又一个解,三种邮票面值为(1,2,4)时的连续邮资区间上限为10,优于之前的解,则更新原问题的当前最优解。

value 1 2 3 4 5 6 7 8 9 10 11
1 1 2 3
2 1 1 2 2 3 3
3 1 1 2 1 2 2 3 2 3 3

8.不断重复上述过程,便可以找到原问题最优解。

findMax()函数的实现

#define NUM 10
#define LEN 10000

int x[NUM];
int cnt = 0;//当前邮票种类
int r[NUM];//结果
int knd = 0;//邮票种类
int lim = 0;//限制张数
int max = 0;//最大值

int C[NUM][LEN] = {};

inline int findMax() {
int j = 1;

//向下DP
    while (C[cnt - 1][j]) {
        if (j < x[cnt] || C[cnt - 1][j] <= C[cnt][j - x[cnt]] + 1)
            C[cnt][j] = C[cnt - 1][j];
        else
            C[cnt][j] = C[cnt][j - x[cnt]] + 1;
        j++;
    }

    //向右DP
    while (true) {
        int tmp = INT_MAX;
        for (int i = 1; i <= cnt; i++) {
            if (tmp > C[cnt][j - x[i]] + 1)
                tmp = C[cnt][j - x[i]] + 1;
        }
        if (tmp == INT_MAX || tmp > lim)
            break;
        else
            C[cnt][j] = tmp;
        j++;
    }
    C[cnt][j] = 0;
    return j - 1;
}

一种可行的剪枝方法

记邮票种类为m,张数限制为n,使用回溯法不断更新的最大连续邮资区间上限为max。
则在遍历第m种邮票的面值时,若x[m]*n<=max便可以直接返回,因为当前邮票面值的组合中x[m]为最大值,不论连续与否,x[m]*n是当前邮票面值组合下的最大值,若这个值都比max要小,则其最大连续邮资区间上限也小于max。
这样可以省去一部分对最后一层结点的动态规划的过程。

采用剪枝后的回溯算法的实现

void dfs() {
    if (cnt == knd) {
        if (x[cnt] * lim < max)//剪枝条件的判定
            return;
        int tmp = findMax();
        if (tmp > max) {
            max = tmp;
            for (int i = 1; i <= knd; i++)
                r[i] = x[i];
        }
    }
    else {
        int tmp = findMax();
        for (int i = tmp + 1; i >= x[cnt] + 1; i--) {
            x[++cnt] = i;
            dfs();
            cnt--;
        }
    }
}

剪枝效果展示

这里分别统计了在不采用剪枝与采用剪枝两种情况下时,回溯过程中进行的动态规划的次数,即调用findMax()函数的次数。

(n,m) 不剪枝(A) 剪枝(B) B/A 1-B/A
(3,2) 8 7 0.875 0.125
(3,3) 17 14 0.823529 0.176471
(3,4) 31 21 0.677419 0.322581
(3,5) 51 41 0.803922 0.196078
(3,6) 78 57 0.730769 0.269231
(3,7) 113 85 0.752212 0.247788
(3,8) 157 113 0.719745 0.280255
(4,2) 25 21 0.84 0.16
(4,3) 103 84 0.815534 0.184466
(4,4) 322 252 0.782609 0.217391
(4,5) 801 588 0.734082 0.265918
(4,6) 1772 1315 0.742099 0.257901
(4,7) 3520 2573 0.730966 0.269034
(4,8) 6489 4654 0.717214 0.282786
(5,2) 90 76 0.844444 0.155556
(5,3) 863 700 0.811124 0.188876
(5,4) 5074 3929 0.77434 0.22566
(5,5) 21682 16553 0.763444 0.236556
(5,6) 74898 55783 0.744786 0.255214
(5,7) 221517 160179 0.7231 0.2769
(5,8) 581716 428977 0.737434 0.262566
(6,2) 382 334 0.874346 0.125654
(6,3) 9381 7078 0.754504 0.245496
(6,4) 114714 91972 0.80175 0.19825
(6,5) 902007 697643 0.773434 0.226566
(6,6) 5310689 4011711 0.755403 0.244597
(6,7) 25150552 18836026 0.748931 0.251069
(6,8) 100956969 76279263 0.755562 0.244438

求得的部分解

使用vs2015编译,运行在surface pro4下。
统计时间有波动很正常,但可以与网上的其他实现比较一下,效率还是比较高的。

(n,m) result max time(s)
(5,5) (1,4,9,31,51) 126 0.008
(5,6) (1,7,12,43,52) 216 0.04
(5,7) (1,8,11,64,102) 345 0.171
(5,8) (1,9,15,80,118) 512 0.793
(6,5) (1,5,8,33,54,67) 211 0.434
(6,6) (1,7,11,48,83,115) 388 3.548
(6,7) (1,7,12,64,113,193) 664 25.74
(6,8) (1,9,14,65,170,297) 1045 170.362
(7,5) (1,4,13,24,30,87,106) 336 28.17
(7,6) (1,4,18,31,104,145,170) 638 596.323

完整代码

//双向动态规划;对最后一层下限剪枝
#include 
#include 
#include 

using namespace std;

#define NUM 10
#define LEN 10000

int x[NUM];
int cnt = 0;//当前邮票种类
int r[NUM];//结果
int knd = 0;//邮票种类
int lim = 0;//限制张数
int max = 0;//最大值

int C[NUM][LEN] = {};

inline int findMax() {
    int j = 1;
    while (C[cnt - 1][j]) {
        if (j < x[cnt] || C[cnt - 1][j] <= C[cnt][j - x[cnt]] + 1)
            C[cnt][j] = C[cnt - 1][j];
        else
            C[cnt][j] = C[cnt][j - x[cnt]] + 1;
        j++;
    }

    while (true) {
        int tmp = INT_MAX;
        for (int i = 1; i <= cnt; i++) {
            if (tmp > C[cnt][j - x[i]] + 1)
                tmp = C[cnt][j - x[i]] + 1;
        }
        if (tmp == INT_MAX || tmp > lim)
            break;
        else
            C[cnt][j] = tmp;
        j++;
    }
    C[cnt][j] = 0;
    return j - 1;
}

void dfs() {
    if (cnt == knd) {
        if (x[cnt] * lim < max)
            return;
        int tmp = findMax();
        if (tmp > max) {
            max = tmp;
            for (int i = 1; i <= knd; i++)
                r[i] = x[i];
        }
    }
    else {
        int tmp = findMax();
        for (int i = tmp + 1; i >= x[cnt] + 1; i--) {
            x[++cnt] = i;
            dfs();
            cnt--;
        }
    }
}

int main() {
    clock_t start = clock();

    x[1] = 1;
    cnt = 1;
    dfs();
    cout << max << endl;
    for (int i = 1; i <= knd; i++)
        cout << r[i] << ' ';
    cout << endl;

    clock_t end = clock();
    cout << "running time:" << (end - start)*1.0 / CLOCKS_PER_SEC << "s" << endl;

    return 0;
}

你可能感兴趣的:(算法,连续邮资问题,动态规划,回溯法,算法,剪枝)