算法之双指针编程案例

算法思想之双指针

1、有序数组的平方

描述:给你一个按 非递减顺序 排序的整数数组 nums,返回每个数字的平方组成的新数组,要求也按 非递减顺序 排序。

示例:

示例 1:
输入:nums = [-4, -1, 0, 3, 10]
输出:[0, 1, 9, 16, 100]
解释:平方后,数组变为[16, 1, 0, 9, 100]
排序后,数组变为[0, 1, 9, 16, 100]

示例 2:
输入:nums = [-7, -3, 2, 3, 11]
输出:[4, 9, 9, 49, 121]

方法一:直接排序

思路与算法:

最简单的方法就是将数组 nums 中的数平方后直接排序。

代码:

var sortedSquares = function (nums) {
    var newNums = [];
    // 所有数依次平方
    nums.forEach((value) => {
        newNums.push(value * value);
    });
    // 排序
    newNums = newNums.sort(function (a, b) {
        return a - b;
    });
    // 返回排序后的数组
    return newNums;
};

var nums = [-4, -1, 0, 3, 10];
var nums1 = [-7, -3, 2, 3, 11];
console.log(sortedSquares(nums));   // [0, 1, 9, 16, 100]
console.log(sortedSquares(nums1));  // [4, 9, 9, 49, 121]

复杂度分析:

  • 时间复杂度:O(nlog n),其中 n 是数组 nums 的长度。
  • 空间复杂度:O(log n)。除了存储答案的数组以外,我们需要O(log n) 的栈空间进行排序。

方法二:双指针

思路: 方法一没有利用「数组nums 已经按照升序排序」这个条件。

首先审题:(非严格)递增数组,求各数字平方后的(非严格)递增顺序。
思考:什么数的平方最小?什么数的平方最大?0的平方最小,绝对值最大的数平方最大。

那么由以上2个问题就可以产生2种解法,

  • 第一种是找平方最大的数,即找绝对值最大的数,要么是最大正数,要么是最小负数。

更进一步的,我们可以思考:

  • 如果全正,那么最大平方就是最右(最大正数);
  • 如果全负,那么最大平方就是最左(最小负数);
  • 如果有正有负,那么就是最左或者最右里绝对值较大的那个;

因此我们可以得出结论,不论正负,平方最大值一定会是最左和最右之一,这样就避免了对正负性的讨论。

现在采用第一种方法,我们可以使用两个指针分别指向位置 0 和 n−1,每次比较两个指针对应的数,选择较大的那个逆序放入答案并移动指针。这种方法无需处理某一指针移动至边界的情况,我们可以仔细思考其精髓所在。

代码:

// 解法2:找到平方后最大的数,从两头向中间遍历
// 无论数字的正负,平方之后最大的数,一定是最左(最小负数)或者最右(最大整数)之一
var sortedSquares = function (nums) {
    var newNums = [];
    // 左指针
    var left = 0;
    // 右指针
    var right = nums.length - 1;
    // newNums数组的指针,每次将最大的数写入数组的后面
    var current = nums.length - 1;
    while (left <= right) {
        // 分别计算最左和最右的平方
        var leftNum = nums[left] * nums[left];
        var rightNum = nums[right] * nums[right];
        // 取较大者
        // 左侧较大时
        if (leftNum >= rightNum) {
            newNums[current] = leftNum;
            // 左指针右移
            left++;
        } else {   // 右侧较大时
            newNums[current] = rightNum;
            // 右指针左移
            right--;
        }
        // newNums数组的指针左移
        current--;
    }
    // 返回排序后的数组
    return newNums;
};

var nums = [-4, -1, 0, 3, 10];
var nums1 = [-7, -3, 2, 3, 11];
console.log(sortedSquares(nums));  // [0, 1, 9, 16, 100]
console.log(sortedSquares(nums1));  // [4, 9, 9, 49, 121]

复杂度分析:

  • 时间复杂度:O(n),其中 nn 是数组 nums 的长度。
  • 空间复杂度:O(1)。除了存储答案的数组以外,我们只需要维护常量空间。

2、旋转数组

描述: 给定一个数组,将数组中的元素向右移动 k 个位置,其中 k 是非负数,最后返回移动后的数组。

示例:

