一、数据结构和算法之数组
1、数组的概念
数组是一种线性表数据结构,它用一组连续的内存空间存储一组具有相同类型的数据。
短短的一句话有三个关键词,我们一一来解释
1.1 线性表
1.1.1 什么是线性表
所谓的线性表就是具有相同特征数据元素的有限序列,其所含的元素的个数称为线性表的长度,线性表可以用来表示数组、栈、队列、链表等常见的数据结构。与线性表相反的概念是非线性表,如树、图等,之所以称为非线性表是因为数据之间并不是简单的前后关系。
1.1.2 线性表的分类
从语言实现的角度
顺序表有两种基本实现方式,一体式和分离式,如下:
图a为一体式结构吗,存储表信息的单元与元素存储区以来连续的方式安排在一块存储区里,两部分数据的整体形成一个完整的顺序表对象。这种结构整体性强,易于管理。但是由于数据元素存储区域是表对象的一部分,顺序表创建后,元素存储区就固定了。C和C++都是一体式的结构。
图b为分离式结构,表对象里只保存与整个表有关的信息(即容量和元素个数),实际数据元素存放在另一个独立的元素存储区里,通过链接与基本表对象关联。Java和Python是分离式结构。
从存储的角度
从存储的角度看,可以分为顺序型和链表型。顺序型就是将数据存放在一段连续的内存空间中,此时访问元素的效率非常高,但是删除和增加元素的代价较高,如果此时需要扩容的话只能整体搬迁,也就是重新申请更大的内存然后把原内存上的元素整体搬迁到新的内存上。
而在链表型里,元素之间是通过地址依次连接的(链表型并不需要一块连续的内存空间,它通过“指针”将一组零散的内存块串联起来使用),因此访问的时候就必须从头开始遍历向后找,因此查找的效率低,而删除和增加元素非常方便,这是因为链表型删除和增加元素并不需要保持内存的连续而搬移结点,链表的存储空间本身就不是连续的。如下图我们只需要考虑相邻结点的指针改变,所以对应的时间复杂度为O(1)。链表的常见实现方式有单链表、循环链表、双向链表等。
从访问限制的角度
栈和队列又称为访问受限的线性表,插入和删除受到了限制,只能在固定的位置进行。而Hash比较特殊
用于将数据映射到固定大小的数组中。因此大部分材料中并不将Hash归结到线性表当中,这里为了学习更紧凑,我们放在一起:
从扩容的角度
采用分离式结构的顺序表,若将数据区更换为存储空间更大的区域,则可以在不改变表对象前提下对其数据存储区进行了扩充,所有使用这个表的地方都不必修改。只要程序的运行环境(也就是计算机系统)还有空闲存储,这种表结构就不会因为满了而导致操作无法进行。人们把采用这种技术实现的顺序表称为动态顺序表,因为其容量可以在使用中动态变化的。
动态顺序表的实现通常包括以下几个关键点:
- 初始大小:动态顺序表在创建时需要指定一个初始大小,用于分配一定的存储空间来存储元素。初始大小可以根据实际需求进行设置。
- 扩容机制:当动态顺序表的存储空间不足以容纳新的元素时,需要进行扩容操作。扩容通常涉及到重新分配更大的存储空间,并将原有的元素复制到新的空间中。扩容的策略可以根据具体情况选择,常见的策略包括按倍数扩容(特点:减少了扩充操作的执行次数,但是可能会浪费空间资源,以空间换时间,推荐的方式)、按固定增量扩容(特点:节省空间,但是扩充操作频繁,操作次数多等。
- 缩容机制:当动态顺序表的存储空间过大且没有充分利用时,可以进行缩容操作以节省内存空间。缩容通常涉及到重新分配较小的存储空间,并将原有的元素复制到新的空间中。缩容的策略可以根据具体情况选择,常见的策略包括按倍数缩容、按固定减量缩容等。
- 元素的插入和删除:动态顺序表支持在任意位置插入和删除元素。当插入或删除元素时,需要考虑存储空间的扩容和缩容,以及元素的移动和重新排列。
动态顺序表的优点是可以根据实际需求动态调整存储空间的大小,避免了静态顺序表固定大小的限制。它可以灵活地处理不同规模的数据,并且具有较高的存储空间利用率。然而,动态顺序表的扩容和缩容操作可能会引入一定的时间和空间开销。
具体到每种结构语言中的结构,实现方式千差万别。其中Java基本是扩容时加倍的方式。
1.2 数组存储元素的特征
数组用索引的数字来标识每项数据在数组中的位置,且在大多数编程语言中,索引都是从0开始算的,我们可以根据数组中的索引快速访问数组中的元素
数组中的元素在内存中是连续存储的,且每个元素占用相同大小的内存(相同类型)。正是因为“连续的内存空间”和“相同类型的数据”的限制,数组才有了一个重要的特性:随机访问。当然也是因为这两个限制也使得数组的很多操作变得非常低效,例如在数组中插入或者删除一个数据。为了保证数组中存储数据的连续性,我们需要做大量的数据搬移工作。
数组支持随机访问,根据下标访问元素的时间复杂度为O(1)。
2、数组的基本操作
数组的大部分情况下都是int类型的。所以这里就用int类型来实现这些功能。
2.1 数组创建和初始化
// 创建一维数组
int[] arr = new int[10];
// 初始化数组
int[] arr = new int[] {0, 1, 2, 3, 4, 5};
// 或者
int[] arr = {2, 5, 6, -10}; // 推荐写法
2.2 查找一个元素
数组笔试考察的比较多的就是查找问题,而数组是查找的最佳载体
/**
*
* @param arr 待查询的数组
* @param size 已经存放的元素个数
* @param key 待查找的元素
*/
public static int findByElement(int[] arr, int size, int key) {
for (int i = 0; i < size; i ++) {
if (arr[i] == key) {
return i;
}
}
return -1;
}
}
作业:还有一种很常见的情况,如果数组是递增的,此时查找的时候如果相等或者当前位置元素比目标值更大就停下来了
/**
*
* @param arr 待查询的递增数组
* @param size 已经存放的元素个数
* @param key 待查找的元素
*/
public static int findByElement(int[] arr, int size, int key) {
for (int i = 0; i < size; i++) {
if (arr[i] >= key) {
return i;
}
}
return -1;
}
}
2.3 增加一个元素
将给定的元素插入到有序数组的对应位置上,我们可以先找位置,再将其后面的元素整体右移,最后插入到空位置上。这里需要注意的是必须能保证子啊数组的首部、尾部和中间位置插入都可以成功
/**
*
* @param arr 待查询的数组
* @param size 数组已经存储的元素数量,从1开始编号
* @param element 待插入的元素
* @return
*/
public static int addByElement(int[] arr, int size, int element) {
// 当前数组的长度等于数组实际存储的元素的个数,这意味着当前数组满了
if (arr.length <= size) {
return -1;
}
// 这里为什么要给index默认值为size呢?而不是size-1,因为数组中的元素是从0开始编号的,
// 因为size-1是当前最后一个元素的索引值,所以size就是新增的这个元素的索引值
int index = size;
for (int i = 0; i < size; i++) {
// 将element和数组中已有的元素一个一个比较,因为数组是有序的,
// 所以需要在数组中遍历出第一个比element的元素,假设element=1,arr={2,3,4,5}
// 那么第一个比element大的元素就是2,那么这个时候index的值就是元素2的下标0
// 也就是element的下标是0,此后的元素往后移动
if (element < arr[i]) {
index = i;
break;
}
}
// 元素后移一个位置
for (int i = size; i < index; i--) {
arr[i] = arr[i - 1]; // index下标开始的元素向后移动一个位置
}
// 移动之后插入新的元素
arr[index] = element;
return index;
}
除了上面的方式还可以一开始就从后向前一边移动一边对比查找,找到位置直接插入。从效率上看更好一些,因为只遍历了一次。
/**
*
* @param arr 待查询的数组
* @param size 数组已经存储的元素数量,从1开始编号
* @param element 待插入的元素
* @return
*/
public static int addByElement1(int[] arr, int size, int element) {
if (arr.length <= size) {
return -1;
}
// 用于记录插入元素的位置
int index = 0;
// 从后向前进行遍历,由于element也有可能插入数组末尾,因此i初始化为size
for (int i = size; i > 0; i --) {
// 如果要插入的元素大于等于当前遍历的元素 arr[i - 1],说明找到了插入位置。
if (element >= arr[i - 1]) {
arr[i] = element;
// 更新 index 的值为当前位置 i,表示插入元素的位置。
index = i;
break;
}
// 如果要插入的元素不在当前位置,也就是在数组的中间,将当前位置的元素后移一位。
arr[i] = arr[i - 1];
// i=1时,element比数组中任何一个元素都小
if (i == 1) {
arr[0] = element;
}
}
return index;
}
2.4 删除一个元素
对于删除,不能一边从后向前移动一边查找了,因为元素可能不存在。所以要分为两个步骤,先从最左侧开始查是否存在元素,如果元素存在,则从该位置开始执行删除操作。
/**
* 从数组中删除元素key
* @param arr 数组
* @param size 数组中的元素个数,从1开始
* @param key 删除的目标值
* @return
*/
public static int removeByElement(int[] arr, int size, int key) {
// index 代表匹配到key的下标
int index = -1;
// 判断数组中是否有这个key,有的话就将这个key的下标赋值给index并退出循环,
// 否则index=-1代表未匹配到
for (int i = 0; i < size; i++) {
if (arr[i] == key) {
index = i;
break;
}
}
// index不等于-1代表上文匹配到了key
if (index != -1) {
// 将key所在的位置的后面所有元素往前移动一位
for (int i = index + 1; i < size; i++) {
arr[i - 1] = arr[i];
// 注意将数组中的实际存储元素长度-1
size --;
}
}
return size;
}