Хеш-таблица с отдельной цепочкой и динамическим изменением размера

Попытка чистой реализации этой важной структуры данных на C.

Трудно сделать такие структуры универсальными в C без потери производительности, поэтому мы специализируемся на char* ключи и int values, но использует некоторые псевдонимы типов, так что для изменения типа ключа или значения нужно изменить только несколько мест.

Хеш-таблица динамически увеличивается в 2 раза, когда используется 80% слотов для поддержания высокой эффективности.

В качестве демонстрации мы разбираем полное собрание сочинений Шекспира, вызов ht_inc() за каждое слово. Затем постройте плоское «представление» хеш-таблицы, отсортируйте и запишите 10 самых распространенных слов. На моей машине вся задача занимает всего 50 мсек.

Отзывы о качестве стиля кодирования.

#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef char* ht_key_t;
typedef int   ht_value_t;

// key => value plus pointer to next item for hash collisions
typedef struct HashTableItem HashTableItem;
struct HashTableItem {
  ht_key_t       key;
  ht_value_t     value;
  HashTableItem* next;
};

// Array of pointers to HashTableItems, plus counters
typedef struct HashTable {
  HashTableItem** slots;
  size_t          size;      // how many slots are there
  size_t          scount;    // how many slots are used
  size_t          itemcount; // how many items are there
} HashTable;

// Creates a new HashTableItem 
HashTableItem* ht_create_item(ht_key_t key, ht_value_t value) {
  HashTableItem* item = malloc(sizeof *item);
  if (!item) {
    perror("malloc item");
    exit(EXIT_FAILURE);
  }
  item->key   = strdup(key); // take a copy
  item->value = value;
  item->next  = NULL;
  return item;
}

// Creates a new HashTable
HashTable* ht_create(size_t size) {
  HashTable* table = malloc(sizeof *table);
  if (!table) {
    perror("malloc table");
    exit(EXIT_FAILURE);
  }
  table->size      = size;
  table->scount    = 0;
  table->itemcount = 0;
  table->slots     = calloc(table->size, sizeof *table->slots); // NOLINT
  if (!table->slots) {
    perror("calloc items");
    exit(EXIT_FAILURE);
  }
  return table;
}

// Frees an item depending on their types, if the key or value members
// need freeing that needs to happen here too
void ht_free_item(HashTableItem* item) {
  free(item->key);
  free(item);
}

// Frees the whole hashtable
void ht_free(HashTable* table) {
  // free the HashTableItems in the linked lists
  for (size_t i = 0; i < table->size; i++) {
    HashTableItem* item = table->slots[i];
    while (item) {
      HashTableItem* next = item->next;
      ht_free_item(item);
      item = next;
    }
  }
  // free the array of pointers to HashTableItems
  free(table->slots);
  free(table);
}

// hash function. crucial to efficient operation
// returns value in range [0, size)
// an appropriate hash function for short strings is FNV-1a
// https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function#FNV-1a_hash
size_t ht_hash(size_t size, const char* str) {
  uint64_t hash = 0xcbf29ce484222325; // FNV_offset_basis
  while (*str) hash = (hash ^ (uint8_t)*str++) * 0x100000001b3; // FNV_prime
  return hash % size;                                           // fit to table
}

void ht_grow(HashTable* table) {
  table->scount++;
  if (table->scount * 100 / table->size >= 80) {
    size_t          nsize  = table->size * 2;
    HashTableItem** nslots = calloc(nsize, sizeof *table->slots); // NOLINT
    if (!nslots) {
      perror("calloc nitems");
      exit(EXIT_FAILURE);
    }

    size_t ncount = 0;
    for (size_t i = 0; i < table->size; i++) {
      HashTableItem* item = table->slots[i];
      while (item) {
        HashTableItem*  next  = item->next; // save next
        HashTableItem** nslot = &nslots[ht_hash(nsize, item->key)];
        if (!*nslot) ++ncount;
        item->next = *nslot; // push into new list
        *nslot     = item;
        item       = next;
      }
    }
    free(table->slots);
    table->scount = ncount;
    table->size   = nsize;
    table->slots  = nslots;
  }
}

