Official

D - お買い物プラン / Shopping Plan Editorial by admin

gemini-3-flash-thinking

概要

この問題は、与えられたアイテムの範囲 \([L_j, R_j]\) と予算 \(X_j\) に対して、最大満足度を求める「範囲制限付きの 0/1 ナップサック問題」です。商品の数 \(N\) や予算の上限 \(M\) は比較的小さいですが、クエリ数 \(Q\) が非常に多いため、クエリごとに独立して動的計画法(DP)を行うと間に合いません。

考察

素朴なアプローチ

1つのクエリに対してナップサック問題を解く場合、計算量は \(O(N \times M)\) となります。これを \(Q\) 回繰り返すと、全体の計算量は \(O(Q \times N \times M)\) です。 制約を代入すると \(10^5 \times 300 \times 500 = 1.5 \times 10^{10}\) となり、実行時間制限(通常 2秒程度)を大幅に超えてしまいます。

効率化のポイント

この問題の肝は、「範囲が固定された静的なデータに対して、大量の範囲クエリをどう捌くか」という点にあります。 商品の並びが固定されているため、分割統治法(Divide and Conquer)を用いることで、重複する計算をまとめ、クエリあたりの計算量を削減することが可能です。

アルゴリズム

「分割統治法」を用いて、クエリを再帰的に処理します。

1. 分割統治の仕組み

関数 solve(L, R, queries) を考えます。これは、範囲 \([L, R]\) に完全収まるクエリ群を処理する関数です。

  1. 範囲の中央 \(mid = (L+R)/2\) を決めます。
  2. クエリを以下の3つに分類します。
    • 左側 \([L, mid]\) に完全に収まるクエリ
    • 右側 \([mid+1, R]\) に完全に収まるクエリ
    • \(mid\) をまたぐ(\(L_j \le mid\) かつ \(R_j > mid\))クエリ
  3. 左右に完全に収まるクエリは、さらに solve を再帰的に呼び出して処理します。
  4. \(mid\) をまたぐクエリを、このステップで一括して処理します。

2. またぐクエリの処理(マージ)

\(mid\) をまたぐクエリ \([L_j, R_j]\) は、「左側の断片 \([L_j, mid]\)」と「右側の断片 \([mid+1, R_j]\)」に分解できます。

  • 左側の準備: \(mid\) から左方向(\(mid, mid-1, \ldots, L\))へ向かって、各位置を開始点としたナップサック DP(接尾辞 DP)を計算しておきます。
  • 右側の準備: \(mid+1\) から右方向(\(mid+1, mid+2, \ldots, R\))へ向かって、各位置を終了点としたナップサック DP(接頭辞 DP)を計算しておきます。
  • 結合: クエリの予算を \(X_j\) とすると、左側で予算 \(k\)、右側で予算 \(X_j - k\) を使う場合の最大満足度の和を、すべての \(0 \le k \le X_j\) について試せば、そのクエリの答えが求まります。

この結合処理は、1つのクエリにつき \(O(M)\) で終わります。

計算量

  • DPテーブルの構築: 分割統治の各レベルにおいて、全アイテム \(N\) 個について DP を行います。分割の深さは \(O(\log N)\) なので、全体で \(O(N \times M \times \log N)\) です。

  • クエリの処理: 各クエリは必ずどこか一つの \(mid\) をまたぐタイミングで一度だけ処理されます。その際の結合処理は \(O(M)\) なので、全体で \(O(Q \times M)\) です。

  • 合計時間計算量: \(O(N M \log N + QM)\) 制約を当てはめると、約 \(1.2 \times 10^6 + 5 \times 10^7\) となり、制限時間内に十分収まります。

  • 空間計算量: \(O(NM + Q)\) 再帰の各段階で DP テーブルを保持する必要があります。

実装のポイント

  • DPテーブルの管理: cur_dp_l[i][w] は「アイテム \(i\) から \(mid\) までを使って重さ \(w\) 以下にする最大満足度」を保持します。メモリ節約のため、1次元配列を再利用したり、必要な分だけ確保するようにします。

  • クエリの振り分け: 再帰ごとにクエリのリストを left_q, right_q, cross_q に振り分けることで、無駄な探索を省きます。

  • 入出力の高速化: \(Q=10^5\) と多いため、C++ の場合は cin.tie(NULL); ios::sync_with_stdio(false); を使用し、endl の代わりに \n を使うのが望ましいです。

    ソースコード

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

// Structure to store item properties: price (h) and satisfaction (v)
struct Item {
    int h;
    long long v;
};

// Structure to store query details: range [l, r], budget (x), and original index (id)
struct Query {
    int l, r, x, id;
};

