03-03-线性-LinkedList-T-源码不变式与选择边界

LinkedList:双向链表的源码不变式与选择边界

系列 :C# 与常用数据结构源码剖析 · 线性结构篇

源码基线.NET 8.0.0dotnet/runtimev8.0.0 tag,System.Private.CoreLib/src/System/Collections/Generic/LinkedList.cs

阅读约定 :本文把公共 API 契约与私有实现分开。字段、循环链和版本计数是 v8.0.0 的实现事实,不是应用可依赖的 ABI。文中源码为教学化节选,省略了接口、特性和抛错辅助方法,不冒充可直接替换运行时文件的逐字源码。


一、链表的价值不是"O(1) 比 O(n) 快"

LinkedList<T> 表达一个可从两端访问、可通过节点句柄在已知位置修改的双向序列。它的关键能力不是"中间插入天然快",而是:当调用者已经持有属于该链表的 LinkedListNode<T> 时,容器可以用常数次引用重连完成插入或删除

如果只有值,没有节点,情况就不同:

复制代码
LinkedList<int> list = new([10, 20, 30]);

LinkedListNode<int>? node = list.Find(20); // O(n) 查找
if (node is not null)
    list.AddAfter(node, 25);               // 已知节点后 O(1) 重连

list.Remove(30);                           // 先 Find,再删节点:整体 O(n)
list.Remove(list.First!);                  // 已知节点:O(1)

复杂度只描述工作量随数据规模如何增长,不描述绝对时间。List<T>.Insert(0, value) 要搬移后缀,LinkedList<T>.AddFirst(value) 要分配一个节点并写多个引用。谁在特定规模与平台上更快,取决于元素类型、操作分布、分配、GC、CPU 局部性和运行时。没有可运行基准与原始报告,不应声称固定字节数或固定倍数。

二、公共模型:容器、节点与所属关系

LinkedList<T> 与只暴露整数索引的线性容器有一个重要差异:节点本身是公开对象。FirstLast 返回 LinkedListNode<T>?,节点公开 ValueNextPreviousList。因此调用者可以长期持有某个节点,不必每次再根据值搜索。

一个节点在任意时刻只能处于两种公共状态之一:

  1. 已连接node.List 指向某个 LinkedList<T>,它可作为该链表 AddBeforeAddAfterRemove 的参数。

  2. 未连接 :新建节点或已被移除的节点,node.Listnull,可以稍后加入任意兼容的链表。

    var left = new LinkedList();
    var right = new LinkedList();
    var node = new LinkedListNode("ready");

    left.AddLast(node);
    // right.AddLast(node); // 抛 InvalidOperationException:节点已属于 left

    left.Remove(node);
    right.AddFirst(node); // 移除后 List 为 null,可重新接入

所属检查是容器不变式的一部分,不是多线程保护。两个线程同时移动同一节点,仍然会产生数据竞争。

Value 可修改,修改值不会改变节点位置。在 .NET 8 v8.0.0 中,LinkedListNode<T>.Value setter 也不会增加链表的 version;但业务代码不应由此推导出"枚举时修改值一定是良好协议"。若需要稳定快照,应显式复制并定义深浅复制边界。

三、.NET 8 的内部形状:一个 head 组织整个环

v8.0.0 的容器核心状态可教学化概括为:

复制代码
internal LinkedListNode<T>? head;
internal int count;
internal int version;

headnull 表示空表。非空时,内部不用 null 表示首尾边界,而是把所有节点连成一个循环双向链表

复制代码
head
 │
 ▼
[ A ] <-> [ B ] <-> [ C ]
  ▲                 │
  └-----------------┘

head.prev == C        // 内部尾节点
C.next == head

循环形状让实现只保存 head 就能常数时间得到尾节点 head.prev,也让大部分插入可以统一成"在某节点前插入"。公共 API 不把这个环暴露给调用者:首节点的 Previous 返回 null,尾节点的 Next 返回 null。因此使用公共节点遍历时看到的仍是有限线性序列,不会无限绕环。

对于实现层,每次操作后至少要保持以下不变式:

  1. count == 0 当且仅当 head == null
  2. 非空时,从 head 连续跟随 next 恰好 count 次回到 head,中途不会遇到 null
  3. 对每个已连接节点 xx.next.prev == xx.prev.next == x
  4. 环上每个节点的所属容器都是当前链表,不存在同一节点同时属于两个链表的合法状态。
  5. head.prev 是逻辑尾节点,它的 nexthead
  6. 结构修改后 version 变化,使旧枚举器能发现修改。

不变式比记忆某几行指针赋值更有用。无论未来字段名如何变化,只要实现仍是这种循环模型,插入、删除和枚举都必须维护同类性质。

四、AddFirst、AddLast 与已知节点插入

空链表的第一个节点是特殊情况:它的 nextprev 都指向自己,然后成为 head

复制代码
// 结构化节选:空表插入
private void InternalInsertNodeToEmptyList(LinkedListNode<T> newNode)
{
    newNode.next = newNode;
    newNode.prev = newNode;
    head = newNode;
    version++;
    count++;
}

非空链表可将插入统一为"在 node 之前插入 newNode":

复制代码
// 结构化节选:在 node 前插入
private void InternalInsertNodeBefore(
    LinkedListNode<T> node,
    LinkedListNode<T> newNode)
{
    newNode.next = node;
    newNode.prev = node.prev;
    node.prev!.next = newNode;
    node.prev = newNode;
    version++;
    count++;
}

四条引用重连后,新节点与前驱、后继都恢复了双向对称。在此基础上:

  • AddLasthead 前插入,新节点自然成为 head.prev
  • AddFirst 同样在 head 前插入,然后把 head 更新为新节点。
  • AddBefore(node, ...) 直接在 node 前插入。
  • AddAfter(node, ...)node.next 前插入,等价于在 node 后插入。

公共重载既可接受值,也可接受由调用者创建的节点。当传入现有节点时,实现首先验证它尚未属于任何链表;传入位置节点时,则验证它正属于当前链表。这些检查阻止了"把外部节点当作本表位置"和"同一节点重复接入"两类环损坏。

所有这些插入在已知合法节点后都是 O(1)。但如果业务输入只给出"在值 X 后插入",先找 X 的过程仍是 O(n)。不能在复杂度表中偷掉定位成本。

五、Find 与 FindLast:相等性、方向和线性成本

Find(value)head 开始沿 next 查找第一个等价值;FindLast(value) 从逻辑尾节点沿 prev 反向查找。两者都使用 EqualityComparer<T>.Default,而不是依赖引用相等。对允许为 nullT,实现也覆盖查找空值的路径。

复制代码
// 概念模型:真实源码还分开了 null 路径
public LinkedListNode<T>? Find(T value)
{
    LinkedListNode<T>? node = head;
    if (node is null)
        return null;

    EqualityComparer<T> comparer = EqualityComparer<T>.Default;
    do
    {
        if (comparer.Equals(node.item, value))
            return node;
        node = node.next;
    }
    while (node != head);

    return null;
}

循环内部表不能用 node != null 作为终止条件;回到 head 才表示扫描了一圈。这是阅读源码时容易忽略的实现边界。

Contains(value) 本质上也要扫描直到命中或绕回表头。Remove(value) 先查找第一个等价节点,命中后再调用节点删除。因此:

操作 时间 前提
First / Last O(1) 只取端点
AddFirst / AddLast O(1) 分配新节点或接入未属于其他表的节点
AddBefore / AddAfter O(1) 已持有属于当前表的位置节点
Remove(node) O(1) 已持有属于当前表的节点
Find / FindLast / Contains O(n) 比较成本还要乘以单次相等比较成本
Remove(value) O(n) 定位 O(n) + 重连 O(1)
枚举 / CopyTo O(n) 输出 n 个元素
Clear O(n) 需要使每个公开节点失效

这张表也说明了双索引模式的价值:例如 LRU 缓存用字典从 key 平均 O(1) 定位节点,再用链表 O(1) 移动和淘汰;代价是两个结构必须作为一个事务维护。

六、Remove 与 Clear:删除不只是修改 Count

删除已知节点时,先把前驱和后继连在一起。如果被删节点是唯一节点,就把 head 设为 null;如果删的是 head,则让后继成为新 head。最后使节点失效、减少数量并更新版本。

复制代码
// 结构化节选:省略了入参检查
private void InternalRemoveNode(LinkedListNode<T> node)
{
    if (node.next == node)
    {
        head = null;
    }
    else
    {
        node.next!.prev = node.prev;
        node.prev!.next = node.next;
        if (head == node)
            head = node.next;
    }

    node.Invalidate(); // list/next/prev 置 null
    count--;
    version++;
}

