数据结构与算法:数组
定义
数组是一种线性表数据结构,它用一段连续内存空间,来存储一组具有相同类型的数据。对于数组的定义有几个关键点:线性表,连续内存空间,相同类型数据。
- 线性表,顾名思义,就是数据像一条线一样串联的数据结构,结构中的每条数据最多只有前后两个方向,链表/栈/队列都属于线性表数据结构。而树/图等数据结构因为数据不是简单的前后关系,所以不属于线性表结构。
- 连续内存空间和相同类型数据则是数组最重要的特征,它帮助我们提升了根据数组下标随机访问数据的速度。可以直接通过寻址公式(arry[i]_address=baseAddress+i*typeSize)访问数组下标所在位置的数据。但有利也有弊,这个特征让数组的插入和删除操作变得非常低效,比如想在数组中插入或者删除一个数据,为了保证数据连续性,就需要做大量的数据搬移工作。
时间复杂度
随机插入数据的时间复杂度:假设要在n长度的数组中第k位插入一条数据,需要将数据插入到第k位置,并将第k位原来的数据向后迁移,这样第k-n位的数据都要向后移动一位。当在数组的末尾插入一条数据时,因为不需要数据移动,所以它的时间复杂度是O(1),而在数组的开头插入数据时,则是所有的数据都需要向后挪动一位,所以它的时间复杂度是O(n),因为我们在每个位置插入数据的概率是一样的,所以平均情况的时间复杂度是(1+2+3+…+n)/n=O(n)。
删除数据与插入数据的操作类似,所以它们的时间复杂度相同,但是在一些特殊场景下,我们并不一定非要追求数组中数据的连续性,当要删除数据时,我们仅将要删除的数据标记为已删除,但不挪动位置,当数组内存空间不足时,我们再触发一次删除操作(将标记已删除数据删除),这样就大大减少了删除操作导致的数据搬移(JVM 标记清除垃圾回收算法的核心思想)。
查询数据的时间复杂度:分为两种情况,第一种是根据数组下标查询数据,因为我们根据寻址公式可以计算出数组下标数据的内存地址,所以时间复杂度为O(1)。第二种情况是根据数据内容查询,这种情况下,如果数组是有序的,通过二分查找时间复杂度是O(logn),如果数组是无序的,只能通过遍历,那么查询的时间复杂度就是O(n)。
访问越界
在C语言中,只要不是访问受限的内存,所有的内存空间都是可以自由访问的,如果数组下标越界,就会访问到不属于数组的内存空间上的数据,这样可能会导致严重的系统错误。而在高级语言中,当数组下标越界时,也会在运行时抛出数组下标访问越界的异常,可能使程序进入不可用的状态。所以我们需要格外注意可能造成数组下标访问越界的场景,例如for循环。
反转数组
1
2
3
4
5
6
7
8
9
10
func reverse(a []int) []int {
if len(a) == 0 || len(a) == 1 {
return a
}
for i := 0; i < len(a)/2; i++ {
a[i], a[len(a)-i-1] = a[len(a)-i-1], a[i]
}
return a
}
变长数组
我们知道,数组的长度在声明后是无法修改的,但可以通过复制的方式实现数组的扩容,从而实现一个变长数组。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 全局变量,大小为10的数组array,长度len,下标i。
int array[] = new int[10];
int len = 10;
int i = 0;
// 往数组中添加一个元素
void add(int element) {
if (i >= len) { // 数组空间不够了
// 重新申请一个2倍大小的数组空间
int new_array[] = new int[len*2];
// 把原来array数组中的数据依次copy到new_array
for (int j = 0; j < len; ++j) {
new_array[j] = array[j];
}
// new_array复制给array,array现在大小就是2倍len了
array = new_array;
len = 2 * len;
}
// 将element放到下标为i的位置,下标i加一
array[i] = element;
++i;
}