练习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操作进行字符串处理。
  • 尝试一些不同顺序的测试,看看能否得到不同的结果。

书籍推荐