练习35:排序和搜索

原文:Exercise 35: Sorting And Searching

译者:飞龙

这个练习中我打算涉及到四个排序算法和一个搜索算法。排序算法是快速排序、堆排序、归并排序和基数排序。之后在你完成基数排序之后,我打算想你展示二分搜索。

然而,我是一个懒人,大多数C标准库都实现了堆排序、快速排序和归并排序算法,你可以直接使用它们:

  1. #include <lcthw/darray_algos.h>
  2. #include <stdlib.h>
  3. int DArray_qsort(DArray *array, DArray_compare cmp)
  4. {
  5. qsort(array->contents, DArray_count(array), sizeof(void *), cmp);
  6. return 0;
  7. }
  8. int DArray_heapsort(DArray *array, DArray_compare cmp)
  9. {
  10. return heapsort(array->contents, DArray_count(array), sizeof(void *), cmp);
  11. }
  12. int DArray_mergesort(DArray *array, DArray_compare cmp)
  13. {
  14. return mergesort(array->contents, DArray_count(array), sizeof(void *), cmp);
  15. }

这就是darray_algos.c文件的整个实现,它在大多数现代Unix系统上都能运行。它们的每一个都使用DArray_comparecontents中储存的无类型指针进行排序。我也要向你展示这个头文件:

  1. #ifndef darray_algos_h
  2. #define darray_algos_h
  3. #include <lcthw/darray.h>
  4. typedef int (*DArray_compare)(const void *a, const void *b);
  5. int DArray_qsort(DArray *array, DArray_compare cmp);
  6. int DArray_heapsort(DArray *array, DArray_compare cmp);
  7. int DArray_mergesort(DArray *array, DArray_compare cmp);
  8. #endif

大小几乎一样,你也应该能预料到。接下来你可以了解单元测试中这三个函数如何使用:

  1. #include "minunit.h"
  2. #include <lcthw/darray_algos.h>
  3. int testcmp(char **a, char **b)
  4. {
  5. return strcmp(*a, *b);
  6. }
  7. DArray *create_words()
  8. {
  9. DArray *result = DArray_create(0, 5);
  10. char *words[] = {"asdfasfd", "werwar", "13234", "asdfasfd", "oioj"};
  11. int i = 0;
  12. for(i = 0; i < 5; i++) {
  13. DArray_push(result, words[i]);
  14. }
  15. return result;
  16. }
  17. int is_sorted(DArray *array)
  18. {
  19. int i = 0;
  20. for(i = 0; i < DArray_count(array) - 1; i++) {
  21. if(strcmp(DArray_get(array, i), DArray_get(array, i+1)) > 0) {
  22. return 0;
  23. }
  24. }
  25. return 1;
  26. }
  27. char *run_sort_test(int (*func)(DArray *, DArray_compare), const char *name)
  28. {
  29. DArray *words = create_words();
  30. mu_assert(!is_sorted(words), "Words should start not sorted.");
  31. debug("--- Testing %s sorting algorithm", name);
  32. int rc = func(words, (DArray_compare)testcmp);
  33. mu_assert(rc == 0, "sort failed");
  34. mu_assert(is_sorted(words), "didn't sort it");
  35. DArray_destroy(words);
  36. return NULL;
  37. }
  38. char *test_qsort()
  39. {
  40. return run_sort_test(DArray_qsort, "qsort");
  41. }
  42. char *test_heapsort()
  43. {
  44. return run_sort_test(DArray_heapsort, "heapsort");
  45. }
  46. char *test_mergesort()
  47. {
  48. return run_sort_test(DArray_mergesort, "mergesort");
  49. }
  50. char * all_tests()
  51. {
  52. mu_suite_start();
  53. mu_run_test(test_qsort);
  54. mu_run_test(test_heapsort);
  55. mu_run_test(test_mergesort);
  56. return NULL;
  57. }
  58. RUN_TESTS(all_tests);

你需要注意的事情是第四行testcmp的定义,它困扰了我一整天。你必须使用char **而不是char *,因为qsort会向你提供指向content数组中指针的指针。原因是qsort会打扫数组,使用你的比较函数来处理数组中每个元素的指针。因为我在contents中存储指针,所以你需要使用指针的指针。

有了这些之后,你只需要实现三个困难的搜索算法,每个大约20行。你应该在这里停下来,不过这本书的一部分就是学习这些算法的原理,附加题会涉及到实现这些算法。

基数排序和二分搜索

既然你打算自己实现快速排序、堆排序和归并排序,我打算向你展示一个流行的算法叫做基数排序。它的实用性很小,只能用于整数数组,并且看上去像魔法一样。这里我打算常见一个特殊的数据结构,叫做RadixMap,用于将一个整数映射为另一个。

下面是为新算法创建的头文件,其中也含有数据结构:

  1. #ifndef _radixmap_h
  2. #include <stdint.h>
  3. typedef union RMElement {
  4. uint64_t raw;
  5. struct {
  6. uint32_t key;
  7. uint32_t value;
  8. } data;
  9. } RMElement;
  10. typedef struct RadixMap {
  11. size_t max;
  12. size_t end;
  13. uint32_t counter;
  14. RMElement *contents;
  15. RMElement *temp;
  16. } RadixMap;
  17. RadixMap *RadixMap_create(size_t max);
  18. void RadixMap_destroy(RadixMap *map);
  19. void RadixMap_sort(RadixMap *map);
  20. RMElement *RadixMap_find(RadixMap *map, uint32_t key);
  21. int RadixMap_add(RadixMap *map, uint32_t key, uint32_t value);
  22. int RadixMap_delete(RadixMap *map, RMElement *el);
  23. #endif

你看到了其中有许多和Dynamic ArrayList数据结构相同的操作,不同就在于我只处理固定32位大小的uint32_t正忽视。我也会想你介绍C语言的一个新概念,叫做union

C联合体

联合体是使用不同方式引用内存中同一块区域的方法。它们的工作方式,就像你把它定义为sturct,然而,每个元素共享同一片内存区域。你可以认为,联合体是内存中的一幅画,所有颜色不同的元素都重叠在它上面。

它可以用于节约内存,或在不同格式之间转换内存块。它的第一个用途就是实现“可变类型”,你可以创建一个带有类型“标签”的结构体,之后在其中创建含有多种类型的联合体。用于在内存的不同格式之间转换时,只需要定义两个结构体,访问正确的那个类型。

首先让我向你展示如何使用C联合体构造可变类型:

  1. #include <stdio.h>
  2. typedef enum {
  3. TYPE_INT,
  4. TYPE_FLOAT,
  5. TYPE_STRING,
  6. } VariantType;
  7. struct Variant {
  8. VariantType type;
  9. union {
  10. int as_integer;
  11. float as_float;
  12. char *as_string;
  13. } data;
  14. };
  15. typedef struct Variant Variant;
  16. void Variant_print(Variant *var)
  17. {
  18. switch(var->type) {
  19. case TYPE_INT:
  20. printf("INT: %d\n", var->data.as_integer);
  21. break;
  22. case TYPE_FLOAT:
  23. printf("FLOAT: %f\n", var->data.as_float);
  24. break;
  25. case TYPE_STRING:
  26. printf("STRING: %s\n", var->data.as_string);
  27. break;
  28. default:
  29. printf("UNKNOWN TYPE: %d", var->type);
  30. }
  31. }
  32. int main(int argc, char *argv[])
  33. {
  34. Variant a_int = {.type = TYPE_INT, .data.as_integer = 100};
  35. Variant a_float = {.type = TYPE_FLOAT, .data.as_float = 100.34};
  36. Variant a_string = {.type = TYPE_STRING, .data.as_string = "YO DUDE!"};
  37. Variant_print(&a_int);
  38. Variant_print(&a_float);
  39. Variant_print(&a_string);
  40. // here's how you access them
  41. a_int.data.as_integer = 200;
  42. a_float.data.as_float = 2.345;
  43. a_string.data.as_string = "Hi there.";
  44. Variant_print(&a_int);
  45. Variant_print(&a_float);
  46. Variant_print(&a_string);
  47. return 0;
  48. }

你可以在许多动态语言实现中发现它。对于为语言中所有基本类型,代码中首先定义了一些带有变迁的可变类型,之后通常给你所创建的类型打上object标签。这样的好处就是Variant通常只需要VariantType type标签的空间,加上联合体最大成员的空间,因为C将Variant.data的每个元素堆起来,它们是重叠的,只保证有足够的空间放下最大的元素。

radixmap.h文件中我创建了RMElement联合体,用于在类型之间转换内存块。这里,我希望存储uint64_t定长整数用于排序目录,但是我也希望使用两个uint32_t用于表示数据的keyvalue对。通过使用联合体我就能够使用所需的两种不同方法来访问内存。

实现

接下来是实际的RadixMap对于这些操作的实现:

  1. /*
  2. * Based on code by Andre Reinald then heavily modified by Zed A. Shaw.
  3. */
  4. #include <stdio.h>
  5. #include <stdlib.h>
  6. #include <assert.h>
  7. #include <lcthw/radixmap.h>
  8. #include <lcthw/dbg.h>
  9. RadixMap *RadixMap_create(size_t max)
  10. {
  11. RadixMap *map = calloc(sizeof(RadixMap), 1);
  12. check_mem(map);
  13. map->contents = calloc(sizeof(RMElement), max + 1);
  14. check_mem(map->contents);
  15. map->temp = calloc(sizeof(RMElement), max + 1);
  16. check_mem(map->temp);
  17. map->max = max;
  18. map->end = 0;
  19. return map;
  20. error:
  21. return NULL;
  22. }
  23. void RadixMap_destroy(RadixMap *map)
  24. {
  25. if(map) {
  26. free(map->contents);
  27. free(map->temp);
  28. free(map);
  29. }
  30. }
  31. #define ByteOf(x,y) (((uint8_t *)x)[(y)])
  32. static inline void radix_sort(short offset, uint64_t max, uint64_t *source, uint64_t *dest)
  33. {
  34. uint64_t count[256] = {0};
  35. uint64_t *cp = NULL;
  36. uint64_t *sp = NULL;
  37. uint64_t *end = NULL;
  38. uint64_t s = 0;
  39. uint64_t c = 0;
  40. // count occurences of every byte value
  41. for (sp = source, end = source + max; sp < end; sp++) {
  42. count[ByteOf(sp, offset)]++;
  43. }
  44. // transform count into index by summing elements and storing into same array
  45. for (s = 0, cp = count, end = count + 256; cp < end; cp++) {
  46. c = *cp;
  47. *cp = s;
  48. s += c;
  49. }
  50. // fill dest with the right values in the right place
  51. for (sp = source, end = source + max; sp < end; sp++) {
  52. cp = count + ByteOf(sp, offset);
  53. dest[*cp] = *sp;
  54. ++(*cp);
  55. }
  56. }
  57. void RadixMap_sort(RadixMap *map)
  58. {
  59. uint64_t *source = &map->contents[0].raw;
  60. uint64_t *temp = &map->temp[0].raw;
  61. radix_sort(0, map->end, source, temp);
  62. radix_sort(1, map->end, temp, source);
  63. radix_sort(2, map->end, source, temp);
  64. radix_sort(3, map->end, temp, source);
  65. }
  66. RMElement *RadixMap_find(RadixMap *map, uint32_t to_find)
  67. {
  68. int low = 0;
  69. int high = map->end - 1;
  70. RMElement *data = map->contents;
  71. while (low <= high) {
  72. int middle = low + (high - low)/2;
  73. uint32_t key = data[middle].data.key;
  74. if (to_find < key) {
  75. high = middle - 1;
  76. } else if (to_find > key) {
  77. low = middle + 1;
  78. } else {
  79. return &data[middle];
  80. }
  81. }
  82. return NULL;
  83. }
  84. int RadixMap_add(RadixMap *map, uint32_t key, uint32_t value)
  85. {
  86. check(key < UINT32_MAX, "Key can't be equal to UINT32_MAX.");
  87. RMElement element = {.data = {.key = key, .value = value}};
  88. check(map->end + 1 < map->max, "RadixMap is full.");
  89. map->contents[map->end++] = element;
  90. RadixMap_sort(map);
  91. return 0;
  92. error:
  93. return -1;
  94. }
  95. int RadixMap_delete(RadixMap *map, RMElement *el)
  96. {
  97. check(map->end > 0, "There is nothing to delete.");
  98. check(el != NULL, "Can't delete a NULL element.");
  99. el->data.key = UINT32_MAX;
  100. if(map->end > 1) {
  101. // don't bother resorting a map of 1 length
  102. RadixMap_sort(map);
  103. }
  104. map->end--;
  105. return 0;
  106. error:
  107. return -1;
  108. }

像往常一样键入它并使它通过单元测试,之后我会解释它。尤其要注意radix_sort函数,我实现它的方法非常特别。

  1. #include "minunit.h"
  2. #include <lcthw/radixmap.h>
  3. #include <time.h>
  4. static int make_random(RadixMap *map)
  5. {
  6. size_t i = 0;
  7. for (i = 0; i < map->max - 1; i++) {
  8. uint32_t key = (uint32_t)(rand() | (rand() << 16));
  9. check(RadixMap_add(map, key, i) == 0, "Failed to add key %u.", key);
  10. }
  11. return i;
  12. error:
  13. return 0;
  14. }
  15. static int check_order(RadixMap *map)
  16. {
  17. RMElement d1, d2;
  18. unsigned int i = 0;
  19. // only signal errors if any (should not be)
  20. for (i = 0; map->end > 0 && i < map->end-1; i++) {
  21. d1 = map->contents[i];
  22. d2 = map->contents[i+1];
  23. if(d1.data.key > d2.data.key) {
  24. debug("FAIL:i=%u, key: %u, value: %u, equals max? %d\n", i, d1.data.key, d1.data.value,
  25. d2.data.key == UINT32_MAX);
  26. return 0;
  27. }
  28. }
  29. return 1;
  30. }
  31. static int test_search(RadixMap *map)
  32. {
  33. unsigned i = 0;
  34. RMElement *d = NULL;
  35. RMElement *found = NULL;
  36. for(i = map->end / 2; i < map->end; i++) {
  37. d = &map->contents[i];
  38. found = RadixMap_find(map, d->data.key);
  39. check(found != NULL, "Didn't find %u at %u.", d->data.key, i);
  40. check(found->data.key == d->data.key, "Got the wrong result: %p:%u looking for %u at %u",
  41. found, found->data.key, d->data.key, i);
  42. }
  43. return 1;
  44. error:
  45. return 0;
  46. }
  47. // test for big number of elements
  48. static char *test_operations()
  49. {
  50. size_t N = 200;
  51. RadixMap *map = RadixMap_create(N);
  52. mu_assert(map != NULL, "Failed to make the map.");
  53. mu_assert(make_random(map), "Didn't make a random fake radix map.");
  54. RadixMap_sort(map);
  55. mu_assert(check_order(map), "Failed to properly sort the RadixMap.");
  56. mu_assert(test_search(map), "Failed the search test.");
  57. mu_assert(check_order(map), "RadixMap didn't stay sorted after search.");
  58. while(map->end > 0) {
  59. RMElement *el = RadixMap_find(map, map->contents[map->end / 2].data.key);
  60. mu_assert(el != NULL, "Should get a result.");
  61. size_t old_end = map->end;
  62. mu_assert(RadixMap_delete(map, el) == 0, "Didn't delete it.");
  63. mu_assert(old_end - 1 == map->end, "Wrong size after delete.");
  64. // test that the end is now the old value, but uint32 max so it trails off
  65. mu_assert(check_order(map), "RadixMap didn't stay sorted after delete.");
  66. }
  67. RadixMap_destroy(map);
  68. return NULL;
  69. }
  70. char *all_tests()
  71. {
  72. mu_suite_start();
  73. srand(time(NULL));
  74. mu_run_test(test_operations);
  75. return NULL;
  76. }
  77. RUN_TESTS(all_tests);

我不应该向你解释关于测试的过多东西,它只是模拟将随机正是放入RadixMap,确保你可以可靠地将其取出。也不是非常有趣。

radixmap.c中的大多数操作都易于理解,如果你阅读代码的话。下面是每个基本函数作用及其工作原理的描述:

RadixMap_create

像往常一样,我分配了结构体所需的内存,结构体在radixmap.h中定义。当后面涉及到radix_sort时我会使用tempcontents

RadixMap_destroy

同样,销毁我所创建的东西。

radix_sort

这个数据结构的灵魂,我会在下一节中解释其作用。

RadixMap_sort

它使用了radix_sort函数来实际对contents进行排序。

RadixMap_find

使用二分搜索算法来寻找提供的key,我之后会解释它的原理。

RadixMap_add

使用RadixMap_sort函数,它会在末尾添加keyvalue,然后简单地重新排序使一切元素都有序。一旦排序完,RadixMap_find会正确工作,因为它是二分搜索。

RadixMap_delete

工作方式类似RadixMap_add,除了“删除”结构中的元素,通过将它们的值设为无符号的32为整数的最大值,也就是UINT32_MAX。这意味着你不能使用这个值作为合法的键,但是它是元素删除变得容易。简单设置它之后排序,它会被移动到末尾,这就算删除了。

学习我所描述的代码,接下来还剩RadixMap_sortradix_sortRadixMap_find需要了解。

RadixMap_find 和二分搜索

我首先以二分搜索如何实现开始。二分搜索是一种简单算法,大多数人都可以直观地理解。实际上,你可以取一叠游戏卡片(或带有数字的卡片)来手动操作。下面是该函数的工作方式,也是二分搜索的原理:

  • 基于数组大小设置上界和下界。
  • 获取上下界之间的中间元素。
  • 如果键小于这个元素的值,就一定在它前面,所以上界设置为中间元素。
  • 如果键大于这个元素的值,就一定在它后面,所以下界设置为中间元素。
  • 继续循环直到上界和下界越过了彼此。如果退出了循环则没有找到。

