【学习笔记】【题解】合理的饭票设计

题目描述

以前大学食堂都使用餐票吃饭,每顿饭菜钱可以为1角,2角,...,最多为n角。如果规定每次吃饭最多只能使用k张餐票,是否可以设计m种不同面值的餐票,使得餐票从1开始可以连续覆盖的面值范围恰好为 1 - n(角)?满足上述条件的方案有多少?

假设 n 的值不超过500,饭菜钱单位为角。
例如,
m=3, k=2, n=8, 则,面值为:{1,3,4} 恰好覆盖 1,2,...,8,此时,1角只需要1张面值为1的即可,2角需要2张面值为1的,3角只需要1张面值为3的,4角只需要1张面值为4的,5角需要1张面值为1的再加上1张面值为4的,6角需要2张面值为3的,7角需要1张面值为3再加上1张面值为4的,8角需要2张面值为4的。即:只需要2张面值的饭票即可覆盖1-8的范围(注意:一定是连续覆盖)。除了这三种面值之外,再没有其他方案的面值。因此,这样的方案有1种。

若m=3, k=2, n=9, 则不存在面值组合,因此,为0种方案。

若m=3, k=2, n=6,则有 {1,2,3},{1,2,4}和{1,3,5}共3种。

若m=3, k=2, n=5, 则不存在,因此,为0种。

关于输入

第1行输入正整数 P, 表示后面有 P行
后面的P行分别为 m,k,n,其间以空格间隔

关于输出

对应输出 P行,若不存在 m 种面值的饭票,则输出0,若有,则输出方案数。

例子输入

4
3 2 5
3 2 6
3 2 8
3 2 9

例子输出

0
3
1
0

题面解释

可行的一套餐票面值需要恰好连续覆盖区间在1 ~ n的菜钱。

连续覆盖指这套餐票面值(在最多用k张前提下)能够凑到1、2、3、...、n-1、n元,即区间[1,n]中的全部元素,而非“只要凑到n元就行”。具体可参考案例输入的第四组数据“3 2 9”。

恰好指这套餐票面值无法连续覆盖所有[1,m] (m>n)的区间,这里留意到,只要这套面值无法凑出n+1元,那后面所有比[1, n+1]更长的区间也就无法被连续覆盖,因为这些区间都会在n+1处“断开”。具体可参考案例输入的第一组数据“3 2 5”,之所以方案数为0,是因为但凡能连续覆盖[1,5]的面值组合都能连续覆盖[1,6],不满足恰好覆盖。

思路描述

枚举(or搜索)

递归枚举所有大小为m的面值组合,再判断当中有多少个组合是满足题目要求的。虽然时间复杂度明显无法保证,但这是只学过递归方法的背景下最有可能的解决办法

伪代码,仅展示代码逻辑
var m,n,k
var combination = [1]
var ans

func verifyCombination() -> bool:
    ......

func genAllCombination( step )
    if len(combination) == m:
        if verifyCombination() ans++
        else return
    for i from step to n+1:
        combination.add(i)
        genAllCombination(i+1)
        combination.remove(i)

main():
    var t;
    while t--:
        ...get user input...
        ans = 0
        genAllCombination(2)
        print(ans)

这道题要攻克的一个难点是:即使已经用递归方法构造出来一个大小为m的面值组合,我要如何判断这个组合是否满足要求呢?

此时问题可以转化为

已知一套拥有m种面值的餐票系统,问在最多用k张餐票的情况下,这套系统是否满足:

一、学生能支付任意价格在1~n元内的饭菜,

二、学生无法支付价格为n+1元的教职工专属套餐。

这是很核心的子问题,最粗暴的解决方法同样是枚举,用递归枚举所有可能的餐票使用方案,再判断这些方案是否完整覆盖了1~n元且不覆盖n+1元。

当然这并非最优解,以下引入一个更聪明的解决方法。

 

动态规划(DP)

 省流版 

dp[n+1]用于记录凑出i元所需要的最少餐票数。

初始化dp[0] = 0,dp[i] = i,代表面值只有{1}时的情况,接下来用循环把剩余的面值逐个加入,例如有一组面值{1, x1, x2, x3} (1 < x1 < x2 < x3 ),最外层循环的任务就是{1} -> {1, x1} -> {1, x1, x2} -> {1, x1, x2, x3},然后每层循环都把dp更新一次。例如在{1, x1}的循环执行如下操作

