C语言之常用数据结构

C 语言之常用数据结构

在日常的工作开发中,最常用的数据结构有:数组、链表、栈、队列、哈希表和二叉搜索树。其中:

  1. 数组和链表是线性数据结构的两种典型物理实现。 它们是最基础的数据结构,是构成其它更复杂数据结构的基石。

    • 数组采用一段连续的内存空间存储,最大的特点是基于索引的快速随机访问。

    • 链表则由非连续存储的、基于指针链接的结点组成,提供了灵活的插入和删除操作。

  2. 栈和队列是数组和链表的经典应用场景。 它们都可以基于数组或链表实现,不同的是栈遵循先进后出的原则,而队列遵循先进先出的原则。

  3. 哈希表 通常结合数组与链表来实现,用于提供快速的数据查询能力。

  4. 二叉搜索树 是一种基于树形的数据结构,它提供了优秀的搜索效率。二叉搜索树在实现时可以借鉴链表的实现思路,所以它和链表也有一定的关联。

如何学习数据结构?

学习一种数据结构,可以从三个角度入手:

  1. 理论基础与模型。 学习一种数据结构,首先要做的就是理解它的概念模型以及知晓其理论基础,包括其特点、用途和适用场景等。
  2. 基础操作。 在掌握了基本理论后,就需要进一步熟悉该数据结构的基础操作以及这些操作的工作原理。这些基础操作一般包括:增、删、查以及遍历。(改操作是查操作的扩展)
  3. 实现与应用。 在了解理论和操作后,就可以编写代码来实现这些数据结构了,这不仅有助于巩固理解数据结构,同时也能提升编程能力。

推荐两个学习网站:

  1. Visualgo 可视化数据结构与算法
  2. Data Structure Visualization (usfca.edu)

动态数组

数组是一种基本的、常见的数据结构,它以其快速的访问速度而著称。

然而,在 C 语言中,传统的数组(指在栈上分配内存空间的数组)拥有一个显著的限制:它们的长度一旦确定便无法更改。这在处理动态数据集时显得尤为不便,如在不确定数据量的情况下存储用户输入。

C++通过引入 Vector 这样的动态数组解决了这个问题(Java 中的 ArrayList 也是类似的),它可以根据需要动态调整大小。遗憾的是,C 语言标准库中并未提供类似的结构。但不要担心,借助于动态内存分配函数如 malloc 和 realloc,我们完全可以在 C 中手动实现类似的功能。

设计思路

在 C 语言中实现一个动态数组,通常使用一个结构体定义,如下所示:

// 使用别名来命名元素类型,如果未来需要改变元素类型,只需修改这个别名即可。
// 这么做提升代码的可维护性和扩展性, 这实际上是模拟了 C++的泛型编程
typedef int ElementType;

typedef struct {
    ElementType *data;      // 指向动态分配数组的指针
    int size;    // 当前动态数组中元素的数量
    int capacity; // 动态数组当前分配的最大容量
} Vector;

可以用下图来理解一个 Vector 动态数组:

Vector动态数组-内存示意图

除此之外,为了满足正常的动态数组的功能,我们还需要实现以下函数:

// 初始化一个 Vector 动态数组.这实际上是模拟了 C++的默认构造函数
Vector* vector_create();
// 销毁一个 Vector 动态数组,释放内存。这实际上模拟了 C++的析构函数
void vector_destroy(Vector* v);
// 向动态数组末尾添加一个元素
void vector_push_back(Vector* v, ElementType element);
// 在动态数组最前面添加元素,所有元素依次后移
void vector_push_front(Vector* v, ElementType val);
// 将元素 val 添加到索引为 idx 的位置,idx 后面的元素依次后移
void vector_insert(Vector* v, int idx, ElementType val);
// 遍历打印整个 Vector 动态数组
void vector_print(Vector* v);

代码实现

vector.h
  #pragma once
// 使用别名来命名元素类型,如果未来需要改变元素类型,只需修改这个别名即可。
// 这么做提升代码的可维护性和扩展性, 这实际上是模拟了 C++/Java 等编程语言的泛型编程
typedef int ElementType;
typedef struct {
    ElementType* data;      // 指向动态分配数组的指针
    int size;    // 当前动态数组中元素的数量
    int capacity; // 动态数组当前分配的最大容量
} Vector;
// 初始化一个 Vector 动态数组.这实际上是模拟了 C++的默认构造函数
Vector* vector_create();
// 销毁一个 Vector 动态数组,释放内存。这实际上模拟了 C++的析构函数
void vector_destroy(Vector* v);
// 向动态数组末尾添加一个元素
void vector_push_back(Vector* v, ElementType element);
// 在动态数组最前面添加元素,所有元素依次后移
void vector_push_front(Vector* v, ElementType val);
// 将元素 val 添加到索引为 idx 的位置,idx 后面的元素依次后移
void vector_insert(Vector* v, int idx, ElementType val);
// 遍历打印整个 Vector 动态数组
void vector_print(Vector* v);
   

头文件主要用于存放以下结构:

  1. 函数的声明
  2. 结构体的定义
  3. 类型别名的定义
  4. 宏定义
  5. ...

头文件允许程序员在多个源文件之间共享函数的声明以及结构体、类型别名和宏的定义,从这个角度讲头文件类似 C++、Java 等编程语言中的接口。

普遍采取的做法是:在头文件中声明函数,然后在某个源文件中包含头文件实现这些函数,最后在其余源文件中包含头文件,使用这些函数。

使用头文件,促进了 模块化编程,提高了 代码的复用性,使得代码更加易于维护。

注意:头文件作为接口的目的是对外暴露公共部分,某些不属于公共部分不需要对外暴露的不要放在头文件当中!!!

包含头文件与实现函数

vector.c
 #include "vector.h"
#include < stdio.h>
#include < stdlib.h>
#define DEFAULT_CAPACITY 10 // 设置动态数组的默认最小容量
#define THRESHOLD 1024
static void vector_resize(Vector* v) {
	// 只要调用这个函数肯定就是需要扩容的
	int old_capacity = v-> capacity;
	int new_capacity = (old_capacity < THRESHOLD) ?
		(old_capacity << 1) :   // 容量还未超出阈值每次扩容 2 倍
		(old_capacity + (old_capacity >> 1));   // 容量超出阈值每次扩容 1.5 倍
	// 利用 realloc 重新分配动态数组
	ElementType * tmp = realloc(v-> data, new_capacity * sizeof(ElementType));    // realloc 惯用法
	if (tmp == NULL) {
		printf("realloc failed in resize_vector.\n");
		exit(1);    // 扩容失败,退出整个程序。或者也可以做别的处理
	}
	// 扩容成功,重新赋值 Vector 成员
	v-> data = tmp;
	v-> capacity = new_capacity;
}
Vector* vector_create() {
	Vector * v = (Vector*)calloc(1, sizeof(Vector));
	if (! v)
	{
		printf("Error: calloc failed in vector_create!\n");
		return NULL;
	}
	v-> data = (ElementType*)calloc(DEFAULT_CAPACITY, sizeof(ElementType));
	if (! v-> data) {
		printf("Error: calloc failed in vector_create!\n");
		free(v);
		return NULL;
	}
	// 继续初始化 Vector 的其它成员
	v-> capacity = DEFAULT_CAPACITY;
	// size 已自动初始化为 0 值,所以不需要再次赋值了。但如果用 malloc 就不要忘记初始化它
	return v;
}
void vector_destroy(Vector* v) {
	free(v-> data);
	free(v);
}
void vector_push_back(Vector* v, ElementType element) {
	if (v-> size == v-> capacity)
	{
		vector_resize(v);
	}
	v-> data [v-> size] = element;
	v-> size++;
}
void vector_push_front(Vector* v, ElementType val) {
	if (v-> size == v-> capacity)
	{
		vector_resize(v);
	}
	// 扩容完成后或不需要扩容,即从首元素开始将所有元素向后移动
		// 倒着遍历数组方便向后移动元素
	for (int i = v-> size - 1; i >= 0; i--) {
		v-> data [i + 1] = v-> data [i];
	}
	// 新增的元素成为首元素
	v-> data [0] = val;
	v-> size++;
}
void vector_insert(Vector* v, int idx, ElementType val) {
	// 先做参数校验,避免传入非法索引
	if (idx < 0 || idx > v-> size) {     // 做插入操作时索引合法范围是 [0, size]
		printf("Illegal argument: idx = %d, size = %d\n", idx, v-> size);
		exit(1);    // 直接退出进程,也可以用别的方式进行错误处理
	}
	if (v-> size == v-> capacity)
	{
		vector_resize(v);
	}
	for (int i = v-> size - 1; i >= idx; i--) {
		v-> data [i + 1] = v-> data [i];
	}
	v-> data [idx] = val;
	v-> size++;
}
void vector_print(Vector* v) {
	printf("[");
	for (int i = 0; i < v-> size; i++) {
		printf("%d, ", v-> data [i]);
	}
	printf("\b\b]\n");
} 

完成一系列关于 Vector 的开发实现后,接下来的关键步骤就是测试。

在实际的开发中,当我们完成了某个独立模块、组件的开发后,都需要编写一系列测试用例,进行彻底的单元测试工作,来及时的发现和修复程序中的缺陷和 bug,从而提高整体代码质量。

编写测试用例,完成单元测试开发,也是一个普通程序员的本职工作之一。

在这里,可以创建一个 "test.c" 的源文件,用于运行一系列的测试用例:

  1. 创建和销毁。验证 Vector 是否可以成功创建和销毁,没有内存泄漏。
  2. 添加元素:测试向 Vector 添加元素的功能,确保数组可以正确地扩容。
  3. 边界条件检查:检查 Vector 在极端情况下(如恰好超出容量)的行为是否正确。
  4. ....
test.c
 #define _CRT_SECURE_NO_WARNINGS
#include  < stdio.h>
#include < stdlib.h>
#include "vector.h"
#define N 100
int main(void) {
    Vector *v = vector_create();
    if (v == NULL) {
        printf("error: vector_create failed in main.\n");
        exit(1);
    }
    // 测试自动扩容机制
    for (int i = 0; i < N; ++i) {
        vector_push_back(v, i + 1);
    }
    vector_print(v);    // 期望打印结果: 1-100
    // 测试头部插入新元素
    vector_push_front(v, 0);
    vector_print(v);    // 期望打印结果: 0, 1-100
    // 测试根据索引插入新元素
    vector_insert(v, 0, -1);
    vector_print(v);    // 期望打印结果: -1, 0, 1-100
    vector_destroy(v);
    return 0;
}

链表

链表:是一种线性数据结构,由一系列结点 "链接" 构成。

结点:在 C 语言中,一个结点通常是一个结构体对象。该结构体对象由数据域和指针域两部分组成。数据域用于存储数据,而指针域则用于存放其它结点的指针。

如下图所示(其中 D 代表 数据域,P 代表 指针域):

结点和链表-示意图

常见的链表主要可以分为以下几种类型:

常见链表分类-示意图

单向链表

单向链表是由一系列结点单向链接组成的数据结构,简称单链表。

基础概念:

  1. 结点: 链表中的每一个存储单元被称为结点,每个结点都包含两部分:

    1. 一部分用于存储结点的数据(数据域)
    2. 另一部分用于存储指向下一个结点的指针(指针域)。

    在 C 程序中一个结点,就是一个结点结构体类型的对象。

  2. 头结点: 某些单链表的实现中,会在第一个结点前附设一个 "一般不存储数据或者存储链表长度信息" 的结点,这个结点就是头结点。

    1. 头结点也叫做 "哑结点"、"虚拟结点"、"哨兵结点" 等。头结点的指针域指向链表的第一个结点。
    2. 在实际应用中,单链表一般不带头结点,但在需要统一链表头部操作的场景中,头结点具有 "奇效"。
  3. 尾结点: 单链表的最后一个结点。它的指针域指向 NULL,表示链表的结束。

  4. 头指针: 指向链表第一个结点的指针,头指针非常重要,单链表的任何操作都是以头指针为入口的,单链表一定会有头指针。 具体而言:

    1. 如果链表为空,则头指针为 NULL,是一个空指针。
    2. 如果单链表没有头结点,那么头指针就指向链表的第一个存储数据的结点。
    3. 如果单链表有头结点,那么头指针就指向该头结点。总之,头指针就指向单链表的第一个结点。
  5. 尾指针: 指向链表最后一个结点的指针。对于单链表而言,尾指针不是必须的,但拥有尾指针可以使得链表尾部的操作更加简单高效。

基本操作:

  1. 创建链表

  2. 销毁链表

  3. 插入结点:对于链表而言,只要在一个确定的结点后面插入结点,都是一个基础的,时间复杂度为 O(1)的操作。

    1. 在一个存在头指针和尾指针的链表当中,头插和尾插就是基础的插入结点操作。
    2. 更复杂的插入操作都是对基础插入操作的扩展,比如根据索引插入一个结点。
  4. 搜索结点:链表并不支持随机访问,任何的访问操作都意味着遍历链表。

    1. 所以不管是有序无序还是搜索的条件是什么,单链表的搜索操作时间复杂度总是 O(n)
    2. 比较常见的搜索条件是:根据索引查找或者根据数据查找
  5. 删除结点:对于链表而言,只要是删除一个确定结点后面的一个结点,都是一个基础的,时间复杂度为 O(1)的操作。

    • 在一个存在头指针和尾指针的链表当中,删除第一个结点就是一个基础的 O(1)时间复杂度操作。

    • 如果需要删除尾结点或中间结点,则通常需要遍历链表,时间复杂度是 O(n)

设计思路

单向链表结构-示意图

代码实现

linked_list.h
 // 头文件保护语法
#ifndef LINKED_LIST_H
#define LINKED_LIST_H
// 包含 linked_list.h 头文件也会同步包含它包含的其它头文件
// 根据这一特点, 可以选择将一些共用的头文件包含在此头文件中
#include < stdbool.h>
#include < stdlib.h>
#include < stdio.h>
#define ERROR_CHECK(ret, error_flag, msg) \
 do { \
     if ((ret) == (error_flag)) { \
         printf("error: %s\n", msg); \
         exit(1); \
     } \
 } while(0)
typedef int DataType;
// 定义链表结点结构
typedef struct node {
    DataType data;      // 数据域
    struct node* next;  // 指针域
} Node;
// 定义链表结构本身
typedef struct {
    Node* head;     // 头指针
    Node* tail;     // 尾结点指针
    int size;       // 用于记录链表的长度
} LinkedList;
// 创建一个空的链表
LinkedList* create_linked_list();
// 销毁链表
void destroy_linked_list(LinkedList* list);
// 头插法
bool add_before_head(LinkedList* list, DataType new_data);
// 尾插法
bool add_behind_tail(LinkedList* list, DataType new_data);
// 根据索引插入一个新结点
bool add_by_idx(LinkedList* list, int idx, DataType new_data);
// 根据索引搜索一个结点
Node * search_by_idx(LinkedList* list, int idx);
// 根据数据搜索一个结点
Node * search_by_data(LinkedList* list, DataType data);
// 根据数据删除一个结点
bool delete_by_data(LinkedList* list, DataType data);
// 扩展: 根据索引删除一个结点
bool delete_by_idx(LinkedList* list, int idx);
// 打印单链表 格式为:1 -> 2 -> 3 ->...
void print_list(LinkedList* list);
#endif // ! LINKED_LIST_H

linked_list.c
 #include "linked_list.h"
LinkedList* create_linked_list()
{
	LinkedList* list = calloc(1, sizeof(LinkedList));
	ERROR_CHECK(list, NULL, "Error: malloc failed in creat_linked_list.\n");
	return list;
}
void destroy_linked_list(LinkedList* list)
{
	Node* curr = list-> head;
	while (curr) {
		Node* temp = curr-> next;
		free(curr);
		curr = temp;
	}
	free(list);
}
// 头插法
bool add_before_head(LinkedList *list, DataType new_data) {
    // 1.分配一个新结点
    Node *new_node = (Node *)malloc(sizeof(Node));
    if (new_node == NULL) {
        printf("malloc failed in add_before_head.\n");
        return false;
    }
    // 2.初始化这个新结点
    new_node-> data = new_data;
    new_node-> next = list-> head;    // 新结点指向原本的第一个结点
    // 3.更新头指针指向新结点
    list-> head = new_node;  // 新结点成为第一个结点
    // 4.如果尾结点指针是 NULL,说明链表头插前为空,那么新结点同时成为尾结点
    // 需要更新尾结点指针
    if (list-> tail == NULL) {
        list-> tail = new_node;
    }
    list-> size++;
    return true;
}
// 尾插法
bool add_behind_tail(LinkedList *list, DataType new_data) {
    // 1.分配一个新结点
    Node *new_node = malloc(sizeof(Node));
    if (new_node == NULL) {
        printf("malloc failed in add_behind_tail.\n");
        return false;
    }
    // 2.初始化新结点的指针域和数据域,指针域初始为 NULL
    new_node-> data = new_data;
    new_node-> next = NULL;
    // 3.如果尾结点指针是 NULL,说明链表尾插前为空,那么新结点同时成为第一个结点和尾结点
    if (list-> tail == NULL) {
        // 链表为空
        list-> head = new_node;
        list-> tail = new_node;
        list-> size++;
        return true;
    }
    // 4.如果尾指针不是 NULL,让以前的尾结点指向新结点,然后更新尾指针
    list-> tail-> next = new_node;
    list-> tail = new_node;
    list-> size++;
    return true;
}
bool add_by_idx(LinkedList* list, int idx, DataType new_data)
{
	// 参数校验
	if (idx <0 || idx> list-> size) {
		printf("Index is not valid.\n");
		return false;
	}
	Node* new_node = calloc(1, sizeof(Node));
	if (! new_node)
	{
		printf("Error: calloc failed in add_behind_tail.\n");
		return false;
	}
	if (idx == 0)
	{
		return add_before_head(list, new_data);
	}
	else if (idx == list-> size) {
		return add_behind_tail(list, new_data);
	}
	else
	{
		Node* prev = NULL;
		Node* curr = list-> head;
		for (size_t i = 0; i < idx; i++)
		{
			prev = curr;
			curr = curr-> next;
		}
		new_node-> data = new_data;
		new_node-> next = curr;
		prev-> next = new_node;
		list-> size++;
		return true;
	}
}
Node * search_by_idx(LinkedList* list, int idx)
{
	// 参数校验
	if (idx < 0 || idx > = list-> size) {
		printf("Index is not valid.\n");
		return NULL;
	}
	Node* curr = list-> head;
	if (! curr)
	{
		printf("The list is empty.\n");
		return NULL;
	}
	for (size_t i = 0; i < idx; i++)
	{
		curr = curr-> next;
	}
	return curr;
}
Node * search_by_data(LinkedList* list, DataType data)
{
	Node* curr = list-> head;
	if (! curr)
	{
		printf("The list is empty.\n");
		return NULL;
	}
	while (curr) {
		if (curr-> data == data)
		{
			return curr;
		}
		curr = curr-> next;
	}
	return NULL;
}
bool delete_by_data(LinkedList* list, DataType data)
{
	Node* prev = NULL;
	Node* curr = list-> head;
	ERROR_CHECK(curr, NULL, "The list is empty.\n");
	while (curr) {
		if (curr-> data == data)
		{
			if (curr == list-> head)
			{
				if (curr-> next) { // 删除的第一个节点有后继节点
					list-> head = curr-> next;
					free(curr);
				}
				else { // 删除的第一个节点无后继节点
					list-> head = NULL;
					list-> tail = NULL;
					free(curr);
				}
			}
			else
			{
				prev-> next = curr-> next;
				if (curr == list-> tail) {
					list-> tail = prev;
				}
				free(curr);
			}
			list-> size--;
			return true;
		}
		prev = curr;
		curr = curr-> next;
	}
	return false;
}
bool delete_by_idx(LinkedList* list, int idx)
{
	// 参数校验
	if (idx < 0 || idx > = list-> size) {
		printf("Index is not valid.\n");
		return false;
	}
	Node* prev = NULL;
	Node* curr = list-> head;
	ERROR_CHECK(curr, NULL, "The list is empty.\n");
	if (idx == 0)
	{
		if (curr-> next) { // 删除的第一个节点有后继节点
			list-> head = curr-> next;
		}
		else { // 删除的第一个节点无后继节点
			list-> head = NULL;
			list-> tail = NULL;
		}
	}
	else {
		for (size_t i = 0; i < idx; i++)
		{
			prev = curr;
			curr = curr-> next;
		}
		prev-> next = curr-> next;
		if (curr-> next == NULL) {
			list-> tail = prev;
		}		
	}
	free(curr);
	list-> size--;
	return true;
}
// 打印单链表 格式为:1 -> 2 -> 3 ->...
void print_list(LinkedList *list) {
    Node *curr = list-> head;
    // 遍历此单链表
    while (curr != NULL) {
        printf("%d", curr-> data);
        // 如果不是链表的最后一个结点,就打印箭头
        if (curr-> next != NULL) {
            printf(" -> ");
        }
        curr = curr-> next;
    }
    printf("\n");
}

test.c
 #define _CRT_SECURE_NO_WARNINGS
#include 
#include "linked_list.h"
#define ARR_SIZE(arr) (sizeof(arr)/sizeof(arr [0]))
/*
*/
int main(void) {
	LinkedList* list = create_linked_list();
	add_before_head(list, 4);
	add_before_head(list, 3);
	add_before_head(list, 2);
	add_before_head(list, 1);
	add_behind_tail(list, 5);
	// add_by_idx(list, 0, 0);
	// add_by_idx(list, 3, 111);
	print_list(list);
	//Node* node =  search_by_idx(list, 5);
	//Node* node = search_by_data(list, 7);
	// delete_by_data(list, 4);
	delete_by_idx(list, 4);
	print_list(list);
	destroy_linked_list(list);
	system("pause");
	return 0;
}

概述

相比较于数组和链表,栈是一种操作受限的线性结构。

这种操作受限体现在:

  1. 栈只能在 同一端 添加、删除以及访问元素(栈顶)
  2. 另一端无法执行任何操作(栈底),栈底的元素既不能直接增删,也不能直接访问。

可以把栈想象成一个杯子,杯底相当于栈底,无法直接进行任何操作。杯口相当于栈顶,是唯一可以进行添加、删除和访问操作的地方。

在栈中,最先添加到栈中的元素总是最后被删除的,遵循 后进先出(LIFO, Last In First Out) 的原则。

如下图所示:

栈-示意图

栈这种数据结构的基本操作主要包括以下几种:

  1. 入栈/压栈(push):在栈顶添加一个元素,成为新的栈顶元素,其余元素则被压入栈底。时间复杂度 O(1)
  2. 出栈/弹栈(pop):删除栈顶元素,下一个元素成为新的栈顶元素。时间复杂度 O(1)
  3. 访问栈顶元素(peek):返回栈顶元素的值。时间复杂度 O(1)
  4. 判空(is_empty):检查栈中是否有任何元素。时间复杂度 O(1),因为只需要检查一下栈顶元素即可。

说白了,栈仍然还是一个线性数据结构(数组或链表都可以),只不过在实现时,只需要提供栈顶的增删访问操作罢了。

栈的优点

栈是一种操作受限的线性结构,既然如此,那么为什么非要使用栈呢?直接使用链表或数组不好吗?

使用栈主要有以下好处:

  1. 高效率。由于栈的操作仅限于顶部,栈的所有基本操作(push、pop、peek)通常都具有 O(1) 的时间复杂度,这使得栈在性能上非常高效。
  2. 简化代码、增强代码可读性。由于操作受限,编程时的考虑因素更少,更容易实现。也有助于写出更清晰、更易于维护的代码。
  3. 非常适合用于解决特定问题。在实际的某些应用场景中,如函数调用管理、括号匹配问题、表达式求值、浏览器的前进后退功能等,自然而然地符合 "先进后出" 的特点,天然就适合使用栈来解决。

在数据结构的选择上,关键在于根据具体的应用需求和场景来决定最合适的结构。

栈的实现方式

栈的实现有两种方式:

  1. 基于链表实现
  2. 基于数组实现

这两种实现方式各有优缺点,总得来说:

  1. 如果以一个固定长度的数组来实现栈,实现方式非常简洁,依赖于数组随机访问效率特别高。也不需要额外的空间来存储指针。但缺点是栈大小固定,无法扩容。
  2. 如果用一个动态数组来实现栈,栈具有更灵活的容量,同样随机访问效率高且不需要额外空间存储指针。但缺点是,重分配内存可能是一个较大的性能开销,拖慢整体栈的效率。
  3. 如果以链表来实现,灵活性比数组更强,且扩容不涉及内存重分配,栈整体性能稳定。但缺点是空间占用更多,需要存储额外的指针。当然,链表实现肯定会更复杂一些,这也算个小毛病。

