代码随想录算法训练营 | 第二天 977.有序数组的平方、209.长度最小的子数组、59.螺旋矩阵II

代码随想录算法训练营第二天 | 977.有序数组的平方、209.长度最小的子数组、59.螺旋矩阵II

1 LeetCode 977.有序数组的平方

题目链接:https://leetcode.cn/problems/squares-of-a-sorted-array/

给你一个按 非递减顺序 排序的整数数组 nums,返回 每个数字的平方 组成的新数组,要求也按 非递减顺序 排序。

示例 1:

输入:nums = [-4,-1,0,3,10]
输出:[0,1,9,16,100]
解释:平方后,数组变为 [16,1,0,9,100]
排序后,数组变为 [0,1,9,16,100]

示例 2:

输入:nums = [-7,-3,2,3,11]
输出:[4,9,9,49,121]

提示:

  • 1 <= nums.length <= 104
  • -104 <= nums[i] <= 104
  • nums 已按 非递减顺序 排序

进阶:

  • 请你设计时间复杂度为 O(n) 的算法解决本问题

1.1 快排方法(408思路)

拿到这题,我首先没有想到双指针法,我首先是按照408算法题的思路来解决的,快排是应用最广的排序算法,平均复杂度比较优秀,适用于乱序数组的排序操作,下面是完美复刻王道咸鱼学长所讲的快排模板代码:

def quickSort(arr, low, high):
    if low >= high:    # 递归结束条件
        return
    mid = partition(arr, low, high)    # 以第一个元素作为基准
    quickSort(arr, low, mid - 1)    
    quickSort(arr, mid + 1, high)

def partition(arr, low, high):
    pivot = arr[high]  # 选择最后一个元素作为基准
    while(low < high):
        while(low < high and arr[low] <= pivot):    # 从左往右找到第一个大于pivot的元素
            low += 1
        arr[high] = arr[low]    # 将该元素放到右边
        while(low < high and arr[high] >= pivot):    # 从右往左找到第一个小于pivot的元素
            high -= 1
        arr[low] = arr[high]    # 将该元素放到左边
    arr[low] = pivot    # 将基准放到中间
    return low

def sortedSquares(nums):
    squares = [num ** 2 for num in nums]
    quickSort(squares, 0, len(squares) - 1)   # 快速排序
    return squares

if __name__ == "__main__":
    nums = list(map(int, input().split()))
    print(sortedSquares(nums))

快速排序在平均情况下的性能非常好(时间复杂度为 O(n log n)),但在最坏情况下(例如本题中如果输入数组全部都为非负整数时)其性能可能下降到 O(n^2),用来应付408算法题绰绰有余。

下面贴出王道咸鱼学长上课讲的模板:
在这里插入图片描述

虽然说上述代码直接使用sort()函数更好更简约,因为sort()函数内置更高效和更稳定的排序算法(如归并排序或 Timsort),但是在408中是不能直接调库实现的,而且面试中面试官也不愿意看见这样的代码,所以最好是手写一个排序代码用来实现。

1.2 双指针法实现

在上一期博客中我们详细学习了双指针法的实现过程,这次我们继续利用双指针法来解决这道题目,这道题目有一个进阶要求就是:请你设计时间复杂度为 O(n) 的算法解决本问题,刚好双指针法就可以满足这个要求。(我打算以后都在力扣的核心代码模式下然后自己加入输入输出代码,如果想要去力扣上执行通过的话,只需要删除对应的输入输出代码即可)

(1)Python版本实现代码

class Solution:
    def sortedSquares(self, nums):
        left, right = 0, len(nums) - 1    # 左闭右开
        result = [0] * len(nums)
        for i in range(len(nums)-1, -1, -1):    # 从后往前填充
            if abs(nums[left]) > abs(nums[right]):  
                result[i] = nums[left] ** 2
                left += 1
            else:
                result[i] = nums[right] ** 2
                right -= 1
        return result
    
    
if __name__ == "__main__":
    nums = list(map(int, input().split()))
    solution = Solution()
    print(solution.sortedSquares(nums))