// Inserts an item (or updates if exists)
void ht_insert(HashTable* table, ht_key_t key, ht_value_t value) {
  HashTableItem** slot           = &table->slots[ht_hash(table->size, key)];
  HashTableItem*  item           = *slot;
  bool            direct_slot     = true;
  while (item) {
    if (strcmp(item->key, key) == 0) {
      // exists, update value, free old value if needed
      item->value = value;
      return;
    }
    slot           = &item->next;
    item           = *slot;
    direct_slot     = false;
  }
  *slot = ht_create_item(key, value); // new entry
  ++table->itemcount;
  if (direct_slot) ht_grow(table); // HashTable accounting
}

// increments the value for a key or inserts with value = 1
// specialised for ht_value_t=int and faster than search then update.
void ht_inc(HashTable* table, ht_key_t key) {
  HashTableItem** slot           = &table->slots[ht_hash(table->size, key)];
  HashTableItem*  item           = *slot;
  bool            direct_slot     = true;
  while (item) {
    if (strcmp(item->key, key) == 0) {
      ++item->value;
      return;
    }
    slot           = &item->next;
    item           = *slot;
    direct_slot     = false;
  }
  *slot = ht_create_item(key, 1); // not found, init with one
  ++table->itemcount;
  if (direct_slot) ht_grow(table); // HashTable accounting
}

// Deletes an item from the table
void ht_delete(HashTable* table, ht_key_t key) {
  HashTableItem** slot           = &table->slots[ht_hash(table->size, key)];
  HashTableItem*  item           = *slot;
  bool            direct_slot     = true;
  while (item) {
    if (strcmp(item->key, key) == 0) {
      *slot = item->next; // remove item from linked list
      ht_free_item(item);
      --table->itemcount;
      if (direct_slot) --table->scount; // HashTable accounting
      return;
    }
    slot           = &item->next;
    item           = *slot;
    direct_slot     = false;
  }
}

// Searches the key in the hashtable
// and returns NULL ptr if it doesn't exist
HashTableItem* ht_search(HashTable* table, ht_key_t key) {
  HashTableItem* item = table->slots[ht_hash(table->size, key)];
  while (item) {
    if (strcmp(item->key, key) == 0) return item;
    item = item->next;
  }
  return NULL;
}

// debug printing. customise printf format strings by key & value types
void ht_print(HashTable* table) {
  printf("n---- Hash Table ---n");
  for (size_t i = 0; i < table->size; i++) {
    printf("@%zu: ", i);
    HashTableItem* item = table->slots[i];
    while (item) {
      printf("%s => %d | ", item->key, item->value);
      item = item->next;
    }
    printf("n");
  }
  printf("-------------------n");
}

// debug printing. customise printf format strings by key & value types
void ht_print_search(HashTable* table, ht_key_t key) {
  HashTableItem* val;
  if ((val = ht_search(table, key)) == NULL) {
    printf("Key:%s does not existn", key);
    return;
  }
  printf("Key:%s => %dn", key, val->value);
}

static inline bool ht_is_alpha(char c) {
  return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');
}
static inline char ht_tolower(char c) { return (c < 'a') ? c + 'a' - 'A' : c; }

int cmp_ht_items(const void* a, const void* b) {
  int a_val = (*(HashTableItem**)a)->value;
  int b_val = (*(HashTableItem**)b)->value;
  if (a_val == b_val) return 0;
  return a_val < b_val ? 1 : -1;
}

