面试经典算法题型——动态规划,回溯,滑动窗口,双指针(javascript,包含全图解详解)| 青训营笔记

594 阅读16分钟

这是我参与第四届青训营笔记创作活动的第5天


一个程序被执行的好坏往往不只是取决于这个程序是否能够正常的运行出来,能否不出错,更取决于这个程序运行时所占用的时间复杂度以及空间复杂度。而算法,则是为提高一个程序运行的效率而诞生的,一个优秀且合适的算法能够为我们大幅度降低一段代码所执行的时间复杂度和空间复杂度。

沉浸数个日夜leetcode的刷题,我经历了简单的回文数,青蛙跳台阶(讲真的,我有被青蛙冒犯到),爬楼梯,两数组的交集;再到中等的打家劫舍(没见过只能隔家偷这么憋屈的小偷),全排列,子集,买卖股票的最佳时机II;到最后困难的接雨水。他们难吗?不难。但是让它们的时间复杂度保证能击败百分之八九十的人就有些难啦。所以,我痛定思痛!肝了三天三夜写下这篇文章。接下来,我会把我对这些题目所总结出来的各种方法为大家讲解!

双指针篇

双指针的定义

指针是什么?

讲到双指针首先得说起指针,在javascript这门语言上,严格的来讲是没有指针这个东西的,只不过是我们将它概念化为“指针”(就像javascript中的栈一样,我的另一篇文章有讲栈,有兴趣了解的也可以去看看);将它定义为“指针”只是为了让我们可以更形象贴切的懂得它在算法中的作用;而javascript里的指针具体来说就只是一个存储各种索引(即下标)的变量。

var arr = [1,2,3,4,5,6,7,8,9];//这里咱们随意定义一个为[1,2,3,4,5,6,7,8,9]的数组
var p = 0;//定义一个指针,可以看到这跟定义了一个变量没两样

我们可以理解为此刻P就是一个指针状的下标(索引),就像下面这张图:

1657898365800.png

如果令p++,则指针也要改变向右边移一位,如下图:

1657898664560.png

所以,个人认为在javascript里面大家可以把指针认为是一种“箭头”状的下标,这样更好理解。

双指针是什么?

双指针,顾名思义就是两根指针,不同的是,双指针指的是在遍历对象的过程中,不是普通的使用单个指针进行访问,而是使用两个相同方向的快慢指针或者相反方向的对撞指针的指针进行扫描,从而达到相应算法的目的。

最常见的双指针算法有两种: 一种是,在一个数组或链表序列里边,用两个指针维护一段区间,即一般情况下的对撞指针

1657899163388.png

另一种是,在两个数组或链表序列里边,一个指针指向其中一个序列,另外一个指针指向另外一个序列,来维护某种次序,即一般情况下的快慢指针

1657899237313.png

这么讲大家可能听得还不是那么懂,那接下来,我就会带着大家具体来了解双指针的这两种基本用法。

双指针的基本用法

双指针基础用法一:对撞指针

一般情况下,对撞指针这种双指针算法通常是两根指针分别表示记录着同一个数组两端的下标值,当这两根指针碰面则停下(即它们两个所记录的下标发生了交互),通常与语法while循环配套使用。

接下来咱们来看一个很简单的回文数题目,题目来自leetcode:回文数,题目如下:

1657964939421.png

在这个题目里我们需要判断题目给定的数字是不是一个回文数字,回文即从左向右和从右向左读都是一样的,而这题正巧用上双指针里的对撞指针就很适合了。

接下来,我来带着大家手把手写代码,一个个的解释:

这里为了方便讲解,我们先直接定义一个数,我们来判断这个数是不是回文数

var x = 5341435;

可以看到的是,题目给我们传的数是一个整数,那我们就需要将它变成一个数组了,而变成数组之前我们还得先把它变成字符串,那我们就可以先用toString()方法

 x = x.toString();

将整数变成字符串之后我们就可以进行下一步了,将该字符串变成数组

    x = x.split('');//split()方法是将字符串以单引号内的字符进行分割成数组,如果只是打单引号则是每个字符都分隔开,这里就是这样

于是x就成了下面这个数组(上面是数组,下面部分是下标)

1657967414541.png

那么从这里开始就是正片了,双指针之对撞指针的应用。

我们先定义两个指针

var p = 0;    //定义一个指针p从左边开始走起,此时p=0;
var q = x.length-1;     //定义一个指针q从右边开始走起,此时q=6

1657968128920.png

此刻就有代表着这个数组两端下标的指针啦,我们现在只需要控制着p指针向右边移动,再控制着q指针向左边移动,直到它们对撞以至它们发生交互就可以啦。

同时,不要忘啦这个题目让我们干什么奥,我们需要判断这个数是不是回文数。

这样的话我们就可以拿题目的这个条件这个判断啦,因为如果这个数是回文数,那么它的左边第一位数和右边第一位数一定相同,左边第二位数和右边第二位数也一定相同,以此类推

while(p<q){           //当指针不发生交互则循环,一旦交互便跳出循环
    if(x[p]!=x[q]){        //判断,如果左边指针和右边指针指着的数不相同,那么就直接可以确认不是回文数,然后直接return返回false结束
        return false
    }
    p++;          //如果上面的判断是它们两个相同,那么左边的指针向右移
    q--;          //右边的指针左移
 }

具体过程图解如下:

1657969128912.png

1657969229955.png

1657969363091.png

1657969485426.png

完整代码如下:

var x = 5341435;
x=x.toString();
x=x.split('')
var p=0;
var q=x.length-1;
while(p<q){
    if(x[p]!=x[q]){
        return false
    }
    p++;
    q--;
}
return true;

这个简单的回文数用双指针就这样算是解决啦!对撞指针想必大家应该还是比较容易理解的。

双指针基础用法二:快慢指针

快慢指针一般应用于两个数组的情况,两根指针分别对不同的数组里的数据进行判断对比,从而找到我们需要做出操作的数据,返回出来;而不同于对撞指针的时,一般让两根快慢指针停止行走的判断条件一般是两根指针有其中一根在它所在的数组序列已经走到了末尾,那么此时,两根指针才会停下。

实战才是最好的演练,那我们就继续看题学习快慢指针吧,leetcode:两个数组的交集

1658104939530.png

题目给出了两个数组序列,我们需要查找它们的交集(相同的元素),并且把它们输出出去,当然这题还有一个要求就是输出结果中的每个元素都一定得是唯一的(即输出的数组不能有重复元素)。

那这里我们就先确定好步骤,为了快慢双指针更方便,这里第一步我们需要对两个数组先进行排序,然后使用快慢双指针,将我们找到的重复的值存储到一个数组中。

为了方便大家理解,先定义输入案例:num1=[4,9,5];num2=[9,4,9,8,4]

var num1 = [4,9,5];
var num2 = [9,4,9,8,4];
num1.sort(function(a,b){
    return a-b;             //利用sort()方法对num1进行排序
})
num2.sort(function(a,b){
    return a-b;            //利用sort()方法对num2进行排序
})

定义两根指针分别指向两个数组的最左端,以及一个空数组用以存储相同元素的值;

var p = 0;
var q = 0;
var arr = [];

现在的情况应该是如下图:

1658117085991.png

准备工作都做完了,那么就该开始正题——快慢指针算法了

while(p<nums1.length && q<nums2.length){   //判断循环,如果没有指针走到末尾,那么就进入循环,一旦任意指针走到末尾则跳出while循环
    if(nums1[p]==nums2[q]){   //判断,如果两个指针所指的值相同,那么我们就进一步判断
        if(!arr.includes(nums1[p])){    //如果这个值在我们提前准备数组里没有,那么就push进去,这么做是为了确保这个最后准备输出的数组里每个元素都是唯一数
            arr.push(nums2[q]); 
        }
        p++;           //执行完该存储操作,则P指针向右移动一位;
        q++;           //执行完该存储操作,q指针也向右移动一位;
    }else if(nums1[p]<nums2[q]){     //如果p指针所指的值小于q指针所指的值,那么p指针向右移动一位,往更大的数移动(因为两个数组都排过序了,右边比左边更大),让p指针所指的数大小和q指针更进一步
        p++;
    }else if(nums2[q]<nums1[p]){      //如果q指针所指的值小于p指针所指的值,那么q指针向右移动一位,往更大的数移动(因为两个数组都排过序了,右边比左边更大),让q指针所指的数大小和p指针更进一步
        q++;
    }
}

图解如下:

1658120132782.png

1658120403420.png

1658120741237.png

1658120968055.png

1658121196362.png

完整代码附:

var num1 = [4,9,5];
var num2 = [9,4,9,8,4];
num1.sort(function(a,b){
    return a-b;             
})
num2.sort(function(a,b){
    return a-b;            
})
var p = 0;
var q = 0;
var arr = [];
while(p<nums1.length && q<nums2.length){
    if(nums1[p]==nums2[q]){
        if(!arr.includes(nums1[p])){
            arr.push(nums2[q]);
        }
        p++;
        q++;
    }else if(nums1[p]<nums2[q]){
        p++;
    }else if(nums2[q]<nums1[p]){
        q++;
    }
}
return arr;

双指针篇到这里就算是结束了,两种双指针基础算法还是比较常见的;相信大家也都能理解,没有很难,但却是很实用的,尤其对于数组方面。

滑动窗口篇

什么是滑动窗口?

滑动窗口,顾名思义,就是维护一个窗口,随着条件和要求,在一个数组或者是链表,队列里面去滑动,找到满足我们要求的子元素组。

说到滑动窗口这种算法,想必有些人会迷惘,有些人会头秃,可我想说的是,滑动窗口真的不难,它非常容易去理解的一门算法,只要抓住它的适用场景以及条件,那么它一定能成为你手上的一柄利器。

滑动窗口的适用场景

滑动窗口一般被应用于线性结构之中,如数组;

滑动窗口一般用于解决数组/字符串的子元素、子数组以及子序列问题;

滑动窗口可以将嵌套的循环问题转化成单循环问题,大幅度降低时间复杂度。

滑动窗口实战教学

案例和实战永远是让人去接受一门学问的最快的方法,那么咱们就直接开始咱们的实战教学了。

题目来源leetcode:3.无重复字符的最长子串

1658303016136.png

这里我们先给大家讲解滑动窗口在这题的运用图解吧:

为了方便讲解,我们这里用到案例'abcabcbb'进行讲解,我们定义一个红色窗口在字符串外面

1658313511518.png

1658313400674(1).png

1658313357485(1).png

1658313616726(1).png

1658313832443(1).png

1658313921423(1).png

1658314026223(1).png

1658314080328(1).png

1658314154049(1).png

1658314208829(1).png

1658314274382.png

1658314315612.png

那么到最后,我们能发现len存取该滑动窗口的最长时候的情况就是它最后留下的长度 3 了

这么一个窗口的增减过程像不像一个窗口在缓慢移动呢?这就是滑动窗口算法

代码实现如下:

    var s = 'abcabcbb'         //为了方便讲解,定义案例为‘s’
    var len=0;                 //定义存储滑动窗口最长时候的长度的变量
    var i=0;                   //定义i是为了循环里方便判断如果字符串长度只有一个则直接返回字符串长度
    var sum=[]                 //定义一个数组,这里它就是指那个滑动窗口
    for(let j=0;j<s.length;j++){
        if(j==0){
            i=0;
        }else{
            i=1;
        }
        if(i==0 && j==s.length-1 && !sum.includes(s[j])){    //判断这个字符串是否是是一个字符串的长度,以方便直接返回
            return s.length; 
        }else if(!sum.includes(s[j])){       //判断滑动窗口内存不存在新扩增所笼罩的元素;不存在则走进这个判断中
            if(j!=s.length-1){      //如果还没遍历到字符串的最后一个数,则往窗口中增加
                sum.push(s[j])
            }else{                  //如果遍历到了字符串最后一个数,则往扩增窗口后再比较长度,存取最大长度值
                sum.push(s[j])
                len = Math.max(sum.length, len)
            }
        }else if(sum.includes(s[j])){     //判断滑动窗口内存不存在新扩增所笼罩的元素;已存在则走进这个判断中
            var a=sum.indexOf(s[j])      //找到窗口中的这个已存在值得下标
            len=Math.max(sum.length,len)     //存取最大长度值
            sum.splice(0,a+1)               //从窗口的最前端一直删除到那个已存在值的下标的位置
            sum.push(s[j])                 //把已存在值之前包括已存在值删除后,再扩增窗口
        }
    }
    return len                   //返回存取的最大不重复子串的长度

滑动窗口是我们针对子串,子数组进行操作的一大利器,熟练掌握它那么百分之五十以上的子数组子串问题都能够通过它去解决。

回溯法篇

回溯法的定义

回溯法可以看做是一种穷举法与递归相互结合的方法,是比较暴力的算法之一。

它通常会将探索的数组集合“解构”成一个树状结构,用以方便我们去理解,层层递进,树状结构的每一个枝叶都可以看做是我们的一次选择,一次结构,我们也能通过一些子枝叶去回到树状结构的主干上,这就是回溯法。

回溯算法的基本思想是:从一条路往前走,能进则进,不能进则退回来,换一条路再试。

image.png

回溯法的应用场景

回溯法适用于求解和搜索遍历的情况下。

回溯法实例教学

题目:全排列

题目来源leetcode:全排列

1658466309340.png

废话咱们就不多说啦,先图解安排上,让大家了解到什么是回溯法,我们再进行代码实现。

1658466715822(1).png

1658467270108(1).png

1658467503141.png

1658467770646(1).png

1658468063152.png

以此类推,最后的结果就只能是这样了:

1659060052535.png

那么接下来,咱们就来讲代码实现:

首先,咱们要先定义一个用来存储最后return出去的数组res

var nums = [1,2,3]
var res = [];

然后我们就可以开始写进行递归回溯的函数count了

function count(path){   //递归进行的同时要对其传参:path数组

    //这个if是对最后递归回溯的一个判定终止条件,以防止一直递归下去,发生无限回溯递归的情况
    if(path.length==nums.length){
        res.push([...path]);
        return 
    }
    
    //对nums这个系统给我们的案例进行遍历
    for(let i=0;i<nums.length;i++){
        if(path.includes(nums[i])){    //因为一个数字只能用一次,所以我们判断如果path里面包含了这个数字,那我们就用continue跳过这次循环
            continue
        }
        path.push(nums[i]);         
        count(path);                //进行下一层递归
        path.pop();                 //删除此时path末尾的元素,以保证path的这一层深度进入下一次循环的长度没发生改变
    }
}

count([]);                         //回溯函数的第一次调用
return res;                        //返回最后的结果出去

递归回溯是比较容易绕晕的一种算法,因为你的思路和逻辑在考虑中间过程的时候要清楚地知道这个函数执行到了第几层(即深度);不过只要仔细一点,问题也不会很大。

动态规划篇

动态规划算法的定义

什么是动态规划呢?

这是一个好问题,动态规划与递推有些相似,它的实质就是分治思想和解决冗余,它会将大问题转化成若干个更小的相同的问题去进行求解;同时,动态规划求的是最优解,所以它的操作也必然是针对于状态的!

动态规划的应用场景

1.最优子结构

最优子结构性质,如果问题的最优解包括的子问题的解也是最优的,我们就称该问题具有最优子结构性质(即满足最优化原理)。最优子结构性质为动态规划提供了重要的线索

2.无后效性

无后效性,即子问题的解一旦确定,就不再改变,不受在这之后,包含它的更大的问题的求解决策影响。

3.子问题重叠

子问题重叠性质,子问题重叠性质是指在用递归算法自顶向下对问题进行求解,每次产生的子问题并不总是新问题,有些子问题会被重复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只计算一次,然后将其计算结果保存在一个表格中,当再次需要计算已经计算过的子问题时,只是在表格中简单地查看一下结果,从而获得较高的效率。

动态规划实战教学

题目:打家劫舍

题目来源:leetcode:打家劫舍

1659072789208.png

理解题目意思可以发现,我们需要在这里面拿到最多的钱,但是我们只能隔着房子才能进行盗窃,这就是一个很限制的条件了;

第一眼可能不少人会觉得最后只有两种结果,一个走奇数之和,一个走偶数只和即可拿到最大的两个值,然后做比较就可以得到最大的那个数,但是这样的思想在这里就是错误的啦;

还有一种特殊的情况就是,隔着一个房子偷没有隔着两个房子进行盗窃得到的金额多,例如:

1659073722184.png

这种情况,不论是怎么隔着一个房子偷,都没有这种隔着两个房子偷一次所得的金额要多。

那么我们就开始我们的动态规划啦,这里我们用例为:nums=[1,9,7,3,4,9,5]

我们从第三个数开始遍历,因为第一个我们是要隔着一个房子才能进行偷窃,每次我们都要记录一次偷窃的状态;

1659074535826.png

既然我们每次都得隔着一个房子才能盗窃,我们当前的位置在nums[2],那么我们就找在nums[1]之前的数里所有数的最大值,不包括nums[1];

然后我们发现在nums[1]之前只有nums[0],所以我们将其加到nums[2]上记录状态:

1659074907457.png

记录好状态之后我们就往下一个数走去,停在num[3]的位置,那么我们就不能盗窃nums[2]里的金额,只能盗窃nums[2]之前的金额;

故我们找到了前面从nums[0]开始到nums[2]之前里面最大的数是nums[1]=9,将其加上,在nums[3]上记录在偷到第四个位置可以偷到的最大的金额状态:

1659075164227.png

记录好状态之后我们就往下一个数走去,停在num[4]的位置,那么我们就不能盗窃nums[3]里的金额,只能盗窃nums[3]之前的金额;

故我们找到了前面从nums[0]开始到nums[3]之前里面最大的数是nums[1]=9,将其加上,在nums[4]上记录在偷到第五个位置可以偷到的最大的金额状态:

1659075414146.png

记录好状态之后我们就往下一个数走去,停在num[5]的位置,那么我们就不能盗窃nums[4]里的金额,只能盗窃nums[4]之前的金额;

故我们找到了前面从nums[0]开始到nums[4]之前里面最大的数是nums[3]=12,将其加上,在nums[5]上记录在偷到第六个位置可以偷到的最大的金额状态:

1659075607598.png

再到最后一个数,同样的,我们不能盗窃它前面那个房子里的数,我们只能偷窃它前面的;

那么我们就找到隔着nums[5]之前里最大的数,这个数则是前面最大的和,我们将它加到最后一个数上记录偷窃状态:

1659075807257.png

那么,我们最后只需要比较最后的两个房子里面的哪个房子里累计盗窃的金额更多啦,更多的那个一定就是连续盗窃这一排房子所能得到金额的最优解啦。

1659075998198.png

即最优解maxmoney=21;

代码实现如下:

nums = [1,9,7,3,4,9,5]
var rob = function(nums) {
    if(nums.length==1){          //判断长度,如果长度为1,那么直接盗窃这一家即可,直接返回
        return nums[0];
    }else if(nums.length==2){     //如果长度为2,那么就盗窃这两家的最大值,返回出去
        return Math.max(nums[0],nums[1]);
    }else{                        //如果长度大于2,那么我舅舅进行上面图解操作
        for(let i=2;i<nums.length;i++){     //对nums进行遍历,从下标为2开始
            var sm=nums.slice(0,i-1);       //存储隔着一位以前的所有数据
            nums[i]=Math.max.apply(Math,sm)+nums[i];       //记录偷到这个房子所能偷盗的最大金额
        }
    }
    return Math.max(nums[nums.length-1],nums[nums.length-2]);      //比较最后两个房子里面的最大值,这个最大值即是盗窃这一排房屋所能进行的最大金额,最优解了
};

动态规划一向是算法里比较难的题目,因为它不像回溯和双指针等等那样有一套固定的思维,它是没有固定的思维的,所以要格外注意它的应用场景,知道在什么场景可以运用它的。

那么我们的算法题就到这了,这些算法算是数组里面试最经常考的算法啦,大家可以好好琢磨清楚嗷

练习--算法题目

如果大家有想法,缺一些题目练手,那么小尘在这里给大家提供一些题目:

双指针题目

leetcode:盛水最多的容器

leetcode:三数之和

leetcode:轮转数组

leetcode:下一个排列

leetcode:接雨水

滑动窗口题目

leetcode:长度最小的子数组

leetcode:找出字符串所有字母异位词

leetcode:最小覆盖子串

回溯法题目

leetcode:电话号码的字母组合

leetcode:子集

leetcode:组合总和

leetcode:全排列II

leetcode:组合总和II

动态规划题目

leetcode:爬楼梯

leetcode:买卖股票的最佳时机

leetcode:买卖股票的最佳时机II

leetcode:买卖股票的最佳时机III

leetcode:打家劫舍II

如果对大家有用的话,大家就动动发财的小手给的小赞叭