总的来说:

  1. 在已知栈大小限制或不需要频繁调整栈大小时,优先考虑使用固定长度的数组来实现栈,这会提供更好的性能以及更简单的实现以及使用方式。
  2. 在栈大小未知或需要频繁调整栈大小时,可以考虑使用动态数组或链表来实现栈,它们的区别是:
    1. 动态数组实现的缺点是如果需要频繁调整大小,那么性能会非常差。所以如果不需要频繁的进行扩容操作,且对性能需求高,则优先选择动态数组实现。而且现代很多高级编程语言(比如 C++、Java 等)都已提供了现成的动态数组实现。
    2. 如果栈的大小完全不可预测,使用动态数组实现会导致频繁调整大小的操作,那么可以更多的考虑使用链表实现。

设计思路

链式栈模型

链式栈模型

动态数组栈模型

动态数组栈模型

代码实现

链式栈模型

linked_stack.h
 #ifndef LINKED_STACK_H
#define LINKED_STACK_H
#include < stdbool.h>
#include < stdio.h>
#include < stdlib.h>
typedef int ElementType;
// 栈的一个结点栈帧, 类型定义
typedef struct node_s {
    ElementType data;
    struct node_s* next;
}StackFrame;
typedef struct {
    StackFrame* top;    // 栈顶指针
}LinkedStack;
// 基本操作
// 创建链式栈
LinkedStack* stack_create();
// 销毁链式栈
void stack_destroy(LinkedStack* stack);
// 判空
bool is_empty(LinkedStack* stack);
// 入栈
void stack_push(LinkedStack* stack, ElementType data);
// 出栈并返回栈顶元素
ElementType stack_pop(LinkedStack* stack);
// 访问栈顶元素
ElementType stack_peek(LinkedStack* stack);
#endif // ! LINKED_STACK_H

linked_stack.c
 #include "linked_stack.h"
// 新建一个空栈
LinkedStack *stack_create() {
    // callock 可以不用手动初始化空指针
    return calloc(1, sizeof(LinkedStack));
}
// 对于链式栈而言,销毁栈就是销毁链表
void stack_destroy(LinkedStack *stack) {
    // 相当于遍历链表(出栈)然后在遍历中逐个 free 结点
    StackFrame *curr = stack-> top;
    while (curr != NULL) {
        StackFrame *tmp = curr-> next;   // 保存后继结点
        free(curr);
        curr = tmp;
    }
    // 最后 free 栈结构体
    free(stack);
}
bool is_empty(LinkedStack *stack) {
    return stack-> top == NULL;
}
// 相当于链表头插法插入结点,栈顶指针相当于链表的头指针
void stack_push(LinkedStack *stack, ElementType data) {
    // 1.新建一个栈帧结点
    StackFrame *new_frame = malloc(sizeof(StackFrame));
    if (new_frame == NULL) {
        printf("malloc failed in stack_push.\n");
        exit(1);
    }
    // 2.初始化新栈帧
    new_frame-> data = data;
    new_frame-> next = stack-> top;
    // 3.更新栈顶指针
    stack-> top = new_frame;
}
// 相当于链表在头部删除第一个结点, 栈顶指针相当于链表的头指针
ElementType stack_pop(LinkedStack *stack) {
    // 1.栈判空处理
    if (is_empty(stack)) {
        printf("error: stack is empty.\n");
        exit(1);
    }
    // 2.出栈返回栈顶元素, 并 free 结点, 更新栈顶指针
    StackFrame *curr = stack-> top;
    ElementType data = curr-> data;
    stack-> top = curr-> next;
    free(curr);
    return data;
}
ElementType stack_peek(LinkedStack *stack) {
    if (is_empty(stack)) {
        printf("error: stack is empty.\n");
        exit(1);
    }
    return stack-> top-> data;
}
test.c
 #define _CRT_SECURE_NO_WARNINGS
#include < stdio.h>
#include < stdlib.h>
#include < string.h>
#include "linked_stack.h"
int main(void) {
    // 创建一个空栈
    LinkedStack *stack = stack_create();
    if (stack == NULL) {
        printf("Failed to create stack.\n");
        return -1;
    }
    // 入栈操作
    stack_push(stack, 1);
    stack_push(stack, 2);
    stack_push(stack, 3);
    // 查看栈顶元素
    printf("当前栈顶元素是: %d\n", stack_peek(stack)); // 应该输出 3
    // 出栈操作,并打印出栈的元素
    printf("依次出栈以下元素:\n");
    while (! is_empty(stack)) {
        printf("%d\n", stack_pop(stack)); // 应该依次输出 3, 2, 1
    }
    // 再次尝试查看栈顶元素,预期会触发错误提示
    printf("栈为空时, 尝试访问栈顶元素:\n");
    stack_peek(stack);  // 应该输出错误信息并退出
    // 销毁栈
    stack_destroy(stack);   return 0;
}

动态数组栈模型

dynamic_stack.h
 #ifndef DYNAMIC_STACK_H
#define DYNAMIC_STACK_H
#include < stdio.h>
#include < stdlib.h>
#include < stdbool.h>
typedef int ElementType;
typedef struct {
    ElementType* elements;   // 指向动态数组首元素的指针
    int size;   // 元素的个数
    int capacity; // 数组的容量,也就是栈的容量
} DynamicStack;
// 创建动态数组栈
DynamicStack* stack_create();
// 销毁动态数组栈
void stack_destroy(DynamicStack* s);
// 入栈
void stack_push(DynamicStack* s, ElementType val);
// 出栈并返回栈顶元素
ElementType stack_pop(DynamicStack* s);
// 访问栈顶元素
ElementType  stack_peek(DynamicStack* s);
// 判空
bool is_empty(DynamicStack* s);
#endif // ! DYNAMIC_ARR_STACK_H 
dynamic_stack.c
 #include "dynamic_stack.h"
#define DEFAULT_CAPACITY 8  // 动态栈的默认容量
#define THRESHOLD 1024  // 扩容阈值
// 实现扩容机制
static void grow_capacity(DynamicStack* s) {
	// 扩容策略: 超过阈值则 1.5 倍的扩容,否则 2 倍的扩容
	int new_capacity = (s-> capacity > THRESHOLD) ?
		(s-> capacity + (s-> capacity >> 1)) :
		(s-> capacity << 1);
	// 使用 realloc 函数实现扩容,重新分配内存
	ElementType * new_arr = realloc(s-> elements, new_capacity * sizeof(ElementType));
	if (new_arr == NULL) {
		printf("Error: realloc failed in grow_capacity\n");
		exit(1);
	}
	// 更新动态数组栈结构体的信息
	s-> elements = new_arr;
	s-> capacity = new_capacity;
}
// 创建动态数组栈
DynamicStack* stack_create() {
	DynamicStack* stack = calloc(1, sizeof(DynamicStack));
	if (stack == NULL)
	{
		printf("Error: malloc failed in stack_create.\n");
		return NULL;
	}
	stack-> size = 0;
	stack-> capacity = DEFAULT_CAPACITY;
	stack-> elements = malloc(DEFAULT_CAPACITY * sizeof(ElementType));
	if (stack-> elements == NULL)
	{
		free(stack);
		printf("Error: malloc failed in stack_create.\n");
		return NULL;
	}
	return stack;
}
// 销毁动态数组栈
void stack_destroy(DynamicStack* s) {
	// 先释放动态数组
	free(s-> elements);
	// 再释放栈结构体
	free(s);
}
// 入栈
void stack_push(DynamicStack* s, ElementType val) {
	if (s-> size >= s-> capacity)
	{
		grow_capacity(s);
	}
	s-> elements [s-> size] = val;
	s-> size++;
}
// 出栈并返回栈顶元素
ElementType stack_pop(DynamicStack* s) {
	if (is_empty(s)) {
		printf("Error: stack is empty\n");
		exit(1);
	}
	s-> size--;
	return s-> elements [s-> size];
}
// 访问栈顶元素
ElementType  stack_peek(DynamicStack* s) {
	if (is_empty(s)) {
		printf("Error: stack is empty\n");
		exit(1);
	}
	return s-> elements [s-> size - 1];
}
// 判空
bool is_empty(DynamicStack* s) {
	return s-> size == 0;
}
test.c
 #define _CRT_SECURE_NO_WARNINGS
#include < stdio.h>
#include "dynamic_stack.h"
int main(void) {
    DynamicStack* s = stack_create();
    // 入队列 1000 个元素
    for (int i = 0; i < 1000; i++) {
        stack_push(s, i);
    }
    // 出队列直到队列为空
    while (! is_empty(s)) {
        ElementType ele = stack_peek(s);
        printf("%d ", ele); // 预期会将 999-0 倒着输出打印
        stack_pop(s);
    }
    printf("\n");
    // 再次尝试出栈, 此时会打印错误提示信息
    stack_pop(s);
    // 销毁队列
    stack_destroy(s);
    system("pause");
    return 0;
}

栈的应用场景

栈特别适用那些存在 "后进先出" 逻辑的场景,在实际的开发,栈很常用。栈至少可以应用于以下领域:

  1. 函数调用机制
  2. 括号匹配问题
  3. 表达式求值问题
  4. 浏览器历史记录前进后退功能
  5. 深度优先遍历算法(一般直接用函数调用者递归实现)
  6. ...

队列

概述

队列是另一种操作受限的线性结构。但它的受限和栈是不同的:

  1. 队列只能在一端添加元素(队尾)
  2. 在另一端访问、删除元素(队头)

队列就不用想象了,它就是我们生活中排队的场景,你只能在队尾插入一个元素,在队头删除一个元素。

在队列中,最先添加到队尾的元素总是最先在队头删除,遵循 先进先出(FIFO,First In First Out) 的原则。

如下图所示:

队列-概念模型

队列这种数据结构的基本操作主要包括以下几种:

  1. 入队:在队尾添加一个元素,新元素成为新的队尾元素。
  2. 出队:在队头删除一个元素,第二个元素称为新的队头元素。
  3. 访问队头元素
  4. 判空
  5. 判满

设计思路

数组队列模型

数组队列-方案三

这种方案,最核心的问题就是:如何移动两个索引,使得它们能够在数组内部循环移动呢?

使用取余运算符 "%" 即可:

rear = (rear + 1) % N
front = (front + 1) % N

解释:
对于数组的下标运算,几乎都是要涉及对数组长度 N 的取余运算,现在希望往后移动索引,而且希望从最后一个索引向后移动是第一个索引,所以就这样计算。

这样设计就实现了对数组前面空间的利用,避免了空间浪费,同时出入队的效率也很高。下面思考一个问题:

这种设计方案,如何进行队列判空和判满呢?

实际上如果就单纯按照上述方案设计循环队列,那么它们的判空和判满条件是一样的:

rear == front

所以对于这两个操作我们还需要进行一点小优化,有两个选择:

(1)牺牲一个存储单元不存储元素,只要 rear 指向 front 的前一个位置就表示队列满了。 如下图所示:

在这种情况下:

  • 队列满了的条件是:(rear + 1) % N == front
  • 队列为空的条件是:rear == front

这种办法是教科书中讲数据结构经常使用的方案,在实际开发中可以用一个更简单粗暴,更易于实现和使用的方式——计数器

(2)使用一个额外的计数器变量来统计数组中元素的个数,这样判空和判满都十分简洁了。具体而言可以参考下图的实现:

数组队列-方案三改进图2

基于上述图中的模型,实现一个 固定长度的数组队列,需要注意的是:

  • front 用于记录队头元素,出队时就将该元素出队。

  • rear 用于指示下一个元素入队的索引,也就是说入队操作时直接将元素插入 rear 索引位置就可以了。

  • 规定 front(包含)和 rear(不包含)索引之间的元素就是队列元素,出队操作时,直接向后移动索引 front 就可以了,不需要任何赋值操作。

  • front 向后移动的方式是:(front + 1) % N

  • rear 向后移动的方式是:(rear + 1) % N

链表队列模型

链表队列模型

代码实现

动态数组队列模型

dynamic_queue.h
 #ifndef DYNAMIC_QUEUE_H
