干翻线段树——指令集优化指北

Posted maxdyf

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了干翻线段树——指令集优化指北相关的知识,希望对你有一定的参考价值。

前言

在我们刷题的时候,总会碰到一些关于区间操作/修改的题目。这些题目往往要求我们维护一段区间,支持对一段区间进行查询/修改操作。这些题目有如树状数组1一般的简单题,也有如[无聊的数列][2]一般,线段树、树状数组能够完成,但是码量长,可读性差,思考难度大的较难题。这种题目对时间的要求非常严格,询问/查询次数序列长度均在\(10^5\)级别或以上,能够使\(O(n^2)\)的暴力算法望屋窃叹。那么,有没有什么方法,可以通过数据,代码又易于实现呢?

指令集优化

今天我要讲的指令集优化,就是对这个问题的最佳解答。那么,什么是指令集?指令集优化又能干什么?

什么是指令集?

指令集是存储在CPU内部,对CPU运算进行指导和优化的指令集合。

简单来说,我们写的程序,无论是C, C++, Java, Python,还是其它高级语言,CPU都是看不懂的。这个时候,我们的编译器(解释器)把这些语言翻译为汇编代码,进而翻译01编码,即CPU能看得懂的指令集命令

但是,由于高级语言的特性,在向下翻译的过程中,为了保证其正确性兼容性,编译器会产生大量冗余代码。这些代码的存在使程序运行的速度变得慢了许多。

那我能不能把那些冗余代码删掉啊?

通常情况下,这些冗余代码是不能完全弄掉的。比较特殊的方法是开启编译器优化,也就是我们常说的吸氧(-o2),吸臭氧(-o3)。但是,开启了优化,并不代表着冗余代码就全部没有了。冗余代码依然大量存在,我们需要进一步干掉他们。

指令集优化能做什么?

前面的内容,大概可以看出优化的重点:干掉冗余代码

那么,怎么干掉它们呢?

大概有两种方法:

1、内嵌汇编

淦,为了做一道题,我还要专门去学一门新的语言,我是**吗?!!

所以,作为一个蒟蒻,这个方案,pass。

2、指令集优化

想干掉这些冗余代码的可不仅仅是码农和广大OIer。作为运算处理器的供给者,CPU厂商自然也想干掉它们,从而提升自己家产品的运算性能。

在这方面,\(Intel\)C++提供了一个解决方案:既然优化干不掉冗余代码,那我自己干!

于是,它自己完成了一个利用我家CPU指令集写成的运算库

这个运算库(在这里只讨论为C++编写的)的优点在于,它直接被包含在了一个库文件里,只需调用相应的头文件,就可以直接使用。同时,由于其与CPU的指令集直接相关,生成的冗余代码极少。在我们做题的角度来看,就是“计算同样的加减法,速度快了好几倍”。

指令集优化的运用

前面讲了那么多废话,总结出来一个字:“指令集优化就是快!”

那么,怎么使用呢?

准备工作

注意!注意!注意!请不要在任何正式比赛中使用指令集优化!!

首先,我们要让编译器知道,我要用指令集。

#pragma GCC target("sse,sse2,sse3,ssse3,sse4.1,sse4.2,avx,avx2,popcnt,tune=native")
//这里的SSE, AVX等等都是指令集的名称

其次,我们得导入两个头文件,这样就可以函数形式调用指令集,而不必内联汇编。

#include <immintrin.h>
#include <emmintrin.h>

注意!!当包含了<bits/stdc++.h>时,请先导入这两个头文件,否则会产生库文件冲突!

然后,我们就可以偷税地使用指令集辣!

变量/函数

在这里,常用的变量大概有__m256, __m256i,__m256d三种,分别用于存储单精度浮点型, 整型, 双精度浮点型。其中256是指一个变量占用了256个bit,可以换成128。

而函数,我们可以在Intel的官方手册中找到你想用的函数。这个手册中给出了函数作用的简述,以及大概工作原理的伪代码,非常方便。不过需要一定的外语阅读水平。

在这里面,函数的命名是有规则的,一般为_mm数据大小_运算类型_epi每个元素的大小()。如_mm256_add_epi32(a, b)就是将__m256i类型的变量ab,以int(占32个Bit)为基准,元素块内对应元素相加,返回表示结果的变量。

在这里,我列一些常用的函数(由ouuan整理,侵删)。

__m256i _mm256_set_epi32 (int e7, int e6, int e5, int e4, int e3, int e2, int e1, int e0):参数是八个数,也就是一个“分块”里的数,注意是逆序的。返回值是一个含这八个数的“分块”。

__m256i _mm256_set_epi64x (__int64 e3, __int64 e2, __int64 e1, __int64 e0):和上面一样,只不过是 64 位整数,也就是 long long。

__m256i _mm256_set1_epi32 (int a):相当于 _mm256_set_epi32(a,a,a,a,a,a,a,a)。

__m256i _mm256_add_epi32 (__m256i a, __m256i b):把两个“分块”的对应位置分别相加,返回结果。

__m256i _mm256_cmpeq_epi32 (__m256i a, __m256i b):判断两个“分块”的对应位置是否相等,若相等则返回的“分块”对应位置是 0xffffffff,否则是 0。

