笨辦法學C 練習39:字符串算法

練習39:字符串算法

原文:Exercise 39: String Algorithms

譯者:飛龍

這個練習中,我會向你展示可能是最快的字符串搜索算法之一,并且將它與bstrlib.c中現有的binstr比較。binstr的文檔說它僅僅使用了“暴力搜索”的字符串算法來尋找第一個實例。我所實現的函數使用Boyer-Moore-Horspool(BMH)算法,如果你分析理論時間的話,一般認為它會更快。你也會看到,如果我的實現沒有任何缺陷,BMH的實際時間會比binstr簡單的暴力搜索更糟。

這個練習的要點并不是真正解釋算法本身,因為你可以直接去Boyer-Moore-Horspool 的維基百科頁面去閱讀它。這個算法的要點就是它會計算出“跳躍字符列表”作為第一步操作,之后它使用這個列表來快速掃描整個字符串。它應當比暴力搜索更快,所以讓我們在文件里寫出代碼來看看吧。

首先,創建頭文件:

#ifndef string_algos_h
#define string_algos_h

#include <lcthw/bstrlib.h>
#include <lcthw/darray.h>

typedef struct StringScanner {
    bstring in;
    const unsigned char *haystack;
    ssize_t hlen;
    const unsigned char *needle;
    ssize_t nlen;
    size_t skip_chars[UCHAR_MAX + 1];
} StringScanner;

int String_find(bstring in, bstring what);

StringScanner *StringScanner_create(bstring in);

int StringScanner_scan(StringScanner *scan, bstring tofind);

void StringScanner_destroy(StringScanner *scan);

#endif

為了觀察“跳躍字符列表”的效果,我打算創建這個算法的兩種版本:

String_find

只是在一個字符串中,尋找另一個字符串的首個實例,以一個動作執行整個算法。

StringScanner_scan

使用StringScanner狀態結構,將跳躍列表的構建和實際的查找操作分開。這讓我能看到什么影響了性能。這個模型有另一個優點,就是我可以在一個字符串中逐步搜索,并且快速地找到所有實例。

一旦你完成了頭文件,下面就是實現了:

#include <lcthw/string_algos.h>
#include <limits.h>

static inline void String_setup_skip_chars(
        size_t *skip_chars,
        const unsigned char *needle, ssize_t nlen)
{
    size_t i = 0;
    size_t last = nlen - 1;

    for(i = 0; i < UCHAR_MAX + 1; i++) {
        skip_chars[i] = nlen;
    }

    for (i = 0; i < last; i++) {
        skip_chars[needle[i]] = last - i;
    }
}


static inline const unsigned char *String_base_search(
        const unsigned char *haystack, ssize_t hlen,
        const unsigned char *needle, ssize_t nlen,
        size_t *skip_chars)
{
    size_t i = 0;
    size_t last = nlen - 1;

    assert(haystack != NULL && "Given bad haystack to search.");
    assert(needle != NULL && "Given bad needle to search for.");

    check(nlen > 0, "nlen can't be <= 0");
    check(hlen > 0, "hlen can't be <= 0");

    while (hlen >= nlen)
    {
        for (i = last; haystack[i] == needle[i]; i--) {
            if (i == 0) {
                return haystack;
            }
        }

        hlen -= skip_chars[haystack[last]];
        haystack += skip_chars[haystack[last]];
    }

error: // fallthrough
    return NULL;
}

int String_find(bstring in, bstring what)
{
    const unsigned char *found = NULL;

    const unsigned char *haystack = (const unsigned char *)bdata(in);
    ssize_t hlen = blength(in);
    const unsigned char *needle = (const unsigned char *)bdata(what);
    ssize_t nlen = blength(what);
    size_t skip_chars[UCHAR_MAX + 1] = {0};

    String_setup_skip_chars(skip_chars, needle, nlen);

    found = String_base_search(haystack, hlen, needle, nlen, skip_chars);

    return found != NULL ? found - haystack : -1;
}

StringScanner *StringScanner_create(bstring in)
{
    StringScanner *scan = calloc(1, sizeof(StringScanner));
    check_mem(scan);

    scan->in = in;
    scan->haystack = (const unsigned char *)bdata(in);
    scan->hlen = blength(in);

    assert(scan != NULL && "fuck");
    return scan;

error:
    free(scan);
    return NULL;
}

static inline void StringScanner_set_needle(StringScanner *scan, bstring tofind)
{
    scan->needle = (const unsigned char *)bdata(tofind);
    scan->nlen = blength(tofind);

    String_setup_skip_chars(scan->skip_chars, scan->needle, scan->nlen);
}

static inline void StringScanner_reset(StringScanner *scan)
{
    scan->haystack = (const unsigned char *)bdata(scan->in);
    scan->hlen = blength(scan->in);
}

int StringScanner_scan(StringScanner *scan, bstring tofind)
{
    const unsigned char *found = NULL;
    ssize_t found_at = 0;

    if(scan->hlen <= 0) {
        StringScanner_reset(scan);
        return -1;
    }

    if((const unsigned char *)bdata(tofind) != scan->needle) {
        StringScanner_set_needle(scan, tofind);
    }

    found = String_base_search(
            scan->haystack, scan->hlen,
            scan->needle, scan->nlen,
            scan->skip_chars);

    if(found) {
        found_at = found - (const unsigned char *)bdata(scan->in);
        scan->haystack = found + scan->nlen;
        scan->hlen -= found_at - scan->nlen;
    } else {
        // done, reset the setup
        StringScanner_reset(scan);
        found_at = -1;
    }

    return found_at;
}


void StringScanner_destroy(StringScanner *scan)
{
    if(scan) {
        free(scan);
    }
}

整個算法都在兩個static inline的函數中,叫做String_setup_skip_charsString_base_search。它們在別的函數中使用,用于實現我想要的的搜索形式。研究這兩個函數,并且與維基百科的描述對比,你就可以知道它的工作原理。

之后String_find使用這兩個函數來尋找并返回所發現的位置。它非常簡單并且我使用它來查看“跳躍字符列表”的構建如何影響到真實性能。要注意,你或許可以使它更快,但是我要教給你在你實現算法之后如何驗證理論速度。

StringScanner_scan函數隨后按照“創建、掃描、銷毀”的常用模式,并且用于在一個字符串中逐步搜索另一個字符串。當我向你展示單元測試的時候,你會看到它如何使用。

最后,我編寫了單元測試來確保算法有效,之后在它的注釋部分,我為三個搜索函數運行了簡單的性能測試:

#include "minunit.h"
#include <lcthw/string_algos.h>
#include <lcthw/bstrlib.h>
#include <time.h>

struct tagbstring IN_STR = bsStatic("I have ALPHA beta ALPHA and oranges ALPHA");
struct tagbstring ALPHA = bsStatic("ALPHA");
const int TEST_TIME = 1;

char *test_find_and_scan()
{
    StringScanner *scan = StringScanner_create(&IN_STR);
    mu_assert(scan != NULL, "Failed to make the scanner.");

    int find_i = String_find(&IN_STR, &ALPHA);
    mu_assert(find_i > 0, "Failed to find 'ALPHA' in test string.");

    int scan_i = StringScanner_scan(scan, &ALPHA);
    mu_assert(scan_i > 0, "Failed to find 'ALPHA' with scan.");
    mu_assert(scan_i == find_i, "find and scan don't match");

    scan_i = StringScanner_scan(scan, &ALPHA);
    mu_assert(scan_i > find_i, "should find another ALPHA after the first");

    scan_i = StringScanner_scan(scan, &ALPHA);
    mu_assert(scan_i > find_i, "should find another ALPHA after the first");

    mu_assert(StringScanner_scan(scan, &ALPHA) == -1, "shouldn't find it");

    StringScanner_destroy(scan);

    return NULL;
}

char *test_binstr_performance()
{
    int i = 0;
    int found_at = 0;
    unsigned long find_count = 0;
    time_t elapsed = 0;
    time_t start = time(NULL);

    do {
        for(i = 0; i < 1000; i++) {
            found_at = binstr(&IN_STR, 0, &ALPHA);
            mu_assert(found_at != BSTR_ERR, "Failed to find!");
            find_count++;
        }

        elapsed = time(NULL) - start;
    } while(elapsed <= TEST_TIME);

    debug("BINSTR COUNT: %lu, END TIME: %d, OPS: %f",
            find_count, (int)elapsed, (double)find_count / elapsed);
    return NULL;
}

char *test_find_performance()
{
    int i = 0;
    int found_at = 0;
    unsigned long find_count = 0;
    time_t elapsed = 0;
    time_t start = time(NULL);

    do {
        for(i = 0; i < 1000; i++) {
            found_at = String_find(&IN_STR, &ALPHA);
            find_count++;
        }

        elapsed = time(NULL) - start;
    } while(elapsed <= TEST_TIME);

    debug("FIND COUNT: %lu, END TIME: %d, OPS: %f",
            find_count, (int)elapsed, (double)find_count / elapsed);

    return NULL;
}

char *test_scan_performance()
{
    int i = 0;
    int found_at = 0;
    unsigned long find_count = 0;
    time_t elapsed = 0;
    StringScanner *scan = StringScanner_create(&IN_STR);

    time_t start = time(NULL);

    do {
        for(i = 0; i < 1000; i++) {
            found_at = 0;

            do {
                found_at = StringScanner_scan(scan, &ALPHA);
                find_count++;
            } while(found_at != -1);
        }

        elapsed = time(NULL) - start;
    } while(elapsed <= TEST_TIME);

    debug("SCAN COUNT: %lu, END TIME: %d, OPS: %f",
            find_count, (int)elapsed, (double)find_count / elapsed);

    StringScanner_destroy(scan);

    return NULL;
}


char *all_tests()
{
    mu_suite_start();

    mu_run_test(test_find_and_scan);

    // this is an idiom for commenting out sections of code
#if 0
    mu_run_test(test_scan_performance);
    mu_run_test(test_find_performance);
    mu_run_test(test_binstr_performance);
#endif

    return NULL;
}

RUN_TESTS(all_tests);

我把它們寫在#if 0中間,它是使用C預處理器來注釋一段代碼的方法。像這樣輸入,并且把它和#endif移除,你就可以運行性能測試。當你繼續這本書時,需要簡單地把它們再次注釋,以防它們浪費你的開發時間。

這個單元測試沒有什么神奇之處,它只是在尊換種調用每個不同的函數,循環需要持續足夠長的時間來得到一個幾秒的樣本。第一個測試(test_find_and_scan)只是確保我所編寫的代碼正常工作,因為測試無效的代碼沒有意義。之后,下面的三個函數使用三個函數中的每一個來執行大量的搜索。

需要注意的一個技巧是,我在start中存儲了起始時間,之后一直循環到至少過了TEST_TIME秒。這確保了我能或得到足夠好的樣本用于比較三者。我之后會使用不同的TEST_TIME設置來運行測試,并且分析結果。

你會看到什么

當我在我的筆記本上運行測試時,我得到的數據是這樣的:

$ ./tests/string_algos_tests
DEBUG tests/string_algos_tests.c:124: ----- RUNNING: ./tests/string_algos_tests
----
RUNNING: ./tests/string_algos_tests
DEBUG tests/string_algos_tests.c:116: 
----- test_find_and_scan
DEBUG tests/string_algos_tests.c:117: 
----- test_scan_performance
DEBUG tests/string_algos_tests.c:105: SCAN COUNT: 110272000, END TIME: 2, OPS: 55136000.000000
DEBUG tests/string_algos_tests.c:118: 
----- test_find_performance
DEBUG tests/string_algos_tests.c:76: FIND COUNT: 12710000, END TIME: 2, OPS: 6355000.000000
DEBUG tests/string_algos_tests.c:119: 
----- test_binstr_performance
DEBUG tests/string_algos_tests.c:54: BINSTR COUNT: 72736000, END TIME: 2, OPS: 36368000.000000
ALL TESTS PASSED
Tests run: 4
$

我看到了它,覺得每輪運行應該超過兩秒。并且,我打算多次運行它,并且像之前一樣使用R來驗證。下面是我獲得的10個樣例,每個基本上是10秒:

scan find binstr
71195200 6353700 37110200
75098000 6358400 37420800
74910000 6351300 37263600
74859600 6586100 37133200
73345600 6365200 37549700
74754400 6358000 37162400
75343600 6630400 37075000
73804800 6439900 36858700
74995200 6384300 36811700
74781200 6449500 37383000

我在shell的一點點幫助下獲取數據,之后編輯輸出:

$ for i in 1 2 3 4 5 6 7 8 9 10; do echo "RUN --- $i" >> times.log; ./tests/string_algos_tests 2>&1 | grep COUNT >> times.log ; done
$ less times.log
$ vim times.log

現在你可以看到scan系統要優于另外兩個,但是我會在R中打開它并且驗證結果:

> times <- read.table("times.log", header=T)
> summary(times)
      scan               find             binstr        
 Min.   :71195200   Min.   :6351300   Min.   :36811700  
 1st Qu.:74042200   1st Qu.:6358100   1st Qu.:37083800  
 Median :74820400   Median :6374750   Median :37147800  
 Mean   :74308760   Mean   :6427680   Mean   :37176830  
 3rd Qu.:74973900   3rd Qu.:6447100   3rd Qu.:37353150  
 Max.   :75343600   Max.   :6630400   Max.   :37549700  
>

為了理解我為什么要生成這份概要統計,我必須對你解釋一些統計學概念。我在這些數字中尋找的東西能夠簡單地告訴我,“這三個函數(scanfindbinstr)實際上不同嗎?”我知道每次我運行測試函數的時候,我都會得到有些不同的數值,并且那些數值始終處理一個固定的范圍。你可以看到兩個四分位數反映了這一點。

我首先會去看均值,并且我會觀察每個樣例的均值是否不同于其它的。我可以清楚地看到scan優于binstr,同時后者優于find。然而問題來了,如果我只使用均值,就可以出現每個樣例的范圍會重疊的可能性。

如果均值不同,但是兩個四分位點重疊會怎么用?這種情況下我只能說有這種可能性,并且如果我再次運行測試,均值就可能不同了。很可能出現的范圍上的重疊是,我的兩個樣例(以及兩個函數)并非實際上不同。任何我看到的差異都是隨機產生的結果。

統計學擁有大量工具來解決這一問題,但是在我們的例子中我可以僅僅觀察兩個四分位值,以及所有樣例的均值。如果均值不同,并且四分位值不可能重疊,就可以說它們完全不同。

在我的三個樣例中,我可以說scanfindbinstr都是不同的,范圍上沒有重疊,并且(最重要的是)我可以相信數據。

分析結果

從結果中可以看出String_find比其它兩個更慢。實際上,我認為慢的原因是我實現的方式有些問題。然而當我將它與StringScanner_scan比較時,我發現正是構造跳躍列表的那一部分最消耗時間。并且它的功能比scan要少,因為它僅僅找到了第一個位置,而scan找到了全部。

我也可以發現scan以很大優勢優于binstr。同時我可以說scan的功能比其他兩個要多,速度也更快。

下面是這個分析的一些注解:

  • 我可能將實現或測試弄亂了。現在我打算研究所有實現BMH的可能方式來改進它。我也會確保我所做的事情正確。
  • 如果你修改了測試運行的時間,你會得到不同的結果。這就是我沒有考慮的”熱身“環節。
  • test_scan_performance單元測試和其它兩個并不相同,但是它比其它測試做得更多(并且也是按照時間和操作數量計算的),所以他可能是合理的。
  • 我只通過在一個字符串內搜索另一個來執行測試。我應該使所查找的字符串隨機化,來移除它們的位置和長度,作為干擾因素。
  • binstr的實現可能比“暴力搜索”要好。(所以應該自己編寫暴力搜索作為對照。)
  • 我可能以不幸的順序來執行這些函數,并且隨機化首先運行的測試可能會得到更好的結果。

可以從中學到的是,你需要確保知己的性能,即使你“正確”實現了一個算法。在這里BMH算法應該優于binstr算法,但是一個簡單的測試證明了它是錯誤。如果我沒有這些測試,我可能就使用了一個劣等的算法實現而不自知。參照這些度量,我可以開始調優我的實現,或者只是拋棄它并尋找新的算法。

附加題

  • 看看你能不能使Scan_find更快。為什么我的實現這么慢?
  • 嘗試一些不同的搜索時長,看看你是否能得到不同的數值。當你改變scan的測試時間時,時間的長度會有什么影響?對于這些結果你能得出什么結論?
  • 修改單元測試,使它最開始執行每個函數一小段時間,來消除任何“熱身”緩解。這樣會修改所運行時長的依賴性嗎?每秒可能出現多少次操作?
  • 使單元測試中的所查找字符串隨機化,之后測量你的得到的性能。一種實現它的方式就是使用bstrlib.h中的bsplit函數在空格處分割IN_STR。之后使用你得到的strList結構訪問它返回的每個字符串。這也教給你如何使用bstrList操作進行字符串處理。
  • 嘗試一些不同順序的測試,看看能否得到不同的結果。
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,732評論 6 539
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,214評論 3 426
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,781評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,588評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,315評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,699評論 1 327
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,698評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,882評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,441評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,189評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,388評論 1 372
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,933評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,613評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,023評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,310評論 1 293
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,112評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,334評論 2 377

推薦閱讀更多精彩內容