生日礼物

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

先把所有正段全部取上,再用二叉堆+双向链表的反悔贪心,以最小代价合并相邻正段或删掉正段,把段数压到不超过 M。

OJ: roj

题目 ID: 3057

难度:提高+/省选-

标签:贪心反悔贪心二叉堆链表python

创建: 2026-10-01 12:43

更新: 2026-10-07 13:50

形式化题目

给定长度为 NN 的整数序列 A1,A2,…,ANA_1, A_2, \dots, A_N 和整数 MM。选取序列中至多 MM 个互不相交的连续段(每段非空),要求

max⁡∑选中的段∑i∈段Ai,\max \sum_{\text{选中的段}} \sum_{i \in \text{段}} A_i ,

即在"段数不超过 MM"的限制下最大化所选元素之和。允许一段也不选(答案为 00)。1⩽N,M⩽1051 \leqslant N, M \leqslant 10^5,∣Ai∣⩽104|A_i| \leqslant 10^4。

样例 A=(2,−3,2,−1,2)A = (2, -3, 2, -1, 2)、M=2M = 2 时最优取 [1,1][1,1] 与 [5,5][5,5],和为 2+2=52 + 2 = 5;若只允许 M=1M = 1 就只能取 [1,1][1,1] 或 [3,3][3,3],答案是 22。

正解

思路

第一步:把序列压成符号交替的段。 值为 00 的元素放进任何一段都不影响和,直接丢掉;剩下的元素按符号合并成若干段,得到

P1, N1, P2, N2, …, PKP_1,\ N_1,\ P_2,\ N_2,\ \dots,\ P_K

其中 Pi>0P_i > 0 是正段的和,Ni<0N_i < 0 是相邻正段之间负段的和,首尾的多余负段永远不会被选,直接剪掉。记 S=∑i=1KPiS = \sum_{i=1}^{K} P_i(取走全部正段的和)。

第二步:分三种平凡情形。

  • M=0M = 0 或 K=0K = 0(没有正段):一段都取不了,答案是 00。
  • K⩽MK \leqslant M:段数限制不起作用,全部正段都取,答案是 SS。

第三步:K>MK > M 时,把"取 MM 段"变成"从全部正段里做 T=K−MT = K - M 次操作"。 先贪心地取走全部 KK 个正段,再从 SS 里扣掉最小的代价,让正段的数量恰好减少 TT 个。每次操作只有两种:

  • 删掉一个正段 PiP_i:代价 PiP_i;
  • 越过一个负段 NiN_i,把它和它两侧的正段并成一段:代价 ∣Ni∣|N_i|(并进来时多损失的和)。

关键在于两种操作互相冲突:一个正段被删掉后,它两侧的负段也就不必再越过;一个负段被越过并与两侧正段合并后,这两个正段已经合成一段,不存在"删掉其中一半"的说法。把 PiP_i 与 ∣Ni∣|N_i| 交替排成一条链

P1    ∣N1∣    P2    ∣N2∣    ⋯    PK,P_1 \;\; |N_1| \;\; P_2 \;\; |N_2| \;\; \cdots \;\; P_K ,

则每种合法操作对应链上的一个点,冲突恰好是"相邻":问题化为在链上选恰好 TT 个两两不相邻的点,使点权和最小;答案是 SS 减去这个最小点权和。

样例 A=(2,−3,2,−1,2)A = (2,-3,2,-1,2)、M=2M = 2 的建模过程:

元素 压缩后的段 链上的点 含义
22 P1=2P_1 = 2 22 删掉它代价 22
−3-3 ∣N1∣=3\lvert N_1 \rvert = 3 33 越过它代价 33
22 P2=2P_2 = 2 22 删掉它代价 22
−1-1 ∣N2∣=1\lvert N_2 \rvert = 1 11 越过它代价 11
22 P3=2P_3 = 2 22 删掉它代价 22

K=3K = 3、M=2M = 2,需要操作 T=1T = 1 次;链是 2, 3, 2, 1, 22,\,3,\,2,\,1,\,2,最小点权为 11(越过 N2N_2,把第 2、3 个正段并成 [3,5][3, 5]),于是答案 =6−1=5= 6 - 1 = 5。

第四步:链上取 TT 个不相邻点的经典反悔贪心。 每步取当前最小的点 ii(用堆维护),然后让 ii 原地变成它的左右两个邻居的"后悔药":删掉左右邻居,把 ii 的点权改成 wl+wr−wiw_l + w_r - w_i 并放回堆。这样再选 ii 一次,净代价就变成 wl+wrw_l + w_r,等价于"撤销刚才的选择,改选左右两个点"——它们原先与 ii 相邻而选不了,现在正好补上相邻位置。两端各加一个权值为 +∞+\infty 的哨兵,保证"反悔到边界外"永远不会被堆顶选中(边界外选点不会改变已选个数,非法)。

