题意
马上假期就要结束了,zjm还有 n 个作业,完成某个作业需要一定的时间,而且每个作业有一个截止时间,若超过截止时间,一天就要扣一分。
zjm想知道如何安排做作业,使得扣的分数最少。
Tips: 如果开始做某个作业,就必须把这个作业做完了,才能做下一个作业。
Input
有多组测试数据。第一行一个整数表示测试数据的组数
第一行一个整数 n(1<=n<=15)
接下来n行,每行一个字符串(长度不超过100) S 表示任务的名称和两个整数 D 和 C,分别表示任务的截止时间和完成任务需要的天数。
这 n 个任务是按照字符串的字典序从小到大给出。
Output
每组测试数据,输出最少扣的分数,并输出完成作业的方案,如果有多个方案,输出字典序最小的一个。
输入样例
2
3
Computer 3 3
English 20 1
Math 3 2
3
Computer 3 3
English 6 3
Math 6 3
输出样例
2
Computer
Math
English
3
Computer
English
Math
提示
在第二个样例中,按照 Computer->English->Math 和 Computer->Math->English 的顺序完成作业,所扣的分数都是 3,由于 English 的字典序比 Math 小,故输出前一种方案。
分析
这是一道状压DP问题。
- 状压DP
状压DP指的是使用特定编码技术来压缩状态的动态规划方法。
1. 压缩状态
- 什么是压缩状态?为什么要压缩状态?
状态指的是动态规划过程中分解出来的各子状态。例如区间DP中分解的各子区间,背包问题中取背包的不同情况等。
当子状态过多、过于复杂,如果用一般动态规划中使用的多维数组来表示空间复杂度无法承受时,就需要利用编码技术来将可能出现的所有状态转化为一个更易存储的变量,以此来降低复杂度。
2. 编码技术
- 二进制编码
二进制编码就是其中一种常用的编码技术。这种方法通过二进制来表示所有的状态,和前面背包问题中所讲的二进制拆分的理念有些相似。【二进制拆分👉[week11]选做题11- 1 东东与ATM —— 动态规划(背包问题)】
二进制编码可以理解为0-1背包。例如,
一个二进制0101 —— 可以理解为该二进制表示的状态中含有第1、3个背包。
也就是每个状态对应的二进制中,从低到高位算起,若该位为1,则代表该位对应的第i个元素在该状态中存在或被选中。
💡二进制编码代码模版
- 题目分析
题意很简单,就是根据给出的所有任务的ddl和完成所需天数,对所有作业的完成顺序进行安排,使得最后所扣分数最小。
1. 状态转移方程
根据状压DP的特点,在解决过程中我们需要依次遍历所有的状态,来确定每个状态的值。
在这道题中,每个状态就代表着每种完成作业的情况。也就是说,如二进制0110就代表着该状态s下第2个和3个作业被完成。
但是这道题需要确认每个作业的完成顺序。因此,我们应该对每个状态依次遍历所有作业,对当前状态s下完成了的作业里进行顺序判定。
假设当前第i个作业和第j个作业是在状态s下相邻完成的作业。当遍历到第j个作业时,假设第j个作业是状态s下最后一个完成的作业,那么,
状态s对应的所扣分数 = 去掉第j个作业的状态s‘所扣分数 + 状态s‘基础上完成第j个作业所扣分数
若此时得出的s所扣分数比原本的状态s记录的值更小,说明当前将第j个作业排在最后是更好的选择;否则就说明原本排在最后的作业依然不变。
则,状态转移方程为,
now = ans[s - (1 << (i - 1))] + max(counting( s - (1 << (i - 1)) ) + manage[i].day - manage[i].ddl,0);
2. 如何记录作业顺序
记录作业的顺序仍然是使用的回溯路径的办法。
但不同的地方在于,在这里是用状态来指向作业。也就是说,每个路径数组代表着此时在状态s应该完成的作业为way[s]。
当状态s被更新,第j个作业被安排在了最后,就说明当前状态s应该完成的作业为j,直到该状态的遍历中出现了更优选择。
因此,利用递归函数输出。从所有作业都已完成的最终状态开始递归,当递归到所有作业都没有被完成的初始状态时开始回溯,依次输出当前状态下要完成的作业。
3. 如何保证输出的作业安排字典序最小?
字典序最小这一约束当且仅当多种安排下,所扣分数都为最小的时候才会有效。
也就是说同一种最优值对应着多种作业安排,要保证该解下输出的安排满足字典序更小的作业先完成。
由于在动态规划过程中,我们是从最小的状态和第1个作业开始遍历。因此在每个状态的遍历过程中,越晚被扫描的作业越有可能替代前面已经扫描过的作业成为最后输出的作业。而越小的状态所记录的作业,越早被输出。
所以,只要保证作业按降序排列,则在从小到大的每个状态的遍历过程中,就能保证一定是该状态下最小的作业被完成。
4. 问题
- 不要忘记初始化状态数组,应将状态数组全部初始化为最大!
总结
- 状压DP还是难😭
代码
//
// main.cpp
// lab5
//
//
#include <cmath>
#include <string>
#include <algorithm>
#include <iostream>
using namespace std;
struct task
{
string name;
int ddl,day;
}manage[20];
//vector<task> manage;
int ans[(1 << 15) + 10];
int way[(1 << 15) + 10];
int n = 0;
bool cmp( task &a,task &b )
{
return a.name > b.name ;
}
int counting( int s ) //计算状态s对应的总时间
{
int m = 0;
for (int i = 1 ; i <= n ; i++ )
{
//如果状态s下完成了第i个作业
if( s & (1 << (i - 1)) )
m += manage[i].day; //累计完成作业的时间
}
return m; //得到完成状态s中所有作业的总时间
}
void output( int s ) //输出安排
{
if( s == 0 )
return;
output( s - ( 1 << ( way[s] - 1 )));
cout<<manage[way[s]].name<<endl;
}
int main()
{
ios::sync_with_stdio(false);
int t = 0;
cin>>t;
while ( t-- )
{
n = 0;
cin>>n;
memset(ans,INT_MAX,sizeof(ans)); //初始化不能忘!
ans[0] = 0;
for( int i = 1 ; i <= n ; i++ )
cin>>manage[i].name>>manage[i].ddl>>manage[i].day;
//按字典序降序排列
sort(manage + 1, manage + n + 1, cmp);
int now = 0;
//遍历所有状态s
for ( int s = 1 ; s <= (1 << n) - 1 ; s++ )
{
//遍历每个作业
//假设在当前状态s下第i个作业是最后完成的
for ( int i = 1 ; i <= n ; i++ )
{
//先判断当前状态s中是否完成了第i个作业
//若完成了第i个作业
if( s & (1 << (i - 1)) )
{
//计算状态s下完成除第i个作业外的其他作业的总扣分 + 最后完成第i个作业的扣分
now = ans[s - (1 << (i - 1))] + max(counting( s - (1 << (i - 1)) ) + manage[i].day - manage[i].ddl,0);
//若计算所得扣分比原状态下记录的分数更小,说明最后完成第i个作业的安排更好
if( now < ans[s] )
{
//记录路径:在当前状态s下,最后完成的是第i个作业
way[s] = i;
ans[s] = now; //更新记录
}
}
}
}
cout<<ans[(1 << n) - 1]<<endl; //输出结果
output((1 << n ) - 1); //输出依次完成的作业
}
return 0;
}