感觉这次题目整体上说还算比较友好,简单整理了一下我的解题思路,希望我不太严谨的语言能被大家理解。
给定一个长度为 N N N 的整数列 { A n } \{A_n\} {An},对于 { A n } \{A_n\} {An} 中出现的每种不同 x x x,按照它们出现的顺序依次输出它们的值以及它们的出现次数。
时间限制 1 s 1s 1s,空间限制 64 M B 64MB 64MB, N ≤ 5 × 1 0 4 , 1 ≤ A i ≤ 2 × 1 0 9 N\leq 5\times 10^4, 1\leq A_i\leq 2\times 10^9 N≤5×104,1≤Ai≤2×109。
这道题的做法比较多,听说有同学用 STL map 过了。关于这道题我想到了两种思路,第一种思路是离散化——把 x x x 的值域映射到 [ 1 , N ] [1,N] [1,N]中便于统计答案,第二种思路是我考试时的做法。
#include
#include
using namespace std;
const int maxn = 50000 + 10;
int A[maxn], cnt[maxn];
/// 如果位置 i 是 A[i] 在 A 数组 中第一次出现,cnt[i] 等于 A[i] 在 A 数组中出现的次数
/// 否则 cnt[i] = 0
struct node {
int val, pos;
} B[maxn];
bool cmp(node A, node B) { /// 以val为第一关键字 pos 为第二关键字排序
if(A.val != B.val) return A.val < B.val;
return A.pos < B.pos;
}
int main() {
int n; scanf("%d", &n);
for(int i = 1; i <= n; i ++) { /// 输入 A 数组,并构建 B 数组
scanf("%d", &A[i]);
B[i].val = A[i];
B[i].pos = i;
}
sort(B+1, B+n+1, cmp);
/// 对 B 数组排序后不难发现 val 原先 A[i] 中相同的值被放置在了相邻的位置上
/// 而且按照 pos 从小到大有序
/// B 数组 按照 val 确定了若干个连续段,其中第一个位置的 pos 值就是 val 在 A 数组中第一次出现的位置
int frnt = 0;
/// frnt 是 front 的意思,用来记录每种 val 在 A 数组中第一次出现的位置
for(int i = 1; i <= n; i ++) {
if(B[i].val != B[i-1].val) {
frnt = B[i].pos; /// 一个新的连续段从 B[i] 开始
}
cnt[frnt] ++;
/// 将答案统计到 cnt[frnt] 中
/// 这与我们前文中对 cnt 含义的定义相一致
}
for(int i = 1; i <= n; i ++) {
if(cnt[i] != 0) {
printf("%d %d\n", A[i], cnt[i]);
}
}
return 0;
}
三种做法中 用 STL map 的做法是最直观、最简洁的:
#include
#include
using namespace std;
const int maxn = 50000 + 5;
int A[maxn];
map<int, int> cnt; /// cnt[x] 记录 数值 x 在 A 数组中出现的次数
int main() {
int n; scanf("%d", &n);
for(int i = 1; i <= n; i ++) {
scanf("%d", &A[i]);
cnt[A[i]] ++; /// 累加答案
}
for(int i = 1; i <= n; i ++) {
if(cnt[A[i]] != 0) {
printf("%d %d\n", A[i], cnt[A[i]]);
cnt[A[i]] = 0;
/// 每当我输出了一个 cnt 时 我们就将该位置 的 cnt 值清空
/// 以保证对于每种数值 x,它的出现次数只被输出一次
/// 不难说明,我们的输出顺序一定与题面要求的一致
}
}
return 0;
}
离散化的做法,对于没接触过离散化概念的同学来说可能不是很好理解,不过代码实现也是非常简单的。
#include
#include
using namespace std;
const int maxn = 50000 + 5;
int A[maxn], B[maxn], val[maxn], cnt[maxn];
/// A[i] 中最终储存经过了 值域缩小映射 后的数据
/// B[i] 是离散化过程中的辅助数组
/// val[x] 表示 离散编码 x 对应的真实值
/// cnt[x] 表示 离散编码 x 在 最终的 A 数组中出现的次数
int main() {
int n; scanf("%d", &n);
for(int i = 1; i <= n; i ++) {
scanf("%d", &A[i]);
B[i] = A[i];
}
sort(B+1, B+n+1);
for(int i = 1; i <= n; i ++) {
int pos = lower_bound(B+1, B+n+1, A[i]) - B;
/// 找到 A[i] 在 B 中第一次出现的位置
/// 作为 A[i] 离散化后的值(离散编码)
/// 不难证明这种方式得到的编码一定位于 [1,n] 中
val[pos] = A[i]; /// 记录逆映射,以便于输出答案
A[i] = pos; /// 将 A 数组修改为离散化后的值
cnt[pos] ++; /// 统计 离散值出现的次数
}
for(int i = 1; i <= n; i ++) {
if(cnt[A[i]] != 0) {
printf("%d %d\n", val[A[i]], cnt[A[i]]);
cnt[A[i]] = 0;
/// 与 map 方法同理,将 cnt 赋值为 0 用来防止重复输出
}
}
return 0;
}
约瑟夫问题。 n n n 个人围成一圈,从 1 1 1 开始报数,每次报到数 m m m 的人出圈,下一个人从 1 1 1 开始继续报数,直到圈中没有人为止。题目要求,按照出圈的先后次序输出每个人的编号。
最直观的做法莫过于环状链表模拟了,考试的时候我使用的是静态链表的实现方式:
#include
const int maxn = 50000 + 10;
int nxt[maxn], lst[maxn];
/// nxt[i] 表示结点 i 的下一个结点的编号
/// lst[i] 表示结点 i 的上一个结点的编号
int main() {
int n, m; scanf("%d%d", &n, &m);
/// 初始化:构建长度为 n 的环
for(int i = 1; i <= n; i ++) {
lst[i] = i-1;
nxt[i] = i+1;
}
lst[1] = n;
nxt[n] = 1;
/// 模拟出圈过程,没沿着 nxt 值前进 m 步就输出一次
/// 并将该点从链表中摘下
int pos = n;
while(n --) {
for(int i = 1; i <= m; i ++) {
pos = nxt[pos];
}
if(n != 0)
printf("%d ", pos);
else
printf("%d", pos);
nxt[lst[pos]] = nxt[pos];
lst[nxt[pos]] = lst[pos];
/// 跳舞操作将 pos 从链表中摘下
}
return 0;
}
使用 n 作为 pos 的初始值,使用跳舞操作摘除链表结点,我个人认为还是挺有趣的技巧的。
不难证明,该算法的时间复杂度为 O ( n m ) O(nm) O(nm)。
线段树优化全域第 K 大查询问题。
不难发现,约瑟夫问题每次出圈都子问题可以规约为一个这样的问题:给定一个整数 k,找到当前圈中人编号集合中第 k 大的那个人,并让他出圈。——而这个过程恰好可以使用权值线段树优化至单次修改/查询 O ( log n ) O(\log n) O(logn)。
#include
#define lch(x) ((x)<<1)
#define rch(x) (lch(x)|1)
/// 堆式构图
/// lch(x) 表示结点 x 的左子的编号
/// rch(x) 表示结点 x 的右子的编号
namespace segT {
const int maxn = 50000 * 4 + 10;
int sum[maxn];
/// sum[x] 表示结点 x 对应的区间和
void maintain(int rt) {
sum[rt] = sum[lch(rt)] + sum[rch(rt)];
/// 维护线段树结点 rt 的区间和的正确性
}
void build(int rt, int L, int R) {
/// 构建线段树
/// 以编号为 rt 的结点为根 构建值域区间 [L, R] 的线段树
if(L == R) {
sum[rt] = 1;
/// 叶子节点与每个人一一对应
/// sum[rt] = 0 表示这个叶子节点对应的人已经出圈
/// sum[rt] = 1 表示这个叶子结点对应的人还没有出圈
}else {
int mid = (L + R) >> 1;
build(lch(rt), L, mid);
build(rch(rt), mid+1, R);
maintain(rt);
/// 递归构建左子树和右子树并维护根节点信息的正确性
/// sum[rt] 表示以 rt 为根节点的值域区间中的存活人数
}
}
int seg(int rt, int L, int R, int l, int r) {
/// 计算区间和
/// rt 对应的区间是[L, R] 查询区间为 [l, r]
if(r < L || R < l) return 0; /// 查询区间与当前区间没有交集
if(l <= L && R <= r) return sum[rt]; /// 查询区间包含了当前区间
int mid = (L + R) >> 1;
return seg(lch(rt), L, mid, l, r) + /// 查询区间与当前区间相交
seg(rch(rt), mid+1, R, l, r);
}
void del(int rt, int L, int R, int x) {
/// 删除一个数
if(L == R && L == x) {
/// 递归到对应的叶子节点将 sum 赋值为 0 即可
sum[rt] = 0;
}else {
int mid = (L + R) >> 1;
if(x <= mid) del(lch(rt), L, mid, x);
else del(rch(rt), mid+1, R, x);
maintain(rt);
}
}
int kth(int rt, int L, int R, int k) {
/// 找到树 rt 中的第 k 大值
if(L == R) {
return L; /// 递归到达了叶子节点,成功找到了 第 k 大值
}else {
int lsiz = sum[lch(rt)]; /// 左子树中的存货人数为 lsiz
int mid = (L + R) >> 1;
/// 如果我要查询的 k 小于等于 lsiz 证明第 k 大元素对应的叶子节点在 左子树中
/// 否则说明我要查询的 元素 对应的叶子节点在右子树中
/// 在右子树中递归查找第 k-lsiz 大值即可
if(k <= lsiz) return kth(lch(rt), L, mid, k);
else return kth(rch(rt), mid+1, R, k-lsiz);
}
}
}
int main() {
int n, m; scanf("%d%d", &n, &m);
segT::build(1, 1, n);
/// 构建叶节点权值均为 1 线段树
int right = 0; /// left 表示上次删除的元素右侧的元素个数
int last = 0; /// 上一次输出的元素的值
for(int i = n; i >= 1; i --) {
int id = (m - 1)%i + 1; /// 计算下一个被删除当前所有元素中第几个元素
int val;
if(id <= right) { /// 说明下一个出圈的人编号比上一次出圈的人大
int left = segT::seg(1, 1, n, 1, last);
int pos = left + id;
val = segT::kth(1, 1, n, pos);
printf("%d", val);
segT::del(1, 1, n, val);
}else { /// 说明下一个出圈的人编号比上一次出圈的人小
id -= right;
val = segT::kth(1, 1, n, id);
printf("%d", val);
segT::del(1, 1, n, val);
}
if(i != 1) {
putchar(' ');
}
right = segT::seg(1, 1, n, val, n);
/// right 记录当前圈中 还有多少个人 的编号比当前出圈的人大
last = val;
/// last 记录上一次出圈的人的编号
}
return 0;
}
这种做法的时间复杂度为 O ( n log n ) O(n\log n) O(nlogn)。
冥思苦想了好久,也没想出来 O ( n ) O(n) O(n) 的做法,只想出了一种 O ( n ) O(n) O(n) 计算最后一个出圈人编号的做法。这个算法比较简单,估计同学们应该都能想到。
用 f ( n ) f(n) f(n) 表示, n n n 个人的约瑟夫问题,最后一个出圈的人的编号,则有递推式:
f ( n ) = { 1 , n = 1 f ( n − 1 ) + T ( n , m ) , f ( n − 1 ) + T ( n , m ) ≤ n f ( n − 1 ) + T ( n , m ) − n , f ( n − 1 ) + T ( n , m ) > n ( ∗ ) f(n)= \left\{ \begin{aligned} 1,\space& n=1 &\\ f(n-1)+T(n, m),\space&f(n-1)+T(n, m) \leq n &\\ f(n-1)+T(n, m)-n, \space& f(n-1) +T(n, m) > n &\\ \end{aligned} \right.(*) f(n)=⎩⎪⎨⎪⎧1, f(n−1)+T(n,m), f(n−1)+T(n,m)−n, n=1f(n−1)+T(n,m)≤nf(n−1)+T(n,m)>n(∗)
其中 T ( n , m ) = ( m − 1 ) m o d n + 1 T(n, m)=(m-1)\mod n + 1 T(n,m)=(m−1)modn+1。
首先,不难证明, n n n 个人中第一个出圈的人的编号为 T ( n , m ) T(n, m) T(n,m)。第一个人出圈后,对整个圈上的人按照如下做法进行重新编号:
原编号: 1 , 2 , 3 , 4 ⋯ , T ( n , m ) , T ( n , m ) + 1 , ⋯ n 1,2,3,4 \cdots,T(n,m),T(n,m)+1,\cdots n 1,2,3,4⋯,T(n,m),T(n,m)+1,⋯n
新编号: n − T ( n , m ) + 1 , n − T ( n , m ) + 2 ⋯ , 0 , 1 , ⋯ n − T ( n , m ) n-T(n, m)+1, n - T(n, m) + 2\cdots, 0, 1,\cdots n-T(n, m) n−T(n,m)+1,n−T(n,m)+2⋯,0,1,⋯n−T(n,m)
给出编号规则的形式化表述,记 V n ( x ) V_n(x) Vn(x) 为原编号为 x x x 的人的新编号,则有:
V n ( x ) = { n − T ( n , m ) + i , i < T ( n , m ) i − T ( n , m ) , i ≥ T ( n , m ) V_n(x)= \left\{ \begin{aligned} n-T(n, m)+i,& i < T(n, m)\\ i-T(n, m), & i \geq T(n, m) \end{aligned} \right. Vn(x)={n−T(n,m)+i,i−T(n,m),i<T(n,m)i≥T(n,m)
从外观上看,我们把整个环从 T ( n , m ) + 1 T(n, m)+1 T(n,m)+1 开始从 1 1 1 重新开始了一轮编号。因此,当 n > 1 n > 1 n>1 时, f ( n ) = V n − 1 ( f ( n − 1 ) ) f(n)=V_n^{-1}(f(n-1)) f(n)=Vn−1(f(n−1)),其中 V n − 1 V_n^{-1} Vn−1 是 V n V_n Vn 的反函数。我们解出 V n − 1 V_n^{-1} Vn−1 就得到了上文中的 ( ∗ ) (*) (∗) 式。画图解释可能更加直观。
但是这种做法只能求出 n n n 个人中最后一个出圈的人的编号,我试图找到一种 O ( n ) O(n) O(n) 的能求出整个出圈序列的算法,但是没有找到。希望有相关思路的同学不吝赐教。
给出一个 长度不超过一千个字符的 以等号为结尾的 不含空格的 算术表达式,求算术表达式的值。要求将计算过程中的除法视为整数除法,计算过程的中间结果按照 int类型自然溢出。
首先想到了功能强大的 python。直接写了个 python3 程序交了上去。
exp = input().split("=")[0] # 删去结尾的等号
exp = exp.replace("/", "//") # 很关键,将除号替换为整数除号
try:
ans = eval(exp) # 计算表达式的值并输出答案
print(ans)
except: # 如果计算出错说明出现了 0/0 的情况
print("NaN")
但是很不幸的是,题目要求计算过程的中间结果按照 int 类型自然溢出,但是 python 自带高精度,所以这种做法很不幸只能得到 90 分。
双栈中缀表达式求值,这个我要是没记错的话,貌似是课上的原题吧。
#include
#include
#include
#include
using namespace std;
char A[1000 + 10];
int now = 0;
stack<int> ans;
stack<char> ope;
int degree(char c) { /// 返回算符的有限级
if(c == '(') return 0; /// ope 单调递增
if(c == '+' || c == '-') return 1;
if(c == '*' || c == '/') return 2;
}
int cal(int x, char c, int y) { /// 进行一次运算
if(c == '+') return x + y;
if(c == '-') return x - y;
if(c == '*') return x * y;
if(c == '/') {
if(y == 0) {
printf("NaN");
exit(0);
}
return x / y;
}
}
void solve() {
while(true) {
char c = A[now]; /// 读入一个符号
if(c == '=') {
while(ope.size()) {
int y, x;
char p = ope.top(); ope.pop();
y = ans.top(); ans.pop();
x = ans.top(); ans.pop();
ans.push(cal(x, p, y));
}
printf("%d", ans.top());
exit(0);
}else if('0'<=c && c<='9'){ /// 处理整数
int tmp = c - '0';
for(now ++; '0'<=A[now] && A[now]<='9'; now ++) {
tmp = tmp * 10 + A[now] - '0';
}
ans.push(tmp);
}else if(c == '(') { /// 左括号
ope.push(c);
now ++;
}else if(c != ')') { /// 算符
while(ope.size() && degree(ope.top()) >= degree(c)) {
/// 保证单调栈中算符优先级单调递增
int y, x;
char p = ope.top(); ope.pop();
y = ans.top(); ans.pop();
x = ans.top(); ans.pop();
ans.push(cal(x, p, y));
}
ope.push(c);
now ++;
}else { /// 右括号
while(ope.top() != '(') {
int y, x;
char p = ope.top(); ope.pop();
y = ans.top(); ans.pop();
x = ans.top(); ans.pop();
ans.push(cal(x, p, y));
}
ope.pop();
now ++;
}
}
}
int main() {
scanf("%s", A); /// 输入表达式
solve();
return 0;
}
具体代码实现中细节挺多的,只要设计合理,考虑全面,还是可以实现的。
给定一个长度为 n n n 的数列 { A n } \{A_n\} {An},计算数列中所有长度在 [ 1 , m ] [1, m] [1,m] 范围内的子串的和的最大值,并输出这个子串的两端点的下标。如果有多个子串的和相同(都为子串和的最大值),则输出最靠左的一个。 n ≤ 5 × 1 0 5 , ∣ A i ∣ ≤ 1 0 3 n\leq 5\times 10^5, |A_i| \leq 10^3 n≤5×105,∣Ai∣≤103。
我们记:
p r e ( x ) = ∑ i = 1 x A i pre(x) = \sum_{i=1}^xA_i pre(x)=i=1∑xAi
特殊地, p r e ( 0 ) = 0 pre(0)=0 pre(0)=0。
则有,区间 [ L , R ] [L, R] [L,R] 的区间和为:
p r e ( R ) − p r e ( L − 1 ) pre(R) - pre(L-1) pre(R)−pre(L−1)
对于一个给定的 R R R,我们考虑以 R R R 为右端点的所有合法区间(即长度小于等于 m m m)中,哪个区间的的区间和最大。显然, p r e ( L − 1 ) pre(L-1) pre(L−1) 当然是越小越好,我们在所有合法的 L L L 中选择 p r e ( L − 1 ) pre(L - 1) pre(L−1) 最小的那个即可,如果您已经理解这一点,下面这段证明没有必要看:
我们记这个最区间和最大的合法区间的左端点为 T ( R ) T(R) T(R)
T ( R ) = arg max max ( R − m + 1 , 1 ) ≤ L ≤ R { p r e ( R ) − p r e ( L − 1 ) } T(R)=\argmax_{\max(R-m+1,1)\leq L \leq R}\{pre(R)-pre(L-1)\} T(R)=max(R−m+1,1)≤L≤Rargmax{pre(R)−pre(L−1)}
这样的表述,不是很直观。我们以 L − 1 L-1 L−1 为参数,可以得到:
T ( R ) − 1 = arg max max ( R − m , 0 ) ≤ t ≤ R − 1 { p r e ( R ) − p r e ( t ) } T(R)-1=\argmax_{\max(R-m, 0)\leq t \leq R-1}\{pre(R)-pre(t)\} T(R)−1=max(R−m,0)≤t≤R−1argmax{pre(R)−pre(t)}
不难看出,当 R R R 一定时, p r e ( R ) pre(R) pre(R) 与 t t t 无关,是一个常量,即有:
T ( R ) − 1 = arg max max ( R − m , 0 ) ≤ t ≤ R − 1 { − p r e ( t ) } = arg min max ( R − m , 0 ) ≤ t ≤ R − 1 { p r e ( t ) } T(R)-1=\argmax_{\max(R-m, 0)\leq t \leq R-1}\{-pre(t)\}=\argmin_{\max(R-m, 0)\leq t \leq R-1}\{pre(t)\} T(R)−1=max(R−m,0)≤t≤R−1argmax{−pre(t)}=max(R−m,0)≤t≤R−1argmin{pre(t)}
因此,对于 p r e pre pre 数组上的每一个长度为 m m m 的区间,如果我们都能找到这个区间的最小值在这个区间上最靠左的一次出现位置,我们就能对于任意的 R R R 给出它所对应的 T ( R ) T(R) T(R) 了。而这个问题可以用单调队列 O ( n ) O(n) O(n) 解决。
#include
#include
#include
using namespace std;
const int maxn = 500000 + 6;
typedef long long ll;
ll A[maxn], pre[maxn]; /// pre[x] = A[1] + A[2] + ... + A[x]
deque<int> pos;
int main() {
int n, m; scanf("%d%d", &n, &m);
for(int i = 1; i <= n; i ++) {
scanf("%lld", &A[i]);
pre[i] = pre[i-1] + A[i]; /// 递推计算 pre[x]
}
ll ans = -0x7f7f7f7f7f7f7f7fLL; /// ans 记录当前求得的最大区间和 ,初始化为负无穷
int L, R; /// L, R 记录当前最大区间和对应的区间端点
for(int i = 0; i < n; i ++) {
/// 计算以 i + 1 为区间右端点的所有合法区间的最大区间和
/// 我们需要维护 pre[i - m + 1] ~ pre[i] 中最小值所在的位置
while(pos.size() && pre[pos.back()] > pre[i]) {
/// 这里不应写 pre[pos.back()] >= pre[i] (虽然写了也能 AC 此题)
/// 但是这是因为数据不够强
pos.pop_back();
}
while(pos.size() && pos.front() < i - m + 1) {
/// 从前端弹出超过滑动窗口的宽度的位置
/// 由于我们这里的 pos.front() 本质上是区间左端点左侧的那个元素
/// 因此 它一定大于等于 i - m + 1
pos.pop_front();
}
pos.push_back(i); /// 将 pre[i] 压入栈中
int tmp = pre[i+1] - pre[pos.front()];
/// 此时 pre.front() 就是上文中定义的最有左端点的左侧位置 T(i+1) - 1
if(tmp > ans) { /// 统计答案即可
ans = tmp;
L = pos.front() + 1;
R = i + 1;
}
}
printf("%lld %d %d", ans, L, R);
return 0;
}
个人感觉这个问题好像不太能用动态规划做,感觉看到一些同学写了贪心算法,甚至有的还通过了此题,感觉应该是能卡掉的。另外就是一些实现上的细节,要想好队列中储存的是 T ( R ) T(R) T(R) 还是 T ( R ) − 1 T(R)-1 T(R)−1,不然在编程时很容易出现逻辑混乱。
更重要的一点是如何保证,我给出的算法一定能找到所谓的“最靠左的解”。我们对左给出如下定义:
也就是说,当我们的程序输入了如下数据时:
6 6
-10 -1 5 -4 -1 6
我们的输出应该是:
6 3 6
而不是:
6 6 6
由于我们的单调队列维护的是单调不下降序列,而不是单调递增序列,我们能够证明对于同一个 R R R 我们给出的 T ( R ) T(R) T(R) 一定是最靠左的。观察程序中的这一段代码:
if(tmp > ans) { /// 统计答案即可
ans = tmp;
L = pos.front() + 1;
R = i + 1;
}
只有当我们找到了一个严格小于当前最优解的 tmp 时,我们才会用它更新当前最优解 ans。倘若存在 [ L 1 , R 1 ] [L_1, R_1] [L1,R1] 与 [ L 2 , R 2 ] [L_2, R_2] [L2,R2] (其中 [ L 1 , R 1 ] [L_1, R_1] [L1,R1] 是当前最优解, [ L 2 , R 2 ] [L_2, R_2] [L2,R2] 是以 R 2 R_2 R2 为右端点的最优区间)满足如下两个条件:
我的程序是不是就可能给出一个错误的结果呢?答案是否定的,证明如下:
为了表述方便,我们记:
s u m ( L , R ) = p r e ( R ) − p r e ( L − 1 ) = ∑ i = L R A [ i ] sum(L, R)=pre(R)-pre(L-1)=\sum_{i=L}^RA[i] sum(L,R)=pre(R)−pre(L−1)=i=L∑RA[i]
即 [ L , R ] [L, R] [L,R] 的区间和,此时有:
s u m ( L 2 , R 2 ) − s u m ( L 1 , R 1 ) = 0 sum(L_2, R_2) - sum(L_1, R_1) = 0 sum(L2,R2)−sum(L1,R1)=0
也就是:
s u m ( L 2 , L 1 − 1 ) + s u m ( R 1 + 1 , R 2 ) = 0 sum(L_2, L_1-1) + sum(R_1+1, R_2)=0 sum(L2,L1−1)+sum(R1+1,R2)=0
不过如果您要是不喜欢数学证明,更不喜欢看数学证明的话,给您推荐两条路: