把相邻给定点之间需要补的新增点数当作代价,做 O(n^2k) 的点列 DP,dp[i][t] 直接统计含新增点在内的点列长度,答案取 max(dp[i][t] + k - t)。
OJ: luogu
题目 ID: P8816
难度:普及
标签:动态规划坐标搜索dp
创建: 2026-06-19 13:10
更新: 2026-10-05 00:00
题意
给出 n 个整数点,还允许你额外添加 k 个整数点。
要求从这些点里选出一个序列,使得相邻两点距离恰好为 1,并且坐标都单调不减。也就是说,每一步只能:
- 向右走一格
- 或向上走一格
问这样的点列最大能有多长。
思路
先看小数据暴力:
#include <bits/stdc++.h>
using namespace std;
// brute.cpp:小数据 DFS 枚举进入上升点列的给定点集合,验证最优答案。
const int MAXN = 25;
struct Point {
int x, y;
};
int n, k;
Point p[MAXN];
int best_given;
bool cmp_point(const Point &a, const Point &b) {
if (a.x != b.x) {
return a.x < b.x;
}
return a.y < b.y;
}
void dfs(int last, int used, int cnt) {
best_given = max(best_given, cnt);
for (int i = last + 1; i <= n; i++) {
if (p[i].x < p[last].x || p[i].y < p[last].y) {
continue;
}
int need = (p[i].x - p[last].x) + (p[i].y - p[last].y) - 1;
if (used + need > k) {
continue;
}
dfs(i, used + need, cnt + 1);
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> k;
for (int i = 1; i <= n; i++) {
cin >> p[i].x >> p[i].y;
}
sort(p + 1, p + n + 1, cmp_point);
best_given = 1;
for (int i = 1; i <= n; i++) {
dfs(i, 0, 1);
}
cout << best_given + k << '\n';
return 0;
}下面是另一种「01 序列」风格的暴力写法。它按排序后的给定点依次决定“选 / 不选”,递归生成完整选择后,叶子节点统一检查任意两点的横纵坐标差是否都满足限制,并统计可选点数:
另一种暴力写法:01 序列
// brute_01_style.cpp:01 序列风格暴力,按排序后的点依次决定选或不选。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 25;
struct Point {
int x, y;
};
int n, k;
Point p[MAXN];
int choose_point[MAXN]; // choose_point[i] = 0/1,表示第 i 个给定点不选/选
int best_given;
bool cmp_point(const Point &a, const Point &b) {
if (a.x != b.x) {
return a.x < b.x;
}
return a.y < b.y;
}
int need_points(int last, int cur) {
return (p[cur].x - p[last].x) + (p[cur].y - p[last].y) - 1;
}
bool check() {
int last = 0;
int used_extra = 0;
for (int i = 1; i <= n; i++) {
if (choose_point[i] == 0) continue;
if (last != 0) {
if (p[i].x < p[last].x || p[i].y < p[last].y) return false;
used_extra += need_points(last, i);
if (used_extra > k) return false;
}
last = i;
}
return true;
}
int calc_answer() {
int cnt = 0;
for (int i = 1; i <= n; i++) {
if (choose_point[i] == 1) cnt++;
}
return cnt;
}
void dfs(int dep) {
if (dep == n + 1) {
if (check()) {
int value = calc_answer();
if (best_given < value) best_given = value;
}
return;
}
// 第 dep 个给定点的 01 选择:0 不选,1 选。
for (int i = 0; i <= 1; i++) {
choose_point[dep] = i;
dfs(dep + 1);
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> k;
for (int i = 1; i <= n; i++) {
cin >> p[i].x >> p[i].y;
}
sort(p + 1, p + n + 1, cmp_point);
best_given = 0;
dfs(1);
cout << best_given + k << '\n';
return 0;
}brute.cpp 会枚举哪些给定点被选进最终的上升点列,只要下一个点坐标不下降,并且连接它所需的新增点数还没超预算,就继续 DFS。
关键在于先看清两个给定点之间到底要花多少新增点。
假设要从:
A(x1, y1)走到B(x2, y2)
并且
因为每一步只能向右或向上,所以总步数固定是:
(x2-x1) + (y2-y1)
而这段路的两个端点已经是给定点,所以中间真正需要补上的新增点数就是:
(x2-x1) + (y2-y1) - 1
两点代价表
| 起点到终点 | 总步数 | 需要补的新增点数 |
|---|---|---|
(1,2) -> (3,2) |
2 |
1 |
(3,3) -> (3,4) |
1 |
0 |
(2,2) -> (5,5) |
6 |
5 |
接下来有个很关键的化简:
如果一条点列里一共选了 g 个给定点,并且为了把它们连起来用了 t 个新增点,那么这条点列当前长度就是:
g + t
而剩下的 k-t 个新增点总能继续接到点列末尾(一路向右或向上走),把长度再增加 k-t。
所以我们干脆让 DP 直接把 g + t 这个“已经定下来的点列长度”统计进去,最后再补上剩下的新增点。
于是做 DP。
先按 (x 升序, y 升序) 排序,设:
dp[i][t]表示以第i个给定点为终点,恰好用了t个新增点时,整条点列最多包含多少个点(新增点也算进去)
排序之后只要从前面找一个前驱即可,和 LIS 一样写双重循环,枚举终点 i、再枚举前驱 j < i。若 j 满足 y_j <= y_i,就计算:
注意这里的下标方向:是从前驱 j 转移到终点 i,所以 need 用 i 减 j。转移时用 t 表示接上 need 个新增点之后的自由点总数,从大到小(倒序)枚举:
dp[i][t] = max(dp[i][t], dp[j][t - need] + need + 1)
其中 +need 是补进去的新增点,+1 是终点 i 自己。
因为 dp 已经把新增点算进去了,最后剩下的 k-t 个新增点还能继续接在点列末尾,所以答案就是:
DP 公式
设前驱为
(要求
设
代码里用 t 表示接上 need 个新增点之后的自由点总数,从 t,和
最终答案为:
公式解释:两个给定点之间的曼哈顿步数固定,中间缺多少整数点也随之固定。dp_{i,t} 记录在新增点预算消耗为 t 时点列已经有多长(含新增点);剩余新增点总能继续补在点列末端,所以答案再统一加上
样例 1 DP 表格
以样例 1 为例:
| 编号 |
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| 坐标 |
关键转移和
| 前驱 |
更新 | |
|---|---|---|
| 0 | ||
| 0 | ||
| 0 | ||
| 1 | ||
| 1 |
最优链:
终点为 8、
代码
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 505;
const int MAXK = 105;
const int NEG = -1000000000; // 表示“不可达”的负无穷
struct Point {
int x, y;
};
int n, k;
Point p[MAXN];
// dp[i][t]:以第 i 个给定点作为点列终点,恰好用了 t 个自由点(新增点)时,
// 整条点列最多包含多少个点(自由点也算进去)。
int dp[MAXN][MAXK];
bool cmp_point(const Point &a, const Point &b) {
if (a.x != b.x) {
return a.x < b.x;
}
return a.y < b.y;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> k;
for (int i = 1; i <= n; i++) {
cin >> p[i].x >> p[i].y;
}
sort(p + 1, p + n + 1, cmp_point); // 坐标单调,排序后只需从前向后转移(类似 LIS)
for (int i = 1; i <= n; i++) {
for (int t = 0; t <= k; t++) {
dp[i][t] = NEG;
}
dp[i][0] = 1; // 只选自己一个给定点,不用自由点
}
// 类似 LIS:枚举终点 i,再从它左边找一个前驱 j 来转移
for (int i = 1; i <= n; i++) {
for (int j = 1; j < i; j++) {
if (p[j].y > p[i].y) {
continue; // 前驱的 y 也必须不大于当前点
}
// 从 j 走到 i,中间需要补的点数:
// 横纵坐标一共要前进 dx+dy 步,去掉 i 自己是给定点,中间要新增 dx+dy-1 个点。
int need = (p[i].x - p[j].x) + (p[i].y - p[j].y) - 1;
if (need < 0 || need > k) {
continue; // 坐标重合(重题面不会给)或自由点不够,无法把 j 接到 i 前面
}
// 用 t 表示接上 need 个自由点之后的自由点总数,从大到小枚举(0/1 背包式倒序)
for (int t = k; t - need >= 0; t--) {
if (dp[j][t - need] == NEG) {
continue; // j 用 t-need 个自由点不可达
}
// 在 j 的基础上接上 need 个自由点,再算上终点 i 本身
dp[i][t] = max(dp[i][t], dp[j][t - need] + need + 1);
}
}
}
// 剩下的自由点可以继续往终点之后再延伸(一直往右/上走),所以答案 = dp[i][t] + (k - t)
int best = 0;
for (int i = 1; i <= n; i++) {
for (int t = 0; t <= k; t++) {
if (dp[i][t] == NEG) {
continue;
}
best = max(best, dp[i][t] + (k - t));
}
}
cout << best << '\n';
return 0;
}复杂度
- 时间复杂度:
- 空间复杂度:
总结
这题最重要的化简是:
- 一条已连好的点列长度就是“选中给定点个数 + 已用新增点个数”,所以可以让
dp[i][t]直接统计这个长度,最后再补上剩下的k - t个新增点。
这样新增点就只剩下“作为代价连接给定点”的作用,整题自然落到一个带资源限制、写法类似 LIS 的点列 DP 上。
一图流解析
这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。
