>> >> >> Reference << << << <<<<<<Ref>>>>>>
>> >> >> Indexer << << << <<<<<<Idx>>>>>>
Matched: 0

Tags

    Categories

      Types

        Top Results

          Array
          M: 2025-12-31 - ljf12825

          数组是一种线性结构,由连续内存空间存储相同类型元素的集合

          Array = 一段连续内存 + 固定步长 + 常数时间寻址

          首元素索引为0

          address(i) = base_address + i * element_size
          

          Cache友好

            [0]   [1]   [2]   [3]   [4]  
          ├─────┼─────┼─────┼─────┼─────┤
          │  10 │  20 │  30 │  40 │  50 │
          └─────┴─────┴─────┴─────┴─────┘
           1000   1004  1008  1012  1016
          

          Fixed Array

          长度在编译期或创建时就确定,之后永远不变的内存块

          int a[1024];
          

          特点:

          • 长度不可变
          • 地址永远不变
          • 零额外开销
          • 行为完全可预测
          • Cache/SIMD/DMA友好

          性能特点:

          操作成本说明
          随机访问O(1)通过索引
          增删(尾部)O(1)无开销
          增删(中间)O(n)需要移动后方元素
          查找(有序)O(log n)二分查找
          查找(无序)O(n)遍历

          致命代价:

          • 长度不可变:不确定最大规模会导致要么浪费内存,要么直接炸
          • 越界
          • 不适合动态数据

          适用场景:

          • 数据规模确定
          • 实时系统
          • 内存布局敏感
          • 高频访问

          不适合:

          • 数据规模未知
          • CRUD型业务
          • 频繁增删

          哲学意义:放弃灵活性,换取绝对可控

          Dynamic Array

          动态数组,在静态数组的基础上实现了扩容

          核心

          ┌──────────────┐
          │ data*        │ -> 指向一段连续内存
          ├──────────────┤
          │ size         │ -> 当前元素个数
          ├──────────────┤
          │ capacity     │ -> 已分配容量
          └──────────────┘
          

          扩容

          扩容的核心问题:内存连续性
          数组在内存中必须是连续存储的,这是其O(1)随机访问的基础,但也正是扩容昂贵的主要原因

          内存示意图

          初始数组(已满):
          ┌───┬───┬───┬───┐
          │ 1 │ 2 │ 3 │ 4 │
          └───┴───┴─┬─┴───┘
          地址:1000-1015      16字节
          
          想要扩容到8个元素,但1000-1023地址可能已被占用:
          ┌───┬───┬───┬───┬───┬───┬───┬───┐
          │ 1 │ 2 │ 3 │ 4 │ ? │ ? │ ? │ ? │
          └───┴───┴───┴───┴───┴───┴───┴───┘
               无法原地扩展
          

          扩容过程

          伪代码

          void expand_array(int** arr, int* capacity) {
              // 1. 重新计算容量(通常翻倍)
              int new_capacity = *capacity * 2;
          
              // 2. 申请新的连续内存空间
              int* new_array = malloc(new_capacity * sizeof(int));
          
              // 3. 检查分配是否成功
              if (new_arr = NULL) {
                  // 内存不足,扩容失败
                  return;
              }
          
              // 4. 复制所有元素(O(n)操作)
              for (int i = 0; i < *capacity; i++) {
                  new_arr[i] = (*arr)[i];
              }
          
              // 5. 释放旧内存
              free(*arr);
          
              // 6. 更新指针和容量
              *arr = new_arr;
              *capacity = new_capacity;
          }
          

          总成本 = 分配内存 + 复制n个元素 + 释放内存

          成本分析

          1. 内存分配成本
          • 操作系统查找足够大的连续空闲内存块(可能需要遍历空闲链表)
          • 分割内存块(如果找到的块比需要的大)
          • 更新内存分配表
          • 如果物理内存不足,可能触发缺页中断、页面置换
          • 如果找不到连续块,可能触发内存碎片整理

          频繁扩容会导致大量内存碎片,小碎片无法被利用,总空闲内存虽然很多,但没有连续的大块内存

          2. 数据复制成本

          假设数组有100万个元素,每个元素8字节
          初始数组大小为8MB

          需要扩容时

          1. 分配新内存:16MB
          2. 复制8MB数据

          即使现代CPU很快,这也是显著的延迟

          内存带宽限制
          如果内存带宽是20GB/s, 复制8MB的数据大约为0.4ms

          但实际还要考虑

          • 缓存未命中
          • TLB未命中
          • 内存控制器排队
          3. 缓存不友好
          // 复制大数组会污染缓存
          int* old_array = ...; // 在缓存中
          int* new_array = malloc(new_size);
          
          // 复制过程
          for (int i = 0; i < old_size; i++) {
              // 1. 从old_array[i]读取(可能缓存命中)
              // 2. 写入new_array[i](新内存,肯定缓存未命中)
              // 3. 这会逐出缓存中的其他有用数据
              new_array[i] = old_array[i];
          }
          

          扩容策略

          翻倍扩容(常用)

          capacity *= 2
          插入n个元素的摊还分析:
          总复制次数 = 1 + 2 + 4 + … + n/2 约等于 n
          平均每个元素复制约2次
          摊还时间复杂度 = O(1)

          固定增量扩容

          capacity += 10 插入n个元素的复制次数:
          总复制约等于 10 + 20 + 30 + … 约为 O(n^2)
          平均每个元素复制O(n)次,性能差

          对比

          插入1,000,000个元素

          策略总复制次数平均每个元素复制次数
          固定 +10~50,000,000,000~50,000
          固定 +100~5,000,000,000~5,000
          x2~2,000,000~2
          x1.5~3,000,000~3

          翻倍策略虽然会浪费一些空间,但时间效率最高

          扩容的摊还分析(Amortized Analysis)

          为什么说摊还时间复杂度是O(1)?
          示例:
          插入序列:第1,2,3,…,n个元素
          扩容发生时刻:容量=1,2,4,8,…,直到>=n
          复制成本:
          插入第1个元素:复制0个(初始容量1)
          插入第2个元素:复制1个(扩容到2)
          插入第3个元素:复制2个(扩容到4)
          插入第5个元素:复制4个(扩容到8)

          插入第2^(k+1)个元素:复制2^k个(扩容到2^(k+1))

          总复制次数 = 1 + 2 + 4 + … + 2^k < 2n
          平均每个元素复制 < 2 次

          所以即使单次扩容是O(n),n次插入的平摊成本是O(1)

          优化策略

          1. 预分配:一次性分配,不存在扩容问题
          2. 使用更合适的数据结构:如果频繁增删,数组不是最优选择
          3. 批量操作:避免多次单元素插入,这会触发多次扩容;批量添加,一次扩展

          动态数组的代价

          • 指针/引用失效

            动态数组在扩容时会重新分配内存,原内存被释放,所有指向旧内存的指针/引用全部失效
            这是动态数组导致的一种特性,最好的做法就是避免碰它,不要妄图使用或修补它

          • 实时系统不友好

          • 内存浪费(trade-off)

          Multidimensional Array

          多维数组在逻辑上是多维的,但在物理内存中始终是一维连续存储的

          • 逻辑视图(三维数组)
          int cube[2][3][4] = {
              {{1,2,3,4}, {5,6,7,8}, {9,10,11,12}},
              {{13,14,15,16}, {17,18,19,20}, {21,22,23,24}}
          };
          
          • 物理视图(内存中连续存储)

          内存地址:[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24]

          内存布局模式

          • Row Major
          • Column Major

          二维数组A[M][N]可以想象成一个MN列的表格

          A[0][0]  A[0][1]  A[0][2] ... A[0][N-1]
          A[1][0]  A[1][1]  A[1][2] ... A[1][N-1]
          ...
          A[M-1][0] ...          A[M-1][N-1]
          

          内存是线性的,所以二维数组必须展开成一维存储。这就是 Row Major 和 Column Major的区别

          row-major 行优先/行主序

          C/C++/Python等大多数语言使用

          按行存储,先把第一行存完,再存第二行…

          二维数组示例

          int matrix[3][4] = {
              {1, 2, 3, 4},
              {5, 6, 7, 8},
              {9, 10, 11, 12}
          };
          

          内存布局

          ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
          │00   │01   │02   │03   │10   │11   │12   │13   │20   │21   │ 22  │23   │
          ├─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤
          │  1  │  2  │  3  │  4  │  5  │  6  │  7  │  8  │  9  │  10 │  11 │  12 │
          └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
          

          地址计算公式:对于A[M][N],元素A[i][j]的线性地址计算:
          $addr(A[i][j])=base_addr+(i*N+j)*element_size$

          优点:遍历数组时按行顺序访问缓存友好(CPU缓存行连续)

          column-major 列优先/列主序

          Fortran/Matlab/Julia使用

          按列存储,先存第一列,再存第二列…

          • Fortran示例
          INTEGER :: matrix(3,4) = RESHAPE([1,2,3,4,5,6,7,8,9,10,11,12], [3,4])
          
          • 内存布局(按列展开)
          ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
          │00   │10   │20   │01   │11   │21   │02   │12   │22   │03   │13   │23   │
          ├─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤
          │  1  │  2  │  3  │  4  │  5  │  6  │  7  │  8  │  9  │  10 │  11 │  12 │
          └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
          

          地址计算公式:
          $addr(A[i][j])=base_addr+(j*M+i)*element_size$

          优点:对按列遍历的算法更高效,比如矩阵乘法的某些实现

          为什么行主序更“缓存友好”

          1. CPU的特点
            • cache line 机制
            • 局部性
          2. 行主序的优势
            • 二维数组在C/C++中是行主序排列的
            • 当按行遍历数组时,逻辑访问和内存物理顺序一致
            • 结果:缓存命中率高,CPU一次加载一个cache line, 就可以顺序访问多个元素
          3. 列主序的劣势(在行主序语言中)
            • 如果按列遍历二维数组,访问的元素在内存中不是连续的
            • 每次访问可能跨越多个cache line, 导致频繁的缓存未命中
            • CPU必须不断从主存加载数据,速度大幅下降

          不同维度的地址计算

          通用公式推导

          对于n维数组:arr[d₁][d₂]...[dₙ]
          
          行主序:
          元素arr[i₁][i₂]...[iₙ]的偏移量:
          offset = ((...((i₁ × d₂ + i₂) × d₃ + i₃)...) × dₙ + iₙ) × element_size
          
          列主序:
          offset = ((...((iₙ × dₙ₋₁ + iₙ₋₁) × dₙ₋₂ + iₙ₋₂)...) × d₁ + i₁) × element_size