int main() {
  // some tests
  HashTable* ht = ht_create(4);
  ht_insert(ht, "1", 10);
  ht_insert(ht, "2", 20);
  ht_insert(ht, "Hel3", 30);
  ht_insert(ht, "Cau4", 40);
  ht_print(ht);
  ht_insert(ht, "Cau6", 60); // grow!
  ht_print(ht);
  ht_inc(ht, "11"); // increment
  ht_inc(ht, "21"); // increment
  ht_inc(ht, "31"); // increment
  ht_inc(ht, "41"); // increment
  ht_inc(ht, "51"); // increment
  ht_inc(ht, "61"); // increment
  ht_inc(ht, "71"); // increment
  ht_inc(ht, "81"); // increment and grow again
  ht_print(ht);

  ht_print_search(ht, "1");
  ht_print_search(ht, "2");
  ht_print_search(ht, "3");
  ht_print_search(ht, "Hel3");
  ht_print_search(ht, "Cau4");

  ht_insert(ht, "Cau6", 61); // update
  ht_inc(ht, "1");           // increment
  ht_inc(ht, "Hel3");        // increment
  ht_inc(ht, "new");         // increment
  ht_print(ht);

  ht_delete(ht, "Hel3");
  ht_delete(ht, "Cau6");
  ht_delete(ht, "1");
  ht_print(ht);

  ht_free(ht);
  // end of tests

  // shakespeare demo
  FILE* fp = fopen("shakespeare.txt", "re");
  if (!fp) {
    perror("fopen");
    exit(EXIT_FAILURE);
  }
  ht = ht_create(32 * 1024);

#define BUFSIZE 1024
#define WORDSIZE 50
  char  buf[BUFSIZE];
  char  word[WORDSIZE];
  char* word_ptr = word;
  while (!ferror(fp) && !feof(fp)) {
    size_t bytes_read = fread(buf, 1, BUFSIZE, fp);
    char*  buf_ptr    = buf;
    while ((buf_ptr < buf + bytes_read)) {
      char c = *buf_ptr;
      if (ht_is_alpha(c)) {
        *(word_ptr++) = ht_tolower(c);
        if (word_ptr - word == WORDSIZE - 1) { // -1 for NULL terminator
          fputs("word too long. terminatingn", stderr);
          exit(EXIT_FAILURE);
        }
      } else if (word_ptr == word) {
        // ignore repeated terminators
      } else {
        *(word_ptr++) = '';
        ht_inc(ht, word); // record
        word_ptr = word;  // restart new word
      }
      ++buf_ptr; // next char from buf
    }
  }
  fclose(fp);

  // build a flat view of the hashtable items
  HashTableItem** itemview = malloc(ht->itemcount * sizeof *itemview); // NOLINT
  if (!itemview) {
    perror("malloc itemview");
    exit(EXIT_FAILURE);
  }
  HashTableItem** curritem = itemview;
  for (size_t i = 0; i < ht->size; i++) {
    HashTableItem* item = ht->slots[i];
    while (item) {
      *curritem++ = item;
      item        = item->next;
    }
  }
  // now sort that view and print the top 10
  qsort(itemview, ht->itemcount, sizeof *itemview, cmp_ht_items); // NOLINT
  for (size_t i = 0; i < 10; i++)
    printf("%s => %dn", itemview[i]->key, itemview[i]->value);

  free(itemview);

  // ht_print(ht);
  ht_free(ht);
}

1 ответ
1

Используйте больше calloc()

я хотел бы использовать calloc() почти везде. Это гарантирует, что память обнулена, поэтому у вас не будет сюрпризов, если вы забудете инициализировать переменную-член. Я бы не стал сильно беспокоиться о производительности; компилятор должен иметь возможность оптимизировать обнуление, если он видит, что вы устанавливаете для переменной-члена что-то другое. Итак, используйте его в ht_create_item() и ht_create() где вы в настоящее время используете malloc().

Старайтесь избегать косвенного обращения

В вашей хеш-таблице есть массив slots который содержит указатели на элементы, однако сами элементы довольно маленькие, это всего лишь два указателя и int. Поскольку вы стремитесь к коэффициенту загрузки от 0,4 до 0,8, он будет использовать меньше памяти для устранения косвенного обращения:

typedef struct HashTable {
  HashTableItem* slots;
  ...
} HashTable;

Это также должно дать небольшой прирост производительности.

Избегайте дублирования кода

ht_inc() и ht_delete() выглядят очень похоже. Можно было бы выделить общие части в отдельную функцию. Я думаю, вы можете сделать это, создав функцию, которая находит указатель на то, где должен быть / идти элемент, и возвращает указатель на этот указатель. По сути, ht_search() который возвращает слот вместо самого элемента.

static HashTableItem** ht_search_slot(HashTable* table, ht_key_t key, bool *direct) {
  HashTableItem** slot = &table->slots[ht_hash(table->size, key)];
  *direct = true;
  while (*slot) {
    if (strcmp((*slot)->key, key) == 0) break;
    *direct = false;
    slot = &(*slot)->next;
  }
  return slot;
}

void ht_inc(HashTable* table, ht_key_t key) {
  bool direct_slot;
  HashTableItem** slot = ht_search_slot(table, key, &direct_slot);
  HashTableItem*  item = *slot;
  if (item) {
    ++item->value;
    return;
  }
  *slot = ht_create_item(key, 1); // not found, init with one
  ++table->itemcount;
  if (direct_slot) ht_grow(table); // HashTable accounting
}

