P10236 [yLCPC2024] D. 排卡 解题报告
P10236 [yLCPC2024] D. 排卡 解题报告
一、 前言:我们要解决什么问题?
大家好!今天我们来分析一道有趣的动态规划问题。简单来说,题目给了我们一个数字队列,比如 [5, 3, 1, 4, 2]
。我们需要玩一个游戏:
- 游戏操作:每次,我们可以从这个队列的最左边或者最右边拿走一个数。
- 构筑新序列:我们把拿出来的数,按顺序排成一个新序列
b
。比如,先拿最左边的5
,再拿最右边的2
,再拿最左边的3
…… - 计算得分:新序列
b
的总分是b[1]
的b[2]
次方 +b[2]
的b[3]
次方 + …… 一直加到最后。 - 最终目标:我们需要找到一种拿数的方法,使得这个总分最大。
二、 思路历程:从错误到正确
1. 最初的想法:贪心行不行?
看到“最大化”,很多同学的第一反应是贪心。什么叫贪心呢?就是“只顾眼前,不看长远”。
-
贪心策略:我们是不是可以每一步都做出当前看起来最优的选择?比如,我们先确定第一个数
b[1]
(从a
的队头或队尾选一个),然后为了让b[1]
的b[2]
次方最大,我们再决定第二个数b[2]
(从剩下队列的头或尾选)。以此类推。 -
为什么贪心是错的?
这种策略非常短视。举个例子,假设我们有一个很大的数,比如100
。如果当前为了让x^y
最大,我们把100
当成了底数x
,可能收益不错。但也许,把这个100
留到后面,让它当一个指数y
(比如2^100
),能带来的总分收益会高得多!贪心策略无法权衡一个数字“现在用”还是“以后用”哪个更好,因此很容易错过全局最优解。
2. 正确的方向:动态规划(DP)
既然贪心不行,我们就需要一种能“瞻前顾后”的全局规划方法。这就是动态规划(DP)大显身手的时候了。
观察这个游戏,我们发现一个关键特征:无论我们怎么从两头取数,剩下的数字永远是原始队列中一段连续的区间。
- 例如,从
[a_1, a_2, a_3, a_4, a_5]
中,我们先取a_1
,再取a_5
,那么剩下的就是[a_2, a_3, a_4]
,它是一个连续的子区间。
处理这种“连续区间”上的最优问题,正是区间动态规划(Interval DP)的典型应用场景。
三、 区间DP详解:如何设计状态与转移
1. 状态定义:我们需要记录什么信息?
区间DP的核心是定义一个好的“状态”。我们想要求解“处理完区间 [l, r]
内所有数能得到的最大分值”。
但这里有一个难点:计算得分 b_i
的 b_{i+1}
次方,需要知道前一个数 b_i
是什么。这意味着我们的决策不仅影响当前,还决定了下一步计算得分的“底数”。
所以,一个简单的 dp[l][r]
是不够的。我们必须在状态里加入更多信息。一个绝妙的设计是:
dp[l][r][0]
:表示我们只处理a[l...r]
这个子区间,并且规定第一个拿的数必须是a[l]
,然后用最优策略拿完剩下所有数,能得到的最大总分。dp[l][r][1]
:同理,表示我们只处理a[l...r]
这个子区间,但规定第一个拿的数必须是a[r]
,然后用最优策略拿完剩下所有数,能得到的最大总分。
这个状态定义非常精髓,它把“下一步的底数是什么”这个问题,转化为了“这个子问题是以谁开头”的确定性信息。
2. 状态转移:如何从小问题推导出大问题?
现在我们来看看如何计算 dp[l][r][0]
和 dp[l][r][1]
。
计算 dp[l][r][0]
(规定先拿 a[l]
)
-
我们已经确定了,在
a[l...r]
这个子问题中,第一个拿出的数是a[l]
。 -
那么,第二个要拿的数是哪个呢?它只能是剩下区间的两端:
a[l+1]
或者a[r]
。 -
我们分两种情况讨论:
- 情况一:第二个拿
a[l+1]
- 当前产生的得分是
power(a[l], a[l+1])
。 - 接下来,我们面对一个新问题:处理
a[l+1...r]
这个更小的区间,并且以a[l+1]
开头。这个问题恰好就是我们已经定义好的dp[l+1][r][0]
! - 所以,这种情况的总分是
power(a[l], a[l+1]) + dp[l+1][r][0]
。
- 当前产生的得分是
- 情况二:第二个拿
a[r]
- 当前产生的得分是
power(a[l], a[r])
。 - 接下来,我们面对的问题是:处理
a[l+1...r]
这个区间,并且以a[r]
开头。这个问题就是dp[l+1][r][1]
。 - 所以,这种情况的总分是
power(a[l], a[r]) + dp[l+1][r][1]
。
- 当前产生的得分是
- 情况一:第二个拿
-
为了让总分最大,我们在这两种情况中取一个最大值。于是,状态转移方程就出来了:
dp[l][r][0] = max( power(a[l], a[l+1]) + dp[l+1][r][0], power(a[l], a[r]) + dp[l+1][r][1] )
计算 dp[l][r][1]
(规定先拿 a[r]
)
这个推导过程完全类似:
- 第一个拿
a[r]
。 - 第二个可以拿
a[l]
或a[r-1]
。 - 推导出的状态转移方程为:
dp[l][r][1] = max( power(a[r], a[l]) + dp[l][r-1][0], power(a[r], a[r-1]) + dp[l][r-1][1] )
3. 实现细节
- 遍历顺序:我们按照区间长度
len
从小到大(len
从 2 到n
)来计算。对于每个len
,再遍历所有可能的左端点l
。这样可以保证,当我们计算dp[l][r]
时,所有它依赖的、区间长度更小的dp
值(如dp[l+1][r]
)都已经被计算出来了。 - 边界条件:当区间长度为1时,没有相邻的数对,所以得分为0。在我们的代码实现中,DP数组初始化为0,相当于自动处理了这种情况。
- 最终答案:当
len
增长到n
时,我们就能算出dp[1][n][0]
和dp[1][n][1]
。这分别代表了从整个队列a[1...n]
出发,第一步是取队头a[1]
还是队尾a[n]
的最优总分。我们最终的答案就是这两者中的较大值:max(dp[1][n][0], dp[1][n][1])
。
四、 代码解读
这里的 f
数组就是我们上面所说的 dp
数组。
// 快速幂函数,用于计算 a^b mod 998244353
long long power(long long base, long long exp) { /* ... */ }
// ...
while(T--){
read(n);
for(rint i=1;i<=n;i++) read(a[i]);
// 初始化DP数组,所有值都为0
for(rint i=1;i<=n;i++) for(rint j=1;j<=n;j++) f[i,j,0]=f[i,j,1]=0;
// len 是区间的长度
for(rint len=2;len<=n;len++){
// l 是区间的左端点
for(rint l=1;l<=(n-len+1);l++){
// r 是区间的右端点
int r=l+len-1;
// 计算 f[l][r][0]:先取 a[l] 的情况
// 对应我们分析的两种选择:下一个取 a[l+1] 或 a[r]
f[l,r,0] = max(f[l+1,r,0] + power(a[l], a[l+1]),
f[l+1,r,1] + power(a[l], a[r]));
// 计算 f[l][r][1]:先取 a[r] 的情况
// 对应我们分析的两种选择:下一个取 a[l] 或 a[r-1]
f[l,r,1] = max(f[l,r-1,0] + power(a[r], a[l]),
f[l,r-1,1] + power(a[r], a[r-1]));
}
}
// 最终答案是处理整个区间[1, n]时,先取头或先取尾的两种情况的最大值
printf("%lld\n",max(f[1,n,0],f[1,n,1]));
}
五、 总结
这道题是一个非常经典的区间DP问题。关键在于正确地识别出问题的本质(处理连续区间),并设计出能够包含解决子问题所需全部信息的状态(即 dp[l][r][0/1]
)。通过这种方式,我们可以将一个复杂的大问题,分解为一系列相互关联但更简单的小问题,并自底向上地求解,最终得到全局最优解。希望这份报告能帮助你更好地理解它!