一、自然语言处理与C语言:一场意外的邂逅

说实话,很多人听到「自然语言处理」这个词,第一反应就是Python、NLTK、spaCy这些库。我当年也是这么想的。直到有一次,我在嵌入式设备上做文本分析——那台设备的内存只有256KB,跑Python?想都别想。

这时候我才意识到,C语言在NLP领域其实有它独特的价值。你想想看,很多底层文本处理库,比如ICU、libunistring,底层都是C写的。说白了,C语言做NLP,就像用螺丝刀修手表——工具看着笨重,但精度极高。

今天我们就从最基础的四个方向入手:分词、关键词提取、命名实体识别、情感分析。我会用C语言把这些概念落地,让你看到底层的实现逻辑。

核心观点:C语言做NLP不是为了替代Python,而是让你理解「文本处理」的本质——字符串操作、统计计算、模式匹配。掌握了这些,你用任何语言都能写出高效的NLP程序。

二、分词:把句子拆成最小单元

2.1 为什么分词是第一步?

分词,说白了就是把一句话切成有意义的片段。比如「我喜欢编程」要切成「我」「喜欢」「编程」。英文分词简单,按空格切就行。中文?嗯,这里要注意——中文没有天然的分隔符。

我在项目中遇到过最头疼的问题:用户输入「武汉市长江大桥」,到底是「武汉/市长/江大桥」还是「武汉市/长江大桥」?这就是歧义分词的典型例子。

2.2 基于字典的最大匹配法

最简单的分词方法就是「最大匹配法」。我习惯用正向最大匹配,也就是从左到右,每次取最长的匹配词。

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

// 模拟一个简单的词典
const char* dict[] = {"我", "喜欢", "编程", "自然", "语言", "处理", "武汉", "长江", "大桥", NULL};

// 正向最大匹配分词
void forward_max_match(const char* text) {
    int len = strlen(text);
    int max_word_len = 4;  // 假设最长词为4个字符
    int i = 0;
    
    while (i < len) {
        int matched = 0;
        // 从最长长度开始尝试匹配
        for (int j = max_word_len; j > 0; j--) {
            if (i + j > len) continue;
            
            char sub[16] = {0};
            strncpy(sub, text + i, j);
            
            // 在词典中查找
            for (int k = 0; dict[k] != NULL; k++) {
                if (strcmp(sub, dict[k]) == 0) {
                    printf("[%s] ", sub);
                    i += j;
                    matched = 1;
                    break;
                }
            }
            if (matched) break;
        }
        
        // 没匹配到,按单字处理
        if (!matched) {
            printf("[%c] ", text[i]);
            i++;
        }
    }
    printf("\n");
}

int main() {
    char text[] = "我喜欢自然语言处理";
    printf("原文: %s\n", text);
    printf("分词结果: ");
    forward_max_match(text);
    return 0;
}

我的经验:最大匹配法虽然简单,但实际项目中我一般会配合「逆向最大匹配」一起用。正向和逆向结果不一致时,再通过统计方法选择更合理的那个。这叫「双向匹配法」,准确率能提升10%左右。

三、关键词提取:找出句子的灵魂

3.1 TF-IDF:最经典的关键词算法

关键词提取,说白了就是找出「最能代表这段文本」的词语。TF-IDF(词频-逆文档频率)是我用得最多的方法。它的核心思想很简单:

  • TF(词频):这个词在本文中出现了多少次
  • IDF(逆文档频率):这个词在所有文档中出现的频率有多低

为什么要有IDF?你想想看,「的」「了」「是」这些词几乎每篇文章都有,它们能代表文章主题吗?显然不能。IDF就是用来惩罚这些常见词的。

3.2 C语言实现TF-IDF

#include <stdio.h>
#include <string.h>
#include <math.h>

#define MAX_DOCS 100
#define MAX_WORDS 1000

typedef struct {
    char word[32];
    int doc_count;      // 包含该词的文档数
    double idf;         // 逆文档频率
} WordStat;

// 计算IDF
void compute_idf(WordStat* stats, int word_count, int total_docs) {
    for (int i = 0; i < word_count; i++) {
        if (stats[i].doc_count > 0) {
            stats[i].idf = log((double)total_docs / stats[i].doc_count);
        }
    }
}

