C语言指针实现高效词频统计

引言  

指针是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 树、红黑树等平衡树来优化。

哈希表

当数据量非常大时,哈希表提供了高效的查找和插入性能。

  • 利用哈希函数将字符串映射到数组下标

  • 处理哈希冲突的方法常见的有外链法(将同一下标上的数据存入链表)和开放地址法

迭代器设计

在需要用户自定义输出格式或对数据进行二次处理时,提供迭代器接口非常有用。

  • 用户通过迭代器遍历数组或链表,而不直接暴露内部数据结构

  • 迭代器的实现需要内部保存当前遍历位置,支持同时存在多个独立的遍历过程

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值