Invalidate 很重要。调用者可能仍持有被删节点;若它继续指向链表和相邻节点,一个过期句柄就可以无意中保活整条引用链。清除所属关系和链接后,该节点变回未连接状态,之后可被再次加入。Value 本身依然保留,因为节点还可被调用者观察和复用。

Clear 不能只将 head = null。所有节点都是公开对象,外部可能持有其中任意一个。因此 .NET 8 实现遍历原链,对每个节点执行失效化,再清空表头、数量并修改版本。这也是 Clear 为 O(n) 的原因之一。

"断开容器引用"不等于"立即释放内存"。对象只有在所有强引用都消失后才变为不可达,之后何时回收由 GC 决定。节点字典、事件、闭包、枚举器和调试器变量都可能继续保持引用。

七、枚举器:把内部环投影成线性序列

LinkedList<T>.Enumerator 保存链表、下一个节点、创建时的 version、当前值和位置状态。MoveNext 在每次推进时检查版本;读取当前节点后沿 next 移动,一旦回到 head 就把下一节点设为 null,对公共枚举呈现有限序列。

复制代码
内部:A -> B -> C -> A -> ...
枚举:A, B, C, 结束

AddFirstAddLastAddBeforeAddAfterRemoveClear 都是结构修改,会使旧枚举器失效。后续 MoveNext 抛出 InvalidOperationException 是 fail-fast 错误检测,不是线程安全保证。在竞争时序中,读者可能在看到一致版本号之后又与写者交错;普通 LinkedList<T> 不承诺并发读写的快照、原子性或内存可见性。

枚举器是值类型,具体 foreach 路径通常无需为枚举器单独分配对象;经过 IEnumerable<T>IEnumerator<T> 或非泛型接口时则可能装箱。这个结论说的是调用形状,不应简化为"struct 永远在栈上"。

八、内存与 CPU:建立条件模型,不传播固定倍数

对现代 .NET 的 LinkedList<T>,定性成本比固定字节数更稳定:

  • 链表对象保存表头、数量、版本及其他运行时状态。
  • 每个逻辑元素对应一个独立 LinkedListNode<T> 对象,其中保存值以及 list/next/prev 引用。
  • T 若是值类型,值存入节点对象内;若是引用类型,节点保存对该对象的引用。
  • 对象头、引用宽度、对齐、T 的形状与运行时会影响实际大小,所以不存在一个适用所有环境的"每节点固定多少字节"。

List<T> 的连续后备数组相比,独立节点通常意味着更多托管对象、更多引用边和更弱的遍历局部性。但"每次跟随 next 都必然 cache miss"也是错误的:节点的分配时间、堆布局、GC 移动、CPU 预取、其他对象干扰和工作集大小都会改变命中率。

GC 成本也要分层理解:

  1. 创建新节点会产生托管分配,但重新接入一个未属于其他表的现有节点不需要再创建节点。
  2. 存活链表会有意保持节点和节点值可达,这不是泄漏。
  3. 删除和清空会断开容器与相邻节点引用,但外部句柄、字典或回调仍可保活对象。
  4. 一个大量创建后立即丢弃的短命链表可提高分配率;长期存活的链表则会把节点保留到更长的生命周期。具体回收成本需用目标运行时的 GC 事件与堆快照确认。

所以应用不应仅因"理论 O(1)"就换成链表,也不应仅因"数组局部性好"就否定所有链表。先问是否真有持久节点句柄、是否高频移动已知节点,再用真实操作分布测量。

九、真正适合的场景一:LRU 缓存的双索引

LRU 缓存既要根据 key 快速定位,又要在每次命中后把已知项移到表头,并从表尾淘汰。字典与链表刚好分担两种不同索引:

复制代码
public sealed class LruCache<TKey, TValue> where TKey : notnull
{
    private readonly int _capacity;
    private readonly Dictionary<TKey, LinkedListNode<Entry>> _byKey;
    private readonly LinkedList<Entry> _recency = new();

    private readonly record struct Entry(TKey Key, TValue Value);

    public LruCache(int capacity, IEqualityComparer<TKey>? comparer = null)
    {
        if (capacity <= 0)
            throw new ArgumentOutOfRangeException(nameof(capacity));

        _capacity = capacity;
        _byKey = new Dictionary<TKey, LinkedListNode<Entry>>(
            capacity, comparer);
    }

    public bool TryGetValue(TKey key, out TValue value)
    {
        if (!_byKey.TryGetValue(key, out LinkedListNode<Entry>? node))
        {
            value = default!;
            return false;
        }

        _recency.Remove(node);
        _recency.AddFirst(node);
        value = node.Value.Value;
        return true;
    }

