剑指Offer算法课(二)数组

110 阅读10分钟

数组的基础知识

数组是一种简单的数据结构,最常见的是一维数组。

数组具有很高的时间效率,能够以 O(1) 的时间复杂度完成元素的插入、删除。

创建数组时需要先指定数组的容量大小,然后根据容量大小分配内存,即使数组中只有少数几个元素,仍然需要预先占据指定大小的内存,导致它的空间效率不高

为了解决数组空间效率不高的问题,人们设计了动态数组,如Java中的ArrayList,无需在初始化时指定大小,当元素增加到一定程度会触发扩容操作,扩容操作对时间性能有负面影响。


面试题6:排序数组中的两个数字之和

leetcode.cn/problems/kL…

给定一个已按照 升序排列 的整数数组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的三个数字

leetcode.cn/problems/1f…

给定一个包含 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的最短子数组

leetcode.cn/problems/2V…

给定一个含有 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的子数组

leetcode.cn/problems/ZV…

给定一个正整数数组 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的子数组

leetcode.cn/problems/QT…

给定一个整数数组和一个整数 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个数相同的子数组

leetcode.cn/problems/A1…

给定一个二进制数组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:寻找数组的中心下标

leetcode.cn/problems/tv…

给你一个整数数组 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:二位子矩阵数字之和

leetcode.cn/problems/O4…

给定一个二维矩阵 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

image.png

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)空间可找出和为指定值的子数组
  • 预处理:数组里既有正数也有负数,提问对子数组累加求和。一维、二维数组均适用该方法。