前言
在我们刷题的时候,总会碰到一些关于区间操作/修改的题目。这些题目往往要求我们维护一段区间,支持对一段区间进行查询/修改操作。这些题目有如树状数组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
类型的变量a
和b
,以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 *) █
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上才能正常运行,其他的编译器会显示“没有此函数”)等等。但是,它依然是一个极好的区间查修解决方法,同时,为以后想要成为码农的同学来说,也是极为有益的。
毕竟,对于一道题目,用不同的方法去解决它,也是锻炼思维的最佳方法之一。
原文地址:https://www.cnblogs.com/MaxDYF/p/11518355.html