T25 K个一组翻转链表
分治法
这一道题目乍一看可能比较难以入手,况且题目中还明确要求我们"只使用常数额外空间"。
别忘了,我们之前做过「两两交换链表中的节点」这道题目。在本题中,我们可以运用「分治法」的思想,将原题转化前述的这道我们已经做过的题目,这样就可以大大降低解题的难度。
下面让我们直接过一遍代码:
function reverseKGroup(headNode, k) {
//由于翻转链表时需要提供两端节点的邻居节点,
//因而本题我们继续采用「哨兵技巧」以简化对边界条件的处理!
let dummyNode = new ListNode();
let curNode = dummyNode.next = headNode;
let leftNode = headNode;
let leftNeighNode = dummyNode;
let count = 1;
while (curNode !== null) {
//遍历到边界位置就触发翻转
if(count % k === 0) {
let newLeftNode = curNode.next;
reverseList(leftNode, curNode, leftNeighNode, newLeftNode);
//更新左邻居节点为原先的leftNode
leftNeighNode = leftNode;
//更新leftNode、curNode
curNode = leftNode = newLeftNode;
}
//没有到边界就继续向下遍历
else {
curNode = curNode.next;
}
count++;
}
return dummyNode.next;
}
/*
reverseList函数用于翻转链表指定子链内的所有节点
leftNode: 需交换子链的最左端节点
rightNode: 需交换子链的最右端节点
leftNeighNode: 原先在leftNode左侧的邻居节点
rightNeighNode: 原先在rightNode右侧的邻居节点
*/
function reverseList(leftNode, rightNode, leftNeighNode, rightNeighNode) {
let LNode = leftNode;
let RNode = leftNode.next;
//两两交换节点
while (LNode !== rightNode) {
//当RNode.next更改后它对原先下一个节点的记录就会丢失,
//因此需要事先缓存RNode原本指向的下一个节点。
let newRNode = RNode.next;
//修改指针方向,使得LNode ← RNode
RNode.next = LNode;
//更新LNode、RNode
LNode = RNode;
RNode = newRNode;
}
//修改左侧邻居节点的next指针
leftNeighNode.next = rightNode;
//修改右侧(原左侧)节点的next指针,使之指向右侧邻居节点
leftNode.next = rightNeighNode;
}
T35 搜索插入位置
题目链接:leetcode-cn.com/problems/se…
二分搜索
这是一道简单的「二分搜索」应用题。参加过浙江高考选考「技术」科目的同学一定很熟悉吧~
代码如下:
function searchInsert(nums, target) {
let i = 0;
let j = nums.length - 1;
while (i <= j) {
let mid = Math.floor((i + j) / 2);
//如果查得值比目标小,说明区间整体偏小,区间左端点向右移动
if (nums[mid] < target) {
i = mid + 1;
}
//如果查得值比目标大,说明区间整体偏大,区间右端点向左移动
else if (nums[mid] > target) {
j = mid - 1;
}
//如果查得答案则直接返回结果
else {
return mid;
}
}
//当i > j时退出循环,表明nums中不存在
//等于target的元素,此时i即为插入位置。
return i;
}
T38 外观数列
题目链接:leetcode-cn.com/problems/co…
模拟法
我们直接依照题意进行模拟即可。
代码如下:
function countAndSay(n) {
let ans = '1';
for(let curN = 2; curN <= n; curN++) {
let key = ans[0];
let count = 1;
let newAns = '';
for(let i = 1; i <= ans.length; i++) {
if (ans[i] === key) {
count++;
} else {
newAns += count + key;
key = ans[i];
count = 1;
}
}
ans = newAns;
}
return ans;
}
相信有部分同学已经注意到这么做存在性能上的缺陷。
因为我们的算法是基于递推实现的,所以如果我们要求解n取较大值的答案,就必须要以n取较小值时的答案为基础(这与「动态规划」非常类似)。
但是问题恰恰出现这里,在上面的代码中,当n传入不同值时,我们都不得不从curN = 2开始从头计算。这在OJ等需要反复调用函数的情景中就会暴露出非常大的性能瓶颈。
性能优化
事实上,在真实项目的开发中我们也常常会碰到类似的情况。下面分享两种常见的解决思路。
预加载
在做算法题时,我们也常常称这种方法为「打表」。当碰到数据量较小的情景(例如本题中规定n ≤ 30)时,我们可以先事预加载(计算)出所需的所有结果,当实际需要时只需直接进行查询或调用即可。
代码如下:
const ansArr = ['1'];
for (let curN = 2; curN <= 30; curN++) {
let oldAns = ansArr[curN - 2];
let key = oldAns[0];
let count = 1;
let newAns = '';
for (let i = 1; i <= oldAns.length; i++) {
if (oldAns[i] === key) {
count++;
} else {
newAns += count + key;
key = oldAns[i];
count = 1;
}
}
ansArr.push(newAns);
}
function countAndSay(n) {
return ansArr[n - 1];
}
Tip:当然如果你想在OJ中看到明显的性能提升,前提是OJ的机制必须是先初始化全部你上传的代码,再循环调用解题函数。如果其机制为每次调用前都会重新加载全部代码,那么这个方法以及下面我们要介绍的「动态加载」也就失效了。
幸运的是,「LeetCode」平台属于前者。
动态加载
这种方法适合数据量较大,不便于一次性完成预加载的情景。我们可以将函数前几次被调用时已计算出的结果进行缓存,当后续函数再被调用时,直接让TA从缓存中直接取用需要的数据即可,从而避免反复计算。
代码如下:
let ansArr = [null, '1'];
let lastestN = 1;
function calculateAns(n) {
for (let curN = lastestN + 1; curN <= n; curN++) {
let oldAns = ansArr[curN - 1];
let key = oldAns[0];
let count = 1;
let newAns = '';
for(let i = 1; i <= oldAns.length; i++) {
if (oldAns[i] === key) {
count++;
} else {
newAns += count + key;
key = oldAns[i];
count = 1;
}
}
ansArr.push(newAns);
}
lastestN = n;
}
function countAndSay(n) {
if(n > lastestN) {
calculateAns(n);
}
return ansArr[n];
}
由于本题的数据量较小,使用此方法的优化效果相较前面介绍的「预加载」稍弱一些。
T32 最长有效括号
朴素解法
首先稍微提一下一种最容易想到的方法。我们可以引入指针i遍历整个数组,再以i为起点引入指针j向后查找符合题意的有效括号串。
值得一提的是,虽然本题和我们之前做过的「有效的括号」看上去有几分相似,但如果我们采用这种朴素解法的话是暂时用不到「队列」的,因为本题中出现的只有圆括号一种括号。在本题中,为了检测括号串是否有效,我们可以通过引入新变量count来检测子串中左右括号数量是否相等。
代码如下:
function longestValidParentheses(str) {
let ans = 0;
for (let i = 0; i < str.length - 1; i++) {
if (str[i] === ')') continue;
let count = 1;
for (let j = i + 1; j < str.length; j++) {
str[j] === '(' ? (count++) : (count--);
if (count === 0) {
ans = Math.max(ans, j - i + 1);
if (str[j + 1] === ')') break;
}
}
}
return ans;
}
当然,这种解法的性能肯定是非常低下的,因为我们不得不将几乎整个字符串遍历两次。
更巧妙的解法:栈
下面我们一起来探究一种基于「栈」的巧妙做法。
在正式开始前,我们不妨先思考一下,在本题中TA能派上什么用场?如果你已经理解了之前「朴素解法」的原理,相信这个问题不难回答。
在先前的解法中,我们之所以需要通过i来遍历整个数组是为了在利用j进行每轮检测时把可能的有效括号子串的左端点给"固定"下来。实际上这么做大可不必,我们可以利用栈来记录每一个(的下标,碰到与它对应的)时候我们便可以直接让下标数据出栈以供我们使用。这便是栈在本题中的主要用法。
为了帮助大家更好地理解这种解法,我将需要检测的字符串分成了三种类型,而其他更复杂的字符串都是这三种类型的组合形态。下面我们开始逐一击破。
类型一:"((())"、")(())"
多个括号直接嵌套在一起,这是最容易检测的一种类型。由于在「有效的括号」我们已经有使用「栈」来解决「括号问题」的经验了,这里不再具体展开。
代码如下:
function longestValidParentheses(str) {
let ans = 0;
let stack = [];
for (let i = 0; i < str.length; i++) {
if (str[i] === '(') {
stack.push(i);
}
else if (stack.length > 0) {
let pos = stack.pop();
ans = Math.max(ans, i - pos + 1);
}
}
return ans;
}
类型二:"(()()"、"((()()"
碰到这种情况,我们发现使用「类型一」中的代码会及时出ans = 2,怎么回事呢?
其实很简单,原因就在于我们每一次都采用最新出栈的(括号下标作为有效括号的左端点,而在「类型二」中,有效括号子串是以连续闭合的括号组的形式出现的,我们刚才的方法自然就行不通了。
这里我们采用一个巧妙的方法来解决问题。
请注意,在这种类型中,在连续括号组的左端会出现连续的(括号,而其中最靠近连续括号组的(实际上就起到了标记有效括号子串左端点的作用。因此我们要做的便是将原先利用出栈(括号下标(也就是pop方法的返回值)进行计算的方式修改为利用出栈(括号左侧的(括号下标来进行计算。
代码如下:
function longestValidParentheses(str) {
let ans = 0;
let stack = [];
for (let i = 0; i < str.length; i++) {
if (str[i] === '(') {
stack.push(i);
}
else if (stack.length > 0) {
stack.pop();
//获取出栈(括号左侧的(括号下标
let pos = stack[stack.length - 1];
//此时的pos在计算区间外,所以就没有+1了!
ans = Math.max(ans, i - pos);
}
}
return ans;
}
很明显,因为我们只是更改了用于计算的下标,所以修改代码后不会对「类型一」的检测产生影响。
类型三:"()"、"()()"、")()()"
最后我们来处理难度最大的「类型三」,此时位于连续括号组左端的(括号被撤走了,我们上面的方法又行不通了!
既然没有可以直接用于标记左端点下标的(括号,我们就自己创造嘛!我们不妨引入一个新变量j,用于标记连续括号组左端的下标,这样即可解决问题。
关于标记变量j的使用,有如下几个要点;如果有同学不明白其中的某一条的话,请务必停在这里好好思考一下:
- 为了兼容「类型二」中我们编写的代码,必须保证变量初值
j = -1. - 当遍历到
)括号且此时stack.length = 0时,表明该括号右侧可能为连续的括号组,此时需要令j = i. - 当调用
pop方法后发现stack.length = 0,则表明此处一定是「类型三」的情况,需令pos = j;否则则一定是「类型二」或「类型一」的情况.`
理清了变量j的用法,这道题就也算基本解决了。
下面让我们过一遍可以完美处理上述三种情况的最终版代码:
function longestValidParentheses(str) {
let ans = 0;
let stack = [];
let j = -1;
for (let i = 0; i < str.length; i++) {
if (str[i] === '(') {
stack.push(i);
}
else if (stack.length > 0) {
stack.pop();
let pos = stack.length > 0 ? stack[stack.length - 1] : j;
ans = Math.max(ans, i - pos);
} else {
j = i;
}
}
return ans;
}
提交结果:
通过「栈」的引入和对特殊情况的巧妙处理,我们成功地将双重循环降为了一重循环,有效地以较小的空间开销实现了代码执行速度的飞跃。
写在文末
我是来自学生组织江南游戏开发社的PAK向日葵,我们目前正在致力于开发自研的非营利性网页端同人游戏《植物大战僵尸:旅行》。
我们诚挚邀请您体验我们作品。如果您喜欢TA的话,欢迎向您的同事和朋友推荐,您的支持是我们最大的动力!