坑点:左右哨兵的指针必须指向自己(prv[0] = 0、nxt[s+1] = s+1),否则反悔到端点时会读到越界下标;堆里还要用 dead 标记做懒删除,跳过已被邻居吞并的历史记录。

经过 TT 次取点,链上恰好选了 TT 个互不相邻的点,答案即为 SS 减去总代价。链上的选点位置与"删正段/越负段"一一对应,反悔贪心给出的正是最小代价。

代码

Python 版:

python
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-01 13:02
# update_at: 2026-10-01 13:02

import heapq
import sys
from itertools import groupby

INF = 1 << 60  # 链表两端哨兵的权值:大到永远不会被选中,从而禁止“反悔到边界外”


def same_sign_runs(a: list[int]) -> list[tuple[bool, int]]:
    """把序列压成 (是否为正段, 该段数值和) 的交替列表,0 直接丢弃。"""
    return [(sign, sum(run)) for sign, run in groupby((x for x in a if x), key=lambda x: x > 0)]


def min_merge_cost(path: list[int], t: int) -> int:
    """在路径上选 t 个两两不相邻的点,最小化权值和(堆 + 双向链表反悔)。

    path 交替排列“删掉一个正段”的代价与“并掉一个负段”的代价:一次操作占用一个点,
    相邻点互斥;取完 i 后把左右邻接进 i 并把权值改成 w[l]+w[r]-w[i],
    再选它一次就等于反悔、改选两侧。
    """
    s = len(path)
    w = [INF, *path, INF]             # 下标 0 与 s+1 为哨兵
    prv = [0, *range(s), s]           # prv[0] 指回自己,反悔到左端时不会越界
    nxt = [*range(1, s + 2), s + 1]   # nxt[s+1] 同理指向自己
    dead = bytearray(s + 2)
    cost = 0
    heap = [(w[i], i) for i in range(1, s + 1)]
    heapq.heapify(heap)

    for _ in range(t):
        value, i = heapq.heappop(heap)
        while dead[i]:                # 已被左右邻居吞并的历史记录
            value, i = heapq.heappop(heap)
        cost += value
        left, right = prv[i], nxt[i]  # 相邻点与 i 互斥,只能合到一起再反悔
        dead[left] = dead[right] = 1
        w[i] = w[left] + w[right] - value
        prv[i], nxt[i] = prv[left], nxt[right]
        nxt[prv[left]] = prv[nxt[right]] = i
        heapq.heappush(heap, (w[i], i))

    return cost


def solve() -> None:
    data = iter(map(int, sys.stdin.buffer.read().split()))
    n, m = next(data), next(data)
    a = [next(data) for _ in range(n)]

    runs = same_sign_runs(a)
    positive = [i for i, (sign, _) in enumerate(runs) if sign]
    if m == 0 or not positive:        # 一段都取不了,或根本没有正段
        print(0)
        return

    total = sum(value for sign, value in runs if sign)  # 取走所有正段
    if len(positive) <= m:
        print(total)
        return

    lo, hi = positive[0], positive[-1]  # 首尾的负段并进来只会白亏,先剪掉
    # 正段记"删掉它的代价",负段记"并过它的代价",于是问题变成路径上取 T 个互不相邻的点
    path = [value if sign else -value for sign, value in runs[lo:hi + 1]]
    print(total - min_merge_cost(path, len(positive) - m))


if __name__ == "__main__":
    solve()

C++ 版(同一算法):

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-06 17:03
 * update_at: 2026-10-06 17:03
 */
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
const int MAXS = 2 * MAXN + 5; // 链上点数最多 2K-1,K 为压缩后的正段数

typedef long long ll;

ll n, m;
ll a[MAXN];
ll seg[MAXN]; // 压缩后的段和:正段为正,负段为负,0 已被丢弃

ll w[MAXS];      // 链上点权,下标 1..s,0 与 s+1 是 +INF 哨兵
ll prv[MAXS];    // 双向链表前驱
ll nxt[MAXS];    // 双向链表后继
char dead[MAXS]; // 懒删除标记,只有 0/1,用 char 控制内存

const ll INF = 1000000000000000LL; // 1e15:远大于任何合法代价之和,且相加不会溢出

priority_queue<pair<ll, int>, vector<pair<ll, int> >, greater<pair<ll, int> > > heap;

