登录日志 题解
一、题意
n 条日志,第 i 条为 \((t_i, b_i)\):\(t_i = 1\) 表示登录,\(t_i = 2\) 表示退出。对已在线者登录、对已离线者退出,均不改变账号状态。
\(q\) 个询问 \((l, r)\):从全离线 状态开始重放第 \(l\) 到第 \(r\) 条日志,求「执行前 + 每条执行后」共 \(r-l+2\) 个时刻中,同时在线的账号数的最大值。
二、核心思想
1. 历史最值 → 区间最值
询问要的是历史最大 在线人数,而不是「执行到 \(r\) 时」的在线人数。
例如「登录 A → 退出 A」:终态人数为 \(0\),但历史最大值为 \(1\)。
因此只固定右端点 \(r\) 维护终态会丢掉峰值 。必须固定左端点 \(l\) ,定义从全离线起始、重放第 \(l\) 到第 \(t\) 条日志之后的在线账号数:
\(f_l(t), \quad t \in l-1,\\, n,\)
并规定 \(f_l(l-1) = 0\)。于是
\\\operatorname{Ans}(l, r) = \\max_{t \\in \[l-1,\\, r} f_l(t). \]
历史最值问题,转化为时刻轴上的区间最大值问题。
对比 HH 的项链:那里种类数只增不减;本题存在退出操作,在线人数会减少,不能直接套用。
2. 定一求一(固定左端点,扫描另一端)
两个端点都在动,直接做必然超时。定住左端点 \(l\):
- 询问按 \(l\) 分组:\(qsl = \{(r, id), \dots\}\)。
- 让 \(l\) 从 \(n\) 到 \(1\) 倒序扫描 ,使线段树中始终存放整行 \(f_l(\cdot)\)。
- 扫到 \(l\) 时,一次性回答所有左端点为 \(l\) 的询问。
为什么定左不定右 :定右端点只能看到终态,会丢掉峰值;定左端点则整行 \(f_l(\cdot)\) 都能保留,任意时刻 \(t\) 的叶子值就是当前在线人数。
3. 在线区间并集
固定 \(l\) 后,每个账号在线的时刻构成若干区间的并集 (同一账号在同一时刻只贡献一次)。\(f_l(t)\) 表示「时刻 \(t\) 被多少个账号的在线区间并集覆盖 」,不是「并集长度」。
同一账号在位置 \(p\) 登录、下一次事件在 \(q\),则这次登录覆盖 \(p, q-1\)。无论 \(q\) 是登录还是退出,都会强制确定该账号的状态 ,因此只需用同账号日志的前驱 / 后继界定区间,nxt 不区分类型。
三、差分引理
设 nxt[i] 为账号 \(b_i\) 在位置 \(i\) 之后 第一条日志的下标(不存在时取 \(n+1\))。则
\f_l(t) - f_{l+1}(t) = \\begin{cases} 1, \& t_l = 1 \\land l \\le t \< nxt_l, \\\\ 0, \& \\text{otherwise}. \\end{cases} \\
其中 \(t_l = 1\) 表示第 \(l\) 条是登录,\(\land\) 表示「且」。
注 :起点从 \(l+1\) 左移到 \(l\),只会影响第 \(l\) 条日志所属账号这一个账号。
- 新增登录 :该账号原本离线,\(l, nxt_l-1\) 内在线人数 +1;
- 新增退出 :与原本离线等价,不修改;
- 从 \(nxt_l\) 起,下一次事件让两条重放链重新一致。
要点 :nxt 的定义不区分日志类型,因此预处理时登录、退出都要更新 lst。
四、算法
在时刻轴 \(t \in 0, n\) 上建线段树,叶子存 \(f_l(t)\),初始全 0(对应空区间 \(f_{n+1} \equiv 0\))。
按 \(l = n, n-1, \dots, 1\) 倒序:
- 若 \(t_l = 1\):执行
change(1, l, nxt[l] - 1, +1);否则不动。 - 对每个 \(qsl\) 中的询问:执行
ans[id] = query(1, l, r)。
顺序必须先修改后回答 ------此时叶子才恰好代表 \(f_l\)。
预处理 nxt(倒序,lst[x] 记账号 \(x\) 最近出现的位置):
五、时间复杂度
| 步骤 | 复杂度 |
|---|---|
预处理 nxt |
\(O(n)\) |
| 每条登录一次区间加 | \(O(n \log n)\) |
| 每个询问一次区间查最大 | \(O(q \log n)\) |
| 总计 | \(O((n+q) \log n)\) |
空间复杂度 \(O(n+q)\)。
六、AC code
cpp
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 3e5 + 5;
ll n, q;
ll t[N], b[N];
ll nxt[N], lst[N];
ll ans[N];
struct node {
ll r, id;
};
struct NODE {
ll l, r;
ll maxv, tag;
};
NODE tr[N * 4];
void pushdown (int p) {
if (tr[p].tag) {
tr[p * 2].maxv += tr[p].tag;
tr[p * 2 + 1].maxv += tr[p].tag;
tr[p * 2].tag += tr[p].tag;
tr[p * 2 + 1].tag += tr[p].tag;
tr[p].tag = 0;
}
}
void pushup (int p) {
tr[p].maxv = max (tr[p * 2].maxv, tr[p * 2 + 1].maxv);
}
void build (int p, int l, int r) {
tr[p].l = l, tr[p].r = r;
tr[p].maxv = 0, tr[p].tag = 0;
if (l == r) return;
int mid = (l + r) / 2;
build (p * 2, l, mid);
build (p * 2 + 1, mid + 1, r);
}
void change (int p, int l, int r) {
if (tr[p].l >= l && tr[p].r <= r) {
tr[p].maxv += 1;
tr[p].tag += 1;
return;
}
pushdown (p);
int mid = (tr[p].l + tr[p].r) / 2;
if (l <= mid) change (p * 2, l, r);
if (r > mid) change (p * 2 + 1, l, r);
pushup (p);
}
ll query (int p, int l, int r) {
if (tr[p].l >= l && tr[p].r <= r) return tr[p].maxv;
pushdown (p);
int mid = (tr[p].l + tr[p].r) / 2;
ll res = 0;
if (l <= mid) res = max (res, query (p * 2, l, r) );
if (r > mid) res = max (res, query (p * 2 + 1, l, r) );
return res;
}
int main () {
freopen("login.in", "r", stdin);
freopen("login.out", "w", stdout);
ios::sync_with_stdio (false);
cin.tie (0);
cin >> n >> q;
for (int i = 1; i <= n; i ++) {
cin >> t[i] >> b[i];
}
vector<vector<node> > que (n + 1);
for (int i = 1; i <= q; i ++) {
ll l, r;
cin >> l >> r;
que[l].push_back ({ r, i });
}
for (int i = n; i >= 1; i --) {
if (!lst[b[i]]) nxt[i] = n + 1;
else nxt[i] = lst[b[i]];
lst[b[i]] = i;
}
build(1, 1, n);
for (int l = n; l >= 1; l --) {
if (t[l] == 1) change (1, l, nxt[l] - 1);
for (auto &[r, id] : que[l]) {
ans[id] = query (1, l, r);
}
}
for (int i = 1; i <= q; i ++) cout << ans[i] << '\n';
return 0;
}
总结
-
历史最值转区间最值;
-
定一求一按左端点扫描;
-
在线区间并集