前言
现在是2021-11-21,开始使用东哥的算法小抄进行算法的学习,这篇文章就算是我的读书笔记吧,侵权删。
1.1 基础语法使用备份
1.哈希表 unordered_map
常用方法:
①size_type count(const key_type& key);
返回哈希表中key出现的次数,因为哈希表不会出现重复的键,所以该函数只可能返回0或者1
可以用来判断键key是否存在于哈希表中
②size_type erase(const key_type& key)
通过key来清除哈希表中的键值对, 如果删除成功则返回1,失败则返回0
③常用代码
#include <iostream>
#include <unordered_map>
using namespace std;
int main()
{
vector<int> nums{1,1,3,4,5,3,6};
unordered_map <int,int>counter;
for(int num:nums){
counter[num]++;
}
for(auto& item: counter){
int key=item.first;
int val=item.second;
cout<<key<<":"<<val<<endl;
}
return 0;
}
对于unordered_map有一点是需要注意的,用方括号[]访问其中的键值对,如果key不存在,那么就会自动创建key,对应的值为值类型的默认值
2.JAVA String
常用方法:
①toCharArray
该方法会将JAVA中的Strng类型转化为char[]类型,进行该方法的转化后即可使用[]运算符进行运算,操作完毕后再转换回String类型
②StringBuilder
如果需要对字符串进行频繁的拼接,则使用该方法较容易接受,示例代码
public static void main(String[] args) {
/*1.StringBuilder*/
StringBuilder sBuilder = new StringBuilder();
for(char c='a';c<='f';c++) {
sBuilder.append(c);
}
//append支持拼接字符、字符串、数字等类型
sBuilder.append('g').append("hij").append(123);
String reString=sBuilder.toString();
System.out.println(reString);
}
③equal
老生常谈的话题了,要比较两个字符串是否相同,就必须采用equal的方法来进行 而不是 ==
1.2 动态规划解题套路
解释:首先,动态规划问题的一般形式就是求最值,而求最值,我们需要穷举所有可能的结果,但是动态规划的穷举存在“重叠子问题”,如果暴力穷举,效率会及其低下,因此需要备忘录或者"DP Table"来优化穷举过程,避免不必要的计算。
动态规划一定具备有“最优子结构”,这样才能通过子问题的最值来得到原问题的最值,而只有列出正确的“状态方程”才能正确的穷举。
核心步骤:
①这个问题的base case(最简单情况)是什么?
②这个问题有什么“状态”?
③对于每个“状态”,可以做出什么“选择”使得“状态”发生改变?
④如何定义dp数组/函数的含义来表现“状态”和“选择”?
伪代码示例
dp[0][0][...]=baseCase;
//进行状态转移
for(状态1 in 状态1的所有取值){
for(状态2 in 状态2的所有取值){
for(状态n....){
dp[状态1][状态2] = 求最值 (选择1,选择2,...);
}
}
}
1.2.1斐波那契数列求解(记忆化搜索+动态规划)
原始递归代码求解
class Solution {
public:
int fib(int n) {
if(n==0){
return 0;
}else if(n==1 || n==2){
return 1;
}
return fib(n-1)+fib(n-2);
}
};
交上去发现超时了,实际上是递归过程太过于低效,实际上每次递归都需要计算一个值,但实际上在之前的递归就可以把这个值求出来了,那么如果有这样的特性的话(重叠子问题),就可以采用动态规划的思路来做了,记忆化计算(搜索)。
递归算法的时间复杂度如何计算?就是用子问题个数乘以解决一个子问题所需要的时间
首先计算子问题的个数,即递归树中节点的总数。显然二叉树节点总数为指数级别的,所以求解子问题个数的时间复杂度为O(2^n),然后解决一个子问题的时间,只有加法操作,因此是常数级别的复杂度O(1)
记忆化计算改进算法:
class Solution {
public:
int MOD = 1000000007;
int fib(int n) {
if(n==0){
return 0;
}
vector<int> m(n+1,0);
return helper(m,n);
}
int helper(vector<int>&m,int n){
if(n==1||n==2){
return 1;
}
if(m[n]!=0){
return m[n];
}
//进行计算m[n]==0
m[n]=(helper(m,n-1)+helper(m,n-2))%MOD;
return m[n];
}
};
上面改进算法的最大特点就是建立了一个m数组来存储计算结果,通过画出递归树可以知道,有大量的重复计算,那么这个m数组就可以将这些冗余的计算给优化掉,从而对程序进行优化。
自顶向下:从一个规模比较大的原问题,向下逐渐分解规模,直到f(1)和f(2)这两个basecase为止,然后逐层返回答案。
自底向上:从最下面最简单问题规模最小的f(1)和f(2)向上推,直到推到答案,这就是动态规划的思路。
动态规划改进算法
class Solution {
public:
int MOD = 1000000007;
int fib(int n) {
if(n==0){
return 0;
}
if(n==1 || n==2){
return 1;
}
int *dp=new int[n+1];
dp[1]=dp[2]=1;//初始状态
for(int i=3;i<=n;i++){
dp[i]=(dp[i-1]+dp[i-2])%MOD;
}
return dp[n];
}
};
提交后发现空间复杂度仍然很高,但是实际上是不是真的需要这个数组呢?实际上题目单case的输入方式,决定了不需要多次计算,因此每次计算只需要前两个数作为计算支撑,因此不需要额外开拓新的数组,只需要把之前的计算结果用两个变量存起来进行迭代即可。
class Solution {
public:
int MOD = 1000000007;
int fib(int n) {
if(n==0){
return 0;
}
if(n==1 || n==2){
return 1;
}
int pre=1,cur=1;
for(int i=3;i<=n;i++){
int sum=(pre+cur)%MOD;
pre=cur%MOD;
cur=sum%MOD;
}
return cur;
}
};
这个技巧就是所谓的状态压缩,如果我们发现每次状态转移只需要DPTable中的一部分,那么可以尝试用状态压缩来缩小DPTable的大小,只记录必要的数据。
总结:斐波那契数列的例子主要学到的是如何来消除重叠子问题的问题。
1.2.2凑零钱问题(暴力递归以及dp迭代求解)
题目描述:给你k种面值的硬币,面值分别为c1,c2,…ck,每种硬币的总量无限,再给一个金额amount,问你最少需要几枚硬币来凑出这个金额,如果不可能凑出,那么就返回-1.
解题思路:
①这个问题的base case(最简单情况)是什么?
就是给出的目标金额为0的时候,那么算法就返回0
②这个问题有什么“状态”?
目标金额amount
③对于每个“状态”,可以做出什么“选择”使得“状态”发生改变?
选择硬币的面值会减少目标金额的量,因此所有硬币的面值就是选择
④如何定义dp数组/函数的含义来表现“状态”和“选择”?
dp(n)的定义:输入一个目标金额n,返回凑出目标金额n最少的硬币数量
暴力解:采用递归的思路,穷举出所有的硬币组合,通过每次选择而逼近目标金额的baseCase的方式来进行计算,在计算过程中,由于要求一个最值,因此需要做一个最佳选择,用min来控制是选还是不选,如果选,那么能否使得选了之后的结果比当前的解决方案有更少的硬币数,交上去发现超时了,然后分析递归树,发现仍然存在重复计算的问题,因此可以从这里入手进行优化
class Solution {
public:
const int INF=0x3f3f3f3f;
int coinChange(vector<int>& coins, int amount) {
return dp(coins,amount);
}
int dp(vector<int>& coins,int n){
if(n==0){
return 0;
}
if(n<0){
return -1;
}
int res=INF;
for(int i=0;i<coins.size();i++){
int subRes=dp(coins,n-coins[i]);
if(subRes==-1){
continue;
}
res=min(res,1+subRes);
}
if(res!=INF){
return res;
}else{
return -1;
}
}
};
记忆化搜索优化之后的暴力解
class Solution {
public:
const int INF=0x3f3f3f3f;
vector<int>m;
int coinChange(vector<int>& coins, int amount) {
for(int i=0;i<amount+1;i++){
m.push_back(0);
}
return dp(coins,amount);
}
int dp(vector<int>& coins,int n){
if(n==0){
return 0;
}
if(n<0){
return -1;
}
if(m[n]!=0){
return m[n];
}
int res=INF;
for(int i=0;i<coins.size();i++){
int subRes=dp(coins,n-coins[i]);
if(subRes==-1){
continue;
}
res=min(res,1+subRes);
}
if(res!=INF){
m[n]=res;
return m[n];
}else{
return -1;
}
}
};
交上去仍然发现超时,但是通过的样例多了15个,证明该算法思路本身没问题,但是需要进一步优化.
接下来考虑动态规划
class Solution {
public:
const int INF=0x3f3f3f3f;
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount+1,amount+1);
//别忘了初始化状态,0的面值当然是0枚硬币了
dp[0]=0;
for(int i=0;i<dp.size();i++){
for(int coin:coins){//在这里要进行选择
//注意dp数组的定义,是i的面值需要dp[i]枚硬币来进行兑换
//1.是否可选?当i的面值比当前可选的硬币面值要小的时候,就不可能凑成这个金额
if(i<coin){
continue;//跳过
}
//否则的话就进行选择
//2.选择的话是不选当前面值,或者是选当前面值的(则硬币数+1)
//那么对于选当前面值的话就会产生状态的转移
//比如说6元有1 2 3三种面值的情况
//那么在选择对应面值的时候可能是在不同已经被计算出来的面值所对应的硬币数的基础进行+1的
dp[i]=std::min(dp[i],1+dp[i-coin]);
}
}
if(dp[amount]==amount+1){
return -1;
}
return dp[amount];
}
};
通过这一节可以了解到求解这一类问题主要是通过记忆化搜索来优化递归树还有优化状态转移方程来进行求解的
1.3 回溯算法解题套路框架
解决一个回溯问题,实际上就是一个决策树的遍历过程。通常情况下需要三个要素
1.路径:也就是已经做出的选择
2.选择列表:你当前可以做出的选择
3.结束条件:到达决策树的底层,无法再做选择的条件
这三点在dfs中体现得非常具体,就不详细讲了,可以简单地理解为递归调用之前做选择,在递归调用之后撤销选择,转而去尝试别的选择
回溯问题的解题的关键是backtrack函数的编写,该函数其实就像一个指针,在这棵树上遍历,同时要正确维护每个节点的属性,每当走到树的底层,其路径就是一个解。
做选择其实就是从选择列表中拿出一个选择,并将它放入路径,撤销选择其实就是路径中拿出一个选择,并将其恢复到选择列表中。
在递归之前做出选择,在递归之后撤销刚才的选择
1.3.1 全排列问题
采用模板写的
class Solution {
public:
vector<vector<int> >ans;
vector<vector<int>> permute(vector<int>& nums) {
vector<int> track;
backTrack(nums,track);
return ans;
}
void backTrack(vector<int>& nums,vector<int>& track){
//参数说明:nums是选择列表,如何体现其被选择和未被选择的状态
//我们通过判断track内的元素数量来判断是否选择了足够的函数
//结束条件,当选择状态为空
if((int)track.size() == (int)nums.size() ){
ans.push_back(track);
return ;
}
for(int i=0;i<nums.size();i++){
//在选择列表中选择,选择那些还没有被选过的元素
if(isContain(track,nums[i])){
continue;
}
track.push_back(nums[i]);
backTrack(nums,track);
track.pop_back();
//撤销选择
}
}
bool isContain(vector<int> track,int num){
for(int i=0;i<track.size();i++){
if(track[i]==num){
return true;
}
}
return false;
}
};
用dfs写的
class Solution {
public:
vector<vector<int> >ans;
vector<int> select;//选择列表,放在全局变量
map<int,int> book;//标记数组
vector<vector<int>> permute(vector<int>& nums) {
//先将选择列表复制一份
for(int i=0;i<nums.size();i++){
select.push_back(nums[i]);
}
vector<int>track;
dfs(track);
return ans;
}
void dfs(vector<int>& track){
if(track.size()==select.size()){
ans.push_back(track);
return;//返回上一次搜索
}
for(int i=0;i<select.size();i++){
if(book[select[i]]==0){
book[select[i]]=1;//标记
track.push_back(select[i]);
dfs(track);
book[select[i]]=0;
track.pop_back();
}
}
}
};
1.3.2N皇后问题
对于N皇后问题,主要的是如何处理判断是否为合法棋盘,而且能够回溯找出所有的棋盘个数的问题,因此使用回溯算法来解决N皇后问题是很有效的
class Solution {
public:
vector<vector<string> > res;
vector<vector<string>> solveNQueens(int n) {
vector<string> board(n,string(n,'.'));
backTrack(board,0);
return res;
}
void backTrack(vector<string> board,int row){
if(row == board.size()){
res.push_back(board);
return ;
}
int n=board[row].size();
for(int col=0;col<n;col++){
if(!isValid(board,row,col)){
continue;
}
board[row][col]='Q';
backTrack(board,row+1);
board[row][col]='.';
}
}
//检查是否能在board[row][col]放置,需要检测上方区域即可
bool isValid(vector<string> board,int row,int col){
//检查同一列
for(int i=0;i<row;i++){
if(board[i][col] == 'Q'){
return false;
}
}
//检查左上方
for(int i=row-1,j=col-1;i>=0&&j>=0;i--,j--){
if(board[i][j] == 'Q'){
return false;
}
}
//检查右上方
for(int i=row-1,j=col+1;i>=0&&j>=0;i--,j++){
if(board[i][j] == 'Q'){
return false;
}
}
return true;
}
};
1.4 BFS算法
1.4.1 求解二叉树的最小高度
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
public:
int minDepth(TreeNode* root) {
if(root == NULL){
return 0;
}
int depth = 1;
queue<TreeNode*> q;
q.push(root);
int flag = false;
while(!q.empty()){//一定要确保是一层一层的遍历,因此需要先将每一层的节点进行遍历扩散之后才可以得到正确的答案
int n = q.size();
for(int i=0;i<n;i++){
TreeNode* head = q.front();
q.pop();
if(head->left == NULL && head->right == NULL){
flag = true;
break;
}
if(head->left != NULL){
q.push(head->left);
}
if(head->right != NULL){
q.push(head->right);
}
}
//当把这个for做完之后才算是高了一层
if(flag){
break;
}
depth++;
}
return depth;
}
};
1.4.2 解开密码锁的最少次数
一个密码锁由 4 个环形拨轮组成,每个拨轮都有 10 个数字: ‘0’, ‘1’, ‘2’, ‘3’, ‘4’, ‘5’, ‘6’, ‘7’, ‘8’, ‘9’ 。每个拨轮可以自由旋转:例如把 ‘9’ 变为 ‘0’,‘0’ 变为 ‘9’ 。每次旋转都只能旋转一个拨轮的一位数字。
锁的初始数字为 ‘0000’ ,一个代表四个拨轮的数字的字符串。
列表 deadends 包含了一组死亡数字,一旦拨轮的数字和列表里的任何一个元素相同,这个锁将会被永久锁定,无法再被旋转。
字符串 target 代表可以解锁的数字,请给出解锁需要的最小旋转次数,如果无论如何不能解锁,返回 -1 。
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/zlDJc7
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
解题思路:
首先拿到这个题的时候,应该思考的是,我们如何才能够按照题意,穷举出转动密码锁所得到的所有密码组合?
首先我们需要知道转动密码锁的时候,是可以向上转也可以向下转的,那么每一次的转动,就有可能产生8种密码(有4个位置,每个位置可以向上转或者向下转),然后又以这八种密码为基础,再次转动,直到产生密码位置
那么思考到这一步的时候就有点眉目了,就相当于:从根节点(0000)出发,然后产生8个子节点,然后向下BFS,从某一个子节点再次出发,再向下BFS,直到找到密码为止
那么抽象的框架已经找到了,接下来需要找限制条件,限制条件也就是这个题目的死亡数字,只要转到产生这个死亡数字的,就认为不可行,以及在BFS过程中,如果不加限制,那么就会产生之前就测试过的密码,就会陷入死循环.
class Solution {
public:
string minusOne(string pwd,int pos){
if(pwd[pos]=='0'){
pwd[pos]='9';
}else{
pwd[pos]-=1;
}
return pwd;
}
string plusOne(string pwd,int pos){
if(pwd[pos]=='9'){
pwd[pos]='0';
}else{
pwd[pos]+=1;
}
return pwd;
}
map<string,bool> M;
map<string,bool> dead;
public:
int openLock(vector<string>& deadends, string target) {
queue<string> q;
string root= "0000";
M[root]=1;
q.push(root);
int step=0;
for(int i=0;i<deadends.size();i++){
dead[deadends[i]]=1;
}
while(!q.empty()){
int n = q.size();
for(int i=0;i<n;i++){//一次for,就是一次尝试
string head = q.front();
q.pop();
if(dead[head]==1){
continue;
}
if(head == target){
return step;
}
for(int j=0;j<head.size();j++){
string up = plusOne(head,j);
string down = minusOne(head,j);
if(M[up]==0){
q.push(up);
M[up]=1;
}
if(M[down]==0){
q.push(down);
M[down]=1;
}
}
}
step++;
}
return -1;
}
};
这个解是基本解,可以通过测试样例,但是观察之后发现有几点可以优化:
①可以写一个不可产生的密码Map,来合并 dead和M这两个map
②使用双向BFS
可以使用双向BFS的题目的特点是,在一开始就已经知道起点和终点。
class Solution {
public:
string minusOne(string pwd,int pos){
if(pwd[pos]=='0'){
pwd[pos]='9';
}else{
pwd[pos]-=1;
}
return pwd;
}
string plusOne(string pwd,int pos){
if(pwd[pos]=='9'){
pwd[pos]='0';
}else{
pwd[pos]+=1;
}
return pwd;
}
public:
int openLock(vector<string>& deadends, string target) {
map<string,int> M;
int step=0;
for(int i=0;i<deadends.size();i++){
M[deadends[i]]=1;
}//优化点1
map<string,int> q1;
map<string,int> q2;
//双向bfs不使用队列而使用哈希表
string begin = "0000";
string end = target;
q1[begin]=1;
q2[end]=1;
while(!q1.empty() && !q2.empty()){
map<string,int> temp;
for(map<string,int>::iterator iter = q1.begin();iter!=q1.end();iter++){
if(M[iter->first]==1){
continue;
}
if(q2[iter->first]==1){
return step;
}
M[iter->first]=1;
for(int j=0;j<4;j++){
string up = plusOne(iter->first,j);
if(M[up]==0){
temp[up]=1;
}
string down = minusOne(iter->first,j);
if(M[down]==0){
temp[down]=1;
}
}
}
step++;
q1=q2;
q2=temp;
}
return -1;
}
};
1.5 双指针技巧套路框架
一般在做题时用到的双指针分为两种,快慢指针和左右指针
1.5.1 快慢指针在链表中的应用
快慢指针一般会初始化指向链表的头节点head,前进时快指针fast在前面,slow指针在后面,巧妙解决一些链表中的问题
应用:
①判定链表中是否含有环
如果链表中不含有环,那么这个指针最终会跑到链表的最末尾,遇到null结束
如果链表中含有环,那么这个指针的遍历将会陷入死循环
代码示例:
bool hasCycle(ListNode head){
while(head!=NULL){
head=head.next;
}
return true;
}
如果含有环的话就会陷入死循环,那么应该要怎么做呢,这时候就可以使用快慢指针了,一个跑的快,一个跑的慢,不含有环,跑的快的那个指针最终会遇到null,说明就没有环了,如果含有环,那么快指针就会超慢指针一圈,和慢指针相遇,说明链表含有环。通用版代码示例:
bool hasCycle(ListNode head){
ListNode fast,slow;
fast = slow = head;//快慢指针的初始化
while(fast !=null && slow!= null){
if(fast.next == null){
break;
}
fast = fast.next.next;//如果fast.next是null不就会SF?
slow = slow.next;
if(fast == slow){
return true;
}
}
return false;
}
②已知链表中含有环,返回这个环的起始位置
可以先上代码来理解一下
ListNode detectCycle(ListNode head){
ListNode fast,slow;
fast = slow = head;
while(fast != NULL && fast.next !=NULL){
fast = fast.next.next;
slow = slow.next;
if(slow = fast ){
break;
}
}
slow =head;
while(slow != fast){
fast = fast.next;
slow = slow.next;
}
return slow;
}
可以看到,当快慢指针相遇时,让其中任何一个指针指向头节点,然后让两个指针以相同速度前进,再次相遇时所在节点位置就是环开始的位置.第一次相遇时,假设慢指针走了k步,那么快指针一定走了2k步,也就是说比slow多走了k步,这k步是环长的整数倍,假设相遇点与环的起点的距离为m,那么环的起点与头节点head的距离为k-m,也就是说从head前进k-m步就能到达环起点,而从相遇点再走k-m步,这时候也会到达环起点(非常重要的等价关系)
整理一下为伪代码
ListNode detectCycleBegin(ListNode head){
ListNode slow,fast;
头尾指针同时指向头节点;
while(快指针非空 && 快指针下一个节点非空){
快指针以两倍速度前进;
慢指针以一倍速度前进;
if(slow == fast){
break;
}
}//这时候快指针走了2k步,慢指针走了k步
当循环跳出来的时候,就是快慢指针相遇的时候,相遇点未知,但是快指针一定会比慢指针多走n(n>=1)圈
根据等价关系,这时候再让其中一个指针回到头节点的位置,二者同时出发,再同时相遇的时候,指向的节点一定是环的起点
slow = head;
while(slow != fast){
slow = slow.next;
fast = fast.next;
}
return fast;
}
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode *detectCycle(ListNode *head) {
ListNode *slow,*fast;
slow = fast = head;
while(fast!= NULL){
if(fast->next == NULL){
return NULL;//没有环的情况
}
slow = slow->next;
fast = fast->next->next;
if(slow == fast ){
break;
}
}
//此时再让其中一个回到头节点
slow = head;
while(slow != fast){
if(fast == NULL){
return NULL;
}
slow = slow->next;
fast = fast->next;
}
return slow;
}
};
③寻找无环单链表的中点
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* middleNode(ListNode* head) {
ListNode *slow,*fast;
slow = fast = head;
while(fast!=NULL && fast->next!=NULL){
fast = fast->next->next;
slow = slow ->next;
}
return slow;
}
};
对于这个,可以用来辅助我们写出链表的归并排序.
④寻找单链表的倒数第k个元素
类似于找单链表的中点,思路仍然是使用快慢指针,让快指针先走k步,然后快慢指针开始同速前进,这样当块指针走到链表末尾null的时候,这样慢指针所在的位置就是倒数第k个链表节点。
class Solution {
public:
ListNode* getKthFromEnd(ListNode* head, int k) {
ListNode *fast;
ListNode *slow;
fast = slow = head;
while(k--){
fast = fast->next;
}
while(fast!=NULL){
fast = fast->next;
slow = slow->next;
}
return slow;
}
};
1.5.2快慢指针的常用算法
两数之和
class Solution {
public:
vector<int> twoSum(vector<int>& numbers, int target) {
int left=0,right=numbers.size()-1;
while(left<right){
int sum = numbers[left]+numbers[right];
if(sum == target){
vector<int> ans;
ans.push_back(left);
ans.push_back(right);
return ans;
}
else if(sum < target){
left++;
}
else if(sum > target){
right--;
}
}
vector<int> ans;
ans.push_back(-1);
ans.push_back(-1);
return ans;
}
};
翻转数组
void reverse(vector<int>& nums){
int left = 0;
int right = nums.size()-1;
while(left < right){
int temp = nums[left];
nums[left] = nums[right];
nums[right] = temp;
left++;right--;
}
}
1.6再探二分搜索算法
public int binarySearch(int[] nums,int target){
int left = 0;
int right =...;
while(...){
int mid = left + (right-left)/2;
if(nums[mid] == target){
return mid;_
}else if(nums[mid] < target){
left = ...;
}else if(nums[mid] > target){
right = ...;
}
}
return ...;
}
需要声明的是,计算mid的时候需要防止溢出,代码中left+(right-left)/2和(left+right)/2的结果相同,但是如果left和right太大,会导致相加之后产生的mid太大产生溢出
public int binarySearch(int[] nums,int target){
int left=0;
int right = nums.length-1;
while(left <= right){
int mid = left + (right - left)/2;
if(nums[mid] == target){
return mid;
}else if(nums[mid] < target){
left = mid+1;
}else if(nums[mid] > target){
right = mid-1;
}
}
return -1;
}
疑问解答:①为什么while循环的条件中是<= 而不是<?
对于这个问题首先需要明确搜索区间,初始化的搜索区间均为[left,right],只不过两端的符号或开或闭,因为或开或闭的原因,两个指针能达到的区间端点而不一样。
对于[left,right]而言,其两端都能达到,故解释为left=0,right = nums.legth-1(因为right能取到这个值)
对于[left,right)而言,其右端无法达到,故解释为left=0,right=nums.length
那么这个就很关键了,这关系到我们的搜索区间,这不仅仅是第一次的搜索,而是每一次的搜索都是这样。
如果初始化的right为nums.length,那么我们代码所定义的搜索区间应该就要是[left,right),右端无法达到,如果初始化的right为nums.length-1,那么我们代码所定义的搜索区间应该就要是[left,right],右端可以达到,而搜索区间,正决定着我们结束条件的不同,所谓结束,也就是搜索区间不能再被搜索了,搜索区间为空,那么在上述情况下,什么样的值会导致搜索区间为空呢?
对于[left,right]而言,是left>right,与之等价的就是left <=right就是可以继续搜索的条件
对于[left,right)而言,就是left == right,但是这样做有个弊端就是left这个点没有被搜索到,需要在循环结束后再加一次判断,那么与之对应的结束调节可以写为left<right(因为left = right的时候是不能再被搜索的)
通过以上的分析,可以改写我们的算法
public int binarySearch(int[] nums,int target){
int left=0;
int right= nums.length;//搜索区间为[left,right)
while(left < right){
int mid = left+(right-left)/2;
if(nums[mid] == target){
return mid;
}else if(nums[mid] < target ){
left = mid+1;
}else if(nums[mid] >target ){
right = mid;
}
}
return nums[left] == target? left : -1;
}
这样的算法也是符合要求的
这样的算法同样具有缺陷,当搜索数组nums[1,3,3,3,5]中3的索引,要求返回最左端3的索引的时候,仅靠原本的算法不能够解决这个问题,但是可以通过再次线性搜索解决问题,但是对于时间复杂度有比较大的影响,因此接下来考虑这个问题。
②寻找左侧边界的二分搜索算法
public int leftBound(int[] nums,int target){
if(nums.length == 0) {
return -1;
}
int left=0;
int right=nums.length;
while(left < right){
int mid = left + (right-left)/2;
if(nums[mid] == target){
right = mid;//缩小接近左边边界的关键代码
}else if(nums[mid] < target){
left = mid+1;
}else if(nums[mid] > target){
right = mid;//!!
}
}
if(left == nums.length){
left = -1;
}
return left;
}
注意,对于一个这样的左侧边界的题目,如果数组中的元素是有序的,那么所返回的值就意味着小于target的元素有left个,那么left的值最小能到达0,最大的能到达nums.length(也就是这个数组中所有的元素都比target要小),根据这一点就可以知道当数组中没有target时,算法将会返回nums.length这个结果给我们。
算法中的重要解释
1.为什么left=mid+1,right=mid?和之前的算法不一样?
依然是搜索区间的问题,因为此时的搜索区间是[left,right)左闭右开的,所以在nums[mid]在被检测之后,下一步的搜索应该去掉mid分割为两个区间,即[left,mid)或者[mid+1,right)
2.为什么该算法能够搜索左侧边界?
关键在于对nums[mid]==target这种情况的处理
在找到target后不要立即返回,而是要缩小搜索区间的上界right,在区间[left,right)中继续搜索,不断向左收缩。
3.写一个[left,right]区间的二分搜索算法
public int leftBound(int[] nums,int target){
int left=0;
int right = nums.length-1;
while(left <= right){
int mid = left+(right-left)/2;
if(nums[mid] == target) {
right = mid - 1;
}else if(nums[mid] > target){
left = mid+1;
}else if(nums[mid] < target){
right = mid-1;
}
}
if(left == nums.length){
left = -1;
}
return left;
}
③寻找右侧边界的二分算法
public int rightBound(int[] nums,int target){//[left,right)型
if(nums.length ==0){
return -1;
}
int left=0;
int right = nums.length;
while(left < right){//回忆,搜索区间不存在的时候,是[right,right),也就是left>=right的时候
int mid = left + (right-left)/2;
if(nums[mid] == target){//与左侧搜索一样,需要不断逼近右端,那么就需要对left指针进行改动
left = mid+1;
}else if(nums[mid] >target){//当target比中点小,说明target应该在左边,缩小右端点
right = mid;
}else if(nums[mid] <target){
left = mid+1;
}
}
if(left == 0){
return -1;
}
return nums[left-1]==target?(left-1):-1;
//这里为什么是right-1呢,我们知道,算法结束的时候是left == right
//而如果target存在的情况下,如果我们逐步逼近右侧端点的时候,等于target的index是mid
//而mid就等于left-1 right-1
}
public int rightBound(int[] nums,int target){//[left,right]型
int left=0;
int right=nums.length-1;
while(left<=right){
int mid = left+(right-left)/2;
if(nums[mid] == target){
left = mid+1;
}else if(nums[mid] > target){
right = mid-1;
}else if(nums[mid] < target){
left = mid+1;
}
}
if(right<0 || nums[right]!=target){
return -1;
}
return right;
}
1.7 滑动窗口算法
该算法的大致逻辑如下
int left=0,right=0;
while(right<s.size()){
window.add(s[right]);//增大窗口
right++;
while(window needs shrink){
//缩小窗口
window.remove(s[left]);
left++;
}
}
void slidingWindow(string s,string t){
unordered_map<char,int> need,windows;//需求数组,窗口数组
for(char c:t){
need[c]++;
}
int left=0,right=0;
int valid=0;
while(right<s.size()){
//c是移入窗口的字符
char c = s[right];
//右移窗口
right++;
//进行窗口内数据的一系列更新
...
//debug
cout<<"windows:["<<left<<","<<right<<")"<<endl;
while(window needs shrink){
//d是将移出窗口的字符
char d = s[left];
//左移窗口
left++;
//进行窗口内数据一系列的更新
}
}
}
1.7.1 最小覆盖子串
1.在字符串S中使用双指针中的左右指针技巧,初始化left = right = 0,把索引左闭右开区间[left,right)称为一个窗口
2.先不断地增加right指针扩大窗口[left,right),直到窗口中的字符串符合要求(包含了T中所有字符)
3.此时,我们停止增加right,转而不断增加left指针缩小窗口[left,right),直到窗口中的字符串不再符合要求(不包含T中的所有字符了)。同时,每次增加left,我们都要更新新一轮结果.
4.重复第二步和第三步,直到right到达字符串S的尽头
第二步相当于在寻找一个“可行解”,然后第三步在优化这个“可行解”,最终找到最优解,也就是最短的覆盖子串。左右指针轮流前进,窗口大小增增减减,窗口不断向右滑动。
int left=0,right=0;
int valid = 0;//表示窗口中满足need条件的字符个数,如果valid和need.size()的大小相同,那么说明窗口已经满足条件,已经完全覆盖了串T
while(right < s.size()){
//滑动
}
那么在编写代码的时候,我们需要思考几个问题:
1.当移动right扩大窗口,即加入字符时,应该要更新哪些数据?
2.什么条件下,窗口应该暂停扩大,开始移动left缩小窗口?
3.当移动left缩小窗口时,即移出字符时应该更新哪些数据?
4.我们要的结果应该在扩大窗口时还是缩小窗口时进行更新?
一般来说,如果一个字符进入窗口,应该增加window计数器,如果一个字符将移出窗口,应该减少window计数器,当valid满足need时应该收缩窗口,收缩窗口的时候应该更新最新结果
class Solution {
public:
string minWindow(string s, string t) {
unordered_map<char,int> need,window;//需求计数器和窗口中字符计数器
//1.初始化需求计数器
for(int i=0;i<t.size();i++){
need[t[i]]++;
}
//2.初始化滑动窗口所需变量
int left=0,right=0,vaild=0;//滑动窗口左指针,右指针,以及以及覆盖了字符的个数
int start=0,len=INT_MAX;//定义打擂算法有关变量,最优窗口的左端开始位置以及长度
//3.进入滑动窗口
while(right<s.size()){//这个限定条件是因为:如果当滑动窗口到达了主串的最右端+1都没能达到需要的条件,那么就证明以及没有找到需要的条件了
//4.当没有找到可行解的时候,窗口向右滑动
char c=s[right++];//先使用再自增
if(need.count(c)){
//count:当在map中寻找c,如果在map中有c,那么返回1,否则返回0
window[c]++;//更新计数器
if(window[c] == need[c] ){//如果当前窗口中,c这个字符的需求已经满足了
vaild ++;
}
}
while(vaild == need.size()){//当可行解找到后,就需要优化了,左指针需要不断向右扩展
if(right - left < len){//打擂,查看当前的覆盖子串能否被更新,这样的写法有个问题,如果同时有两个最小不能得到后面那个,但是题目没有这种样例
len = right -left;
start = left;
}
//缩小窗口
char d = s[left++];
if(need.count(d)){//如果该字符是有关字符,那么应当进行更新
if(window[d] == need[d]){//只有当会威胁到可行解的时候,才会对valid进行修正
vaild--;
}
window[d]--;
}
}
}
return len == INT_MAX?"":s.substr(start,len);
}
};
1.7.2 字符串的排列
这道题与之前不同的是,1.7.1的题目是允许最小覆盖子串中存在其他字符,而本题是不允许的,那么本题的突破口就在关于找最优解的情况
class Solution {
public:
bool checkInclusion(string s1, string s2) {
if(s2.size()<s1.size()){
return false;
}
//1.初始化计数器
unordered_map<char,int> need,window;
//2.初始化需求计数器
for(int i=0;i<s1.size();i++){
need[s1[i]]++;
}
//3.初始化变量
int left=0,right=0,vaild=0;
int start=0;
bool flag=false;
//4.进行滑动窗口
while(right < s2.size()){
char c = s2[right++];
if(need.count(c)){
window[c]++;
if(window[c] == need[c]){
vaild++;
}
}
//答案应该怎么得出来?首先找到的这个窗口需要满足如下条件
//①这个窗口中的字符能够全部覆盖住
//②字符不能够多
while(right-left >= s1.size()){
if(vaild == need.size()){
flag = true;
break;
}
char d = s2[left++];
if(need.count(d)){
if(window[d] == need[d]){
vaild--;
}
window[d]--;
}
}
}
return flag;
}
};
第二种写法,个人觉得比较好理解
class Solution {
public:
bool checkInclusion(string s1, string s2) {
if(s2.size()<s1.size()){
return false;
}
//1.初始化计数器
unordered_map<char,int> need,window;
//2.初始化需求计数器
for(int i=0;i<s1.size();i++){
need[s1[i]]++;
}
//3.初始化变量
int left=0,right=0,vaild=0;
int start=0,len=INT_MAX;
//4.进行滑动窗口
while(right<s2.size()){
char c = s2[right++];
if(need.count(c)){
window[c]++;
if(window[c] == need[c]){
vaild++;
}
}
while(vaild == need.size()){
len = right-left;
if(len == s1.size()){
return true;
}
char d = s2[left++];
if(need.count(d)){
if(window[d] == need[d]){
vaild--;
}
window[d]--;
}
}
}
return false;
}
};
1.7.3 找到所有字母异位词
class Solution {
public:
vector<int> findAnagrams(string s, string p) {
unordered_map<char,int> need,window;
for(int i=0;i<p.size();i++){
need[p[i]]++;
}
int left=0,right=0;
int valid=0;
vector<int>ans;
while(right<s.size()){
char c=s[right++];
if(need.count(c)){
window[c]++;
if(window[c]==need[c]){
valid++;
}
}
while(valid == need.size()){
if(right-left==p.size()){
ans.push_back(left);
}
char d = s[left++];
if(need.count(d)){
if(window[d]==need[d]){
valid--;
}
window[d]--;
}
}
}
return ans;
}
};
1.7.4 最长无重复子串
class Solution {
public:
int lengthOfLongestSubstring(string s) {
unordered_map<char,int> window;
int left=0,right=0;
int ans=0;
while(right<s.size()){
char c = s[right++];
window[c]++;
while(window[c]>1){//当出现重复字符时
char d = s[left++];
window[d]--;
}
if(right - left >ans){
ans = right- left;
}
}
return ans;
}
};