for(i=x1; i<=n+1; i++){
    dp[i] = min(dp[i], dp[i-x1]+1); // 转移方程
}

代表新加入的面值对最优解的改变。

最终得到的dp数组便储存该餐票系统凑i元的最少餐票数。

 

 详细版 

枚举过程中我们会留意到有很多的“方案截然不同,结果如出一辙”。比方现在有1元、2元、3元三种面值,我们会发现方案{1,1,1}、{1,2}和{3}最终凑出来的都是3元,也就是说,对于3元,我们有三种支付方案!同理的对于每个价格i,往往能对应到多种支付方案。

留意到对于各个支付方案,我们只需要保留“使用最少餐票”的方案,因为最终判断面值系统是否可行时,我们只用在意每个价格的使用最少餐票的方案是否用了不多于k张餐票!进一步地,由于做最终判断时用不着知道具体的支付方案是什么,甚至只需要保留“凑出一个i元最少使用多少张餐票”这一信息

例如对于3元,我们的最优方案只需要用1张餐票,所以当k大于等于1,这套面值就能支付3元。如果面值只剩1元和2元,那最优方案就是2张餐票,当k大于等于2,可支付3元,否则就不可支付。

一段伪代码,呈现最终求解判断过程的代码逻辑
var m,n,k;
var best_solution[n+1]; //best_solution[i]表示凑出i元要用至少多少张餐票

getAllBestSolution(1,n+1); //假设有方法求到1~n+1元的最优方案

if best_solution[n+1] <= k:  //不满足条件二
    print(NOT WORK)
    EXIT

for i 从 1 ~ n :
    if best_solution[i] > k:  //不满足条件一
        print(NOT WORK)
        EXIT

print(WORK)

此处发现,原问题可以转化为“凑出i元至少用多少张餐票”的策略优化问题,现在要为每个i找出最优解

解决决策优化问题有个常用方法叫动态规划(Dynamic Programming),“动态”在于算法执行过程中最优解会被反复不断地更新(动态更新)。DP问题通常还有两个特征:执行过程中某一刻的最优解依赖于前面时刻的最优解(存在转移方程),并且某一刻的最优解的确立不会改变前面时刻的最优解(无后效性)。

step 1:寻找动态过程

我们先来寻找一个动态优化过程,也就是“从什么状态(state)到什么状态,我们的策略一定越来越优”。对于这个问题,可以发现“面值种数越多,策略一定越来越优”

以餐票系统有1元、2元、3元三种面值为例,从状态“只有1元一种面值” 到 “有1元和2元两种面值” 再到“有1元、2元和3元三种面值”,我们“凑出i元”的策略肯定是被优化的(下一状态的最优解只会优于上一状态的)。于是,我们可以把这个面值种数的拓展过程视为一个动态优化过程

// 一段伪代码,主要呈现代码逻辑
var best_solution[n+1]
while current_state in all_states: // 顺序遍历各状态
    for i from 1 ~ n+1 :
        upDate(best_solution[i]); // 更新当前当前阶段最优解
    current_state = next_state

step 2:确立动态过程

接下来详细地去确立具体的动态过程。

首先,考虑过程中各状态之间的“间隔”,不妨让相邻状态之间只相差一种面值,即一个状态到下一个状态只会新增一种面值,这样我们更新最优解时只需要考虑一个新元素带来的影响。

然后,考虑起始状态,这个起始状态需要对任意情况的面值组合都成立。例如,我们可以把“没有任何面值”当作起始状态,又0元不需要花任何餐票,此时最优解为[0, INF, INF, ..., INF] (用INF来表示不可能凑到某个数)。

此时会发现,对于任何可行的面值组合必须包含面值1,没有面值1的餐票的话1元的饭肯定吃不了,因为餐票无法对“支付价格比其面值更小的饭”这件事作出任何贡献,例如4元的餐票对于支付3元及以下的饭没有任何作用。

所以不妨让“只有1元一种面值”作为起始状态,此时最优解为[0, 1, 2, 3, ..., n, n+1]

最后,确定状态的更新方式,已知起始状态为{1},考虑到上一步递归枚举得到的组合都是递增排序,不妨就让状态更新时直接把剩余面值中的下一个面值加进去即可(即按递增序{1} -> {1,3} -> {1,3,5}而非随机加{1} -> {1,5} -> {1,5,3} ),加上餐票无法对“支付价格比其面值更小的饭”这件事作出任何贡献这一观察,新状态中只需要更新价格不小于新面值的最优解即可。