int N, M, Q;
Item items[305];
long long results[100005];

/**
 * Solves the range knapsack problem using a Divide and Conquer approach.
 * This handles queries by splitting the range and processing those that cross the midpoint.
 * 
 * Time Complexity: O(N * M * log N + Q * M)
 * Space Complexity: O(N * M + Q)
 */
void solve(int L, int R, const vector<Query>& queries) {
    // If no queries are in this range, return early
    if (queries.empty()) return;

    // Base case: range consists of a single item
    if (L == R) {
        for (const auto& q : queries) {
            // Only one item to consider, check if budget allows it
            results[q.id] = (items[L].h <= q.x ? items[L].v : 0);
        }
        return;
    }

    int mid = (L + R) / 2;
    vector<Query> left_q, right_q, cross_q;

    // Distribute queries into three groups based on their range [l, r]
    for (const auto& q : queries) {
        if (q.r <= mid) {
            left_q.push_back(q);
        } else if (q.l > mid) {
            right_q.push_back(q);
        } else {
            cross_q.push_back(q);
        }
    }

    // Process queries that cross the midpoint
    if (!cross_q.empty()) {
        int l_size = mid - L + 2;
        int r_size = R - mid + 1;
        int m_plus_1 = M + 1;

        // DP tables for the suffix of the left side and prefix of the right side
        // cur_dp_l[i - L] stores the knapsack for items [i, mid]
        // cur_dp_r[i - mid] stores the knapsack for items [mid + 1, i]
        vector<long long> cur_dp_l(l_size * m_plus_1, 0);
        vector<long long> cur_dp_r(r_size * m_plus_1, 0);

        // Compute suffix knapsacks for the left side [L, mid]
        for (int i = mid; i >= L; --i) {
            int idx = i - L;
            int next_idx = idx + 1;
            int cur_base = idx * m_plus_1;
            int next_base = next_idx * m_plus_1;
            int h = items[i].h;
            long long v = items[i].v;
            for (int w = 0; w < h; ++w) {
                cur_dp_l[cur_base + w] = cur_dp_l[next_base + w];
            }
            for (int w = h; w <= M; ++w) {
                long long val1 = cur_dp_l[next_base + w];
                long long val2 = cur_dp_l[next_base + w - h] + v;
                cur_dp_l[cur_base + w] = (val1 > val2 ? val1 : val2);
            }
        }

        // Compute prefix knapsacks for the right side [mid + 1, R]
        for (int i = mid + 1; i <= R; ++i) {
            int idx = i - mid;
            int prev_idx = idx - 1;
            int cur_base = idx * m_plus_1;
            int prev_base = prev_idx * m_plus_1;
            int h = items[i].h;
            long long v = items[i].v;
            for (int w = 0; w < h; ++w) {
                cur_dp_r[cur_base + w] = cur_dp_r[prev_base + w];
            }
            for (int w = h; w <= M; ++w) {
                long long val1 = cur_dp_r[prev_base + w];
                long long val2 = cur_dp_r[prev_base + w - h] + v;
                cur_dp_r[cur_base + w] = (val1 > val2 ? val1 : val2);
            }
        }

        // Combine the results from the left suffix and right prefix for crossing queries
        for (const auto& q : cross_q) {
            long long max_v = 0;
            int l_base = (q.l - L) * m_plus_1;
            int r_base = (q.r - mid) * m_plus_1;
            for (int k = 0; k <= q.x; ++k) {
                long long current_v = cur_dp_l[l_base + k] + cur_dp_r[r_base + (q.x - k)];
                if (current_v > max_v) max_v = current_v;
            }
            results[q.id] = max_v;
        }
    }

    // Recursively solve for queries strictly in the left or right halves
    solve(L, mid, left_q);
    solve(mid + 1, R, right_q);
}

int main() {
    // Optimize I/O operations
    ios::sync_with_stdio(false);
    cin.tie(NULL);

    if (!(cin >> N >> M >> Q)) return 0;

    // Input items (1-indexed)
    for (int i = 1; i <= N; ++i) {
        cin >> items[i].h >> items[i].v;
    }

    // Input queries
    vector<Query> queries(Q);
    for (int i = 0; i < Q; ++i) {
        cin >> queries[i].l >> queries[i].r >> queries[i].x;
        queries[i].id = i;
    }

    // Solve using divide and conquer
    solve(1, N, queries);

    // Output all query results
    for (int i = 0; i < Q; ++i) {
        cout << results[i] << "\n";
    }

    return 0;
}

この解説は gemini-3-flash-thinking によって生成されました。

posted:
last update: