算法训练营 Day1-数组 1 | 二分查找 | 移除元素 | 有序数组的平方
查阅文档地址:programmercarl.com/
本期题目地址: 704.二分查找 - 力扣; 27.移除元素 - 力扣; 977. 有序数组的平方 - 力扣;
本期题目答案地址:704 解法一、二、三跳转链接;34. 解法跳转链接 - 力扣;
本期同类题目地址:35.搜索插入位置,简单 ;34.在排序数组中查找元素的第一个和最后一个位置,中等;69.x 的平方根,简单;367.有效的完全平方数,简单;26. 删除有序数组中的重复项 - 简单 - 力扣;844. 比较含退格的字符串 - 力扣 - 简单;
目录:
- 基本概念(做题前要理解的概念)
- 我的解法
- 疑问点(过程中产生了问题并且查找资料解决)
语言
采用C++,一些分析也是用于 C++,请注意。
基本概念
- 数组是存放在连续内存空间上的相同类型数据的集合。 在删除、添加元素等操作时候,通过连续的下标/地址遍历其他元素,进行新的赋值操作。(用赋值来实现删除和添加元素)
- C++ 中二维数组在地址空间上是连续的。不同编程语言的内存管理是不一样的。(可以用取址符&来验证,测试后输出内存地址是 16 进制)ps:Java 寻址操作交给虚拟机,Java 没有指针,查看内存地址使用 System.out.println(a[0]);
- 前面 1 和 2 两点讲的是数组(array)的特性。C++ 中,vector 是容器,不是数组,是动态数组的封装,std::vector 它的底层实现是动态数组;std::array 是静态数组的封装,它的底层实现是静态数组。
- 如果需要动态调整大小,使用 std::vector;如果大小固定且已知,使用 std::array。vector 和数组在内存布局上类似,但它们的行为和功能有所不同。
- 二分查找的前提条件是有序;查找时候开区间和闭区间是有差异的,要根据循环不变量原则(推荐闭区间,以为极端情况下,半开半闭区间多查找一次)。
- 二分查找时间复杂度:O(log n)。
- 双指针法(快慢指针法)在数组和链表的操作中是非常常见的,很多考察数组、链表、字符串等操作的面试题,都使用双指针法。比如在链表中删除节点或在数组中去重。
- 数组的非递减顺序是指非严格递增,严格递增是[1, 2, 3, 4]不会出现一样的数字,非严格递增[1, 1, 2, 2, 3, 4, 5]
- int 类型在大多数现代系统中通常是 32 位,其范围是:-2^31 ~ 2^31 - 1。long long 是 64 位整数类型,其范围是:-2^63 ~ 2^63 - 1。
704. 二分查找
我的代码解法一“闭区间”
// 力扣模式
class Solution {
public:
int search(vector<int>& nums, int target) {
int n = nums.size(); // 使用 nums.size() 获取数组长度
int left = 0;
int right = n - 1; // 注意
while(left <= right) {
int mid = (left + right ) / 2;
if(nums[mid] == target) return mid;
if(nums[mid] > target) right = mid - 1;
else left = mid + 1;
}
return -1;
}
};
//解法一、闭合区间 (acm 模式)
#include <bits/stdc++.h>
using namespace std;
// 二分查找函数:在有序数组 nums 中查找目标值 target
int search(vector<int>& nums, int target) {
int left = 0; // 左边界初始化为数组起始位置
int right = nums.size() - 1; // 右边界初始化为数组末尾位置
while (left <= right) { // 当左边界不超过右边界时继续查找
int middle = left + ((right - left) >> 1); // 计算中间索引(位运算优化)
if (nums[middle] == target) // 如果找到目标值,返回其索引
return middle;
if (nums[middle] > target) // 如果中间值大于目标值,搜索左半部分
right = middle - 1;
else // 否则,搜索右半部分
left = middle + 1;
}
return -1; // 如果未找到目标值,返回 -1
}
int main() {
ios::sync_with_stdio(0); // 优化输入输出性能
cin.tie(0);
cout.tie(0);
vector<int> nums; // 存储输入的有序数组
int num;
// 读取用户输入的有序数组,直到输入非数字字符
while (cin >> num) {
nums.push_back(num); // 将输入的数字加入数组
}
cin.clear(); // 清除输入流的错误状态
cin.ignore(numeric_limits<streamsize>::max(), '\n'); // 清空输入缓冲区
int target; // 目标值
cin >> target; // 读取目标值
int result = search(nums, target); // 调用二分查找函数
cout << result << endl; // 输出结果(目标值的索引或 -1)
return 0;
}
我的代码解法二,查找区间左闭右开
// 力扣模式
class Solution {
public:
int search(vector<int>& nums, int target) {
int left = 0;
int right = nums.size(); // 不再减一,以为右边开区间
while (left < right) {
int middle = left + ((right - left) >> 1);
if (nums[middle] == target)
return middle;
if (nums[middle] > target) {
right = middle; // 右边开区间,不必 -1
} else {
left = middle + 1;
}
}
return -1;
}
};
我的代码解法三,查找区间左开右闭
// 在最坏情况下,开区间实现的二分查找可能会多进行一次循环,这会导致时间复杂度从 O(log n) 变为 O(log n + 1)。虽然这在大多数情况下不会显著影响性能,但在某些极端情况下,这可能会导致超时。
// 力扣模式下,运行超时
int search(vector<int>& nums, int target) {
int left = -1; // left 开区间
int right = nums.size() - 1;
while(left < right) {
int middle = left + ((right - left) >> 1);
if(nums[middle] == target) {
return middle;
} else if(nums[middle] > target) {
right = middle - 1;
} else {
left = middle; // left 开区间
}
}
return -1;
}
疑问点
- 如果需要被查找的数据范围变大,怎么计算 middle: int middle = left + ((right - left) >> 1);防止溢出,因为 left + right 可能会超出 int 类型的表示范围。可读性更好的是 int middle = left + ((right - left) % 1);
- 动态数组大小的获取使用什么默认方法: std::vector 的大小获取:使用 size() 方法。 std::string 的长度获取:可以使用 length() 或 size()。 普通数组的大小获取:使用 sizeof(array) / sizeof(array[0]),但不适用于动态数组。
- AI 给的答案里使用了清楚缓冲区的方法: cin.clear() 和 cin.ignore() 的组合用于清除输入流的错误状态并清空输入缓冲区。它们通常一起使用,解决用户输入错误或缓冲区残留的问题。
- std::前缀问题: 添加了 using namespace std;,这样可以避免在代码中频繁使用 std:: 前缀。
- 取消同步流: std::ios::sync_with_stdio(false)、std::cin.tie(nullptr) 和 std::cout.tie(nullptr) 是用于优化输入输出性能的语句。它们通常用于竞赛编程中,以减少输入输出操作的时间开销。
- 禁止混用:使用 std::ios::sync_with_stdio(false) 后,不能混用 C 和 C++ 的输入输出函数。
- 位运算符 >> 的优先级低于加法和减法运算符。
- 粗心: 比较 nums[middle] 和 target,而不是直接比较 middle 和 target。
- 对循环输入的研究: while (cin >> num) 是一个常见的输入循环,它会持续读取输入,直到输入流(cin)遇到无效输入(如非数字字符)。这个是 C++ 的输入流机制决定的。
- 在 cin >> num 中,换行符(\n)被视为分隔符,而不是无效输入。因此,当你按下回车键时,cin 会等待下一个有效的输入。
- 希望用户可以通过换行符来终止输入: 可以使用 std::getline 来读取整行输入,并逐行解析数字。使用 getline 读取整行输入,使用 stringstream 解析数字,stringstream ss(line)。(在某些输入环境中(如某些在线评测系统或 IDE),getline 的行为可能与预期不同,导致需要多次按回车。)
34.在排序数组中查找元素的第一个和最后一个位置
34.在排序数组中查找元素的第一个和最后一个位置,中等 - 力扣;
我的代码解法
// acm 模式 力扣里运行时间超过了 100% 的用户
#include <bits/stdc++.h>
using namespace std;
vector<int> searchRange(vector<int>& nums, int target);
int main() {
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
int num;
vector<int> nums;
while(cin >> num) {
nums.push_back(num);
}
cin.clear();
cin.ignore(numeric_limits<streamsize>::max(), '\n');
int target;cin >> target;
vector<int> result = searchRange(nums, target);
copy(result.begin(), result.end(), ostream_iterator<int>(cout, " "));
}
vector<int> searchRange(vector<int>& nums, int target) {
int left = 0;
int right = nums.size() - 1;
vector<int> results(2,-1);
// left 找最右的边界
while(left <= right) {
int mid = left + ((right - left) >> 1);
if(nums[mid] <= target) {
if(nums[mid] == target) results[1] = mid;
left = mid + 1;
} else {
right = mid - 1;
}
}
// right 找最左边界
left = 0; right = nums.size() - 1;
while(left <= right) {
int mid = left + ((right - left) >> 1);
if(nums[mid] < target) {
left = mid + 1;
} else {
if(nums[mid] == target) results[0] = mid;
right = mid - 1;
}
}
return results;
}
我的疑惑点
-
34.在排序数组中查找元素的第一个和最后一个位置,中等。vector searchRange(vector& nums, int target) 我怎么在函数中返回 vector类型的答案。return result; vector result(2, -1) 表示初始化一个长度为 2 的 vector,默认值为 -1。
-
在 C++ 中,std::vector 是一个容器,而不是普通的数组。因此,你不能直接使用 std::cout 来输出整个 std::vector(cout<<不支持容器类)。方法 1:使用循环逐个输出;方法二、熟悉迭代器。使用 std::ostream_iterator 或 std::copy。vector result = searchRange(nums, target);
for (int num : result) { cout << num << " "; } 或者 copy(result.begin(), result.end(), ostream_iterator(cout, " "));
-
根据区间不变的原则,我优先采用闭区间,那查找条件是 left <= right。
367.有效的完全平方数
我的代码 - 二分
// 力扣模式
class Solution {
public:
bool isPerfectSquare(int num) {
if (num == 1) return true; // 特殊情况处理
long long left = 1;
long long right = num / 2;
while (left <= right) {
long long mid = left + (right - left) / 2; // 使用 long long 避免溢出
if (mid * mid == num) {
return true;
} else if (mid * mid < num) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return false;
}
};
我的疑惑点
- #define int long long // 宏定义;typedef 或 using,它们是 C++ 中用于定义别名的语法工具;typedef long long int64;using int64 = long long;与宏定义相比,typedef 和 using 是类型安全的,不会改变语言的基本语义。更推荐使用 using,语义更好理解。
- 单独处理 1 后,问题仍然在于 整数溢出。通过将 left、right 和 mid 的类型改为 long long。
- 为了避免溢出,我们需要确保在计算 mid * mid 时,mid 的类型提升为 long long。这样,mid * mid 的结果也会被正确地存储为 long long 类型,从而避免溢出。通过显式声明 mid 为 long long,可以安全地处理大数运算。
27. 移除元素
我的代码
class Solution {
public:
int removeElement(vector<int>& nums, int val) {
int n = nums.size() - 1;
int cur = 0;
for(int i = 0; i <= n; i ++) {
if(nums[i] != val) {
nums[cur ++] = nums[i];
}
}
return cur;
}
};
时间复杂度 O(n)
我用的是双指针法。算法文档中这句话说得很恰当:数组的元素在内存地址中是连续的,不能单独删除数组中的某个元素,只能覆盖。
- 双指针法(快慢指针法)在数组和链表的操作中是非常常见的,很多考察数组、链表、字符串等操作的面试题,都使用双指针法。
- 覆盖操作:nums[cur ++] = nums[i];
- 暴力解法使用两次 for 循环,模拟快慢指针。时间复杂度是 O(nlogn)。
26. 删除有序数组中的重复项
我的代码
// 力扣版本
class Solution {
public:
int removeDuplicates(vector<int>& nums) {
// 非严格递增排列 nums 数组。
// 删去重复,返回长度
int slow = 0;
for (int fast = 0; fast < nums.size(); fast++) {
if(nums[slow] != nums[fast]) {
nums[++slow] = nums[fast];
}
}
return slow+1;
}
};
我的疑惑
- 出错:nums[slow ++] = nums[fast]; 当都指向同一个元素时候 nums[slow ++] 会将当前元素覆盖为自身,逻辑出现错误。此时输入 1 1 2,会输出 2 1。
- 使用库函数,去重。unique(nums.begin(), nums.end()) - nums.begin(); 通常与容器(如 std::vector、std::list 等)配合使用。unique 的作用是移除容器中连续的重复元素,并将这些元素移动到容器的末尾。它返回一个迭代器,指向“去重后”容器中最后一个唯一元素的下一个位置。因此,通过这个返回值,你可以知道去重后的元素范围。
- auto last = unique(nums.begin(), nums.end());
- int uniqueCount = last - nums.begin(); // 去重后唯一元素的数量
- nums.erase(last, nums.end()); // 移除多余的元素
844. 比较含退格的字符串
我的代码 1
// 力扣模式
class Solution {
public:
void act(string& str) {
int slow = 0;
// 时间复杂度 O(n) 空间复杂度 O(1)
for(int fast = 0; fast <= str.length() - 1; fast ++) {
if(str[fast] != '#') {
str[slow ++] = str[fast];
} else if(str[fast] == '#' && slow > 0) { v// 此处一定要有 slow > 0
slow --;
}
}
str.resize(slow);
}
bool backspaceCompare(string s, string t) {
act(s);
act(t);
return s == t;
}
};
代码 2
// 力扣模式
class Solution {
public:
void act(string& str) {
vector<char> stack;
for(char c : str) {
if(c == '#') {
if(!stack.empty()) {
stack.pop_back();
}
} else {
stack.push_back(c);
}
}
str = "";
for(char c : stack) {
str += c;
}
}
bool backspaceCompare(string s, string t) {
act(s);
act(t);
return s == t;
}
};
我的疑惑
- 写 act()辅助函数的参数时,&是必须的嘛?引用(void act(string& str))是合适的,它直接修改传入的字符串,避免了不必要的复制。当然也可以不使用引用,而是返回修改后的字符串。s = act(s); // 需要显式地处理返回值
- 显式处理。(代码更加安全、函数的副作用更加小)
// 显式处理
string s = "ab#";
s = act(s); // 需要显式处理。
cout << s << endl;
- 在循环结束后调用 str.resize(slow),确保所有操作都在安全范围内。在循环中直接修改字符串的大小可能会导致迭代器失效或越界访问。一定要有 slow > 0。
// resize 使用教学
vector<int> vec = {1, 2, 3};
vec.resize(5); // 多的默认为 0
vec.resize(2);
string str = "Hello";
str.resize(3);
str.resize(10, '#');
977.有序数组的平方
我的代码
// 先遍历做平方操作,再做排序操作。时间复杂度 O(nlogn)
class Solution {
public:
vector<int> sortedSquares(vector<int>& nums) {
for(int i = 0; i < nums.size() ; i ++) {
nums[i] = nums[i] * nums[i];
}
sort(nums.begin(), nums.end()); // sort 底层基于快速排序,操作时间复杂度 O(nlogn)
return nums;
}
}
思考
看了算法文档,原来可以使用双指针法进行优化,时间复杂度 O(n)。
思路是:数组本身有序,对数组每个数字进行平方,最大的均匀在两个最顶端。 设置 left 和 right 指针分别在最左右两端,开一个新数组,比较 left 和 right 选取大的放入新数组末尾,再改变指针区域,直到全部放入。
class Solution {
public:
vector<int> sortedSquares(vector<int>& nums) {
vector<int> res(nums.size(),0);
int t = nums.size() - 1;
for(int left = 0, right = nums.size()-1; left <= right;) {
if(nums[left]*nums[left] > nums[right]*nums[right]) {
res[t --] = nums[left]*nums[left];
left ++;
} else {
res[t --] = nums[right]*nums[right];
right --;
}
}
return res;
}
};
为什么使用双指针法可以优化时间复杂度?可以思考一下~ 它可以利用数组的有序性,避免嵌套循环,能够原地改变数据。
总结
要掌握的知识点放在了开头,可以到顶端看一看。
二分查找:注意区间定义和边界条件。
移除元素:双指针法的应用。
有序数组的平方:双指针法优化排序
本期能够非常直观的感受到快慢指针的灵魂,下期再见~