xiedaxia1hao / leetcode_pattern

1 stars 0 forks source link

Fast & Slow Pointers #6

Open xiedaxia1hao opened 3 years ago

xiedaxia1hao commented 3 years ago

141. 环形链表 142. Linked List Cycle II 234. 回文链表 143. 重排链表 202. Happy Number 457. 环形数组循环 189. 旋转数组 61. 旋转链表 80. Remove Duplicates from Sorted Array II 26. Remove Duplicates from Sorted Array

龟兔赛跑

相信很多人小时候听得最多的两个故事,莫过于高斯和龟兔赛跑了。高斯的故事让小朋友知道了学好数学的重要性,龟兔赛跑则无时无刻提醒小朋友们不要像兔子骄傲懈怠,同时也要像乌龟一样坚持不懈。

是的,学习算法技巧亦是。需要不断地练习,总结,持之以恒。然而,龟兔赛跑也是一个重要的算法技巧。也称之为 Floyd判圈算法。其主要目的是判断有限状态机、迭代函数或者链表是否有环。采用的技巧即使快慢指针,一个快,一个慢,正像兔子和乌龟一样,如果存在环,那么最终慢指针会遇到快指针。

证明就不具体描述,在头脑试想一下。假设一个跑道(链表)有环,那么无论兔子还是乌龟,总有一天它们都得进入跑道。并且进去之后就再也无法出来。那么一个快一个慢,快的就会逐渐超过慢的,最终会相遇。如果没有环,兔子又不懈怠,无论如何,慢的追不上快的,快的也不会遇见慢的。

xiedaxia1hao commented 3 years ago

快慢指针 & 链表有无环

leetcode 141. 环形链表即可以使用快慢指针解决。

给定一个链表,判断链表中是否有环。
如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。
如果链表中存在环,则返回 true 。 否则,返回 false 。

从题意可知,需要对输入的链表判断其是否成环。初始化一个慢指针 slow,再初始化一个快指针 fast,fast 每次移动的速度比 slow 快一倍。如果快慢指针相遇,则判断有环。如果没有环,快慢指针不会相遇,并且快指针最终会走完链表。具体代码如下:

public class Solution {
    public boolean hasCycle(ListNode head) {

        ListNode slow = head, fast = head;
        while(fast != null && fast.next != null) {
            fast = fast.next.next;
            slow = slow.next;
            if(fast == slow) {
                return true;
            }
        }

        return false;
    }
}

fast 指针每次比 slow 多快一步。如果链表有环,那么 slow 和 fast 最终都会进入到环。

算法的最终时间复杂度为 O(n),毕竟 slow 指针是一步步的迭代整个链表。空间复杂度为常数。


环形链表环的长度

这题可以有个很快速的followup,如果我们知道该链表有环,那么该环的长度该如何解呢?

实际上,有了环的入口,再通过环走一圈记录长度即可。那么可以再仔细想想,只要 slow 进入了环,那么起点不用从环的入口开始,当前位置作为起点,再走回当前位置即可。

public static int findCycleLength(ListNode head) {
  ListNode slow = head;
  ListNode fast = head;
  while (fast != null && fast.next != null) {
    fast = fast.next.next;
    slow = slow.next;
    if (slow == fast) // found the cycle
      return calculateLength(slow);
  }
  return 0;
}

private static int calculateLength(ListNode slow) {
  ListNode current = slow;
  int cycleLength = 0;
  do {
    current = current.next;
    cycleLength++;
  } while (current != slow);
  return cycleLength;
}

环形链表的入口

使用快慢指针,不仅能判断链表是否有环,还能判断链表的环的起始位置。Leetcode 上的 142. Linked List Cycle II 是上一题的升级版。

给定一个链表,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。
为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。注意,pos 仅仅是用于标识环的情况,并不会作为参数传递到函数中。
说明:不允许修改给定的链表。
进阶:你是否可以使用 O(1) 空间解决此题?

这题我们只需要记住,一旦 slow 和 fast 相遇,就让 slow 从新回到起点,fast 起始位置不变,但是步长变成和 slow 一样,当他们再次相遇,就是环的入口点。

那具体该如何证明呢?

