数组的基础知识
数组是一种简单的数据结构,最常见的是一维数组。
数组具有很高的时间效率,能够以 O(1) 的时间复杂度完成元素的插入、删除。
创建数组时需要先指定数组的容量大小,然后根据容量大小分配内存,即使数组中只有少数几个元素,仍然需要预先占据指定大小的内存,导致它的空间效率不高。
为了解决数组空间效率不高的问题,人们设计了动态数组,如Java中的ArrayList,无需在初始化时指定大小,当元素增加到一定程度会触发扩容操作,扩容操作对时间性能有负面影响。
面试题6:排序数组中的两个数字之和
给定一个已按照 升序排列 的整数数组
numbers,请你从数组中找出两个数满足相加之和等于目标数target。假设数组中存在且只存在一对符合条件的数字,同时一个数字不能使用两次。
ANSWER
注意题目中提到升序排列,对于已排序的数组,可以采用双指针法。指针位于数组首尾,若和小于target则左指针右移,若和大于target则右指针左移。终止条件是两个指针相遇or找到和target的两个数。
这种解法只需要遍历一次数组,时间复杂度是O(n) 。空间复杂度是O(1) 。
public int[] twoSum(int[] numbers, int target) {
int i = 0;
int j = numbers.length - 1;
while (i<j && numbers[i] + numbers[j] != target) {
if (numbers[i] + numbers[j] < target) {
i++;
} else {
j--;
}
return new int[]{i, j}
}
面试题7:数组中和为0的三个数字
给定一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a ,b ,c ,使得 a + b + c = 0 ?请找出所有和为 0 且 不重复 的三元组。
ANSWER
是上一道题的扩展,采用基于上一题的解法来做。首先将数组排序,使用快速排序的时间复杂度是O(nlogn) ,然后遍历数组,每次遍历时固定首个数字(假设为x),接下来查找数组中是否有两个数字满足它们的和=-x,期间注意跳过重复值。
由于遍历时需要两重循环,故总的时间复杂度是O(nlogn)+O(n^2)=O(n^2) 。
public List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> result = new LinkedList<List<Integer>>(); // 返回值的数据结构
if (nums.length >= 3) {
Arrays.sort(nums); // 直接用Java API进行排序
int i=0;
while (i<nums.length - 2) {
twoSum(nums i, result);
int temp = nums[i]; // i递增同时跳过重复数字
while (i<nums.length && nums[i] == temp) {
++i;
}
}
}
return result;
}
// 从i开始,向后找2个数字,满足三个数字和为0
private void twoSum(int[] nums, int i, List<List<Integer>> result) {
int j = i + 1; // 指向数组首尾的两个指针
int k = nums.length - 1;
while (j < k) {
if (nums[i] + nums[j] + nums[k] == 0) {
result.add(Arrays.asList(nums[i], nums[j], nums[k]));
int temp = nums[j]; // 跳过重复的j,无需跳过k
while (nums[j] == temp && j < k) {
++j;
}
/*
temp = nums[k];
while (nums[k] == temp && j < k) {
--k;
}
*/
} else if (nums[i] + nums[j] + nums[k] < 0) {
j++;
} else {
k--;
}
}
}
注意到上述twoSum函数里,处理j递增时只是跳过了重复的j,并没有跳过重复的k,这是为什么?
因为当j跳过后,即使k重复了,它们的和也会是>0,不会=0。
面试题8:和大于或等于k的最短子数组
给定一个含有 n 个正整数的数组和一个正整数 target 。
找出该数组中满足其和 ≥ target 的长度最小的 连续子数组 [numsl, numsl+1, ..., numsr-1, numsr] ,并返回其长度。如果不存在符合条件的子数组,返回 0 。
ANSWER
查找子数组问题,题目中是正整数组成的数组,因此可以使用双指针平移窗口,双指针从左侧出发,比较窗口内数字之和与target,如果小于target则扩大窗口(右侧指针右移) ,若大于target则缩小窗口(左侧指针右移) 。
该解法只需要遍历一次数组,时间复杂度O(n) ,只需要临时保存一个sum值,空间复杂度O(1) 。
public int minSubArrayLen(int k, int[] nums) {
int left = 0;
int sum = 0;
int minLen = Integer.MAX_VALUE;
for (int right=0; right<nums.length; right++) {
sum += nums[right];
while (left <= right && sum >= k) {
minLen = Math.min(minLen, right - left + 1);
sum -= nums[left++]; // 从sum里减去左值,左指针右移
}
}
return minLen == Integer.MAX_VALUE ? 0 : minLen;
}
面试题9:乘积小于k的子数组
给定一个正整数数组
nums和整数k,请找出该数组内乘积小于k的连续的子数组的个数
ANSWER
乘法与加法类似,解法与上题雷同,不赘述。
该解法只需要遍历一次数组,时间复杂度O(n) ,只需要临时保存一个sum值,空间复杂度O(1)
public int numSubarrayProductLessThanK(int[] nums, int k) {
long product = 1;
int left = 0;
int cnt = 0;
for (int right=0; right<nums.length; ++right) {
product *= nums[right];
while (left <= right && product >= k) {
product /= nums[left++];
}
cnt += right >= left ? right - left + 1 : 0; // 两指针之间有多少数字,就对应多少满足条件的子数组
}
return cnt;
}
面试题10:和为k的子数组
给定一个整数数组和一个整数 target ,请找到该数组中和为 target 的连续子数组的个数。
ANSWER
与上几道题不同之处在于,这里的数组元素可以是负数,意味着无法通过增减元素来使数字和(积)增加或减少。
暴力解法,需要三层嵌套循环,时间复杂度O(n^3) ,不赘述。
引入预处理的概念,对于输入长度为n的数组nums[0..n-1],首先计算出S[0..0], S[0..1], S[0..n-1]的值,它们分别是从元素0开始到元素k(0<=k<=n-1)的子数组和。经过预处理后得到一个长度同样为n的数组S,可以用S辅助计算原数组的任意子数组数字之和。预处理的时间复杂度是O(n) ,空间复杂度是O(n) 。
得到上述预处理数组S后,遍历该数组,假设遍历到S[i],它的含义是nums[0..i]的和,如果可以找出下标数字j,使得S[i]-S[j] = target,说明原数组的子数组nums[j..i]是满足条件(和为target)的一个子数组,这一步需要两层循环,时间复杂度O(n^2) 。
继续优化,在遍历预处理数组S的过程中,目标是找到下标j使得S[i]-S[j] = target,即S[j]=S[i]-target,是要找出值为S[i]-target的下标,因此,在上一步预处理时,我们使用HashMap直接把<S[j], j>保存起来,如果发生碰撞则++,这样就可以将双层遍历简化为单层,时间复杂度降低到O(n) 。
在一次的扫描中完成预处理+查找两个操作,非常精妙。
最优解法代码如下。
public int subArraySum(int nums, int target) {
Map<Integer, Integer> sumToCount = new HashMap<>();
sumToCount.put(0, 1); // 和为0的子数组至少有一个,就是空数组
int sum = 0;
int cnt = 0;
for (int num: nums) {
sum += num;
cnt += sumToCount.getOrDefault(sum - target, 0);
sumToCount.put(sum, sumToCount.getOrDefault(sum, 0) + 1); // 同一个sum的子数组频次+1
}
}
return cnt;
}
面试题11:0和1个数相同的子数组
给定一个二进制数组nums,找到含有相同数量0和1的最长连续子数组,并返回该子数组的长度。
ANSWER
划重点:整数数组,不限制正负,寻找连续子数组,求和 --> 使用预处理法。
这道题目的变换点在于“相同数量的0和1”条件该如何利用,如果我们将0视作-1,“相同数量的-1和1”==“所有数字和为0” ,就容易多了。
思路与上一道题类似,遍历nums,预处理得出S[0..0]~S[0..n-1],预处理过程中将0视作-1,1不变。同时建立一个HashMap<sum, idx>,这里不需要考虑sum值相同的场景,因为第一个被插入到HashMap的坐标值idx肯定是最小的(对于sum相同的情况)。
该解法时间复杂度与空间复杂度都是O(n)。
public int findMaxLen(int[] nums) {
Map<Integer, Integer> sumToIdx = new HashMap();
sumToIdx.put(0, 1); // 和为0的子数组至少有一个,就是空数组
int sum = 0;
int maxLen = 0;
for (int i=0; i<nums.length; ++i) {
sum += nums[i] == 0 ? -1 : 1; // 将0视作-1
if (sumToIdx.containsKey(sum)) {
maxLen = Math.max(maxLen, i-sumToIdx.get(sum));
} else {
sumToIdx.put(sum, i);
}
}
return maxLen;
}
面试题12:寻找数组的中心下标
给你一个整数数组 nums ,请计算数组的 中心下标。数组 中心下标 是数组的一个下标,其左侧所有元素相加的和等于右侧所有元素相加的和。如果中心下标位于数组最左端,那么左侧数之和视为 0 ,因为在下标的左侧不存在元素。这一点对于中心下标位于数组最右端同样适用。如果数组有多个中心下标,应该返回 最靠近左边 的那一个。如果数组不存在中心下标,返回 -1。
ANSWER
找出数组中心点,其左侧子数组和=右侧子数组和。
有符号整数数组+求和 --> 预处理法,预处理过程只需要求总和即可。
public int pivotIndex(int[] nums) {
int total = 0;
for (int num : nums) total += num; // 求总和
int sum = 0;
for (int i=0; i<nums.length; ++i) {
sum += nums[i];
if (sum - nums[i] == total - sum) {
return i;
}
}
return -1;
}
面试题13:二位子矩阵数字之和
给定一个二维矩阵
matrix,以及以下类型的多个请求:
计算其子矩形范围内元素的总和,该子矩阵的左上角为
(row1, col1),右下角为(row2, col2)。实现
NumMatrix类:
NumMatrix(int[][] matrix)给定整数矩阵 matrix 进行初始化
int sumRegion(int row1, int col1, int row2, int col2)返回左上角(row1, col1)、右下角(row2, col2)的子矩阵的元素总和。例:如下图,当输入(2,1)、(4,3)作为红色边框子矩阵左上角、右下角时,计算出该子矩阵元素总和为8
ANSWER
这道题虽然是矩阵(二维向量),但看到题目中“求和”的问题,仍然可以想到借助于预处理来进行计算。目标矩阵的元素之和可以拆解为四个从(0,0)作为起点的子矩阵元素和进行计算的结果:T0-T1-T2+T3。因此,考虑建立一个长宽与原矩阵相等的预处理矩阵。
注意,如果题目里输入的目标矩阵长/宽值是1,是无法通过相加减得到该矩阵的,因此需要在目标矩阵上增加一行&一列,类似链表算法中的哨兵节点,该行/列元素均为0。因此目标矩阵的大小是 [m+1][n+1] 。
预处理过程需要对输入矩阵进行遍历,时间复杂度为O(mn),空间复杂度为O(mn) 。
一旦建立完成预处理矩阵,后续用O(1)时间就可以查询任意子矩阵的元素之和。
class NumMatrix {
private int[][] sums;
public NumMatrix(int[][] matrix) {
if (matrix.length == 0 || matrix[0].length == 0) return; // 输入有效性检验
sums = new int[matrix.length + 1][matrix[0].length + 1]; // 增加一行,增加一列
for (int i=0; i<matrix.length; ++i) {
int rowSum = 0; // 维护一个“当前和”的值,不必每次迭代都重新计算
for (int j=0; j<matrix[0].length; ++j) {
rowSum += matrix[i][j];
sums[i+1][j+1] = sums[i][j+1] + rowSum;
}
}
}
public int sumRegion(int row1, int col1, int row2, int col2) {
return sums[row2+1][col2+1] - sums[row2+1][col1+1] - sums[row1+1][col2+1] + sums[row1+1][col1+1];
}
}
小结
数组是一种支持以O(1)时间进行随机访问的数据结构,应用十分广泛。在面对数组类算法题的时候,常用技术有双指针、预处理。
- 双指针
-
- 若数组已排序,则用O(n)时间就可以找到两个和为指定值的数字
- 若所有数字都是正整数,用O(n)时间、O(1)空间可找出和为指定值的子数组
- 预处理:数组里既有正数也有负数,提问对子数组累加求和。一维、二维数组均适用该方法。