    public void Set(TKey key, TValue value)
    {
        if (_byKey.TryGetValue(key, out LinkedListNode<Entry>? existing))
        {
            existing.Value = new Entry(key, value);
            _recency.Remove(existing);
            _recency.AddFirst(existing);
            return;
        }

        var added = _recency.AddFirst(new Entry(key, value));
        _byKey.Add(key, added);

        if (_byKey.Count <= _capacity)
            return;

        LinkedListNode<Entry> victim = _recency.Last!;
        _recency.Remove(victim);
        _byKey.Remove(victim.Value.Key);
    }
}

这段代码的核心不是"链表自动得到 O(1) LRU",而是字典直接保存节点句柄,从而没有 Find。真实系统还要定义线程安全、值创建失败、逐出回调、按字节而非条目数限额等行为。更新两个结构必须在同一所有权或锁协议内完成;任何一边成功、另一边失败都会留下悬空节点或无索引项。

十、真正适合的场景二:可取消调度与顺序编辑

当调度队列按到达顺序处理,同时调用者需要通过注册时返回的句柄 O(1) 取消任务,LinkedList<T> 也可用:注册返回 LinkedListNode<T>,取消时删除该节点,处理时从 First 移除。但句柄只在 node.List == schedulerList 时有效;任务已处理、已取消或整表清空后,再取消必须按协议返回 false,而不是将过期节点当成当前任务。

如果调度要求按截止时间取最小项,普通链表就不合适:按时间找插入点需 O(n)。应评估 PriorityQueue<TElement,TPriority>、时间轮或可定位堆。若是撤销/重做系统,只在编辑器支持"保持任意当前节点并从中间分叉"时双向链表才有独特价值;普通两个栈通常更能表达标准 undo/redo 契约。

同理,队首入队尾出的普通 FIFO 不需要公开节点,Queue<T> 的环形数组往往更匹配。双端操作需要 deque 语义时,应先寻找目标平台上经验证的双端队列,不要因为 LinkedList<T> 能做就忽略节点分配成本。

十一、数组链表与索引池:优化布局会引入新契约

在高频创建节点、遍历很热或需要 Burst 的场景,可将节点存入连续槽位,用整数索引表示 nextprev。这不是对 LinkedList<T> 的等价无成本替换,而是一个新容器。一个可上线的索引池至少必须明确:

  1. 槽位存储 valuenextprev、占用标志与一个不会在每次复用时保持不变的 generation
  2. 外部句柄不能只是 index,而应是 (index, generation)。删除后槽位可复用,但代次变化;旧句柄因代次不匹配而被拒绝,避免 ABA 式"旧 index 指向新对象"。
  3. 每次接入和删除都同时维护 head/tail/count、双向对称和空闲链;删除头尾、唯一节点与中间节点必须分别测试。
  4. 容器扩容可以保持 index,但紧凑压缩若搬迁槽位就会使所有句柄失效;API 必须选择"不搬槽位"或显式返回重定向表。
  5. 代次计数有限,要定义溢出策略,而不是假定它永不回绕。
  6. 调试构建中应有完整不变式扫描:活动数与 count 一致、活动链无重复、空闲链无重复、两类槽位不相交,且每个活动节点的前后链对称。

只写一个包含 Node[]NextPrev 的示例,却没有处理 head/tail、容量耗尽、双重删除、过期句柄和代次,不是可用的数据结构。它即使通过了几个顺序用例,也可能在槽位复用后静默修改错误对象。因此本文不给出一个省略关键部分的"高性能数组链表";实际实现应将句柄与属性测试一起交付。

十二、Unity、Mono、IL2CPP 与 Burst 边界

Unity 项目首先要核对编辑器完整版本、API Compatibility Level、脚本后端和目标平台。桌面 .NET 8 v8.0.0 的私有 LinkedList<T> 字段与 GC 行为不是 Unity Player 的自动证据。公共操作可用也不意味着 Mono 与 IL2CPP 使用同一份 CoreCLR 实现。

LinkedList<T>LinkedListNode<T> 是托管对象。IL2CPP 把 IL 转换为 C++ 再 AOT 编译,并不会把每个托管节点自动变成无 GC 的紧凑原生槽位。节点的分配、对象生命周期和遍历成本要在目标 Player 上使用 Unity Profiler、Memory Profiler 和可控回放测量,不能把 Editor 或 CoreCLR 微基准平移为真机结论。

