LeetCode--数组
基础知识
数组是存放在连续内存空间上的相同类型数据的集合。数组可以方便的通过下标索引的方式获取到下标下对应的数据。需要两点注意的是:
- 数组下标都是从0开始的。
- 数组内存空间的地址是连续的。
因为数组的在内存空间的地址是连续的,所以我们在删除或者增添元素的时候,就难免要移动其他元素的地址。数组的元素是不能删的,只能覆盖。
技巧
1.在处理数组和链表相关问题时,双指针技巧是经常用到的,双指针技巧主要分为两类:左右指针和快慢指针。所谓左右指针,就是两个指针相向而行或者相背而行;而所谓快慢指针,就是两个指针同向而行,一快一慢。
2.快慢指针。数组问题中比较常见的快慢指针技巧,是让你原地修改数组。比如有序数组/链表中去重,对数组中的某些元素进行**「原地删除」**。
题目
一、快慢指针(修改数组、滑动窗口)
Ⅰ修改数组
1. 删除有序数组中的重复项
简单
思路:
由于数组已经排序,所以重复的元素一定连在一起。让慢指针 slow 走在后面,快指针 fast 走在前面探路,找到一个不重复的元素就赋值给 slow 并让 slow 前进一步。这样,就保证了 nums[0…slow] 都是无重复的元素,当 fast 指针遍历完整个数组 nums 后,nums[0…slow] 就是整个数组去重之后的结果。
删除排序链表中的重复元素也是同理。
/**
* @param {number[]} nums
* @return {number}
*/
var removeDuplicates = function(nums) {
let slow = fast = 0;
while(fast<nums.length){
if(nums[fast]!=nums[slow]){
slow++;
nums[slow] = nums[fast];
}
fast++;
}
return slow+1;
};
2. 移除元素
简单
思路:
如果 fast 遇到值为 val 的元素,则直接跳过,否则就赋值给 slow 指针,并让 slow 前进一步。这里是先给 nums[slow] 赋值然后再给 slow++,这样可以保证 nums[0…slow-1] 是不包含值为 val 的元素的,最后的结果数组长度就是 slow。
/**
* @param {number[]} nums
* @param {number} val
* @return {number}
*/
var removeElement = function(nums, val) {
let k = 0;
for(let i = 0; i < nums.length; i++){
if(nums[i]!=val){
nums[k++] = nums[i]
}
}
return k
};
var removeElement = function(nums, val) {
let slow = fast = 0;
while(fast<nums.length){
if(nums[fast]!=val){
nums[slow] = nums[fast];
slow++;
}
fast++;
}
return slow;
}
3. 移动零
简单
思路:
相当于移除 nums 中的所有 0,然后再把后面的元素都赋值为 0 即可。
/**
* @param {number[]} nums
* @return {void} Do not return anything, modify nums in-place instead.
*/
var moveZeroes = function(nums) {
let slow = fast = 0;
while(fast<nums.length){
if(nums[fast]!=0){
nums[slow] = nums[fast];
slow++;
}
fast++;
}
for(;slow<nums.length;slow++){
nums[slow] = 0;
}
};
Ⅱ滑动窗口
A.模板
/* 滑动窗口算法框架 */
var slidingWindow = function(s) {
let window = {};
let left = 0, right = 0;
while (right < s.length) {
// c 是将移入窗口的字符
let c = s[right];
// 增大窗口
right++;
// 进行窗口内数据的一系列更新
...
/*** debug 输出的位置 ***/
// 注意在最终的解法代码中不要 print
// 因为 IO 操作很耗时,可能导致超时
alert("window: [%d, %d)\n", left, right);
/********************/
// 判断左侧窗口是否要收缩
while (window needs shrink) {
// d 是将移出窗口的字符
let d = s[left];
// 缩小窗口
left++;
// 进行窗口内数据的一系列更新
...
}
}
}
1.left 指针在后,right 指针在前,两个指针中间的部分就是「窗口」,算法通过扩大和缩小「窗口」来解决某些问题。
2.两处 … 表示的更新窗口数据的地方, 这两个 … 处的操作分别是扩大和缩小窗口的更新操作,它们操作是完全对称的。(以下题解都将窗口定义为左开右闭区间,需要搞清楚到底是先更新结果还是先缩小窗口。)
3.算法的时间复杂度依然是 O(N),其中 N 是输入字符串/数组的长度。字符串/数组中的每个元素都只会进入窗口一次,然后被移出窗口一次,不会说有某些元素多次进入和离开窗口,所以算法的时间复杂度就和字符串/数组的长度成正比。
4.需要思考:
什么时候应该移动 right 扩大窗口?窗口加入字符时,应该更新哪些数据?
什么时候窗口应该暂停扩大,开始移动 left 缩小窗口?从窗口移出字符时,应该更新哪些数据?
我们要的结果应该在扩大窗口时还是缩小窗口时进行更新?
B.题目
1. 最小覆盖子串
困难
思路:
滑动窗口算法的思路是这样:
1、在字符串 S 中使用双指针中的左右指针技巧,初始化 left = right = 0,把索引左闭右开区间 [left, right) 称为一个「窗口」。
PS:理论上设计两端都开或者两端都闭的区间,但设计为左闭右开区间是最方便处理的。因为这样初始化 left = right = 0 时区间 [0, 0) 中没有元素,但只要让 right 向右移动(扩大)一位,区间 [0, 1) 就包含一个元素 0 了。如果你设置为两端都开的区间,那么让 right 向右移动一位后开区间 (0, 1) 仍然没有元素;如果你设置为两端都闭的区间,那么初始区间 [0, 0] 就包含了一个元素。这两种情况都会给边界处理带来不必要的麻烦。
2、不断地增加 right 指针扩大窗口 [left, right),直到窗口中的字符串符合要求(包含了 T 中的所有字符)。
3、此时,停止增加 right,转而不断增加 left 指针缩小窗口 [left, right),直到窗口中的字符串不再符合要求(不包含 T 中的所有字符了)。同时,每次增加 left,我们都要更新一轮结果。
4、重复第 2 和第 3 步,直到 right 到达字符串 S 的尽头。
在这里,创建need 和 window 两个计数器,分别记录 T 中字符出现次数和「窗口」中的相应字符的出现次数。 创建valid 变量表示窗口中满足 need 条件的字符个数,如果 valid 和 need.size 的大小相同,则说明窗口已满足条件,已经完全覆盖了串 T。如果一个字符进入窗口,应该增加 window 计数器;如果一个字符将移出窗口的时候,应该减少 window 计数器;当 valid 满足 need 时应该收缩窗口;应该在收缩窗口的时候更新最终结果。
当我们发现某个字符在 window 的数量满足了 need 的需要,就要更新 valid,表示有一个字符已经满足要求。而且,你能发现,两次对窗口内数据的更新操作是完全对称的。当 valid == need.size() 时,说明 T 中所有字符已经被覆盖,已经得到一个可行的覆盖子串,现在应该开始收缩窗口了,以便得到「最小覆盖子串」。移动 left 收缩窗口时,窗口内的字符都是可行解,所以应该在收缩窗口的阶段进行最小覆盖子串的更新,以便从可行解中找到长度最短的最终结果。
/**
* @param {string} s
* @param {string} t
* @return {string}
*/
var minWindow = function(s, t) {
//左右窗口 左闭右开区间
let left = right = 0;
//记录最小子串的开始索引和长度 Infinity(无穷大)在 JS 中是一个特殊的数字,它的特性是:它比任何有限的数字都大
let start = 0, len = Infinity;
//记录满足字符个数的数量
let valid= 0;
//使用两个字典分别记录窗口和t中的字符个数
let window = {};
let need = {};
//记录目标字符串中各元素个数
// for(let i = 0; i<t.length; i++){
// if(need[t[i]]!=undefined)need[t[i]]++;
// else need[t[i]] = 1;
// window[t[i]] = 0;
// }
for(const c of t){
if(need[c] == undefined)need[c] = 0;
need[c]++;
window[c] = 0;
}
//Object.keys(obj) 返回值:一个表示给定对象的所有可枚举属性的字符串数组
const needSize = Object.keys(need).length;
//左闭右开区间
while(right<s.length){
// addChar 是将移入窗口的字符
let addChar = s[right];
// 增大窗口
right++;
// 更新窗口
//如果是目标字符串中的元素 则更新窗口中记录的元素个数 并判断是否个数满足
if(need[addChar]!=undefined){
window[addChar]++;
if(window[addChar]==need[addChar]){
valid++;
}
}
//判断左侧窗口是否要收缩
//目标串中各元素的数量在窗口中都得到满足
//救命 map的大小是size不是length!!!!
while(valid==needSize){
//更新最小覆盖子串
//左闭右开区间长度为right-left
if(right-left<len){
start = left;
len = right-left;
}
// delChar 是将移出窗口的字符
let delChar = s[left];
// 减小窗口
left++;
// 更新窗口
//如果是目标字符串中的元素 则更新窗口中记录的元素个数 并判断是否有效条件数是否减少
if(need[delChar]!=undefined){
//注意这里也是== 与上面相同
if(window[delChar]==need[delChar]){
valid--;
}
window[delChar]--;//救命 应该先比较在做减法!!!
// 因为 right 是开区间,就比方说 right = 0 的初始情况,此时 s[right] 还没进入窗口对吧,所以你扩大窗口时要先 window[s[right]]++,把它移入窗口,然后才能更新 valid 变量。反过来,left 是闭区间,比方说 left = 0 的情况,你要缩小窗口的话,先得判断移出 s[left] 是否会引起 valid 的更新,所以要先判断 valid 的更新,再执行 window[s[left]]-- 将 s[left] 移出窗口。
//时刻记住窗口区间是左闭右开区间,然后搞清楚到底是先更新结果还是先缩小窗口。
}
}
}
//string.substr(start,length) 在字符串中抽取从 开始 下标开始的指定数目的字符。
return len === Infinity?'':s.substr(start, len);
};
/**
* @param {string} s
* @param {string} t
* @return {string}
*/
var minWindow = function(s, t) {
//左右窗口 左闭右开区间
let left = right = 0;
//记录最小子串的开始索引和长度
let start = 0, len = 100000;
//记录满足字符个数的数量
let valid= 0;
//使用两个map分别记录窗口和t中的字符个数
let window = new Map();
let need = new Map();
//记录目标字符串中各元素个数
for(let i = 0; i<t.length; i++){
need.set(t[i],(need.get(t[i])||0)+1);
}
//左闭右开区间
while(right<s.length){
// addChar 是将移入窗口的字符
let addChar = s[right];
// 增大窗口
right++;
// 更新窗口
//如果是目标字符串中的元素 则更新窗口中记录的元素个数 并判断是否个数满足
if(need.has(addChar)){
window.set(addChar,(window.get(addChar)||0)+1);
if(window.get(addChar)==need.get(addChar)){
valid++;
}
}
//判断左侧窗口是否要收缩
//目标串中各元素的数量在窗口中都得到满足
//注意:map大小为size属性
while(valid==need.size){
//更新最小覆盖子串
//左闭右开区间长度为right-left
if(right-left<len){
start = left;
len = right-left;
}
// delChar 是将移出窗口的字符
let delChar = s[left];
// 减小窗口
left++;
// 更新窗口
//如果是目标字符串中的元素 则更新窗口中记录的元素个数 并判断是否有效条件数是否减少
if(need.has(delChar)){
//注意:先-1则为<
window.set(delChar,window.get(delChar)-1);
if(window.get(delChar)<need.get(delChar)){
valid--;
}
}
}
}
let ret = [];
if(len<100000){
while(len--){
ret.push(s[start]);
start++;
}
return ret.join('');
}
else return '';
};
2. 字符串的排列
中等
思路:
跟上一题比,相当于窗口大小是固定的,就是s1的长度。那么窗口长度大于固定长度时就要缩小窗口。当发现 valid == need.size() 时,就说明窗口中就是一个合法的排列,所以立即返回 true。
/**
* @param {string} s1
* @param {string} s2
* @return {boolean}
*/
var checkInclusion = function(s1, s2) {
let left = 0,
right = 0;
//let need =window= {}; 定义的是同一个{} 不能这样写!!!
let need ={},window= {};
let valid = 0;
for(const x of s1){
if(need[x]==undefined)need[x] = 0;
need[x]++;
window[x]=0;
}
const needSize = Object.keys(need).length;
while(right<s2.length){
let addChar = s2[right];
right++;
if(need[addChar]!=undefined){
window[addChar]++;
if(window[addChar]==need[addChar]){
valid++;
}
}
while(right-left==s1.length){
if(valid == needSize){
return true;
}
let delChar = s2[left];
left++;
if(need[delChar]!=undefined){
if(window[delChar]==need[delChar]){
valid--;
}
window[delChar]--;
// 因为 right 是开区间,就比方说 right = 0 的初始情况,此时 s[right] 还没进入窗口对吧,所以你扩大窗口时要先 window[s[right]]++,把它移入窗口,然后才能更新 valid 变量。反过来,left 是闭区间,比方说 left = 0 的情况,你要缩小窗口的话,先得判断移出 s[left] 是否会引起 valid 的更新,所以要先判断 valid 的更新,再执行 window[s[left]]-- 将 s[left] 移出窗口。
}
}
}
return false;
};
3. 查找字符串中的所有字母异位词
中等
思路:
同上,将所有索引输出即可。
4. 无重复字符的最长子串
中等
思路:
要在收缩窗口完成后更新 res,因为窗口收缩的 while 条件是存在重复元素,换句话说收缩完成后一定保证窗口中没有重复
/**
* @param {string} s
* @return {number}
*/
var lengthOfLongestSubstring = function(s) {
let left = 0, right = 0;
let window = {};
let res = 0;
while(right<s.length){
let addChar = s[right];
right++;
if(window[addChar]==undefined)window[addChar] = 0;
window[addChar]++;
while(window[addChar]>1){
let delChar = s[left];
left++;
window[delChar]--;
}
//左闭右开区间
res = Math.max(right-left,res);
}
return res;
};
二、左右指针(二分查找、nSum、反转数组、回文串判断)
Ⅰ二分查找
1.前提是数组是有序的。
2.二分思维的精髓就是:通过已知信息尽可能多地收缩(折半)搜索空间,从而增加穷举效率,快速找到目标。
3.在解决这个问题时采用的是左闭右闭区间,[left, right]。这个区间其实就是每次进行搜索的区间。初始化 right 的赋值是 nums.length - 1,即最后一个元素的索引,而不是 nums.length。while 循环终止当搜索区间为空的时候应该终止。while中循环条件为为left<=right, 当left==rigth+1时终止。
4.分析二分查找的一个技巧是:不要出现 else,而是把所有情况用 else if 写清楚,这样可以清楚地展现所有细节。
5.计算 mid 时需要防止溢出。left + (right - left) / 2就和 (left + right) / 2 的结果相同,但是有效防止了 left 和 right 太大,直接相加导致整数溢出的情况。mid = left + ((right-left)>>1)和left + Math.floor((right-left)/2)也可。
A.模板(有序数组)
1.寻找一个数(基本的二分搜索)
var binarySearch = function(nums, target) {
let left = 0;
let right = nums.length - 1; // 左闭右闭区间 right是可以取到的
//终止条件是left == right+1
while(left <= right) {
let mid = left + ((right-left)>>1);//防止溢出
if(nums[mid] == target)
return mid;
else if (nums[mid] < target)//左闭右闭区间 mid已经被搜索比较 跳过mid
left = mid + 1; // 注意
else if (nums[mid] > target)
right = mid - 1; // 注意
}
return -1;
}
注意:
1.[left, right]这个区间其实就是每次进行搜索的区间。循环条件是left<=right。
2.当我们发现索引 mid 不是要找的 target 时,去搜索区间 [left, mid-1] 或者区间 [mid+1, right]继续寻找。因为 mid 已经搜索过,应该从搜索区间中去除。
3.但是这个算法只能查找到这个数,无法寻找其边界。比如说给你有序数组 nums = [1,2,2,2,3],target 为 2,此算法返回的索引是 2,没错。但是如果我想得到 target 的左侧边界,即索引 1,或者我想得到 target 的右侧边界,即索引 3,这样的话此算法是无法处理的。
因为我们初始化 right = nums.length - 1
所以决定了我们的「搜索区间」是 [left, right]
所以决定了 while (left <= right)
同时也决定了 left = mid+1 和 right = mid-1
因为我们只需找到一个 target 的索引即可
所以当 nums[mid] == target 时可以立即返回
2.寻找左侧边界的二分搜索
var left_bound = function(nums, target) {
let left = 0, right = nums.length - 1;
// 搜索区间为 [left, right]
while (left <= right) {
let mid = left + ((right-left)>>1);
if (nums[mid] < target) {
// 搜索区间变为 [mid+1, right]
left = mid + 1;
} else if (nums[mid] > target) {
// 搜索区间变为 [left, mid-1]
right = mid - 1;
} else if (nums[mid] == target) {
// 收缩右侧边界
// 找到 target 时不要立即返回,而是缩小「搜索区间」的上界 right,在区间 [left, mid-1] 中继续搜索,即不断向左收缩,达到锁定左侧边界的目的。
right = mid - 1;
}
}
// 判断 target 是否存在于 nums 中
// 此时 target 比所有数都大,返回 -1
if (left == nums.length) return -1;
// 判断一下 nums[left] 是不是 target
return nums[left] == target ? left : -1;
}
注意:
1.left 的取值范围是 [0, nums.length-1]。假如输入的 target 非常大,那么就会一直触发 nums[mid] < target 的 if 条件,left 会一直向右侧移动,直到等于nums.length, 索引越界。需要进行判断。
2.找到 target 时不要立即返回,而是缩小「搜索区间」的上界 right,在区间 [left, mid-1] 中继续搜索,即不断向左收缩,达到锁定左侧边界的目的。
3.假设target存在,while终止前 left==right, 计算得出left, right, mid三者相等。right由上一次循环收缩右侧边界而来,则target == right+1。则这次循环left=mid+1,即right+1,那么left>right跳出循环。此时left等于target。
3.寻找右侧边界的二分查找
var right_bound = function(nums, target) {
let left = 0, right = nums.length - 1;
while (left <= right) {
let mid = left + ((right-left)>>1);
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if (nums[mid] == target) {
// 这里改成收缩左侧边界即可
left = mid + 1;
}
}
// 最后改成返回 left - 1
if (left - 1 < 0) return -1;
return nums[left - 1] == target ? (left - 1) : -1;
}
注意:
1.当 nums[mid] == target 时,不要立即返回,而是增大「搜索区间」的左边界 left,使得区间不断向右靠拢,达到锁定右侧边界的目的。
2. 对 left 的更新必须是 left = mid + 1(mid等于target),就是说 while 循环结束时,nums[left] 一定不等于 target 了,而 nums[left-1] 可能是 target。假设target存在,循环在left等于right时,计算得出left,right,mid三者相等,而这个left由上次循环得出可能等于target+1,那么这次循环中right=mid-1则right等于target,left等于target+1.
3. left 的取值范围是 [0, nums.length-1]。left取0时left-1会越界,需要进行判断。
4. 由于 while 的结束条件为 right 等于 left - 1,所以你把上述代码中的 left - 1 都改成 right 也没有问题。
B.题目
1. 二分查找
简单
/**
* @param {number[]} nums
* @param {number} target
* @return {number}
*/
var search = function(nums, target) {
var left = 0;
var right = nums.length - 1;
var mid =0;
while(left<=right){
mid = left + ((right-left)>>1);
if(nums[mid]>target){
right = mid-1
}
else if(nums[mid]<target){
left = mid+1
}
else{
return mid
}
}
return -1
};
2. 在排序数组中查找元素的第一个和最后一个位置
中等
/**
* @param {number[]} nums
* @param {number} target
* @return {number[]}
*/
var searchRange = function(nums, target) {
var left_bound = function(nums,target){
let left = 0, right = nums.length-1;
while(left<=right){
let mid = left + ((right-left)>>1);
if(nums[mid]==target){
right = mid-1;
}
else if(nums[mid]>target){
right = mid-1;
}
else if(nums[mid]<target){
left = mid+1;
}
}
if(left==nums.length)return -1;
return nums[left]==target?left:-1;
}
var right_bound = function(nums, target){
let left=0, right = nums.length - 1;
while(left<=right){
let mid = left + Math.floor((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)return -1;
return nums[right]==target?right:-1;
}
let res = [];
let left = left_bound(nums,target);
let right = right_bound(nums,target);
res.push(left);
res.push(right);
return res;
};
C.实际应用题目
二分搜索的原型就是在有序数组中搜索一个元素 target,返回该元素对应的索引。如果该元素不存在,那可以返回一个什么特殊值,这种细节问题只要微调算法实现就可实现。还有一个重要的问题,如果「有序数组」中存在多个 target 元素,那么这些元素肯定挨在一起,这里就涉及到算法应该返回最左侧的那个 target 元素的索引还是最右侧的那个 target 元素的索引,也就是所谓的「搜索左侧边界」和「搜索右侧边界」,这个也可以通过微调算法的代码来实现。
在具体的算法问题中,常用到的是搜索左侧边界和搜索右侧边界这两种场景。
思路:
首先从题目中抽象出一个自变量x(一般是题目所求的变量),一个关于x的函数f(x),以及一个目标值target。
x,f(x),target需要满足以下条件:
①.f(x)必须是x上的单调函数(单调增或单调减都可以)。
②.题目是计算满足约束条件f(x)==target时的x值。要根据题目确定是求左边界还是右边界。同时还要注意f(x)与x的是单调递增还是递减的关系,注意是移动左边界还是右边界。
③.**根据题目确定自变量x的取值范围。**初始化left和right。
框架:
var f = function(x){
...
}
var solution = function(nums, target) {
//具体问题具体分析自变量x的最小值
let left = ...;
//具体问题具体分析自变量x的最大值
let right = ...+1;
// 搜索区间为 [left, right]
while (left <= right) {
let mid = left + ((right-left)>>1);
if (f[mid] < target) {
// 移动左边界还是右边界让f(x)大一点?
...
} else if (f[mid] > target) {
// 移动左边界还是右边界让f(x)小一点?
...
} else if (f[mid] == target) {
//看求左边界还是右边界
...
}
}
return left;
}
题目:
1. 珂珂吃香蕉
中等
思路:
自变量x是珂珂吃香蕉的速度。吃香蕉的速度越快,吃完所有香蕉所需时间就越少,速度和时间是一个单调递减关系。f(x)定义为:若吃香蕉的速度为x根/小时,则需要f(x)小时吃完所有香蕉。target是吃香蕉的时间限制H。
吃香蕉的最小速度为1,最大应该是piles数组中的最大值,因为每小时最多吃一堆香蕉,吃再多也白搭。也可以根据题目中的piles[i]的取值范围1<=plies[i]<=10^9,将right初始化为取值范围之外的值。
题目要求计算最小速度,也就是x要尽可能小, 求左边界。
/**
* @param {number[]} piles
* @param {number} h
* @return {number}
*/
var minEatingSpeed = function(piles, h) {
var f = function(piles, x){
let hour = 0;
for(let i = 0; i< piles.length; i++){
hour+=Math.floor(piles[i]/x);
if(piles[i]%x>0)hour++;
}
return hour;
}
let left = 1;
let right = 1000000000;
while(left<=right){
let mid = left + Math.floor((right-left)/2);
let hour = f(piles, mid);
if(hour==h){
right = mid-1;
}
else if(hour>h){
left = mid+1;
}
else if(hour<h){
right = mid-1;
}
}
return left;
};
2. 在D天内送达货物的能力
中等
思路:
船的运载能力是自变量x,f(x)是x运载能力下需要的运算天数,是单调递减的。target是days。船的最小运输能力应该是weight中元素的最大值,每次得装一件东西走,不能说装不了。船的最大运输能力是weights中左右元素之和,也就是一次把所有货物都运走。计算最小载重,也就是满足f(x)==target的条件下,x要尽可能小,求左边界。
/**
* @param {number[]} weights
* @param {number} days
* @return {number}
*/
var shipWithinDays = function(weights, days) {
var f = function(weights,x){
let day = 1;
let weightOneDay = 0;
for(let i = 0; i<weights.length; i++){
if((weightOneDay+weights[i]) <= x){
weightOneDay+=weights[i];
}
else{
day++;
weightOneDay = weights[i];
}
}
return day;
}
//注意最小值是最大货物的重量,最大值是所有货物加一起的重量
let minx = 0, maxx= 0;
for(let i = 0; i<weights.length; i++){
minx = Math.max(minx,weights[i]);
maxx += weights[i];
}
let left = minx, right = maxx;
while(left<=right){
let mid = left + Math.floor((right-left)/2);
let day = f(weights, mid);
if(day==days){
right = mid - 1;
}
//注意f(x)与x成反比 当target<f(x)时 f(x)应该减小 mid应该增大 左边界增大
else if(day>days){
left = mid + 1;
}
else if(day<days){
right = mid - 1;
}
}
return left;
};
3. 分割数组的最大值
困难
思路:
这题和上面的题一样。货船每天运走的货物就是一个nums子数组,在m天内运完就是将nums划分为m个子数组。让货船的载重尽可能小,就是让所有子数组中最大的子数组之和尽可能小。
/**
* @param {number[]} nums
* @param {number} k
* @return {number}
*/
var shipWithinDays = function(weights, days) {
var f = function(weights,x){
let day = 1;
let weightOneDay = 0;
for(let i = 0; i<weights.length; i++){
if((weightOneDay+weights[i]) <= x){
weightOneDay+=weights[i];
}
else{
day++;
weightOneDay = weights[i];
}
}
return day;
}
//注意最小值是最大货物的重量,最大值是所有货物加一起的重量
let minx = 0, maxx= 0;
for(let i = 0; i<weights.length; i++){
minx = Math.max(minx,weights[i]);
maxx += weights[i];
}
let left = minx, right = maxx;
while(left<=right){
let mid = left + Math.floor((right-left)/2);
let day = f(weights, mid);
if(day==days){
right = mid - 1;
}
//注意f(x)与x成反比 当target<f(x)时 f(x)应该减小 mid应该增大 左边界增大
else if(day>days){
left = mid + 1;
}
else if(day<days){
right = mid - 1;
}
}
return left;
};
var splitArray = function(nums, k) {
return shipWithinDays(nums, k);
};
Ⅱ nSum问题
A.模板
1.两数之和
①假设输入一个数组 nums 和一个目标和 target,请你返回 nums 中能够凑出 target 的两个元素的值,比如输入 nums = [1,3,5,6], target = 9,那么算法返回两个元素 [3,6]。可以假设只有且仅有一对儿元素可以凑出 target。比如题目 两数之和和 两数之和||-输入有序数组
先保证数组是有序数组,再使用双指针法即可。
/**
* @param {number[]} numbers
* @param {number} target
* @return {number[]}
*/
//两数之和||-输入有序数组
var twoSum = function(numbers, target) {
let left = 0, right = numbers.length-1;
while(left<right){
let sum = numbers[left] + numbers[right];
if(sum == target){
return [left+1,right+1];
}
else if(sum>target){
right--;
}
else if(sum<target){
left++;
}
}
return [];
};
②nums 中可能有多对儿元素之和都等于 target,请你的算法返回所有和为 target 的元素对儿,其中不能出现重复。
思路:排序加左右指针。sum == target 条件下,当给 res 加入一次结果后,left 和 right 应该跳过所有重复的元素。就可以保证一个答案只被添加一次,重复的结果都会被跳过,可以得到正确的答案。其他两个 if 分支也是可以做一点效率优化,跳过相同的元素
twoSum模板:
var twoSum = function(nums, target){
nums.sort((a,b)=>a-b);
let left = 0, right = nums.length-1;
let res = [];
while(left<right){
let sum = nums[left] + nums[right];
let lbound = nums[left], rbound = nums[right];
if(sum==target){
res.push([nums[left], nums[right]]);
while(left<right&&nums[left]==lbound){
left++;
}
while(left<right&&nums[right]==rbound){
right--;
}
}
else if(sum>target){
while(left<right&&nums[right]==rbound){
right--;
}
}
else if(sum<target){
while(left<right&&nums[left]==lbound){
left++;
}
}
}
return res;
}
双指针操作的部分虽然有那么多 while 循环,但是双指针部分的时间复杂度还是 O(N)。
2. nSum模板
想找和为 target 的n个数字,那么对于第一个数字,可能是什么?nums 中的每一个元素 nums[i] 都有可能!确定了第一个数字之后,剩下的n-1个数字可以是什么呢?其实就是和为 target - nums[i] 的n-1个数字。类似 twoSum,nSum 的结果也可能重复,比如3Sum,输入是 nums = [1,1,1,2,3], target = 6,结果就会重复。关键点在于,不能让第一个数重复,至于后面的两个数,我们复用的 twoSum 函数会保证它们不重复。所以代码中必须用一个 while 循环来保证 3Sum 中第一个元素不重复。
时间复杂度为o(N^n-1)。
该模板的前提是调用这个函数时数组必须是排序的。
n == 2 时是 twoSum 的双指针解法,n > 2 时就是穷举第一个数字,然后递归调用计算 (n-1)Sum,组装答案。需要注意的是,调用这个 nSum 函数之前一定要先给 nums 数组排序,因为 nSum 是一个递归函数,如果在 nSum 函数里调用排序函数,那么每次递归都会进行没有必要的排序,效率会非常低。
//调用该函数时nums必须是排过序的
var nSum = function(nums, n, start, target){
let res = [];
if(n<2||nums.length<n)return res;
//一般情况 两数之和
if(n==2){
let left = start, right = nums.length-1;
while(left<right){
let sum = nums[left]+nums[right];
let lbound = nums[left], rbound = nums[right];
if(sum==target){
res.push([nums[left], nums[right]]);
while(left<right&&nums[left]==lbound){
left++;
}
while(left<right&&nums[right]==rbound){
right--;
}
}
else if(sum<target){
while(left<right&&nums[left]==lbound){
left++;
}
}
else if(sum>target){
while(left<right&&nums[right]==rbound){
right--;
}
}
}
}
//n>2时 递归计算(n-1)sum的结果
if(n>2){
for(let i = start; i<nums.length-(n-1); i++){
let sub = nSum(nums, n-1, i+1, target-nums[i]);
if(sub.length){
for(let j = 0; j<sub.length; j++){
sub[j].push(nums[i]);
res.push(sub[j]);
}
}
//去重 保证第一个数字不重复
while(nums[i]==nums[i+1]){
i++;
}
}
}
return res;
}
B.题目
1. 三数之和
中等
//解法一
var threeSum = function(nums) {
// 将数组升序排序
nums.sort((a,b)=>a-b);
let res = [];
for(let i = 0;i<nums.length;i++){
// 数组排过序,如果第一个数大于0直接返回res
if(nums[i]>0)return res;
let left = i+1, right = nums.length-1;
//去重
if(i>0&&nums[i]==nums[i-1]) continue;
while(left<right){
let sum = nums[i]+nums[left]+nums[right];
if(sum<0)left++;
else if (sum>0)right--;
else{
res.push([nums[i],nums[left],nums[right]]);
//去重
while(left<right&&nums[left]==nums[left+1]){
left++;
}
while(left<right&&nums[right]==nums[right-1]){
right--;
}
left++;
right--;
}
}
}
return res;
};
//解法二
var threeSum = function(nums){
var twoSum = function(nums, start, target){
let left = start, right = nums.length-1;
let res = [];
while(left<right){
let sum = nums[left] + nums[right];
let lbound = nums[left], rbound = nums[right];
if(sum==target){
res.push([nums[left], nums[right]]);
while(left<right&&nums[left]==lbound){
left++;
}
while(left<right&&nums[right]==rbound){
right--;
}
}
else if(sum>target){
while(left<right&&nums[right]==rbound){
right--;
}
}
else if(sum<target){
while(left<right&&nums[left]==lbound){
left++;
}
}
}
return res;
}
//升序排序
nums.sort((a,b)=>a-b);
let res = [];
for(let i = 0; i<nums.length-2; i++){
//寻找和为0-nums[i]的剩下两个数
let twoNum = twoSum(nums,i+1,0-nums[i]);
if(twoNum.length){
for(let j = 0 ;j<twoNum.length; j++){
twoNum[j].push(nums[i]);
res.push(twoNum[j]);
}
}
//去重
while(nums[i]==nums[i+1]){
i++;
}
}
return res;
}
2. 四数之和
中等
//解法一
var fourSum = function(nums, target) {
let ret = [];
if(nums.length<4)return ret;
nums.sort((a,b)=>a-b);
for(let i = 0;i<nums.length-3;i++){
if(i>0&&nums[i]==nums[i-1])continue;
for(let j = i+1;j<nums.length-2;j++){
if(j>i+1&&nums[j]==nums[j-1])continue;
let l = j+1, r = nums.length-1;
while(l<r){
let sum = nums[i]+nums[j]+nums[l]+nums[r];
if(sum<target)l++;
else if(sum>target)r--;
else{
ret.push([nums[i],nums[j],nums[l],nums[r]]);
while(l<r&&nums[l]==nums[l+1])l++;
while(l<r&&nums[r]==nums[r-1])r--;
l++;
r--;
}
}
}
}
return ret;
};
//解法二
//调用该函数时nums必须时排过序的
var nSum = function(nums, n, start, target){
let res = [];
if(n<2||nums.length<n)return res;
//一般情况 两数之和
if(n==2){
let left = start, right = nums.length-1;
while(left<right){
let sum = nums[left]+nums[right];
let lbound = nums[left], rbound = nums[right];
if(sum==target){
res.push([nums[left], nums[right]]);
while(left<right&&nums[left]==lbound){
left++;
}
while(left<right&&nums[right]==rbound){
right--;
}
}
else if(sum<target){
while(left<right&&nums[left]==lbound){
left++;
}
}
else if(sum>target){
while(left<right&&nums[right]==rbound){
right--;
}
}
}
}
//n>2时 递归计算(n-1)sum的结果
if(n>2){
for(let i = start; i<nums.length-(n-1); i++){
let sub = nSum(nums, n-1, i+1, target-nums[i]);
if(sub.length){
for(let j = 0; j<sub.length; j++){
sub[j].push(nums[i]);
res.push(sub[j]);
}
}
while(nums[i]==nums[i+1]){
i++;
}
}
}
return res;
}
var fourSum = function(nums, target) {
nums.sort((a,b)=>a-b);
return nSum(nums, 4, 0, target);
}
Ⅲ 反转数组
1. 反转字符串
简单
/**
* @param {character[]} s
* @return {void} Do not return anything, modify s in-place instead.
*/
var reverseString = function(s) {
let left = 0, right = s.length-1;
while(left<right){
[s[left],s[right]] = [s[right],s[left]];
left++;
right--;
}
return s;
};
Ⅳ 回文串判断
回文串就是正着读和反着读都一样的字符串。比如说字符串 aba 和 abba 都是回文串,因为它们对称,反过来还是和本身一样;反之,字符串 abac 就不是回文串。
可以使用左右指针相向而行判断是否为回文串。
回文子串问题则是让左右指针从中心向两端扩展。
1. 最长回文子串
中等
思路:
找回文串的难点在于,回文串的的长度可能是奇数也可能是偶数,解决该问题的核心是从中心向两端扩散的双指针技巧。如果回文串的长度为奇数,则它有一个中心字符;如果回文串的长度为偶数,则可以认为它有两个中心字符。如果输入相同的 l 和 r,就相当于寻找长度为奇数的回文串,如果输入相邻的 l 和 r,则相当于寻找长度为偶数的回文串。
/**
* @param {string} s
* @return {string}
*/
var longestPalindrome = function(s) {
var Palindrome = function(s, l ,r){
while(l>=0&&r<s.length&&s[l]==s[r]){
// 双指针,向两边展开
l--;
r++;
}
//substring() 方法返回的子串包括 开始 处的字符,但不包括 结束 处的字符。
//string.substr(start,length)方法可在字符串中抽取从 开始 下标开始的指定数目的字符。
// 返回以 s[l+1] 和 s[r](不包括s[r]) 为中心的回文串
return s.substring(l+1, r);
}
let res = '';
for(let i = 0; i<s.length; i++){
//如果输入相同的 l 和 r,就相当于寻找长度为奇数的回文串,如果输入相邻的 l 和 r,则相当于寻找长度为偶数的回文串。
//奇数串
let s1 = Palindrome(s, i, i);
//偶数串
let s2 = Palindrome(s, i, i+1);
res = res.length<s1.length?s1:res;
res = res.length<s2.length?s2:res;
}
return res;
};