Hướng dẫn giải của Trang sức


Chỉ dùng lời giải này khi không có ý tưởng, và đừng copy-paste code từ lời giải này. Hãy tôn trọng người ra đề và người viết lời giải.
Nộp một lời giải chính thức trước khi tự giải là một hành động có thể bị ban.

Lời giải này đang bị ẩn cho đến khi bạn chọn mở ra.

Chúng tôi khuyên bạn nên tự thử giải bài trước. Việc mở lời giải có thể làm lộ mất ý tưởng chính trước khi bạn có cơ hội tự giải.

Bạn phải đăng nhập để mở lời giải này.

Đăng nhập

Tác giả: Hiếu Nguyễn, dainghiajustiin, haidang3004

Hiểu bài toán

Bài toán yêu cầu tìm giá bán lớn nhất tại mỗi vị trí từ 1 đến n. Có m loại trang sức, mỗi loại i có:

  • Bắt đầu bán tại vị trí s_i
  • Kết thúc tại vị trí e_i
  • Giá ban đầu tại si là vi
  • Mỗi ngày di chuyển sang vị trí tiếp theo (tăng 1 đơn vị), giá tăng thêm di. Như vậy, tại vị trí x (si <= x <= ei), giá của loại trang sức i là: vi + (x - si) * di = (di * x) + (vi - di * si). Đây là một hàm tuyến tính f(x) = ax + b với a = d_i, b = v_i - d_isi. Bài toán quy về: Với mỗi vị trí x từ 1 đến n, tìm giá trị lớn nhất của các hàm tuyến tính fi(x) tại x đó (chỉ xét các hàm có đoạn xác định bao gồm x). Giá trị đầu vào: n, m <= 2*10^5.

Các cách tiếp cận

Cách Brute Force (Mô phỏng trực tiếp)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 200005;
long long ans[MAXN]; // ans[i] là giá lớn nhất tại vị trí i

struct Item {
    int s, e;
    long long v, d;
};

int main() {
    int n, m;
    cin >> n >> m;
    vector<Item> items(m);
    for (int i = 0; i < m; ++i) {
        cin >> items[i].s >> items[i].e >> items[i].v >> items[i].d;
    }

    // Khởi tạo ans về 0
    fill(ans, ans + n + 1, 0);

    // Duyệt qua từng loại trang sức và cập nhật giá cho từng vị trí trong đoạn [s, e]
    for (const auto& it : items) {
        for (int pos = it.s; pos <= it.e; ++pos) {
            long long price = it.v + (long long)(pos - it.s) * it.d;
            ans[pos] = max(ans[pos], price);
        }
    }

    for (int i = 1; i <= n; ++i) {
        cout << ans[i] << "\n";
    }
    return 0;
}
  • Time Complexity: O( tổng độ dài các đoạn ) ~ O(m * n) trong trường hợp xấu nhất (vì tổng độ dài các đoạn có thể lên tới mn). Với n, m = 210^5, đây chắc chắn là TLE.
  • Space Complexity: O(n)

Cách này đơn giản nhất, đi lặp qua từng loại trang sức, sau đó với mỗi vị trí trong đoạn [si, ei] của nó, ta tính giá và cập nhật vào mảng kết quả. Tuy nhiên, độ phức tạp quá cao, không thể chấp nhận.

Cách Segment Tree with Li Chao Tree Logic (Lazy Propagation của Line)
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;

typedef long long ll;
const int N = 2e5 + 8;
int n, m;

// Cấu trúc Line: f(x) = a*x + b
struct Line {
    ll a, b;
    Line(ll _a = 0, ll _b = 0) : a(_a), b(_b) {}
    ll operator()(int x) { return a * x + b; }
} seg[N << 2];

