算法训练营 Day1-数组 1 | 二分查找 | 移除元素 | 有序数组的平方

71 阅读13分钟

算法训练营 Day1-数组 1 | 二分查找 | 移除元素 | 有序数组的平方

查阅文档地址:programmercarl.com/

本期题目地址: 704.二分查找 - 力扣27.移除元素 - 力扣977. 有序数组的平方 - 力扣

本期题目答案地址:704 解法一、二、三跳转链接34. 解法跳转链接 - 力扣

本期同类题目地址:35.搜索插入位置,简单34.在排序数组中查找元素的第一个和最后一个位置,中等69.x 的平方根,简单367.有效的完全平方数,简单26. 删除有序数组中的重复项 - 简单 - 力扣844. 比较含退格的字符串 - 力扣 - 简单

目录:

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

语言

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

基本概念

  1. 数组是存放在连续内存空间上的相同类型数据的集合。 在删除、添加元素等操作时候,通过连续的下标/地址遍历其他元素,进行新的赋值操作。(用赋值来实现删除和添加元素)
  2. C++ 中二维数组在地址空间上是连续的。不同编程语言的内存管理是不一样的。(可以用取址符&来验证,测试后输出内存地址是 16 进制)ps:Java 寻址操作交给虚拟机,Java 没有指针,查看内存地址使用 System.out.println(a[0]);
  3. 前面 1 和 2 两点讲的是数组(array)的特性。C++ 中,vector 是容器,不是数组,是动态数组的封装,std::vector 它的底层实现是动态数组;std::array 是静态数组的封装,它的底层实现是静态数组。
  4. 如果需要动态调整大小,使用 std::vector;如果大小固定且已知,使用 std::array。vector 和数组在内存布局上类似,但它们的行为和功能有所不同。

  1. 二分查找的前提条件是有序;查找时候开区间和闭区间是有差异的,要根据循环不变量原则(推荐闭区间,以为极端情况下,半开半闭区间多查找一次)。
  2. 二分查找时间复杂度:O(log n)。

  1. 双指针法(快慢指针法)在数组和链表的操作中是非常常见的,很多考察数组、链表、字符串等操作的面试题,都使用双指针法。比如在链表中删除节点或在数组中去重。
  2. 数组的非递减顺序是指非严格递增,严格递增是[1, 2, 3, 4]不会出现一样的数字,非严格递增[1, 1, 2, 2, 3, 4, 5]

  1. int 类型在大多数现代系统中通常是 32 位,其范围是:-2^31 ~ 2^31 - 1。long long 是 64 位整数类型,其范围是:-2^63 ~ 2^63 - 1。

704. 二分查找

704.二分查找 - 力扣 题目跳转链接

704 题目解答跳转链接 解法 1、2、3

我的代码解法一“闭区间”

    // 力扣模式
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; 
}

疑问点

  1. 如果需要被查找的数据范围变大,怎么计算 middle: int middle = left + ((right - left) >> 1);防止溢出,因为 left + right 可能会超出 int 类型的表示范围。可读性更好的是 int middle = left + ((right - left) % 1);
  2. 动态数组大小的获取使用什么默认方法: std::vector 的大小获取:使用 size() 方法。 std::string 的长度获取:可以使用 length() 或 size()。 普通数组的大小获取:使用 sizeof(array) / sizeof(array[0]),但不适用于动态数组。
  3. AI 给的答案里使用了清楚缓冲区的方法: cin.clear() 和 cin.ignore() 的组合用于清除输入流的错误状态并清空输入缓冲区。它们通常一起使用,解决用户输入错误或缓冲区残留的问题。
  4. std::前缀问题: 添加了 using namespace std;,这样可以避免在代码中频繁使用 std:: 前缀。
  5. 取消同步流: std::ios::sync_with_stdio(false)、std::cin.tie(nullptr) 和 std::cout.tie(nullptr) 是用于优化输入输出性能的语句。它们通常用于竞赛编程中,以减少输入输出操作的时间开销。
  6. 禁止混用:使用 std::ios::sync_with_stdio(false) 后,不能混用 C 和 C++ 的输入输出函数。
  7. 位运算符 >> 的优先级低于加法和减法运算符。
  8. 粗心: 比较 nums[middle] 和 target,而不是直接比较 middle 和 target。
  9. 对循环输入的研究: while (cin >> num) 是一个常见的输入循环,它会持续读取输入,直到输入流(cin)遇到无效输入(如非数字字符)。这个是 C++ 的输入流机制决定的。
  10. 在 cin >> num 中,换行符(\n)被视为分隔符,而不是无效输入。因此,当你按下回车键时,cin 会等待下一个有效的输入。
  11. 希望用户可以通过换行符来终止输入: 可以使用 std::getline 来读取整行输入,并逐行解析数字。使用 getline 读取整行输入,使用 stringstream 解析数字,stringstream ss(line)。(在某些输入环境中(如某些在线评测系统或 IDE),getline 的行为可能与预期不同,导致需要多次按回车。)

34.在排序数组中查找元素的第一个和最后一个位置

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;
}

我的疑惑点

  1. 34.在排序数组中查找元素的第一个和最后一个位置,中等。vector searchRange(vector& nums, int target) 我怎么在函数中返回 vector类型的答案。return result; vector result(2, -1) 表示初始化一个长度为 2 的 vector,默认值为 -1。

  2. 在 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, " "));

  3. 根据区间不变的原则,我优先采用闭区间,那查找条件是 left <= right。

367.有效的完全平方数

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;
    }
};

我的疑惑点

  1. #define int long long // 宏定义;typedef 或 using,它们是 C++ 中用于定义别名的语法工具;typedef long long int64;using int64 = long long;与宏定义相比,typedef 和 using 是类型安全的,不会改变语言的基本语义。更推荐使用 using,语义更好理解。
  2. 单独处理 1 后,问题仍然在于 整数溢出。通过将 left、right 和 mid 的类型改为 long long。
  3. 为了避免溢出,我们需要确保在计算 mid * mid 时,mid 的类型提升为 long long。这样,mid * mid 的结果也会被正确地存储为 long long 类型,从而避免溢出。通过显式声明 mid 为 long long,可以安全地处理大数运算。

27. 移除元素

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)

我用的是双指针法。算法文档中这句话说得很恰当:数组的元素在内存地址中是连续的,不能单独删除数组中的某个元素,只能覆盖。

  1. 双指针法(快慢指针法)在数组和链表的操作中是非常常见的,很多考察数组、链表、字符串等操作的面试题,都使用双指针法。
  2. 覆盖操作:nums[cur ++] = nums[i];
  3. 暴力解法使用两次 for 循环,模拟快慢指针。时间复杂度是 O(nlogn)。

26. 删除有序数组中的重复项

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;
    }
};

我的疑惑

  1. 出错:nums[slow ++] = nums[fast]; 当都指向同一个元素时候 nums[slow ++] 会将当前元素覆盖为自身,逻辑出现错误。此时输入 1 1 2,会输出 2 1。
  2. 使用库函数,去重。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. 比较含退格的字符串

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;
    }
};

我的疑惑

  1. 写 act()辅助函数的参数时,&是必须的嘛?引用(void act(string& str))是合适的,它直接修改传入的字符串,避免了不必要的复制。当然也可以不使用引用,而是返回修改后的字符串。s = act(s); // 需要显式地处理返回值
  2. 显式处理。(代码更加安全、函数的副作用更加小)
// 显式处理
string s = "ab#";
s = act(s); // 需要显式处理。
cout << s << endl;
  1. 在循环结束后调用 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.有序数组的平方

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;
    }
};

为什么使用双指针法可以优化时间复杂度?可以思考一下~ 它可以利用数组的有序性,避免嵌套循环,能够原地改变数据。

总结

要掌握的知识点放在了开头,可以到顶端看一看。

二分查找:注意区间定义和边界条件。

移除元素:双指针法的应用。

有序数组的平方:双指针法优化排序

本期能够非常直观的感受到快慢指针的灵魂,下期再见~