C语言实现Hash Map(2):Map代码实现详解

在上一节C语言实现Hash Map(1):Map基础知识入门中,我们介绍了Map的基础概念和在C++中的用法。但我写这两篇文章的目的是,能够在C语言中实现这样的一个数据结构,毕竟有时我们的项目中可能会用到Map,但是C语言库中并没有提供相关的数据结构供我们使用。所以这一节,我们就来看一下在C语言中是如何实现Map的。

  • 参考代码:https://github.com/rxi/map

文章目录

  • 1 使用实例
  • 2 代码分析
    • 2.1 map_init
      • 2.1.1 map相关数据结构
        • 2.1.1.1 变量声明
        • 2.1.1.2 map_t
        • 2.1.1.3 map_base_t
        • 2.1.1.4 map_node_t
      • 2.1.2 初始化函数
    • 2.2 通用函数分析
      • 2.2.1 map_hash:求哈希值
      • 2.2.2 map_newnode:创建新节点
      • 2.2.3 map_bucketidx:计算桶
        • 2.2.3.1 桶的数量和哈希值的关系
      • 2.2.4 map_addnode:添加节点
      • 2.2.5 map_resize:重新调整哈希表大小
    • 2.3 map_set:设置键值
        • 2.3.1 函数参数和值sizeof
        • 2.3.2 map_getref
        • 2.3.3 添加新节点
    • 2.4 map_get:获取键对应的值
    • 2.5 删除键值和遍历
  • 3 总结

1 使用实例

我们学习的过程一定是从已经成熟运用的代码中学习的,所以本文就来学习一下Github中这个已经被很多人用在项目中的map库。文件很简单,就一个map.cmap.h。我们拿到这个代码就可以直接使用的,非常简单:

#include <stdio.h>
#include <stdlib.h>
#include "map.h"static map_str_t langMap;int main()
{char *ret;map_init(&langMap);map_set(&langMap, "test", "1234");ret = map_get(&langMap, "test");if(ret != NULL){printf("%s\r\n", ret);}else{printf("NULL\r\n");}return 0;
}

程序输出如下,可以看到我们初始化之后只需要设置键和值,然后使用map_get函数即可获取对应键的值了。

在这里插入图片描述

下面我们就来分析一下这里面的代码。

2 代码分析

现在,我们就基于我上面写的一个简单的例子,来分析一下代码完成了哪些操作。

2.1 map_init

2.1.1 map相关数据结构

2.1.1.1 变量声明

这里我声明了一个langMap变量:

static map_str_t langMap;

map.h文件中有声明不同的typedef:

typedef map_t(void*) map_void_t;
typedef map_t(char*) map_str_t;
typedef map_t(int) map_int_t;
typedef map_t(char) map_char_t;
typedef map_t(float) map_float_t;
typedef map_t(double) map_double_t;

这里键的类型固定是char *,上面我的例子中使用的是map_str_t这个typedef,实际上就是定义值的类型,也就是这里键和值都是char *。如果想要值是其他的类型,定义其它的类型就行了。

2.1.1.2 map_t

接下来看一下这个宏定义:

#define map_t(T)\struct { map_base_t base; T ref;}

可以看到就是根据用户提供的数据类型,声明一个对应的ref变量。

2.1.1.3 map_base_t

再来看一下map_base_t的数据结构,这实际上也是我们map的核心数据结构:

typedef struct {map_node_t **buckets;unsigned nbuckets, nnodes;
} map_base_t;

根据上一节我们学到的知识,通过nbucketsnnodes的名字,我们就可以猜测其含义如下:

  1. nbuckets
    • 作用:表示哈希表中桶(bucket)的数量。桶是哈希表的基本存储单元,每个桶可以包含零个或多个键值对(节点)。
    • 用途:nbuckets 用于确定将键值对分配到哪个桶中。哈希值经过处理后,取模操作决定具体的桶索引。
  2. nnodes
    • 作用:表示哈希表中当前存储的键值对(节点)的数量。
    • 用途:nnodes 用于跟踪哈希表中的实际元素数目。这个信息对于决定是否需要调整哈希表的大小(例如扩展或收缩)非常重要。当 nnodes 达到 nbuckets 的某个临界值时(如 nnodes 等于或超过 nbuckets),哈希表需要进行扩展以保持较低的碰撞率和较高的性能。
