浮躁,是互联网时代的
大家好,我是柒八九。
今天,我们继续前端面试的知识点。我们来谈谈关于JS算法的相关知识点。
该系列的文章,大部分都是前面文章的知识点汇总,如果想具体了解相关内容,请移步相关系列,进行探讨。
如果,想了解该系列的文章,可以参考我们已经发布的文章。如下是往期文章。
好了,天不早了,干点正事哇。
题目描述:
给定两个整数 a 和 b ,求它们的除法的商 a/b ,要求不得使用乘号 '*'、除号 '/' 以及求余符号 '%' >
提示:
- 当发生溢出时,返回最大的整数值。假设除数不能为0
- 只能存储 32 位有符号整数,其数值范围是
[−231, 231−1]
示例:输入: -15和2 输出: -7
[−231, 231−1]
,所以,我们可以定义边界值 MIN = -Math.pow(2, 31)
/ MAX = Math.pow(2, 31) - 1
sign =(a>0)^(b>0)
位运算的异或运算(^):在两个二进制位不同时返回1,相同时返回0 ,这样有一个好处,就是其实我们不关心,到底是哪个为正哪个为负。Math.abs(x)将x变更成正整数
count
) ==> 而最后的次数,就是所求的商function divide(a, b) { const MAX = Math.pow(2, 31) - 1, //最大值 MIN = -Math.pow(2, 31) //最小值 if (a == MIN && b == -1) return MAX; // 处理边界情况 const sign = (a > 0) ^ (b > 0); // 处理商的正负问题 a = Math.abs(a), b = Math.abs(b) // 变成正整数之间的相减 let count = 0 while(a >= b) { a -= b count++ } // sign为正,说明,除数和被除数之间 有一个为负数 return sign ? -count : count; }; 复制代码
题目描述:
给定两个 01 字符串 a 和 b ,请计算它们的和,并以二进制字符串的形式输出。
输入为 非空 字符串且只包含数字 1 和 0
示例:输入 a = "11", b = "10" 输出: "101"
参考十进制加法来完成二进制加法。在进行十进制加法操作时候,总是将两个数的右端对齐,然后从它们的个位开始从右向左相加同一位置的两个数。如果前一位置有进位,还要加上进位。
从上面分析可知,有几个关键点
i
/j
)carry
)index
处字符的ASCII
码 str.charAt(index)
(digitA + digitB + carry)>=2
carry是上一位的残留产物function addBinary(a, b) { let result = ''; // 用于存储结果 let i = a.length - 1; // 指针i let j = b.length -1; // 指针j let carry = 0; // 用于存储进位 while(i>=0 || j>=0){ //只有有数据,就进行数据操作 // 指定位置的数字(二进制0/1) // 判断位置,以免位置出错 i >=0 let digitA = i >=0 ? a.charAt(i--) -'0':0; // 指定位置的数字(二进制0/1) // 判断位置,以免位置出错 j >=0 let digitB = j >=0 ? b.charAt(j--) -'0':0; // 求和处理 let sum = digitA + digitB + carry; // 处理进位 carry = sum >=2 ? 1 :0; // sum可能超过最大当前进度的最大值, //例如: 十进制的 7+8 = 15 ==> 指定的个位存 5 (15-10) sum = sum >=2 ? sum - 2:sum; result = sum + result; } // 最高位可能会产生进位 if(carry ==1){ result = '1' + result; } return result }; 复制代码
function Nsum(a,b,n){ let result = ''; let i = a.length - 1; let j = b.length -1; let carry = 0; while(i>=0 || j>=0){ let digitA = i >=0 ? a.charAt(i--) -'0':0; let digitB = j >=0 ? b.charAt(j--) -'0':0; let sum = digitA + digitB + carry; carry = sum >=n ? 1 :0; sum = sum >=n ? sum - n:sum; result = sum + result; } if(carry ==1){ result = '1' + result; } return result; } 复制代码
题目描述:
给定一个非负整数 n ,请计算 0 到 n 之间的每个数字的二进制表示中 1 的个数,并输出一个数组。
输入: n = 2 输出: [0,1,1]
解释:
0 --> 0
1 --> 1
2 --> 10
我们可以为题目做一个转化,只要我们能求出一个整数i
的二进制形式中1的个数,这个问题就迎刃而解。 因为,最后的要的数组,无非就是将某一范围内数据的二进制中1的个数做一个统计。
我们能从题目中挖掘的主要信息有:
利用
i&(i-1)
将整数i的最右边的1变成0
整数i
减去1,那么它最右边的1变成了0。例如:二进制1100
,它减少1的结果是1011
。而1100 & 1011 = 1000
。
所以我们可以利用这个特性,对0~n
所有的数进行i&(i-1)
处理。也就是需要两层循环,第一层循环是遍历整体数据,第二层循环是针对特定数据i
。
function countBits(n){ // 构建一个长度为n+1 (0~n),每项都是0的数组 let result = new Array(n+1).fill(0); // 外层循环:处理整体数据 for(let i=0;i<=n;i++){ let j =i; // 内层循环:对特定数据进行j&(j-1)处理,直到 j里面不含有1,也就是为0 while(j!=0){ result[i]++; j = j & (j-1) } } return result; } 复制代码
整数i的二进制形式中1的个数比i&(i-1)
的二进制形式中1的个数多1。并且,原来的代码中我们是从i=0
开始整体遍历的,然后,根据常识可知,i=0
中1的个数为0。根据这些条件,我们可以对上述代码,做一个优化处理,也就是合理利用已经计算出的结果。
function countBits(n){ // 构建一个长度为n+1 (0~n),每项都是0的数组 let result = new Array(n+1).fill(0); // 从i=1开始遍历 for(let i=1;i<=n;i++){ result[i] = result[i&(i-1)]+1 } return result; } 复制代码
这样,少了一层遍历,然后对应的时间复杂度也减少了。
题目描述:
一个整数数组 nums ,除某个元素仅出现 一次 外,其余每个元素都恰出现 三次 。找出并返回那个只出现了一次的元素。
提示:
-231 <= nums[i] <= 231 - 1
输入:nums = [2,2,3,2] 输出:3
从提示,我们可以得知,整数是由32个0和1组成的。我们可以将数组中的所有数字的同一位置相加。
i>>1
通过右移动一位的方式,来快速获取 i/2,其实在位运算中,还可以i>>n
。 也就是将当前位的数右移N位。i
)的二进制位。所以,(num>>(31-i))&1
就是某个数字在i
的位数。num<<1
将当前num向右扩大一倍,从二进制角度看,就是将最右边位置补0 10
2<<1 == 4
(二进制位100) 可以通过(4).toSting(2)
进行验证function singleNumber(nums) { // 构建一个用于存储数组所有数字位数之和的数组 let bitSums = new Array(32).fill(0); for(let num of nums){ for(let i=0;i<32;i++){ // 求num在i位置的位数,并将其与指定位置的位数相加 bitSums[i] +=(num>>(31-i)) &1; } } let result =0; for(let i=0;i<32;i++){ //从最地位(0)位开始遍历 result = (result<<1) + bitSums[i]%3; } return result; }; 复制代码
代码中(result<<1) + bitSums[i]%3
其实也承载了两种含义
bitSums[i]%3 ==0
,也就是能被3整除,只出现一次的数字在该位置(i
)没出现过bitSums[i]%3 ==1
,也就是能被3除余1,只出现一次的数字在该位置(i
)出现过题目描述:
一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。
输入: [2,2,1]输出: 1
我们将上面的代码,做一个简单的修改。
function singleNumber(nums) { // 构建一个用于存储数组所有数字位数之和的数组 let bitSums = new Array(32).fill(0); for(let num of nums){ for(let i=0;i<32;i++){ // 求num在i位置的位数,并将其与指定位置的位数相加 bitSums[i] +=(num>>(31-i)) &1; } } let result =0; for(let i=0;i<32;i++){ //从最地位(0)位开始遍历 result = (result<<1) + bitSums[i]%2; } return result; }; 复制代码
通过对比发现,其实我们就更改了一处地方将(result<<1) + bitSums[i]%3
更换为了(result<<1) + bitSums[i]%2
仅此而已。
其实,针对该题还有一个更简单的处理方式。是利用了位运算中异或运算(^):两个二进制位不同时返回1,相同时返回0
function singleNumber(nums) { let result = 0; for(let i of nums){ result ^= i; } return result }; 复制代码
result^=i
如果在位置i上存在两个相同的数字,通过异或处理,直接清零。类似于消消乐,他们两个互相抵消了。那么剩下的就是那个只出现一次的数字的位数了。
由于文章篇幅有限,如果想了解相关内容,请移步到指定文章中。
JS 只支持一维数组,并不支持矩阵(多维数组) 在JS中,我们可以通过很多方式来构建一维数组。
let array = Array('柒','八','九'); // new Array / []等 复制代码
而构建多维数组,就需要借助函数来构建。
const matrix= (x,y) => new Array(x).fill(0) .map( ()=>new Array(y).fill(0) ) 复制代码
通过matrix
我们构建了一个,x
行,y
列 且数组中值都为0
的二维数组(矩阵)。
双指针是一种常见的解题思路,使用两个相反方向或者相同方向的指针扫描数组从而达到解题目的。
有两种解题思路: 反向双指针/同向双指针
sum
)或者乘积(mult
)。题目描述:
输入一个递增排序的数组和一个值
target
,在数组中找出两个和为target
的数字并返回它们的下标提示:
数组中有且只有一对符合要求
同时一个数字不能使用两次
示例:输入数组: [1,2,4,6,10],k的值为8 输出[1,3]
left
指向首元素,right
指向尾元素sum
VS target
移动对应的指针function twoSum4SortedArray(nums,target){ let left=0,right= nums.length-1; // 初始化指针left,right while(left<right && nums[left] + nums[right] != target){ if(nums[left] + nums[right]<target){ left++; }else{ right--; } } return [left,right] } 复制代码
注意:如果大家在刷leetcode
等算法网站中,肯定会见过关于数组的两数之和的题。但是,这里的题干和常规的两数之和还有点区别。首先,该题干中,天生有序,所以,可以套用反向双指针的套路。
为了做区分,我们将twoSum
的解题代码也直接写出来。它的解题思路是,利用了Map
对数据和下标做了转换。
function twoSum(nums,target){ let valueToIndex = new Map(); // 用于,存储[nums[i],i]之间的关系 for(let i = 0;i<nums.length;i++){ let expectValue = target - nums[i]; // 先从map中找,是否存在指定值 if(valueToIndex.has(expectValue)){ // 如果有,直接返回与值相对于的下标 return [valueToIndex.get(expectValue),i] } // 存储[nums[i],i]之间的关系 valueToIndex.set(nums[i],i); } return null; } 复制代码
题目描述:
输入一个数组,找出数组中所有和为
target
的3个数字的三元组提示:
返回值不得包含重复的三元组
示例:输入数组:
[-1,0,1,2,-1,-4]
,target的值为0 输出[[-1,0,1],[-1,-1,2]]
left
指向固定元素的后一个元素,right
指向尾元素sum
VS target
移动对应的指针sort
但是需要指定排序函数nums.sort((a,b)=>a-b)
function threeSum(nums,target){ let result = []; if(nums.length<3) return []; // 人工对数据进行排序处理 nums.sort((a,b)=>a-b); let i =0; while(i<nums.length-2){ twoSum(nums,i,target,result); let temp = nums[i]; // 剔除,重复元祖中第一个数值 while(i<nums.length && nums[i]==temp) i++; } return result; } 复制代码
我们把排序数组中的两个数字之和的算法,做了一个改造,因为left
不在从0
开始,所有需要将left
的前一个位置i
传入,right
的逻辑不变,还是数组尾部
left = i + 1
right = nums.length - 1
function twoSum(nums,i,target,result){ // 初始化指针left,right let left = i + 1, right = nums.length -1; while(left<right){ // 求和 let sum = nums[i] + nums[left] + nums[right]; // 指针移动过程 (if/else) if(sum === target){ result.push([nums[i],num[left],nums[right]]); let temp = nums[left]; // 剔除,重复元祖第二个数值 while(nums[left] === temp && left < right) left++; }else if(sum < 0) { left++; }else{ right--; } } } 复制代码
我们来对比下,两个twoSum
的共同和不同点。
它们的核心框架是相似的。都是利用方向双指针进行sum
与target
之间的数据对比。
题目描述:
输入一个正整数组成的数组和一个正整数
target
,找出数组中和大于或等于target
的连续子数组的最短长度提示:
如果不存在满足条件的子数组,返回0
示例:输入数组:
[5,1,4,3]
,target
的值为7 输出2
(和大于或等于7的最短连续子数组是[4,3]
)
left
指向子数组的第一个数字right
指向子数组的最后一个数字left
/right
两指针之间的所有数字的组成left
永远不会走到指针right
的右边left
/right
初始化的时候都指向数组的第一个元素,套用上面的公式sum
< target
: 右移 right
(right++
),在子数组右边增加新的数字,子数组长度+1sum
>= target
: 右移left
(left++
),删除子数组最左边的数字,子数组长度-1sum
>=target
时,为了求最短子数组,我们需要移动left
进行子数组的瘦身处理 (left++
)function minSubArrayLen(nums,target){ let left=0,right=0; // 同向双指针,默认值 let sum=0; // 最小的长度 let minLength = Number.MAX_SAFE_INTEGER; for(right=0;right<nums.length;right++){ sum+=nums[right]; while(left<=right && sum>=target){ // 更新最小长度 minLength = Math.min(minLength,right - left + 1); // 子数组**瘦身**处理 sum-= nums[left++] } } return minLength == Number.MAX_SAFE_INTEGER?0:minLength; } 复制代码
有几点需要唠叨一下:
Number.MAX_SAFE_INTEGER
) 如果已知最大范围,我们也可以给一个定值。例如,100/1000
等;那最大值,就是用0
等替换right
先动,left
视情况而动题目描述:
输入一个正整数组成的数组和一个正整数
target
,找出数组中乘积小于target
的连续子数组的所有组合的个数
示例:输入数组:
[10,5,2,6]
,target
的值为100
输出8
([10],[5],[2],[6],[10,5],[5,2],[2,6],[5,2,6])
left
永远不会走到指针right
的右边 (left<=right
)mult
< target
: 右移 right
(right++
),在子数组右边增加新的数字,子数组长度+1 (mult变大)mult
>= target
: 右移left
(left++
),删除子数组最左边的数字,子数组长度-1(mult变小)left
到某个位置时子数组的乘积小于target
,就不需要移动left
。只要right
保持不动,[left
,right
]区间的所有的子数组的乘积都一定小于target
。 也就说说,两个指针之间有多少个数字,就有多少个满足条件的子数组function numSubarrayMultLessThanTarget(nums,target){ let mult = 1; let left =0,right=0; // 初始化指针 let count = 0; for(right=0;right<nums.length;right++){ mult *=nums[right]; // 指针left永远不会走到指针right的右边 while(left<=right && mult >= target){ mult /=nums[left++]; } count += right>=left?right - left +1 :0; } return count; } 复制代码
虽然,在求正整数数组和或者乘积之间,有些具体逻辑不大相同,但是总体的思路都是利用同向双指针对数据进行处理。
使用双指针解决子数组之和有一个前提条件:数组中的所有数字都是正数。所有,双指针的在解决非正数的数组时,是不满足条件的。
针对非正数的数组,我们换一个思路来求子数组之和。
假设整个数组的长度为n
,它的某个子数组的第一个数字的下标是i
;最后一个数字的下标是j
。我们做一个预处理,计算从数组下标为0的数字开始到以每个数字为结尾的子数组之和。预处理只需要从头到尾扫描一次,就能求出从下标0开始到下标0结束的子数组之和 S0 ,以此类推,S1,S2,Sn-1因此,从下标为i
开始到下标为j
结束的子数组的和就是 Sj-Si-1
例如:在数组[1,2,3,4,5]
中,从S2的子数组[1,2,3]
之和是6,S4的子数组[1,2,3,4,5]
之和是15,那么从下标3开始到下标4结束的子数组之和[4,5]
之和是9,也就是 S4 - S2 即: 15 - 9 = 6
题目描述:
输入一个整数组成的数组和一个整数
target
,找出数组中数字之和等于target
的连续子数组的个数
示例:输入数组:
[1,1,1]
,target
的值为2
输出2
i
个数字之和,并将和保存下来i
个数字之和记为x
j
(j<i
) 即,j
在x
前面,且数组的前j
个数字之和为x-target
(很重要)j+1
个数字开始到第i
个数字结束的子数组之和为target
target
的子数组的个数。当扫描到数组第i
个数字并求得前i
个数字之和是x
时,需要知道在i
之前存在多少个j
并且前j
个数字之和等于x-target
i
,不但要保存前i
个数字之和,还要保存每个和出现的次数Map
(哈希表)的键(key)保存前i
个数字之和,值(value)保存每个和出现的次数function subarraySum(nums,target){ // 构建哈希表,并初始化[0,1] let sumToCount = new Map(); sumToCount.set(0,1); let sum = 0; let count =0; for(let num of nums){ sum += num; // 统计 在当前数值下的 x-target的值 count += sumToCount.get(sum - target) // 前面已经存在 || 0; // 首次出现 sumToCount.set( sum, (sumToCount.get(sum)||0)+1 ) } return count; } 复制代码
题目描述:
输入一个只包含0和1的数组,求0和1的个数相同的最长连续子数组的长度
示例:输入数组:
[0,1,0]
输出2
(两个子数组分别是[0,1]和[1,0])
i
个数字之和i
个数字之和为m,前j
个数字(j<i
)之和也为m,那么从j+1
到第i个数字的子数组之和为0,长度为i - j
function findMaxLength(nums){ let sumToIndex = new Map(); sumtoIndex.set(0,-1); let sum = 0; let maxLength =0; for(let i=0;i<nums.length;i++){ // 数据转化 sum+=nums[i]==0?-1:1; if(sumToIndex.has(sum)){ maxLength = Math.max(maxLength,i-sumToIndex.get(sum)); }else{ // 存储关系 sumToIndex.set(sum,i) } } return maxLength; } 复制代码
我们可以看到,在和为target的子数组和0和1个数相同的子数组中,虽然有些细节是不一样的,但是总体框架还是一致的。
i
个数字之和 求出sum
Map
来存储sum
与对应所求的(count/index
)直接的关系题目描述:
输入一个整数数组,如果一个数字左边的子数组的数字之和等于右边子数组的数字之和,返回该数字的下标
示例:输入数组:
[1,7,3,6,2,9]
输出3
(对应的值为6) ,下标为3的数字(值为6)的左边3个数1,7,3的和与右边两个数字2,9的和相等
i
个数字时i-1
个数字的和i+1
个数字开始累加到最后一个数字的和,这个和等于数组中所有数字之和减去从第一个数字累加到第i
个数字的和function pivotIndex(nums){ let total =0; total =nums.reduce((pre,cur)=>pre+cur); let sum = 0; for(let i=0;i<nums.length;i++){ sum+=nums[i]; if(sum - nums[i] == total - sum){ return i } } return -1; } 复制代码
sum
套路就那般left
为首right
为尾(求两数之和)i
个数据和)Map(sum,count/index)
来辅助在JS中,字符串可以被视为字符数组
str.charAt(i)
str
在i
位置的字符在JS中,字符之间是无法之间相减
'b' - 'a' // NAN 复制代码
其实,这里面的深层次的原因是,JS中针对 '-'操作符,没兼容字符。而-
操作符要的预期就是返回数值,因为,字符没被兼容,所以结果返回了一个NAN
。
作为替换方案,str.charAt(i).charCodeAt()
(获取str
在i
位置的字符ASCLL
码值 )就肩负起,字符之间相减的操作
str = 'ba'; str.charAt(1).charCodeAt() - str.charAt(0).charCodeAt() // 结果为1 b的ascll 为98,a的ascll为97 即:98 -97 复制代码
字符串可以被视为字符数组,那么也可以用双指针的技巧来处理字符串的一些问题。
由于在处理字符串时,很多都与统计字母出现的次数有关,所以我们可以借用哈希表(map)来存储每个元素出现的次数。
Map 在信息存储方面还是很有用的。在讲数组算法中,在非正整数用Si时,就用 Map进行key 和value的信息存储
题目描述:
输入字符串s1和s2,判断s2中是否包含s1的某个变位词
提示:
如果s2中包含s1的某个变位词,则s1至少有一个变位词是s2的子字符串
假设两个字符串中只包含英文小写字母
示例:s1 为“ac”, s2为“dgcaf” ,由于s2包含s1的变位词"ca", 结果为true
a
, 下标为1对应字母b
s1
,每扫描到一个字符,就找到它在哈希表中的位置,并把它对应+1
s2
的子字符串是否包含s1
的变位词s1
长度为n
s2
中长度为n
的子字符串是不是s1
的变位词-1
s1
的变位词function checkInclusion(s1,s2){ let s1l = s1.length,s2l = s2.length; if(s2l<s1l) return false; // 构建 字符 => 个数 数组 let counts = new Array(26).fill(0); // 遍历s1,并对s1中字符进行数据收集 (++) // 针对已收集的s1数据信息,与s2进行匹配(--) for(let i =0;i<s1l;i++){ counts[s1.charAt(i).charCodeAt() - 97]++; counts[s2.charAt(i).charCodeAt() - 97]--; } //判断,是否全为0,如果是,刚才已经满足情况了,直接返回true if(areaAllZero(counts)) return true; //从s1l的位置出发,先匹配,后收集(类似同向双指针) for(let i = s1l;i<s2l;i++){ counts[s2.charAt(i).charCodeAt() - 97]--; counts[s2.charAt(i - s1l).charCodeAt() -97]++; if(areaAllZero(counts)) return true; } return false } 复制代码
辅助函数,用于判断,数值中值是否都为0
function areaAllZero(counts){ for(let count of counts) { if(count >0) return false } return true; } 复制代码
在上面的函数中,
i-s1l
的位置i
相当于第二个指针,指向子字符串的最后一个字符s1
的长度题目描述:
输入字符串s1和s2,找出s1的所有变位词在s2中的起始下标
提示:
假设两个字符串中只包含英文小写字母
示例:s1 为“abc”, s2为“cbadabacg” ,s1的两个变位词"cba"/"bac"是
s2
中的子字符串,输出在s2
中的起始下标为0和5
和找字符串中的变位词的思路是一样的
a
, 下标为1对应字母b
s1
,每扫描到一个字符,就找到它在哈希表中的位置,并把它对应+1
s2
的子字符串是否包含s1
的变位词s1
长度为n
s2
中长度为n
的子字符串是不是s1
的变位词-1
s1
的变位词(进行下标的记录处理)function findAnagrams(s1,s2){ let result = []; let s1l = s1.length,s2l = s2.length; if(s2l<s1l) return result; let counts = new Array(26).fill(0); for(let i= 0;i<s1l;i++){ counts[s1.charAt(i).charCodeAt() - 97]++; counts[s2.charAt(i).charCodeAt() - 97]--; } if(areaAllZero(counts)) result.push(0); for(let i= s1l;i<s2l;i++){ counts[s2.charAt(i).charCodeAt()-97]--; counts[s2.charAt(i-s1l).charCodeAt()-97]++; // 在满足情况下,对应的开始下标为`i-s1l+1` if(areaAllZero(counts)) result.push(i - s1l+1); } return result } 复制代码
辅助函数,用于判断,数值中值是否都为0
function areaAllZero(counts){ for(let count of counts) { if(count >0) return false } return true; } 复制代码
针对字符串中的变位词还是字符串中所有变位词中用到的思路,都是利用数组来模拟哈希表(map)然后,针对特定的场景进行数据的处理。然后,针对双指针的定义,在第二个for
循环中,第一个指针为i-s1l
对应的位置,第二个指针,为i
对应的位置,而两者恰好相差(s1l)的长度。
题目描述:
输入一个字符串,求该字符串中不含重复字符的最长子字符串
示例: 输入"babcca",其最长的不含重复字符的子字符串为“abc”,长度为3
new Array(256).fill(0)
function lengthOfLongestSubstring(s){ let sl = s.length; if(sl==0) return 0; let counts = new Array(256).fill(0); let longest = 0; let j= -1; // 左指针 // i 为右指针 for(let i=0;i<sl;i++){ counts[s.charAt(i).charCodeAt()]++; while(hasGreaterThan1(counts)){ j++ counts[s.charAt(j).charCodeAt()]--; } // 更新最长子串的长度 longest = Math.max(i-j,longest); } return longest; } 复制代码
辅助函数,用于判断数组中是否有含有大于1的数
function hasGreaterThan1(counts){ for(let count of counts){ if(count>1) return true } return false; } 复制代码
在上面代码中,其实难点就是双指针的定义和赋值
hasGreaterThan1
为true时,j++
,且counts
指定位置counts[s.charAt(j).charCodeAt()]--
回文是一类特殊的字符串。不管从前往后,还是从后往前,得到的字符信息都是一样的。
题目描述:
输入一个字符串,判断它是不是回文
提示:
只考虑字母和数字字符,忽略大小写
示例: 输入字符串“abba”返回true, 输入“abc”返回false
function isPalindrome(s){ let left =0,right = s.length -1; while(left<right){ // 获取指定位置的字符 let cl = s.charAt(left); let cr = s.charAt(right); // 跳过非数字和字母的字符 (!isLetterOrDigit(x)) if(!isLetterOrDigit(cl)){ left++; }else if(!isLetterOrDigit(cr)){ right--; }else { // 大小写不敏感 cl = cl.toLocaleLowerCase(); cr = cr.toLocaleLowerCase(); // 不一样,跳出循环 if(cl!=cr) return false // 指针移动 left++; right--; } } return true; } 复制代码
辅助函数
const isLetterOrDigit = str => /^[A-Za-z0-9]+$/.test(str) 复制代码
题目描述:
输入一个字符串,判断最多从字符串中删除一个字符能不能得到一个回文字符串
示例: 输入字符串“abca”, 删除字符
b
或者c
能得到一个回文字符串,因此输出true
function validPalindrome(s){ let left =0,right = s.length -1; let middlePosition = s.length>>1; // 移动指针,并比较字符是否相等 for(;left<middlePosition;left++,right--){ if(s.charAt(left)!=s.charAt(right)) break; } // 这里有两类情况 // 1: 字符串本身是回文 (left == middlePosition) // 2. 需要对字符串进行字符剔除 (isPalindrome) return left == middlePosition || isPalindrome(s,left,right-1) || isPalindrome(s,left+1,right) } 复制代码
辅助函数,用于判断字符串是否是回文
function isPalindrome(s,left,right){ while(left<right){ if(s.charAt(left)!= s.charAt(right)) break; left++; right--; } return left>=right; } 复制代码
这里有一个比较重要的点,就是最多可以删除一个字符。放到代码中其实就是在validPalindrome
中return
那里体现
validPalindrome
中for
循环没阻断,即:left == middlePositon
validPalindrome
中的for
发生了阻断(break)isPalindrome(s,left,right-1)
isPalindrome(s,left+1,right)
题目描述:
输入一个字符串,求字符串中有多少个回文连续子字符串?
示例: 输入字符串“abc”有3个回文子字符串,分别是"a"/"b"/"c"
function countSubstrings(s){ if(s==null || s.length==0) return 0; //处理边界 let count = 0; for(let i=0;i<s.length;i++){ // 字符串下标为i。 // 既作为奇数回文的中心 // 又可以和i+1一同作为偶数回文的中心 count+=countPalindrome(s,i,i); count+=countPalindrome(s,i,i+1); } return count; } 复制代码
辅助函数,
function countPalindrome(s,left,right){ let count = 0; while(left>=0&&right<s.length && s.charAt(left)==s.charAt(right)){ count++; left--; right++; } return count; } 复制代码
这个题,最主要的就是需要明白:
i
个字符本身可以成为长度为奇数的回文字符串的对称中心i
的位置 countPalindrome(s,i,i);
i
个字符和第i+1
个字符可以成为长度为偶数的回文字符串的对称中心i
的位置 countPalindrome(s,i,i+1);
counts = new Array(x).fill(0)
s.charAt(i).charCodeAt()
++
或--
i-s1l
,第二指针i
left=0
/right= s.length-1
i
可为奇数中心,i
与i+1
可为偶数中心构造链表节点 单向链表的节点代码
class ListNode { val: number next: ListNode | null constructor(val?: number, next?: ListNode | null) { this.val = (val===undefined ? 0 : val) this.next = (next===undefined ? null : next) } } 复制代码
其实,针对链表存在一些常规的工具方法。一些比较复杂的算法,都是各种工具方法的组合。
而下面的这些方法,是需要熟记的。
关键点解释:
perv = null
cur = head
next = cur.next
next= cur.next
)cur.next = prev
)prev= cur
)cur=next
)function reverseList(head){ // 初始化prev/cur指针 let prev = null; let cur = head; // 开始遍历链表 while(cur!=null){ // 暂存后继节点 let next = cur.next; // 修改引用指向 cur.next = prev; // 暂存当前节点 prev = cur; // 移动指针 cur = next; } return prev; }; 复制代码
关键点解释:
while(fast && fast.next)
function hasCycle(head){ let fast = head; let slow = head; while(fast && fast.next){ fast = fast.next.next; slow = slow.next; if(fast == slow) return true; } return false; } 复制代码
关键点解释:
l1/l2
可能初始为空),采用dumy
节点dumy = new ListNode(0)
node = dumy
l1/l2
相同位置的节点信息while(l1 && l2)
node.next = lx
(x=1/2)lx = lx.next
l1/l2
溢出部分的节点信息if(lx) node.next = lx
;dumy.next
function mergeList(l1,l2){ let dumy = new ListNode(0); let node = dumy; while(l1 && l2){ node.next = l1; l1 = l1.next; node.next = l2; l2 = l2.next; } // 由于l1/l2长度一致 if(l1) node.next = l1; if(l2) node.next = l2; return dumy.next; } 复制代码
关键点解释:
slow = head
fast = head
fast && fast.next
非空fast
每次移动两步 fast = fast.next.next
slow
每次移动一步 slow = slow.next
fast.next ==null
,但是,fast
可能不为空fast
不为空,slow
还需要移动一步 slow = slow.next
(奇数情况)slow
所在的节点就是链表的中间节点function middleNode(head){ let slow = head; let fast = head; // 遍历链表节点 while (fast && fast.next) { slow = slow.next; fast = fast.next.next; } // 处理链表节点为偶数的情况 if(fast){ slow = slow.next; } return slow; } 复制代码
哨兵节点是为了简化处理链表边界条件而引入的附加链表节点
哨兵节点通常位于链表的头部,它的值没有任何意义。在一个有哨兵节点的链表中,从第二个节点开始才真正的保存有意义的信息。
哨兵节点,在链表尾部添加元素
function append(head,value) { // 哨兵节点 let dumy = new ListNode(0); dumy.next = head; // 遍历链表,直到链表尾部 let node = dumy; while(node.next!=null){ node = node.next; } node.next = new ListNode(value); return dumy.next; } 复制代码
在遍历链表的时候,并不是直接对dumy
进行处理,而是用了一个临时游标节点(node
)。这样做的好处就是,在append
操作完成以后,还可以通过dumy
节点来,直接返回链表的头节点dumy.next
。 因为,dumy
一直没参与遍历过程。
为了删除一个节点,需要找到被删除节点的前一个节点,然后把该节点的
next
指针指向它下一个节点的下一个节点。
哨兵节点,在删除指定节点
function delete(head ,value){ let dumy = new ListNode(0); dumy.next = head; let node = dumy; while(node.next!=null){ if(node.next.value==value){ node.next = node.next.next; barek; } node = node.next; } return dumy.next; } 复制代码
通过哨兵节点(dumy
)直接将链表为空和被删除节点是头节点的两种特殊情况,直接囊括了。用最少的代码,处理最多的情况。
使用哨兵节点可以简化创建或删除链表头节点的操作代码
在链表中,双指针思路又可以根据两个指针的不同移动方式分为两种不同的方法。
- 前后双指针:即一个指针在链表中提前朝着指向下一个节点的指针移动若干步,然后移动第二个指针。
- 应用:查找链表中倒数第
k
个节点
- 快慢双指针:即两个指针在链表中移动的速度不一样,通常是快的指针朝着指向下一个节点的指针一次移动两步,慢的指针一次只移动一步。
- 特征:在一个没有环的链表中,当快的指针到达链表尾节点的时候,慢的指针正好指向链表的中间节点
k
个节点题目描述:
给定一个链表,删除链表中倒数第
k
个节点提示:
假设链表中节点的总数为
n
且1≤ k ≤ n
只能遍历一次链表
示例:链表为
1->2->3->4->5
,删除倒数第2个节点后链表为1->2->3->5
,删除了4
所在的节点
front
从链表的头节点开始向前走k
步的过程中,第2个指针back
保持不动k+1
步开始,back
也从链表的头节点开始和front
以相同的速度遍历k
,当指针front
指向链表的尾节点时(如果存在dumy
节点的话,就是front
指向尾节点的下一个节点),指针back
正好指向倒数第k+1
个节点k
个节点,而利用快慢双指针,我们可以找到倒数第k+1
个节点,即倒数k
节点的前一个节点, 那更新倒数k+1
节点的next
指针,就可以将倒数k
个节点删除k
等于链表的节点总数时,被删除的节点为原始链表的头节点,我们可以通过dumy
节点来简化对此处的处理dumy
节点的出现,由于存在front
/back
两个指针,就需要对其进行初始化处理。back
:由于存在③的情况(删除原始链表头节点),所以back
初始化必须是back=dumy
(back指向dumy
)front
:初始指向原始链表的头节点(front=head
)function removeNthFromEnd(head,k){ let dumy = new ListNode(0); dumy.next = head; let front = head; //指向原始链表头节点 let back = dumy; // 指向dumy节点 // front 向前移动了k个节点 for(let i =0; i<k; i++){ front = front.next; } // 同步移动 while(front!=null){ front = front.next; back = back.next; } // 删除第k个节点 back.next = back.next.next; return dumy.next; } 复制代码
在同步移动的过程中,只有front
移动到尾节点的下一个节点,即:null
时,此时back
节点才到了倒数k+1
的位置
题目描述:
如果一个链表包含环,找出环的入口节点!
示例:输入:head = [3,2,0,-4], 输出: pos = 1 返回索引为 1 的链表节点
append/delete
,可以不用dumy
节点)n
圈之后将会追上走的慢的指针fast
指针指向链表头部,slow
指针保持不变。 并且,slow/fast
以相同的速度(每次移动一个节点),在slow/fast
再次相遇时,就是环的入口节点n
后相遇,我们可以有a + n(b+c) + b
= a+(n+1)b+nc
(快指针移动的距离)(a+b)
(慢指针移动的距离)a+(n+1)b+nc=2(a+b)
(快指针比慢指针多移动一倍的距离)a=c+(n−1)(b+c)
可以得出,如果有两个指针分别从头节点和相遇节点以相同的速度进行移动。在经过n-1
圈后,他们会在环的入口处相遇判断一个链表是否有环
function hasCycle(head){ let fast = head; let slow = head; while(fast && fast.next){ fast = fast.next.next; slow = slow.next; if(fast == slow) return true; } return false; } 复制代码
找到环的入口节点
function detectCycle(head){ let fast = head; let slow = head; while(fast && fast.next){ fast = fast.next.next; slow = slow.next; if(fast ==slow){ fast = head; while(fast!=slow){ fast = fast.next; slow = slow.next; } return slow } } return null; } 复制代码
通过对比我们发现,利用双指针查找链表中环的入口节点,其实就是在判断链表是否有环的基础上进行额外的处理。
题目描述:
输入两个单向链表,找出它们的第一个重合节点
示例:链表A:1->2->3->4->5->6 链表B: 7->8->4->5->6
输出 两个链表的第1个重合节点的值是4
next
指针都指向同一个节点。count1/count2
)distance = Math.abs(count1-count2)
个节点distance
个节点后,此时两个链表中所剩节点数相同,也就是说,从接下来,可以认为在两个相同长度的单向链表中寻找第一个重合节点计算链表中有多少个节点
function countList(head){ let count = 0; while(head!=null){ count++; head = head.next; } return count; } 复制代码
查找第一个重合节点
function getIntersectionNode(headA, headB) { // 计算各自节点数量 let count1 = countList(headA); let count2 = countList(headB); // 相差节点数 let distance = Math.abs(count1-count2); // 找出最长/最短 链表 let longer = count1 > count2 ? headA : headB; let shorter = count1 > count2 ? headB : headA; // 定义指向 longer链表的指针 let node1 = longer; // 优先移动distance个节点 for(let i =0 ;i<distance;i++){ node1 = node1.next; } // 定义指向 shorter 链表的指针 let node2 = shorter; // 判断处理 while(node1!=node2){ node2 = node2.next; node1 = node1.next; } return node1; }; 复制代码
单向链表最大的特点就是其单向性--即只能顺着指向下一个节点的指针方向从头到尾遍历。
而有些情况,从链表尾部开始遍历到头节点更容易理解。所以,就需要对链表进行反转处理。
题目描述:
将指定的链表反转,并输出反转后链表的头节点
示例:链表:
1->2->3->4->5
反转后的链表为
5->4->3->2->1
, 头节点为5所在的节点
function reverseList(head){ // 初始化prev/cur指针 let prev = null; let cur = head; // 开始遍历链表 while(cur!=null){ // 暂存后继节点 let next = cur.next; // 修改引用指向 cur.next = prev; // 暂存当前节点 prev = cur; // 移动指针 cur = next; } return prev; }; 复制代码
题目描述:
给一个链表,链表中节点的顺序是
l0->l1->l2->(...)->l(n-1)->ln
。对链表进行重排处理,使节点顺序变成l0->ln->l1->l(n-1)->l2->l(n-2)->(...)
示例:链表:
1->2->3->4->5->6
重排后的链表为
1->6->2->5->3->4
1->2->3
第二部分:4->5->6
4->5->6
-->6->5->4
1->6->2->5->3->4
next
指针方向向前走两步找到链表的中间节点(使用快慢指针)
function middleNode(head){ let slow = head; let fast = head; // 遍历链表节点 while (fast && fast.next) { slow = slow.next; fast = fast.next.next; } // 处理链表节点为偶数的情况 if(fast){ slow = slow.next; } return slow; } 复制代码
合并两个链表
function mergeList(l1,l2){ let dumy = new ListNode(0); let node = dumy; while(l1 && l2){ node.next = l1; l1 = l1.next; node.next = l2; l2 = l2.next; } // 由于l1/l2长度一致 if(l1) node.next = l1; if(l2) node.next = l2; return dumy.next; } 复制代码
重排链表
function reorderList(head){ if(head==null) return head; //找到链表的中间节点 let mid = middleNode(head); // 前半部分链表 let l1 = head; // 后半部分链表 let l2 = mid.next; // 将原链表一分为二 mid.next = null; // 后半部分链表反转 l2 = reverseList(l2); // 合并处理 mergeList(l1, l2); } 复制代码
题目描述:
判断一个链表是回文链表
要求:时间复杂度为
O(n)
,空间复杂度为O(1)
示例:链表:
1->2->3->3->2->1
该链表为回文链表
还是熟悉的味道
找到链表的中间节点 (前文有介绍,这里不再赘述)
反转某部分链表 (前文有介绍,这里不再赘述)
那么,现在就剩下对两个链表进行对比判断了
function equails(head1,head2){ while(head1 && head2){ //相应位置的val不同,两个链表不同 if(head1.val!=head2.val){ return faslse; } head1 = head1.next; head2 = head2.next; } // 如果head1/head2长度不同,也返回false return head1 ==null && head2==null; } 复制代码
判断是否为回文
function isPalindrome(head) { let left = head; // 找到链表的中间节点 let slow = middleNode(head) // 反转链表 let right = reverse(slow); // 比较链表 return equals(left,right) }; 复制代码
dumy
节点来相助k
个节点prev/cur/next
next= cur.next
)cur.next = prev
)prev= cur
)cur=next
)我们就自己实现一个比较功能完备的stack
。它有如下的功能点
push(element(s))
:添加一个(或几个)新元素到栈顶pop()
:移除栈顶的元素,同时返回被移除的元素peek()
: 返回栈顶的元素,不对栈做任何修改isEmpty()
:如果栈里没有任何元素就返回true
,否则返回false
size()
: 返回栈里的元素个数clear()
: 移除栈里所有的元素class Stack { constructor() { this.items = []; } // 添加element到栈顶 push(element) { this.items.push(element); } // 移除栈顶的元素,同时返回被移除的元素 pop() { return this.items.pop(); } // 返回栈顶的元素,不对栈做任何修改 peek() { return this.items[this.items.length - 1]; } // 如果栈里没有任何元素就返回`true`,否则返回`false` isEmpty() { return this.items.length === 0; } // 返回栈里的元素个数 size() { return this.items.length; } // 移除栈里所有的元素 clear() { this.items = []; } } 复制代码
虽然,我们实现了一个功能完备的stack
结构,但是仔细一看,其实就是对array
中push/pop
等api
进行了一次包装。但是,经过包装后,使得针对stack
结构的各种操作,变得更具有封装性,而不会产生很多样板代码。
题目描述:
后缀表达式是一种算术表达式,也叫逆波兰式(
RPN
),它的操作符在操作数的后面。要求输入一个用字符串数组表示的后缀表达式,请输出该后缀表达式的计算结果。
示例:后缀表达式
["2","1","3","*","+"]
对应的表达式是2 + 1 * 3
,因此输出的计算结果为5
["2","1","3","*","+"]
为例子分析。2
,由于后缀表达式的特点,操作符还在后面,在操作符未知的情况下,是无法进行计算处理。所以,需要将当前的操作数进行暂存处理。1/3
)还是没有操作符的出现,继续将对应的操作数进行暂存处理*
)。按照后缀表达式的规则,此操作符对应的操作数是刚刚被暂存的一对操作数1/3
1/3
明显位于容器的尾部。也就是说,需要从容器的尾部将一对数据取出,并做运算处理。stack
来作为存储操作数的容器function evalRPN(tokens){ let stack = new Stack(); for(let token of tokens){ switch(token){ // 处理操作符 case "+": case "-": case "*": case "/": // 在源数据中,靠后的操作数 let back = stack.pop(); // 在源数据中,靠前的操作数 let prev = stack.pop(); // 计算操作数,并将其入栈处理 stack.push( calculate(prev,back,token) ); break; default: // 处理操作数,直接入栈 stack.push(parseInt(token)); } } // 操作符都处理完,且栈中只有一个数据 return stack.pop(); } 复制代码
辅助函数,用于处理两个操作数之间的算术问题。(有一点需要注意,就是操作数之间的顺序问题)
fucntion calculate(prev,back,operator){ switch(operator){ case "+": return prev + back; case "-": return prev - back; case "*": return prev * back; case "/": return (prev/back)>>0; // 数据取整 default: return 0; } } 复制代码
输入一个表示小行星的数组
- 数组中每个数字的绝对值表示小行星的大小
- 数字的正负表示小行星运动的方向,正号表示向右飞行,负号表现向左飞行。
- 如果两个小行星相撞,体积小的小行星会消失,体积大的不受影响
- 如果相撞的小行星大小相等,两个都会消失
- 飞行方向相同的小行星永远不会相撞
示例:有6颗小行星[4,5,-6,4,8,-5]
,它们相撞之后最终剩下3颗小行星[-6,4,8]
[4,5,-6,4,8,-5]
4/5
,而在遇到方向不同的行星时,是率先取最近一次加入到数据容器的数据。也就是说,针对数据容器中的数据的存取,满足后进先出的规则。我们可以考虑用栈来具象化该数据结构。stack
)battle
一下,哪怕身败名裂function asteroidCollision(asteroids){ let stack = new Stack(); for(let as of asteroids){ // 当前元素向左飞行,并且该元素的绝对值比栈顶元素大 while(!stack.empty() && stack.peek()>0 && stack.peek()<-as ){ stack.pop(); } // 当前元素向左飞行,当前元素和栈顶元素体积一样 (需要互相抵消) if(stack.length && as<0 && stack.peek()==-as ){ stack.pop(); }else if( as >0 //当前元素向右飞行 || stack.isEmpty() // 栈为空 (初始化) // 当前元素向左飞行(在经过对比后,还是无法消除) || stack.peek()<0 ){ stack.push(as) } } return stack; } 复制代码
给定一个只包括
'(',')'
,'{','}'
,'[',']'
的字符串 s ,判断字符串是否有效。 有效字符串需满足:
- 左括号必须用相同类型的右括号闭合。
- 左括号必须以正确的顺序闭合。
示例:
输入:s = "()[]{}" 输出:true
输入:s = "(]" 输出:false
function isValid (s) { let stack = new Stack(); // 遍历 字符串 for(let c of s){ // 遇到左括号,将与其匹配的右括号入栈处理 if(c==='('){ stack.push(')') }else if(c==='['){ stack.push(']') }else if(c==='{'){ stack.push('}') // 遇到右括号 // 1. 判断栈内是否有括号,如果没有,那说明此时匹配不了 // 2. 满足①的情况下,判断此时字符是否和栈顶元素匹配 }else if(stack.isEmpty() || stack.pop()!==c){ return false; } } // 最后再验证一下,栈是否为空,如果不为空,说明还有未匹配的括号 return stack.isEmpty(); }; 复制代码
输入一个数组,每个数字都是某天的温度。
计算每天需要等几天才会出现更高的温度
示例:输入数组
[35,31,33,36,34]
,输出结果为[3,1,1,0,0]
- 第一天温度为35°,要等3天才会出现更高的温度36°
- 第四天的文档是36°,后面没有更高的温度,与其对应的输出是0
function dailyTemperatures(temperatures){ // 定义一个与源数组相同的数组,用于存储最后结果 let result = new Array(temperatures.length); let stack = new Stack(); for(let i = 0;i<temperatures.length;i++){ // stack 非空,且当前的温度大于栈顶温度 while(!stack.empty() && temperatures[i]>temperatures[stack.peek()]){ // 取出,存于stack中的满足条件的温度的下标 let prev = stack.pop(); // 计算等待天数 并将其存入result[prev]中 result[prev] = i - prev; } // 将当前下标存入stack中 stack.push(i) }