1. 概述
1.1 数组简介
数组是所有人闭着眼都知道的基本数据结构。在LeetCode中官方统计是1000多道与数组有关。不过,数组是很多高级算法的载体,例如排列组合、集合、动态规划等等,因此题目的难度跨度非常大,这里先学习一些基础的。
算法要找到根,要看透本质,那本质到底是啥呢?其实就是数组的增删改查,其实任何数据结构的基本操作都是这几个,大部分题目都是基于这几个操作的拓展,因此将其搞清楚就掌握一半了。
增删改查看似简单,但是面试的时候却非常危险,经常会翻车。例如在任一位置增加元素,或者在已排序数组中插入一个元素时,可能在首位置插入错误,可能尾部插入错误,可能首位置好了,尾部又不行了等等,删除也是类似情况。涉及到数组少不了要考虑好元素存在哪里,所以在写很多高级算法问题也常常因为这些问题拿不准而出错。特别是远程面试时,明明就要成功了,就是不对,想S的心都有。所以准确写出能适用于各种场景的添加方法和删除目标元素的方法本身就是非常重要的问题。
另外,元素添加可以扩展出数组合并的系列问题和进制系列问题,元素删除可以扩展出重复项处理的系列问题,元素的查找可以引出奇偶、大小(就是排序)等的系列问题等等,而这些问题就是面试喜欢考的题目。
2. 数组基础
2.1 数组的概念
数组是线性表最基本的结构,特点是元素是一个紧密在一起的序列,相互之间不需要记录彼此的关系就能访问,例如月份、星座等。
数组会用一些名为索引的数字来标识每项数据在数组中的位置,且在大多数编程语言中,索引是从0算起的。我们可以根据数组中的索引,快速访问数组中的元素。
数组有两个需要注意的点,一个是从0开始记录,也就是第一个存元素的位置是a[0],最后一个是a[length-1]。
其次,数组中的元素在内存中是连续存储的,且每个元素占用相同大小的内存。
另外数组空间不一定是满的,100的空间可能只用了10个位置,所以要注意数据长度size和数组长度length是不一样的。在写代码的时候必须要判断并处理size和length之间的关系,否则你的代码就是不合格的。
2.2 数组基本操作
在面试中,数组大部分情况下都是int类型的,所以我们就用int类型来实现这些基本功能。
2.2.1 数组创建和初始化
创建一维数组的方法如下:
int[] arr = new int[10];
初始化数组最基本的方式是循环赋值:
for(int i = 0; i < arr.length; i++) {
arr[i] = i;
}
但是这种方式在面试题中一般不行,因为很多题目会给定若干测试数组让你都能测试通过,例如给你两个数组 [0,1,2,3,5,6,8] 和 [1,4,5,6,7,9,10] 。那这时候该如何初始化呢?此时显然不能用循环赋值了,更多的是采用下面这种方式:
int[] arr = new int[]{1,2,3,5,6,7,8};
// 这么写也行:
int[] nums = {1, 4, 2, 8, 0, -10};
如果要测试第二组数据,直接将其复制替换就行了。这种初始化方式很简单,在面试时特别实用,但是务必记住写法,否则面试时可能慌了或者忘了,老写不对,这会让你无比着急。因此我们练习算法的一个目标就是熟悉这些基本问题,要避免阴沟里翻船。
另外要注意上面在创建数组时大小就是元素的数量,是无法再插入元素的,如果遇到相关场景就考虑如何调整了。
2.2.2 查找一个元素
为什么数组的题目特别多呢,因为很多题目本质就是查找问题,而数组是查找的最佳载体。很多复杂的算法都是为了提高查找效率的,例如二分查找、二叉树、红黑树、B+树、Hash和堆等等。另一方面很多算法问题本质上都是查找问题,例如滑动窗口问题、回溯问题、动态规划问题等等等都是在寻找那个目标结果。
查找的实现策略也很多,例如先排序再查找等等。这里只看两个简单的情况:根据索引查找和根据元素线性查找。
根据索引位置查找的实现为:
int[] arr = new int[10]
/**
* @param arr
* @param index 要查找的位置
* @return
*/
public static int findByIndex(int[] arr,int size, int index) {
if (index < 0 || index > size-1) {
throw new IllegalArgumentException("Add failed. Require index >= 0 and index < size.");
}
return arr[index];
}
更多的时候是需要查找目标元素所在的位置:
/**
* @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.2.3 增加一个元素
增加和删除元素是数组最基本的操作,看别人的代码非常容易,但是自己手写的时候经常bug满天飞。能准确处理游标和边界等情况是数组算法题最基础重要的问题,没有之一!
增加一个元素也有两种常见的情况:在指定位置添加;或者给出一个有序数组和一个元素让你在正确的位置添加。
如果是给出索引的,由于在数组中添加元素大部分情况下需要移动元素,所以干脆就从后向前一边遍历,一边移动,找到对应位置之后直接赋值就行了。另外一定要注意检测是否越界了,这个是数组的基本功,如果考虑不到就直接玩完。
在指定位置添加元素
这里需要知道数组的引用,数组中已存放元素数量,插入位置和待插入的元素四个参数。有些材料会自定义一个对象来将多个入参封装在一起,但是为了不制造障碍,我们就用最基本的方式全部手写。
/**
* @param arr 数组
* @param size 已经存放元素的数量
* @param index 索引值,从0开始
* @param key 插入的元素
*/
public static void addByIndex(int[] arr, int size, int index, int key) {
if (size >= arr.length - 1) {
throw new IllegalArgumentException("Array is full.");
}
if (index < 0 || index >= arr.length - 1) {
throw new IllegalArgumentException("Add failed.");
}
for (int i = size - 1; i >= index; i--) {
arr[i + 1] = arr[i];
}
arr[index] = key;
size++;
}
将给定的元素插入到有序数组的对应位置中
由于数组的特性,我们少不了查找和移动元素,这就有两种处理方式:一种是先查找位置,再移动数据并插入。另一种是从后向前一边移动一边查找,找到位置直接插入。从效率上看后者是要好一些,但是前一种面试时更容易想到,所以我们这里手写前一种。
在中间位置插入非常容易,貌似一个for循环找一下就搞定了,但是这个题如果面试直接让运行调试,我相信大部分还是会挂,为什么呢?因为必须考虑数组头和数组尾部插入时的情况,下面的实现请读者先不看,自己先自行实现一下addByElementSequence()方法试试。
测试方法:
private static void addTest() {
//通过元素有序插入
int[] arr = new int[20];
arr[0] = 2;
arr[1] = 5;
arr[2] = 6;
arr[3] = 7;
//中间位置插入
addByElementSequence(arr, 5, 7);
printList("通过元素顺序插入", arr, 6);
//尾部插入
addByElementSequence(arr, 6, 9);
printList("通过元素顺序,尾部插入", arr, 7);
//测试元素有序并且在表头插入
addByElementSequence(arr, 5, 0);
printList("通过元素顺序,尾部插入", arr, 8);
}
为了便于遍历输出数组,我们先定义一个方法:
public static void printList(String msg, int[] arr, int size) {
System.out.println("\n通过" + msg + "打印");
for (int i = 0; i < size; i++) {
System.out.print(arr[i] + " ");
}
}
如果你插入的方法正确执行,打印的结果应该为:
通过通过元素顺序插入打印
3 4 6 7 8
通过通过元素顺序,尾部插入打印
3 4 6 7 8 9
通过通过元素顺序,尾部插入打印
0 3 4 6 7 8 9
这个例子看上去很简单是不?你一定要亲自写一个试试,因为在写的过程中你会发现前中后都能成功执行还是要费工夫的。
/**
* @param arr 数组已经存储的元素数量
* @param size 数组已经存储的元素数量
* @param element 待插入的元素
* @return
*/
public static int addByElementSequence(int[] arr, int size, int element) {
if (size >= arr.length)
throw new IllegalArgumentException("添加失败,数组已满");
int index = size;
// 找到新元素的插入位置
for (int i = 0; i < size; i++) {
if (element < arr[i]) {
index = i;
break;
}
}
// 元素后移
for (int j = size; j > index; j--) {
arr[j] = arr[j - 1]; // index下标开始的元素后移一个位置
}
arr[index] = element; // 插入数据
return index;
}
2.2.4 删除一个元素
删除同样存在根据索引位置直接删或者先查找元素位置再删除两种情况。
根据索引位置
/**
* @param arr
* @param size 数组已经存储的元素数量
* @param index 删除位置
* @return
*/
public static int removeByIndex(int[] arr, int size, int index) {
if (index < 0 || index >= size)
throw new IllegalArgumentException("Remove failed. Index is illegal.");
int ret = arr[index];
for (int i = index + 1; i < size; i++)
arr[i - 1] = arr[i];
size--;
return ret;
}
先查找元素再删除元素
这里需要注意的是不能一边从后向前移动一边查找,因为元素可能不存在。所以要分为两个步骤,先查是否存在元素,存在再删除。可以直接复用前面的代码:
/**
* 从数组中删除元素。
* @param arr
* @param size 数组
* @param key
*/
public static void removeElement(int[] arr, int size, int key) {
int index = findByElement(arr, size, key);
if (index != -1)
remove(arr, size, index);
}
但是考虑到我们面试时一般只会直接写一个方法,所以我们合并在一起可以这么写:
/**
* 从数组中删除元素
* @param arr
* @param size 数组
* @param key
*/
public static void removeByElement(int[] arr, int size, int key) {
int index = -1;
for (int i = 0; i < size; i++) {
if (arr[i] == key) {
index = i;
break;
}
}
if (index != -1) {
for (int i = index + 1; i < size; i++)
arr[i - 1] = arr[i];
size--;
}
}
最后提供一个测试类:
private static void deleteTest() {
int[] arr = new int[]{2, 3, 4, 9, 10, 11, 12};
// 根据索引位置删除
removeByIndex(arr, 7, 2);
printList("根据索引删除", arr, 6);
removeByElement(arr, 6, 2);
printList("根据索引删除", arr, 5);
}
3. java中的数组
你平时写代码的时候是否注意过,jdk里竟然有三个数组相关的类:Array、Arrays和ArrayList。这三个看上去都是数组结构的类姓,但是有啥区别呢?
我们平时大概不会注意这个高级方法,主要是因为其实对我们提供了reverse()、sort()等方法,在解决复杂问题的时候可以直接用,不必自己去解决了大部分问题,但是reverse等基础问题还要写,最后代码又多又乱。简单的代码也能让面试官喜欢。
3.1 Array与Arrays类的区别
在java中的这两个类有点奇怪,它们不是在一个包里的,Arrays在java.util包下,这个包是我们经常使用的各类基础工具。而Array不是典型数组类,而且也不在java.util包下,而是在java.lang.reflect包下。这两个到底啥关系呢?
既然是在reflect包下,那一定是和反射有关系了,反射要获得什么呢?动态创建和访问的方法,所以Array就是为了解决在静态编译的情况下访问Java数组的方法而提供的一个类,其主要方法如newInstance、getByte等都是为了方便反射操作而提供的。而Arrays的路径是java.util.Arrays,有自己定义的public方法来操作数组(比如排序和搜索等)。此类还包含一个允许将数组作为列表来查看的静态工厂。除非特别注明,否则如果指定数组引用为null,则此类中的方法都会抛出NullPointerException。
结论就是两者其实没有半毛钱关系,我们用到数组的时候放心的使用Arrays就行了。
而Arrays类里有几个特别重要的方法:sort()和binarySearch(),在比较复杂的算法题目中我们可以直接用的,关键时候甚至能救你一命。
3.2 Arrays与ArrayList类的区别
ArrayList 也在java.util下,是 Java 集合框架中比较常用的数据结构。我们先看其定义:
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
}
通过这个定义我们就看到了,其功能远比Arrays强大。它继承自 AbstractList,实现了 List 接口。底层基于数组实现容量大小动态变化,允许 null 的存在。同时还实现了 RandomAccess、Cloneable、Serializable 接口,所以 ArrayList 是支持快速访问、复制、序列化的。因为其功能强大,特别是支持动态扩容特性,这个类在我们的业务代码里用的最多。
ArrayList的get方法是基于迭代器来做的,其设计也非常精妙,这个可以在后面梳理递归和迭代器的时候专门分析这个问题。
现在先轻松一下,看看Arrays的使用方法吧。
3.3 Arrays的使用
Arrays类提供了几个非常有用的方法。如果熟练掌握的话,对我们写出简洁优美的算法大有裨益。
Arrays提供的方法都是静态的,比较重要的有:
void Arrays.sort()
void Array.sort(Object[] array)
功能是对数组按照升序排序,在处理复杂算法的时候可以直接使用,这就减少了很多麻烦。
int[] nums = {2,5,0,4,6,-10};
Arrays.sort(nums);
输出结果: -10 0 2 4 5 6
这个排序还会对数组元素进行指定范围排序:
Arrays.sort(Object[] array, int from, int to)
功能:对数组元素指定范围进行排序(排序范围是从元素下标为from, 到下标为to-1的元素进行排序)
int[] nums = {2,5,0,4,1,-10};
//对前四位元素进行排序
Arrays.sort(nums, 0, 4);
输出结果:0 2 4 5 1 -10
Arrays.fill(Object[] array, Object object)
功能:可以为数组元素填充相同的值
int[] nums = {2,5,0,4,1,-10};
Arrays.fill(nums, 1);
for(int i : nums)
System.out.print(i + " ");
执行结果: 1 1 1 1 1 1
这里也可以对数组的部分元素填充一个值,从起始位置到结束位置,取头不取尾
Arrays.fill(Object[] array, int from, int to, Object object)
int[] nums = {2,5,0,4,1,-10};
//对数组元素下标2到4的元素赋值为3
Arrays.fill(nums,2,5,3);
执行结果:2 5 3 3 3 -10
Arrays.toString(Object[] array)
功能:返回数组的字符串形式
int[] nums = {2,5,0,4,1,-10};
System.out.println(Arrays.toString(nums));
输出结果:[2, 5, 0, 4, 1, -10]
Arrays.deepToString(Object arrays)
功能:返回多维数组的字符串形式
int[][] nums = {{1,2},{3,4}};
System.out.println(Arrays.deepToString(nums));
输出结果:[[1, 2], [3, 4]]