#define DYNAMIC_QUEUE_H
#include < stdbool.h>
#include < stdlib.h>
#include < stdio.h>
typedef int ElementType;    // 该队列当前存储 int 元素
#define DEFAULT_CAPACITY 10 // 数组队列的初始长度是 10
#define THRESHOLD 1000  // 超过阈值每次扩容 1.5 倍扩,否则 2 倍的扩
// 定义队列结构体
typedef struct {
    ElementType* data;   // 动态数组存储队列元素
    int front;           // 标记队头元素的索引
    int rear;            // 标记队尾元素下一个位置的索引
    int size;            // 当前队列中元素数量
    int capacity;        // 队列容量
} DynamicQueue;
// 队列基本操作函数声明
// 创建动态数组队列
DynamicQueue* create_queue();
// 销毁动态数组队列
void destroy_queue(DynamicQueue* q);
// 判空
bool is_empty(DynamicQueue* q);
// 判满
bool is_full(DynamicQueue* q);
// 入队列
bool enqueue(DynamicQueue* q, ElementType data);
// 出队列并且返回队头元素
ElementType dequeue(DynamicQueue* q);
#endif // ! DYNAMIC_QUEUE_H 
dynamic_queue.c
 #include "dynamic_queue.h"
// 创建并初始化队列
DynamicQueue *create_queue() {
    DynamicQueue *q = calloc(1, sizeof(DynamicQueue));
    if (q == NULL) {
        printf("error: calloc failed in create_queue.\n");
        return NULL;
    }
    // front、rear、size 自动初始化 0 值,无需再手动初始化了
    q-> data = calloc(DEFAULT_CAPACITY, sizeof(ElementType));    // 使用 calloc 避免随机值
    if (q-> data == NULL) {
        printf("error: calloc failed in create_queue.\n");
        free(q);
        return NULL;
    }
    q-> capacity = DEFAULT_CAPACITY;
    return q;
}
// 销毁队列
void destroy_queue(DynamicQueue *q) {
    free(q-> data);
    free(q);
}
// 检查队列是否为空
bool is_empty(DynamicQueue *q) {
    return q-> size == 0;
}
// 检查队列是否已满
bool is_full(DynamicQueue *q) {
    return q-> size == q-> capacity;
}
/*
    这里采用一种简单粗暴的扩容手段:
    直接分配新容量的内存块
    然后遍历旧内存块中的队列元素, 将这些元素全部从头开始复制到新内存块中
    这样的操作在完成后, 需要更新 front 索引和 rear 索引
*/
static bool resize_queue(DynamicQueue *q) {
    int old_capacity = q-> capacity;
    int new_capacity = (old_capacity < THRESHOLD) ?
        (old_capacity << 1) :
    (old_capacity + (old_capacity >> 1));
    // 重新分配一个新的, 更长的动态数组
    ElementType *new_data = malloc(new_capacity * sizeof(ElementType));
    if (new_data == NULL) {
        printf("error: realloc failed in resize_queue.\n");
        return false;
    }
    int curr = q-> front;    // curr 索引用于遍历整个队列中的元素
    int index = 0;
    while (index < q-> size) {
        new_data [index] = q-> data [curr];
        curr = (curr + 1) % q-> capacity;
        index++;
    } // while 循环结束时, new_data 就从头开始包含了队列的所有元素 
    free(q-> data);
    q-> data = new_data;
    q-> front = 0;
    q-> rear = q-> size;
    q-> capacity = new_capacity;
    return true;
}
// 入队操作
bool enqueue(DynamicQueue *q, ElementType data) {
    if (is_full(q)) {
        if (! resize_queue(q)) {
            printf("error: 扩容失败.\n");
            return false; // 队列扩容失败, 入队也同步失败
        }
    }
    q-> data [q-> rear] = data;
    q-> rear = (q-> rear + 1) % q-> capacity;  // 循环队列
    q-> size++;
    return true;
}
// 出队操作
ElementType dequeue(DynamicQueue *q) {
    if (is_empty(q)) {
        printf("error: 队列为空,无法出队。\n");
        exit(1);
    }
    ElementType item = q-> data [q-> front];
    q-> front = (q-> front + 1) % q-> capacity; // 循环队列
    q-> size--;
    return item;
}
test.c
 #define _CRT_SECURE_NO_WARNINGS
#include < stdio.h>
#include "dynamic_queue.h"
int main(void) {
    // 创建队列
    DynamicQueue* queue = create_queue();
    if (queue == NULL) {
        printf("创建队列失败。\n");
        return 1;
    }
    // 入队测试
    for (int i = 0; i < 15; i++) {
        if (enqueue(queue, i)) {
            printf("已入队: %d\n", i);
        }
        else {
            printf("入队失败: %d\n", i);
        }
    }
    // 打印队列容量和大小
    printf("队列容量: %d, 队列大小: %d\n", queue-> capacity, queue-> size);
    // 出队测试
    printf("正在出队...\n");
    while (! is_empty(queue)) {
        printf("已出队: %d\n", dequeue(queue));
    }
    // 销毁队列
    destroy_queue(queue);
    system("pause");
    return 0;
}

链表队列模型

list_queue.h
 #ifndef LIST_QUEUE_H
#define LIST_QUEUE_H
#include < stdbool.h>
#include < stdio.h>
#include < stdlib.h>
// 定义队列中的元素类型
typedef int ElementType;
// 队列节点的结构
typedef struct node_s {
    ElementType data;
    struct node_s* next;
} QueueNode;
// 队列的结构
typedef struct {
    QueueNode* front;  // 队头结点指针
    QueueNode* rear;   // 队尾结点指针
} LinkedListQueue;
// 函数声明
// 创建链式队列
LinkedListQueue* create_queue();
// 销毁链式队列
void destroy_queue(LinkedListQueue* q);
// 入队列
bool enqueue(LinkedListQueue* q, ElementType element);
// 出队列并返回队头元素
ElementType dequeue(LinkedListQueue* q);
// 访问队头元素
ElementType peek_queue(LinkedListQueue* q);
// 判空
bool is_empty(LinkedListQueue* q);
#endif // ! LIST_QUEUE_H 
list_queue.c
 #include "list_queue.h"
// 创建链式队列
LinkedListQueue* create_queue() {
	return calloc(1, sizeof(LinkedListQueue));
}
// 销毁链式队列
void destroy_queue(LinkedListQueue* q)
{
	QueueNode* curr = q-> front;
	while (curr != NULL) {
		QueueNode* temp = curr-> next;
		free(curr);
		curr = temp;
	}
	free(q);
}
// 入队列
bool enqueue(LinkedListQueue* q, ElementType element)
{
	QueueNode* new_node = calloc(1, sizeof(QueueNode));
	if (new_node == NULL)
	{
		printf("Error: malloc failed in enqueue.\n");
		return false;
	}
	new_node-> data = element;
	new_node-> next = NULL;
	if (q-> front == NULL)
	{
		q-> front = new_node;
		q-> rear = new_node;
	}
	else
	{
		q-> rear-> next = new_node;
		q-> rear = new_node;
	}
	return true;
}
// 出队列并返回队头元素
ElementType dequeue(LinkedListQueue* q)
{
	if (is_empty(q))
	{
		printf("The queue is empty.\n");
		exit(1);
	}
	ElementType item = q-> front-> data;
	QueueNode* temp = q-> front;
	q-> front = temp-> next;
	if (q-> front == NULL)
	{
		q-> rear == NULL;
	}
	free(temp);
	return item;
}
// 访问队头元素
ElementType peek_queue(LinkedListQueue* q)
{
	if (is_empty(q))
	{
		printf("The queue is empty.\n");
		exit(1);
	}
	return q-> front-> data;
}
// 判空
bool is_empty(LinkedListQueue* q)
{
	return q-> front == NULL;
}
test.c
 #define _CRT_SECURE_NO_WARNINGS
#include "list_queue.h"
int main(void) {
	LinkedListQueue* q = create_queue();
    // 逐一入队列
    enqueue(q, 1);
    enqueue(q, 2);
    enqueue(q, 3);
    enqueue(q, 4);
    enqueue(q, 5);
    enqueue(q, 6);
    // 先进先出,出队列全部元素直到队列为空
    while (! is_empty(q)) {
        int val = peek_queue(q);
        printf("%d ", val); // 预期出队列顺序: 1 2 3 4 5 6
        dequeue(q);
    }
	system("pause");
	return 0;
}

队列的应用场景

队列是一种非常实用的数据结构,由于其先进先出的特性,队列特别适用于那些需要按顺序处理元素的场景。比如:

  1. 操作系统的任务调度,进程/线程按照到达顺序来排队等待 CPU 执行权。
  2. 各种数据处理系统中的缓冲区/缓存机制。比如 stdin/stdout 缓冲区,先输入缓冲区的数据,总是先从缓冲区输出。
  3. 打印机的任务管理,当多个打印任务同时到达时,它们在队列中排队,按照提交的顺序进行打印。
  4. 后端应用系统中的消息队列。
  5. 广度优先搜索(比如二叉搜索树的层次遍历)
  6. ...

哈希表

哈希相关概念

哈希(Hash)是一种将 任意长度大小 的输入转换处理成 固定长度大小 输出的过程:

  1. 输入可以是一个任意数字或字符串、也可以是一个任意文件等等。(一般来说同一个哈希过程,输入的类型应该是一致的,但长度大小不限制)
  2. 普遍来说,哈希过程的输出会是一个固定长度的整数,这个整数就是常说的 哈希值
  3. 实现哈希的过程中,代表转换处理规则的就是 哈希函数

哈希函数(Hash Function)是实现哈希过程的关键,负责将输入的任意数据 映射 到一个哈希值(一般是一个固定长度的整数)上。

哈希函数具有以下特点:

  1. 同一个输入多次调用哈希函数,必须映射到同一输出上。映射不允许出现一对多。
  2. 不同输入多次调用哈希函数,不要求映射到不同输出上。映射不要求完全一一对应,允许出现多对一,这就是哈希冲突。

有关哈希的概念,总结如下:

哈希(过程):

  1. 哈希是一种可以接受各种类型、大小的输入,输出一个固定长度整数的过程。
  2. 可以将哈希理解成一种特殊的映射,哈希映射,将一个理论无限的集合 A 映射到有限整数集合 B 上。

哈希函数:哈希函数是哈希过程的核心,它决定了哈希映射过程的规则。

哈希冲突:哈希是一种化无限为有限的映射,允许出现多对一,但绝不允许出现一对多。若映射中出现多对一,就是哈希冲突。哈希冲突可以减少,但绝不可能没有。

哈希表相关概念

哈希表是一种基于 "键值对" 的存储方式的数据结构,能够提供快速的数据查找、插入和删除操作。

哈希表是能够存储键值对元素的数据结构,具体到存储的机制是:

  1. 先利用哈希函数计算键值对元素中 key (哈希表的 key 是唯一的) 对应的哈希值
  2. 然后根据哈希值的不同决定这个元素在哈希表中的存储位置。

由于哈希冲突的存在,所以哈希表在具体存储元素时,不得不把多个元素存储在同一个位置上,这样就出现了 哈希桶 的概念:

  1. 哈希桶是哈希表存储数据的基本单位,一个哈希表由多个哈希桶组成。
  2. 当一个新的键值对需要加入哈希表时,会使用哈希函数计算该键的哈希值,然后根据这个哈希值决定将键值对存储在哪个哈希桶中。
  3. 一般而言,每个哈希桶都可以存储一个或多个元素。

总之,一个哈希表的概念模型可以用下图描述(不涉及实现):

哈希表的概念模型图

根据这个理论模型,我们不难发现:

假设在完美情况下,哈希冲突不出现,任何一个哈希桶都只用存储一个元素,那么这个哈希表就是一个数组。

也就是说在理想、完美的情况下,哈希表就是一个数组。当然这种情况,在现实里并不存在,哈希冲突是必然存在的。

哈希函数

哈希函数是整个哈希过程的核心,那么应该如何设计一个哈希函数呢?什么样的哈希函数是一个优秀的哈希函数呢?

在编程领域,哈希函数的最常见应用就是实现哈希表。为了哈希表的性能考虑,此哈希函数应该:

  1. 能够快速计算。在实现哈希表时,对安全性没有非常高的要求,但如果哈希函数的效率很差势必影响哈希表的性能。
  2. 均匀映射,尽量减少哈希冲突。哈希函数应该将输入均匀地映射到输出当中。这减少了哈希冲突的概率,意味着不同的输入值更有可能得到不同的哈希值。

