问题描述
假设国家发行了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层时,便比较当前邮票组合下的最大连续邮资区间的上限是否大于已搜到结果。若是,则记录该值及对应的邮票面值组合。
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,更新的数据为下表中标为粗体的部分。
2.则第2种邮票的面值的取值范围为[2,4],这里先取其面值为2。因为前1种邮票在DP矩阵中已经存在总面值为1到3的数据,所以可以直接利用这些数据,做向下DP,更新的数据为下表中标为粗体的部分。
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张。
显然,这样的表述过程过于啰嗦,不过为了方便理解,这是值得的。
4.则第3种邮票的面值的取值范围为[3,7],这里先取其面值为3。因为前2种邮票在DP矩阵中已经存在总面值为1到6的数据,所以可以直接利用这些数据,做向下DP,更新的数据为下表中标为粗体的部分。
5.为了找出前3种邮票的最大邮资区间上限,利用前3种邮票在总面值为1到6的最少张数,做向右DP,这个过程只利用到当前行的数据,更新的数据为下表中标为粗体的部分。此时取得了原问题的一个解,三种邮票面值为(1,2,3)时的连续邮资区间上限为9。
6.回溯法返回上一层结点,选择第3张邮票的下一面值为4。因为前2种邮票在DP矩阵中已经存在总面值为1到6的数据,所以可以直接利用这些数据,做向下DP,更新的数据为下表中标为粗体的部分。
7.为了找出前3种邮票的最大邮资区间上限,利用前3种邮票在总面值为1到6的最少张数,做向右DP,这个过程只利用到当前行的数据,更新的数据为下表中标为粗体的部分。此时取得了原问题的又一个解,三种邮票面值为(1,2,4)时的连续邮资区间上限为10,优于之前的解,则更新原问题的当前最优解。
8.不断重复上述过程,便可以找到原问题最优解。
一种可行的剪枝方法
记邮票种类为m,张数限制为n,使用回溯法不断更新的最大连续邮资区间上限为max。
则在遍历第m种邮票的面值时,若x[m]*n<=max便可以直接返回,因为当前邮票面值的组合中x[m]为最大值,不论连续与否,x[m]*n是当前邮票面值组合下的最大值,若这个值都比max要小,则其最大连续邮资区间上限也小于max。
这样可以省去一部分对最后一层结点的动态规划的过程。
package com.zyc.test;
import org.junit.Test;
import java.text.DateFormatSymbols;
public class MaxEms {
private static int knd = 5;//邮票种类
private static int lim = 4;//限制张数
private static int cnt = 0;//当前邮票种类
private static int max = 0;//存储邮资最大区间
private static int[] x = new int[100];//当前邮票面值
private static int[] r = new int[100];//最终的最优结果
private static int[][] C = new int [100][10000];//记录搜索结点状态
public static void dfs(){
if (cnt == knd) {//判断 当前 的邮票种类 是否等于 最大种类数 也就是已经获取全部的票
if (x[cnt] * lim < max) //做一个限制 如果当前 所有面值 中的最大值 * 限制张数 都小于 max 那几个数 可定不是 最优解
return;
int tmp = findMax(); //算出 当前 所有面值 所获得的最大面区间的 给 max
if (tmp > max) {
max = tmp;
for (int i = 1; i <= knd; i++) //把面值 赋值给 村结果的数组
r[i] = x[i];
}
}else {
int tmp = findMax(); //获取 当前 票面的 最大值
// 下面用公式 :x[i]的可取值范围是[x[i-1]+1:r+1]
for (int i = x[cnt] + 1; i <= tmp + 1; i++) {//下一层结点的面值的可能取值
x[++cnt] = i;//将可能面值加入当前面值组合中
dfs();
cnt--; // 走完 这一趟 dfs 恢复下标
}
}
}
public static int findMax(){ //计算 现有 x[] 中的最大邮资区间
int j = 1;
//向下DP
while (C[cnt - 1][j] != 0) { //遍历表中上一行 为 下一行 赋值
//判断 当前票面是否大于值 || 比较 当前点 的上方 和 当前点的 总面值 -减去当前面值 的差 的最优解 +1
//C[cnt][j - x[cnt]] 代表 当前点的 总面值 - 减去当前面值 的差 的票数
//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 = Integer.MAX_VALUE;//赋值为 最大值 存放 邮票张数
for (int i = 1; i <= cnt; i++) {
if (tmp > C[cnt][j - x[i]] + 1)
tmp = C[cnt][j - x[i]] + 1;C[cnt][j - x[cnt]] 代表 当前点的 总面值 - 减去当前面值 的差 的票数
}
if (tmp == Integer.MAX_VALUE || tmp > lim) //最限制 当temp等于最大值 或者 大于最大张数 结束
break;
else
C[cnt][j] = tmp;
j++;
}
C[cnt][j] = 0;
return j - 1;
}
public static void main(String[] args) {
x[1] = 1; //已知 第一个 面值 为 1
cnt = 1; //因为 已知 第一个面值为 1 所以 现在 种类数 为 1;
dfs(); //调用深搜 跑 算法 走你..
System.out.println(max);
for (int i = 1; i <= knd; i++)
System.out.print(r[i] + " ");
System.out.println();
}
}