ICPC2023瀋陽K

加固文明幻景發表於2024-10-06

https://codeforces.com/gym/104869/problem/K

DS題儘量進一步思考,簡化維護過程

權值線段樹上二分

首先得出一個顯然的轉化:對於每次操作,求出此次下所有正數從小到大的字首和的第一次大於所有負數和的絕對值的位置即為答案。

賽時做法

既然要求每次都求a升序下的字首和,很顯然的想到維護ai的權值線段樹,然後對這個位置二分答案,時間複雜度 \(O(n\log n\log n)\)

其實是完全能過的,但是我沒有透過思考來簡化維護過程:

  • 錯誤實現

維護到當前這個正數的總數時想當然的把所有正數的 rank 扔到了 multiset 裡面,

然後用 std::distance(S.begin(), S.lower_bound(k)) 來表示。

for (auto&[x, v] : querires) {
    //當前這個下標上對應的數的權值線段樹的值
    //不一定就是單點賦值,而是把修改前的那個點減去一次a[x], 然後在新的v對應的上面加一次a=v

    if (a[x] <= 0) {negative_abs_sum -= std::abs(a[x]);}
    else {
        int id{discreter.query(a[x])};
        T.modify(id, {-a[x]});
    }
    if (v <= 0) {
        negative_abs_sum += std::abs(v);
    } else {
        int id{discreter.query(v)};
        T.modify(id, {v});
    }
    int k{n + Q};
    {//第一遍k
        int lo{}, hi{n + Q - 1}; while (lo <= hi) {
            int mid{(lo + hi) / 2}; 
            (T.rangeQuery(0, mid + 1).sum > negative_abs_sum) 
            ? hi = mid - 1, k = mid : lo = mid + 1;
        }
    }

    a[x] = v; 

    if (k == n + Q) {
        std::cout << std::size(S) + 1 << "\n";
        continue ;
    }

    i64 pre_sum{T.rangeQuery(0, k).sum};

    int k_take{}, pre_take{(int)(std::distance(S.begin(), S.lower_bound(k)))};


    {//第二遍找k取了多少個
        auto k_sum{T.rangeQuery(k, k + 1).sum};
        auto k_val{discreter.queryInv(k)};
        i64 lo{1}, hi{k_sum / k_val}; while (lo <= hi) {
            i64 mid{(lo + hi) / 2}; 
            (pre_sum + mid * k_val > negative_abs_sum) ? 
            hi = mid - 1, k_take = mid : lo = mid + 1;
        }
    }

    std::cout << k_take + pre_take << "\n";

}

但是牛魔的,set 類容器都是不可隨機訪問容器啊,求迭代器距離是 \(o(n)\) 的,牛魔的。

而且一開始寫 S.lb(k) - S.begin() 不讓過編譯其實已經告訴你了,但是我當時居然覺得這是對安全性要求比較嚴格。。。

知道了這個問題之後,要改就很輕鬆了。

  • 改進思路

顯然正數的個數是可以扔到線段樹裡一起維護的,然後就結束了。

然後還發現另一個唐氏指出,下面那個找第幾個的完全可以推個柿子之後 \(O(1)\) 出來,沒必要二分。

struct Info {i64 sum{}, cnt{};};//加一個cnt,維護這個權值上的數量

Info operator + (const Info& a, const Info& b) {return {a.sum + b.sum, a.cnt + b.cnt};}

void solve()
{
    int n, Q; std::cin >> n >> Q; std::vector<int> a(n); for (auto& ai : a) {std::cin >> ai;}
    SegmentTree<Info> T(n + Q);
    std::vector<int> positives; for (auto& ai : a) if (ai > 0) {positives.push_back(ai);}
    std::vector<std::pair<int, int>> querires(Q); for (auto&[x, v] : querires) {
        std::cin >> x >> v; --x; if (v > 0) {positives.push_back(v);} 
    }

    Discreter discreter(positives);

    std::map<int, int> cnt; for (auto& ai : a) if (ai > 0) {cnt[ai] += 1;}
    for (auto&[ai, c] : cnt) {T.modify(discreter.query(ai), {c * ai, c});}

    i64 negative_abs_sum{}; for (auto& ai : a) if (ai <= 0) {negative_abs_sum += -ai;}

    for (auto&[x, v] : querires) {
        if (a[x] <= 0) {negative_abs_sum -= std::abs(a[x]);}
        else {
            int id{discreter.query(a[x])};
            T.modify(id, {-a[x], -1});//很顯然
        }
        if (v <= 0) {
            negative_abs_sum += std::abs(v);
        } else {
            int id{discreter.query(v)};
            T.modify(id, {v, 1});//很顯然
        }
        int k{n + Q};
        {//第一遍k
            int lo{}, hi{n + Q - 1}; while (lo <= hi) {
                int mid{(lo + hi) / 2}; 
                (T.rangeQuery(0, mid + 1).sum > negative_abs_sum) 
                ? hi = mid - 1, k = mid : lo = mid + 1;
            }
        }

        a[x] = v; 

        if (k == n + Q) {
            std::cout << T.rangeQuery(0, n + Q).cnt + 1 << "\n";
            continue ;
        }

        i64 pre_sum{T.rangeQuery(0, k).sum};

        int k_take{}, pre_take{T.rangeQuery(0, k).cnt};//直接維護出來了 [0, k - 1] 的所有正數個數


        {//第二遍找k取了多少個
            auto k_sum{T.rangeQuery(k, k + 1).sum};
            auto k_val{discreter.queryInv(k)};
            //i64 lo{1}, hi{k_sum / k_val}; while (lo <= hi) {
            //    i64 mid{(lo + hi) / 2}; 
            //    (pre_sum + mid * k_val > negative_abs_sum) ? 
            //    hi = mid - 1, k_take = mid : lo = mid + 1;
            //}
          	k_take = (negative_abs_sum - pre_sum + k_val - 1) / k_val;
        }

        std::cout << k_take + pre_take << "\n";

    }

}

佛了。

權值線段樹上二分

https://blog.nowcoder.net/n/90af997f26fe4e9ba18c399139d1607e

  • 板子
template<class F>
int findFirst(int p, int l, int r, int x, int y, F &&pred) {
    if (l >= y || r <= x) {
        return -1;
    }
    if (l >= x && r <= y && !pred(info[p])) {
        return -1;
    }
    if (r - l == 1) {
        return l;
    }
    int m = (l + r) / 2;
    int res = findFirst(2 * p, l, m, x, y, pred);
    if (res == -1) {
        res = findFirst(2 * p + 1, m, r, x, y, pred);
    }
    return res;
}
template<class F>
int findFirst(int l, int r, F &&pred) {
    return findFirst(1, 0, n, l, r, pred);
}

考慮怎麼最佳化掉二分這個 log,可以線上段樹上二分。

  • 先解決一個類似且簡單的線段樹二分問題:

對於 \(a_1...a_n\),求第一個 \(a_i > k\)\(i\),帶修

顯然維護一個最大值的位置線段樹:不最佳化的做法就是二分右端點,\(check\) T.rangeQuery(0, mid + 1).mx > k

但我們發現這個問題存在一個性質,先左子樹後右子樹這個順序找到的第一個解一定是最早出現的。

於是有了下述思路:

每次查詢時從根遞迴向下查詢, 對於當前區間 \([l,r]\)

  • 若當前節點為葉子結點, 若結點的值滿足 $ > v$ , 返回下標即可;

  • 若左子樹最大值大於 \(v\)(約束), 則左子樹可能存在解, 遞迴查詢左子樹; 若左子樹查詢到解,則直接返回該解(這是一個重要剪枝,可以大幅最佳化時間, 顯然此時即使右子樹存在大於 \(v\) 的元素,也不可能是第一個出現的了,所以沒必要再查,否則,若右子樹最大值大於 $ v$(約束), 遞迴查詢右子樹;

struct Info {int mx{-1};};

Info operator+(const Info& a, const Info& b) {return {std::max(a.mx, b.mx)};}

void solve()
{
#define tests
    SegmentTree<Info> T(7); int idx{};
    for (auto& ai : {6, 3, 2, 10, 7, 9, 13}) {T.modify(idx, {ai}); idx += 1;}
    debug(T.findFirst(0, 7, [&](auto& f){return f.mx > 9;}))
    //輸出為3,正確
}
  • 找到權值線段樹上第一個字首和(指的是權值線段樹上的字首和,從零開始)大於 k 的權值

這個問題同於上述問題的地方是我們要找的也是第一個位置。

這個問題不同於上述問題的地方是,這個問題要求的是字首和。字首和這個資訊顯然並不屬於葉子節點,所以如果這樣寫:

T.findFirst(0, n + Q, [&](auto& f){return f.sum > k;})

是不正確的,這實際上是在找第一個大於 \(k\) 的葉子節點的權值,也就是單個值而非字首和。

struct Info {int sum{-1};};

Info operator+(const Info& a, const Info& b) {return {a.sum + b.sum};}

void solve()
{
#define tests
    SegmentTree<Info> T(7); int idx{};
    for (auto& ai : {6, 3, 2, 10, 7, 9, 13}) {T.modify(idx, {ai}); idx += 1;}
    debug(T.findFirst(0, 7, [&](auto& f){return f.sum > 9;}));
    //輸出為3,他實際上想說的是 10 > 9
    debug(T.findFirst(0, 7, [&](auto& f){return f.sum > 13;}));
    //輸出為-1,因為沒有葉子節點上的sum比13大,但我們想輸出的是 (6 + 3 + 2 + 10 > 13) -> 3 
}

要怎麼維護字首和呢?我們發現一個權值線段樹上一個葉子節點的字首和肯定是等於遍歷到他之前所有葉子節點的和,也就是左子樹是一定會貢獻到右子樹上的。

那麼在操作的時候我們每次遍歷左子樹失敗(也就是左子樹的總和還不夠大),準備遍歷右子樹時,就把左子樹的貢獻算上去,這樣到葉子節點的時候就是對應的字首和了。

int findFirst(int p, int l, int r, int x, int y, i64 tar) {
    if (l >= y || r <= x) {
        return -1;
    }
    if (l >= x && r <= y && (info[p].sum <= tar)) {
        return -1;
    }
    if (r - l == 1) {
        return l;
    }
    int m = (l + r) / 2;
    int res = findFirst(2 * p, l, m, x, y, tar);
    if (res == -1) {
        res = findFirst(2 * p + 1, m, r, x, y, tar - info[2 * p].sum);//把左邊的所有貢獻加起來,因為是求字首和,也就是說接下來只要查詢到比 tar 減去左邊的貢獻後還大的值就行了。
    }
    return res;
}
//第一遍線段樹二分,找到第一個大於負數絕對值的字首和的下標
int k{T.findFirst(0, n + Q, negative_abs_sum)};
if (k == -1) {std::cout << T.rangeQuery(0, n + Q).cnt + 1 << "\n"; continue;}
//第二遍找k取了多少個
auto pre_sum{T.rangeQuery(0, k).sum}, pre_take{T.rangeQuery(0, k).cnt}; int k_val{discreter.queryInv(k)};
i64 k_take{(negative_abs_sum - pre_sum) / k_val + 1};

相關文章