【LeetCode选讲·第十四期】「K个一组翻转链表」「搜索插入位置」「外观数列」「最长有效括号」

799 阅读6分钟

T25 K个一组翻转链表

题目链接:leetcode.cn/problems/re…

分治法

这一道题目乍一看可能比较难以入手,况且题目中还明确要求我们"只使用常数额外空间"。

别忘了,我们之前做过「两两交换链表中的节点」这道题目。在本题中,我们可以运用「分治法」的思想,将原题转化前述的这道我们已经做过的题目,这样就可以大大降低解题的难度。

下面让我们直接过一遍代码:

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等需要反复调用函数的情景中就会暴露出非常大的性能瓶颈。

无标题.png

性能优化

事实上,在真实项目的开发中我们也常常会碰到类似的情况。下面分享两种常见的解决思路。

预加载

在做算法题时,我们也常常称这种方法为「打表」。当碰到数据量较小的情景(例如本题中规定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];
}

无标题.png

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];
}

由于本题的数据量较小,使用此方法的优化效果相较前面介绍的「预加载」稍弱一些。

无标题.png

T32 最长有效括号

题目链接:leetcode.cn/problems/lo…

朴素解法

首先稍微提一下一种最容易想到的方法。我们可以引入指针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;
}

当然,这种解法的性能肯定是非常低下的,因为我们不得不将几乎整个字符串遍历两次。

无标题.png

更巧妙的解法:栈

下面我们一起来探究一种基于「栈」的巧妙做法。

在正式开始前,我们不妨先思考一下,在本题中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;
}

提交结果:

无标题.png

通过「栈」的引入和对特殊情况的巧妙处理,我们成功地将双重循环降为了一重循环,有效地以较小的空间开销实现了代码执行速度的飞跃。

写在文末

我是来自学生组织江南游戏开发社的PAK向日葵,我们目前正在致力于开发自研的非营利性网页端同人游戏《植物大战僵尸:旅行》

我们诚挚邀请您体验我们作品。如果您喜欢TA的话,欢迎向您的同事和朋友推荐,您的支持是我们最大的动力!

QQ图片20220701165008.png