Burst Job 不能将普通托管 LinkedList<T> 作为可直接调度的原生容器。Unity Collections 中的 NativeList<T> 是连续动态数组,不是链表;它不因名字含 List 就提供稳定节点句柄或 O(1) 中间删除。NativeArray<T>NativeList<T> 和自定义原生索引池还有 Allocator、Dispose、JobHandle 依赖、AtomicSafetyHandle 和并发写限制,与 LinkedListNode<T>.List 契约不可互换。

若真需要 Burst 可用的索引链,节点中的 T 和所有状态必须符合 Burst/原生容器要求,且代次句柄、空闲链、容量与作业依赖都由项目自己维护。开发成本只有在 Profile 已证明托管节点是实际瓶颈,且标准连续容器不能表达必需语义时才合理。

十三、常见失败反例

反例一:只有值,却宣称中间删除 O(1)。 Remove(value) 必须先线性查找。要获得已知节点的常数重连,业务模型要保存节点句柄或用另一索引直接找到节点。

反例二:保存节点却不定义失效。 节点移除或 ClearListnull。调用者必须能识别句柄过期;多线程下仅检查 List 也不是原子取消协议。

反例三:认为 AddAfter 可直接"移动"已连接节点。 一个节点已属于链表时,不能再接入。要移动必须先 Remove(node)AddBefore/AddAfter(node),并在同一同步边界内完成。

反例四:在 foreach 中删除当前节点。 foreach 枚举的是值,结构修改会使枚举器失效。若要单线程原地删除,手动保存下一节点:

复制代码
for (LinkedListNode<int>? node = list.First; node is not null;)
{
    LinkedListNode<int>? next = node.Next;
    if ((node.Value & 1) == 0)
        list.Remove(node);
    node = next;
}

反例五:用链表做排名或按时间排序队列。 链表不提供快速有序定位和第 k 项索引。根据需求选 SortedSet、排序数组、PriorityQueue 或顺序统计结构。

反例六:将链表私有环或节点对象图序列化。 持久化应保存逻辑值序列、schema 和必要标识,读取时重建容器。私有 next/prev/head/version 不是跨版本格式。

反例七:因为链表不搬移元素就认为它没有分配或 GC 成本。 用值重载新增元素通常创建新节点对象。是否可接受必须用目标负载测量。

十四、可复现实验:测前提,不测传说

实验应回答"我的操作分布下是否值得保存节点",而不是只测一次空表插入。建议建立 net8.0 Release 项目,锁定 SDK 完整版本,并保存 BenchmarkDotNet 完整报告:

复制代码
dotnet new console -n LinkedListLab -f net8.0
cd LinkedListLab
dotnet add package BenchmarkDotNet
dotnet run -c Release

至少拆分五组负载:

  1. 顺序遍历List<int>LinkedList<int> 处理相同校验和;数量参数化,防止结果未消费而被优化。
  2. 已知节点删除 :预先保存 LinkedListNode<int>[] 句柄,基准迭代中删除指定节点;每次迭代重建容器,不得让后续迭代删除已空数据。
  3. 只有值的删除 :用 Remove(value) 与已知节点路径对照,命中位置分成头、中、尾和未命中。
  4. 构建与分配 :分开测量从空构建、复用已分配节点和 List<T> 合理预分配;记录总分配、GC 计数与存活堆,不只看 Mean。
  5. 端到端 LRU:使用可重放的 key 轨迹,参数化命中率、容量和写入比,对比正确性等价的实现,同时检查最终键集与淘汰顺序。

报告必须记录 OS、CPU、.NET SDK/runtime 完整版本、GC 模式、元素类型、数据规模、输入种子、命中位置、构建是否计时与原始统计分布。若要说明 CPU 局部性,应使用所在平台可用的硬件计数器并说明统计误差,不要用"节点数量"直接冒充 cache miss 数。

功能实验则应优先于性能实验:

  • 空表的 First/Last 为 null,单节点的 First 与 Last 是同一节点。
  • 从两端与中间插入后,正向序列与反向节点遍历互为逆序。
  • 删除头、尾、唯一节点和中间节点后 Count 和序列正确,被删节点的 List/Next/Previous 为 null。
  • 把已属于一张表的节点加入另一张表会失败;移除后可合法重用。
  • Find/FindLast 在重复值和 null 值上分别返回第一个/最后一个等价节点。
  • 结构修改后旧枚举器失效;纯 Value 更新的行为按目标 tag 锁定,不推广为跨版本并发契约。
  • LRU 在更新、命中、淘汰和重复 key 轨迹后,字典节点都属于同一 recency 链,两者数量一致。