示例 1:
输入: nums = [1, 2, 3, 4, 5, 6, 7], k = 3
输出: [5, 6, 7, 1, 2, 3, 4]
解释:
向右旋转 1: [7, 1, 2, 3, 4, 5, 6]
向右旋转 2: [6, 7, 1, 2, 3, 4, 5]
向右旋转 3: [5, 6, 7, 1, 2, 3, 4]

示例 2:
输入:nums = [-1, -100, 3, 99], k = 2
输出:[3, 99, -1, -100]
解释:
向右旋转 1: [99, -1, -100, 3]
向右旋转 2: [3, 99, -1, -100]

思路:
我们可以使用额外的数组来将每个元素放至正确的位置。用 n 表示数组的长度,我们遍历原数组,将原数组下标为 i 的元素放至新数组下标为 (i+k) mod n 的位置,最后将新数组拷贝至原数组即可。

代码:

var rotate = function (nums, k) {
    const n = nums.length;
    const newNums = new Array(n);
    for (let i = 0; i < n; i++) {
        newNums[(i + k) % n] = nums[i];
    }
    for (let i = 0; i < n; i++) {
        nums[i] = newNums[i];
    }
    return nums;
};

let nums1 = [1, 2, 3, 4, 5, 6, 7];
let k1 = 3;
console.log(rotate(nums1, k1));   //  [5, 6, 7, 1, 2, 3, 4]
let nums2 = [-1, -100, 3, 99];
let k2 = 2;
console.log(rotate(nums2, k2));   //   [3, 99, -1, -100]

复杂度分析:

  • 时间复杂度:O(n),其中 n 为数组的长度。
  • 空间复杂度:O(n)。

3、移动零

描述: 给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。

要求:

  1. 必须在原数组上操作,不能拷贝额外的数组。
  2. 尽量减少操作次数。

示例:

示例:
输入: [0, 1, 0, 3, 12]
输出: [1, 3, 12, 0, 0]

方法一: 使用两次遍历

思路

  • 第一次遍历,用 count 记录非零元素的个数,只要是非零的通通都赋值给 nums[count] 。
  • 非零元素统计完了,剩下的都是 0 了,所以第二次遍历把末尾的元素都赋值为 0 即可。

代码:

var moveZeroes = function (nums) {
    let count = 0;  // 记录非零个数
    // 第一次遍历,用 count 记录非零元素的个数,只要是非零的通通都赋值给 nums[count] 
    for (let i = 0; i < nums.length; i++) {
        if (nums[i] != 0) {
            nums[count++] = nums[i];
        }
    }
    // 非零元素统计完了,剩下的都是 0 了,所以第二次遍历把末尾的元素都赋值为 0 即可
    for (let i = count; i < nums.length; i++) {
        nums[i] = 0;
    }
    // 返回移动后的数组
    return nums;
};

const nums = [0, 1, 0, 3, 12];
console.log(moveZeroes(nums));   //  [1, 3, 12, 0, 0]

复杂度分析:

  • 时间复杂度:O(n),其中 n 为序列长度。
  • 空间复杂度:O(1)。只需要常数的空间存放若干变量。

方法二: 使用双指针

思路及解法:

使用双指针,左指针指向当前已经处理好的序列的尾部,即非零元素指针,右指针指向待处理序列的头部。右指针不断向右移动,即遍历数组,每次右指针指向非零数,则将左右指针对应的数交换,同时左指针右移。

注意到以下性质:

  1. 左指针左边均为非零数;
  2. 右指针左边直达左指针处均为零。

因为每次交换,都是将左指针的零与右指针的非零数交换,且非零数的相对顺序并未改变。

代码:

var moveZeroes = function (nums) {
    // 左指针 即非零元素指针
    let left = 0;
    // 右指针 即遍历指针
    let right = 0;

    while (right < nums.length) {
        if (nums[right] != 0) {
            let temp = nums[left];
            nums[left] = nums[right];
            nums[right] = temp;

            left++;
        }
        right++;
    }
    // 返回移动后的数组
    return nums;
};
const nums = [0, 1, 0, 3, 12];
console.log(moveZeroes(nums));   //  [1, 3, 12, 0, 0]