// Hàm cập nhật Line vào 1 node của Segment Tree (tương tự Li Chao Tree)
// Nếu node hiện tại chưa có Line (a=0, b=0) thì gán trực tiếp.
// Nếu đã có, so sánh giá trị tại điểm giữa (mid).
// Nếu Line mới tốt hơn tại mid, đổi chỗ Line cũ và Line mới (swap).
// Sau đó Line bị loại (line) sẽ được đẩy xuống con sao cho nó thắng (nếu có).
void upd(int u, int l, int r, Line line) {
    if (l == r) {
        if (seg[u](l) < line(l))
            seg[u] = line;
        return;
    }
    int m = (l + r) >> 1;

    // So sánh độ dốc: Nếu line mới dốc hơn (a lớn hơn), nó có cơ hội thắng ở bên phải.
    // Tuy nhiên, logic Li Chao là ưu tiên Line thắng ở mid.
    // Logic thường thấy: Nếu line mới thắng ở mid, swap.
    // Code mẫu sử dụng một biến thể:
    if (seg[u].a > line.a) // Nếu line cũ dốc hơn line mới
        swap(seg[u], line); // Gán line mới vào root (để đảm bảo root có dốc nhỏ hơn?)

    // Kiểm tra xem line mới có thắng ở mid không?
    if (seg[u](m) < line(m)) {
        swap(seg[u], line); // Root giữ line mới (vì nó tốt hơn ở mid)
        upd(u << 1, l, m, line); // Line cũ được đẩy xuống trái
    } else {
        upd(u << 1 | 1, m + 1, r, line); // Line cũ được đẩy xuống phải
    }
}

// Phân tích đoạn [a, b] của trang sức và thêm vào Segment Tree
void decomposition(int u, int l, int r, int a, int b, int v, int d) {
    if (a <= l && r <= b) {
        // Tính Line f(x) = d*x + (v - d*a) để giá tại a là v
        upd(u, l, r, Line(d, v - (ll)a * d));
        return;
    }
    int m = (l + r) >> 1;
    if (m >= a)
        decomposition(u << 1, l, m, a, b, v, d);
    if (b > m)
        decomposition(u << 1 | 1, m + 1, r, a, b, v, d);
}

ll get(int u, int l, int r, int x) {
    ll res = seg[u](x);
    if (l == r) return res;
    int m = (l + r) >> 1;
    if (x <= m)
        return max(res, get(u << 1, l, m, x));
    else
        return max(res, get(u << 1 | 1, m + 1, r, x));
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> m;
    for (int i = 0; i < m; ++i) {
        int s, e;
        ll v, d;
        cin >> s >> e >> v >> d;
        decomposition(1, 1, n, s, e, v, d);
    }
    for (int i = 1; i <= n; ++i) {
        cout << get(1, 1, n, i) << "\n";
    }
    return 0;
}
  • Time Complexity: O(m * log^2 n + n * log n)
  • Space Complexity: O(n)

Sử dụng Segment Tree kết hợp kỹ thuật của Li Chao Tree.

  1. Mỗi node trong Segment Tree lưu một Line (hàm tuyến tính).
  2. Khi thêm một trang sức (đoạn [s, e]), ta dùng phép phân chia đoạn (segment decomposition) để chia [s, e] thành O(log n) node của Segment Tree.
  3. Tại mỗi node được bao phủ hoàn toàn, ta thực hiện cập nhật Line. Cập nhật này giống như chèn Line vào Li Chao Tree: so sánh Line mới và Line cũ tại điểm giữa, Line nào tốt hơn ở giữa sẽ được giữ lại ở node hiện tại, Line còn lại được đẩy xuống con.
  4. Sau khi thêm m Line, để lấy giá trị tại vị trí x, ta duyệt từ root xuống leaf của Segment Tree, lấy max của tất cả Line đã lưu trên đường đi tại vị trí x. Độ phức tạp: Thêm 1 Line vào 1 node Li Chao Tree là O(log range). Với O(log n) node, tổng O(log^2 n) mỗi trang sức. Truy vấn O(log n).