如图,我们可以假设从起点到环入口的距离是m,环的长度为n,环的入口到fast和slow相遇的距离是y,剩下的距离是x

根据fast走过的路程是2倍慢指针走过的路程,我们最终可以得到:

m = C * n + x,其中C是一个任意的常数。

通过这个式子,我们知道了,如果我们把slow指针移到最开始,fast的起始位置不变,但是每次和slow一样只走一个单位,那么当slow指针走了m长度的时候,fast刚好也走了x,这样这个式子只剩下了最后的C * n,代表着环的入口了。

所以我们的代码可以这么写:

public class Solution {
    public ListNode detectCycle(ListNode head) {
        ListNode fast = head, slow = head;
        while(fast != null && fast.next != null) {
            fast = fast.next.next;
            slow = slow.next;
            // find the cycle
            if(fast == slow) {
                slow = head;
                while(slow != fast) {
                    slow = slow.next;
                    fast = fast.next;
                }
                return slow;
            }
        }

        return null;
    }
}
xiedaxia1hao commented 3 years ago

链表的中点

由于快慢指针中,快指针走的速度是慢指针的2倍。因此很容易就可以推出,对于无环的链表,快指针终究会走到尽头,那么此时慢指针的停留的位置,正好是链表的中点。

如果链表长度为奇数,中点的左右正好长度正好相等。如果链表长度是偶数,那么中点就偏右一个单位。

中点把链表分为左右两部,需要操作左右部分就得先找到中点。利用这个性质可以解决 Leetcode 的 234. 回文链表

请判断一个链表是否为回文链表。

leetcode的回文题有很多,通常判断一个线性字串是否是回文串,可以使用对撞指针解决。即初始化 left 和 right 指针,就像 reverse 字串的套路一样,只不过交互 left right 改成比对 left 和 right。

而对于链表,显然不太适合设置一个 right 指针,即使并且通过 right 指针也不能直接向左移动。既然不方便重两边向中间夹,那么可以换一个思路。从中间像两边扩展。

通过快慢指针先找到链表的中点,然后将中点到链表结尾进行 reverse,然后分别对比中点左边和中点右边的部分。如下图所示:

image

class Solution {
    public boolean isPalindrome(ListNode head) {
        ListNode slow = head, fast = head;
        while(fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;
        }

        ListNode mid = slow;
        ListNode end = reverse(slow);

        ListNode copiedEnd = end;
        while(copiedEnd != null) {
            if(head.val != copiedEnd.val) {
                return false;
            }
            head = head.next;
            copiedEnd = copiedEnd.next;
        }

        reverse(end);

        return true;

    }

    public ListNode reverse(ListNode head) {
        ListNode prev = null;
        while(head != null) {
            ListNode next = head.next;
            head.next = prev;
            prev = head;
            head = next;
        }
        return prev;
    }
}

需要注意,翻转是以 中点 开始。图示很明白,最后中点也需要加入比对。可是对于链表长度为偶数,那么中点就偏右,偏右的中点也需要跟其左边的元素进行比对,因此翻转的内容包括中点。当然,输入是一个链表,输出是一个boolean,但是不能改变原来的链表结构。因此最后还需要把 reverse 的右边再 reverse。


通过翻转再进行链表迭代,类似的还可以参考 143. 重排链表。也是通过快慢指针,先找到中点,然后将右变进行 reverse,最后再合左边部分一次相间插入即可。

给定一个单链表 L:L0→L1→…→Ln-1→Ln , 将其重新排列后变为: L0→Ln→L1→Ln-1→L2→Ln-2→… 你不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。

class Solution {
    public void reorderList(ListNode head) {
        ListNode slow = head, fast = head;
        while(fast != null && fast.next != null) {
            fast = fast.next.next;
            slow = slow.next;
        }

        ListNode secondHalfHead = reverse(slow);
        ListNode firstHalfHead = head;

        while(firstHalfHead != null && secondHalfHead != null) {
            ListNode temp = firstHalfHead.next;
            firstHalfHead.next = secondHalfHead;
            firstHalfHead = temp;

            temp = secondHalfHead.next;
            secondHalfHead.next = firstHalfHead;
            secondHalfHead = temp;
        }

        // set the next of the last node to 'null'
        if(firstHalfHead != null) {
            firstHalfHead.next = null;
        }
    }

