P2157 [SDOI2009] 学校食堂
P2157 [SDOI2009] 学校食堂 解题报告
1. 题目解读:我们要解决什么问题?
想象一下,你就是食堂大厨,面前有 N 个同学排成一队。你的目标是:用最少的时间给所有同学做完菜。
- 时间怎么算? 做菜的时间不固定,取决于上一道菜和当前这道菜的口味。如果上一道菜口味是
a
,当前是b
,时间就是(a | b) - (a & b)
。这是一个位运算的小花招,它其实就等于a ^ b
(按位异或)。做第一道菜不花时间。 - 做菜顺序有限制吗? 有!虽然你可以不按排队顺序来,但不能太任性。每个同学
i
都有一个“容忍度”B_i
,意思是,他最多能容忍排在他身后的B_i
个人比他先拿到饭。比如,3 号同学的B_3
是 2,那么 4 号和 5 号可以插队到他前面,但 6 号就不行了。
我们的任务就是,在不惹怒任何同学的前提下,找到一个做菜顺序,使得总时间最短。
2. 初步分析:为什么这题这么难?
如果我们想找到“最优顺序”,最暴力的方法就是尝试所有可能的做菜顺序。但 N 个同学就有 N!
种顺序,当 N=20 时,这已经是一个天文数字了,计算机根本算不过来。所以,暴力枚举是行不通的。
我们需要更聪明的方法。这类“寻找最优解”、“多阶段决策”的问题,通常会用到动态规划 (Dynamic Programming, DP)。
3. 关键突破口:抓住题目的“小尾巴”
DP 的核心是设计一个好的“状态”。状态需要能描述清楚当前进展到了哪一步,并且能从之前的状态推导出来。
这道题最关键的线索是 B_i <= 7
。这个数字非常小!这意味着什么?
- 对于排在第
i
位的同学,他只关心他后面紧跟着的 7 个人(i+1
到i+7
)会不会插队。至于i+8
号同学,他绝对不可能在i
之前吃饭,因为(i+8) - i = 8 > 7 >= B_i
,i
同学肯定会发怒。 - 这给了我们一个启示:在任何时候,我们做决策(下一个给谁做菜),其实只需要关注一个很小的“窗口”。这个窗口就是当前队伍最前面还没吃饭的人,以及他身后的 7 位同学。
这个“局部性”的特点,正是使用状态压缩 DP 的完美场景。我们可以用一个整数(二进制位)来“压缩”这个小窗口内每个人的状态(是否已吃饭)。
4. DP 状态设计:三维数组的奥秘
基于上面的分析,我们可以设计出如下的 DP 状态:
f[i][j][k]
这个状态表示一个特定的局面,它存的是到达这个局面所需的最少时间。我们来拆解这三个维度:
i
:主线进度。 这代表“从 1 号到i-1
号的同学都已经吃完饭了”。我们就像在玩一个游戏,一关一关地过,i
就是我们当前要处理的关卡。j
:当前窗口的状态 (Bitmask)。 这是一个 8 位的二进制数。它的第h
位(从右到左,从 0 开始数)是 1 还是 0,表示窗口内的第h
个人(也就是i+h
号同学)是否已经吃饭。- 例如,
j = 5
,二进制是...00101
。这表示在[i, i+7]
这个窗口里,i
号同学(第0位)和i+2
号同学(第2位)已经吃完饭了。
- 例如,
k
:记录上一个吃饭的人。 为了计算下一次做菜的时间,我们必须知道上一个做菜的是谁。如果存绝对编号(1 到 N),状态就太多了。聪明的做法是存一个相对位置。k
表示上一个吃饭的人是i+k
号同学。k
可以是负数。比如k=-1
,就表示上一个吃饭的是i-1
号同学。- 在代码实现中,因为数组下标不能是负数,所以通常会加上一个偏移量,比如
k+8
。
所以,f[i][j][k]
的完整含义是:在 1 到 i-1
号同学都吃完饭,且 i
到 i+7
号同学的吃饭状态为 j
,并且上一个吃饭的人是 i+k
号的情况下,所花费的最少总时间。
5. DP 转移逻辑:如何从一个状态到另一个状态?
我们的目标是从初始状态 f[1][0][...] = 0
(游戏开始,没人吃饭,时间为0)出发,通过一系列决策,最终达到 f[N+1][0][...]
(所有人都吃完饭了)。
对于任何一个状态 f[i][j][k]
,我们有两种可能的操作:
情况一:当前关卡 i
的同学已经吃完饭了。
- 判断条件:
j
的第 0 位是 1(在代码里就是j & 1
)。 - 意味着什么? 这意味着我们处理
i
号同学这个“子任务”已经完成(他本人吃完了,并且所有在他之前的人也吃完了)。我们可以“推进主线”,进入下一个状态i+1
。 - 如何转移?
- 状态
i
变成i+1
。 - 窗口整体向右平移一位。原来
i
到i+7
的状态j
,对于新的i+1
来说,就变成了i+1
到i+8
的状态。这在二进制上体现为j
右移一位,即j >> 1
。 - 上一个吃饭的人是
i+k
。现在我们的参考点变成了i+1
,他的相对位置就成了(i+k) - (i+1) = k-1
。 - 所以,我们用
f[i][j][k]
的值去更新f[i+1][j >> 1][k-1]
。因为这个过程没有做新的菜,所以不增加时间。 f[i+1][j >> 1][k-1] = min(f[i+1][j >> 1][k-1], f[i][j][k])
- 状态
情况二:当前关卡 i
的同学还没吃饭。
- 判断条件:
j
的第 0 位是 0(在代码里就是else
分支)。 - 意味着什么? 我们还不能进入
i+1
状态,因为i
号同学还在饿着。我们必须在当前窗口内[i, i+7]
挑一个还没吃饭的人,给他做菜。 - 如何转移?
- 我们遍历窗口里的人,从
h = 0
到7
,尝试给i+h
号同学做菜。 - 1. 检查资格:
i+h
必须还没吃饭(j
的第h
位是 0)。 - 2. 检查容忍度(最关键的一步): 我们能让
i+h
插队吗?这取决于所有排在i+h
前面、且还没吃饭的人。他们都得能容忍i+h
。- 这等价于,对于所有
p < i+h
且没吃饭的p
,都要满足i+h <= p + B_p
。 - 题解代码用了一个很巧妙的方法:它按
h
从小到大的顺序枚举。维护一个变量lir
(limit range),记录下到目前为止,所有未吃饭的人所能容忍的最靠后的插队位置。当要选择i+h
时,只要i+h
不超过lir
,就是合法的。
- 这等价于,对于所有
- 3. 更新状态: 如果
i+h
可以做菜,我们就更新状态。- 主线进度仍然是
i
。 i+h
吃完饭了,所以新的状态j'
是j | (1 << h)
。- 新的“上一个吃饭的人”是
i+h
,所以相对位置k'
是h
。 - 时间要增加,增加量为
T[上一个吃饭的人] ^ T[i+h]
,也就是T[i+k] ^ T[i+h]
。 - 所以,我们用
f[i][j][k] + time
去更新f[i][j | (1 << h)][h]
。
- 主线进度仍然是
- 我们遍历窗口里的人,从
6. 初始和最终状态
- 初始化: 游戏开始时,我们面对 1 号同学,窗口是
[1, 8]
,没人吃饭。所以f[1][0][...] = 0
。但“上一个吃饭的人”是 누구? 实际上没有。代码通过一个技巧,将k
初始化为 -1(代码中是k+8=7
),并且在计算时间时判断,如果是第一个人,时间为 0。 - 最终答案: 当所有
N
个同学都吃完饭后,我们的主线进度推进到了N+1
。此时,窗口[N+1, ...]
里自然没有人吃饭了,所以j=0
。最终答案就是f[N+1][0][k]
中的最小值(因为最后一个吃饭的人k
可以是N, N-1, ...
中的任何一个)。
7. 总结
这道题的解法可以概括为以下几步:
- 识别问题模型:这是一个寻找最优顺序的组合优化问题,数据规模暗示了非暴力解法。
- 发现关键约束:
B_i <= 7
是解题的钥匙,它将问题局部化,使得我们只需要关心一个大小为 8 的滑动窗口。 - 设计 DP 状态:使用三维状态
f[i][j][k]
来精确描述问题局面,其中i
是宏观进度,j
(位掩码)是窗口微观状态,k
是计算成本所需的前置信息。 - 理清状态转移:分两种情况讨论——当前任务点
i
是否完成。如果完成,则推进主线;如果未完成,则在当前窗口内选择一个可行的决策(做菜),并更新状态。 - 处理细节:注意容忍度约束的巧妙实现、位运算的运用、以及初始和结束状态的定义。
通过这套精巧的 DP 设计,我们就把一个看似无法解决的排列问题,转化为了一个可以在规定时间内求解的动态规划问题。希望这份报告能帮助你彻底理解这个解法!