// 在链上选恰好 t 个两两不相邻的点,返回最小点权和(堆 + 双向链表反悔贪心)。
// 每步取当前最小点 i,再让 i 原地变成左右邻居的"后悔药":
// 删掉左右邻居,令 w[i] = w[left] + w[right] - w[i] 放回堆,
// 于是再选一次 i 的净代价变成 w[left] + w[right],等价于撤销本次并改选左右两点。
ll min_nonadjacent_sum(int s, ll t) {
    w[0] = INF;
    w[s + 1] = INF;
    prv[0] = 0;         // 左哨兵指向自己,反悔到左边界时不会读到越界下标
    nxt[s + 1] = s + 1; // 右哨兵同理
    for (int i = 1; i <= s; i++) {
        prv[i] = i - 1;
        nxt[i] = i + 1;
    }
    nxt[0] = 1;
    prv[s + 1] = s;
    for (int i = 0; i <= s + 1; i++) {
        dead[i] = 0;
    }
    while (!heap.empty()) {
        heap.pop();
    }
    for (int i = 1; i <= s; i++) {
        heap.push(make_pair(w[i], i));
    }

    ll cost = 0;
    for (ll step = 1; step <= t; step++) {
        pair<ll, int> cur = heap.top();
        heap.pop();
        ll value = cur.first;
        int i = cur.second;
        while (dead[i]) { // 已被左右邻居吞并的历史记录
            cur = heap.top();
            heap.pop();
            value = cur.first;
            i = cur.second;
        }
        cost += value;
        int left = prv[i];
        int right = nxt[i];
        dead[left] = 1;
        dead[right] = 1;
        w[i] = w[left] + w[right] - value;
        prv[i] = prv[left];
        nxt[i] = nxt[right];
        nxt[prv[left]] = i;
        prv[nxt[right]] = i;
        heap.push(make_pair(w[i], i));
    }
    return cost;
}

void solve() {
    // 1. 压缩:跳过 0,把同号连续元素合并成符号交替的段
    int cnt = 0;
    int i = 1;
    while (i <= n) {
        if (a[i] == 0) {
            i++;
            continue;
        }
        int positive = (a[i] > 0) ? 1 : 0;
        ll sum = 0;
        while (i <= n) {
            if (a[i] == 0) { // 0 不影响和,直接跳过,不打断同号段
                i++;
                continue;
            }
            if (((a[i] > 0) ? 1 : 0) != positive) {
                break;
            }
            sum += a[i];
            i++;
        }
        cnt++;
        seg[cnt] = sum;
    }

    // 2. 统计正段个数、正段总和,以及首尾正段的下标
    ll total = 0;
    int pos_cnt = 0;
    int first_pos = -1;
    int last_pos = -1;
    for (int j = 1; j <= cnt; j++) {
        if (seg[j] > 0) {
            total += seg[j];
            pos_cnt++;
            if (first_pos == -1) {
                first_pos = j;
            }
            last_pos = j;
        }
    }

    // 3. 平凡情形:一段都取不了,或段数不超过 M,全部正段都取
    if (m == 0 || pos_cnt == 0) {
        cout << 0 << "\n";
        return;
    }
    if (pos_cnt <= m) {
        cout << total << "\n";
        return;
    }

    // 4. 建链:从第一个正段到最后一个正段,正段记删除代价,负段记并过代价。
    //    需要在全部正段里做 t = pos_cnt - m 次操作,对应链上取 t 个不相邻的点。
    int s = 0;
    for (int j = first_pos; j <= last_pos; j++) {
        s++;
        if (seg[j] > 0) {
            w[s] = seg[j];
        } else {
            w[s] = -seg[j];
        }
    }
    ll t = pos_cnt - m;
    cout << total - min_nonadjacent_sum(s, t) << "\n";
}

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

    cin >> n >> m;
    for (ll i = 1; i <= n; i++) {
        cin >> a[i];
    }

    solve();

    return 0;
}

复杂度

  • 时间复杂度:O(N+Klog⁡K)O(N + K \log K)。压缩与建链各扫一遍 O(N)O(N);堆的初始化为 O(K)O(K),每次取点、懒删除、放回都是 O(log⁡K)O(\log K),共 T⩽KT \leqslant K 次。N=K=105N = K = 10^5 时约 10510^5 次堆操作,实测每个测例最慢 0.090.09s,远低于 10001000ms。
  • 空间复杂度:O(N)O(N):段数组、链的点权、prv/nxt 指针表各 O(K)O(K);实测峰值约 4040MB,低于 128128MB 限制。

总结

  • 全取正段是"免费"的贪心上界 SS,剩下的问题只有"让正段数减少 K−MK - M 个需要付多少代价"。
  • 每次"删一个正段"或"越过一个负段"是链上相邻的两个点,冲突结构是链而不是任意图,于是可以用堆 + 双向链表 + 反悔(wl+wr−wiw_l + w_r - w_i)在 O(Klog⁡K)O(K \log K) 内求出恰好 TT 个不相邻点的最小权和。
  • 两个易错点:M=0M = 0 或全负时要输出 00;首尾的负段不会出现在链上,不能给它们留反悔位置,两端用 +∞+\infty 哨兵封住。