你实际上所做的事情是,通过挑选中间的值来比较,猜出key可能的位置。由于数据是有序的,你知道key一定会在它前面或者后面,这样就能把搜索区域分成两半。之后你继续搜索知道找到他,或者越过了边界并穷尽了搜索空间。

RadixMap_sort 和 radix_sort

如果你事先手动模拟基数排序,它就很易于理解。这个算法利用了一个现象,数字都以十进制字符的序列来表示,按照“不重要”到“重要”的顺序排列。之后它通过十进制字符来选取数字并且将它们储存在桶中,当它处理完所有字符时,数字就排好序了。一开始它看上去像是魔法,浏览代码也的确如此,但是你要尝试手动执行它。

为了解释这个算法,需要先写下一组三位的十进制数,以随机的顺序,假设就是223、912、275、100、633、120 和 380。

  • 按照它们的个位,将数字放入桶中:[380, 100, 120], [912], [633, 223], [275]
  • 现在遍历每个桶中的数字,接着按十位排序:[100], [912], [120, 223], [633], [275], [380]
  • 现在每个桶都包含了按照个位和十位排序后的数字。接着我需要按照这个顺序遍历,并把它们放入最后百位的桶中:[100, 120], [223, 275], [380], [633], [912]
  • 到现在为止,每个数字都按照百位、十位和个位排序,并且如果我按照顺序遍历每个桶,我会得到最终排序的结果:100, 120, 223, 275, 380, 633, 912

确保你多次重复了这个过程,便于你理解它如何工作。这实在是一种机智的算法,并且最重要的是它对于任何大小的数字都有效。所以你可以用它来排序比较大的数字,因为你一次只是处理一位。

在我的环境下,“字符”是独立的8位字节,所以我需要256个桶来储存这些数字按照字节的分布结果。我需要一种方法来储存它,并且不需要花费太多的空间。如果你查看radix_sort,首先我会构建count直方图,便于我了解对于给定的offset,每个字节的频率。

一旦我知道了每一种字节的数量(共有256种),我就可以将目标数组用于存储这些值的分布。比如,如果0x00的数量为10个,我就可以将它们放在目标数组的前10个位置中。这可以让我索引到它们在目标数组中的位置,这就是radix_sort中的第二个for循环。

最后,当我知道它们在目标数组中储存在哪里,我只是遍历source数组对于当前offset的所有字节,并且将数值按顺序放入它们的位置中。ByteOf宏的使用有助于保持代码整洁,因为它需要一些指针的黑魔法,但是最后当for循环结束之后,所有整数都会按照它们的字节放入桶中。

我在RadixMap_sort中对这些64位的整数按照它们的前32位进行排序,这非常有意思。还记得我是如何将键和值放入RMElement类型的联合体了吗?这意味着如果要按照键来对这个数组排序,我只需要对每个整数前4个字节(32位/8位每字节)进行排序。

如果你观察RadixMap_sort,你会看到我获取了contentstemp的便利指针,用于源数组和目标数组,之后我四次调用radix_sort。每次调用我将源数组和目标数组替换为下一字节的情况。当我完成时,radix_sort就完成了任务,并且contents中也有了最后的结果。

如何改进

这个实现有个很大的缺点,就是它遍历了整个数组四次。它执行地很快,但是如果你通过需要排序的数值大小来限制排序的总量,会更好一些。

有两个方法可以用于改进这个实现:

  • 使用二分搜索来寻找新元素的最小位置,只对这个位置到微末之间进行排序。你需要找到它,将新元素放到末尾,之后对它们之间进行排序。大多数情况下这会显著地缩减排序范围。
  • 跟踪当前所使用的最大的键,之后只对足够的位数进行排序,来处理这个键。你也可以跟踪最小的数值,之后只对范围中必要的字节进行排序。为了这样做,你需要关心CPU的整数存储顺序(大小端序)。

附加题

  • 实现快速排序、堆排序和归并排序,并且提供一个#define让其他人在二者(标准库和你的实现)当中进行选择,或者创建另一套不同名称的函数。使用我教给你的技巧,阅读维基百科的算法页面,之后参照伪代码来实现它。
  • 对比你的实现和标准库实现的性能。
  • 使用这些排序函数创建DArray_sort_add,它可以向DArray添加元素,但是随后对数组排序。
  • 编写DArray_find,使用RadixMap_find中的二分搜索算法和DArray_compare,来在有序的DArray中寻找元素。