在面试中,考察数组的题目一般在思维上都不难,主要是考察对代码的掌控能力。
数组是存放在连续内存空间上的相同类型数据的集合。
注意:
数组下标都是从0开始的。
数组内存空间的地址是连续的
因为数组的在内存空间的地址是连续的,所以我们在删除或者增添元素的时候,就难免要移动其他元素的地址。所以查询操作多、增删操作少的地方可以使用数组。
例题:力扣链接
定义查找的范围[left,right]
,初始查找范围是整个数组nums
。每次取查找范围的中点mid
,比较数组中点值nums[mid]
和目标值target
的大小,如果相等则mid
即为要寻找的下标,如果不相等则根据nums[mid]
和target
的大小关系将查找范围缩小一半。如此往复,直至找到target
。
使用二分查找法的前提是数组为有序数组,且数组中无重复元素。
二分查找法的难点:区间定义不好理解,例如:到底是 while(left < right)
还是 while(left <= right)
,到底是right = middle
呢,还是要right = middle - 1
呢?
写二分法,区间的定义一般为两种,左闭右闭即[left, right],或者左闭右开即[left, right)。
第一种方法(左闭右闭)
定义target
是在一个在左闭右闭的区间里,也就是[left, right]
。
此时有如下两点:
while (left <= right)
要使用<=
,因为left == right
是有意义的,所以使用<=
if (nums[mid] > target)
中right
要赋值为mid - 1
,因为当前这个nums[mid]
一定不是target
,那么接下来要查找的左区间结束下标位置就是mid - 1
。同理,if (nums[mid] < target)
中left
要赋值为mid + 1
。
代码如下:
class Solution { public int search(int[] nums, int target) { int left = 0, right = nums.length - 1; // 定义target在左闭右闭的区间里,[left, right] while (left <= right) { // 当left==right,区间[left, right]依然有效,所以用 <= int mid = (right - left) / 2 + left; // 防止溢出 等同于(left + right)/2 int num = nums[mid]; if (num == target) { return mid; // 数组中找到目标值,直接返回下标 } else if (num > target) { right = mid - 1; // target 在左区间,所以[left, middle - 1] } else { left = mid + 1; // target 在右区间,所以[middle + 1, right] } } return -1; // 未找到目标值 } }
第二种方法(左闭右开)
定义target
是在一个在左闭右开的区间里,也就是[left, right)
。
此时有如下两点:
while (left < right)
,这里使用<
,因为left == right
在区间[left, right)
是没有意义的
if (nums[mid] > target)
中right
更新为mid
,因为当前nums[mid]
不等于target
,去左区间继续寻找,而寻找区间是左闭右开区间,所以right
更新为mid
,即:下一个查询区间不会去比较nums[mid]
。而由于区间是左闭右开的,因此if (nums[mid] < target)
中的left
在下一个查询区间仍然会被查询到,所以left
要赋值为mid + 1
。
代码如下:
class Solution { public int search(int[] nums, int target) { int left = 0, right = nums.length; // 定义target在左闭右开的区间里,即:[left, right) while (left < right) { // 因为left == right的时候,在[left, right)是无效的空间,所以使 用 < int mid = (right - left) / 2 + left; // 防止溢出 等同于(left + right)/2 int num = nums[mid]; if (num == target) { return mid; // 数组中找到目标值,直接返回下标 } else if (num > target) { right = mid; // target 在左区间,在[left, middle)中 } else { left = mid + 1; // target 在右区间,在[middle + 1, right)中 } } return -1; // 未找到目标值 } }
例题:力扣链接
数组的元素在内存地址中是连续的,不能单独删除数组中的某个元素,只能覆盖。
双指针法(快慢指针法): 通过一个快指针和慢指针在一个for循环下完成两个for循环的工作。
原理:由于题目要求删除数组中等于val
的元素,因此输出数组的长度一定小于等于输入数组的长度,我们可以把输出的数组直接写在输入数组上。可以使用双指针:右指针right
指向当前将要处理的元素,左指针left
指向下一个将要赋值的位置。
如果右指针指向的元素不等于val
,它一定是输出数组的一个元素,我们就将右指针指向的元素复制到左指针位置,然后将左右指针同时右移;
如果右指针指向的元素等于val
,它不能在输出数组里,此时左指针不动,右指针右移一位。
整个过程保持不变的性质是:区间[0,left)
中的元素都不等于val
。当左右指针遍历完输入数组以后,left
的值就是输出数组的长度。
这样的算法在最坏情况下(输入数组中没有元素等于val
),左右指针各遍历了数组一次。
class Solution { public int removeElement(int[] nums, int val) { int n = nums.length; int left = 0; for (int right = 0; right < n; right++) { if (nums[right] != val) { nums[left] = nums[right]; left++; } } return left; } }
例题:力扣链接
每个数平方之后,排个序
class Solution { public int[] sortedSquares(int[] nums) { int[] ans = new int[nums.length]; for (int i = 0; i < nums.length; ++i) { ans[i] = nums[i] * nums[i]; } Arrays.sort(ans); //快速排序 return ans; } }
原理:数组其实是有序的, 只不过负数平方之后可能成为最大数了。那么数组平方的最大值就在数组的两端,不是最左边就是最右边,不可能是中间。此时可以考虑双指针法了,i
指向起始位置,j
指向终止位置。
定义一个新数组result
,和nums
数组一样的大小,让k
指向result
数组终止位置。
如果nums[i] * nums[i] < nums[j] * nums[j]
那么result[k--] = nums[j] * nums[j];
。
如果nums[i] * nums[i] >= nums[j] * nums[j]
那么result[k--] = nums[i] * nums[i];
。
如动画所示:
class Solution { public int[] sortedSquares(int[] nums) { int n = nums.length; int k = n-1; int[] result = new int[n]; for(int i=0,j=n-1;i<=j;){ if(nums[i]*nums[i]>=nums[j]*nums[j]){ result[k--] = nums[i]*nums[i]; i++; }else{ result[k--] = nums[j]*nums[j]; j--; } } return result; } }
例题:力扣链接
暴力法是最直观的方法。初始化子数组的最小长度为无穷大,枚举数组nums
中的每个下标作为子数组的开始下标,对于每个开始下标i
,需要找到大于或等于i
的最小下标j
,使得从nums[i]
到nums[j]
的元素和大于或等于s
,并更新子数组的最小长度(此时子数组的长度是j−i+1
)。
class Solution { public int minSubArrayLen(int s, int[] nums) { int n = nums.length; if (n == 0) { return 0; } int ans = Integer.MAX_VALUE; // 最终的结果 for (int i = 0; i < n; i++) { // 设置子序列起点为i int sum = 0; // 子序列的数值之和 for (int j = i; j < n; j++) { // 设置子序列终止位置为j sum += nums[j]; if (sum >= s) { // 一旦发现子序列和超过了s,更新ans ans = Math.min(ans, j - i + 1); break; // 因为是找符合条件最短的子序列,所以一旦符合条件就break } } } // 如果result没有被赋值的话,就返回0,说明没有符合条件的子序列 return ans == Integer.MAX_VALUE ? 0 : ans; } }
所谓滑动窗口,就是不断的调节子序列的起始位置和终止位置,从而得出我们要想的结果。
滑动窗口也可以理解为双指针法的一种。
这里还是以题目中的示例来举例,s=7, 数组是 2,3,1,2,4,3,来看一下查找的过程:
最后找到 4,3 是最短距离。
实现滑动窗口,主要确定如下三点:
窗口内是什么?
如何移动窗口的起始位置?
如何移动窗口的结束位置?
滑动窗口的精妙之处在于根据当前子序列和大小的情况,不断调节子序列的起始位置。从而将O(n^2)
的暴力解法降为O(n)
。
原理:定义两个指针left
和right
分别表示子数组(滑动窗口窗口)的开始位置和结束位置,维护变量sum
存储子数组中的元素和(即从nums[left]
到nums[right]
的元素和)。
初始状态下,left
和right
都指向下标0
,sum
的值为0
。
每一轮迭代,将nums[right]
加到sum
,如果sum≥s
,则更新子数组的最小长度(此时子数组的长度是right-left+1
),然后将nums[left]
从sum
中减去并将left
右移,直到sum
,在此过程中同样更新子数组的最小长度。在每一轮迭代的最后,将right
右移。
class Solution { // 滑动窗口 public int minSubArrayLen(int s, int[] nums) { int left = 0; // 滑动窗口起始位置 int sum = 0; // 滑动窗口数值之和 int result = Integer.MAX_VALUE; for (int right = 0; right < nums.length; right++) { sum += nums[right]; // 注意这里使用while,每次更新left(起始位置),并不断比较子序列是否符合条件 while (sum >= s) { result = Math.min(result, right - left + 1); // 这里体现出滑动窗口的精髓之处,不断变更left(子序列的起始位置) sum -= nums[left++]; } } // 如果result没有被赋值的话,就返回0,说明没有符合条件的子序列 return result == Integer.MAX_VALUE ? 0 : result; } }
例题:力扣链接
这道题目可以说在面试中出现频率较高的题目,本题并不涉及到什么算法,就是模拟过程,但却十分考察对代码的掌控能力。
第二节中的二分查找法坚持了循环不变量原则,本体依然要坚持循环不变量原则。
模拟顺时针画矩阵的过程:
填充上行从左到右
填充右列从上到下
填充下行从右到左
填充左列从下到上
这里一圈下来,我们要画每四条边,这四条边怎么画,每画一条边都要坚持一致的左闭右开,或者左开右闭的原则,这样这一圈才能按照统一的规则画下来。
如图所示:
这里每一种颜色,代表一条边,我们遍历的长度,可以看出每一个拐角处的处理规则,拐角处让给新的一条边来继续画。这也是坚持了每条边左闭右开的原则。
class Solution { public int[][] generateMatrix(int n) { int[][] res = new int[n][n]; // 循环次数 int loop = n / 2; // 定义每次循环起始位置 int startX = 0; int startY = 0; // 定义偏移量 int offset = 1; // 定义填充数字 int count = 1; // 定义中间位置 int mid = n / 2; while (loop > 0) { int i = startX; int j = startY; // 模拟上侧从左到右 for (; jstartY; j--) { res[i][j] = count++; } // 模拟左侧从下到上 for (; i > startX; i--) { res[i][j] = count++; } loop--; startX += 1; startY += 1; offset += 2; } if (n % 2 == 1) { res[mid][mid] = count; } return res; } }
数组是存放在连续内存空间上的相同类型数据的集合。可以方便的通过下标索引的方式获取到下标下对应的数据。
关于数组需要两点注意的是:
数组下标都是从0开始的。
数组内存空间的地址是连续的
正是因为数组的在内存空间的地址是连续的,所以数组的元素是不能删的,只能覆盖,并且在删除或者增添元素的时候,难免要移动其他元素的地址。
1)二分法
二分法要坚持循环不变量原则,只有在循环中坚持对区间的定义,才能清楚的把握循环中的各种细节。
二分法是算法面试中的常考题。
2)双指针法
双指针法(快慢指针法):通过一个快指针和慢指针在一个for循环下完成两个for循环的工作。
双指针法(快慢指针法)在数组和链表的操作中是非常常见的,很多考察数组和链表操作的面试题,都使用双指针法。
3)滑动窗口
主要要理解滑动窗口是如何移动窗口起始位置,达到动态更新窗口大小的,从而得出长度最小的符合条件的长度。
滑动窗口的精妙之处在于根据当前子序列和大小的情况,不断调节子序列的起始位置。从而将O(n^2)
的暴力解法降为O(n)
。
4)模拟行为
模拟类的题目在数组中很常见,不涉及到什么算法,就是单纯的模拟,十分考察大家对代码的掌控能力。
在这道题目中,再一次介绍到了循环不变量原则,其实这也是写程序中的重要原则。