但如果将哈希函数运用到其他领域,比如文件校验,密码存储等(密码学场景),对哈希函数的要求就会更高:

  1. 几乎没有哈希冲突。使得哈希值能够像指纹一样,可以作为输入的唯一标识。
  2. 通过哈希值逆向破解输入几乎不可能。
  3. 对输入的任何一点修改,都最好得到一个完全不同的哈希值。

在非密码学场景(追求高效计算、不追求极致减少哈希冲突),比较常用的哈希算法有:MurmurHash,该算法以高运行效率和分布均匀性著称,在实现哈希表时选择它非常合适。

在密码学场景中(追求极致减少哈希冲突,追求安全性),比较常用的算法有:MD5,SHA1,SHA-256,SHA-3 等。MD5 和 SHA1 由于存在明显的安全漏洞,已基本被淘汰。SHA-256 是目前最常见、最推荐的密码学哈希算法。

在后续实现哈希表时,这里给出一个 MurmurHash 哈希算法在 C 语言中的实现函数:

Show Code
 /* murmur_hash2 */
static uint32_t hash(const void* key, int len, uint32_t seed) {
    const uint32_t m = 0x5bd1e995;
    const int r = 24;
    uint32_t h = seed ^ len;
    const unsigned char * data = (const unsigned char*)key;
    while (len >= 4) {
        uint32_t k = *(uint32_t*)data;
        k *= m;
        k ^= k >> r;
        k *= m;
        h *= m;
        h ^= k;
        data += 4;
        len -= 4;
    }
    switch (len) {
        case 3: h ^= data [2] << 16;
        case 2: h ^= data [1] << 8;
        case 1: h ^= data [0];
            h *= m;
    };
    h ^= h >> 13;
    h *= m;
    h ^= h >> 15;
    return h;
}

MurmurHash 是一种非加密哈希函数,适用于一般的哈希检索操作。它以高运行速度和分布均匀性而闻名。其中 uint32_t 表示无符号 32 位整型,要想使用它需要包含 头文件 <stdint.h>

该函数调用需要三个参数:

  1. void* key,也就是需要计算哈希值的 key 元素。 此形参的类型是 void*,这意味着它可以传入任何类型的数据,提高了函数的通用性。

    • 如果 key 是基本数据类型,那就传入指向基本数据类型的指针

    • 如果 key 本身就是一个指针类型,比如字符串,那么就直接传入这个指针就可以了。

  2. int len,表示哈希函数要处理的 key 的大小长度。key 若是字符串可以使用函数 strlen 计算,若是其它类型可以使用 sizeof 计算。

  3. uint32_t seed,种子值。

    • 此哈希函数会根据 key 和 seed 共同生成一个哈希值

    • 不同的种子值会导致相同的数据产生不同的哈希值。需要注意的是,在程序一次运行中,同一个哈希表应该具有相同的种子值!

    • 最常见的设置种子值的方式就是用时间戳,也就是 time(NULL); 函数调用。

哈希表的核心操作

对于一个哈希表而言,最核心的操作是三个:

  1. 插入一个新的键值对。(哈希表中的 key 是唯一的)

    • 如果 key 已存在重复,更新对应 value

    • 如果 key 不存在,将新的键值对添加到哈希表中。

  2. 根据 key 查找目标 value。

  3. 根据 key 删除目标键值对元素。

一般来说,不管采用何种方式实现哈希表,都要尽量保证这三个基本操作的时间复杂度接近 O(1)

除此之外,遍历哈希表也是一个常见操作。

如何实现一个哈希表?

在实现一个哈希表的过程中,最需要关注的是两个问题:

  1. 选择哪一个哈希函数?这一点我们在上面已经给出了答案。
  2. 如何设计哈希桶呢?由于哈希冲突一定存在,哈希桶的设计要保证能够处理这种冲突导致的多个元素要存储在一个哈希桶中。所以换句话说,这个问题就是——如何处理哈希冲突呢?

关于这个话题,这里给出两种常见办法。

开放寻址法

哈希表是一种增查频繁的数据结构,这让我们想起了数组,因为数组根据索引增查效率很高,于是直接将一个数组当成哈希表。那怎么处理冲突问题呢?

一旦出现哈希冲突,就采取下列办法找到一个空位置:

  1. 线性探测法,逐一探测下一个位置是否为空位置,直到找到。
  2. 平方探测法,每次探测都会检查当前位置索引加上探测次数的平方(1, 4, 9, 16, ...)的位置。
  3. 再哈希法。使用多个哈希函数共同实现哈希表,当插入一个元素时,首先使用第一个哈希函数计算其位置,若冲突,则继续调用后续函数,直到不冲突为止。
  4. ...

这种解决哈希冲突的方式,就是常说到的 "开放寻址法"

开放寻址法听起来实现简单,利用数组的随机访问,内存连续效率高,但实际上:

  1. 删除操作实现非常困难,复杂。

    • 删除操作如果只是简单修改数据,那么后续查找可能出现误访问,于是可能需要特殊标记以表示元素被删除。这样实现很麻烦,而且会拖累查询的效率。

    • 如果通过整体挪动数组元素来表示删除,那删除效率就太差了,也不太可取。

  2. 数据量较大时哈希冲突难以解决。

  3. 开放寻址法必然意味着,需要经常进行数组扩容。

    • 这个过程,往往需要重分配内存

    • 然后将原数组中所有的元素再哈希,拷贝到新数组中。

    • 这样的一套操作下来,成功过于高昂。

但也不是说这种方式完全一无是处,当你有以下场景时:

  1. 数据量比较少,不需要频繁扩容数组。
  2. 删除操作几乎不做,只做增查操作。

这时倒也不妨可以使用 "开放寻址法" 来实现哈希表。

但对于大多一般开发场景而言,哈希表的实现基本不会采用开放寻址法。

拉链法(常用)

解决哈希冲突,设计实现哈希桶,在开发中最常见的方式还是拉链法,也就是采用 "数组 + 链表" 的方式来实现哈希桶。具体而言:以一个 键值对结点指针数组 为哈希表的基本结构,此数组的每一个存储单元都指向一个键值对结点链表。

这种设计下哈希表的基本操作思路如下(大致):

  1. 进行插入操作时,计算 key 的哈希值以确定存储的哈希桶位置,接下来:

    • 若哈希桶为空,则此结点成为哈希桶的第一个元素。

    • 若哈希桶不为空,则需要遍历哈希桶,如果 "Key" 已存在,则更新 Value。若 "Key" 不存在,则再向哈希桶中添加一个元素。此时哈希桶中的多个元素,是以链表的形式挂起来的。

  2. 进行查找操作时:同样先计算 key 的哈希值,然后在对应索引的链表中遍历查找这个键。

  3. 进行删除操作时:也是先计算 key 的哈希值,然后在对应链表中找到并移除这个键值对。

拉链法实现的哈希表,实际上就是数组的高访问效率和链表的灵活性的综合应用,是最常见的哈希表实现方式。如下图所示:

拉链法-实现哈希表

下面,基于 C 语言,以拉链法来实现一个固定容量的哈希表。(若以动态数组实现哈希表,就可实现扩容)

代码实现

hash_map.h
 #ifndef HASH_MAP_H
#define HASH_MAP_H
#include < stdint.h> // 包含它是为了使用别名 uint32_t
#include < stdbool.h>
#include < stdlib.h>
#include < stdio.h>
#include < string.h>
#define HASHMAP_CAPACITY 10 // 哈希表中数组的长度固定是 10
// 此时哈希表用于存储字符串类型的键值对
typedef char* KeyType;
typedef char* ValueType;
// 键值对结点
typedef struct node_s {
	KeyType key;
	ValueType val;
	struct node_s* next;
} KeyValueNode;
typedef struct {
	// 哈希桶
	KeyValueNode* buckets [HASHMAP_CAPACITY];    // 直接给定哈希桶的数量是 10 个
	// 哈希函数需要的种子值
	uint32_t hash_seed;
} HashMap;
// 创建一个固定容量的哈希表
HashMap* hashmap_create();
// 销毁一个哈希表
void hashmap_destroy(HashMap* map);
// 插入一个键值对
ValueType hashmap_put(HashMap* map, KeyType key, ValueType val);
// 查询一个键值对
ValueType hashmap_get(HashMap* map, KeyType key);
// 删除某个键值对
bool hashmap_remove(HashMap* map, KeyType key);
#endif // ! HASH_MAP_H 
hash_map.c
 #include "hash_map.h"
/* murmur_hash2 */
static uint32_t hash(const void* key, int len, uint32_t seed) {
    const uint32_t m = 0x5bd1e995;
    const int r = 24;
    uint32_t h = seed ^ len;
    const unsigned char * data = (const unsigned char*)key;
    while (len >= 4) {
        uint32_t k = *(uint32_t*)data;
        k *= m;
        k ^= k >> r;
        k *= m;
        h *= m;
        h ^= k;
        data += 4;
        len -= 4;
    }
    switch (len) {
    case 3: h ^= data [2] << 16;
    case 2: h ^= data [1] << 8;
    case 1: h ^= data [0];
        h *= m;
    };
    h ^= h >> 13;
    h *= m;
    h ^= h >> 15;
    return h;
}
// 创建一个固定容量的哈希表
HashMap* hashmap_create() {
    HashMap* map = calloc(1, sizeof(HashMap));
    if (map == NULL) {
        printf("error: calloc failed in hashmap_create.\n");
        exit(-1);
    }
    map-> hash_seed = time(NULL);
    return map;
}
// 销毁一个哈希表
void hashmap_destroy(HashMap* map) {
    // 遍历数组, 然后销毁哈希桶中的链表, 最后销毁 HashMap 结构体
    for (size_t i = 0; i < HASHMAP_CAPACITY; i++) {
        KeyValueNode* curr = map-> buckets [i];
        while (curr != NULL) {
            KeyValueNode* tmp = curr-> next;
            free(curr);
            curr = tmp;
        }
    }
    free(map);
}
// 插入一个键值对
ValueType hashmap_put(HashMap* map, KeyType key, ValueType val) {
    // 1.计算哈希值确定哈希桶的位置
    int idx = hash(key, strlen(key), map-> hash_seed) % HASHMAP_CAPACITY;
    // 2.遍历哈希桶,查找 Key 是否重复
    KeyValueNode* curr = map-> buckets [idx];
    while (curr != NULL) {
        // 比较字符串
        if (strcmp(key, curr-> key) == 0) {
            // key 已存在, 更新 value, 并返回旧 value
            ValueType old_val = curr-> val;
            curr-> val = val;
            return old_val;
        }
        curr = curr-> next;
    }
    // 3.只要 Key 不重复肯定都要插入,于是无脑使用链表头插法插入结点
    KeyValueNode* new_node = malloc(sizeof(KeyValueNode));
    if (new_node == NULL) {
        printf("Error: malloc failed in hashmap_put.\n");
        exit(1);
    }
    new_node-> key = key;    // key 和 val 都是指针类型,可以直接 "=" 赋值
    new_node-> val = val;
    // 链表头插法
    new_node-> next = map-> buckets [idx]; // 新结点指向原本的第一个结点
    map-> buckets [idx] = new_node;   // 更新头指针
    // 没有更新键值对返回空指针
    return NULL;
}
// 查询一个键值对
ValueType hashmap_get(HashMap* map, KeyType key) {
    // 1.计算哈希值确定哈希桶的位置
    int idx = hash(key, strlen(key), map-> hash_seed) % HASHMAP_CAPACITY;
    // 2.遍历哈希桶,查找 Key 是否存在
    KeyValueNode* curr = map-> buckets [idx];
    while (curr != NULL) {
        // 比较字符串
        if (strcmp(key, curr-> key) == 0) {
            // 已找到目标键值对
            return curr-> val;
        }
        curr = curr-> next;
    }
    // 目标键值对不存在
    return NULL;
}
// 删除某个键值对
bool hashmap_remove(HashMap* map, KeyType key) {
    // 1.计算哈希值确定哈希桶的位置
    int idx = hash(key, strlen(key), map-> hash_seed) % HASHMAP_CAPACITY;
    // 2.初始化两个指针, 用于删除结点
    KeyValueNode* curr = map-> buckets [idx];
    KeyValueNode* prev = NULL;
    // 3.遍历链表查找目标 Key
    while (curr != NULL) {
        // 比较字符串
        if (strcmp(key, curr-> key) == 0) {
            // 已找到目标键值对
            if (prev == NULL) {
                // 删除的是第一个结点
                map-> buckets [idx] = curr-> next;
            }
            else
            {
                // 删除的不是第一个结点
                prev-> next = curr-> next;
            }
            // free 结点
            free(curr);
            return true;    // 删除成功
        }
        prev = curr;        // 更新 prev 为当前节点
        curr = curr-> next;  // 当前结点向后移动
    }
    // 没找到目标键值对, 删除失败
    return false;
}
test.c
 #include "hash_map.h"
