在数据分析与后端开发中,统计特定ID在不同时间段的出现情况是高频需求。无论是用户行为日志分析、商品点击统计,还是系统监控指标聚合,本质上都在处理同一类问题:如何高效地对分组数据进行去重计数和总量统计。CCF CSP认证中的词频统计题,恰好为我们提供了一个绝佳的微型案例,来探讨这类问题的通用解法。
这道题看似简单,却蕴含了数据处理的核心思维——集合运算与计数聚合。我们将跳出"解题"框架,将其视为一个完整的数据处理管道:输入是分组序列,输出是两个维度的聚合指标。通过对比不同实现方案的时空复杂度,不仅能提升算法能力,更能培养将竞赛思维迁移到真实业务场景的能力。
词频统计题的核心是计算两个指标:
这直接对应着业务中的两类常见需求:
假设我们有以下电商日志数据:
plaintext复制用户1 浏览记录: [商品A, 商品B, 商品A]
用户2 浏览记录: [商品B, 商品C]
用户3 浏览记录: [商品A, 商品C]
对应的统计结果应该是:
| 商品 | 浏览用户数(UV) | 浏览次数(PV) |
|---|---|---|
| A | 2 | 3 |
| B | 2 | 2 |
| C | 2 | 2 |
原题参考解法使用了bool appeared[m]数组来标记单词是否在当前文章中出现过。这种方案的优点是:
cpp复制bool appeared[m] = {false};
for (int j = 0; j < count; j++) {
int num = /* 读取单词 */;
if (!appeared[num - 1]) {
appeared[num - 1] = true;
result[num - 1][0]++; // 文章数+1
}
result[num - 1][1]++; // 总次数+1
}
但在实际业务中,这种方案存在局限性:
更通用的做法是使用哈希表记录每个单词的统计结果,并用集合来去重:
python复制from collections import defaultdict
def word_statistics(articles):
stats = defaultdict(lambda: {'article_count': 0, 'total_count': 0})
for article in articles:
seen_words = set()
for word in article:
stats[word]['total_count'] += 1
if word not in seen_words:
seen_words.add(word)
stats[word]['article_count'] += 1
return stats
这种实现的特点是:
| 方案 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 数组+布尔标记 | O(n*avg(l)) | O(m) | ID范围已知且密集的小型数据集 |
| 哈希表+集合 | O(n*avg(l)) | O(m) | ID范围未知或稀疏的中大型数据集 |
| 排序+遍历 | O(n*avg(l)*logm) | O(1) | 内存极度受限的环境 |
提示:在真实业务中,90%的情况会选用哈希表方案,因其在开发效率和适应性上的优势通常超过微小的性能差异。
这种统计模式在数据库中对应着经典的COUNT DISTINCT和GROUP BY组合。假设有文章单词表article_words:
sql复制SELECT
word_id,
COUNT(DISTINCT article_id) AS article_count,
COUNT(*) AS total_count
FROM article_words
GROUP BY word_id;
数据库优化器通常会采用以下执行策略之一:
哈希聚合:
排序聚合:
现代数据库如PostgreSQL会根据数据特征自动选择最优策略。理解这些底层机制,有助于我们:
COUNT DISTINCT添加合适的索引)当数据量达到TB级别时,单机处理不再可行。此时需要分布式计算框架如Spark:
python复制from pyspark.sql import functions as F
df = spark.read.parquet("hdfs://path/to/articles")
result = df.groupBy("word_id").agg(
F.countDistinct("article_id").alias("article_count"),
F.count("*").alias("total_count")
)
分布式环境引入了新的考量维度:
一个典型的优化是使用两阶段聚合:
python复制# 第一阶段:局部聚合
df_local = df.rdd.mapPartitions(process_partition).toDF()
# 第二阶段:全局聚合
result = df_local.groupBy("word_id").agg(
F.sum("article_count").alias("article_count"),
F.sum("total_count").alias("total_count")
)
当处理海量数据时,内存成为瓶颈。可以考虑:
布隆过滤器:用概率性数据结构替代精确集合
python复制from pybloom_live import ScalableBloomFilter
bf = ScalableBloomFilter()
if not bf.add(word):
stats[word]['article_count'] += 1
分片处理:按单词哈希值分片处理
python复制def process_shard(shard_id):
for article in articles:
local_stats = {}
for word in article:
if hash(word) % N_SHARDS == shard_id:
# 更新local_stats
return local_stats
初始值问题:
时间窗口处理:
python复制# 错误:跨天数据会重复计数
daily_stats[day][word]['uv'] += (user not in seen_users)
# 正确:每个时间窗口独立计算
daily_stats[day][word]['uv'] = len(users_per_day[day][word])
数据类型选择:
完善的测试应覆盖以下边界情况:
| 测试场景 | 预期结果 |
|---|---|
| 空输入 | 空输出 |
| 单篇文章重复单词 | article_count=1 |
| 单词跨多篇文章出现 | article_count=文章数 |
| 超大ID值 | 不崩溃且结果正确 |
| 非连续ID | 正确统计所有ID |
python复制def test_word_stats():
assert stats([]) == {}
assert stats([[1,1,1]]) == {1: {'article_count':1, 'total_count':3}}
assert stats([[1,2], [2,3]]) == {
1: {'article_count':1, 'total_count':1},
2: {'article_count':2, 'total_count':2},
3: {'article_count':1, 'total_count':1}
}
假设我们需要处理10亿条访问日志,以下是优化演进过程:
初始方案:
python复制stats = {}
for log in logs:
user_id = log['user_id']
item_id = log['item_id']
if item_id not in stats:
stats[item_id] = {'uv': set(), 'pv': 0}
stats[item_id]['uv'].add(user_id)
stats[item_id]['pv'] += 1
# 最终结果
result = {item: {'uv': len(data['uv']), 'pv': data['pv']}
for item, data in stats.items()}
问题:内存消耗过大,每个item都存储了完整的user_id集合
优化方案1:使用HyperLogLog近似统计
python复制from hyperloglog import HyperLogLog
stats = defaultdict(lambda: {'hll': HyperLogLog(0.01), 'pv': 0})
for log in logs:
item_id = log['item_id']
user_id = log['user_id']
stats[item_id]['hll'].add(user_id)
stats[item_id]['pv'] += 1
result = {item: {'uv': data['hll'].card(), 'pv': data['pv']}
for item, data in stats.items()}
优化方案2:分批次处理+合并
python复制def process_batch(batch):
batch_stats = {}
for log in batch:
# 同初始方案但只处理当前批次
...
return batch_stats
def merge_stats(stats_list):
merged = {}
for stats in stats_list:
for item, data in stats.items():
if item not in merged:
merged[item] = {'uv': set(), 'pv': 0}
merged[item]['uv'].update(data['uv'])
merged[item]['pv'] += data['pv']
return merged
# 分批处理
batch_size = 1_000_000
results = []
for i in range(0, len(logs), batch_size):
batch = logs[i:i+batch_size]
results.append(process_batch(batch))
final_result = merge_stats(results)
这种统计模式可应用于:
A/B测试分析:
系统监控:
python复制# 统计每个错误码出现的服务器和总次数
error_stats = defaultdict(lambda: {'servers': set(), 'count': 0})
for log in error_logs:
code = log['error_code']
server = log['server_ip']
error_stats[code]['servers'].add(server)
error_stats[code]['count'] += 1
推荐系统:
网络安全:
在实际项目中,我曾用类似方法优化过一个广告点击分析系统。原系统使用关系数据库直接计算UV,每天报表生成需要4小时。改用预聚合模式后: