当前位置: 首页 > news >正文

从0到1的数据结构教程——深入思考动态数组

目录

前言

所以,从定义出发

在实现中窥探数组的特性

从内存分配策略仔细考察这个结构

创建一个空白的动态数组

bonus:pos_pointer,访问第i个元素的工具函数

尾插一个元素到动态数组上

插入

尾删

删除指定位置的元素

访问与删除

回过头来思考我们的实现


本文章隶属于项目:从0到1的数据结构教程(含Leetcode刷题笔记)Charliechen114514/From-0-To1-Tutorials-for-ds-algorithm-lib-in-C: This is a toturials for createing a portable C Common Library by steps!https://github.com/Charliechen114514/From-0-To1-Tutorials-for-ds-algorithm-lib-in-C本篇文章的代码在:

From-0-To1-Tutorials-for-ds-algorithm-lib-in-C/Tutorial_1_DynamicArray/Codes at main · Charliechen114514/From-0-To1-Tutorials-for-ds-algorithm-lib-in-Chttps://github.com/Charliechen114514/From-0-To1-Tutorials-for-ds-algorithm-lib-in-C/tree/main/Tutorial_1_DynamicArray/Codes

前言

我们这一张的主要内容,就是仔细的学习动态数组这个数据结构。出于刷算法题的目的,笔者需要强调的是——动态数组这个事情,本质上是为了更好的理解数组操作的概念。看这篇博客的朋友,是需要通过这篇博客理解数组性质的,作为基本数据结构的根基,笔者也会强调一下常见的数据结构操作有哪些,这样我们理解了数据结构的操作,思考项目,或者功利点说,刷题更加的方便。

笔者这里也有一份最小简单实现的代码,放在文章的,介绍各种数据结构原理的章节部分,感兴趣的朋友酌情参考即可!

所以,从定义出发

什么是动态数组?简单的说——动态的数组,我们知道如下的基本事实:

  • 在C/C++中,假设给定数组为:int arr[10],即可以使用arr[i]访问第i + 1个元素,也可以使用arr + i * sizeof(elem)访问第i + 1个元素,这是因为我们可以使用指针访问数组,或者使用下表访问符访问数组

  • 传统的数组显然是静态的,换而言之,我们在编译期就确定了数组的大小,如果我们想要存储更多的内容,就必须重新另寻他处,这个问题的办法非常好解决,只需要在堆上提供动态的内存空间存放即可。我们需要的信息,也就是这个内存的起始地址和数组大小,以及存储的元素的元素大小,就可以访问当前空间中任何一个元素了。

我们的动态数组就是这样的一个可调节大小的数组结构,我们要实现针对这个结构的如何接口。毕竟,在真实的业务开发中,我们不应该非常关心底层的结构是如何实现元素存储,访问的。笔者简单的基于上述讨论,给出一些一个数据结构应当拥有的接口,这里我就以动态数组DynamicArray作为例子。

CCDynamicArray* create_blank_dynamic_array(size_t  elem_type);
void dynamic_array_push_back(CCDynamicArray* array, void* data, size_t elem_type);
void dynamic_array_insert(CCDynamicArray* array, void* data, size_t elem_type, size_t position);
void dynamic_array_pop_back(CCDynamicArray* array);
void dynamic_array_erase(CCDynamicArray* array, size_t where);
void dynamic_array_iterate(CCDynamicArray* array, IterationFunc func);
void erase_dynamic_array(CCDynamicArray* array);
函数名参数类型用途
CCDynamicArray* create_blank_dynamic_arraysize_t elem_type创建一个空的动态数组,elem_type 表示数组元素的类型大小(字节)。
void dynamic_array_push_backCCDynamicArray* array向动态数组末尾添加一个元素,data 是指向元素的指针,elem_type 确保类型匹配。
void* data
size_t elem_type
void dynamic_array_insertCCDynamicArray* array在指定位置插入一个元素,position 是插入的位置索引。
void* data
size_t elem_type
size_t position
void dynamic_array_pop_backCCDynamicArray* array删除动态数组的最后一个元素。
void dynamic_array_eraseCCDynamicArray* array删除指定位置的元素,where 是要删除的位置索引。
size_t where
void dynamic_array_iterateCCDynamicArray* array遍历动态数组并对每个元素执行 func 函数。
IterationFunc func
void erase_dynamic_arrayCCDynamicArray* array释放动态数组的内存并销毁数组。

笔者认为这就是一个最小的接口了。声明完接口,我们才知道我们的抓手在什么地方。理解这个事情,我们就可以深入的探讨动态数组这个数据结构的基本特点了。早在最开始(嗯,比较久以前的第0章,笔者介绍了数据结构的时间复杂度计算方法),我们下面也会结合这些技术指标,仔细分析动态数组这个数据结构。