__m256i _mm256_cmpgt_epi32 (__m256i a, __m256i b):和上面一样,只不过比较符是大于而不是相等。

__m256i _mm256_and_si256 (__m256i a, __m256i b):返回两个“分块”的按位与,可以配合上面两条比较指令来使用。

tips:

如果想要访问块内的每一个元素,可以通过指针的形式访问。

__m256i block;
int *a = (int *) &block;
for(int i = 0; i < 8; i++)
    printf("%d", a[i]);

实战

这里,我们以一道例题线段树1来讲解其食用方法。

首先,我们先定义一个__m256i数组block[],用于存储数据。

__m256i block[100000];

将输入的数字按照4个一块的方式,全部压进这个数组里。

a = (long long *) & block;
for(int i = 0; i < n; i++)
    scanf("%lld", a + i);

然后是区间增加。这里我们采用的是分块的思想,即“先更改两边,再将中间整块修改”。

void add(int l, int r, long long x) 
    //本文采用的存储方式为a[0..n-1], 区间操作均为前闭后开[l,r)。
    while((l & 3) && (l < r))// 判断边界
        a[l++] += x;
    if(l == r)
        return;
    while((r & 3)) // 判断边界
        a[--r] += x;
    if(l == r)
        return;
    __m256i ad = _mm256_set1_epi64x(x);
    for(l >>= 2, r >>= 2; l < r; l++) 
        block[l] = _mm256_add_epi64(block[l], ad);
    

区间查询,其操作与修改大同小异。

long long ask(int l, int r) 
    long long ans = 0;
    while((l & 3) && (l < r))
        ans += a[l++];
    if(l == r)
        return ans;
    while((r & 3))
        ans += a[--r];
    if(l == r)
        return ans;
    __m256i ans1 = _mm256_set1_epi64x(0);
    for(l >>= 2, r >>= 2; l < r; l++) 
        ans1 = _mm256_add_epi64(block[l], ans1);
    
    for(int i = 0; i < 4; i++)
        ans += ans1[i];
    return ans;

完整代码:

#define __AVX__ 1
#define __AVX2__ 1
#define __SSE__ 1
#define __SSE2__ 1
#define __SSE2_MATH__ 1
#define __SSE3__ 1
#define __SSE4_1__ 1
#define __SSE4_2__ 1
#define __SSE_MATH__ 1
#define __SSSE3__ 1

#pragma GCC optimize("Ofast,no-stack-protector,unroll-loops,fast-math")
#pragma GCC target("sse,sse2,sse3,ssse3,sse4.1,sse4.2,avx,avx2,popcnt,tune=native")

#include <immintrin.h>
#include <emmintrin.h>
#include <bits/stdc++.h>
using namespace std;
__m256i block[100001], mod;
long long *a;
void add(int l, int r, long long x) 
    while((l & 3) && (l < r))
        a[l++] += x;
    if(l == r)
        return;
    while((r & 3))
        a[--r] += x;
    if(l == r)
        return;
    __m256i ad = _mm256_set1_epi64x(x);
    for(l >>= 2, r >>= 2; l < r; l++) 
        block[l] = _mm256_add_epi64(block[l], ad);
    

long long ask(int l, int r) 
    long long ans = 0;
    while((l & 3) && (l < r))
        ans += a[l++];
    if(l == r)
        return ans;
    while((r & 3))
        ans += a[--r];
    if(l == r)
        return ans;
    __m256i ans1 = _mm256_set1_epi64x(0);
    for(l >>= 2, r >>= 2; l < r; l++) 
        ans1 = _mm256_add_epi64(block[l], ans1);
    
    for(int i = 0; i < 4; i++)
        ans += ans1[i];
    return ans;

int main() 
    int n, m, op, x, y, val;
    scanf("%d%d", &n, &m);
    a = (long long *) & block;
    for(int i = 0; i < n; i++)
        scanf("%lld", a + i);
    while(m--) 
        scanf("%d%d%d", &op, &x, &y);
        x--;
        if(op == 1) 
            scanf("%lld", &val);
            add(x, y, val);
         else 
            printf("%lld\n", ask(x, y));
        
    

总结

指令集优化的大概就是这样。理论上的复杂度为\(O(nm)\),实际上因为指令集的优化,效率提升数十倍,完全可以通过一些时限较为宽松的题目。当然,其也有巨大的局限性,如不支持区间mod,不能够区间除法(似乎只有在Intel GCC上才能正常运行,其他的编译器会显示“没有此函数”)等等。但是,它依然是一个极好的区间查修解决方法,同时,为以后想要成为码农的同学来说,也是极为有益的。

毕竟,对于一道题目,用不同的方法去解决它,也是锻炼思维的最佳方法之一。

以上是关于干翻线段树——指令集优化指北的主要内容,如果未能解决你的问题,请参考以下文章

BZOJ 2143 飞飞侠(线段树优化建边 / 并查集优化最短路)BZOJ修复工程

P3097 [USACO13DEC]最优挤奶(线段树优化dp)

Codeforces Round #603 (Div. 2) - E. Editor(线段树)

解析·优化 ZKW线段树

●记录今日上午○线段树

CF786B Legacy[线段树优化建图]