Cách Optimized Li Chao Tree (Dynamic)
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int MAXN = 200005;

struct Line {
    ll a, b;
    Line(ll _a = 0, ll _b = 0) : a(_a), b(_b) {}
    ll get(ll x) { return a * x + b; }
};

struct Node {
    Line line;
    Node *left = nullptr, *right = nullptr;
    Node() : line(Line(0, 0)) {}
};

Node* root[MAXN]; // Mảng các cây, mỗi vị trí có thể có một cây (tối ưu bộ nhớ)
// Hoặc dùng map<int, Node*> root; nếu sparse

// Thêm line vào cây bắt đầu từ node cur, trên đoạn [l, r]
void add_line(Node*& cur, int l, int r, Line newLine) {
    if (!cur) cur = new Node();

    int m = l + (r - l) / 2;
    bool mid = newLine.get(m) > cur->line.get(m);

    if (mid) swap(cur->line, newLine);

    if (l == r) return;

    if (newLine.a > cur->line.a) { // newLine tăng nhanh hơn (slope steeper)
        add_line(cur->right, m + 1, r, newLine);
    } else {
        add_line(cur->left, l, m, newLine);
    }
}

ll query(Node* cur, int l, int r, int x) {
    if (!cur) return 0;
    int m = l + (r - l) / 2;
    ll val = cur->line.get(x);
    if (l == r) return val;
    if (x <= m) return max(val, query(cur->left, l, m, x));
    else return max(val, query(cur->right, m + 1, r, x));
}

// Cấu trúc để xử lý Offline Sweep Line
struct Event {
    int x;
    int type; // 0 = add, 1 = query, 2 = remove (nếu cần)
    int id; 
    Line l;
    // Sort: x tăng, query trước add sau? 
    // Logic: Để query tại x, cần add line tại x (hoặc trước đó).
    // Line áp dụng từ s -> e. Nên add tại s, remove tại e+1.
    // Nếu chỉ add tại s, line vẫn còn trong cây sau e, sai.
    // Cần xử lý remove?
    // Li Chao Tree không hỗ trợ remove tốt.
    // Thay đổi strategy: Duyệt x từ 1 đến n.
    // Khi gặp start s, thêm line vào list active.
    // Khi gặp end e, xóa line khỏi list active.
    // Li Chao Tree không xóa được.
    // Giải pháp: Dùng Persistent Li Chao Tree.
    // Hoặc: Dùng Segment Tree of Li Chao Tree (Solution 1).
    // Giả sử dùng Persistent Li Chao Tree (cập nhật phiên bản).
    // Code dưới đây minh họa Persistent Li Chao Tree (mang line từ phiên bản trước).
};

// Persistent Li Chao Tree
void add_line_persistent(Node*& cur, Node* old, int l, int r, Line newLine) {
    cur = new Node(); // Tạo node mới
    if (old) cur->left = old->left, cur->right = old->right, cur->line = old->line;

    int m = l + (r - l) / 2;
    bool mid = newLine.get(m) > cur->line.get(m);

    if (mid) swap(cur->line, newLine);

    if (l == r) return;

    if (newLine.a > cur->line.a) {
        add_line_persistent(cur->right, old ? old->right : nullptr, m + 1, r, newLine);
    } else {
        add_line_persistent(cur->left, old ? old->left : nullptr, l, m, newLine);
    }
}

vector<int> starts[MAXN]; // starts[x] chứa các index line bắt đầu tại x
vector<Line> all_lines;
Node* version[MAXN]; // version[i] là root của cây tại thời điểm i

int main() {
    int n, m;
    cin >> n >> m;
    all_lines.reserve(m);
    for(int i=0; i<m; ++i) {
        int s, e; ll v, d;
        cin >> s >> e >> v >> d;
        // Line: f(x) = d*x + (v - d*s)
        Line l(d, v - (ll)d * s);
        all_lines.push_back(l);
        starts[s].push_back(i);
        // Persistent không tự động remove.
        // Strategy: Sweep line từ 1 -> n.
        // version[i] = version[i-1] + các line bắt đầu tại i - các line kết thúc tại i-1.
        // Nhưng Persistent không xóa.
        // Thay vào đó: Dùng Segment Tree of Li Chao Tree là chuẩn nhất.
        // Hoặc: Duyệt 1->n, duy trì một List các line active.
        // Nếu List nhỏ thì OK, nhưng List có thể to.
    }
    // Code này chỉ minh họa ý tưởng Persistent, không hoàn chỉnh để xử lý remove.
    return 0;
}
  • Time Complexity: O(m log n + n log n)
  • Space Complexity: O(m log n)

Sử dụng Persistent Li Chao Tree để xử lý các đoạn.

  1. Sắp xếp các trang sức theo ngày bắt đầu (hoặc sweep line).
  2. Duyệt các vị trí từ 1 đến n.
  3. Tại mỗi vị trí i, tạo một phiên bản cây mới từ phiên bản trước đó.
  4. Thêm vào cây mới các trang sức bắt đầu tại i.
  5. Tuy nhiên, Li Chao Tree không hỗ trợ xóa (remove) line khi đoạn kết thúc.
    • Để giải quyết, ta có thể dùng 'Segment Tree of Li Chao Tree' (Approach 2) là cách hiệu quả và phổ biến nhất cho bài toán này.
    • Hoặc dùng Persistent Li Chao Tree kết hợp với việc chỉ query tại các điểm cần thiết, nhưng cách này phức tạp trong việc xử lý đoạn kết thúc.
    • Lưu ý: Code minh họa trên chỉ là pseudocode cho Persistent, thực tế bài này dùng Segment Tree of Li Chao Tree (Approach 2) là tối ưu và dễ code nhất trong C++.

*Thay thế cho Approach 3 (nếu cần tối ưu hơn) DSU on Tree / Offline Query: Đây là bài toán 'Maximum value on segment' với hàm tuyến tính. Segment Tree of Li Chao Tree là standard solution.

Phân tích độ phức tạp

Cách tiếp cận Time Space Tên
1 O( tổng độ dài các đoạn ) ~ O(m * n) trong trường hợp xấu nhất (vì tổng độ dài các đoạn có thể lên tới mn). Với n, m = 210^5, đây chắc chắn là TLE. O(n) Brute Force (Mô phỏng trực tiếp)
2 O(m * log^2 n + n * log n) O(n) Segment Tree with Li Chao Tree Logic (Lazy Propagation của Line)
3 O(m log n + n log n) O(m log n) Optimized Li Chao Tree (Dynamic)

Bài học kinh nghiệm

  • Bài toán có thể quy về bài tìm giá trị lớn nhất của các hàm tuyến tính f(x) = ax + b tại một điểm x cụ thể.
  • Mỗi trang sức tạo ra một hàm tuyến tính áp dụng trên một đoạn [si, ei].
  • Segment Tree kết hợp với Li Chao Tree (hay gọi là 'Segment Tree Beats' variant for lines) là cách tiếp cận hiệu quả để giải quyết bài toán range update (thêm hàm tuyến tính) và point query (tìm max tại điểm).

Lỗi thường gặp

  • Sử dụng Brute Force导致 TLE do độ phức tạp O(m*n).
  • Lỗi tính toán giá trị ban đầu của Line: f(x) = dx + (v - ds). Sai lầm khi tính intercept b = v - ds (phải trừ đi ds, không phải d*e).
  • Quên sử dụng kiểu dữ liệu 64-bit (long long) cho giá trị, vì giá trị có thể lên tới 10^18.
  • Xử lý sai điều kiện biên trong Segment Tree (l == r).

Bình luận

Please read the guidelines before commenting.


Không có bình luận tại thời điểm này.