算法训练营 Day2-数组 2 | 长度最小的子数组 | 螺旋矩阵 II | 区间和 | 开发商购买土地

74 阅读14分钟

算法训练营 Day1-数组 2 | 长度最小的子数组 | 螺旋矩阵 II | 区间和 | 开发商购买土地

查阅文档地址:programmercarl.com/

本期题目地址:

  1. 209. 长度最小的子数组 - 中等 - 力扣
  2. 59.螺旋矩阵 II - 中等 - 力扣
  3. 区间和 - 卡网

本期题目答案地址:

  1. 904. 水果成篮 - 这个细节很不错
  2. 76. 最小覆盖子串 - 灵神和他的评论区

本期同类题目地址:

  1. 904. 水果成篮 - 中等 - 力扣
  2. 76. 最小覆盖子串 - 困难 - 力扣
  3. 54.螺旋矩阵 - 中等 - 力扣
  4. LCR 146. 螺旋遍历二维数组 - 简单 - 力扣
  5. 开发商购买土地 - 卡网

目录:

  1. 基本概念(做题前要理解的概念)
  2. 我的解法
  3. 疑问点(过程中产生了问题并且查找资料解决)

语言

采用C++,一些分析也是用于 C++,请注意。

基本概念

  1. 子数组(Subarray) 是数组的一个片段,由数组中的连续元素组成。
  2. 滑动窗口 需要想清楚如何移动开始位置。和同向快慢指针一个道理。
  3. INT32_MAX 是一个常量,表示有符号 32 位整数类型(int32)能够表示的最大值。

209.长度最小的子数组 (容易出错)

209.长度最小的子数组 - 力扣

滑动窗口 B 站学习视频链接;

做题过程

我最初的想法是暴力,两个 for 循环找所有区间,记录最短的 min 子数组个数,这个时间复杂度是 O(n^2)。

看了算法文档,get 滑动窗口新思路!~~倒着取区间(意思是遍历结束位置/快指针),不断的调节子序列的起始位置和终止位置,从而得出我们要想的结果。

滑动窗口用一个 for 循环来完成区间搜索操作

  1. 循环的索引,一定是表示滑动窗口的终止位置,和快指针一个道理。
  2. 根据当前子序列和大小的情况,不断调节子序列的开始位置,类似调整慢指针。
  3. O(n^2) 暴力解法降为 O(n)。

我的代码 1

class Solution {
public:
    int minSubArrayLen(int target, vector<int>& nums) {
        int left = 0;          // 滑动窗口的左边界
        int sum = 0;           // 当前窗口内元素的和
        int len = INT32_MAX;   // 初始化最短长度为最大值
        for (int right = 0; right < nums.size(); right++) { // 右边界向右扩展
            sum += nums[right];  // 将当前元素加入窗口
            while (sum >= target) { // 如果当前窗口的和满足条件
                len = min(len, right - left + 1); // 更新最短长度
                sum -= nums[left++]; // 移动左边界,缩小窗口
            }
        }
        return len == INT32_MAX ? 0 : len; // 如果未找到满足条件的子数组,返回 0
    }
};

我的代码 2

// 使用同向的快慢指针去理解 acm 模式
#include <bits/stdc++.h>
using ll = long long; 
using namespace std;

int minSubArrayLen(int target, vector<int>& nums);

signed main() {
	ios::sync_with_stdio(0);
	cin.tie(0);
	cout.tie(0);
	
	int target;cin >> target;
	int num;
	vector<int> nums;
	while(cin >> num) {
		nums.push_back(num);
	}
	cin.clear();
	cin.ignore(numeric_limits<streamsize>::max(), '\n');
	
	cout << minSubArrayLen(target, nums) << endl;
}

int minSubArrayLen(int target, vector<int>& nums) {
	int n = nums.size();
	
	ll sum = 0;
	int slow = 0;
	int len = INT32_MAX;
	for(int fast = 0; fast <= n - 1; fast ++) {
		sum += nums[fast];
		while(sum >= target) {
			len = min(len, fast - slow + 1);
			sum -= nums[slow ++]; // 下一个起始位置 
		}
	}
	
	return (len == INT_MAX? 0 : len); 
}

疑问点

  1. **for 循环 + while 循环,为什么时间复杂度是 O(n)。**因为:虽然有嵌套的 for 和 while 循环,但每个元素最多被访问两次(一次加入窗口,一次移出窗口)。因此,总的操作次数是线性的,时间复杂度为 O(n)

904. 水果成篮

904. 水果成篮

我的代码

// 力扣模式
class Solution {
public:
    // 成员函数 totalFruit,接收一个整数向量 fruits 作为参数,返回一个整数
    int totalFruit(std::vector<int>& fruits) {
        // 获取水果数组的长度
        int n = fruits.size();
        // 如果数组长度小于等于 1,那么能采摘的最大水果数量就是数组长度本身
        if(n <= 1) return n;

        // 定义滑动窗口的左指针,初始化为 0
        int slow = 0;
        // 定义一个 map 容器 mp,用于存储每种水果及其出现的次数
        // 这里使用 map 是因为它会自动按照键(水果种类)的升序排序,方便后续处理
        std::map<int, int> mp;
        // 定义一个变量 result 用于记录最大的水果采摘数量,初始化为 0
        int result = 0;

        // 右指针 fast 从 0 开始遍历数组
        for(int fast = 0; fast <= n - 1; fast ++) {
            // 将当前水果种类存入 map 中,并将其出现次数加 1
            // 如果该水果种类之前不存在,会自动插入一个新的键值对,值初始化为 0 后再加 1
            mp[fruits[fast]] ++;
            // 当 map 中存储的水果种类超过 2 种时,需要调整窗口
            while(mp.size() > 2) {
                // 获取当前左指针指向的水果种类
                int o = fruits[slow];
                // 将该水果种类的出现次数减 1
                mp[o] --;
                // 如果该水果种类的出现次数变为 0,说明该种类的水果在当前窗口中已经没有了
                // 则从 map 中删除该水果种类的记录
                if(mp[o] == 0) {
                    mp.erase(o);
                }
                // 左指针右移,缩小窗口
                slow ++; 
            }

            // 计算当前窗口的长度,即当前可以采摘的水果数量
            // fast - slow + 1 表示当前窗口内水果的数量
            // 更新最大采摘数量
            result = std::max(result, fast - slow + 1);
        }
        // 返回最大采摘数量
        return result;
    }
};

我的疑问点

  1. 要保持字典序的种类不超多 2,怎么实现?while 循环使用 size 函数和 erase 函数。mp.size() > 2; if(mp[o]) == 0 { mp.erase(o) };
  2. unordered_map:基于哈希表实现。它通过哈希函数将键映射到哈希表的特定位置,以此来存储和查找键值对。哈希函数的设计目的是尽可能均匀地将键分布到哈希表中,以减少哈希冲突。当发生哈希冲突(即不同的键映射到了同一个位置)时,通常会使用链表或红黑树等方法来处理冲突;map:基于红黑树(一种自平衡的二叉搜索树)实现。红黑树的每个节点存储一个键值对,并且满足左子树的所有节点键值小于根节点,右子树的所有节点键值大于根节点的特性,同时通过一些规则保证树的平衡,从而保证插入、查找和删除操作的时间复杂度为对数级别。unordered_map:在平均情况下,插入、查找和删除操作的时间复杂度为 O(1),最坏情况下(哈希冲突严重),时间复杂度会退化为 O(n)。map:插入、查找和删除操作的时间复杂度始终为 O(logn),这是由红黑树的性质决定的,无论数据量大小,操作时间都相对稳定。
  3. 如果 mp[o] 的值变为 0,此时这个键值对仍然存在于 mp 中。size() 也会对他进行统计
  4. 题解绝了

76. 最小覆盖子串(第一次见)

76. 最小覆盖子串 - 困难 - 力扣; 209 长度最小的子数组作为本题目的铺垫,都是[最短型]滑动窗口。

我的代码

class Solution {
public:
	// 力扣模式
	// 时间复杂度:O(n)
	// 空间复杂度:O(k)
    // 功能:在字符串 s 中找出包含字符串 t 所有字符的最小窗口子串
    string minWindow(string s, string t) {
        int n = s.length(), m = t.length();
        // 若 s 长度小于 t,无法包含 t 所有字符,返回空串
        if (n < m) return "";

        unordered_map<char, int> cnt;
        int less = 0;
        // 统计 t 中字符出现次数,记录不同字符种类数
        for (char c : t) {
            if (cnt[c] == 0) ++less;
            ++cnt[c];
        }

        int nleft = -1, nright = n;
        int left = 0;
        // 右指针扩大窗口
        for (int right = 0; right < n; ++right) {
            char c = s[right];
            --cnt[c];
            // 若该字符数量满足 t 需求,缺少的字符种类数减 1
            if (cnt[c] == 0) --less;

            // 窗口包含 t 所有字符,尝试缩小窗口
            while (less == 0) {
                // 更新最小窗口端点
                if (right - left < nright - nleft) {
                    nright = right;
                    nleft = left;
                }
                char o = s[left];
                ++cnt[o];
                // 若移除字符后不满足需求,缺少的字符种类数加 1
                if (cnt[o] > 0) ++less;
                ++left;
            }
        }
        // 若未找到,返回空串,否则返回最小窗口子串
        return nleft == -1 ? "" : s.substr(nleft, nright - nleft + 1);
    }
};

我的疑惑点

  1. 法一:每次使用检测个数函数。一旦 t > s 就输出 false --> 统计个数还有个目的,看是否覆盖。
  2. 花费 O(∣Σ∣) 的时间去判断是否涵盖,能不能优化到 O(1) 呢?用一个变量 less 维护目前子串中有 less 种字母的出现次数小于 t 中字母的出现次数。
  3. 题目明确说明字符串 s 和 t 由英文字母组成,代码中仍将数组大小设为 128,主要是从代码实现的简洁性、通用性和便利性等多方面来考量
  4. 法二:在同一个计数数组里进行操作。法三:在两条计数数组外加一个 less 随时记录两者个数是否相等的情况。
  5. std::string 类的 substr 函数用于从原字符串中提取一个子字符串,其一般形式为:string.substr(pos, len),其中 pos 表示子字符串在原字符串中开始的位置(索引从 0 开始),len 表示子字符串的长度。
  6. 可以将 std::map 替换为 std::unordered_map,因为 std::unordered_map 的平均插入和查找操作的时间复杂度为 O(1),这样可以将整体时间复杂度优化到 O(n)。

59. 螺旋矩阵 II

59.螺旋矩阵 II - 中等 - 力扣

59. 螺旋矩阵 IIB 站学习视频

思考

我的胡思乱想:用题目给的例子 n = 3 去模拟一遍。第一次做,第一想法是模拟递归,方向是右->下->左->上,拐弯(这样思考模拟下一个方向边界处理每一次发生变化)。

去看算法文档:关键是在转圈的逻辑,理解并使用二分搜索中提到的区间定义。

我的代码

// 力扣模式 
// 时间复杂度:O(n^2) 
// 空间复杂度:O(1)
class Solution {
public:
    vector<vector<int>> generateMatrix(int n) {
        
        vector<vector<int>> nums(n, vector<int>(n, 0));
        int nx = 0, ny = 0; // 起点
        int cc = 1; // 层数
        int cnt = 0; // 计数
        // 循环
        int x, y;
        int t = n/2;
        while(t --) {
            for(y = ny; y < n - cc; y ++) {
                nums[nx][y] = ++ cnt;
            }

            for(x = nx; x < n - cc; x ++) {
                nums[x][y] = ++ cnt;
            }

            for(; y > ny; y --) {
                nums[x][y] = ++ cnt;
            }

            for(; x > nx; x --) {
                nums[x][ny] = ++ cnt;
            } 
            // 更新起点
            nx ++;
            ny ++;
            cc ++;
        }
        if(n % 2 == 1) {
            int mid = n / 2;
            nums[mid][mid] = n * n;
        }
        return nums;
    }
};

我的疑惑

  1. 循环不变量原则:左闭右开的规则来处理。n 为偶数,完整圈数是 n / 2; n 为奇数。完整圈数是 n / 2,还要遍历正中心坐标位置。
  2. 几种常见的正确初始化二维 vector 的方式:
  • vector<vector> nums(n, vector()); 初始化包含一个 n 个为空的 vector 的二维 vector。输出二维 vector 的行数 nums.size(); 输出列数 nums[i].size();
  • vector<vector> nums(n, vector(m, 0)); 初始化一个 n 行 m 列的二维 vector,初始值都为 0;
  1. 为什么使用 while(cnt < n * n) 运行时间超时。

54.螺旋矩阵

54.螺旋矩阵 - 中等 - 力扣

我的代码

// 力扣模式
// 时间复杂度:O(n*m)
// 空间复杂度:O(1)
class Solution {
public:
    vector<int> spiralOrder(vector<vector<int>>& matrix) {

        // 几行几列
        int m = matrix.size();
        int n = matrix[0].size();
        vector<int> result(n * m, 0);
        // 上下左右
        int top = 0, bottom = m - 1;
        int left = 0, right = n - 1;

        int cnt = 0, row, col;

        while (top <= bottom && left <= right) {
            // 最上面一排
            for (col = left; col <= right; col++) {
                result[cnt++] = matrix[top][col];
            }
            top++;
            // 最右边一列
            if (top <= bottom) {
                for (row = top; row <= bottom; row++) {
                    result[cnt++] = matrix[row][right];
                }
            }
            right--;
            // 最下面一排
            if (top <= bottom && left <= right) {
                for (col = right; col >= left; col--) {
                    result[cnt++] = matrix[bottom][col];
                }
            }

            bottom--;
            // 最左边一列
            if (top <= bottom && left <= right) {
                for (row = bottom; row >= top; row--) {
                    result[cnt++] = matrix[row][left];
                }
            }
            left++;
        }
        return result;
    }
};

我的疑惑

  1. 记录矩阵的行数 rows 和列数 cols。定义四个边界变量 left、right、top 和 bottom,分别表示矩阵的左、右、上、下边界。每次遍历完一条边后,相应的边界向内收缩。
  2. 依旧 while 循环条件没有想清楚,导致超时;while (left <= right && top <= bottom) 可以。能够自动适应 n != m 的情况。
  3. 我的 while 循环是整除 2 的倍数,不适应 n != m 的情况。
  4. 当矩阵的行数或列数较少时,例如只有一行或一列,在执行从右到左和从下到上的遍历操作时,可能会重复访问之前已经访问过的元素。比如当矩阵只有一行时,在执行完从左到右的遍历(第一排)之后,top 会加 1,此时 top 就会大于 bottom,但代码仍然会执行后续的从右到左以及从下到上的遍历,从而导致重复访问。

LCR 146. 螺旋遍历二维数组

LCR 146. 螺旋遍历二维数组 - 简单 - 力扣

我的代码

class Solution {
public:
    vector<int> spiralArray(vector<vector<int>>& array) {
        if (array.empty())
            return {};
        int rows = array.size();
        int cols = array[0].size();
        vector<int> result;
        int left = 0, right = cols - 1, top = 0, bottom = rows - 1;

        while (left <= right && top <= bottom) {
            // 从左到右遍历上边界
            for (int col = left; col <= right; ++col) {
                result.push_back(array[top][col]);
            }
            top++;

            // 从上到下遍历右边界
            for (int row = top; row <= bottom; ++row) {
                result.push_back(array[row][right]);
            }
            right--;

            // 当 top <= bottom 时,从右到左遍历下边界
            if (top <= bottom) {
                for (int col = right; col >= left; --col) {
                    result.push_back(array[bottom][col]);
                }
            }
            bottom--;

            // 当 left <= right 时,从下到上遍历左边界
            if (left <= right) {
                for (int row = bottom; row >= top; --row) {
                    result.push_back(array[row][left]);
                }
            }
            left++;
        }

        return result;
    }
};

###我的疑惑点

  1.     if (array.empty())
         return {};
    

区间和(前缀和)

区间和 - 卡网

一年前我接触的第一个算法技巧,也是面试题目。

在处理与数组相关的查询和更新操作时。为了高效解决这查询区间问题,通常会使用“前缀和”技术

第一次接触的小伙伴可以搜索相关资料,网上讲解资料很多。

我的代码

#include <bits/stdc++.h>

using namespace std;

signed main() {
	ios::sync_with_stdio(0);
	cin.tie(0);
	cout.tie(0);
	
	int n; cin >> n;
	vector<int> nums(n + 1, 0);
	vector<int> pre(n + 1, 0);
	// 从下标 1 开始存放
	for(int i = 1; i <= n; i ++) {
		int num;
		cin >> num;
		nums[i] = num;
		
		// 前缀和预处理 
		pre[i] = pre[i - 1] + nums[i];
	}
	
	// 随机输入几个区间查询
	int l, r; // 实际是 l+1,r+1 
	while(cin >> l >> r) {
		int result = pre[r + 1] - pre[l];
		cout << result << '\n';
	} 	 
}

开发商购买土地

开发商购买土地 - 卡网

  • 上一题是前缀和 本题是差分;都是最典型的常见技巧

我的代码

#include <bits/stdc++.h>

using namespace std;

// n * m nums[] 权值
// 要求横向 或者 纵向 划分为 A B 两块;找到总权值差最小

int diff(vector<int> num_d, int total) {
	int min_d = INT32_MAX;
	int sum = 0;
	for(int i = 0; i < num_d.size() - 1; i ++) {
		sum = sum + num_d[i];
		int t = total - sum;
		int dd_d = abs(t - sum);
		min_d = min(min_d, dd_d);
	}
	return min_d;	
}

signed main() {
	
	ios::sync_with_stdio(0);
	cin.tie(0);
	cout.tie(0);
	
	int n, m; cin >> n >> m; // n 行 m 列 
	vector<vector<int>> nums(n, vector<int>(m, 0)); // 二维数组 
	int total = 0; // 总权重 
	
	for(int i = 0; i < n; i ++) {
		for(int j = 0; j < m; j ++) {
			int num;
			cin >> num;
			total += num; 
			nums[i][j] = num;
		} 
	}
	
	// rows[] = 每一行的权重 
	vector<int> rows(n);
	for(int i = 0; i < n; i ++) {
		int sum = 0;
		for(int j = 0; j < m; j ++) {
			sum += nums[i][j];
		} 
		rows[i] = sum;
	}
	
	// cols[] = 每一列的权重 
	vector<int> cols(m);
	for(int j = 0; j < m; j ++) {
		int sum = 0;
		for(int i = 0; i < n; i ++) {
			sum += nums[i][j];
		} 
		cols[j] = sum;
	}
	// rows 和 cols 分别做差分;只要记录差分的最小值;
	int rows_d = diff(rows, total);
	int cols_d = diff(cols, total);
	
	cout << (rows_d < cols_d ? rows_d : cols_d) << '\n'; 
}
#include <bits/stdc++.h>

using namespace std;


signed main() {
	
	ios::sync_with_stdio(0);
	cin.tie(0);
	cout.tie(0);
	
	int n, m;
	cin >> n >> m;
	
	vector<vector<int>> vec(n+1, vector<int>(m+1, 0));
	for(int i = 1; i <= n; i ++) {
		for(int j = 1; j <= m; j ++) {
			int num;cin >> num;
			vec[i][j] = num + vec[i - 1][j] + vec[i][j - 1] - vec[i - 1][j - 1]; // 推理出前缀和公式 
		}
	}
	// 算差值
    for(int i = 1; i < n; i++)
    {
        int x = abs(s[n][m] - 2 * s[i][m]);
        ans = min(ans, x);
    }

    // 尝试纵向划分矩阵,i 表示划分的列
    for(int i = 1; i < m; i++)
    {
        int x = abs(s[n][m] - 2 * s[n][i]);
        ans = min(ans, x);
    }
    cout << ans; 
}

总结

本期重点在于掌握滑动窗口和循环不变量原则,感受边界处理的重要性。通过优化算法,显著提升了问题解决效率。下期将继续深入学习更多算法知识