Unity 实验需要在实际 Editor 与目标设备 Development/Release Player 上分别执行,明确 Mono 或 IL2CPP,保存 Profiler capture 与 Memory Profiler 快照。如果比较托管链表和原生索引池,必须包含数据转换、Job 调度、依赖完成和 Dispose 的端到端成本。

十五、代码审查清单

  1. 业务真的需要稳定节点句柄吗,还是只需 FIFO、LIFO、索引访问或优先级?
  2. 所谓 O(1) 操作是否已持有节点,还是偷掉了 Find/Contains 的 O(n) 定位?
  3. 节点所属关系是否被验证,过期句柄、重复删除和跨表接入如何处理?
  4. 若同时维护字典与链表,两者更新是否在一个所有者/锁/回滚协议内?
  5. 是否在枚举期间修改结构,或把 version 检查误当作线程安全?
  6. 节点 Value 是可变对象时,读者需要活视图还是深快照?
  7. 每次业务操作创建多少节点,是否真的出现分配/GC 瓶颈,还是只根据大 O 猜测?
  8. 性能结论是否包含完整环境、输入分布、正确性校验和原始报告,而非固定字节/倍数?
  9. 自定义索引池是否有 (index,generation) 句柄、空闲链、完整边界测试和代次溢出策略?
  10. Unity 结论是否固定 Editor、API profile、Mono/IL2CPP、Player 平台,并没有把 NativeList 误当原生链表?
  11. Burst/原生容器的 Allocator、Dispose、JobHandle 和并发权限是否在同一设计中受控?
  12. 序列化是否只保存逻辑值与业务顺序,而没有依赖私有节点环、字段名和版本号?

十六、结论:只在节点身份有价值时购买链表成本

.NET 8 v8.0.0LinkedList<T>headcountversion 组织一个内部循环双向链。head.prev 提供尾节点,节点的 list/next/prev 维护所属与双向对称,公共 Next/Previous 则把内部环投影为有限序列。Add 通过常数次重连接入节点,Remove 断链后使节点失效,Clear 必须遍历并使全部公开节点脱离,枚举器用 version 发现结构修改。

"链表插入/删除 O(1)"的完整句子必须包含"已持有属于该表的节点"。只有值时,Find、Contains 与 Remove(value) 仍然是线性搜索。节点对象还带来独立分配、引用跟踪和局部性成本,但实际字节数、cache miss 和性能倍数必须在固定环境中测量。

因此,LinkedList<T> 最有说服力的场景不是"任何中间修改",而是 LRU、可取消就绪队列和显式当前位置等真正需要稳定节点身份的模型。对普通遍历、索引访问、FIFO、优先级和 Burst 数据处理,数组、List<T>Queue<T>PriorityQueueNativeList<T> 或经完整验证的代次索引池往往能更准确地表达契约。

下一篇Span 与 Memory:连续内存视图、生命周期与所有权

相关推荐
DOLA_Tech1 小时前
电脑共享文件夹教程(Windows、统信UOS、银河麒麟)
windows·电脑
钝挫力PROGRAMER2 小时前
Windows Docker 环境搭建 GitLab CI/CD 流水线
windows·docker·gitlab·gitlab-runner
拿本唠嗑AI研究3 小时前
从电脑到手机:DeepSeek Harness Windows安装与手机互动教程(避坑完全版)
人工智能·windows·智能手机·deepseek·harness
面对疾风叭!哈撒给13 小时前
Windows 11 系统更新操作设置停止更新的时间
windows
深念Y14 小时前
SSD 寿命洁癖:编译过程不入盘的原则与实践
缓存·io·内存·编译·ssd·读取·写入
longxiaozhang616 小时前
C# Array类:数组其实没那么难
开发语言·c#
hold?fish:palm17 小时前
30 两两交换链表中的节点
数据结构·算法·链表
小飞侠在吗19 小时前
Windows 下 Nginx 访问 127.0.0.1 报 50x 错误:根因是路径里 \t 被当成 Tab
运维·windows·nginx
longxiaozhang620 小时前
C#运算符重载:让对象也能使用“加减乘除”
开发语言·c#