这样代码实现更简单、逻辑更清晰。

// 一段伪代码,主要呈现代码逻辑
var best_solution[n+1] = [0, 1, 2, ..., n+1]
var new_face_value = 1
var cnt = 1;

func getAllBestSolution(combination):
    while new_face_value != combination[m-1]:
        new_face_value = combination[cnt++]
        for i from new_face_value ~ n+1 :
            upDate(best_solution[i])

step 3:找转移方程

接下来寻找转移方程,即研究动态优化过程中某个状态的最优解和前状态的关联。

假设现在新增了面值x,目前储存最优解的数组best_solution还在储存上个状态时的最优解。

要寻找当前状态的最优解,不难发现

 

当前最优解 =  whichBetter( 不用新面值时的最优解,用上新面值后的最优解)

 

考虑对于价格i,要寻找当前状态下凑i元的最优解,有

 

best_solution[i] = whichBetter( 不用x凑i的最优解,用上x凑i的最优解)

 

其中,“不用x凑i的最优解”即“上一个状态的最优解”,即

不用x凑i时的最优解 = best_solution[i]

用上x凑i的最优解 = min( 用1张x凑i时的最优解,用2张x凑i时的最优解,... ) 

 

又 “用n张x凑i时的最优解”其实就是n再加上“凑i-nx的最优解”

        用上x凑i的最优解 = min( best_solution[i - x] + 1,best_solution[i - 2*x] + 2,... ) 

 

此时我们注意到best_solution数组内部的逻辑顺序,由于我们是用循环语句顺序更新best_solution的,当程序运行到 i 的时候,在 i 之前的best_solution就已经被更新为当前状态下的最优解了(i之前的best_solution已经用上新面值了!),所以对于i我们只用考虑用1张x和不用x的情况。

最终得到转移方程(当然前提是顺序遍历best_solution)

best_solution[i] = min( best_solution[i], best_solution[i - x] + 1) 

 

通常人们习惯把记录最优解的数组命名为dp

dp[i] = min( dp[i], dp[i - x] + 1) 

伪代码
var dp[n+1] = [0, 1, 2, ..., n+1]
var x= 1
var cnt = 1;

func getAllBestSolution(var combination):
    while x != combination[m-1]:
        x = combination[cnt++]
        for i from x~ n+1 :
            dp[i] = min(dp[i], dp[i-x]+1)

最后会发现dp的过程和上一步枚举组合的过程可以整合在一起。 

伪代码,仅展示代码逻辑
var m,n,k
var ans
var combination = [1]
var dp[n+1] = [0, 1, 2, ..., n+1]

func updateDP(x):
    var cnt = 1;
    for i from x~ n+1 :
        dp[i] = min(dp[i], dp[i-x]+1)

func verifyCombination() -> bool:
    if dp[n+1] <= k:  
        return false
    for i from 1 to n :
        if dp[i] > k:  
            return false
    return true

func tryAllPosibleCombination( )
    if len(combination) == m:
        if verifyCombination() ans++
        else return
    for i from combination[len(combination)-1]+1 to n+1:
        temp = copy(dp) // 用来还原dp
        combination.add(i)
        updateDP(i)
        tryAllPosibleCombination(i+1)
        // 回溯
        combination.remove(i)        
        dp = copy(temp)

main():
    ...

剪枝

一般进阶的枚举问题还要搭配适当的剪枝优化,通常在写完整个递归后,发现能通过前面的case但后面大一点的case会Time out,就意味要剪枝了。

通常是研究一下数学规律,找到在什么条件下一个方案会“绝对不可行”,进而把它们排除掉,这样就能缩小我们枚举的范围,目的是降低递归的时间成本,尽量只花时间在那些“潜在的、可行的”的方案上。

这道题目很多位置可以理解为剪枝,通常剪枝都会表现为一堆的判断语句(循环语句的结束条件也算),具体可以看AC代码。

伪代码,仅展示代码逻辑,欢迎抓虫orz
var m,n,k
var ans
var combination = [1]
var dp[n+1] = [0, 1, 2, ..., n+1]

func updateDP(x): //顺便会返回用k张餐票可连续覆盖的最大区间
    var r = min(x*k, n+1);
    for i from x ~ r:                                        //剪枝3
        dp[i] = min(dp[i], dp[i-x]+1)
    for i from x ~ r+1:
        if dp[i] > k:
            return i-1
    return -1

func tryAllPosibleCombination( mcover )
    if mcover <= 0: return
    if len(combination) == m:
        if mcover == n: ans++
        else return
    for i from combination[len(combination)-1]+1 to mcover+1: //剪枝1
        temp = copy(dp) // 用来还原dp
        combination.add(i)
        mcover = updateDP(i)
        if dp[n+1] > k:                                       //剪枝2
            tryAllPosibleCombination(i+1, mcover)
            combination.remove(i)        
            dp = copy(temp)

main():
    ......

以上伪代码新增一个变量mcover用于记录当前面值组合能连续覆盖的最长区间,在并让dp数组更新后返回一个新的mcover。

剪枝1:新增的面值若大于mcover+1,那这个combination显然这辈子都凑不到mcover+1元了,已经不满足要求。

剪枝2:若dp更新完后发现dp[n+1]<=k,那这个combination已经不满足题意恰好的条件了

剪枝3:由于最多只能用k张餐票,新加入的面值最多只能凑到k*x元,kx后面的dp值肯定用超过k张凑,不影响后面操作不用更新。

 

AC代码(注释版)

代码来源:https://github.com/LC-John/IntroductionToComputationHomework2022/blob/master/%E6%9C%9F%E6%9C%AB%E8%80%83%E8%AF%95/%E5%90%88%E7%90%86%E7%9A%84%E9%A5%AD%E7%A5%A8%E8%AE%BE%E8%AE%A1.cpp

#include <iostream>
using namespace std;

#define MAX_N 1000

int ac[MAX_N] = {0}; // 记录当前面值组合时,饭钱为i的菜至少要用ac[i]张饭票
int p[MAX_N] = {0}; // 用来记录dfs过程中生成的面值组合的各面值
int n = 0, m = 0, k = 0, ans = 0;

void dfs(int x,int mx) //x表示正在找第几个面值,mx表示当前组合能恰好连续覆盖的最大区间
{
	if(mx <= 0)
        return;
	if(x > m)
    {
		if(mx == n)
            ans++;
		return;
	}
    int *tmp = new int[MAX_N]; // 临时数组,用来保存ac数组,回溯用
	for(int i = p[x - 1] + 1;i <= mx + 1; i++) // 剪枝1:加入i > mx+1对于拓展目前“连续”覆盖区间没有意义
    {
		p[x] = i;
		int r = min(i * k, n + 1), j = 0; // 剪枝2: r表示当前面值组合能潜在影响到的最大区间
		for(j = i; j <= r; j++) // 当前面值会影响到i到r的区间的ac值,注意这里并不代表当前组合能用k张以下凑到r,即ac[j]是可能大于k的
		{
			tmp[j] = ac[j];
			ac[j] = min(ac[j], ac[j - i] + 1); //如果用了新加入的面值能用更少饭票,更新ac数组
		}
		if(ac[n + 1] > k) // 剪枝3:合法面值组合不会覆盖到n+1(因为题目要求恰好连续覆盖)
		{
			for(j = i; j <= r + 1; j++) // 寻找当前组合能连续覆盖的最大区间,r+1是为了防止r刚好是ac数组能覆盖的最大值,注意若r=n+1, 程序运行到j = r就会break
				if(ac[j] > k) // 如果当前组合能覆盖到j-1,但无法覆盖到j,则继续寻找下一个面值
				{
					dfs(x + 1, j - 1);
					break;
				}
		}
		for(j = i; j <= r; j++) // 回溯,将新加入的面值移出,所以将ac数组还原
            ac[j] = tmp[j];
	}
}

int main()
{
	int t = 0;
	cin >> t;
	while(t--)
    {
		cin >> m >> k >> n;
		ans = 0;
		for(int i = 1; i <= n + 1; i++)
            ac[i] = n + n; // 初始化为一个足够大的值,也可以是n+1,只要是ac[i]实际中不可能大于的值即可
		p[1] = 1;
		for(int i = 0; i <= k; i++)
            ac[i]=i; // 如果面值只有1,那么饭钱为i的菜至少要用i张饭票,当饭钱大于k,不可能用不多于k张饭票凑到。
		dfs(2, k); // 寻找第二个面值,目前组合能恰好连续覆盖的区间为k
		cout << ans << endl;
	}
	return 0;
}

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值