文章目录

‹
›

登录日志 题解

登录日志 题解

一、题意

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\):

  1. 询问按 \(l\) 分组:\(qs[l] = \{(r, id), \dots\}\)。
  2. 让 \(l\) 从 \(n\) 到 \(1\) 倒序扫描,使线段树中始终存放整行 \(f_l(\cdot)\)。
  3. 扫到 \(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\) 倒序:

  1. 若 \(t_l = 1\):执行 change(1, l, nxt[l] - 1, +1);否则不动。
  2. 对每个 \(qs[l]\) 中的询问:执行 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

#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;
}


总结

  • 历史最值转区间最值;

  • 定一求一按左端点扫描;

  • 在线区间并集

posted @ 2026-10-04 20:18  lvwangshu  阅读(6)  评论(0)    收藏  举报