1. 程式人生 > >2018.12.5【WC2017】【LOJ2286】【洛谷P4604】挑戰(卡常)

2018.12.5【WC2017】【LOJ2286】【洛谷P4604】挑戰(卡常)

洛谷傳送門

LOJ傳送門


解析:

目前LOJ速度rank1。但是洛谷上面有兩個40msAC的在我前面(空間還小的出奇,估計連排序的陣列都存不下)。。。估計是面向資料程式設計。。

說明:博主是一個高一OIER,沒有認真學過大學電腦科學課程,如果題解中有任何地方不嚴謹,請在評論區告知博主。


Subtask1:

首先這個排序用 O ( n log

n ) O(n\log n) 的快排做是妥妥的超時,而且不便於常數優化。

桶排也算了,空間開不下。

所以我們會用到與桶排類似的一種演算法,基數排序。

不知道什麼是基數排序的自行百度。

它的複雜度是 O (

n m a x ( a i ) /
b a s e ) O(n\cdot max(a_i)/base) ,其中 b a s e base 是選取的基數大小,一般來說在不超過記憶體限制的情況下,選擇的基數越大排序越快。

注意是一般情況下,不包括需要卡常數的時候。

然後考慮選擇什麼樣的基數,由於目標大小是 2 32 1 2^{32}-1 以內,所以我們可以選擇 2 16 2^{16} 或者 2 8 2^8 作為基數進行排序。( 2 4 2^4 太小,排序次數有點多了,不考慮)。

那麼問題來了,選擇哪個?

你可能覺得選擇 2 16 2^{16} 作為基數只需要排兩次就能出結果,應該比 2 8 2^8 排四次要快吧?

大錯特錯!常數要是算的這麼簡單,為什麼大學還要學計算機組成原理?

要明白這個,首先引入一個東西,快取記憶體器。


快取記憶體器(Cache):

一下內容摘自百度百科

高速緩衝儲存器(Cache)其原始意義是指存取速度比一般隨機存取記憶體(RAM)來得快的一種RAM,一般而言它不像系統主記憶體那樣使用DRAM技術,而使用昂貴但較快速的SRAM技術,也有快取記憶體的名稱。
高速緩衝儲存器是存在於主存與CPU之間的一級儲存器, 由靜態儲存晶片(SRAM)組成,容量比較小但速度比主存高得多, 接近於CPU的速度。在計算機儲存系統的層次結構中,是介於中央處理器和主儲存器之間的高速小容量儲存器。它和主儲存器一起構成一級的儲存器。高速緩衝儲存器和主儲存器之間資訊的排程和傳送是由硬體自動進行的。
高速緩衝儲存器最重要的技術指標是它的命中率。

注意最後一句話

高速緩衝儲存器最重要的技術指標是它的命中率。

那麼關於命中率的相關概念也請自己去百度百科裡面瞭解,或者感性理解如下:

快取記憶體器裡面存了一段記憶體的東西,可以通過快取記憶體器快速訪問這段記憶體裡面的任何一個元素。

一旦訪問記憶體外的元素,Cache就會看情況選擇是否清空並重新裝填記憶體,而在重新裝填次數儘可能少的同時儘可能多的訪問Cache裡面的東西,就能手動提高它的命中率。


那麼我們的目的就是使得訪問(準確說是查詢,不考慮修改)的記憶體儘可能的相鄰,如果你選擇的桶的大小是 2 16 = 65536 2^{16}=65536 ,就卡不進一級快取,不能儘可能利用Cache帶來的優勢。

所以選擇桶的大小為 2 8 = 256 2^8=256 ,Cache肯定能夠存下的大小,隨便過吧。

實際測試中 2 16 2^{16} 的桶根本過不去。

具體實現請看文章末尾程式碼中的namespace Sorting


Subtask2:

首先不要考慮用資料結構維護了吧。。。刻意造的資料你根本維護不了任何有用的東西。

這個顯然是 O ( n q ) O(nq) 的複雜度,那麼我們繼續考慮如何優化。

首先考慮一個東西:迴圈展開


迴圈展開:

比如說我們要求一個數列的和,一般的寫法是這樣:

long long sum=0;
for(int i=1;i<=n;++i)sum+=a[i];
return sum;

但是迴圈展開後是這樣寫的:

long long sum1=0,sum2=0,sum3=0,sum4=0,sum5=0,sum6=0,sum7=0,sum8=0;

for(int i=0;i+8<=n;i+=8){
	sum1+=a[i+1];
	sum2+=a[i+2];
	sum3+=a[i+3];
	sum4+=a[i+4];
	sum5+=a[i+5];
	sum6+=a[i+6];
	sum7+=a[i+7];
	sum8+=a[i+8];
}
switch(n&7){
	case 7:sum7+=a[n-6];
	case 6:sum6+=a[n-5];
	case 5:sum5+=a[n-4];
	case 4:sum4+=a[n-3];
	case 3:sum3+=a[n-2];
	case 2:sum2+=a[n-1];
	case 1:sum1+=a[n]; 
}

return sum1+sum2+sum3+sum4+sum5+sum6+sum7+sum8;

這樣寫有什麼好處呢?也就是說,為什麼這樣寫要快那麼多呢?

那麼我們回到計算機執行程式的本質:儲存,查詢和計算。

其中儲存沒有什麼可以在時間上產生太多優化的做法,卡空間常數並不會對時間產生過多影響。

查詢上的優化主要就是 S u b t a s k 1 Subtask1 中用到的卡高速緩衝器和常用的卡register暫存器。

那麼優化的主要目的就到了計算上面。
聽說過一種做法似乎可以把整型和實型的四則運算常數優化10倍,沒學過,而且聽說碼量略大,不予考慮。

那麼我們就用到了迴圈展開。

考慮我們計算的步驟(需要迴圈的演算法)

1.初始化
2.進行一次迴圈中的操作
3.進入下一次迴圈

一下的討論假設迴圈變數為 i i
我們發現,每一次計算下一個迴圈中的東西時候,需要修改 i i
所以說,下一次的 i i 是與這一次的修改相關的

那麼要呼叫下一個迴圈中的 i i 實際上需要這一次的修改。

不要為難編譯器,它也無法預測下一次的 i i 會不會變成什麼奇怪的東西,所以它只能一個步驟一個步驟的執行。

那麼我們可以明確告訴它接下來的幾個操作中所要用到的 i i 與現在的 i i 有什麼關係。從而讓它能夠知道接下來該幹什麼,讓CPU以一定概率同時執行這些操作中的好幾個,這就是CPU併發,也是迴圈展開的終極目的。

至於為什麼能夠讓CPU做到這樣,讀者可以自行了解,詳細的敘述已經偏離了本文的目的。這裡只稍微提一下,一般來說CPU中是有多個運算器的,也就是多核心,讓這麼多運算器睡大覺真是一種資源的浪費啊。

Deltail:

一般來說,迴圈展開只需要展開6~8層就已經夠了,多了的話可能造成暫存器溢位從而反使程式的執行速度變慢。至於為什麼是暫存器,請讀者自己瞭解,這裡不再過多展開。

再來看兩種不夠優秀的寫法,但是也有優化作用:

1.只用一個sum,不能充分刺激CPU併發。

long long sum=0;

for(int i=0;i+8<=n;i+=8){
	sum+=a[i+1];
	sum+=a[i+2];
	sum+=a[i+3];
	sum+=a[i+4];
	sum+=a[i+5];
	sum+=a[i+6];
	sum+=a[i+7];
	sum+=a[i+8];
}
switch(n&7){
	case 7:sum+=a[n-6];
	case 6:sum+=a[n-5];
	case 5:sum+=a[n-4];
	case 4:sum+=a[n-3];
	case 3:sum+=a[n-2];
	case 2:sum+=a[n-1];
	case 1:sum+=a[n]; 
}

return sum;

2.展開的時候用了++i,也是不能充分刺激CPU併發。

long long sum1=0,sum2=0,sum3=0,sum4=0,sum5=0,sum6=0,sum7=0,sum8=0;

for(int i=0;i+8<=n;){
	sum1+=a[++i];
	sum2+=a[++i];
	sum3+=a[++i];
	sum4+=a[++i];
	sum5+=a[++i];
	sum6+=a[++i];
	sum7+=a[++i];
	sum8+=a[++i];
}
switch(n&7){
	case 7:sum7+=a[n-6];
	case 6:sum6+=a[n-5];
	case 5:sum5+=a[n-4];
	case 4:sum4+=a[n-3];
	case 3:sum3+=a[n-2];
	case 2:sum2+=a[n-1];
	case 1:sum1+=a[n]; 
}

return sum1+sum2+sum3+sum4+sum5+sum6+sum7+sum8;

TIPS:關於上述程式碼中的switch

懂上面為什麼這樣寫的可以跳過這一段不看。

switch內部有兩種可以用的關鍵字:case和default,其中case後面還需要跟一個常量表達式。每次switch進入大括號的時候,直接根據選擇分支跳到相應的位置。

然後按照順序一直執行到switch的末尾,除非遇到break。

所以上面的迴圈展開就寫成了那個樣子。(並且減少了多次 i f if 判斷)


但是光是迴圈展開是不能把卡常數做到極致的,對於這種連續區間型 b o o l bool 計數(博主瞎yy的一種叫法,好記又好理解),我們可以用壓位。


壓位:

由於是 b o o l bool 型計數,所以我們可以壓位亂搞。

s 1   s 2 s1\text{ }s2 分別設定兩個陣列 f 1   f 2 f1\text{ }f2 ,每一個字串在陣列位置上佔有3個位置,分別表示它出石頭,剪刀或布,查詢的時候兩個部分的拿出來一起搞就行了,直接用與運算判斷是否有東西,然後統計位數就行了。

統計位數平時可以直接用 O ( 1 ) O(1) 的__builtin_popcountll(C++STL裡面的函式),或者自己預處理256以內的數的位數個數,然後每個數分四段用位移運算取得每一段統計一下就行了。


具體實現請看文章末尾的namespace Game
聽說有 O ( n 1.5 log n ) O(n^{1.5}\log n) (在洛谷討論區看見的)FFT的做法,沒有這種優化過的 O ( n q ) O(nq) 快(話說FFT本身就不利於常數優化),不講了。(可以自己去LOJ看xumingkuan大佬的程式碼


Subtask3:

一般的做法就是直接在DP陣列上面用一個指標掃動,遇到 ? ? 就把後面所有的全部更新一遍,其實就是考慮上一個位置為‘)’和‘(’的情況就行了。

但是這個還能優化:並非所有下標都可以達到。

所以我們只需要在遇到‘(’和‘)’的時候移動指標判斷一下奇偶性就行了(不想搞奇偶性可以直接樸素寫法+奇偶性卡常,區別不大)。

具體實現參考文章末尾的namespace Parentheses


一個非常實用並且常用的卡常技巧:指標優化定址

一般來說,如下兩種方式都聲明瞭一個大小為100的int陣列,沒有任何區別

int x[100];
int *const y=new int[100];

所以說,陣列名實際上是陣列的頭指標。

同理,對於指標,我們也可以用[]運算子來定址,這樣就能解釋為什麼負數下標是允許的了。

一下兩種方式都是表示在陣列 x x 中的第 i i 個物件

x[i];
*(x+i);

那麼,在訪問一個數組的時候,我們可以考慮用一個指標掃一遍。

這個卡常主要就是卡一個加法的常數,因為f[0]的訪問是比*f訪問指標指向的第一個元素快的。


程式碼:

#include<bits/stdc++.h>
using namespace std;
#define ll long long
#define re register
#define gc getchar
#define pc putchar
#define cs const

#define u32 unsigned int
#define u64 unsigned ll

inline u32 getint(){
    re u32 num;
    re char c;
    while(!isdigit(c=gc()));num=c^48;
    while(isdigit(c=gc()))num=(num+(num<<2)<<1)+(c^48);
    return num;
}

inline void skip(){while(isspace(cin.peek()))gc();}

#define nxt_integer(x) (x^=x<<13,x^=x>>17,x^=x<<5)

inline void output_arr(u32 * a,u32 size){
    re u32 ret=size;--a;
    for(u32 re x=23333333,*to=a+(size>>2)+1;++a<to;)