記一次程序優(yōu)化
背景
大約在一年前,我接觸到了 IN 操作符的特殊用法:
var in array_name
該用法將變量的值直接與數(shù)組中每個元素的值進行比較,而無需借助循環(huán)語句,返回值則是布爾類型,形式簡潔且優(yōu)美。為了記住此法,我盡可能地在自己的工作中使用它,其中一個用例是:如何找到字符串中首個漢字的位置?測試數(shù)據(jù)集如下所示:

第1行純粹是單字節(jié)字符,第2行至第5行在不同位置開始出現(xiàn)中文,第6至8行則是外文文字(阿、日、俄)。我當時給出的解決方案如下:

我利用 unicode() 函數(shù),將基本漢字集(U+4E00-9FA5)存入臨時數(shù)組,然后遍歷源字符串中的所有字符,判斷其是否為數(shù)組中的一員,在這里我用到了 IN 操作符的特殊用法,當判斷返回真值時,查找當前字符在源字符串中的位置,并將其作為結(jié)果輸出。

自定義函數(shù)
最近,我又翻到了這個用例,我認為它與 SAS 的內(nèi)建函數(shù) anynum()、anyalpha()、anyspace()等非常類似,都是用來查找源字符串中特定類型字符的首次出現(xiàn)位置。于是,我誕生了制作自定義函數(shù) anyhan() 的想法,將之前的代碼片段進行封裝,并做了必要的調(diào)整,第一版設(shè)計就誕生了:
我使用 PROC FCMP 進行自定義函數(shù)的設(shè)計,主要有三處調(diào)整:
FCMP 過程步對數(shù)組的定義與數(shù)據(jù)步不同,數(shù)組下標起始值和_temporary_ 關(guān)鍵字發(fā)生了改變;
FCMP 過程步不支持 var in array_name 用法,調(diào)整成了do 循環(huán)進行遍歷查找;
由于增加了一層循環(huán),循環(huán)的跳出和結(jié)果變量的賦值稍有改變;
這個函數(shù)定義完備,并可以使用,但是速度卻不盡如人意。我嘗試將它用在測試數(shù)據(jù)集上,每條觀測調(diào)用該函數(shù)100次,居然花費了7秒多的時間。測試程序如下:
我稍加思索,認為不是每個字符都需要判斷是否為漢字,比如單字節(jié)字符明顯就可以跳過,于是我將函數(shù)定義第14行的:
do j=1 to dim(_han_);
改為:
if length(kchar)>1 then do j=1 to dim(_han_);
速度有了一點點改善,同樣的測試從7秒變成了約6秒,但仍然不能使我滿意。我盯著程序,要找出是誰拉了后腿,注意到在原始的解決方案中,數(shù)組賦值的操作有限定條件:if _n_=1,而 anyhan() 中是沒有的,會不會是它耗時太多呢?我立刻為自定義函數(shù)的數(shù)組賦值也加上這個限定條件,但接下來又陷入泥潭:耗時減少到1.5秒,但結(jié)果變量的值全為0!看來,_n_ 并沒有被 FCMP 當成數(shù)據(jù)步中的自動變量。
我無法再閱讀出蛛絲馬跡,但我很快想到還有另外一種手段可用,那就是刪代碼調(diào)試法。這種方法脫胎于控制變量法思想,在保證代碼可以運行的前提下,每次刪除一部分代碼,從而觀察到被刪除的代碼對結(jié)果的影響。我早就懷疑自定義函數(shù)中數(shù)組的賦值行為與數(shù)據(jù)步不同,因此直接就修改了函數(shù)定義:
運行同樣的測試程序,耗時約4.8秒,拖慢速度的源頭找到了!找到它不容易,要優(yōu)化它就更麻煩些。我深入地思考:制作漢字集數(shù)組是為了后面的比較提供方便,但每條觀測都執(zhí)行20901次循環(huán),調(diào)用20901次 unicode() 函數(shù),有這個必要嗎?顯然,要執(zhí)行查找的源字符串一般不會用到這么多個不同的漢字,《現(xiàn)代漢語常用字表》不過3500字,如果限定在臨床研究或金融研究領(lǐng)域就更少了。同樣是判定指定字符是否處在漢字基本字集內(nèi),我不一定要將該字符與基本字集元素逐個比較,基于基本字集編碼連續(xù)的特點,我也可以將該字符的編碼與基本字集的編碼范圍進行比較,這樣就無需定義漢字集數(shù)組,一定能快上許多。我改了改,又調(diào)試了一會,第二版設(shè)計也出來了:
在逐個截取源字符串中的字符時,使用 unicodec() 獲取當前字符的 unicode 編碼值,編碼值以 \u 開頭,后面跟著2位、4位或8位十六進制數(shù)字,接著配合使用 substr() 和 input() 將字符型編碼值轉(zhuǎn)換為數(shù)值,最后再與基本字集的編碼上下限進行比較,當比較結(jié)果為真時,查找當前字符在源字符串中的位置,并將其作為結(jié)果輸出。
我再次將它放在同樣的測試程序上,大概一眨眼的功夫,程序就成功地給出了結(jié)果,這十分令我振奮!我再次回顧函數(shù)定義,發(fā)現(xiàn)還有兩點可以改進的地方:
當前只剩下一層循環(huán),goto 語句的使用或許存在爭議,可改用其它跳出循環(huán)做法;
既然是對字符編碼進行比較,就不一定要把編碼值轉(zhuǎn)換為數(shù)值,字符型的編碼值也可以比較;
基于這兩點改進思路,我很快給出了第三版的設(shè)計:
在新的設(shè)計當中,do until() 代替了原先的 goto 語句+標簽語句的組合,程序的可讀性更好。另外,編碼值的比較被設(shè)計成基于字符串的比較方式,即從左至右逐個比較字符的的大小,由于編碼值的構(gòu)成特點,這實際上就是在挨個比較編碼值中組成十六進制值的字符的大小,更具體一些,就是在比較這些字符的 ASCII 碼值的大小,例如4(碼值52)小于9(碼值57),而F(碼值70)大于E(碼值69)。
我十分滿意這個設(shè)計,它足夠簡潔,又易于擴展,假如將不等式兩端的上下限換為 \u3040 和 \u30FF,就可以用來匹配日文字符,換為 \u0400 和 \u052F 就可以匹配俄文字符。
我使用 SAS 9.4 M8 進行開發(fā)和測試, CPU 為 Intel i7-1265U,不同設(shè)計的 anyhan() 函數(shù)運行時間如下(運行10次取平均):