void ht_delete(HashTable* table, ht_key_t key) {
  bool direct_slot;
  HashTableItem** slot = ht_search_slot(table, key, &direct_slot);
  HashTableItem*  item = *slot;
  if (item) {
    *slot = item->next; // remove item from linked list
    ht_free_item(item);
    --table->itemcount;
    if (direct_slot) --table->scount; // HashTable accounting
  }
}

HashTableItem* ht_search(HashTable* table, ht_key_t key) {
  bool direct_slot;
  return *ht_search_slot(table, key, &direct_slot);
}

Конечно, этот подход будет противоречить приведенному выше совету избегать косвенного обращения, хотя, возможно, что-то подобное все еще возможно.

Всегда звонить ht_grow() при вставке элементов

Ты только звонишь ht_grow() когда используется пустой прямой слот. Однако, если элемент вставлен в уже используемый слот, вы его не вызываете. Это означает, что если вам не повезло, все хешируется в один и тот же слот, и вы никогда не сможете расти и балансировать. Я бы перестраховался и просто безоговорочно позвонил ht_grow() при вставке элемента. Конечно, ht_grow() следует использовать itemcount вместо scount чтобы проверить, нужно ли увеличивать хеш-таблицу.

Избегайте операций деления и по модулю

Деления (а значит, и операции по модулю) довольно дороги. Я бы избегал их, следя за тем, чтобы размер хеш-таблицы всегда был степенью двойки. А если вы проследите, чтобы размер был не менее 16 элементов, то вы можете заменить чек в ht_grow() по:

if (table->itemcount >= 13 * (table->size >> 4)) {
  ...
}

Это приведет к изменению размера, когда хеш-таблица заполнена на 81,25%. Вы даже можете избавиться от умножения, сохранив двоичный логарифм размера, но если вы не планируете запускать этот код на микроконтроллерах, я бы не стал беспокоиться об этом. А в хэш-функции вы можете заменить модуль следующим образом:

return hash & (size - 1);

  • Хороший материал, спасибо. Я рассмотрел большинство из них. Я держал окольные, чтобы не привязывать себя к этому ключ => значение структуры, но это, конечно, спорно. Я подумал о calloc, м-м .. Дублирование кода очень интересно, и я все еще боролся с этим. Каждая функция состоит всего из 10 строк, но они представляют собой «несколько сложное» манипулирование указателями. Таким образом, не только дублирование, но и ясность может улучшиться, если я исключу «поиск слота с частью связанного списка». Я думал о коэффициенте загрузки по количеству элементов и количеству слотов, чаще? И мне очень нравится идея силы двух и убрать по модулю / делить. Еще раз спасибо.

    – Оливер Шёнрок

  • Вы уверены, что это правильно? table->itemcount >= 13 * (table->size >> 2) Кажется, я не могу заставить это работать в моей голове. Для меня это сработало бы только при коэффициенте загрузки> 300%? Или я что-то упускаю? не должно быть table->itemcount * 10 >= (table->size << 3) для отсечения 80%? И никаких ограничений на размер> = 16 (что нарушает мои тесты). Фактически, если я сделаю замену на количество элементов, то пороговое значение должно быть больше 160%, но это легко настроить.

    – Оливер Шёнрок


  • Интересно, что удаление % модуль в ht_hash сразу дало 20% прирост, тогда как if в ht_grow вообще ничего не сделал (одинаковое количество звонков). Я предполагаю, что в большом подразделении процессоров Intel это быстро (но по модулю – нет), поэтому, если я не собираюсь использовать 8-битный pic18, разделение, вероятно, более читабельно? (8-битная картинка все равно не может обрабатывать uint64, malloc и т.д. и т.д.)

    – Оливер Шёнрок

  • Это должно было быть >> 4, что равно / 16. Фактически вы можете просто написать последнее, компилятор может оптимизировать это, потому что он видит, что это константа. Ваш вариант сделать ... * 10 >= ... << 3 также должен работать, но могут возникнуть проблемы с очень большими значениями table->size.

    – Г. Сон

  • 1

    Думал, вам может понравиться этот коммит: github.com/oschonrock/cproj/commit/…

    – Оливер Шёнрок

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *