一个被BCL遗忘的高性能集合:C# CircularBuffer<T>深度解析
大家好,在最近的一个业余项目——天体运行模拟器中,我遇到了一个有趣的需求:我需要记录每个天体最近一段时间的历史位置,从而在屏幕上为它们画出一条长长而漂亮的轨迹线。
你可能会说,用一个 List<T> 不就行了?但问题在于,如果模拟持续运行,这个 List<T> 会无限增长,最终会消耗大量内存,甚至可能导致程序崩溃。我真正需要的是一个“固定大小”的集合,当新数据加进来时,最老的数据能被自动丢弃。这正是“循环缓冲区”(Circular Buffer)大显身手的场景。CircularBuffer<T> 完美地满足了我的需求。

CircularBuffer<T> 的实现
C# 的基础类库(BCL)中并没有内置 CircularBuffer<T> 这个类型,但这完全不妨碍我们自己动手,丰衣足食。下面就是我所使用的 CircularBuffer<T> 的完整实现,它支持泛型,并且实现了 IEnumerable<T> 接口,可以方便地进行遍历。
using System.Collections;
using System.Collections.Generic;
using System;
namespace Sdcb.NBody.Common;
/// <summary>
/// 表示一个固定容量的循环缓冲区(或环形列表)。
/// 当缓冲区满时,添加新元素会覆盖最早的元素。
/// </summary>
/// <typeparam name="T">缓冲区中元素的类型。</typeparam>
public class CircularBuffer<T> : IEnumerable<T>
{
private readonly T[] _data;
private int _end; // 指向下一个要写入元素的位置(尾部)
/// <summary>
/// 获取缓冲区中实际存储的元素数量。
/// </summary>
public int Count { get; private set; }
/// <summary>
/// 获取缓冲区的总容量。
/// </summary>
public int Capacity => _data.Length;
/// <summary>
/// 获取一个值,该值指示缓冲区是否已满。
/// </summary>
public bool IsFull => Count == Capacity;
/// <summary>
/// 计算并获取第一个元素(头部)在内部数组中的索引。
/// </summary>
private int HeadIndex
{
get
{
if (Count == 0) return 0;
// 当 Count < Capacity 时,_end 就是 Count,结果为 0。
// 当缓冲区满时 (Count == Capacity),_end 会循环,这个公式能正确计算出头部的索引。
return (_end - Count + Capacity) % Capacity;
}
}
/// <summary>
/// 初始化 <see cref="CircularBuffer{T}"/> 类的新实例。
/// </summary>
/// <param name="capacity">缓冲区的容量。必须为正数。</param>
/// <exception cref="ArgumentException">当容量小于等于 0 时抛出。</exception>
public CircularBuffer(int capacity)
{
if (capacity <= 0)
{
throw new ArgumentException("Capacity must be a positive number.", nameof(capacity));
}
_data = new T[capacity];
_end = 0;
Count = 0;
}
/// <summary>
/// 将一个新元素添加到缓冲区的尾部。
/// 如果缓冲区已满,此操作会覆盖最早的元素。
/// </summary>
/// <param name="item">要添加的元素。</param>
public void Add(T item)
{
_data[_end] = item;
_end = (_end + 1) % Capacity;
if (Count < Capacity)
{
Count++;
}
}
/// <summary>
/// 清空缓冲区中的所有元素。
/// </summary>
public void Clear()
{
// 只需重置计数器和指针即可,无需清除数组数据
Count = 0;
_end = 0;
}
/// <summary>
/// 获取或设置指定逻辑索引处的元素。
/// 索引 0 是最早的元素,索引 Count-1 是最新的元素。
/// </summary>
/// <param name="index">元素的逻辑索引。</param>
/// <exception cref="IndexOutOfRangeException">当索引超出范围时抛出。</exception>
public T this[int index]
{
get
{
if (index < 0 || index >= Count)
{
throw new IndexOutOfRangeException("Index is out of the valid range of the buffer.");
}
int actualIndex = (HeadIndex + index) % Capacity;
return _data[actualIndex];
}
set
{
if (index < 0 || index >= Count)
{
throw new IndexOutOfRangeException("Index is out of the valid range of the buffer.");
}
int actualIndex = (HeadIndex + index) % Capacity;
_data[actualIndex] = value;
}
}
/// <summary>
/// 获取缓冲区中的第一个(最早的)元素。
/// </summary>
/// <exception cref="InvalidOperationException">当缓冲区为空时抛出。</exception>
public T First
{
get
{
if (Count == 0) throw new InvalidOperationException("Buffer is empty.");
return _data[HeadIndex];
}
}
/// <summary>
/// 获取缓冲区中的最后一个(最新的)元素。
/// </summary>
/// <exception cref="InvalidOperationException">当缓冲区为空时抛出。</exception>
public T Last
{
get
{
if (Count == 0) throw new InvalidOperationException("Buffer is empty.");
// _end 指向下一个要写入的位置,所以上一个写入的位置是 (_end - 1)
int lastIndex = (_end - 1 + Capacity) % Capacity;
return _data[lastIndex];
}
}
/// <summary>
/// 返回一个循环访问集合的枚举器。
/// </summary>
public IEnumerator<T> GetEnumerator()
{
int head = HeadIndex;
for (int i = 0; i < Count; i++)
{
yield return _data[(head + i) % Capacity];
}
}
/// <summary>
/// 返回一个循环访问集合的枚举器。
/// </summary>
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
原理浅析
这个类的核心思想非常巧妙:
- 内部存储:它内部使用一个固定长度的数组
_data作为元素的物理存储空间。 - 指针管理:它使用一个
_end指针来标记下一个新元素应该插入的位置(尾部),以及一个Count属性来记录当前存储的元素数量。 - 循环的奥秘:
HeadIndex这个计算属性是关键,它通过_end和Count的位置动态计算出“逻辑上”第一个元素(头部)在物理数组中的实际索引。取模运算% Capacity保证了无论是_end指针的移动还是HeadIndex的计算,都能在数组的边界内“循环”。 - 添加与覆盖:当你调用
Add方法时,新元素会被放到_end指向的位置。如果缓冲区还没满 (Count < Capacity),Count会加一。如果已经满了,Count不再变化,而旧的头部元素就会被自然而然地覆盖掉。整个过程对于调用者来说是无感的。
骚操作之:在天体模拟中使用
得益于 CircularBuffer<T> 的优雅设计,在我的天体模拟器中使用它变得异常简单。我只需要关心一件事:把新的位置点加进去。
这就是它在我的模拟循环中的样子:每当一个天体的位置变化超过了2个像素(为了避免轨迹点过于密集),我就把它新的坐标 Add 到它的轨迹历史记录 TrackingHistory 中。
// _system.MoveNext();
// _acc += _system.Current.Timestamp - _lastSnapshot.Timestamp;
for (int i = 0; i < _system.Current.Bodies.Length; ++i)
{
BodySnapshot star = _system.Current.Bodies[i];
BodyUIProps props = _uiProps[i];
Vector2 now = new(star.Px, star.Py);
// 如果历史轨迹中有数据,则判断距离
if (props.TrackingHistory.Count > 0)
{
Vector2 old = props.TrackingHistory.Last;
float dist = Vector2.Distance(old, now);
// 只有当移动距离超过阈值时才添加新点
if (dist > 2 / _scale.Value)
{
props.TrackingHistory.Add(now);
}
}
else
{
// 如果是第一个点,直接添加
props.TrackingHistory.Add(now);
}
}
我完全不需要写 if (list.Count > MAX_COUNT) { list.RemoveAt(0); } 这样的代码。CircularBuffer<T> 自动为我处理了覆盖最早元素逻辑,代码因此变得更加简洁和高效。
骚操作之:遍历与渲染
当需要绘制轨迹线时,由于 CircularBuffer<T> 实现了 IEnumerable<T> 接口,我可以直接使用 foreach 循环来遍历其中的所有点,并将它们连接成线。下面的代码片段使用 Direct2D 来绘制几何路径:
if (prop.TrackingHistory.Count < 2) continue;
using ID2D1PathGeometry1 path = XResource.Direct2DFactory.CreatePathGeometry();
using ID2D1GeometrySink sink = path.Open();
// 将画笔移动到轨迹的第一个点
sink.BeginFigure(prop.TrackingHistory.First, FigureBegin.Hollow);
// 遍历并连接后续的点
foreach (Vector2 pt in prop.TrackingHistory.Skip(1))
{
sink.AddLine(pt);
}
sink.EndFigure(FigureEnd.Open);
sink.Close();
// 绘制几何路径
ctx.DrawGeometry(path, XResource.GetColor(prop.Color), 0.02f);
这里的 foreach 会按照元素添加的先后顺序,从最早的(头部)到最新的(尾部)依次返回,完美符合我绘制轨迹的需求。
性能分析:为什么它如此高效?
我们实现的这个 CircularBuffer<T> 不仅用起来方便,其性能也相当出色。让我们来分析一下它主要操作的时间复杂度:
- 插入 (
Add): O(1)。添加一个新元素仅涉及一次数组写入和一次指针(_end)的算术运算,无论缓冲区有多大,耗时都是固定的。 - 获取元素数量 (
Count): O(1)。这只是返回一个字段的值,是瞬时操作。 - 索引访问 (
this[index]): O(1)。通过索引获取元素,需要经过HeadIndex的 O(1) 计算来定位实际的数组下标,然后进行一次数组访问,总体仍然是 O(1)。 - 获取头/尾元素 (
First/Last): O(1)。与索引访问类似,都是通过计算直接定位,无需遍历。 - 查找/遍历 (
foreach): O(n)。和大多数集合一样,如果要查找一个不确定位置的元素或完整遍历,需要访问所有n个元素。
现在,让我们来对比一下,如果使用 List<T> 并手动在列表满时执行 list.RemoveAt(0) 来模拟这个行为,会发生什么。
List<T>.RemoveAt(0) 是一个非常昂贵的操作,其时间复杂度为 O(n)。这是因为它需要将索引 0 之后的所有元素在内存中向前移动一位来填补空缺。如果你的缓冲区很大(比如存储几千个历史点),每次添加新元素都可能触发一次大规模的内存复制,这会带来巨大的性能开销。
相比之下,我们的 CircularBuffer<T> 仅仅通过移动一个整数指针就巧妙地“移除”了最旧的元素,整个 Add 操作的复杂度始终是 O(1)。在这种需要固定大小并频繁读写的场景下,其效率比 List<T> 的模拟方案好得不知道哪里去了,性能简直是天壤之别。
总结
虽然 C# 基础类库里没有提供 CircularBuffer<T>,但它无疑是一个非常实用的数据结构。它在需要固定容量、自动淘汰旧数据的场景下表现出色。
除了今天演示的天体运行轨迹记录,它还可以广泛应用于:
- 日志记录:只保留最近的 N 条日志。
- 性能监控:记录最近一段时间的 CPU 或内存使用率。
- 实时数据流处理:缓存最新的数据点用于分析。
- 轮询调度(Round-Robin):在多个任务或资源间循环切换。
希望这个 CircularBuffer<T> 的实现能对你有所启发。
感谢阅读到这里,如果感觉本文对您有帮助,请不吝评论和点赞,这也是我持续创作的动力!
也欢迎加入我的 .NET骚操作 QQ群:495782587,一起交流.NET 和 AI 的各种有趣玩法!
一个被BCL遗忘的高性能集合:C# CircularBuffer<T>深度解析的更多相关文章
- Fastjson是一个Java语言编写的高性能功能完善的JSON库。
简介 Fastjson是一个Java语言编写的高性能功能完善的JSON库. 高性能 fastjson采用独创的算法,将parse的速度提升到极致,超过所有json库,包括曾经号称最快的jackson. ...
- Map.putAll方法——追加另一个Map对象到当前Map集合(转)
该方法用来追加另一个Map对象到当前Map集合对象,它会把另一个Map集合对象中的所有内容添加到当前Map集合对象. 语法 putAll(Map<? extends K,? extends V ...
- Map.putAll方法——追加另一个Map对象到当前Map集合
转: Map.putAll方法——追加另一个Map对象到当前Map集合(转) 该方法用来追加另一个Map对象到当前Map集合对象,它会把另一个Map集合对象中的所有内容添加到当前Map集合对象. 语法 ...
- Eruda 一个被人遗忘的调试神器
Eruda 一个被人遗忘的调试神器 引言 日常工作中再牛逼的大佬都不敢说自己的代码是完全没有问题的,既然有问题,那就也就有调试,说到调试工具,大家可能对于 fiddler.Charles.chro ...
- Java集合---Array类源码解析
Java集合---Array类源码解析 ---转自:牛奶.不加糖 一.Arrays.sort()数组排序 Java Arrays中提供了对所有类型的排序.其中主要分为Prim ...
- 分享一个生成反遗忘复习计划的java程序
想必这个曲线大家都认识,这是遗忘曲线,展示人的记忆会随着时间的延长慢慢遗忘的规律,同时还展示了如果我们过一段时间复习一次对遗忘的有利影响. 道理大家都懂,关键怎么做到? 靠在本子上记下今天我该复习哪一 ...
- Educational Codeforces Round 74 (Rated for Div. 2)E(状压DP,降低一个m复杂度做法含有集合思维)
#define HAVE_STRUCT_TIMESPEC#include<bits/stdc++.h>using namespace std;char s[100005];int pos[ ...
- 如何 创建一个model对象保存到LIST集合里面并取出来
/// <summary> /// 缓存客服集合信息 /// </summary> public class model { /// <summary> /// 客 ...
- Eruda 一个被人遗忘的移动端调试神器
引言 日常工作中再牛逼的大佬都不敢说自己的代码是完全没有问题的,既然有问题,那就也就有调试,说到调试工具,大家可能对于fiddler.Charles.chrome devtools.Firebug ...
- 声明一个set集合,使用HashSet类,来保存十个字符串信息,然后通过这个集合,然后使用iterator()方法,得到一个迭代器,遍历所有的集合中所有的字符串;然后拿出所有的字符串拼接到一个StringBuffer对象中,然后输出它的长度和具体内容; 验证集合的remove()、size()、contains()、isEmpty()等
package com.lanxi.demo1_3; import java.util.HashSet; import java.util.Iterator; import java.util.Set ...
随机推荐
- 【教程】Ubuntu 16.04 配置 CLion 开发 ROS Melodic
[教程]Ubuntu 16.04 配置 CLion 开发 ROS Melodic 目录 [教程]Ubuntu 16.04 配置 CLion 开发 ROS Melodic 笔者环境 步骤 下载安装 CL ...
- 测试使用自己编译的WPF框架(本地nuget 包引用)
上一篇博客 本地编译WPF框架源码 - wuty007 - 博客园 说到自己在本地编译WPF 框架源码,并在本地 源码 的 \wpf\artifacts\packages\Debug\NonShipp ...
- CentOS使用yum update更新时不升级内核的方法
RedHat/CentOS/Fedora使用 yum update 更新时,默认会升级内核.但有些服务器硬件(特别是组装的机器)在升级内核后,新的内核可能会认不出某些硬件,要重新安装驱动,很麻烦.所以 ...
- 洛谷 P3268 [JLOI2016]圆的异或并
洛谷 P3268 [JLOI2016]圆的异或并 题目描述 在平面上有两两不相交的\(n\)个圆,即其关系只有相离和包含.求这些圆的异或面积并. 异或面积并为:当一片区域被奇数个圆包含则计算其面积,否 ...
- 从困境到突破,EasyMR 集群迁移助力大数据底座信创国产化
在大数据时代,企业对数据的依赖程度越来越高.然而,随着业务的不断发展和技术的快速迭代,大数据平台的集群迁移已成为企业数据中台发展途中无法回避的需求.在大数据平台发展初期,国内数据中台市场主要以国外开源 ...
- Web Platform Tests (WPT) 跨浏览器测试套件
项目标题与描述 Web Platform Tests (WPT) 是一个跨浏览器的测试套件,用于验证Web平台栈的兼容性.其目标是确保不同浏览器实现的一致性,帮助开发者构建跨浏览器兼容的Web应用. ...
- MySQL 02 日志系统:一条SQL更新语句是如何执行的?
比如执行一条更新语句: update T set c=c+1 where ID=2; 首先,更新语句也会走一遍查询语句的流程.除此以外,更新还涉及两个日志模块,分别是redo log和binlog. ...
- FFmpeg开发笔记(七十二)Linux给FFmpeg集成MPEG-5视频编解码器EVC
MPEG-5是新一代的国际音视频编解码标准,像我们熟悉的MP3.MP4等音视频格式就来自于MPEG系列.MP3格式的说明介绍参见<FFmpeg开发实战:从零基础到短视频上线>一书的&qu ...
- 在 SQL Server 中 你可以使用以下查询来找到引用 的 FOREIGN KEY 约束
SELECT f.name AS ForeignKeyName, OBJECT_NAME(f.parent_object_id) AS ReferencingTable, COL_NAME(fc.pa ...
- SQL Server 部分数据库问题。 sp_configure 值 'contained database authentication' 必须设置为 1 才能 创建 包含的数据库
人总是会下意思地逃避去做自己不喜欢的事情,导致这类事情越积越多. 最后,记忆这些不喜欢的事情所产生的负担,远远超出了完成它们所花费的痛苦 sp_configure 值 'contained datab ...