#include 
#include 
#include 
int main(void) {
    // 创建哈希表
    HashMap* map = hashmap_create();
    if (map == NULL) {
        printf("哈希表创建失败。\n");
        return 1;
    }
    // 插入键值对
    hashmap_put(map, "key1", "value1");  // 首次插入,应成功
    hashmap_put(map, "key2", "value2");  // 再插一个新键值对,应成功
    char* old_value = hashmap_put(map, "key1", "value3");  // 更新已存在的键值对,应返回旧值 "value1"
    printf("更新键值对时返回的旧值应为 value1,实际返回:%s\n", old_value);
    // 查询键值对
    char* value = hashmap_get(map, "key1");
    printf("期待得到 value3,实际得到:%s\n", value);  // 预期输出 value3
    value = hashmap_get(map, "key2");
    printf("期待得到 value2,实际得到:%s\n", value);  // 预期输出 value2
    value = hashmap_get(map, "key3");
    if (value == NULL) {
        printf("如预期,key3 未找到\n");  // 预期找不到 key3
    }
    // 删除键值对
    bool deleted = hashmap_remove(map, "key1");  // 删除 key1, 预期删除成功
    if (deleted) {
        printf("key1 成功删除。\n");
    }
    deleted = hashmap_remove(map, "key3");  // 尝试删除不存在的 key3, 预期删除失败
    if (! deleted) {
        printf("key3 未找到且未删除,符合预期。\n");
    }
    // 销毁哈希表
    hashmap_destroy(map);
    system("pause");
    return 0;
}

性能分析

拉链法解决哈希冲突实现的哈希表,在选择合适的哈希算法,键值对较为平均分布的前提下,其性能取决于哈希表中链表的 平均长度 L

  1. 最佳情况 下,即哈希函数均匀分布且冲突较少时,哈希表的插入、查询和删除操作的效率非常高,接近 O(1)。
  2. 最坏情况 下,特别是哈希冲突很多导致链表过长时,这些操作的效率会降低,接近 O(L),因为此时的哈希表访问几乎相当于链表的访问。

为了保证哈希表的性能,就需要控制链表的长度不能太长,也就是要 控制哈希表不能太满,要始终留出一定的空位,这样才不会出现过多的元素用链表存储的情况。

为了衡量一个哈希表满的程度,我们提出了 "负载因子(Load Factor )" 的概念:

=

一般来说,负载因子在 0.7/0.75 以下时,哈希表处在健康、性能优秀的状态。但一旦超出这个阈值,就意味着哈希冲突会增多,链表平均长度变长,从而导致性能下降。

此时,为了维持哈希表的高效性能,通常采取的措施是“扩容”和“重新哈希”。在拉链法的哈希表中也就是 对数组进行扩容,然后将所有元素重新哈希再映射到新哈希表中

既然要扩容,那就需要使用动态数组实现哈希桶的底层,涉及到内存重分配、以及全部元素重新哈希确定存储的哈希桶,显然是一个非常耗费性能的操作。因此,这个过程应该尽量避免频繁发生。

实现动态哈希表

设计思路

动态哈希表-概念模型图

代码实现

dynamic_hashmap.h
 #ifndef DYNAMIC_HASHMAP_H
#define DYNAMIC_HASHMAP_H
#include < stdint.h>
#include < stdbool.h>
#include < string.h>
#include < stdlib.h>
#include < stdio.h>
typedef char* KeyType;
typedef char* ValueType;
typedef struct node_s {
    KeyType key;
    ValueType val;
    struct node_s* next;
} KeyValueNode;
typedef struct {
    KeyValueNode** buckets;     // 此时哈希桶是一个动态数组,指向 char*元素的指针,所以是一个二级指针
    int size;               // 键值对的个数
    int capacity;           // 数组的长度
    uint32_t hash_seed;     // 哈希函数需要的种子值
} DynamicHashMap;
// 创建一个固定容量的哈希表
DynamicHashMap* hashmap_create();
// 销毁一个哈希表
void hashmap_destroy(DynamicHashMap* map);
// 插入一个键值对
ValueType hashmap_put(DynamicHashMap* map, KeyType key, ValueType val);
// 查询一个键值对
ValueType hashmap_get(DynamicHashMap* map, KeyType key);
// 删除某个键值对
bool hashmap_remove(DynamicHashMap* map, KeyType key);
#endif // ! DYNAMIC_HASHMAP_H 
dynamic_hashmap.c
 #include "dynamic_hashmap.h"
#define DEFAULT_CAPACITY 8  // 哈希表的初始默认容量是 8
#define LOAD_FACTOR_THRESHOLD  0.75     // 负载因子的阈值
#define CAPACITY_THRESHOLE 1024     // 数组容量的阈值
/* murmur_hash2 */
static uint32_t hash(const void* key, int len, uint32_t seed) {
	const uint32_t m = 0x5bd1e995;
	const int r = 24;
	uint32_t h = seed ^ len;
	const unsigned char * data = (const unsigned char*)key;
	while (len >= 4) {
		uint32_t k = *(uint32_t*)data;
		k *= m;
		k ^= k >> r;
		k *= m;
		h *= m;
		h ^= k;
		data += 4;
		len -= 4;
	}
	switch (len) {
	case 3: h ^= data [2] << 16;
	case 2: h ^= data [1] << 8;
	case 1: h ^= data [0];
		h *= m;
	};
	h ^= h >> 13;
	h *= m;
	h ^= h >> 15;
	return h;
}
// 创建一个固定容量的哈希表
DynamicHashMap* hashmap_create() {
	DynamicHashMap* map = calloc(1, sizeof(DynamicHashMap));
	if (map == NULL)
	{
		printf("Error: malloc failed in hashmap_create.\n");
		exit(1);
	}
	map-> buckets = calloc(DEFAULT_CAPACITY, sizeof(KeyValueNode*));
	if (map-> buckets == NULL)
	{
		printf("Error: malloc failed in hashmap_create.\n");
		free(map);
		exit(1);
	}
	map-> capacity = DEFAULT_CAPACITY;
	map-> hash_seed = time(NULL);
	return map;
}
// 销毁一个哈希表
void hashmap_destroy(DynamicHashMap* map) {
	// 1.先遍历动态数组销毁链表的每一个结点
	for (int i = 0; i < map-> capacity; i++) {
		KeyValueNode* curr = map-> buckets [i];
		while (curr != NULL) {
			KeyValueNode* tmp = curr-> next;
			free(curr);
			curr = tmp;
		}
	}
	// 2.再销毁动态数组
	free(map-> buckets);
	// 3.最后销毁哈希表结构体
	free(map);
}
static void rehash(KeyValueNode * curr, KeyValueNode** new_table, int new_capacity, uint32_t seed) {
	int len = strlen(curr-> key);
	int idx = hash(curr-> key, len, seed) % new_capacity;
	curr-> next = new_table [idx];
	new_table [idx] = curr;
}
// 对哈希表进行扩容操作
static void grow_capacity(DynamicHashMap* map) {
	/*
	* 扩容策略:
	* 1.如果容量没有达到阈值,那就每次将长度扩大为原先的 2 倍
	* 2.如果容量达到阈值,此时哈希表已经很长了,为了避免扩容过程性能损耗过大
	*   所以扩容保守一些,每次只扩容阈值长度的容量
	*
	* 扩容的过程:
	* 1.每个键值对结点都要重新计算哈希值,重新映射到新哈希桶中(新数组)
	* 2.原先的动态数组的链表很复杂,难以进行重哈希操作,建议直接丢弃它
	* 重新创建一个新动态数组
	*/
	int new_capacity = (map-> capacity <= CAPACITY_THRESHOLE)
		? (map-> capacity << 1) : (map-> capacity + CAPACITY_THRESHOLE);
	KeyValueNode** new_buckets = calloc(new_capacity, sizeof(KeyValueNode*));
	if (new_buckets == NULL) {
		printf("Error: calloc failed in grow_capacity\n");
		exit(1);
	}
	uint32_t seed = time(NULL);
	for (size_t i = 0; i < map-> capacity; i++)
	{
		KeyValueNode* curr = map-> buckets [i];
		while (curr != NULL) {
			KeyValueNode* next = curr-> next;
			rehash(curr, new_buckets, new_capacity, seed);
			curr = next;
		}
	}
	// 将旧动态数组 free,但是注意不要 free 键值对结点,结点已经被链接到新数组中了。
	free(map-> buckets);
	// 更新 HashMap 的信息
	map-> buckets = new_buckets;
	map-> capacity = new_capacity;
	map-> hash_seed = seed;
	}
	// 插入一个键值对
	// 1. 如果 key 不存在,则添加键值对结点
	// 2. 如果 key 存在,则更新 val,将旧的 val 返回
	ValueType hashmap_put(DynamicHashMap* map, KeyType key, ValueType val) {
	// 计算 key 的哈希值确定存储位置
	int idx = hash(key, strlen(key), map-> hash_seed) % (map-> capacity);
	// 遍历链表
	KeyValueNode* curr = map-> buckets [idx];
	while (curr != NULL) {
		if (strcmp(key, curr-> key) == 0) {
			// 更新 key 关联的 val, 并将旧的 value 返回
			ValueType old_val = curr-> val;
			curr-> val = val;
			return old_val;
		}
		curr = curr-> next;
	} // while 循环结束时, curr 是一个 NULL
	// 键值对不存在,即需要将键值对插入
	// 插入操作前需要计算当前哈希表的负载因子
	double load_factor = 1.0 * map-> size / map-> capacity;
	if (load_factor >= LOAD_FACTOR_THRESHOLD)
	{
		// 当前哈希表负载因子已达到阈值,将动态数组进行扩容
		grow_capacity(map);
		// 数组长度已变,需要再哈希确定当前键值对的存储位置
		idx = hash(key, strlen(key), map-> hash_seed) % (map-> capacity);
	}
	// 开始插入新键值对
	KeyValueNode* new_node = calloc(1, sizeof(KeyValueNode));
	if (new_node == NULL)
	{
		printf("Error: calloc failed in grow_capacity\n");
		exit(1);
	}
	// 初始化结点
	new_node-> key = key;
	new_node-> val = val;
	// 链表的头插法
	new_node-> next = map-> buckets [idx];
	map-> buckets [idx] = new_node;
	// 不要忘记更新 size
	map-> size++;
	return NULL;
	}
	// 查询一个键值对
	ValueType hashmap_get(DynamicHashMap* map, KeyType key) {
	// 计算 key 的哈希值确定位置
	int idx = hash(key, strlen(key), map-> hash_seed) % (map-> capacity);
	// 遍历链表
	KeyValueNode* curr = map-> buckets [idx];
	while (curr != NULL) {
		if (strcmp(key, curr-> key) == 0) {
			return curr-> val;
		}
		curr = curr-> next;
	} 
	return NULL;
	}
	// 删除某个键值对
	bool hashmap_remove(DynamicHashMap* map, KeyType key) {
	// 计算 key 的哈希值确定位置
	int idx = hash(key, strlen(key), map-> hash_seed) % (map-> capacity);
	// 遍历链表
	KeyValueNode* curr = map-> buckets [idx];
	KeyValueNode* prev = NULL;
	while (curr != NULL) {
		if (strcmp(key, curr-> key) == 0) {
			if (prev == NULL)
			{
				map-> buckets [idx] = curr-> next;
			}
			else
			{
				prev-> next = curr-> next;
			}
			free(curr);
			map-> size--;
			return true;
		}
		prev = curr;
		curr = curr-> next;
	}
	return false;
	}
	
test.c
 #define _CRT_SECURE_NO_WARNINGS