这里可能有人会好奇,为什么要从后往前遍历,即从 len(nums) - 1 开始倒序填充数组,这是因为平方最大的数可能在数组的两端。但是写代码的时候我也考虑过,如果从前面开始遍历的话,代码应该怎么写?

我尝试过修改上面的代码,但是由于水平有限,没能成功,因此我就去搜索了一番,下面是从前开始遍历的实现代码:

class Solution:
    def sortedSquares(self, nums):
        n = len(nums)
        result = [0] * n

        # 找到第一个非负数的位置
        first_non_negative = 0
        while first_non_negative < n and nums[first_non_negative] < 0:          
            first_non_negative += 1     

        i, j = first_non_negative - 1, first_non_negative   # i指向左边,j指向右边
        k = 0   

        # 从中间向两边遍历
        while i >= 0 and j < n:
            if nums[i] ** 2 < nums[j] ** 2: 
                result[k] = nums[i] ** 2
                i -= 1
            else:
                result[k] = nums[j] ** 2
                j += 1
            k += 1

        # 如果左边还有元素
        while i >= 0:
            result[k] = nums[i] ** 2
            i -= 1
            k += 1

        # 如果右边还有元素
        while j < n:
            result[k] = nums[j] ** 2
            j += 1
            k += 1

        return result

if __name__ == "__main__":
    nums = list(map(int, input().split()))
    solution = Solution()
    print(solution.sortedSquares(nums))

如果选择从前面开始遍历数组,使用双指针法也是可能的,但这将需要一些额外的步骤,主要的问题是处理正负数平方后的排序顺序,因为负数平方后可能变得比正数大。

上面代码的实现是先找到第一个非负数的位置,这个位置将数组分成两部分:左边是负数(平方后递减),右边是非负数(平方后递增),然后像归并排序中合并两个有序数组那样,使用两个指针分别从这个分界点向左和向右遍历数组,比较并选择较小的平方数添加到结果数组中。

其中i 从分界点的左侧开始向左遍历,j 从分界点的右侧开始向右遍历。我们比较 nums[i]nums[j] 的平方值,将较小的那个添加到结果数组 result 中。这种方法虽然也是 O(n) 的时间复杂度,但相比直接从两端开始的双指针法,它需要更多的步骤和计算。

(2)C++版本实现代码

#include <iostream>
#include <vector>
#include <cmath>
using namespace std;

class Solution {
public:
    vector<int> sortedSquares(vector<int>& nums) {
        int left = 0, right = nums.size() - 1;
        vector<int> result(nums.size());
        for (int i = nums.size() - 1; i >= 0; i--) {
            if (abs(nums[left]) > abs(nums[right])) {
                result[i] = nums[left] * nums[left];
                left++;
            } else {
                result[i] = nums[right] * nums[right];
                right--;
            }
        }
        return result;
    }
};

int main() {
    Solution solution;
    vector<int> nums;
    int num;
    while (cin >> num) {
        nums.push_back(num);
        if (cin.get() == '\n') break;
    }
    vector<int> result = solution.sortedSquares(nums);
    for (int i : result) {
        cout << i << " ";
    }
    cout << endl;
    return 0;
}

2 LeetCode 209.长度最小的子数组

题目链接:https://leetcode.cn/problems/minimum-size-subarray-sum/description/

给定一个含有 n 个正整数的数组和一个正整数 target

找出该数组中满足其总和大于等于 target 的长度最小的 连续子数组 [numsl, numsl+1, ..., numsr-1, numsr] ,并返回其长度**。**如果不存在符合条件的子数组,返回 0

示例 1:

输入:target = 7, nums = [2,3,1,2,4,3]
输出:2
解释:子数组 [4,3] 是该条件下的长度最小的子数组。

示例 2:

输入:target = 4, nums = [1,4,4]
输出:1

示例 3:

输入:target = 11, nums = [1,1,1,1,1,1,1,1]
输出:0

提示:

  • 1 <= target <= 109
  • 1 <= nums.length <= 105
  • 1 <= nums[i] <= 105

进阶:

  • 如果你已经实现 O(n) 时间复杂度的解法, 请尝试设计一个 O(n log(n)) 时间复杂度的解法。

2.1 暴力枚举

暴力解法就是使用两个嵌套for循环不断地遍历数组,寻找符合条件地子数组,时间复杂度为 O ( n 2 ) O(n^2) O(n2)

class Solution:
    def minSubArrayLen(self, target, nums):
        result = float('inf')       # 初始化为正无穷
        sum = 0                     # 子序列的数值之和
        subLength = 0               # 子数组长度
        for i in range(len(nums)):  
            sum = 0;            # 每次循环都要重新初始化
            for j in range(i, len(nums)):   
                sum += nums[j]  
                if sum >= target:   # 如果和大于等于target
                    subLength = j - i + 1       # 计算子数组长度
                    result = min(result, subLength)     # 一旦发现子序列和超过了s,更新result
                    break 
                
        return result if result != float('inf') else 0  # 如果result没有被更新过,返回0
    

if __name__ == "__main__":
    target = int(input())
    nums = list(map(int, input().split()))
    solution = Solution()
    print(solution.minSubArrayLen(target, nums))

其中外层循环从数组的第一个元素开始遍历,直到数组的最后一个元素,这个循环的目的是确定子数组的起始位置。内层循环对于每个外层循环选定的起始位置 i,内层循环从 i 开始,逐渐向数组的末尾扩展,这个循环的目的是确定子数组的结束位置,逐步将元素加到 sum 上,并检查 sum 是否达到或超过了目标值 target

C++代码我就直接贴出卡哥的代码随想录的代码,和上面的Python代码是类似的。

class Solution {
public:
    int minSubArrayLen(int s, vector<int>& nums) {
        int result = INT32_MAX; // 最终的结果
        int sum = 0; // 子序列的数值之和
        int subLength = 0; // 子序列的长度
        for (int i = 0; i < nums.size(); i++) { // 设置子序列起点为i
            sum = 0;
            for (int j = i; j < nums.size(); j++) { // 设置子序列终止位置为j
                sum += nums[j];
                if (sum >= s) { // 一旦发现子序列和超过了s,更新result
                    subLength = j - i + 1; // 取子序列的长度
                    result = result < subLength ? result : subLength;
                    break; // 因为我们是找符合条件最短的子序列,所以一旦符合条件就break
                }
            }
        }
        // 如果result没有被赋值的话,就返回0,说明没有符合条件的子序列
        return result == INT32_MAX ? 0 : result;
    }
};

2.2 滑动窗口实现

滑动窗口是一种常用于解决数组或字符串相关问题的方法,特别适用于需要处理连续数据块的场景,其基本思想是在数据的一段连续区域内移动,同时根据问题的需要调整窗口的大小,以下是滑动窗口的一般实现过程:

(1) 初始化窗口

  • 定义两个指针 leftright,分别表示窗口的开始和结束位置,初始时两个指针都指向数组的起始位置。

(2) 扩展窗口

  • 移动 right 指针向右扩展窗口,直到窗口内的数据满足特定条件,在这个步骤涉及不断添加 right 指向的元素到当前考虑的数据集合中。

(3) 收缩窗口

  • 一旦窗口内的数据满足了条件(例如,和超过了给定值),开始移动 left 指针以缩小窗口,在这个过程通常涉及从考虑的数据集合中移除 left 指向的元素。目的是看看是否能够在满足条件的情况下减小窗口的大小。

(4) 更新结果

  • 在整个过程中根据问题的需要更新结果,例如如果问题是寻找满足条件的最小窗口,那么每次窗口满足条件时,都应该更新最小窗口的大小。

(5) 重复以上步骤

  • 继续重复扩展和收缩窗口的过程,直到 right 指针到达数组的末尾。

下面是本题利用滑动窗口思想实现的代码:

(1)Python版本实现代码:

class Solution:
    def minSubArrayLen(self, target: int, nums):
        n = len(nums)
        min_len = float('inf')
        sum = 0    # 子序列的数值之和
        left = 0    # 滑动窗口的左指针

        for right in range(n):  # 滑动窗口
            sum += nums[right]  
            while sum >= target:    # 当子序列和大于等于target时
                min_len = min(min_len, right - left + 1)    # 更新最小长度
                sum -= nums[left]   # 左指针右移
                left += 1           # 更新左指针

        return 0 if min_len == float('inf') else min_len    # 如果min_len没有被更新过,返回0

if __name__ == "__main__":
    target = int(input())
    nums = list(map(int, input().split()))
    solution = Solution()
    print(solution.minSubArrayLen(target, nums))

(2)C++版本实现代码:

#include <iostream>
#include <vector>
using namespace std;

class Solution {
public:
    int minSubArrayLen(int target, vector<int>& nums) {
        int n = nums.size();
        int min_len = INT_MAX;
        int sum = 0;
        int left = 0;

        for (int right = 0; right < n; right++) {
            sum += nums[right];
            while (sum >= target) {
                min_len = min(min_len, right - left + 1);
                sum -= nums[left];
                left++;
            }
        }

        return (min_len != INT_MAX) ? min_len : 0;
    }
};

int main() {
    Solution solution;
    int target, n;
    cin >> target;
    cin >> n;
    vector<int> nums(n);
    for (int i = 0; i < n; i++) {
        cin >> nums[i];
    }
    cout << solution.minSubArrayLen(target, nums) << endl;
    return 0;
}

在实现滑动窗口时,通常只需要一个 for 循环来表示滑动窗口的结束位置,通过单循环控制结束位置,同时在循环内调整起始位置,可以有效地遍历所有可能的窗口,同时保持时间复杂度为 O(n)。那么为什么for 循环必须要表示滑动窗口的结束位置而不能表示滑动窗口的起始位置原因在于如何有效地探索所有可能的窗口大小。

(1)为什么用 for 循环表示结束位置:

  • 探索所有窗口大小
    • 当我们使用 for 循环来递增结束位置时,我们实际上是在逐步增大窗口的大小,这样我们可以保证覆盖所有可能的窗口大小,从最小的窗口(起始和结束位置相同)开始,逐渐增大,直到覆盖整个数组。
  • 动态调整窗口起始位置
    • 在同一个循环中,我们可以根据当前窗口内元素的总和来决定是否移动起始位置。如果当前窗口的总和已经满足条件(比如在本题中总和达到或超过了 target),我们可以尝试通过移动起始位置来减小窗口大小,同时继续保持总和条件满足,这种动态调整使得算法可以在遍历数组的同时不断更新可能的最优解。
  • 保持高效率
    • 通过不断移动结束位置,我们只需遍历数组一次,就能够找到所有可能的窗口,这种一次遍历的方法大大提高了算法的效率,使得时间复杂度保持在 O(n)。

(2)为什么不用 for 循环表示起始位置:

  • 如果我们使用 for 循环来递增起始位置,我们将不得不在每次迭代中使用另一个循环(或类似机制)来找到满足条件的结束位置,这种方法会导致重复遍历某些数组元素,增加了不必要的计算,从而使得时间复杂度可能上升到 O ( n 2 ) O(n^2) O(n2)

2.3 进阶方法(前缀和+二分查找)

由于本人实力不够,水平不足还无法实现进阶的要求,不过我们有强大的互联网,我上网搜索了该方法,可将时间复杂度降至O(n log(n)),那就是使用前缀和加二分查找的方法,这种方法的核心思想是首先计算数组的前缀和,然后对于每个前缀和,使用二分查找找到最小的索引,使得从这个索引到当前索引的子数组的和至少为 target

具体实现步骤如下:

  • 计算前缀和
    • 创建一个新数组 prefixSums,其中 prefixSums[i] 表示原数组 nums 中从第 0 个元素到第 i 个元素的和。
    • 这一步的时间复杂度是 O(n)
  • 对每个前缀和使用二分查找
    • 对于 prefixSums 中的每个元素,使用二分查找找到最小的索引 j,使得 prefixSums[i] - prefixSums[j] >= target(即子数组 nums[j+1...i] 的和至少为 target)。
    • 这一步对于每个前缀和都是 O(log(n)) 的时间复杂度,因此总的时间复杂度是 O(n log(n))
  • 更新最小长度
    • 在执行二分查找的过程中,更新满足条件的最短子数组的长度。

下面是使用 Python 实现的代码:

import bisect

class Solution:
    def minSubArrayLen(self, target: int, nums: List[int]) -> int:
        n = len(nums)
        if n == 0:
            return 0

        min_length = float('inf')
        prefixSums = [0] * (n + 1)

        for i in range(n):
            prefixSums[i + 1] = prefixSums[i] + nums[i]

        for i in range(1, n + 1):
            required_sum = target + prefixSums[i - 1]
            bound = bisect.bisect_left(prefixSums, required_sum)
            if bound != len(prefixSums):
                min_length = min(min_length, bound - (i - 1))

        return 0 if min_length == float('inf') else min_length

if __name__ == "__main__":
    target = int(input())
    nums = list(map(int, input().split()))
    solution = Solution()
    print(solution.minSubArrayLen(target, nums))

在这个实现中首先计算了数组 nums 的前缀和 prefixSums,然后对于每个前缀和,使用 Python 内置的 bisect.bisect_left 函数来执行二分查找,这个方法找到满足条件的最短子数组长度,同时保持整体的时间复杂度为 O(n log(n)),学有余力的朋友可以研究一下,我打算后续在学习,目前贴出来仅供参考学习。

3 LeetCode 59.螺旋矩阵II(不会写)

题目链接:https://leetcode.cn/problems/spiral-matrix-ii/description/

给你一个正整数 n ,生成一个包含 1n2 所有元素,且元素按顺时针顺序螺旋排列的 n x n 正方形矩阵 matrix

示例 1:

img

输入:n = 3
输出:[[1,2,3],[8,9,4],[7,6,5]]

示例 2:

输入:n = 1
输出:[[1]]

提示:

  • 1 <= n <= 20

这种题408目前没有出现,但是蓝桥杯应该挺喜欢出的,纯模拟过程,但是对于这种题目目前我还没有经过训练,所以实现起来很难,目前会写的也就是更408算法题相关的那些相似的题目,模拟题目不涉及算法,但是十分考察对代码的掌控能力,面试中应该也容易出现,后续周末我再好好研究一下这道题目,再更新出我自己的实现思路。

下面我就直接给出卡哥的代码随想录这道题目的地址:59.螺旋矩阵II,大家可以去代码随想录里面学习这道题目,卡哥讲的非常详细还有对应的讲解视频,我在这里就只贴出代码随想录上的代码,然后我帮忙加上输入和输出的代码,方便各位朋友调试。

(1)Python版本:

class Solution:
    def generateMatrix(self, n):
        nums = [[0] * n for _ in range(n)]
        startx, starty = 0, 0  # 起始点
        loop, mid = n // 2, n // 2  # 迭代次数、n为奇数时,矩阵的中心点
        count = 1  # 计数

        for offset in range(1, loop + 1):  # 每循环一层偏移量加1,偏移量从1开始
            for i in range(starty, n - offset):  # 从左至右,左闭右开
                nums[startx][i] = count
                count += 1
            for i in range(startx, n - offset):  # 从上至下
                nums[i][n - offset] = count
                count += 1
            for i in range(n - offset, starty, -1):  # 从右至左
                nums[n - offset][i] = count
                count += 1
            for i in range(n - offset, startx, -1):  # 从下至上
                nums[i][starty] = count
                count += 1
            startx += 1  # 更新起始点
            starty += 1

        if n % 2 != 0:  # n为奇数时,填充中心点
            nums[mid][mid] = count
        return nums

if __name__ == "__main__":
    n = int(input())
    solution = Solution()
    matrix = solution.generateMatrix(n)
    for row in matrix:
        print(" ".join(map(str, row)))

(2)C++版本:

class Solution {
public:
    vector<vector<int>> generateMatrix(int n) {
        vector<vector<int>> res(n, vector<int>(n, 0)); // 使用vector定义一个二维数组
        int startx = 0, starty = 0; // 定义每循环一个圈的起始位置
        int loop = n / 2; // 每个圈循环几次,例如n为奇数3,那么loop = 1 只是循环一圈,矩阵中间的值需要单独处理
        int mid = n / 2; // 矩阵中间的位置,例如:n为3, 中间的位置就是(1,1),n为5,中间位置为(2, 2)
        int count = 1; // 用来给矩阵中每一个空格赋值
        int offset = 1; // 需要控制每一条边遍历的长度,每次循环右边界收缩一位
        int i,j;
        while (loop --) {
            i = startx;
            j = starty;

            // 下面开始的四个for就是模拟转了一圈
            // 模拟填充上行从左到右(左闭右开)
            for (j = starty; j < n - offset; j++) {
                res[startx][j] = count++;
            }
            // 模拟填充右列从上到下(左闭右开)
            for (i = startx; i < n - offset; i++) {
                res[i][j] = count++;
            }
            // 模拟填充下行从右到左(左闭右开)
            for (; j > starty; j--) {
                res[i][j] = count++;
            }
            // 模拟填充左列从下到上(左闭右开)
            for (; i > startx; i--) {
                res[i][j] = count++;
            }

            // 第二圈开始的时候,起始位置要各自加1, 例如:第一圈起始位置是(0, 0),第二圈起始位置是(1, 1)
            startx++;
            starty++;

            // offset 控制每一圈里每一条边遍历的长度
            offset += 1;
        }

        // 如果n为奇数的话,需要单独给矩阵最中间的位置赋值
        if (n % 2) {
            res[mid][mid] = count;
        }
        return res;
    }
};

int main() {
    Solution solution;
    int n;
    cin >> n;
    vector<vector<int>> matrix = solution.generateMatrix(n);
    for (auto &row : matrix) {
        for (int val : row) {
            cout << val << " ";
        }
        cout << endl;
    }
    return 0;
}
  • 时间复杂度 O(n^2): 模拟遍历二维矩阵的时间
  • 空间复杂度 O(1)

4 数组篇总结

4.1 数组理论基础

数组是一种基本的数据结构,用于存储一系列相同类型的元素,数组中的元素可以通过索引快速访问,在大多数编程语言中,数组的索引通常从0开始,数组的主要特点包括:

  • 固定大小:在很多语言中,数组在初始化时其大小是固定的,不能动态扩展或缩小。
  • 连续的内存位置:数组中的元素在内存中连续存放,这意味着可以通过索引在常数时间内访问任何元素。
  • 同一数据类型:数组中的所有元素类型相同,这使得数组的存储更为高效。

在408选择题、应用题以及算法题就很喜欢涉及有关数组的问题,因此大家请务必掌握有关数组的一些实用算法。

4.2 经典题目总结

  • 二分查找
    • 用于在有序数组中查找特定元素的高效算法。
    • 通过比较数组中间元素与目标值,可以排除一半的搜索区域。
    • 时间复杂度为 O(log n)。
  • 双指针法
    • 用于解决一系列数组问题,如反转数组、找出元素对等。
    • 涉及使用两个指针(如左右指针)在一次遍历中完成任务。
    • 时间复杂度取决于具体问题,通常为 O(n)。
  • 滑动窗口
    • 用于解决需要连续数据段的问题,如找到满足条件的最小子数组。
    • 通过动态调整窗口的起始和结束位置来探索不同的可能性。
    • 适用于数组和字符串,时间复杂度通常为 O(n)。
  • 模拟
    • 用于解决需要按照特定规则操作数组或矩阵的问题。
    • 通过直接模拟问题要求的操作来找到解决方案。
    • 如螺旋矩阵问题,通过模拟矩阵填充的过程来构建解答。

4.3 总结

数组是最基本也是最常用的数据结构之一,熟练掌握各种数组操作技巧和算法对于提高编程能力至关重要,在实际应用中,选择合适的算法和数据结构是关键,例如对于搜索问题,如果数组是有序的,那么二分查找是一个很好的选择,对于需要频繁修改或动态扩展大小的数据集,可以考虑使用其他数据结构,如链表或动态数组,通过解决经典题目,如二分查找、双指针、滑动窗口和模拟等,我们可以加深对数组及其相关算法的理解,不断实践和挑战更复杂的问题将有助于提升解决实际问题的能力。

  • 21
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

-北天-

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值