// 计算TF-IDF得分
double compute_tfidf(int tf, double idf) {
    return (1 + log(tf)) * idf;  // 使用平滑后的TF
}

int main() {
    // 模拟3篇文档
    const char* docs[] = {
        "我喜欢编程和自然语言处理",
        "自然语言处理是人工智能的重要分支",
        "编程需要不断练习和实践"
    };
    int doc_count = 3;
    
    // 这里简化处理,实际需要完整的分词和统计
    WordStat stats[] = {
        {"自然", 2, 0},  // 出现在文档0和1中
        {"语言", 2, 0},
        {"处理", 2, 0},
        {"编程", 2, 0},  // 出现在文档0和2中
        {"人工智能", 1, 0}
    };
    int word_count = 5;
    
    compute_idf(stats, word_count, doc_count);
    
    printf("关键词IDF值:\n");
    for (int i = 0; i < word_count; i++) {
        printf("  %s: IDF=%.3f\n", stats[i].word, stats[i].idf);
    }
    
    // 计算文档0中每个词的TF-IDF
    printf("\n文档0的关键词得分:\n");
    int tf_values[] = {1, 1, 1, 1, 0};  // 文档0中各词出现次数
    for (int i = 0; i < word_count; i++) {
        if (tf_values[i] > 0) {
            double score = compute_tfidf(tf_values[i], stats[i].idf);
            printf("  %s: TF-IDF=%.3f\n", stats[i].word, score);
        }
    }
    
    return 0;
}

避坑指南:我曾经在计算IDF时忘记处理「分母为零」的情况——如果某个词在所有文档中都出现,IDF会变成0。更极端的是,如果某个词从未出现过,log(0)会直接崩溃。所以一定要加平滑处理,比如 IDF = log((N+1)/(df+1)) + 1。

四、命名实体识别:找到人名、地名、机构名

4.1 基于规则的方法

命名实体识别(NER),说白了就是从文本中找出「专有名词」。比如「张三去了北京」——「张三」是人名,「北京」是地名。

我个人的习惯是:先用规则匹配,再用统计模型兜底。规则匹配虽然简单,但在特定领域效果出奇的好。比如在医疗领域,「阿司匹林」「布洛芬」这些药名,用字典匹配准确率能到95%以上。

4.2 简单的规则引擎实现

#include <stdio.h>
#include <string.h>
#include <ctype.h>

typedef enum {
    ENTITY_PERSON,
    ENTITY_LOCATION,
    ENTITY_ORGANIZATION,
    ENTITY_UNKNOWN
} EntityType;

typedef struct {
    char name[64];
    EntityType type;
} Entity;

// 简单的实体词典
Entity entity_dict[] = {
    {"张三", ENTITY_PERSON},
    {"李四", ENTITY_PERSON},
    {"北京", ENTITY_LOCATION},
    {"上海", ENTITY_LOCATION},
    {"华为", ENTITY_ORGANIZATION},
    {"阿里巴巴", ENTITY_ORGANIZATION},
    {"", ENTITY_UNKNOWN}  // 结束标记
};

// 识别命名实体
void recognize_entities(const char* text) {
    printf("原文: %s\n", text);
    printf("识别到的实体:\n");
    
    int len = strlen(text);
    int i = 0;
    
    while (i < len) {
        int found = 0;
        
        // 尝试匹配词典中的实体
        for (int j = 0; entity_dict[j].type != ENTITY_UNKNOWN; j++) {
            int ent_len = strlen(entity_dict[j].name);
            if (i + ent_len <= len) {
                if (strncmp(text + i, entity_dict[j].name, ent_len) == 0) {
                    const char* type_str;
                    switch (entity_dict[j].type) {
                        case ENTITY_PERSON: type_str = "人名"; break;
                        case ENTITY_LOCATION: type_str = "地名"; break;
                        case ENTITY_ORGANIZATION: type_str = "机构名"; break;
                        default: type_str = "未知"; break;
                    }
                    printf("  [%s] -> %s\n", entity_dict[j].name, type_str);
                    i += ent_len;
                    found = 1;
                    break;
                }
            }
        }
        
        if (!found) {
            i++;  // 跳过未识别的字符
        }
    }
}

int main() {
    recognize_entities("张三去了北京,在华为工作");
    return 0;
}

进阶技巧:实际项目中,我还会加入「上下文规则」。比如「XX教授」「XX市长」后面的词大概率是人名;「位于XX」「去XX」后面的词大概率是地名。这些规则虽然简单,但能大幅提升召回率。

五、情感分析:判断文字是褒是贬

5.1 基于情感词典的方法

情感分析,说白了就是判断一段话是正面还是负面。最简单的方法就是「情感词典法」——把句子中的褒义词和贬义词分别计数,哪个多就偏向哪边。

我记得有一次给电商平台做评论分析,用户说「这个手机电池续航不错,但屏幕太差了」。如果只看情感词,「不错」是正面,「太差」是负面,两个词抵消了。但实际用户是「褒贬各半」,这时候就需要更精细的权重计算。

5.2 简单的情感分析器

#include <stdio.h>
#include <string.h>

// 情感词典(简化版)
const char* positive_words[] = {"好", "棒", "喜欢", "不错", "优秀", "满意", NULL};
const char* negative_words[] = {"差", "坏", "讨厌", "糟糕", "垃圾", "失望", NULL};

// 计算情感得分
double sentiment_analysis(const char* text) {
    int pos_count = 0;
    int neg_count = 0;
    
    // 统计正面词
    for (int i = 0; positive_words[i] != NULL; i++) {
        if (strstr(text, positive_words[i]) != NULL) {
            pos_count++;
        }
    }
    
    // 统计负面词
    for (int i = 0; negative_words[i] != NULL; i++) {
        if (strstr(text, negative_words[i]) != NULL) {
            neg_count++;
        }
    }
    
    // 计算得分:范围 -1 到 1
    int total = pos_count + neg_count;
    if (total == 0) return 0.0;
    
    return (double)(pos_count - neg_count) / total;
}

int main() {
    const char* texts[] = {
        "这个产品非常好,我很喜欢",
        "质量太差了,真糟糕",
        "今天天气不错",
        "这个手机屏幕好,但电池差"
    };
    
    for (int i = 0; i < 4; i++) {
        double score = sentiment_analysis(texts[i]);
        printf("文本: %s\n", texts[i]);
        printf("情感得分: %.2f (", score);
        if (score > 0.3) printf("正面");
        else if (score < -0.3) printf("负面");
        else printf("中性");
        printf(")\n\n");
    }
    
    return 0;
}

实际项目经验:情感词典法最大的问题是「否定词」。比如「不好」——「好」是正面词,但前面加了个「不」,意思完全反了。我后来加了一个规则:如果正面词前面3个字符内有「不」「没」「无」等否定词,就把情感值取反。这个小改动让准确率从65%提升到了78%。

六、知识体系总览

下面这张图总结了本章的核心内容。我习惯用流程图来梳理知识结构,这样一眼就能看清各个模块之间的关系。

C语言NLP基础:四大核心任务 自然语言处理 分词 关键词提取 命名实体识别 情感分析 最大匹配法 双向匹配法 TF-IDF算法 词频统计 规则匹配 上下文规则 情感词典法 否定词处理 核心思想:字符串操作 + 统计计算 + 模式匹配 C语言实现NLP,理解底层原理,一通百通

七、本章小结

今天我们走完了NLP入门的四个基础步骤:

  • 分词:用最大匹配法把句子切成词
  • 关键词提取:用TF-IDF找出重要词汇
  • 命名实体识别:用规则匹配找到人名、地名、机构名
  • 情感分析:用情感词典判断文字倾向

说实话,这些方法在工业界已经用了十几年了。虽然现在深度学习很火,但底层的基础方法依然有它的价值——尤其是在资源受限的环境下,或者你需要快速验证一个想法的时候。

我个人建议:先把这些基础方法吃透,再去学复杂的模型。因为你会发现,无论多高级的NLP系统,底层都离不开这些基本操作。

课后练习:试着用今天学到的知识,写一个简单的「文本分类器」。比如判断一段新闻是属于「体育」「科技」还是「娱乐」类别。提示:可以用关键词匹配 + 类别词典的方式实现。


公众号:蓝海资料掘金营,微信deep3321