#include "dynamic_hashmap.h"
#include < stdio.h>
#include < stdlib.h>
#include < string.h>
#define N 100 // 初始容量是 8,设置为 10 足以触发一次扩容
int main() {
    // 创建哈希表
    DynamicHashMap* map = hashmap_create();
    if (map == NULL) {
        printf("哈希表创建失败。\n");
        return 1;
    }
    // 插入多个键值对以触发扩容
    char tmp_key [1024], tmp_val [1024];
    for (int i = 0; i < N; i++) {
        /*
            sprintf 是一个将格式化的数据输出到一个字符数组中,并最终保存为字符串的标准库函数
            第一个参数是输出的目的地字符串
            后面的参数则和 printf 使用方式一样
        */
        sprintf(tmp_key, "key%d", i);
        sprintf(tmp_val, "value%d", i);
        // 使用 calloc 分配和复制键值对
        char* key = calloc(strlen(tmp_key) + 1, sizeof(char));
        char* val = calloc(strlen(tmp_val) + 1, sizeof(char));
        strcpy(key, tmp_key);
        strcpy(val, tmp_val);
        hashmap_put(map, key, val);
        printf("插入 %s: %s\n", key, val);
    }
    // 检查扩容前后的数据一致性
    bool flag = true;
    for (int i = 0; i < N; i++) {
        sprintf(tmp_key, "key%d", i);
        ValueType ret_val = hashmap_get(map, tmp_key);
        sprintf(tmp_val, "value%d", i);
        if (ret_val == NULL || strcmp(ret_val, tmp_val) != 0) {
            printf("错误:数据不一致,键 %s 应有的值为 %s,检索得到的值为 %s\n",
                tmp_key, tmp_val,
                ((ret_val != NULL) ? ret_val : "null"));
            flag = false;   // 若数据插入有问题,则修改标志为 false
        }
    }
    if (flag) {
        printf("所有数据在扩容后仍正确无误。\n");
    }
    // free 所有堆上分配的字符串
    for (int i = 0; i < map-> capacity; i++) {
        KeyValueNode* curr = map-> buckets [i];
        while (curr != NULL) {
            free(curr-> key);   // 释放键字符串
            free(curr-> val);   // 释放值字符串
            curr = curr-> next;
        }
    }
    // 销毁哈希表
    hashmap_destroy(map);
    system("pause");
    return 0;
}

哈希表的应用场景

哈希表的应用十分广泛,任何编程语言的程序在日常开发中,哈希表都是必须要使用的数据结构。它大致有以下常见的用途:

  1. 用作容器,存储键值对元素。用作容器是哈希表最常见的使用场景之一,毕竟键值对数据在开发中出现的频率很高,比如 "用户名-密码" 等。常见的高级编程语言,往往都会提供现成的哈希表库实现,比如:
    1. C++ 中的 unordered_mapunordered_set
      • map 是是一种存储键值对的哈希表容器,提供了高效的插入,查询,删除等操作。
      • set 是一种基于哈希表的集合容器,它存储唯一元素,并提供高效的成员插入、查找和删除操作。
    2. Java 中的 HashMapHashSet 以及 HashTable,它们都是基于哈希表实现的集合容器。
  2. 在算法领域,哈希表还经常用于以下场景中:
    1. 查找重复或唯一元素:哈希表提供了快速查询和插入的功能,且 Key 唯一使得它在查重或查找唯一元素时特别好用。
    2. 计数器应用:统计频率次数,也是哈希表经常应用的场景。
  3. 哈希表可以用于实现数据库的索引,提高数据库的访问效率。
  4. 哈希表还常用于实现缓存,利用其快速查询的特点,用于快速查询缓存的消息。比如目前业界最流行的高速缓存中间件——Redis,它有个别名叫:键值对内存数据库,哈希表是 Redis 最常用的类型之一。

二叉搜索树

二叉树

树是一种层次化的数据结构,它在现实生活和计算机科学中广泛存在并发挥重要作用。比如:

  1. 族谱(Family Tree)
  2. 组织架构
  3. 计算机文件系统的目录结构
  4. ...

树这种数据结构的特点,是从一个单一的根结点开始,分支出多个结点,每个结点又可能有自己的子结点,形成一个分层次的树状结构。

不过在众多的树形结构中,我们只学习二叉树,原因很简单,它很重要!

它有多重要呢?

单就面试而言,在 LeetCode 中二叉树相关的题目共有 300 多道,占据总题目的近三分之一。同时,二叉树在整个算法板块中还起到承上启下的作用:它不但是数组和链表的延伸,又可以作为图的基础。总之,非常重要!

那什么是 二叉树(BinaryTree) 呢?

二叉树的定义:二叉树是一棵树,并且二叉树的每个结点最多有两棵子树。二叉树的子树又分为左子树和右子树。

从根结点开始,每个结点最多有两棵子树,这就是一棵 "二叉树"。二叉树有两种比较独特的形态:完全二叉树和满二叉树。

完全二叉树,若二叉树的深度为 h,除第 h 层外,其它各层(1~h-1)的结点数目都达到最大值,第 h 层的结点都连续排列在最左边,这样的二叉树就是完全二叉树。

满二叉树,每一层的结点数目都达到最大值(包括最下面一层)。

完全二叉树和满二叉树-示意图

二叉搜索树

二叉搜索树是一种特殊的二叉树,其中每个结点的左子树只包含小于当前结点的值,右子树只包含大于当前结点的值。

当然二叉搜索树的定义非常适合使用递归:

二叉搜索树是一种特殊的二叉树,从根结点开始,其每个结点都满足:

  1. 左子树中的每个结点的值都小于该结点的值,并且左子树本身也是二叉搜索树
  2. 右子树中的每个结点的值都大于该结点的值,并且右子树本身也是二叉搜索树

所以二叉搜索树在定义时就已经和递归绑定了,我们在下面处理二叉搜索树时,递归是常用的手段。

如下图所示:

二叉搜索树-示意图

需要注意的是,二叉搜索树只是在二叉树的基础上规定了结点取值的大小关系,二叉搜索树并不一定就是完全二叉树、满二叉树。

设计思路

可以用一个结构体描述二叉搜索树的某个结点:

typedef int KeyType;
// 二叉搜索树的结点类型
typedef struct node {
 KeyType key;       // 结点 key 值, 具有唯一性
 struct node *left; // 左子树
 struct node *right;    // 右子树
} TreeNode;

其中 key 决定了结点在树中的存储位置,也是搜索的凭证。所以出于简化操作的目的,大多二叉搜索树都会规定 key 是唯一的

二叉搜索树最重要、基础的操作有以下:

  1. 插入。向树中添加一个新的结点。这个操作需要保持二叉搜索树的性质,即插入的结点必须放置在保持左子结点小于父结点,且右子结点大于父结点的正确位置上。
  2. 搜索。根据 key 查找目标结点。在二叉搜索树中,搜索操作可以高效进行,通过比较目标值与结点值来决定向左子树或右子树移动。搜索操作的实现是比较简单的。
  3. 删除。根据 key 从树中删除某个结点。这是二叉搜索树中最复杂的操作。
  4. 遍历。遍历树中的所有结点。常见的遍历方式有前序遍历、中序遍历、后序遍历以及层次遍历。其中,中序遍历二叉搜索树会得到一个有序的结点序列。
二叉搜索树实现-概念模型

代码实现

bst.h
 #ifndef BST_H
#define BST_H
#include < stdbool.h>
#include < stdlib.h>
#include < stdio.h>
typedef int KeyType;
typedef struct node {
    KeyType key;        // 结点 key 值, 具有唯一性
    struct node* left;  // 左子树
    struct node* right; // 右子树
} TreeNode;
// 推荐定义一个二叉搜索树的结构体, 这样更便于扩展
typedef struct {
    TreeNode* root; // 根结点指针
}BST;
// 基础操作
// 创建一棵空的 BST
BST* bst_create();
// 销毁一棵 BST
void bst_destroy(BST* tree);
// 根据 key 插入一个新结点
bool bst_insert(BST* tree, KeyType key);
// 根据 key 搜索某个结点并返回
TreeNode * bst_search(BST* tree, KeyType key);
// 根据 key 删除一个结点
bool bst_delete(BST* tree, KeyType key);
// 深度优先-先序遍历
void bst_preorder(BST* tree);
// 深度优先-中序遍历
void bst_inorder(BST* tree);
// 深度优先-后序遍历
void bst_postorder(BST* tree);
// 广度优先-层次遍历
void bst_levelorder(BST* tree);
#endif // ! BST_H

bst.c
 #include "bst.h"
