【LeetCode选讲·第九期】「合并两个有序链表」「删除有序数组中的重复项」「移除元素」

301 阅读6分钟

T21 合并两个有序链表

题目链接:leetcode-cn.com/problems/me…

哨兵技巧

这是一道基于「单向链表」的「模拟」题,做这道题我们主要的目的是复习「哨兵技巧」的运用。忘记的同学请戳这里

题目比较简单,需要注意的地方有两点:①对传入空链表的特殊化处理;②while循环中if语句条件的设计。

我们直接过一遍代码:

function mergeTwoLists(L1, L2) {
    if (L1 === null) return L2;
    if (L2 === null) return L1;
    let ans = new ListNode();  //创建哨兵节点
    let tempNode = ans;
    while (L1 !== null || L2 !== null) {
        if(L2 !== null && (L1 === null  || L1.val >= L2.val)) {
            tempNode = tempNode.next = L2;
            L2 = L2.next;
        } else {
            tempNode = tempNode.next = L1;
            L1 = L1.next;
        }
    }
    return ans.next;
}

T26 删除有序数组中的重复项

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

双指针法

朴素解法:先扫再移

为了便于入手,我们先不考虑优化,直接从最简单的想法开始:

  • 定义双指针ij,并通过指针i对数组元素进行枚举;
  • j的初值为j = i + 1,通过j寻找满足nums[j] ≠ nums[i]的第一个位置;
  • 定义移动指针k并令其初值为k = i + 1,使用k将后方数组元素向前移动,移动间隔delta = j - i - 1.

代码如下:

function removeDuplicates(nums) {
    let i = 0;
    let border = nums.length;
    while(i < border) {
        let num = nums[i];
        /* 搜索最后一位与nums[i]重复的数字的位置 */
        let j = i + 1;
        while(j < border && nums[j] === num) {
            j++;
        }
        /* 数组元素移位 */
        let delta = j - i - 1;
        border -= delta;
        for(let k = i + 1; k < border; k++) {
            nums[k] = nums[k + delta];
        }
        /* 更新i,进入下一轮循环以校验下一位元素是否重复 */
        i++;
    }
    return (nums.length = border);
}

Tip: 实际上,题目中并没有要求我们删除多余的元素,因此最后的返回语句直接写成return border;就行了。但出于便于调试的目的,我们这里仍然保留对有效区间外元素的删除操作,下文的代码我们也将进行这样的操作。

优化解法:边扫边改(双指针法)

前述的「朴素解法」中我们使用了三个循环来实现题目要求,比较耗时。能不能用更少的循环进行解答?

答案是肯定的。我们可以这么考虑:

首先,无论如何去重,数组nums中的第一位nums[0]肯定是保持不动的,我们对数组的遍历只需要从i = 1开始即可。

然后为了理解方便,我们不妨引入一个假想的目标数组N,并且令指针j始终指向N的末端,以便于我们向N中写入数据。我们称j为「写入位置」。现在,我们只需要将原数组nums中需要保留的元素依次写入N[j]中,再让j向右移动即可。

如何知道哪些元素是需要保留的呢?别忘了,我们题目的要求可是要给原数组nums去重。同时也很重要的是,它可是有序数组!也就是说,如果原数组中出现重复的数字,那么它们肯定是挨在一起的。

现在你应该明白过来了。

是的,如果原数组中出现重复的数字,那么当它们中的第一个数字被写入N[j]后,当指针i指向它们中的第二个数字时,一定会有nums[i] = N[j - 1](现在的j已经往后移动了)。因此我们的任务便是阻止当这种情况发生时,N[j]被更新成nums[i]

但现在你的心中一定有疑惑:题目中不允许我们使用额外的空间,这个算法还是不可行啊!

事实上,把上文中的N全部替换成nums就行了。很容易理解:指针j不可能超越指针i。因此它们俩的工作并不会发生冲突,我们可以实现对原数组「边扫边改」的效果!

代码如下:

function removeDuplicates(nums) {
    let j = 1;
    for(let i = 1; i < nums.length; i++) {
        if (nums[i] !== nums[j - 1]) {
            nums[j++] = nums[i];
        }
    }
    //这个结论是容易归纳的,请自己尝试一下!
    return (nums.length = j);
}

这种「优化解法」的思想本源与前面我们介绍的「朴素解法」是类似的。

我们可以这么理解:我们只是在前面的基础上将「扫描」(遍历数组检查重复)和「移动」(在这里是对有效区间内元素的逐个修改)两个步骤合二为一,将三个循环缩减到了一个循环,实现了时间的节约。

拓展:最多允许重复k次

下面我们对原题进行一般化的推广:给定存在重复元素的有序数组,使得数组中的元素最多允许重复k次。

朴素解法:先扫再移

同样地,我们先从「朴素解法」入手。

"最多允许重复k次的问题",本质上还是数组的移位问题。容易想到的办法,仍然是确定连续数字的区间,然后分情况讨论是否要进行位移。

实现这个思路只需要对原题的「朴素解法」代码进行修改即可。

修改后的代码如下:

function removeDuplicates(nums, k) {
    let i = 0;
    let border = nums.length;
    while(i < border) {
        let num = nums[i];
        let j = i + 1;
        while(j < border && nums[j] === num) {
            j++;
        }
        if(j - i + 1 > k) {  //如果区间长度超过k,则需要进行移位
            let delta = j - (i + k);
            border -= delta;
            for(let m = i + k; m < border; m++) {
                nums[m] = nums[m + delta];
            }
            i = i + k;
        } else {  //如果区间长度没有超过k,直接让i跳转到下一区间即可
            i = j;
        }
    }
    return (nums.length = border);
}

优化解法:边扫边改

初次尝试(多指针法)

同样地,下面让我们来探索如何实现「边扫边改」的优化解法。下面是一个比较容易入手的想法:

首先我们可以模仿「删除有序数组中的重复项」中的「双指针法」引入指针ij,再引入指针p用于标记首次出现当前数字的数组下标。nums[p]便是我们遍历数组过程中的参照对象。

我们通过指针i逐位遍历数组,对nums[i]nums[j]进行比较,之后的操作需要进入如下的分类讨论:

  • 若当前数字nums[i]与比较数字nums[p]相同,且当前数字总数不超过k,则直接写入nums[i]并更新ij
  • 若当前数字nums[i]与比较数字nums[p]相同,且当前数字总数超过k,则先暂时保持jp不动,只更新i以检查下一个数字;
  • 若当前数字nums[i]与比较数字nums[p]不同,则表示出现新数字. 令p = i以更新参照对象,同时写入新数字nums[i]并更新ij.

同时类似于原题中运用「双指针法」解题时我们直接保留了nums[0],在这里由于我们要保留k个相同数字,那么对于原数组前k个数字,我们一定可以直接保留。进而我们可以进一步压缩遍历的次数。

代码如下:

function removeDuplicates(nums, k) {
    let p = 0;  //记录比较位置
    let j = k;  //记录写入位置
    for(let i = k; i < nums.length; i++) {  //遍历数组进行逐位检查
        if(nums[i] === nums[p] && i - p < k) {
            nums[j++] = nums[i];
        }
        else if (nums[i] !== nums[p]) {
            p = i;
            nums[j++] = nums[i];
        }
    }
    return (nums.length = j);
}

再次改进(双指针法)

事实上,我们还可以再次改进前述的解法,使之成为真正的「双指针法」。因为即使没有比较指针p,我们也可以实现相同的功能。

下面请你自行归纳:对于任意数组元素nums[i](其中i ≥ k),当该元素与nums[j - k]不相同时,则需要保留(即令nums[j] = nums[i]并更新j),反之则需要放弃。

代码如下:

function removeDuplicates(nums, k) {
    let j = k;  //记录写入位置
    for(let i = k; i < nums.length; i++) {  //遍历数组进行逐位检查
        if(nums[i] !== nums[j - k]) {
            nums[j++] = nums[i];
        }
    }
    return (nums.length = j);
}

对比最初的「朴素解法」,现在我们只需要两个变量ij外加一次循环就能实现相同的功能,不得不感叹算法的神奇!

T27 移除元素

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

双指针法

本题本质上是前面的「删除有序数组中的重复项」的变式。由于朴素解法的代码编写较为繁琐,让我们来直接参照前面的优化解法(「双指针法」)进行解题。

代码如下:

function removeElement(nums, val) {
    let j = 0;
    for(let i = 0; i < nums.length; i++) {
        if(nums[i] !== val) {
            nums[j++] = nums[i];
        }
    }
    return (nums.length = j);
}

走到这里,相信你已经发现了:题目中所谓的"元素的顺序可以改变"其实只是一个误导。只要我们把握了「双指针法」解「数组内元素删除问题」的精髓——通过指针i进行数组的遍历和数据校验,并通过另一个指针j标记数据的写入位置,这类题型就都可以轻松解决。

写在文末

我是来自在校学生编程兴趣小组江南游戏开发社的PAK向日葵,我们目前正在致力于开发自研的非营利性网页端同人游戏《植物大战僵尸:旅行》,以锻炼我们的前端应用开发能力。

我们诚挚邀请您体验我们的这款优秀作品,如果您喜欢TA的话,欢迎向您的同事和朋友推荐。如果您有技术方面的问题希望与我们探讨,欢迎直接与我联系。您的支持是我们最大的动力!

QQ图片20220701165008.png