信息学竞赛中的一些经典思维 (题)

倍增

倍增字面上意思是:成倍地增加。当模拟一个过程时,一步一步进行太慢,考虑把模拟的步数二进制分解;经过一些预处理,每次可以模拟 \(2^i\) 步,从而达到优化复杂度的目的。
倍增主要模型有RMQ,LCA等。

例题

给出一个长度为 n 的环和一个常数 k,每次可以从第 i 个点跳到第 (i + k) mod (n+1) 个点,总共跳 m 次。第 i 个点的权值为 a[i],求 m 次跳跃的起点的权值之和 mod 1e9 + 7 。
数据范围:$ 1 ≤ n ≤ 10^6 , 1 ≤ m ≤ 10^{18} , 1 ≤ k ≤ n , 0 ≤ a[i] ≤ 10^9 $

问题分析
这里显然不能暴力模拟跳 m 次。因为 最大可到 \(10^{18}\) 级别,如果暴力模拟的话,时间承受不住。
所以就需要进行一些预处理,提前整合一些信息,以便于在查询的时候更快得出结果。如果记录下来每一个可能的跳跃次数的结果的话,不论是时间还是空间都难以承受。
倍增思想:每个数都可以表示成二进制的形式, 对于从每个点开始的 \(2^i\) 步,记录一个 go[i][x] 表示第 x 个点跳 \(2^i\) 步之后的终点,而 sum[i][x] 表示第 x 个点跳 \(2^i\) 步之后能获得的点权和。对于跳 \(2^i\) 步的信息,预处理的时候可以看作是先跳了 \(2^{i−1}\) 步,再跳了 \(2^{i−1}\) 步。
即有 \(sum[i][x] = sum[i-1][x]+sum[i-1][go[i-1][x]] ,且 go[i][x] = go[i-1][go[i-1][x]]\)
例如,从1到14的整个跳跃过程由 \(2^3 + 2^2 + 2^0\) 三步组成。也就是说,对于环上这 n 个位置,预处理出每一个位置向前跳 1, 2, 4, ... 次的位置,则必然能够到达 m。
实现代码

#include <bits/stdc++.h>
using namespace std;

const int mod = 1000000007;

int modadd(int a, int b) {
  if (a + b >= mod) return a + b - mod;  // 减法代替取模,加快运算
  return a + b;
}

int vi[1000005];

int go[75][1000005];  // 将数组稍微开大以避免越界,小的一维尽量定义在前面
int sum[75][1000005];

int main() {
  int n, k;
  scanf("%d%d", &n, &k);
  for (int i = 1; i <= n; ++i) {
    scanf("%d", vi + i);
  }

  for (int i = 1; i <= n; ++i) {
    go[0][i] = (i + k) % n + 1;
    sum[0][i] = vi[i];
  }

//int logn = 31 - __builtin_clz(n);  // 一个快捷的取对数的方法
  int logn = 65;
  for (int i = 1; i <= logn; ++i) {
    for (int j = 1; j <= n; ++j) {
      go[i][j] = go[i - 1][go[i - 1][j]];
      sum[i][j] = modadd(sum[i - 1][j], sum[i - 1][go[i - 1][j]]);
    }
  }

  long long m;
  scanf("%lld", &m);

  int ans = 0;
  int curx = 1;
  for (int i = 0; m; ++i) {
    if (m & (1 << i)) {  // 参见位运算的相关内容,意为 m 的第 i 位是否为 1
      ans = modadd(ans, sum[i][curx]);
      curx = go[i][curx];
      m ^= 1ll << i;  // 将第 i 位置零
    }
  }

  printf("%d\n", ans);
}

这题的 \(m≤10^{18}\) ,虽然看似恐怖,但是实际上只需要预处理出 65 以内的 i ,就可以轻松解决,比起暴力枚举快了很多。用行话讲,这个做法的时间复杂度是预处理 O(nlogm) ,查询每次 O(logm) 。
倍增除了作为一种独立的思想以外,还经常被应用到各种算法里面,例如 快速幂、LCA 和 RMQ 问题 。

