[NOI2005] 瑰丽华尔兹

GitHub跳转原题关系图返回列表

把每段固定方向的时间看成一次行或列上的区间转移,设 dp[x][y] 表示当前位置最大滑行距离,再用单调队列优化每段的滑动窗口最大值。

OJ: luogu

题目 ID: P2254

难度:提高+/省选-

标签:动态规划单调队列网格

日期: 2026-06-21 06:17

题意

舞厅是一个 n x m 的网格,x 表示有家具,. 表示空地。

钢琴一开始在 (start_x, start_y)

接下来有 k 段时间,第 i 段时间内船体倾斜方向固定。

这一段里的每一秒:

  • 可以施魔法,让钢琴原地不动
  • 也可以不施魔法,让钢琴朝当前固定方向滑动一格

但钢琴不能撞到家具,也不能滑出舞厅。

要求最大化总滑行距离。

思路

先看一个小数据暴力:

cpp
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 15;
const int MAXT = 205;
const int NEG_INF = -1000000000;

int n, m, start_x, start_y, seg_cnt;
char grid_map[MAXN][MAXN];
int dp[MAXN][MAXN], nxt_dp[MAXN][MAXN];
int dir_list[MAXT], total_time;
int dx[5] = {0, -1, 1, 0, 0};
int dy[5] = {0, 0, 0, -1, 1};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // brute.cpp:按时间一秒一秒地做 DP。
    // 每一秒只有两种选择:施魔法原地不动,或者顺着当前方向滑动一格。
    cin >> n >> m >> start_x >> start_y >> seg_cnt;
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            cin >> grid_map[i][j];
        }
    }

    total_time = 0;
    for (int i = 1; i <= seg_cnt; i++) {
        int l, r, d;
        cin >> l >> r >> d;
        int len = r - l + 1;
        for (int t = 1; t <= len; t++) {
            dir_list[++total_time] = d;
        }
    }

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            dp[i][j] = NEG_INF;
        }
    }
    dp[start_x][start_y] = 0;

    for (int t = 1; t <= total_time; t++) {
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= m; j++) {
                nxt_dp[i][j] = NEG_INF;
            }
        }

        int d = dir_list[t];
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= m; j++) {
                if (dp[i][j] <= NEG_INF / 2) {
                    continue;
                }

                // 这一秒施魔法,原地不动。
                nxt_dp[i][j] = max(nxt_dp[i][j], dp[i][j]);

                // 这一秒不施魔法,顺着当前方向滑一格。
                int ni = i + dx[d];
                int nj = j + dy[d];
                if (ni >= 1 && ni <= n && nj >= 1 && nj <= m &&
                    grid_map[ni][nj] != 'x') {
                    nxt_dp[ni][nj] = max(nxt_dp[ni][nj], dp[i][j] + 1);
                }
            }
        }

        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= m; j++) {
                dp[i][j] = nxt_dp[i][j];
            }
        }
    }

    int ans = 0;
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            ans = max(ans, dp[i][j]);
        }
    }

    cout << ans << '\n';
    return 0;
}

暴力按“秒”来做 DP。

dp[x][y] 表示当前这一秒结束后,钢琴停在 (x,y) 的最大滑行距离。

每秒只有两种决策:

  • 施魔法,留在原地
  • 不施魔法,沿当前方向走一格

这样总时间是 T,总复杂度大约是 O(Tnm)O(T * n * m)

本题 T 最多到 40000,虽然不算特别离谱,但如果把每段时间单独拆成一秒一秒处理,常数会比较大,而且很难进一步利用“同一段方向固定”这个性质。

正解的关键是按“整段时间”处理。

设:

dp[x][y] 表示处理完前若干段时间后,钢琴停在 (x,y) 的最大滑行距离。

考虑某一整段时间,方向固定,长度为 len

假设这一段方向是“向右”。

如果这一段结束后停在 (x,y),那么这一整段开始时一定在同一行的某个位置 (x,k),并且:

  • k <= y
  • y-k <= len
  • ky 中间不能跨过家具

如果这段时间里从 k 走到了 y,新增的滑行距离就是 y-k,所以转移式是:

dp[x][y] = max(old_dp[x][k] + y-k)

整理一下:

dp[x][y] = y + max(old_dp[x][k] - k)

这里 k 的可选范围是一个长度不超过 len+1 的滑动窗口。

对固定的一行来说,y 从左到右枚举时,这个窗口也在一起右移,所以可以用单调队列维护

old_dp[x][k] - k

的最大值。

向左、向上、向下完全同理,只是把维护的式子换成:

  • 向左:old_dp[x][k] + k
  • 向上:old_dp[k][y] + k
  • 向下:old_dp[k][y] - k

另外,家具会把一行或一列切成若干互不连通的连续空地段。

一旦扫到家具,这一格就不能落脚,也不能跨过去,因此单调队列必须立刻清空,表示窗口不能跨障碍延续。

于是每一段时间都可以在 O(nm)O(n*m) 内完成转移,总复杂度就是 O(knm)O(k*n*m)

DP 转移方程

核心状态:

dp[x][y] 为处理完若干段后停在格子的最大距离

核心转移:

向右: new[x][y]=y+max(old[x][k]-k)

答案收束:

所有格子的最大 dp

代码

cpp
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 205;
const int NEG_INF = -1000000000;

int n, m, start_x, start_y, seg_cnt;
char grid_map[MAXN][MAXN];
// dp[x][y]:处理完当前这段时间后,钢琴停在 (x,y) 的最大滑行距离。
int dp[MAXN][MAXN];
// old_dp 是上一段时间结束后的状态,当前段的所有转移都只能从它转来。
int old_dp[MAXN][MAXN];
// 单调队列里存的是某一行/列上的候选下标。
int q[MAXN];

struct Segment {
    int l, r, dir;
} seg[MAXN];

// 处理“向上滑动 len 步”的整段转移。
void move_up(int len) {
    for (int col = 1; col <= m; col++) {
        int head = 0, tail = -1;
        for (int row = n; row >= 1; row--) {
            if (grid_map[row][col] == 'x') {
                head = 0;
                tail = -1;
                dp[row][col] = NEG_INF;
                continue;
            }

            // 向上最多走 len 步,所以候选起点行号不能超过 row + len。
            while (head <= tail && q[head] > row + len) {
                head++;
            }

            // 转移式:
            // old_dp[k][col] + (k - row)
            // 对固定 row 来说,只要维护 old_dp[k][col] + k 的最大值即可。
            while (head <= tail &&
                   old_dp[q[tail]][col] + q[tail] <= old_dp[row][col] + row) {
                tail--;
            }
            q[++tail] = row;

            if (old_dp[q[head]][col] <= NEG_INF / 2) {
                dp[row][col] = NEG_INF;
            } else {
                dp[row][col] = old_dp[q[head]][col] + q[head] - row;
            }
        }
    }
}

// 处理“向下滑动 len 步”的整段转移。
void move_down(int len) {
    for (int col = 1; col <= m; col++) {
        int head = 0, tail = -1;
        for (int row = 1; row <= n; row++) {
            if (grid_map[row][col] == 'x') {
                head = 0;
                tail = -1;
                dp[row][col] = NEG_INF;
                continue;
            }

            // 向下最多走 len 步,所以候选起点行号不能小于 row - len。
            while (head <= tail && q[head] < row - len) {
                head++;
            }

            // 转移式:
            // old_dp[k][col] + (row - k)
            // 对固定 row 来说,维护 old_dp[k][col] - k 的最大值。
            while (head <= tail &&
                   old_dp[q[tail]][col] - q[tail] <= old_dp[row][col] - row) {
                tail--;
            }
            q[++tail] = row;

            if (old_dp[q[head]][col] <= NEG_INF / 2) {
                dp[row][col] = NEG_INF;
            } else {
                dp[row][col] = old_dp[q[head]][col] - q[head] + row;
            }
        }
    }
}

// 处理“向左滑动 len 步”的整段转移。
void move_left(int len) {
    for (int row = 1; row <= n; row++) {
        int head = 0, tail = -1;
        for (int col = m; col >= 1; col--) {
            if (grid_map[row][col] == 'x') {
                head = 0;
                tail = -1;
                dp[row][col] = NEG_INF;
                continue;
            }

            // 向左最多走 len 步,所以候选起点列号不能超过 col + len。
            while (head <= tail && q[head] > col + len) {
                head++;
            }

            // 转移式:
            // old_dp[row][k] + (k - col)
            while (head <= tail &&
                   old_dp[row][q[tail]] + q[tail] <= old_dp[row][col] + col) {
                tail--;
            }
            q[++tail] = col;

            if (old_dp[row][q[head]] <= NEG_INF / 2) {
                dp[row][col] = NEG_INF;
            } else {
                dp[row][col] = old_dp[row][q[head]] + q[head] - col;
            }
        }
    }
}

// 处理“向右滑动 len 步”的整段转移。
void move_right(int len) {
    for (int row = 1; row <= n; row++) {
        int head = 0, tail = -1;
        for (int col = 1; col <= m; col++) {
            if (grid_map[row][col] == 'x') {
                head = 0;
                tail = -1;
                dp[row][col] = NEG_INF;
                continue;
            }

            // 向右最多走 len 步,所以候选起点列号不能小于 col - len。
            while (head <= tail && q[head] < col - len) {
                head++;
            }

            // 转移式:
            // old_dp[row][k] + (col - k)
            while (head <= tail &&
                   old_dp[row][q[tail]] - q[tail] <= old_dp[row][col] - col) {
                tail--;
            }
            q[++tail] = col;

            if (old_dp[row][q[head]] <= NEG_INF / 2) {
                dp[row][col] = NEG_INF;
            } else {
                dp[row][col] = old_dp[row][q[head]] - q[head] + col;
            }
        }
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m >> start_x >> start_y >> seg_cnt;
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            cin >> grid_map[i][j];
        }
    }
    for (int i = 1; i <= seg_cnt; i++) {
        cin >> seg[i].l >> seg[i].r >> seg[i].dir;
    }

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            dp[i][j] = NEG_INF;
        }
    }
    dp[start_x][start_y] = 0;

    for (int i = 1; i <= seg_cnt; i++) {
        // 这一段时间的总秒数。
        int len = seg[i].r - seg[i].l + 1;

        for (int x = 1; x <= n; x++) {
            for (int y = 1; y <= m; y++) {
                old_dp[x][y] = dp[x][y];
            }
        }

        // 方向编号:
        // 1 上,2 下,3 左,4 右
        if (seg[i].dir == 1) {
            move_up(len);
        } else if (seg[i].dir == 2) {
            move_down(len);
        } else if (seg[i].dir == 3) {
            move_left(len);
        } else {
            move_right(len);
        }
    }

    int ans = 0;
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            ans = max(ans, dp[i][j]);
        }
    }

    cout << ans << '\n';
    return 0;
}

复杂度

时间复杂度 O(knm)O(k * n * m),空间复杂度 O(nm)O(n * m)

总结

这题表面是在网格上模拟滑动,核心其实是“固定方向的一整段时间”的区间最值转移。

一旦把每一段压成一次行/列 DP,就能自然地看到单调队列优化的窗口结构。

一图流解析

这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。

一图流解析