在实现中窥探数组的特性

如何实现动态数组的抽象?显然,一个熟悉Malloc接口的朋友会给出这样的抽象:

typedef struct {
    void*       holding_array;
    size_t      capicity;
    size_t      current_size;
    size_t      element_size;
}CCDynamicArray;

可以简单的衡量一下成员的含义:

  • holding_array就是我们的内存的指针变量,拿到他,我们拿到了整个数组的开头,只需要一个游标变量,就能知道访问内存中我们感兴趣的特定的元素

  • capicity说明了我们当前内存申请最大可以存放的元素个数,实际上,capicity * elem_size就是我们动态数组内存的总大小

  • current_size说明了我们现在这个池子里维护了多少个元素

  • elem_size说明了存储的元素大小,这个是我们后面会用到的。

笔者上面绘制的图,说明了我们动态数组的抽象的物理绘图。我想我说的很明白了。

从内存分配策略仔细考察这个结构

显然我说过,动态数组的存储内存是需要堆上开辟的,这个没有任何的疑问。对于C语言,这个接口就是malloc函数

static void __reallocate_memory_with_given_size(CCDynamicArray* array, size_t new_size)
{
    void* new_space = realloc(array->holding_array, new_size * array->element_size);
    assert(new_space);
    array->holding_array = new_space;   // 注意,holding_array指针变量不会发生变动,我们需要存储realloc返回的值
    array->capicity = new_size;
}

realloc接口是对malloc和free的小幅度封装,我们申请的这个接口将会对给定的起始地址的内存块进行大小的重新调整。基于此,我们就实现了存储内存的动态变化。__reallocate_memory_with_given_size作为一个私有函数,就是实现了这样的作用。我们给定准备调整内存大小的array结构体对象和新的目标的容量,进行一定的调整。

创建一个空白的动态数组

“Create Until Use”这个想法笔者非常喜欢。这个也是一种优化策略——直到你真的要用了,你再去创建它。否则不要为不存在的需求分配任何资源。

CCDynamicArray* create_blank_dynamic_array(size_t  elem_type)
{
    CCDynamicArray* array = malloc(sizeof(CCDynamicArray));
    array->holding_array = NULL;
    array->capicity = 0;
    array->element_size = elem_type;
    array->current_size = 0;
    return array;
}

笔者的这个实现当中创造了一个最空白的动态数组,在下面用户真正调用push_back或者insert的时候,才会分配资源。

bonus:pos_pointer,访问第i个元素的工具函数

C语言是没有泛型的,或者说,唯一的泛型void*不好用,我们必须自己偏移指针拿到对应元素的地址。举个例子,在一个存储了整形的数组中,外部程序只给定了数组地址和元素单个大小,如何访问第i个元素,我们需要使用这个工具函数

static inline char* pos_pointer(CCDynamicArray* array, size_t pos)
{
    return (char*)(array->holding_array + pos * array->element_size);
}

这个函数就等价于访问了arr[pos]了,为什么?,请看下面的图:

尾插一个元素到动态数组上

#define BASE_SZ (5)
void dynamic_array_push_back(
    CCDynamicArray* array, void* data, size_t elem_type)
{
    assert(array);
    assert(data);
    assert(elem_type == array->element_size);
    if(!array->holding_array)
    {
        __reallocate_memory_with_given_size(array, BASE_SZ);
    }
​
    if(array->current_size >= array->capicity){
        __reallocate_memory_with_given_size(array, array->capicity * 2);
    }
    char* pos_p = pos_pointer(array, array->current_size);
    memcpy(pos_p, data, elem_type);
    array->current_size++;
}

插入的时候,我们总是需要考虑两种重要的情况:

  • 一开始,我们的holding_array为空,所以我们需要提前分配内存,保证内存访问合法

  • 如果我们的存储马上就要溢出了,就需要做扩容策略。笔者这里的策略是:扩大一倍。

处理完了内存,现在我们的访问是合法了,我们直接找到尾部,使用memcpy尾插数据,更新我们现在维护的数组大小

插入

这个是我们的重点,尾插实际上耗费的时间是O(N),为什么呢?举个例子,我们来说明一下,假设在一个5个元素大小的数组上,想插入一个元素到第三个位置上,我们需要怎么做呢?画一个图。

将剩下部分的元素,往后诺一个位置:

空出来的位置,留给我们的插入元素

当然,内存扩容的议题,我们就不再深究了,下面我们重点思考一个问题,一个问题是你看完这些内容中必然有想法的——那就是,如何界定剩下的元素往后挪这个说法?举个例子,我们向第三个位置,也就是上层应用程序提供了psoition=2的参数,我们需要挪动的是整个5个元素的后三个元素,也就是current_size - position个元素大小,想清楚整个事情,问题就很简单了。