总结: 这就是倍增预处理出以二的整数次幂为单位的信息:
*在递推中,如果状态空间很大,可以通过成倍增长的方式,只递推出状态空间在2的整数次幂的值作为代表。
*每个数都可以表示成二进制的形式,可用之前的求出的代表值拼成所需的值。
*要求这个递推问题的状态空间关于2的次幂具有可划分性。
注意:为了保证统计的时候不重不漏,一般预处理出左闭右开的点权和。

Luogu P1419 寻找段落

题目描述

给定一个长度为n的序列ai,定义a[i]为第i个元素的价值。现在需要找出序列中最有价值的“段落”。段落的定义是长度在[S,T]之间的连续序列。最有价值段落是指平均值最大的段落,段落的平均值=段落总价值/段落长度。

输入输出格式

输入格式:

第一行一个整数n,表示序列长度。
第二行两个整数S和T,表示段落长度的范围,在[S,T]之间。
第三行到第n+2行,每行一个整数表示每个元素的价值指数。

输出格式:

一个实数,保留3位小数,表示最优段落的平均值。

输入输出样例

输入样例#1:

3
2 2
3
-1
2

输出样例#1:

1.000

数据范围

对于30%的数据有n<=1000。
对于100%的数据有n<=100000,1<=S<=T<=n,-10000<=元素价值<=10000。

解题思路

可以看出所求问题的答案具有单调性,考虑二分答案,变为判定性问题。
对于可能的答案k,设b[i]=a[i]-k。
就是求b数组一段长度在s,t之间的和的最大值,判断其是否大于等于0。求区间和的最大值可以用前缀和+单调队列维护。
具体过程,贴一份洛谷题解的图:

时间复杂度

假设数据范围为A,则二分答案是O(logA)的,判断一次用了前缀和和单调队列,复杂度是O(n)的,总时间复杂度为O(nlogA)。
实现代码

#include <bits/stdc++.h>
using namespace std;
int n,S,T,a[100005],q[100005];//q数组用来记录前缀和的下标
double Sum[100005];           //前缀和记录到第i个的减去平均值的和
int check(double m)
{
    Sum[0]=0;
    for(int i=1;i<=n;i++)
        Sum[i]=Sum[i-1]+a[i]-m; //初始化差值的前缀和 
 
    int head=1,tail=0;	        //head为单调(上升)队列的左端点,tail为右端点。
    for(int i=1;i<=n;i++)
    {
        if(i>=S)
        {
            //一直减小单调队列的右端点,队列右端点处的位置其前缀和小于Sum[i-S]
            //单调队列 q[] 中存的是,假设区间的右端点是i,区间前面的左端点最小可行位置 i-S 
            while(head<=tail&&Sum[i-S]<Sum[q[tail]])
                tail--;     //维护队列的右端点
            q[++tail]=i-S;  //对于每一个右边的i,都将它的区间左端点初始化为 i-S;
        }
        //单调队列的队首存储的区间左端点位置,小于满足区间长度[S,T]范围时,i位置的左端点最小值;
        if(head<=tail&&i-q[head]>T)	
            head++;
        //如果这一段区间内的和大于等于0,则证明有这个值可行,且可能由更大的平均值存在。
        if(head<=tail&&Sum[i]-Sum[q[head]]>=0)
            return 1;
    }
    return 0;
}
int main()
{
    cin>>n>>S>>T;
    for(int i=1;i<=n;++i)
    {
        cin>>a[i];
    }
    double l=-10005,m,r=10005;
    while(l+1e-5<r)  // 二分区间平均值的最大值,注意控制精度保留三位小数 
    {
        m=(l+r)/2;
        if(check(m))
            l=m;
        else
            r=m;
    }
    cout <<fixed<<setprecision(3)<<l<< endl;
    return 0;
}
posted @ 2020-07-10 16:17  _tham  阅读(893)  评论(1编辑  收藏  举报