2.1.1.4 map_node_t

map.h中,声明了map_node_t

struct map_node_t;
typedef struct map_node_t map_node_t;

这个结构体的实例在map.c中:

struct map_node_t {unsigned hash;void *value;map_node_t *next;/* char key[]; *//* char value[]; */
};

这里的几个参数有什么作用,后面我们在代码中碰到了再分析。

2.1.2 初始化函数

这里的map_init函数实际上只是一个宏定义:

#define map_init(m)\memset(m, 0, sizeof(*(m)))

只是将map_str_t中各个数据结构清零,在有些RAM中,上电后初始值不一定为0,所以保险起见,还是清空一下。

2.2 通用函数分析

在分析设置键值函数之前,我们首先来学习一下后面可能会在函数中用到的一些通用的函数。

2.2.1 map_hash:求哈希值

map_hash 是一个用于计算字符串哈希值的函数。它采用了经典的 DJB2 哈希算法,这是一种快速且分布均匀的字符串哈希算法。以下是对 map_hash 函数的详细介绍:

static unsigned map_hash(const char *str) {unsigned hash = 5381;while (*str) {hash = ((hash << 5) + hash) ^ *str++;}return hash;
}

map_hash 函数利用 DJB2 哈希算法计算一个字符串的哈希值。DJB2 算法的核心思想是通过不断地乘以一个质数(在这里是33:左移5位+1)并进行异或操作来更新哈希值,以确保哈希值的分布均匀并减少冲突。

这个哈希函数在哈希表的实现中扮演着重要角色,因为它决定了键在哈希表中的存储位置。哈希值的质量直接影响哈希表的性能,包括查找、插入和删除操作的效率。

2.2.2 map_newnode:创建新节点

前面我们提到节点的数据结构是map_node_t,这个函数就是动态分配一个map_node_t节点并返回,实现如下:

static map_node_t *map_newnode(const char *key, void *value, int vsize) {map_node_t *node;int ksize = strlen(key) + 1;int voffset = ksize + ((sizeof(void*) - ksize) % sizeof(void*));node = MAP_MALLOC(sizeof(*node) + voffset + vsize);if (!node) return NULL;memcpy(node + 1, key, ksize);node->hash = map_hash(key);node->value = ((char*) (node + 1)) + voffset;memcpy(node->value, value, vsize);return node;
}

1、内存的分配和释放函数

大家可以移植单片机中的,比如有FreeRTOS,就可以移植vPortMallocvPortFree,我这里使用c库里的内存分配函数:

#define MAP_MALLOC malloc
#define MAP_FREE free

2、((sizeof(void*) - ksize) % sizeof(void*))

这很明显就是根据CPU的位数(sizeof(void *))来进行字节对齐。


再回来看一下map_node_t的数据结构:

struct map_node_t {unsigned hash;void *value;map_node_t *next;/* char key[]; *//* char value[]; */
};

这里内存分配的总大小是sizeof(*node) + voffset + vsize。其中,node为上面声明的map_node_t数据结构的总大小,然后voffset为键所占的字节对齐后的内存大小,value为值所占的内存大小。这里键和值的内存由于是不固定的,所以没有声明在结构体中,我们直接将键和值放在map_node_t的后面。

**如果后续匹配了,怎么获取键值?**获取键很容易,就在map_node_t最后,对于值的话,每次通过键设置或查值的时候,再计算一下voffset就行了。

2.2.3 map_bucketidx:计算桶

map_bucketidx 函数用于确定一个哈希值应该被放置到哈希表的哪个桶(bucket)中。很明显这个函数通过将哈希值与哈希表中的桶数量进行模运算来计算桶的索引。

  • 这里使用位与运算代替取模的话可以加快运算速度,但需要保证nbuckets的值是2n
static int map_bucketidx(map_base_t *m, unsigned hash) {/* If the implementation is changed to allow a non-power-of-2 bucket count,* the line below should be changed to use mod instead of AND */return hash & (m->nbuckets - 1);
}
2.2.3.1 桶的数量和哈希值的关系

在哈希表中,桶的数量(nbuckets)和哈希值之间的关系如下:

  • 哈希值:由 map_hash 函数计算得到,它是一个无符号整数,用于唯一标识一个键。
  • 桶的数量(nbuckets:表示哈希表中可用桶的数量。每个桶可以包含零个或多个键值对(节点)。
  • 桶索引:由 map_bucketidx 函数通过位与运算计算得到,用于决定哈希值被分配到哪个桶中。

2.2.4 map_addnode:添加节点

由前面的buckets的声明我们知道,buckets可以理解为map_node_t的指针的数组,数组中的每一个元素代表一个桶,每个桶也是map_node_t,里面有一个next参数,这类似于链表的数据结构,就可以连接当前桶内的所有节点。

static void map_addnode(map_base_t *m, map_node_t *node) {int n = map_bucketidx(m, node->hash);node->next = m->buckets[n];m->buckets[n] = node;
}

所以上面的函数就很好理解了,就是把新节点插入桶中链表的最前面。

2.2.5 map_resize:重新调整哈希表大小

map_resize 函数用于调整哈希表的大小(桶的数量)。当哈希表中的节点数超过一定比例时,通过增加桶的数量来减小冲突,提高查找、插入和删除操作的效率。具体来说,map_resize 函数将重新分配哈希表中的所有节点,使它们分布在新的桶中。下面是该函数的详细解释:

static int map_resize(map_base_t *m, int nbuckets) {map_node_t *nodes, *node, *next;map_node_t **buckets;int i;/* Chain all nodes together */nodes = NULL;i = m->nbuckets;while (i--) {node = (m->buckets)[i];while (node) {next = node->next;node->next = nodes;nodes = node;node = next;}}/* Reset buckets */buckets = realloc(m->buckets, sizeof(*m->buckets) * nbuckets);if (buckets != NULL) {m->buckets = buckets;m->nbuckets = nbuckets;}if (m->buckets) {memset(m->buckets, 0, sizeof(*m->buckets) * m->nbuckets);/* Re-add nodes to buckets */node = nodes;while (node) {next = node->next;map_addnode(m, node);node = next;}}/* Return error code if realloc() failed */return (buckets == NULL) ? -1 : 0;
}

简单分析一下上面的代码:

1、链表化所有节点

将所有节点串成一个单链表。遍历当前所有桶,将节点从桶中移除并加入到新的链表 nodes 中。

2、重新分配桶

使用 realloc 函数重新分配桶数组的内存,使其大小调整为新的桶数量 nbuckets。如果 realloc 成功,更新哈希表的桶指针和桶数量。

  • 注意:前面提到我们可以替换内存分配和释放的宏定义为自己的,但是这里又出现一个realloc函数,这个是在stdlib.h中的,在FreeRTOS中肯定是没有的,我们最好也不要用两种内存分配的方法,后面我们对这部分的代码做一些优化。

3、重新初始化桶

如果桶重新分配成功,则将新的桶数组初始化为 0,并将所有节点重新插入到新的桶中。通过 map_addnode 函数重新计算每个节点的桶索引,并将节点添加到对应的桶中。

2.3 map_set:设置键值

从前面的例子中,初始化之后就直接设置键值了:

map_set(&langMap, "test", "1234");

这也是这里map实现的核心,这就是一个简单的宏定义:

#define map_set(m, key, value)\( map_set_(&(m)->base, key, value, sizeof(value)) )

我们主要来看一下map_set_是如何实现的:

int map_set_(map_base_t *m, const char *key, void *value, int vsize) {int n, err;map_node_t **next, *node;/* Find & replace existing node */next = map_getref(m, key);if (next) {memcpy((*next)->value, value, vsize);return 0;}/* Add new node */node = map_newnode(key, value, vsize);if (node == NULL) goto fail;if (m->nnodes >= m->nbuckets) {n = (m->nbuckets > 0) ? (m->nbuckets << 1) : 1;err = map_resize(m, n);if (err) goto fail;}map_addnode(m, node);m->nnodes++;return 0;
fail:if (node) MAP_FREE(node);return -1;
}
2.3.1 函数参数和值sizeof

先来看一下函数的参数,其中m就是map_base_t变量的地址,key就是键,value就是值的地址,vsize就是值的大小。现在这里有一个问题,我们使用sizeof(value)来获取值的长度,值的类型有以下几种:

typedef map_t(void*) map_void_t;
typedef map_t(char*) map_str_t;
typedef map_t(int) map_int_t;
typedef map_t(char) map_char_t;
typedef map_t(float) map_float_t;
typedef map_t(double) map_double_t;

对于void *char *int来说都没什么问题,分别返回4,字符串的长度(如果输入的是一个字符串常量的话)和4。但是:

1、char:如sizeof('c')

在C语言中,字符字面量(例如 'c')的类型是 int,而不是 char。因此,sizeof('c') 实际上会返回 sizeof(int) 的值,这通常是 4 字节(在大多数现代系统上)。这可能与期望的 sizeof(char) 返回值(通常为1字节)不同。

2、floatdouble

大家可以试一下,sizeof(3.14)sizeof(2.71828) 实际上都会返回 sizeof(double),因为在C语言中,字面值浮点数默认为 double 类型。

也就是说,这里的sizeof并不是实际的大小。


注意:在这个仓库的readme中,使用的是map_int_t类型举例的:map_set(&m, “testkey”, 123),这样明显也是不行的,因为第三个参数是void *,这里却直接传了一个数字。按照数据类型来看,这里还要声明一个int变量,然后map_set传地址才行,那这样完全变成了void *类型的了 基于此,使用map_str_t肯定是没有问题的,但是使用其它的几个数据类型,程序肯定有问题,要么编译不通过,要么通过了也内存越界,大家可以自己试一下。

也就是说,虽然这个map实现在github中是star比较多的,但是bug还是挺多的。我们有时可能还是希望可以直接设置值,而不是还要声明一个变量。所以本篇文章仅以map_str_t例子举例,实际产品用这个也是没问题的。


好了,我们暂时不纠结这个数据类型的问题,至少整个代码的map实现逻辑是没有问题的,只是兼容性这边出了点问题。下面我们开始分析map_set_函数。

2.3.2 map_getref

首先执行的是map_getref函数,下面是这个函数的实现:

static map_node_t **map_getref(map_base_t *m, const char *key)
{unsigned hash = map_hash(key);map_node_t **next;if (m->nbuckets > 0) {next = &m->buckets[map_bucketidx(m, hash)];while (*next) {if ((*next)->hash == hash && !strcmp((char*) (*next + 1), key)) {return next;}next = &(*next)->next;}}return NULL;
}

我们暂时不知道nbucketsbuckets数组在哪里设置的,还有它们的作用是什么。但是从这个函数大概可以知道,大概就是先求键的哈希值,然后去寻找一下是否有相同的键(有可能不同的键有同一个hash),如果有的话就返回这个节点指针的地址,没有的话就返回NULL。来看一下代码:

next = map_getref(m, key);
if (next) {memcpy((*next)->value, value, vsize);return 0;
}

如果该key的节点已经存在的话,就直接修改这个节点的值即可,函数直接返回。

2.3.3 添加新节点

继续分析map_set_中的代码:

    /* Add new node */node = map_newnode(key, value, vsize);if (node == NULL) goto fail;if (m->nnodes >= m->nbuckets) {n = (m->nbuckets > 0) ? (m->nbuckets << 1) : 1;err = map_resize(m, n);if (err) goto fail;}map_addnode(m, node);m->nnodes++;return 0;
fail:if (node) MAP_FREE(node);return -1;

简单分析一下:

1、节点不存在则创建节点

2、如果当前节点数量超过或等于桶的数量,计算新的桶数量**(这里设置为当前桶数量的两倍)**,然后调用 map_resize 函数调整哈希表大小。

  • 刚运行没初始化的时候,m->nbuckets设置为1

3、添加新节点到对应的桶中,并增加 nnodes 个数

  • 注意:从代码中可以看出桶的数量是我们设置节点的时候动态增加的,而且使用的是realloc函数,后续我们可以优化为上电初始化后默认有n个桶

2.4 map_get:获取键对应的值

在前面的示例代码中,设置完键值之后就可以使用map_get获取对应键的值了,返回值就是值的地址:

ret = map_get(&langMap, "test");

同样,这个函数也是一个宏定义:

#define map_get(m, key)\( (m)->ref = map_get_(&(m)->base, key) )
  • 前面用宏定义map_t声明的不同数据类型的宏定义中的ref变量,只是用来临时保存值的,这个变量在其它地方都没有使用到。

所以我们就来看一下map_get_函数的实现:

void *map_get_(map_base_t *m, const char *key) {map_node_t **next = map_getref(m, key);return next ? (*next)->value : NULL;
}

前面分析过map_getref函数了:根据哈希值找到对应的桶,然后在桶中找匹配的哈希值,若哈希值匹配(有可能不同的键有同样的哈希值),再比较键,若匹配,返回键的值。

2.5 删除键值和遍历

代码中还提供了删除键值的函数map_remove,还有遍历map的函数map_itermap_next,实际上就是链表的一些操作,本文就不做分析了。

3 总结

基于本篇文章,我们已经学习到了哈希map实现的基本逻辑。另外,前面我们有提到,这个代码在值声明为其它几个数据类型的情况下,根本运行不了,或者并不方便我们开发程序(有时我们希望直接传值而不是变量地址),然后还有内存分配和初始化桶数量的地方可以优化。那么下一篇文章,我们就来解决这些问题,并优化这个代码。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/840498.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

C语言学习笔记之指针(一)

目录 什么是指针&#xff1f; 指针和指针类型 指针的类型 指针类型的意义 指针-整数 指针的解引用 指针 - 指针 指针的关系运算 野指针 什么是野指针&#xff1f; 野指针的成因 如何规避野指针&#xff1f; 二级指针 什么是指针&#xff1f; 在介绍指针之前&#…

第十五届“北斗杯”全国青少年空天科技体验与创新大赛安徽赛区阜阳市解读会议

5月19日&#xff0c;第十五届“北斗杯”全国青少年空天科技体验与创新大赛安徽赛区阜阳解读活动在阜阳市图书馆隆重举行。共青团阜阳市委员会学少部副部长丁晓龙、阜阳市师范大学物理系副主任黄银生教授、安徽科技报阜阳站站长李伟、市人工智能学会秘书长郭广泽、“北斗杯”安徽…

【开发 | 环境配置】解决 VSCode 编写 eBPF 程序找不到头文件

问题描述&#xff1a; 在使用 vscode 编写 eBPF 程序时&#xff0c;如果不做一些头文件定位的操作&#xff0c;默认情况下头文件总是带有“红色下划线”&#xff0c;并且大部分的变量不会有提示与补全。 在编写代码文件较小时&#xff08;或者功能需求小时&#xff09;并不会…

开放式耳机什么牌子好用?五大高分力作安利,不容错过!

​开放式耳机如今备受瞩目&#xff0c;其独特的不入耳设计不仅避免了长时间佩戴对耳道的压力&#xff0c;还维护了耳朵的卫生健康&#xff0c;特别受运动爱好者和耳机发烧友青睐。然而&#xff0c;市场上的开放式耳机品质良莠不齐&#xff0c;让许多消费者在选择时陷入困惑。作…

嵌入式全栈开发学习笔记---C语言笔试复习大全20

目录 指针数组 数组指针 指针和二维数组 通过指针访问二维数组 通过数组指针访问二维数组 用指针表示二维数组并访问 地址等级 0级地址&#xff1a; 一级地址&#xff1a; 二级地址&#xff1a; 三级地址&#xff1a; 总结 指针的指针 命令行参数 上一篇复习了指…

路由_传递params参数和query参数

传递params参数 传递params参数可以直接在路径后面加上参数&#xff1a; 上述就是在路径变化的时候传过去三个值分别为哈哈、嘿嘿、呵呵的参数 但是这样的话会被认为三个参数是路径的一部分&#xff0c;计算机没有办法区分哪些是路径哪些是参数&#xff0c;所以首先要在这条路…

React useState数组新增和删除项

在React中&#xff0c;我们可以使用useState钩子来管理组件的状态&#xff0c;其中包括数组。如何在React函数组件中对数组进行增加和删除项的操作&#xff1f; 新增项时&#xff1a;我们可以对原数组进行解构&#xff0c;并把新增项一起加入到新数组&#xff1b; 删除项时&…

LeetCode 264 —— 丑数 II

阅读目录 1. 题目2. 解题思路3. 代码实现 1. 题目 2. 解题思路 第一个丑数是 1 1 1&#xff0c;由于丑数的质因子只包含 2 、 3 、 5 2、3、5 2、3、5&#xff0c;所以后面的丑数肯定是前面的丑数分别乘以 2 、 3 、 5 2、3、5 2、3、5 后得到的数字。 这样&#xff0c;我…

电脑同时配置两个版本mysql数据库常见问题

1.配置时&#xff0c;要把bin中的mysql.exe和mysqld.exe 改个名字&#xff0c;不然两个版本会重复&#xff0c;当然&#xff0c;在初始化数据库的时候&#xff0c;如果时57版本的&#xff0c;就用mysql57(已经改名的)和mysqld57 代替 mysql 和 mysqld 例如 mysql -u root -p …

LLM 大模型学习必知必会系列(十):基于AgentFabric实现交互式智能体应用,Agent实战

LLM 大模型学习必知必会系列(十)&#xff1a;基于AgentFabric实现交互式智能体应用,Agent实战 0.前言 **Modelscope **是一个交互式智能体应用基于ModelScope-Agent&#xff0c;用于方便地创建针对各种现实应用量身定制智能体&#xff0c;目前已经在生产级别落地。AgentFabri…

01.msf

文章目录 永恒之蓝下载msfconsolemsfvenom 永恒之蓝 下载 msdn.itellyou.cn msfconsole M e t a s p l o i t C y b e r M i s s i l e C o m m a n d Metasploit Cyber Missile Command MetasploitCyberMissileCommand 的简称 search ms17_010 use 0 或者 use exploit/wind…

从零开始:手把手教你使用Python实现PDF到Excel的转换

来百 在日常工作和学习中&#xff0c;我们经常会遇到需要将PDF文件中的数据提取到Excel表格中的情况。可能是为了进行数据分析、报告生成或者其他目的。虽然手动复制粘贴是一种方法&#xff0c;但对于大量的数据来说&#xff0c;这种方式显然效率太低。幸运的是&#xff0c;Py…

npm 错误,ERESOLVE unable to resolve dependency tree

npm 错误,ERESOLVE unable to resolve dependency tree 在命令中增加 --legacy-peer-dep 选项或者–force npm install --legacy-peer-depsnpm install --force

保存商品信息功能(VO)

文章目录 1.分析前端保存商品发布信息的json数据1.分析commoditylaunch.vue的submitSkus1.将后面的都注销&#xff0c;只保留查看数据的部分2.填写基本信息3.保存信息&#xff0c;得到json4.使用工具格式化一下 2.使用工具将json转为model3.根据业务修改vo&#xff0c;放到vo包…

「网络流浅谈」最大流的应用

更好的阅读体验 二分图匹配 考虑如何将二分图匹配问题&#xff0c;转化为流网络。设置 1 1 1 个汇点和源点&#xff0c;从源点向二分图一侧的每一个点连边&#xff0c;从另一侧向汇点连边&#xff0c;边权均为 1 1 1&#xff0c;二分图中的边也全部加入&#xff0c;权值设为…

【第1章】SpringBoot入门

文章目录 前言一、版本要求1. SpringBoot版本2. 其他2.1 System Requirements2.2 Servlet Containers2.3 GraalVM Native Images 3. 版本定型 二、新建工程1.IDEA创建 ( 推荐 ) \color{#00FF00}{(推荐)} (推荐)2. 官方创建 三、第一个SpringBoot程序1. 引入web2. 启动类3. 启动…

Edge浏览器:重新定义现代网页浏览

引言 - Edge的起源与重生 Edge浏览器&#xff0c;作为Microsoft Windows标志性的互联网窗口&#xff0c;源起于1995年的Internet Explorer。在网络发展的浪潮中&#xff0c;IE曾是无可争议的霸主&#xff0c;但随着技术革新与用户需求的演变&#xff0c;它面临的竞争日益激烈。…

用这8种方法在海外媒体推广发稿平台上获得突破-华媒舍

在今天的数字时代&#xff0c;海外媒体推广发稿平台已经成为了许多机构和个人宣传和推广的有效途径。如何在这些平台上获得突破并吸引更多的关注是一个关键问题。本文将介绍8种方法&#xff0c;帮助您在海外媒体推广发稿平台上实现突破。 1. 确定目标受众 在开始使用海外媒体推…

篮球论坛|基于SprinBoot+vue的篮球论坛系统(源码+数据库+文档)

篮球论坛系统 目录 基于SprinBootvue的篮球论坛系统 一、前言 二、系统设计 三、系统功能设计 1系统功能模块 2管理员功能模块 3用户功能模块 四、数据库设计 五、核心代码 六、论文参考 七、最新计算机毕设选题推荐 八、源码获取&#xff1a; 博主介绍&#xff…