void dynamic_array_insert(CCDynamicArray* array, 
        void* data, size_t elem_type, size_t position)
{
    assert(array);
    assert(data);
    assert(elem_type == array->element_size);
    assert(position <= array->current_size);
    if(array->current_size == position){
        return dynamic_array_push_back(array, data, position);
    }
    if(!array->holding_array)
    {
        __reallocate_memory_with_given_size(array, BASE_SZ);
    }
​
    if(array->current_size >= array->capicity){
        __reallocate_memory_with_given_size(array, array->capicity * 2);
    }
​
    memmove(
        pos_pointer(array, position + 1),
        pos_pointer(array, position),
        (array->current_size - position) * array->element_size
    );
    memcpy(pos_pointer(array, position), data, elem_type);
    array->current_size++;
}

memmove是一个这样的API:他将内存的数据搬运到我们的指定的位置。当然需要提供开始的地方和大小。我们向后挪一个位置,然后插入空出来的内存我们的数据!

尾删

整个很简单,我们直接给current_size减掉1,这样就好了,牢记一个事情,current_size维护了我们打算维护的数据结构的大小,换而言之,整个动态数组存在多少有效元素的事情

void dynamic_array_pop_back(CCDynamicArray* array)
{
    if(!array) return;
    if(array->current_size == 0){
        return;
    }
    array->current_size--;
    if(array->current_size < array->capicity / 2){
        __reallocate_memory_with_given_size(array, array->capicity / 2);
    }
}

值得一说的是,这里我们使用了缩一半的策略,在每一次pop的时候,我们发现一半的空间都被浪费了,我们才会去回收。

删除指定位置的元素

下一步,我们删除指定位置的元素。

删除指定地址的元素,只需要让给定位置的元素依次前挪即可。

void dynamic_array_erase(CCDynamicArray* array, size_t where)
{
    if(!array) return;
    if(array->current_size == 0){
        return;
    }
    assert(where < array->current_size);
    if(array->current_size == 1){
        return dynamic_array_pop_back(array);
    }
    memmove(pos_pointer(array, where),
            pos_pointer(array, where+1), 
            (array->current_size - where) * array->element_size);   // 整个大小给出跟插入一个原理
    array->current_size--;
}

访问与删除

整个部分的代码就没啥好说的了,这里给出我们的代码

void dynamic_array_iterate(CCDynamicArray* array, IterationFunc func)
{
    for(size_t i = 0; i < array->current_size; i++)
    {
        func(pos_pointer(array, i));
    }
}
​
void erase_dynamic_array(CCDynamicArray* array)
{
    free(array->holding_array);
    free(array);
}

回过头来思考我们的实现

我们说数组的通用插入和删除的时间复杂度是O(N),我们没有直接表现出来,但是memcpy和memmove的复杂度就是O(N)的,毕竟我们需要一个一个搬运我们的字节,因此,实际上还是O(N)的实现,但是我们访问内容,可以直接对着数据访问。

void* data_access(CCDynamicArray* array, size_t pos){
    return (void*)( (char*)array->holding_array + pos * array->element_size );
}

下表的访问最后会被编译成一条相对寻址的指令,所以,这就是连续内存的好处,从数组访问内容从来只需要知道第几个就可以O(1)常数时间完成,而且值非常的小!

相关文章:

  • 记录一下最近参与实习 外包 社招流程里的一些感悟
  • Java EE(18)——网络原理——应用层HTTP协议
  • 内网渗透-网络分离免杀
  • git --- cherry pick
  • 深入解析C++模板:从基础到高级应用
  • AI Agent开发大全第十五课-零售智能导购Agent的代码实现
  • Nginx RTMP 处理模块 (ngx_rtmp_handler.c) 详细分析
  • 1371. 货币系统-dp背包问题
  • Oracle数据库数据编程SQL<3.4 PL/SQL 自定义函数>
  • HarmonyOSNext_API16_Tabs组件
  • 架构师面试(二十三):负载均衡
  • Spring AI 实现 STDIO和SSE MCP Server
  • C++ STL常用算法之常用查找算法
  • 智能体的学习(一)
  • k8s日志管理
  • Linux网络编程概述
  • ISATAP自动隧道
  • web自动化第一步:安装浏览器和驱动(Chrome和ChromeDriver)
  • 【Java】字符串String类
  • 相似度计算 第33次CCF-CSP计算机软件能力认证
  • ps怎么做电商网站/灰色关键词代发可测试
  • 怎么可以做自己的网站/网络营销最新案例
  • 嘉兴企业做网站/seo就业指导
  • 财佰通突然做网站维护/百度网址大全 官网首页
  • 巴中微信开发 做网站/seo是干嘛的
  • 有哪些官方网站做的比较好/百度关键词排名推广工具