引言
指针是C语言的核心特性之一,但它的复杂性常令初学者望而生畏。本文将通过一个词频统计程序
(word_count)的案例,深入探讨指针在数据结构中的实际应用,涵盖数组、链表、树等结构的
实现与优化。无论你是刚接触指针的新手,还是想巩固数据结构知识的开发者,本文都将为你提供
清晰的实践路径。
一、案例需求:word_count程序
程序目标:输入一个英文文本文件,按字典序输出所有单词及其出现次数。
输入方式:支持文件路径参数或标准输入流。
单词定义:连续字母数字字符(通过isalnum()宏判断)。
核心模块:
1. 单词获取模块:逐词读取输入流。
2. 单词管理模块:存储、排序、统计单词。
3. 主程序模块:协调各模块执行流程。
二、数据结构选择:数组 vs 链表
1. 数组实现
实现思路
采用数组存储结构体,每个结构体包含单词字符串和计数值。主要步骤包括:
-
查找与排序:从数组头开始遍历,利用
strcmp()
按字母顺序比较单词。 -
添加单词:如果发现已存在,则计数加一;否则找到合适位置插入新单词。插入时需移动后续所有元素以保持排序。
-
输出统计结果:遍历数组,将单词与计数格式化输出。
/*
* word_count.c
*
* 统计输入文本中各个单词的出现频率,并按字母顺序输出。
* 使用数组存储单词及其计数,每个单词用动态分配的字符串保存。
*
* 编译方法示例(Linux/Mac):gcc -o word_count word_count.c
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#define WORD_LEN_MAX 1024 // 单词缓冲区大小
#define WORD_NUM_MAX 10000 // 数组能容纳的最大单词数
/* 定义存储单词及其计数的结构体 */
typedef struct {
char *name; // 动态分配的单词字符串
int count; // 单词出现次数
} Word;
/* 全局数组,用于存储单词信息;全局变量记录当前单词数量 */
Word word_array[WORD_NUM_MAX];
int num_of_word = 0;
/*
* 函数:get_word
* 从指定的输入流中读取一个单词,存入 buf 中,最多读取 size-1 个字符,
* 遇到非字母或数字字符结束。若遇到 EOF 则返回 EOF,否则返回读取的字符数。
*/
int get_word(char *buf, int size, FILE *stream) {
int ch, i = 0;
// 跳过非字母数字字符
while ((ch = fgetc(stream)) != EOF && !isalnum(ch))
;
if (ch == EOF)
return EOF;
// 将第一个合法字符保存
buf[i++] = ch;
// 继续读取后续的字母数字字符
while (i < size - 1 && (ch = fgetc(stream)) != EOF && isalnum(ch)) {
buf[i++] = ch;
}
buf[i] = '\0'; // 添加字符串结束符
return i;
}
/*
* 函数:shift_array
* 将数组中从 index 开始的所有元素后移一位,为新单词插入腾出空间。
*/
static void shift_array(int index) {
int src;
for (src = num_of_word - 1; src >= index; src--) {
word_array[src+1] = word_array[src];
}
num_of_word++; // 插入后,单词总数加一
}
/*
* 函数:my_strdup
* 自己实现字符串复制函数,分配足够内存保存 src 字符串,并返回复制后的指针。
*/
static char *my_strdup(char *src) {
char *dest = malloc(sizeof(char) * (strlen(src) + 1));
if (dest != NULL) {
strcpy(dest, src);
}
return dest;
}
/*
* 函数:add_word
* 将单词添加到全局数组中:
* 1. 从头开始遍历,利用 strcmp() 按字母顺序比较单词。
* 2. 如果发现数组中已有相同的单词,则直接将计数加一;
* 3. 否则找到合适位置,并调用 shift_array() 将后续元素后移,插入新单词。
*/
void add_word(char *word) {
int i, result = 0;
if (num_of_word >= WORD_NUM_MAX) {
fprintf(stderr, "too many words.\n");
exit(1);
}
// 查找合适的插入位置:第一个字典序大于或等于 word 的位置
for (i = 0; i < num_of_word; i++) {
result = strcmp(word_array[i].name, word);
if (result >= 0)
break;
}
// 如果找到相同单词,则只增加计数
if (num_of_word != 0 && result == 0) {
word_array[i].count++;
} else {
// 否则,在位置 i 插入新单词
shift_array(i); // 后移数组元素
word_array[i].name = my_strdup(word); // 复制单词字符串
word_array[i].count = 1; // 初始化计数为 1
}
}
/*
* 函数:dump_word
* 按字母顺序遍历数组,将每个单词及其出现次数格式化输出到指定文件流 fp 中。
*/
void dump_word(FILE *fp) {
int i;
for (i = 0; i < num_of_word; i++) {
fprintf(fp, "%-20s %5d\n", word_array[i].name, word_array[i].count);
}
}
/*
* 函数:word_finalize
* 释放全局数组中所有动态分配的单词字符串,并重置单词数量。
*/
void word_finalize(void) {
int i;
for (i = 0; i < num_of_word; i++) {
free(word_array[i].name);
}
num_of_word = 0;
}
/*
* 主函数:根据命令行参数决定从文件或标准输入读取数据,
* 然后统计单词出现频率并输出统计结果。
*/
int main(int argc, char **argv) {
char buf[WORD_LEN_MAX];
FILE *fp;
// 如果没有命令行参数,则从标准输入读取
if (argc == 1) {
fp = stdin;
} else {
fp = fopen(argv[1], "r");
if (fp == NULL) {
fprintf(stderr, "%s: %s can not open.\n", argv[0], argv[1]);
exit(1);
}
}
// 逐个读取单词,并添加到全局数组中
while (get_word(buf, WORD_LEN_MAX, fp) != EOF) {
add_word(buf);
}
// 输出统计结果到标准输出
dump_word(stdout);
// 结束处理:释放内存
word_finalize();
if (fp != stdin)
fclose(fp);
return 0;
}
优缺点
-
优点:
-
数组内数据排好序后,可采用二分查找等高效算法。
-
查找速度快(在数据量不大时优势明显)。
-
-
缺点:
-
插入新单词时需移动后续所有元素,效率较低。
-
数组大小需事先确定,不易动态扩展(虽然可以用
realloc()
扩展,但成本较高)。
-
2. 链表实现
实现思路
使用单向链表,每个节点(结构体 Word)包含单词、计数及指向下一个节点的指针。
主要步骤包括:
-
遍历查找:从链表头开始,按字典顺序查找目标单词。
-
添加单词:若找到相同单词,则计数加一;否则构造新节点并插入到合适的位置。由于链表是单向的,为实现“前插”,引入了前驱指针(prev)。
-
内存释放:遍历整个链表,通过
free()
逐个释放节点内分配的内存,避免内存泄露。
/*
* word_count_linked.c
*
* 统计输入文本中各个单词的出现频率,并按字典顺序输出。
* 使用单向链表存储单词信息,每个节点包含单词字符串、计数以及指向下一个节点的指针。
*
* 编译方法示例(Linux/Mac):gcc -o word_count_linked word_count_linked.c
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#define WORD_LEN_MAX 1024 // 定义单词最大长度
/* 定义单词节点结构体 */
typedef struct Word {
char *name; // 动态分配的单词字符串
int count; // 单词出现次数
struct Word *next; // 指向下一个节点的指针
} Word;
/* 全局指针,指向链表的头部 */
Word *word_head = NULL;
/*
* 函数:get_word
* 从指定输入流中读取一个单词,存放到 buf 中,最多读取 size-1 个字符。
* 跳过非字母数字字符,遇到第一个合法字符开始读取,直到遇到非字母数字字符或达到缓冲区末尾。
* 若遇到 EOF 则返回 EOF,否则返回读取的字符数。
*/
int get_word(char *buf, int size, FILE *stream) {
int ch, i = 0;
// 跳过所有非字母数字的字符
while ((ch = fgetc(stream)) != EOF && !isalnum(ch))
;
if (ch == EOF)
return EOF;
buf[i++] = ch;
// 继续读取后续字母数字字符
while (i < size - 1 && (ch = fgetc(stream)) != EOF && isalnum(ch)) {
buf[i++] = ch;
}
buf[i] = '\0'; // 添加字符串结束符
return i;
}
/*
* 函数:my_strdup
* 实现字符串复制,分配足够的内存保存 src 字符串,并返回复制后的指针。
*/
static char *my_strdup(char *src) {
char *dest = malloc(sizeof(char) * (strlen(src) + 1));
if (dest != NULL) {
strcpy(dest, src);
}
return dest;
}
/*
* 函数:add_word
* 将单词添加到链表中:
* 1. 从链表头开始,按字典顺序查找目标单词,使用 prev 指针保存当前节点的前驱节点。
* 2. 如果找到相同单词,则直接将计数加 1。
* 3. 否则构造新节点,并将其插入到合适的位置。
*/
void add_word(char *word) {
Word *prev = NULL;
Word *curr = word_head;
int cmp = 0;
// 按字典顺序遍历链表,直到找到第一个字典序大于或等于 word 的节点
while (curr != NULL) {
cmp = strcmp(curr->name, word);
if (cmp >= 0) {
break;
}
prev = curr;
curr = curr->next;
}
// 如果找到相同单词,则计数加 1
if (curr != NULL && cmp == 0) {
curr->count++;
} else {
// 构造新节点
Word *new_node = malloc(sizeof(Word));
if (new_node == NULL) {
fprintf(stderr, "内存分配失败!\n");
exit(1);
}
new_node->name = my_strdup(word);
new_node->count = 1;
new_node->next = curr;
// 如果 prev 为 NULL,说明插入在链表头部
if (prev == NULL) {
word_head = new_node;
} else {
prev->next = new_node;
}
}
}
/*
* 函数:dump_word
* 遍历整个链表,将每个单词及其出现次数格式化输出到指定的文件流 fp 中。
*/
void dump_word(FILE *fp) {
Word *curr = word_head;
while (curr != NULL) {
fprintf(fp, "%-20s %5d\n", curr->name, curr->count);
curr = curr->next;
}
}
/*
* 函数:word_finalize
* 遍历整个链表,通过 free() 逐个释放节点内动态分配的内存,避免内存泄露。
*/
void word_finalize(void) {
Word *curr = word_head;
while (curr != NULL) {
Word *temp = curr;
curr = curr->next;
free(temp->name); // 释放保存单词的内存
free(temp); // 释放节点本身
}
word_head = NULL;
}
/*
* 主函数:
* 根据命令行参数决定从文件或标准输入中读取数据,
* 并调用相应的函数统计单词频率、输出统计结果,最后释放内存。
*/
int main(int argc, char **argv) {
char buf[WORD_LEN_MAX];
FILE *fp;
// 判断是否有命令行参数传入文件名
if (argc == 1) {
fp = stdin;
} else {
fp = fopen(argv[1], "r");
if (fp == NULL) {
fprintf(stderr, "%s: 无法打开文件 %s\n", argv[0], argv[1]);
exit(1);
}
}
// 逐个读取单词,并添加到链表中
while (get_word(buf, WORD_LEN_MAX, fp) != EOF) {
add_word(buf);
}
// 输出统计结果到标准输出
dump_word(stdout);
// 释放链表中所有节点占用的内存
word_finalize();
if (fp != stdin)
fclose(fp);
return 0;
}
优缺点
-
优点:
-
插入和删除操作十分灵活,不需移动大量元素。
-
内存利用上能够动态分配,不受初始大小限制。
-
-
缺点:
-
查找操作需要从头顺序遍历,不适合采用二分查找(因为链表不支持随机访问)。
-
单向链表不能反向遍历,如果需要反向操作则需设计双向链表。
-
拓展:其他数据结构与迭代器设计
双向链表
为解决单向链表在删除节点和逆向遍历上的局限,可以使用双向链表。
-
每个节点多出一个指向前驱节点的指针
-
虽然需要额外的内存空间,但操作(如删除某个节点时,无需寻找前驱节点)更加方便。
树形结构与二叉查找树
树形结构(尤其是二叉查找树)可以高效管理有序数据。
-
二叉查找树:左子树的节点值小于根节点,右子树的节点值大于根节点
-
优点是查找、插入操作在平均情况下具有较高效率,但如果数据本身是有序的,则树可能退化成链表,需要采用 AVL 树、红黑树等平衡树来优化。
哈希表
当数据量非常大时,哈希表提供了高效的查找和插入性能。
-
利用哈希函数将字符串映射到数组下标
-
处理哈希冲突的方法常见的有外链法(将同一下标上的数据存入链表)和开放地址法
迭代器设计
在需要用户自定义输出格式或对数据进行二次处理时,提供迭代器接口非常有用。
-
用户通过迭代器遍历数组或链表,而不直接暴露内部数据结构
-
迭代器的实现需要内部保存当前遍历位置,支持同时存在多个独立的遍历过程