    private ListNode reverse(ListNode head) {
        ListNode prev = null;
        while(head != null) {
            ListNode next = head.next;
            head.next = prev;
            prev = head;
            head = next;
        }
        return prev;
    }
}
xiedaxia1hao commented 3 years ago

快乐数

202. Happy Number

编写一个算法来判断一个数 n 是不是快乐数。
「快乐数」定义为: 对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。 然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。 如果 可以变为  1,那么这个数就是快乐数。 如果 n 是快乐数就返回 true ;不是,则返回 false 。

从题意可知,输入是任一个数,然后对其对位进行平方相加。什么样的数不会变成1呢,如果没有特别思路,可以拿几个简单的数字来验证一下,比如 4

4 16 = 4 ^ 2 37 = 1 ^ 2 + 6 ^ 2 58 = 3 ^ 2 + 7 ^ 2 ... ... 20 = 4 ^ 2 + 2 ^ 2 4 = 2 ^ 2

可以看到,对于 4 ,通过一些演算,最终又出现了 4, 在出现1之前就有 4 有重复,那么就永远也不可能出现 1。而这种情况,正好可以使用快慢指针。具体代码如下:

class Solution {
    public boolean isHappy(int n) {
        int slow = n, fast = n;
        do {
            slow = nextOperation(slow);
            fast = nextOperation(nextOperation(fast));
        } while(slow != fast);

        return slow == 1;
    }

    private int nextOperation(int num) {
        int res = 0;
        while(num > 0) {
            res += Math.pow(num % 10, 2);
            num /= 10;
        }
        return res;
    }
}

从代码结构上看,和之前的链表判环十分相像。其关键在于如何设置快指针和慢指针,对于链表可以直接 使用 next。而这个操作其实可以转化成多调一次函数。


类似的还有 457. 环形数组循环

image

理解题意非常重要,索引位置的数按照其值进行移动,移动之后新位置又继续移动,再次回到最终起点,那么就是一个循环。比快乐数稍微复杂在于,循环长度的规定,以及不能形成方向震荡。

依然可以使用快慢指针,对数组的每一个元素进行判断。判断每一个元素的时候,需要寻找下一个元素,寻找下一个元素可以封装成一个步骤。这个步骤中,如果发现循环长度和方向不符合要求,就可以提前返回。结束当前元素的判断,具体代码如下:

class Solution {
    public boolean circularArrayLoop(int[] nums) {
        for(int i = 0; i < nums.length; i++) {
            boolean isForward = nums[i] >= 0;
            int slow = i, fast = i;
            do {
                slow = findNextIndex(nums, isForward, slow);
                fast = findNextIndex(nums, isForward, fast);
                if(fast != -1) {
                    fast = findNextIndex(nums, isForward, fast);
                }
            } while(slow != -1 && fast != -1 && slow != fast);

            if(slow != -1 && slow == fast) {
                return true;
            }
        }

        return false;
    }

    private int findNextIndex(int[] nums, boolean isForward, int currentIndex) {
        boolean direction = nums[currentIndex] >= 0;
        if(direction != isForward) {
            return -1;
        }

        int nextIndex = (nums[currentIndex] + currentIndex) % nums.length;
        if(nextIndex < 0) {
            nextIndex += nums.length;
        }

        return nextIndex == currentIndex ? -1 : nextIndex;
    }
}

由于最外层需要对每个数组元素进行迭代判断,判定循环的函数执行复杂度是 O(N),最坏的情况下算法的时间复杂度是 O(N*N)。但是由于判定每个元素的时候可以提前返回,其均摊复杂度依然可以认为是线性 O(N)。

xiedaxia1hao commented 3 years ago

旋转数组和链表

leetcode 189. 旋转数组61. 旋转链表 都是针对一个序列,向右移动 k 个位置,右边超过边界就从左边接上。不同在于一个旋转是数组,一个是链表。

给定一个链表,旋转链表,将链表每个节点向右移动 k 个位置,其中 k 是非负数。
示例 1: 输入: 1->2->3->4->5->NULL, k = 2 输出: 4->5->1->2->3->NULL 解释: 向右旋转 1 步: 5->1->2->3->4->NULL 向右旋转 2 步: 4->5->1->2->3->NULL
示例 2: 输入: 0->1->2->NULL, k = 4 输出: 2->0->1->NULL 解释: 向右旋转 1 步: 2->0->1->NULL 向右旋转 2 步: 1->2->0->NULL 向右旋转 3 步: 0->1->2->NULL 向右旋转 4 步: 2->0->1->NULL

如果需要旋转的是 数组,可以对前 len - k 位置的进行reverse,然后对后 k 元素进行 reverse,最后再对整个数组进行reverse。如下图

image

class Solution {
    public void rotate(int[] nums, int k) {
        k %= nums.length;
        reverse(nums, 0, nums.length-k-1);
        reverse(nums, nums.length-k, nums.length-1);
        reverse(nums, 0, nums.length-1);
    }

    public void reverse(int[] nums, int lo, int hi) {
        while(lo < hi) {
            int temp = nums[lo];
            nums[lo] = nums[hi];
            nums[hi] = temp;
            lo++;
            hi--;
        }
    }
}

对于链表,显然不是特别方便像数组这样处理。但是可以使用快慢指针,即 fast 比 slow 快 k 步起点,然后以相同的速度前进。找到需要断开的节点,slow 的 next 就是新链表的 head,然后相对于的链表拼接即可图示如下:

image

最终对链表的处理代码如下:

class Solution {
    public ListNode rotateRight(ListNode head, int k) {
        if(head == null || k == 0) {
            return head;
        }

        int length = 1;
        ListNode lastNode = head;
        while(lastNode.next != null) {
            lastNode = lastNode.next;
            length++;
        }

        k %= length;        
        ListNode slow = head, fast = head;
        for(int i = 0; i < k; i++) {
            fast = fast.next;
        }

        while(fast.next != null) {
            slow = slow.next;
            fast = fast.next;
        }

        fast.next = head;
        ListNode newHead = slow.next;
        slow.next = null;

        return newHead;

    }
}
xiedaxia1hao commented 3 years ago

快快快

80. Remove Duplicates from Sorted Array II

给定一个排序数组,你需要在原地删除重复出现的元素,使得每个元素最多出现两次,返回移除后数组的新长度。 不要使用额外的数组空间,你必须在原地修改输入数组并在使用 O(1) 额外空间的条件下完成。
示例 1: 给定 nums = [0,0,1,1,1,1,2,3,3], 函数应返回新长度 length = 7, 并且原数组的前五个元素被修改为 0, 0, 1, 1, 2, 3, 3 。 你不需要考虑数组中超出新长度后面的元素。

For those who have trouble grasping the logic, the key is that num[i] is replaced by the value of the current element and i steps forward, but stops replacing if nums[i-2] = current, indicating that the current identical sequence has the maximum length of 2. Essentially, we are walking through the array and copying over each element to the left pointer unless the last sequence of identical elements hits the limit of length 2... then we wait until we find a new element and start copying over again.
This approach can also use any max length (e.g. this problem used 2)

class Solution {
    public int removeDuplicates(int[] nums) {
        int i = 0;
        for(int n : nums) {
            if(i < 2 || n > nums[i-2]) {
                nums[i++] = n;
            }
        }
        return i;
    }
}

这样的解法可以延伸到任意k,比如回到第26题。

该题其实和上面一题是一样的,只是把k改成1。

26. Remove Duplicates from Sorted Array

给你一个有序数组 nums ,请你 原地 删除重复出现的元素,使每个元素 只出现一次 ,返回删除后数组的新长度。 不要使用额外的数组空间,你必须在 原地 修改输入数组 并在使用 O(1) 额外空间的条件下完成。
示例 1: 给定数组 nums = [1,1,2], 函数应该返回新的长度 2, 并且原数组 nums 的前两个元素被修改为 1, 2。 你不需要考虑数组中超出新长度后面的元素。

class Solution {
    public int removeDuplicates(int[] nums) {
        int i = 0;
        for(int n : nums) {
            if(i < 1 || n > nums[i-1]) {
                nums[i++] = n;
            }
        }
        return i;
    }
}