// 代码运行过程如下:
// left = 0, right = 0  [0, 1, 0, 3, 12]
// left = 0, right = 1  [1, 0, 0, 3, 12]
// left = 1, right = 2  [1, 0, 0, 3, 12]
// left = 1, right = 3  [1, 3, 0, 0, 12]
// left = 2, right = 4  [1, 3, 12, 0, 0]
// 循环结束

复杂度分析:

  • 时间复杂度:O(n),其中 n 为序列长度。每个位置至多被遍历两次。
  • 空间复杂度:O(1)。只需要常数的空间存放若干变量。

4、两数之和——输入有序数组

描述: 给定一个已按照 升序排列 的整数数组 numbers ,请你从数组中找出两个数满足相加之和等于目标数 target 。

函数应该以长度为 2 的整数数组的形式返回这两个数的下标值。numbers 的下标 从 1 开始计数 ,所以答案数组应当满足 1 <= answer[0] < answer[1] <= numbers.length 。

你可以假设每个输入只对应唯一的答案,而且你不可以重复使用相同的元素。

示例:

示例 1:
输入:numbers = [2,7,11,15], target = 9
输出:[1,2]
解释:27 之和等于目标数 9 。因此 index1 = 1, index2 = 2 。

示例 2:
输入:numbers = [2,3,4], target = 6
输出:[1,3]

示例 3:
输入:numbers = [-1,0], target = -1
输出:[1,2]

利用输入数组有序的性质,有以下两种方法:

方法一:二分查找

思路:

在数组中找到两个数,使得它们的和等于目标值,可以首先固定第一个数,然后寻找第二个数,第二个数等于目标值减去第一个数的差。利用数组的有序性质,可以通过二分查找的方法寻找第二个数。为了避免重复寻找,在寻找第二个数时,只在第一个数的右侧寻找。

代码:

// 解法1:二分查找
var twoSum = function (numbers, target) {
    for (let i = 0; i < numbers.length; i++) {
        let low = i + 1;  // 在寻找第二个数时,只在第一个数的右侧寻找
        let high = numbers.length - 1;
        while (low <= high) {
            let mid = Math.floor(low + (high - low) / 2);
            if (numbers[mid] == target - numbers[i]) {
                return [i + 1, mid + 1];
            } else if (numbers[mid] > target - numbers[i]) {
                high = mid - 1;
            } else {
                low = mid + 1;
            }
        }
    }
    return [-1, -1];
};

let numbers = [2, 7, 11, 15];
let target = 9;
console.log(twoSum(numbers, target));   // [1, 2]

let numbers1 = [1, 2, 3, 4, 4, 9, 56, 90];
let target1 = 8;
console.log(twoSum(numbers1, target1));   // [4, 5]

复杂度分析:

  • 时间复杂度:O(nlog n),其中 n 是数组的长度。需要遍历数组一次确定第一个数,时间复杂度是 O(n),寻找第二个数使用二分查找,时间复杂度是 O(log n),因此总时间复杂度是 O(nlog n)。
  • 空间复杂度:O(1)。

方法二:使用双指针

思路:

初始时两个指针分别指向第一个元素位置和最后一个元素的位置。每次计算两个指针指向的两个元素之和,并和目标值比较。

  • 如果两个元素之和等于目标值,则发现了唯一解。
  • 如果两个元素之和小于目标值,则将左侧指针右移一位。
  • 如果两个元素之和大于目标值,则将右侧指针左移一位。
  • 移动指针之后,重复上述操作,直到找到答案。

由于题目确保有唯一的答案,因此使用双指针一定可以找到答案。

代码:

// 解法2:双指针
var twoSum = function (numbers, target) {
    let low = 0;  // 第一个元素的位置
    let high = numbers.length - 1;  // 最后一个元素的位置
    while (low < high) {
        let sum = numbers[low] + numbers[high];
        if (sum == target) {
            return [low + 1, high + 1];
        } else if (sum < target) {
            low++;
        } else {
            high--;
        }
    }
    return [-1, -1];
}

let numbers = [2, 7, 11, 15];
let target = 9;
console.log(twoSum(numbers, target));   // [1, 2]

let numbers1 = [1, 2, 3, 4, 4, 9, 56, 90];
let target1 = 8;
console.log(twoSum(numbers1, target1));   // [4, 5]

复杂度分析:

  • 时间复杂度:O(n),其中 n 是数组的长度。两个指针移动的总次数最多为 n 次。
  • 空间复杂度:O(1)。

5、反转字符串

描述: 编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 char[] 的形式给出。

不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。

你可以假设数组中的所有字符都是 ASCII 码表中的可打印字符。

示例:

示例 1:
输入:["h", "e", "l", "l", "o"]
输出:["o", "l", "l", "e", "h"]

示例 2:
输入:["H", "a", "n", "n", "a", "h"]
输出:["h", "a", "n", "n", "a", "H"]

方法: 使用双指针

代码:

var reverseString = function (s) {
    let left = 0;
    let right = s.length - 1;
    while (left <= right) {
        // 数组解构赋值
        [s[left], s[right]] = [s[right], s[left]];
        left++;
        right--;
    }
    return s;
};

let arr = ["h", "e", "l", "l", "o"];
console.log(reverseString(arr));   // ["o", "l", "l", "e", "h"]
let arr1 = ["H", "a", "n", "n", "a", "h"];
console.log(reverseString(arr1));   // ["h", "a", "n", "n", "a", "H"]

复杂度分析:

  • 时间复杂度:O(N),其中 N 为字符数组的长度。一共执行了 N/2 次的交换。
  • 空间复杂度:O(1)。只使用了常数空间来存放若干变量。

6、反转字符串中的单词

描述: 给定一个字符串,你需要反转字符串中每个单词的字符顺序,同时仍保留空格和单词的初始顺序。

示例:

示例:
输入:"Let's take LeetCode contest"
输出:"s'teL ekat edoCteeL tsetnoc"

思路: 使用额外空间
开辟一个新字符串。然后从头到尾遍历原字符串,直到找到空格为止,此时找到了一个单词,并能得到单词的起止位置。随后,根据单词的起止位置,可以将该单词逆序放到新字符串当中。如此循环多次,直到遍历完原字符串,就能得到翻转后的结果。

需要注意的是,原地解法在某些语言(比如 Java,JavaScript)中不适用,因为在这些语言中 String 类型是一个不可变的类型。

代码:

var reverseWords = function (s) {
    const arr = [];
    let i = 0;
    while (i < s.length) {
        let start = i;
        while (i < s.length && s.charAt(i) != ' ') {
            i++;
        }
        for (let p = start; p < i; p++) {
            arr.push(s.charAt(start + i - 1 - p));
        }
        while (i < s.length && s.charAt(i) == ' ') {
            i++;
            arr.push(' ');
        }
    }
    return arr.join('');
};
let str = "Let's take LeetCode contest";
console.log(reverseWords(str));   // s'teL ekat edoCteeL tsetnoc

复杂度分析:

  • 时间复杂度:O(N),其中 N 为字符串的长度。原字符串中的每个字符都会在 O(1) 的时间内放入新字符串中。
  • 空间复杂度:O(N)。我们开辟了与原字符串等大的空间。

7、链表的中间结点

描述: 给定一个头结点为 head 的非空单链表,返回链表的中间结点。如果有两个中间结点,则返回第二个中间结点。

示例:

示例 1:
输入:[1, 2, 3, 4, 5]
输出:此列表中的结点 3(序列化形式:[3, 4, 5])
返回的结点值为 3 。 (测评系统对该结点序列化表述是[3, 4, 5]) 。
注意,我们返回了一个 ListNode 类型的对象 ans,
这样:ans.val = 3, ans.next.val = 4, ans.next.next.val = 5, 
以及 ans.next.next.next = NULL.

示例 2:
输入:[1, 2, 3, 4, 5, 6]
输出:此列表中的结点 4(序列化形式:[4, 5, 6])
由于该列表有两个中间结点,值分别为 3 和 4,我们返回第二个结点。
方法一:数组

思路和算法:

链表的缺点在于不能通过下标访问对应的元素。因此我们可以考虑对链表进行遍历,同时将遍历到的元素依次放入数组 A 中。如果我们遍历到了 N 个元素,那么链表以及数组的长度也为 N,对应的中间节点即为 A[N/2]。

代码:

function ListNode(val, next) {
    this.val = (val === undefined ? 0 : val)
    this.next = (next === undefined ? null : next)
}
// 方法一:数组
var middleNode = function (head) {
    let A = [head];
    while (A[A.length - 1].next != null) {
        A.push(A[A.length - 1].next);
    }
    // Math.trunc() 方法会将数字的小数部分去掉,只保留整数部分。
    return A[Math.trunc(A.length / 2)];
};