#include "list_queue.h"
BST* bst_create()
{
	return  calloc(1, sizeof(BST));
}
/*
* 向二叉搜索树中新增一个结点
* 1.若 key 已存在,则不新增,插入失败
* 2.若 key 不存在,则进行新增,插入成功
*/
bool bst_insert(BST* tree, KeyType key) {
	// 1.遍历找到插入的位置
	// 初始化两个指针, 一个用于找到插入位置, 一个用于指向它的父结点
	TreeNode* parent = NULL;
	TreeNode* curr = tree-> root;
	int diff;   // while 循环一定执行, 不用担心 diff 未初始化
	while (curr != NULL) {  // 此遍历就是为了查找 NULL, curr 最终等于 NULL 说明查找到了插入位置
		diff = key - curr-> key;
		if (diff > 0) {
			// 待插入结点的 key 大于当前结点 key
			// 向右遍历查找
			parent = curr;
			curr = curr-> right;
		}
		else if (diff < 0)
		{
			// 待插入结点的 key 小于当前结点 key
			// 向左遍历查找
			parent = curr;
			curr = curr-> left;
		}
		else
		{
			// 待插入结点的 key 等于当前结点 key
			// key 重复, 插入失败
			return false;
		}
	}   // 循环结束, 说明 curr 是 NULL, 即 key 不重复, 找到了插入的位置, parent 指针指向待插入位置的父结点
	// 2.新建一个结点, 初始化结点
	// 重要建议: 在二叉搜索树中新建结点, 请一律使用 calloc, 安全第一!
	TreeNode* new_node = calloc(1, sizeof(TreeNode));
	if (new_node == NULL) {
		printf("calloc failed in bst_insert.\n");
		exit(1);
	}
	new_node-> key = key;
	// 3.将新结点链接到二叉树上(链表的尾插)
	if (parent == NULL) {
		// 说明此时树是一个空树
		tree-> root = new_node;  // 新结点成为根结点
		return true;    // 插入成功
	}
	// 插入的结点不是第一个结点
	if (diff < 0) {
		// 插入左子树
		parent-> left = new_node;
	}
	else {
		// 插入右子树
		parent-> right = new_node;
	}
	return true;
}
TreeNode * bst_search(BST* tree, KeyType key)
{
	TreeNode* curr = tree-> root;
	while (curr != NULL) {
		if (curr-> key == key)
		{
			return curr;
		}
		else  if (curr-> key > key)
		{
			curr = curr-> left;
		}
		else
		{
			curr = curr-> right;
		}
	}
	return NULL;
}
// 根据 key 删除一个结点
bool bst_delete(BST* tree, KeyType key) {
	// 1.利用 curr 和 parent 两个指针找到待删除结点以及它的父节点
	TreeNode* curr = tree-> root;
	TreeNode* parent = NULL;
	// 2.根据 key 值的大小关系, 来遍历这棵树的结点
	while (curr != NULL) {  // 这一点模拟了单链表的遍历, 因为搜索路径就是一条单链表
		int diff = key - curr-> key;
		if (diff > 0) {
			// 待删除 key 值比当前结点 key 值要大, 于是去右子树中遍历查找
			parent = curr;
			curr = curr-> right;
		}
		else if (diff < 0) {
			// 待删除 key 值比当前结点 key 值要小, 于是去左子树中遍历查找
			parent = curr;
			curr = curr-> left;
		}
		else {
			// 找到待删除结点, 下面就开始删除该结点的操作
			break;
		}
	}
	/*
		while 循环结束, 有两种可能性:
		1.找到了目标 key 结点, 依赖 break 结束循环
			此时 curr 指针指向待删除结点, parent 指向它的父结点
		2.没找到目标 key 结点, 依赖 curr 变成 NULL 结束循环
			此时 curr 指针是 NULL 相当于找到了这个不存在 key 结点的插入位置
			而 parent 就指向插入位置的父结点
		以上两种情况, 如果是第一种情况, 我们就继续删除
		但如果是第二种情况, 目标结点不存在, 删除就失败了
	*/
	if (curr == NULL) {
		return false;
	}
	// 2.根据度不同来进行不同的处理, 使得待删除结点从树结构中断开
	// 2.1 判断结点的度是否为 2, 如果度为 2, 进行降级处理
	if (curr-> left != NULL && curr-> right != NULL) {
		// 删除的结点是度为 2 的, 于是将它降低为删除度为 0 或 1
		TreeNode* min_node = curr-> right;   // 寻找待删除结点的右子树最小结点, 从 curr 的右子树结点开始
		TreeNode* min_parent = curr;    // 寻找待删除结点的右子树最小结点的父结点
		while (min_node-> left != NULL) {
			min_parent = min_node;
			min_node = min_node-> left;
		}   // while 循环结束时, min_node 指向右子树最小结点, min_parent 指向 min_node 结点的父结点
		// 将右子树最小结点的值替换给 curr 结点, 并且后续将删除右子树最小结点
		curr-> key = min_node-> key;
		// 后续的删除操作都是基于 curr 指针和 parent 指针的, 所以这里要进行一步赋值操作
		curr = min_node;
		parent = min_parent;
	}
	// 接下来待删除的结点就是 curr 结点, 它的父结点就是 parent, 而且这个结点的度一定是 0 或 1
	// 2.2 统一处理度为 0 或 1 结点的删除操作
	// 核心操作: parent -> left/right = curr -> left/right
	// 先确定 parent 指向待删除结点的左子树还是右子树
	TreeNode* child = (curr-> left != NULL) ? curr-> left : curr-> right;
	/*
		考虑特殊情况:
		删除的结点是度为 0 或 1 的根结点
		此时需要更新根结点指针
	*/
	if (parent == NULL) {
		// 删除的结点是根结点, 此根结点度为 0 或 1
		tree-> root = child;
	}
	else {
		// 删除的结点不是根结点
		// 执行核心操作: parent -> left/right = child;
		if (parent-> left == curr) {
			// 待删除结点是父结点的左子树, 那么就让父结点的左子树指向 child
			parent-> left = child;
		}
		else {
			// 待删除结点是父结点的右子树, 那么就让父结点的右子树指向 child
			parent-> right = child;
		}
	}
	// 3.free 这个结点
	free(curr); // 删除成功
	return true;
}
/*
  利用递归实现 BST 的先序遍历
  root 参数表示此一轮递归调用中的根结点
  先序遍历是: DLR
  分解的思路是:
  对于任何一棵树 root 来说:
  1.总是先输出根结点的值
  2.然后递归遍历它的左子树
  3.最后递归遍历它的右子树
  preoder(root) = D + preorder(root-> left) + preorder(root-> right)
  这就是递归体
  这样的分解不会无休止进行, 如果分解成一棵空树, 也就是 root 是空指针时, 返回函数
  这就是递归的出口
*/
static void preorder(TreeNode* root) {
	if (root == NULL)
	{
		return;
	}
	printf("%d ", root-> key);
	preorder(root-> left);
	preorder(root-> right);
}
// 深度优先-先序遍历
void bst_preorder(BST* tree)
{
	if (tree == NULL) {
		// 树为空直接返回
		return;
	}
	// 利用辅助函数递归实现 BST 的先序遍历
	preorder(tree-> root);
	printf("\n");
}
/*
  利用递归实现 BST 的中序遍历
  root 参数表示此一轮递归调用中的根结点
  中序遍历是: LDR
  分解的思路是:
  对于任何一棵树 root 来说:
  1.总是先递归遍历它的左子树
  1.然后再输出根结点的值
  3.最后递归遍历它的右子树
  inorder(root) = inorder(root-> left) + D + inorder(root-> right)
  这就是递归体
  这样的分解不会无休止进行, 如果分解成一棵空树, 也就是 root 是空指针时, 返回函数
  这就是递归的出口
*/
static void inorder(TreeNode* root) {
	if (root == NULL)
	{
		return;
	}
	inorder(root-> left);
	printf("%d ", root-> key);
	inorder(root-> right);
}
// 深度优先-中序遍历
void bst_inorder(BST* tree)
{
	if (tree == NULL) {
		// 树为空直接返回
		return;
	}
	// 利用辅助函数递归实现 BST 的中序遍历
	inorder(tree-> root);
	printf("\n");
}
/*
  利用递归实现 BST 的后序遍历
  root 参数表示此一轮递归调用中的根结点
  中序遍历是: LRD
  分解的思路是:
  对于任何一棵树 root 来说:
  1.总是先递归遍历它的左子树
  3.然后递归遍历它的右子树
  3.最后再输出根结点的值
  postorder(root) = postorder(root-> left) + postorder(root-> right) + D
  这就是递归体
  这样的分解不会无休止进行, 如果分解成一棵空树, 也就是 root 是空指针时, 返回函数
  这就是递归的出口
*/
static void postorder(TreeNode* root) {
	// 1.递归的出口
	if (root == NULL) {
		return;
	}
	// 2.递归体
	postorder(root-> left);
	postorder(root-> right);
	printf("%d ", root-> key);
}
void bst_postorder(BST* tree) {
	if (tree == NULL) {
		// 树为空直接返回
		return;
	}
	// 利用辅助函数递归实现 BST 的后序遍历
	postorder(tree-> root);
	printf("\n");
}
static void destroy(TreeNode* root) {
	// 1.递归的出口
	if (root == NULL) {
		return;
	}
	// 2.递归体
	destroy(root-> left);
	destroy(root-> right);
	free(root);
}
void bst_destroy(BST* tree)
{
	if (tree == NULL) {
		// 树为空直接返回
		return;
	}
	// 利用辅助函数递归实现 BST 的销毁
	destroy(tree-> root);
	free(tree);
}
void bst_levelorder(BST* tree) {
	LinkedListQueue* q = create_queue();
	enqueue(q, tree-> root);
	while (! is_empty(q)) {
		int level_size = q-> size;
		for (int i = 0; i < level_size; i++)
		{
			TreeNode* node = dequeue(q);
			printf("%d ", node-> key);
			if (node-> left != NULL)
			{
				enqueue(q, node-> left);
			}
			if (node-> right != NULL)
			{
				enqueue(q, node-> right);
			}
		}
		printf("\n");
	}
	destroy_queue(q); // 完成遍历后,销毁队列释放资源
	printf("\n");
}
list_queue.h
 #ifndef LIST_QUEUE_H
#define LIST_QUEUE_H
#include < stdbool.h>
#include < stdio.h>
#include < stdlib.h>
#include "bst.h"
// 定义队列中的元素类型
typedef TreeNode* ElementType;
// 队列节点的结构
typedef struct node_s {
    ElementType data;
    struct node_s* next;
} QueueNode;
// 队列的结构
typedef struct {
    QueueNode* front;  // 队头结点指针
    QueueNode* rear;   // 队尾结点指针
    int size;   // 队列中元素的个数
} LinkedListQueue;
// 函数声明
// 创建链式队列
LinkedListQueue* create_queue();
// 销毁链式队列
void destroy_queue(LinkedListQueue* q);
// 入队列
bool enqueue(LinkedListQueue* q, ElementType element);
// 出队列并返回队头元素
ElementType dequeue(LinkedListQueue* q);
// 访问队头元素
ElementType peek_queue(LinkedListQueue* q);
// 判空
bool is_empty(LinkedListQueue* q);
#endif // ! LIST_QUEUE_H 
list_queue.c
 #include "list_queue.h"
// 函数声明
LinkedListQueue* create_queue() {
    return calloc(1, sizeof(LinkedListQueue));
}
void destroy_queue(LinkedListQueue* q) {
    // 从队头开始遍历链表,销毁每一个结点
    QueueNode* current = q-> front;
    while (current != NULL) {
        QueueNode* temp = current-> next;
        free(current);
        current = temp;
    }
    // 销毁队列结构体
    free(q);
}
bool is_empty(LinkedListQueue* q) {
    // 队头指针是空指针,即表示空队列
    return q-> front == NULL;
}
// 入队操作: 只能在队尾插入一个结点
// 由于已存在尾指针,所以这里的操作就是链表尾插
bool enqueue(LinkedListQueue* q, ElementType element) {
    QueueNode* new_node = malloc(sizeof(QueueNode));
    if (new_node == NULL) {
        printf("Error: malloc failed in enqueue.\n");
        return false;
    }
    // 初始化新结点
    new_node-> data = element;
    new_node-> next = NULL;
    // 开始进行尾插法插入一个结点
    // 分两种情况:如果尾插插入的是第一个结点需要同步更新头指针,否则仅需要更新尾指针
    if (q-> front == NULL) {
        // 插入的是第一个结点
        q-> front = new_node;
        q-> rear = new_node;
    }
    else {
        // 插入的不是第一个结点
        q-> rear-> next = new_node;
        q-> rear = new_node;
    }
    q-> size++;
    return true;
}
// 出队,在队头删除一个结点。也就是在删除链表的第一个结点
ElementType dequeue(LinkedListQueue* q) {
    if (is_empty(q)) {
        printf("Error: queue is empty.\n");
        exit(1);
    }
    QueueNode* tmp = q-> front;
    // 将出队的结点数据保存
    ElementType remove_data = tmp-> data;
    // 更新队头指针
    q-> front = tmp-> next;
    if (q-> front == NULL) {
        // 如果队头更新后, 队列为空, 说明出队的就是最后一个元素
        // 于是同步更新队尾指针
        q-> rear = NULL;
    }
    free(tmp);
    q-> size--;
    return remove_data;
}
// 访问队头元素但不出队
ElementType peek_queue(LinkedListQueue* q) {
    if (is_empty(q)) {
        printf("Error: queue is empty.\n");
        exit(1);
    }
    return q-> front-> data;
}
test.c
 #define _CRT_SECURE_NO_WARNINGS
#include < stdio.h>
#include "bst.h"
int main(void) {
    // 创建二叉搜索树
    BST* tree = bst_create();
    if (! tree) {
        printf("二叉搜索树创建失败。\n");
        return 1;
    }
    // 插入元素
    int elements [] = { 50, 30, 70, 20, 40, 60, 80, 30 }; // 包含重复元素 30
    printf("正在插入元素: ");
    int arr_len = sizeof(elements) / sizeof(elements [0]);
    for (int i = 0; i < arr_len; i++) {
        if (bst_insert(tree, elements [i])) {
            printf("%d ", elements [i]); // 打印成功插入的元素
        }
        else {
            printf("(重复 %d) ", elements [i]); // 打印重复元素的信息
        }
    }
    printf("\n");
    // 预期结果:重复的元素不应该被插入到树中。
    // 中序遍历,应该输出有序序列
    printf("中序遍历输出(预期有序): ");
    bst_inorder(tree);
    printf("\n");
    // 预期结果:20, 30, 40, 50, 60, 70, 80
    // 先序遍历输出 预期结果:50 30 20 40 70 60 80
    printf("先序遍历输出: ");
    bst_preorder(tree);
    printf("\n");
    // 后序遍历输出 预期结果:20 40 30 60 80 70 50
    printf("后序遍历输出: ");
    bst_postorder(tree);
    printf("\n");
    // 层次遍历输出 预期结果:50 30 70 20 40 60 80
    printf("层次遍历输出: ");
    bst_levelorder(tree);
    printf("\n");
    // 测试搜索存在和不存在的情况 预期结果:找到了
    int searchKey = 40;
    printf("搜索键 %d 的结果: %s\n", searchKey, bst_search(tree, searchKey) ? "找到了" : "未找到");
    // 预期结果:未找到
    searchKey = 100;
    printf("搜索键 %d 的结果: %s\n", searchKey, bst_search(tree, searchKey) ? "找到了" : "未找到");
    // 测试删除操作
    printf("删除元素 70 和 20\n");
    bst_delete(tree, 70);
    bst_delete(tree, 20);
    // 再次进行中序遍历验证删除操作 // 预期结果:30, 40, 50, 60, 80
    printf("删除元素后中序遍历输出: ");
    bst_inorder(tree);
    printf("\n");
    // 销毁二叉搜索树
    bst_destroy(tree);
    system("pause");
    return 0;
}
posted @   Invinc-Z  阅读(341)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
点击右上角即可分享
微信分享提示