不定维数组的 Slice 与 Padding 算法
Numpy 包里面的数组是一种不定维数组,使用它的时候,我在思考它的 Slice 与 Pad 算法是如何。因为在不定维的情况下 Slice 与 Pad 的算法是比较难实现,经过不断的尝试,最终把算法实现,在此分享记录。
00 Slice 与 Padding
首先,解析一下 Slice 与 Padding 是什么。Slice 意思是分片,在数组上的操作便是截取一部分。而 Padding**相反,意为扩充数组。
可以想象,在一维、二维甚至三维的数组,Slice 与 Padding 都可以很容易实现。Slice 于 Padding 本质是对数组中的元素进行定位,确定要访问的元素的边界与数量。若维数是不确定,那么 Slice 与 Padding 起来就有那么一点麻烦。因为不定数组的维数是不确定的,在做slice 或者 padding 的时候无法通过下标去做数据读取。这就需要通过复杂寻址计算。
01 内存只有一维
在访问多维数组的时候,通常使用数组下标会比较人性化。例如访问二维数组第一行,第三个单元,那么我们可以使用 arr[0][2] 来访问。
其实无论数组表现为多少维,在内存中存储的只有一维。每个数据按照顺序一个个地存放在内存中。通过数组首地址,结合数组下标便可计算得到准确数据的地址,从而实现数据存储。举个例子,一个 5 x 3 的数组,arr[5][3]。这个数组怎么理解他的结构呢?刚刚学编程的人眼里是这样子:
但它其实是长着个样子:
任何时候数组都只有一维。
那么,在我们要访问元素的时候如何准确的地位到相关的元素呢。当然是通过计算数组的下标得到地址。例如上面的定义的数组arr[5][3], 在访问数组[2][1]的元素时,他的计算公式是:
p
o
s
=
a
r
r
+
2
⋆
3
+
1
pos = arr + 2 \star 3 + 1
pos=arr+2⋆3+1
其中 arr 是数组的首地址,2 是第 2 行,为什么要乘以 3 呢,因为每行有 3个元素,最后加1就是最后寻址到第 2 行后,第 1 个元素的偏移量。可以想象数组 arr[5][3],是由 5 个大盒子,然后装着 3 个小盒子组成。那么 [2][1] b便是要找第 2 个大盒子中的第 1 个小盒子。
02 Slice 与 Padding 算法实现
了解数组的寻址方式后, 然后讨论Slice 与 Padding 的算法实现。先来说说 Slice。实现 Slice 功能的根本是要精确数据定位,因为只有精确地定位到元素,才能准确地将数据截取。上一节说到,多维数组可以比作箱子嵌套,那么也就可以将其比作树型结构,最后一维是叶子节点,以上都是父节点,那么对于元素的定位便可以用递归的方式。
首先考虑最简单的情况,便是一维数组的 Slice, 要截取一维数组中的某一段数据,需要掌握两个条件,1、是数组的首地址,2、是截取数据的偏移量。如图:
于是 在对多维数组进行 Slice 的时候,1、是要找到每个维度入口的地址。2、是要确定每个维度操作的偏移量
举例,现有一个 6 * 5 * 4 *3 的整形数组,然后我要进行截取的是 “1:3, 2:4, 2:3, 1:2”, 这里我们约定使用左闭右开 “[ )" 的方式约定截取范围。现在面对的难点:1、此数组不定维的数组,于是我们不能强求下标的方式去寻址元素。2、在物理上此数组上的元素就是在一维的空间里面排列,所以需要将元素的坐标定位信息从 1 维转换到 X 维,或者将 X 维的坐标转换到 1 维。
再来看看我们的 Slice 参数:“1:3, 2:4, 2:3, 1:2”, 在第一维度,需要截取 1 ≤ x < 3 1 \le x \lt 3 1≤x<3个单位,这个怎么定位呢,首先看数组从 0 开始,截取从 1 开始,那么我么第一个维度就要 1 个偏移量。那么第一个维度的一个单位是多少呢?
按照上一节的计算方法一个单位的大小应该是:
Uszie = 5 * 4 * 3 * sizeof(float);
那么偏移一个单位就是 1 * Usize。哪个位置开始偏移呢?便是从数组首地址开始偏移。假设数组首地址是 Base_arr。
那么第一个维度截取的开始地址便是:
addr = Base_arr + 1 * Usize
那么同理我们再看第二个维度的截取地址,在第二个维度,需要截取 2 ≤ x < 4 2\le x \lt4 2≤x<4 它从第三个单位开始。在第二个维度每个单元大小便是:
Usize = 4 * 3 * sizeof(float);
那么偏移两个单位的就是 2 * Usize。那么重点来了,从哪个位置开始偏移呢?应该从上一级维度,所截取每个单元的开始地址。就是在第一维度中截取第 1, 第 2 个单元(1:3),再从这第1,第2个单元中截取第二维度的第2,第3个单元(2:4),然后再从第2维度的第2,第3个单元找截取第三个维度的第2个单元(2:3), 最后从第三个维度中截取第四维度的第1单元(1:2)。也就是本维度截取基地址是上一维度截取单元的首地址。
03 看代码
首先假定一个不定维的数组设计如下:
typedef struct {
int axisn;
int* dimens;
float* elemes;
} XArray_t;
其中 axisn 存储不定数组的维度数量, dimens 指针指向一个整型数组,此数组存储不定数组的维度信息,由于是不定维数组,所以维度信息数组的长度也是不定的。elems 指向一个内存池,池中便是存储数组的真实数据。
设计一下它初始化函数如下:
XArray_t* create_array(int axisn, ...)
{
va_list valist;
va_start(valist, axisn);
XArray_t* arr = malloc(sizeof(XArray_t):
arr->axisn = axisn;
int elem_number = 1;
// 申请存放维度信息的数组
arr->dimens= malloc(axisn * sizeof(int));
// 从不定参数中获取维度信息
for (int i=0; i<axisn; ++i){
((int*)(arr->handle[0]))[i] = va_arg(valist, int);
elem_number *= ((int*)(arr->handle[0]))[i] ;
}
// 假设它是一个浮点型的数组
arr->elems = malloc( elem_number * sizeof (float));
return arr;
}
// 那么生成一个以上的数组可以如下调用:
XArray_t* arr = create_array(4, 6, 5, 4,3);
设计好模型后,我们来实现 Slice 的算法:
// 应该是多个循环嵌套,直到最后一个维度为止:
for (...) {
for (...) {
for (...) {
....
...
...
}
}
}
不对,不定维数组,不知道应该嵌套多少层的 for 循环。所以搞错了,
重来 !!!
设计好模型后我们来设计 Slice 的算法。
XArray_t* array_slice(XArray_t* arr, ...)
{
XArray_t* slice_arr = malloc(sizeof(XArray_t));
va_list valist;
va_start(valist, arr);
// 每个维度应该有两个截取信息,所以截取信息改为维度数量的两倍。
int slice_info[arr->axisn * 2];
for (int i=0; i<arr->axisn * 2; ++i) {
slice_info[i] = va_arg(valist, int);
}
// 根据 slice_info 测算出新数组的维度
slice_arr->axisn = arr->axisn;
slice_arr->dimens = malloc (slice_arr->axisn * sizeof(int);
int elems_size = 1;
// 从截取的信息中获取新数组的维度信息
for (int j=0; j<slice_arr->axisn; j+=2){
int diff = slice_info[j+1] - slice_info[j];
slice_arr->dimens[j] = diff;
elemes_size *= diff;
}
// 至此新的数组建立完成
slice_arr->elems = malloc (elems_size * sizeof(float));
// 核心函数,把要截取额数据从 arr copy 到 slice_arr 中。
void* dest = slice_arr->elems;
do_slicing( &dest, arr->elems, slice_info, 0);
return slice_arr;
}
核心函数 do_slicing 实现如下:
void do_slice(void** dest, void* base, int* slice_info, int curr_dimen)
{
//
int axisn = src->axisn;
// 计算当前维度下,每一个单元的大小
int Usize = 1;
for (int i = curr_dimen+1; i < axisn; ++i)
{
Usize *= src->dimens[i];
}
Usize *= sizeof(float);
int slice_start = slice_info[curr_dimen];
int slice_end = slice_info[curr_dimen+1];
int diff = slice_end - slice_start;
if (curr_dimens == axisn - 1) {
// 最后一个维度,是存放真是元素的。
// 当来到最后一个维度的时候,便把 src 元素拷贝到 dest
// 指向的内存,并且该 dest 内存指针也要往前递进,
// 配合下一次的数据拷贝。
offset = (char*)base;
offset += slice_start * Usize;
memcpy(*dest, offset, diff * sizeof(float));
*dest += diff * sizeof(float);
} else {
// 如果发现当前维度并不是 最后一个维度
// 那么便计算出当前维度被截取的单位的地址,
// 用其地址作为下一维度的基础地址。
char* offset;
for (int j=slice_start; j <slice_end; ++j) {
offset = (char*)base;
offset += j * Usize;
do_slice(dest, offset, slice_info, curr_dimen + 1);
}
}
}
以上实现的逻辑是递归算法,主要是直到每个维度需要截取的单元的首地址,用于作为下一个维度截取时参考坐标。Padding 实现和 Slice 类似,只不过在填充新数组的数据的时候我们必须确定好边界。那些位置是填入 Padding 的值,那些是需要从原数组拷贝。
04 看效果
1 本文slice算法效果:
2 Numpy 的 Slice:
3 本文的 Padding 算法
4 Numpy 的 pad 算法
结果一致, 想要了解更多无聊的算法,欢迎关注,评论、转发、点赞!
源码:https://github.com/zuweie/boring-code/blob/main/src/ultra_array/x_array.c
完