复杂度分析:

  • 时间复杂度:O(N),其中 N 是给定链表中的结点数目。
  • 空间复杂度:O(N),即数组 A 用去的空间。
方法二:单指针法

思路和算法:

我们可以对方法一进行空间优化,省去数组 A。

我们可以对链表进行两次遍历。第一次遍历时,我们统计链表中的元素个数 N;第二次遍历时,我们遍历到第 N/2 个元素(链表的首节点为第 0 个元素)时,将该元素返回即可。

代码:

function ListNode(val, next) {
    this.val = (val === undefined ? 0 : val)
    this.next = (next === undefined ? null : next)
}

// 方法二:单指针法
var middleNode = function (head) {
    let n = 0;
    let current = head;
    // 计算链表中元素的个数
    while (current != null) {
        n++;
        current = current.next;
    }
    let k = 0;
    current = head;
    while (k < Math.trunc(n / 2)) {
        k++;
        current = current.next;
    }
    return current;
};

复杂度分析:

  • 时间复杂度:O(N),其中 N 是给定链表的结点数目。
  • 空间复杂度:O(1),只需要常数空间存放变量和指针。
方法三:快慢指针法

思路和算法:

我们可以继续优化方法二,用两个指针 slow 与 fast 一起遍历链表。slow 一次走一步,fast 一次走两步。那么当 fast 到达链表的末尾时,slow 必然位于中间。

代码:

function ListNode(val, next) {
    this.val = (val === undefined ? 0 : val)
    this.next = (next === undefined ? null : next)
}
// 方法三:快慢指针法
var middleNode = function (head) {
    let slow = head;
    let fast = head;
    // 当 fast 走到末尾,slow 则走到了中间位置
    while (fast && fast.next) {
        slow = slow.next;   // 走一步
        fast = fast.next.next;   // 走两步
    }
    return slow;
};

复杂度分析:

  • 时间复杂度:O(N),其中 NN 是给定链表的结点数目。
  • 空间复杂度:O(1),只需要常数空间存放 slow 和 fast 两个指针。

8、删除链表的倒数第N个结点

描述: 给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。

在这里插入图片描述

示例 1:
输入:head = [1,2,3,4,5], n = 2
输出:[1,2,3,5]

示例 2:
输入:head = [1], n = 1
输出:[]

示例 3:
输入:head = [1,2], n = 1
输出:[1]
方法一:使用双指针

思路和算法:

我们可以设想假设设定了双指针 slow 和 fast 的话,当 fast 指向末尾的 NULL,slow 与 fast 之间相隔的元素个数为 n 时,那么删除掉 slow 的下一个指针就完成了要求。

  • 设置虚拟节点 dummyHead 指向 head。
  • 设定双指针 slow 和 fast,初始都指向虚拟节点 dummyHead。
  • 移动 fast,直到 slow 和 fast 之间相隔的元素个数为 n。
  • 同时移动 slow 和 fast,直到 fast 指向的为 NULL。
  • 将 slow 的下一个节点指向下下个节点(即删除了倒数第N个结点)。

代码:

function ListNode(val, next) {
    this.val = (val === undefined ? 0 : val)
    this.next = (next === undefined ? null : next)
}

var removeNthFromEnd = function (head, n) {
    // 设置虚拟节点dummy,指向head
    let dummy = new ListNode(0, head);
    let slow = dummy;
    let fast = head;
    // 移动fast,直到 fast 和 slow 之间相隔n个元素
    for (let i = 0; i < n; i++) {
        fast = fast.next;
    }
    // 同时移动 slow 和 fast, 直到 fast 指向 null
    while (fast != null) {
        fast = fast.next;
        slow = slow.next;
    }
    // slow 此时指向要删除的节点的前一个节点
    // slow 的下一个节点指向下下个节点,删除节点
    slow.next = slow.next.next;
    // 让 ans 指向 head
    let ans = dummy.next;
    // 返回链表
    return ans;
};

复杂度分析:

  • 时间复杂度:O(L),其中 L 是链表的长度。
  • 空间复杂度:O(1)。
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值