题目来源是HDU 1074,题目大意是说:有几门课的作业需要做,已知每门课作业的deadline和完成该作业需要花费的时间,求一个作业的完成顺序,使得最终的超时的天数最小。
比如,现在如下输入:
Computer 3 3
English 20 1
Math 3 2
其中,第一列表示课程名,第二列表示deadline天数,第三列表示完成该作业需要的时间。
这题最开始看到的时候感觉没有思路,后来参考这篇博客才知道用状态压缩动态规划,仔细分析了下这个算法,发现就是枚举所有的状态,然后找到代价最小的。由于对状态压缩不是特别了解,所以先写下这题的解题报告,以后熟练了再做汇总和总结。下面先来详细分析一下本题算法的思想。
n门课程的完成顺序,其实就是n门课程的全排列,最多有 n! 种可能。题目中给出 n <= 15, 则最多有 15!=1307674368000 中状态,对这么多状态要求每一种状态的代价,消耗的时间不可谓不高。总而言之,这种枚举法的时间复杂度是 ,显然是不可取的。
考虑每门课的状态,无非就是做与不做,用1和0两个二进制数就能表示了。这样的话,n 个作业,用 个二进制数就能把所有的状态表示完,也不需要 n! 个状态。同时,用二进制数表示的状态,不同状态之间进行转换会比较快速和方便,因为基于本题的情况,那些可以相互转移的状态之间一定只有一个二进制位不同,这样的话可以通过简单的位运算来达到状态转移的目的。
以上面的输入为例,下面是我画的状态转移图:
说明一下:右下角是各门课程的编码,而中间状态码表示课程已经完成的状态,比如 011 表示已经完成了课程 001 和 010 时的状态。而 011 这个状态有两种进入途径,一种是 001->010 ->011,一种是 010->001->011,两种途径代表了两种作业执行顺序。实际在选择的时候要选择代价(也就是图中的delay)最小的进入途径。图中虚线箭头表示最后输出时通过 pre 参数进行的回溯,也即pre指向的是该状态的前驱状态。
一个比较重要的现象是,对任意两个相继的状态a, b (假设a是前驱,b是后继), 则a^b(a,b异或)表示的是从状态a到达状态b时,选择的作业的编码是a^b。比如 011^111 = 100,则从011状态抵达111状态时,被执行的作业的编码是100,也即是本例中的课程Math。这点是回溯输出时的一个重要依据。
由于题目中说明了输入的课程名已经按字典顺序处理了,所以输出时也是按字典顺序选择的。下面是AC的代码:
//hdu 1074
//states compressing and dynamic programming
//0MS 760K 2775 B
#include <iostream>
#include <cstdio>
#include <cstring>
using namespace std;
const int MAXN = 1<<16;
struct Course{
char name[101];
int deadline, cost;
}course[15];
struct node{
int cost;
int delay;
int pre;
}dp[MAXN];
bool visited[MAXN];
int max(int a, int b)
{
return a>b?a:b;
}
//stat是一个二进制状态数,只有一个 1,函数返回这个1在第几位
int digit(int stat)
{
int n = 0;
while((stat>>n)^1 != 0) n++;
return n;
}
void print(int stat)
{
if(dp[stat].pre != -1)
{
print(dp[stat].pre);
int course_code = dp[stat].pre ^ stat; //异或方式得到课程编码
int n = digit(course_code);
cout<<course[n].name<<endl;
}
}
int main()
{
int T;
cin>>T;
while(T--)
{
int n;
cin>>n;
for(int i = 0 ; i < n; i++)
{
scanf("%s %d %d", course[i].name, &course[i].deadline, &course[i].cost);
dp[1<<i].delay = 0;
dp[1<<i].cost = 0;
dp[1<<i].pre = -1;
}
int stats_max = (1<<n)-1;
dp[0].cost = 0;
dp[0].delay = 0;
dp[0].pre = -1;
memset(visited, false, sizeof(visited));
for(int j = 0 ; j <= stats_max ; j++)
{
//下面主要是求当前编码可以到达的编码
for(int k = 0 ; k < n ; k++)
{
int cur = 1<<k; //获取当前工作的编码
if( (cur & j)==0 ) //当前工作没有做过,添加到已完成工作序列中
{
int next_work_stat = cur | j;//做完当前工作之后的状态码
int next_cost = dp[j].cost+course[k].cost;//做完当前工作总共需要的天数
int next_delay = max(next_cost - course[k].deadline, 0)+ dp[j].delay;
if( !visited[next_work_stat] || //下一个工作状态尚未访问过
dp[next_work_stat].delay > next_delay //或者延时大于当前的路径方案
)
{
dp[next_work_stat].cost = next_cost;
dp[next_work_stat].delay = next_delay;
dp[next_work_stat].pre = j; //注意题目中说输入的课程名已经按字典顺序排序了
visited[next_work_stat] = true;
}
}
}
}
cout<<dp[stats_max].delay<<endl;
print(stats_max);
}
//system("pause");
return 0;
}
算法的性能分析:该算法事实上也是遍历了所有的状态,只不过通过对状态进行压缩,全部的状态数只有
补充说明,为什么采用一个二进制数表示就能将状态从阶乘压缩到指数数量级呢?我们取n=4时的情况来分析一下。当n=4时,如果直接穷举所有状态,则有4!=24种,如下:
1234 1243 1324 1342 1423 1432
2134 2143 2314 2341 2413 2431
3124 3142 3241 3214 3412 3421
4123 4132 4213 4231 4321 4312
注意到很多状态都有相同的前缀子串,比如状态1234和1243有相同的前缀子串 12, 都表示先做任务1和任务2,从理论上讲,状态1234和1243在前两步(12)的执行结果是一样的,可以重复使用。这就是本题使用动态规划的基础,对作为中间状态的子问题进行存储。如果仔细观察上面的状态转移图,不难看出,在状态压缩算法中,每一层(相对于直接穷举法里面的每一步)的两个状态,其相同的部分都来源于上一层(相当于上一步)的同一个状态。这是算法能够有效压缩状态的原因。同时,因为状态之间的转移是效率更高的位运算,使得算法的效率得到大幅度提高。