技巧1:封装一个能进行越界检查的Array类
C/C++中数组越界是一件很让人头疼的事。内置的数组类型不进行越界检查,而越界后的行为是未定义的。这就导致有时候程序的行为极其诡异,程序员却迟迟定位不了bug的位置。
C++11提供的std::array类提供了at()成员函数,该函数会在下标越界时抛出一个std::out_of_range类型的异常。std::array重载的operator []不进行越界检查。
但有时我们既想要越界检查,又不想牺牲性能,还不想改变用operator []访问元素的写法。为此我们对数组进行一些封装:
1 #include <array> 2 template <class Tp, size_t N> 3 struct Arr: public std::array<Tp, N> 4 { 5 #ifndef NDEBUG 6 Tp& operator [] (size_t idx) { return this->at(idx); } 7 const Tp& operator [] (size_t idx) const { return this->at(idx); } 8 #endif 9 }; 10 template <class Tp, size_t N1, size_t N2> 11 using Arr2 = Arr<Arr<Tp, N2>, N1>;
这里的Arr类继承了std::array的所有成员。在NDEBUG宏未定义时,派生类Arr的operator []覆盖了基类的版本。(顺便黑一波C++坑出风采的const机制)
不需要越界检查时,只需在前边加一句#define NDEBUG即可。(-O2和-O3编译优化下会自动添加NDEBUG,以禁用assert宏)
最后两行是二维数组的一个typedef。注意第11行里边,第二维的大小N2在里层,第一维的大小N1在外层。
在OI、ACM等比赛中,常数优化虽然不像复杂度优化那样重要,但对于某些数据量很大的题目,如果实现时常数太大,也会有TLE的风险。
不过也要注意,常数优化往往是以程序可读性降低为代价的,在实际编程时需要有所取舍。(更何况卡常数的题很容易被喷成毒瘤¬_¬)
技巧2:arr[x +1] = f(arr[x])的时间常数通常比arr[x] = f(arr[x - 1])更小
举一个简单的例子:
1 using LL = long long; 2 LL arr[50000000]; 3 void foo() { 4 for (int i = 1; i < size(arr); i++) 5 arr[i] = i + arr[i - 1]; 6 } 7 void bar() { 8 for (int i = 0; i < size(arr) - 1; i++) 9 arr[i + 1] = i + 1 + arr[i]; 10 }
两种递推的作用是完全等效的,不过对于CPU而言,前一种写法更为“缓存友好”(cache friendly)。当访问arr[i]时,CPU会将紧随其后的一小块缓存起来,访问CPU缓存的速度要快于访问内存。
关于“缓存友好”的详细内容可以参考:https://stackoverflow.com/questions/16699247/what-is-cache-friendly-code
测试程序以及运行结果如下:(编译参数:-std=c++17 -O2)
1 #include <iostream> 2 #include <utility> 3 #include <boost/timer.hpp> 4 5 using namespace std; 6 using LL = long long; 7 LL arr[50'000'000]; 8 void foo() { 9 for (int i = 1; i < size(arr); i++) 10 arr[i] = i + arr[i - 1]; 11 } 12 void bar() { 13 for (int i = 0; i < size(arr) - 1; i++) 14 arr[i + 1] = i + 1 + arr[i]; 15 } 16 17 int main() 18 { 19 for (int i = 0; i < 5; i++) 20 { 21 cout << "Case " << i + 1 << ":\n"; 22 boost::timer timer; 23 foo(); 24 cout << "foo(): " << timer.elapsed() << '\n'; 25 timer.restart(); 26 bar(); 27 cout << "bar(): " << timer.elapsed() << "\n"; 28 } 29 } 30 31 /* 32 Case 1: 33 foo(): 0.227 34 bar(): 0.073 35 Case 2: 36 foo(): 0.099 37 bar(): 0.077 38 Case 3: 39 foo(): 0.1 40 bar(): 0.074 41 Case 4: 42 foo(): 0.102 43 bar(): 0.075 44 Case 5: 45 foo(): 0.103 46 bar(): 0.079 47 */
可以看到对于long long类型,bar()的运行时间比foo()短了大约20%(Case 1的数据比较诡异,第一次运行的话耗时都会很长,求大神解释)。
而对于int类型,测试结果表明bar()的运行时间比foo()短了50%~60%,优势更为突出。
——To be continued——