算法班笔记 第三章 双指针算法

第三章 双指针算法

相向双指针 

相向双指针,指的是在算法的一开始,两根指针分别位于数组/字符串的两端,并相向行走。如我们在小学的时候经常遇到的问题:

小明和小红分别在铁轨A站和B站相向而行,小红的速度为 1m/s, 小明的速度为 2m/s,A站和B站相距 1km。
请问 ... 他们什么时候被火车撞死?

一个典型的相向双指针问题就是翻转字符串的问题。在第二节课中我们学到的三步翻转法,就是一个典型的例子。

用 while 循环的写法: 

void reverse(string s) {
    int left = 0, right = s.size() - 1;
    while (left < right) {
        char temp = s[left];
        s[left] = s[right];
        s[right] = temp;
        left++;
        right--; 
    }
}

用 for 循环的写法: 

void reverse(string s) {
    for (int i = 0, j = s.size() - 1; i < j; i++, j--) {
        char temp = s[i];
        s[i] = s[j];
        s[j] = temp;
    }
}

双指针的鼻祖:两数之和

Two sum

使用哈希表来解决 

public int[] twoSum(int[] numbers, int target) {
    HashSet<Integer> set = new HashSet<>();

    for (int i = 0; i < numbers.length; i++) {
        if (set.contains(target - numbers[i])) {
            int[] pair = new int[2];
            pair[0] = numbers[i];
            pair[1] = target - numbers[i];
            return pair;
        }
        set.add(numbers[i]);
    }

    return null;
}

我们使用一个HashSet,来记录每个值是否存在。
每次查找 target - numbers[i] 是否存在,存在即说明找到了,返回两个数即可。 

使用双指针算法来解决 

public class Solution {
    public int[] twoSum(int[] numbers, int target) {
        Arrays.sort(numbers);

        int L = 0, R = numbers.length - 1;
        while (L < R) {
            if (numbers[L] + numbers[R] == target) {
                int[] pair = new int[2];
                pair[0] = numbers[L];
                pair[1] = numbers[R];
                return pair;
            }
            if (numbers[L] + numbers[R] < target) {
                L++;
            } else {
                R--;
            }
        }
        return null;
    }
}
  1. 首先我们对数组进行排序。
  2. 用两个指针(L, R)从左右开始:
    • 如果numbers[L] + numbers[R] == target, 说明找到,返回对应的数。
    • 如果numbers[L] + numbers[R] < target, 此时L指针右移,只有这样才可能让和更大。
    • 反之使R左移。
  3. L和R相遇还没有找到就说明没有解。

两个算法的对比

  1. Hash方法使用一个Hashmap结构来记录对应的数字是否出现,以及其下标。时间复杂度为O(n)。空间上需要开辟Hashmap来存储, 空间复杂度是O(n)。

  2. Two pointers方法,基于有序数组的特性,不断移动左右指针,减少不必要的遍历,时间复杂度为O(nlogn), 主要是排序的复杂度。但是在空间上,不需要额外空间,因此额外空间复杂度是 O(1)

 

判断回文串

另外一个双指针的经典练习题,就是回文串的判断问题。给一个字符串,判断这个字符串是不是回文串。

我们可以用双指针的算法轻易的解决:

boolean isPalindrome(String s) {
    for (int i = 0, j = s.length() - 1; i < j; i++, j--) {
        if (s.charAt(i) != s.charAt(j)) {
            return false;
        }
    }
    return true;
}

Follow up 1: 不区分大小写,忽略非英文字母

完整的题目描述请见:
http://www.lintcode.com/problem/valid-palindrome/

这个问题本身没有太大难度,只是为了给过于简单的 isPalindrome 函数增加一些实现技巧罢了。
代码上和上面的 isPalindrome 函数主要有2个区别:

  1. 在 i++ 和 j-- 的时候,要用 while 循环不断的跳过非英文字母
  2. 比较的时候要都变成小写之后再比较

 

Follow up 2: 允许删掉一个字母(类似的,允许插入一个字母)

完整的题目描述请见:
http://www.lintcode.com/problem/valid-palindrome-ii/

FLAG 的面经中出现过此题。一个简单直观的粗暴想法是,既然要删除一个字母,那么我们就 for 循环枚举(Enumerate)每个字母,试试看删掉这个字母之后,该字符串是否为一个回文串。

上述粗暴算法的时间复杂度是 O(n^2),因为 for 循环枚举被删除字母的复杂度为 O(n),判断剩余字符构成的字符串是否为回文串的复杂度为 O(n),总共花费 O(n^2)。这显然一猜就应该不符合面试官的要求。

正确的算法如下:

  1. 依然用相向双指针的方式从两头出发,两根指针设为 L 和 R。
  2. 如果 s[L] 和 s[R] 相同的话,L++, R--
  3. 如果 s[L] 和 s[R] 不同的话,停下来,此时可以证明,如果能够通过删除一个字符使得整个字符串变成回文串的话,那么一定要么是 s[L],要么是 s[R]。

简单的来说,这个算法就是依然按照原来的算法走一遍,然后碰到不一样的字符的时候,从总选一个删除,如果删除之后的字符换可以是 Palindrome 那就可以,都不行的话,那就不行。

假设从两边往中间比较的过程中,找到了第一对 s[L] != s[R],L的左边和R的右边都一样:

xyz...?...?...zyx
      ^   ^
      L   R

我们总共需要证明两件事情:

  1. L和R中间不存在任何字符,删除之后可以使得字符串变为回文串。
  2. L左侧(R右侧同理)不存在任何字符,删除之后可以使得字符串变为回文串。

先证明 1

假如被删除的字符在中间,我们用 $ 来表示($ 可以是任何字符):

xyz...?.$.?...zyx
      ^   ^
      L   R

既然 $ 删除之后,整个字符串是回文串,那么这个字符串左右两边必然包含 xyz..L 和 R...zyx 的部分(xyz 只是一个例子,可以是任何其他的对称字符串),那又因为 s[L] != s[R],所以可以知道这个字符串并不是轴对称的,也就是并不是回文串。

再证明 2

假如 L 左侧存在一个字符 (是个变量,可以是任何字符),删除之后,使得整个字符串为回文串:

xyz.$$'.?...?..$.zyx
       ^   ^
       L   R

我们将其对称的右边的位置也标记出来。如果 $ 被删除之后,那么他后面紧随而来的字符 $' 就有义务和 $ 的对称字符,也就是 $ 相等。
也就是说,===',那么此时,我们删除 $ 和 删除 $’ 的效果应该是一样的。那么我们就认为这次删除相当于删除了 $',那么同理我们可以证明,如果 $ 后面的字符分别是 $', $'', $'''。。可以得到 $ == $' == $'' == $''' ... 一直到 $ == L。那么此时也就是说,删除 $ 的效果和删除 L 的效果是一样的。那么就证明了,删除任何 L 左侧的字符,和删除 L 没有区别,那么就证明了仍然是在 L 和 R 中去选一个删除就行了。

 

同向双指针

数组去重问题 Remove duplicates in an array

http://www.lintcode.com/problem/remove-duplicate-numbers-in-array/

这个问题有两种做法,第一种做法比较容易想到的是,把所有的数扔到 hash 表里,然后就能找到不同的整数有哪些。但是这种做法会耗费额外空间 O(n)。面试官会追问,如何不耗费额外空间。

此时我们需要用到双指针算法,首先将数组排序,这样那些重复的整数就会被挤在一起。然后用两根指针,一根指针走得快一些遍历整个数组,另外一根指针,一直指向当前不重复部分的最后一个数。快指针发现一个和慢指针指向的数不同的数之后,就可以把这个数丢到慢指针的后面一个位置,并把慢指针++

 

滑动窗口问题 Window Sum

http://www.lintcode.com/problem/window-sum/

这个问题并没有什么难度,但是如果你过于暴力的用户O(n * k) 的算法去做是并不合适的。比如当前的 window 是 |1,2|,3,4。那么当 window 从左往右移动到 1,|2,3|,4 的时候,整个 window 内的整数和是增加了3,减少了1。因此只需要模拟整个窗口在滑动的过程中,整数一进一出的变化即可。这就是滑动窗口问题。

时间复杂度O(n),空间复杂度O(n-k+1)

 

两数之差问题 Two Difference

http://www.lintcode.com/problem/two-sum-difference-equals-to-target/

作为两数之和的一个 Follow up 问题,在两数之和被问烂了以后,两数之差是经常出现的一个面试问题。
我们可以先尝试一下两数之和的方法,发现并不奏效,因为即便在数组已经排好序的前提下,nums[i] - nums[j] 与 target 之间的关系并不能决定我们淘汰掉 nums[i] 或者 nums[j]。

那么我们尝试一下将两根指针同向前进而不是相向而行,在 i 指针指向 nums[i] 的时候,j 指针指向第一个使得 nums[j] - nums[i] >= |target| 的下标 j:

  1. 如果 nums[j] - nums[i] == |target|,那么就找到答案
  2. 否则的话,我们就尝试挪动 i,让 i 向右挪动一位 => i++
  3. 此时我们也同时将 j 向右挪动,直到 nums[j] - nums[i] >= |target|

可以知道,由于 j 的挪动不会从头开始,而是一直递增的往下挪动,那么这个时候,i 和 j 之间的两个循环的就不是累乘关系而是叠加关系。

Arrays.sort(nums);
target = Math.abs(target)

// 下面这个部分的代码是 O(n) 的
int j = 1;
for (int i = 0; i < nums.length; i++) {
    while (j < nums.length && nums[j] - nums[i] < target) {
        j++;
    }
    if (nums[j] - nums[i] == target) {
        // 找到答案!
    }
}

相似问题

G家的一个相似问题:找到一个数组中有多少对二元组,他们的平方差 < target(target 为正整数)。
我们可以用类似放的方法来解决,首先将数组的每个数进行平方,那么问题就变成了有多少对两数之差 < target。
然后走一遍上面的这个流程,当找到一对 nums[j] - nums[i] >= target 的时候,就相当于一口气发现了:

nums[i + 1] - nums[i]
nums[i + 2] - nums[i]
...
nums[j - 1] - nums[i]

一共 j - i - 1 对满足要求的二元组。累加这个计数,然后挪动 i 的位置 +1 即可。

 

链表中点问题 Middle of Linked List

http://www.lintcode.com/problem/middle-of-linked-list/

数据流问题 Data Stream Problem

所谓的数据流问题,就是说,你需要设计一个在线系统,这个系统不断的接受一些数据,并维护这些数据的一些信息。比如这个问题就是在数据流中维护中点在哪儿。(维护中点的意思就是提供一个接口,来获取中点)

类似的一些数据流问题还有:

 

  1. 数据流中位数 http://www.lintcode.com/problem/data-stream-median/
  2. 数据流最大 K 项 http://www.lintcode.com/problem/top-k-largest-numbers-ii/
  3. 数据流高频 K 项 http://www.lintcode.com/problem/top-k-frequent-words-ii/

用双指针算法解决链表中点问题

我们可以使用双指针算法来解决链表中点的问题,更具体的,我们可以称之为快慢指针算法。该算法如下:

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

return slow;

在上面的程序中,我们将快指针放在第二个节点上,慢指针放在第一个节点上,while 循环中每一次快指针走两步,慢指针走一步。这样当快指针走到头的时候,慢指针就在中点了。

 

带环链表问题 Linked List Cycle

2 pointers

fast pointer ->next->next

slow pointer->next

fast pointer meet slow pointer

follow up:

entrance node

slow pointer reset at start

slow->next

fast->next

follow up:

intersection of two linked lists

transform to the previous question

connect the end of the list to one of the start of the list

 

两大经典排序算法

快速排序(Quick Sort)和归并排序(Merge Sort)是算法面试必修的两个基础知识点。很多的算法面试题,要么是直接问这两个算法,要么是这两个算法的变化,要么是用到了这两个算法中同样的思想或者实现方式,要么是挑出这两个算法中的某个步骤来考察。

本小节将从算法原理,实现,以及时间复杂度,空间复杂度、排序稳定性等方面的对比,让大家对这两个经典算法有一个更深入的理解和认识。

快速排序算法 Quick Sort O(1)额外空间

It picks an element as pivot and partitions the given array around the picked pivot.

The key process in quickSort is partition(). Target of partitions is, given an array and an element x of array as pivot, put x at its correct position in sorted array and put all smaller elements (smaller than x) before x, and put all greater elements (greater than x) after x. All this should be done in linear time.

/* low  --> Starting index,  high  --> Ending index */
quickSort(arr[], low, high)
{
    if (low < high)
    {
        /* pi is partitioning index, arr[pi] is now
           at right place */
        pi = partition(arr, low, high);

        quickSort(arr, low, pi - 1);  // Before pi
        quickSort(arr, pi + 1, high); // After pi
    }
}

 

class Solution {
public:
    void quickSort(vector<int> &A, int left, int right){
        if(left >= right){
            return;
        }
        int start = left;
        int end = right;
        int pivot = A[(left+right)/2];
        
        // use start <= end is to include the case when k < smallest or k > largest
        while(start <= end){
            while(start <= end && A[start] < pivot){
                start++;
            }
            while(start <= end && A[end] > pivot){
                end--;
            }
            if(start <= end){
                swap(A[start],A[end]);
                start++;
                end--;
                
            }
        }
        
        quickSort(A, left, end); 
        quickSort(A, start, right); 
    }
    
    void sortIntegers2(vector<int> &A) {
        // write your code here
        quickSort(A,0,A.size()-1);
    }
};

归并排序算法 Merge Sort O(n)额外空间

It divides input array in two halves, calls itself for the two halves and then merges the two sorted halves.

MergeSort(arr[], l,  r)
If r > l
     1. Find the middle point to divide the array into two halves:  
             middle m = (l+r)/2
     2. Call mergeSort for first half:   
             Call mergeSort(arr, l, m)
     3. Call mergeSort for second half:
             Call mergeSort(arr, m+1, r)
     4. Merge the two halves sorted in step 2 and 3:
             Call merge(arr, l, m, r)
public class Solution {
    /**
     * @param A an integer array
     * @return void
     */
    public void sortIntegers2(int[] A) {
        // use a shared temp array, the extra memory is O(n) at least
        int[] temp = new int[A.length];
        mergeSort(A, 0, A.length - 1, temp);
    }
    
    private void mergeSort(int[] A, int start, int end, int[] temp) {
        if (start >= end) {
            return;
        }
        
        int left = start, right = end;
        int mid = (start + end) / 2;

        mergeSort(A, start, mid, temp);
        mergeSort(A, mid+1, end, temp);
        merge(A, start, mid, end, temp);
    }
    
    private void merge(int[] A, int start, int mid, int end, int[] temp) {
        int left = start;
        int right = mid+1;
        int index = start;
        
        // merge two sorted subarrays in A to temp array
        while (left <= mid && right <= end) {
            if (A[left] < A[right]) {
                temp[index++] = A[left++];
            } else {
                temp[index++] = A[right++];
            }
        }
        while (left <= mid) {
            temp[index++] = A[left++];
        }
        while (right <= end) {
            temp[index++] = A[right++];
        }
        
        // copy temp back to A
        for (index = start; index <= end; index++) {
            A[index] = temp[index];
        }
    }
}

快速排序与归并排序的比较

quick sort:

average O(nlogn)

worst O(n^2)

inplace

先整体再局部

merge sort:

O(nlogn)

O(n) extra space

具有稳定性, 相同数字,排序完后前一个数字在前后一个后

先局部再整体

 

快速选择算法 Quick Select 

Partition

如果数组有重复元素,则可能 partition 左边都是 小于等于,右边大于 or partition 左边小于,右边大于等于

// two pointers
class Solution {
public:
    /**
     * @param nums: The integer array you should partition
     * @param k: An integer
     * @return: The index after partition
     */
    int partitionArray(vector<int> &nums, int k) {
        if(nums.empty()){
            return 0;
        }
        // write your code here
        int start = 0;
        int end = nums.size()-1;
        
        // use start <= end is to include the case when k < smallest or k > largest
        while(start <= end){
            while(start <= end && nums[start] < k){
                start++;
            }
            while(start <= end && nums[end] >= k){
                end--;
            }
            if(start <= end){
                swap(nums[start],nums[end]);
                start++;
                end--;
                
            }
        }
        
        return start;
    }
};

 

average O(n)

Kth largest element

The algorithm is similar to QuickSort. The difference is, instead of recurring for both sides (after finding pivot), it recurs only for the part that contains the k-th smallest element. The logic is simple, if index of partitioned element is more than k, then we recur for left part. If index is same as k, we have found the k-th smallest element and we return. If index is less than k, then we recur for right part. This reduces the expected complexity from O(n log n) to O(n), with a worst case of O(n^2).

function quickSelect(list, left, right, k)

   if left = right
      return list[left]

   Select a pivotIndex between left and right

   pivotIndex := partition(list, left, right, 
                                  pivotIndex)
   if k = pivotIndex
      return list[k]
   else if k < pivotIndex
      right := pivotIndex - 1
   else
      left := pivotIndex + 1
// use partition
// template
// use left <= right so the condition ends
// [...,right ptr, pivot, left ptr,...]

class Solution {
public:
    int findKthLargest(vector<int>& nums, int k) {
        if (nums.empty() || k < 1 || k > nums.size()){
            return -1;
        }
        return partition(nums,0,nums.size()-1,nums.size()-k);
    }

    int partition(vector<int>& nums, int start, int end, int k){
        if(start >= end){
            return nums[k];
        }
        int left = start;
        int right = end;
        int pivot = nums[(start+end)/2];
        
        while(left <= right){
            while(left <= right && nums[left] < pivot){
                left++;
            }
            while(left <= right && nums[right] > pivot){
                right--;
            }
            if(left <= right){
                swap(nums[left],nums[right]);
                left++;
                right--;
            }
        }
        if(k <= right){
            return partition(nums,start,right,k);
        }
        if(k >= left){
            return partition(nums,left,end,k);
        }
        return nums[k];
    }
};

 

三指针算法 

http://www.lintcode.com/problem/sort-colors/ 

将包含0,1,2三种颜色代码的数组按照颜色代码的大小排序。如 [1,0,1,0,2] => [0,0,1,1,2]。 

在颜色排序(Sort Color)这个问题中,传统的双指针算法可以这么做:

  1. 先用 partition 的方式区分开 0 和 1, 2
  2. 再在右半部分区分开 1 和 2

这个算法不可避免的要使用两次 Parition,写两个循环。许多面试官会要求你,能否只 partition 一次,也就是只用一个循环。

public void sortColors(int[] a) {
    if (a == null || a.length <= 1) {
        return;
    }
    
    int pl = 0;
    int pr = a.length - 1;
    int i = 0;
    while (i <= pr) {
        if (a[i] == 0) {
            swap(a, pl, i);
            pl++;
            i++;
        } else if(a[i] == 1) {
            i++;
        } else {
            swap(a, pr, i);
            pr--;
        }
    }
}

pl 和 pr 是传统的双指针,分别代表 0~pl-1 都已经是 0 了,pr+1~a.length - 1 都已经是 2 了。
另一个角度说就是,如果你发现了一个 0 ,就可以和 pl 上的数交换,pl 就可以 ++;如果你发现了一个 2 就可以和 pr 上的数交换 pr 就可以 --。

这样,我们用第三根指针 i 来循环整个数组。如果发现 0,就丢到左边(和 pl 交换,pl++),如果发现 2,就丢到右边(和 pr 交换,pr--),如果发现 1,就不管(i++)

这就是三根指针的算法,两根指针在两边,一根指针扫描所有的数。

这里有一个实现上的小细节,当发现一个 0 丢到左边的时候,i需要++,但是发现一个2 丢到右边的时候,i不用++。原因是,从pr 换过来的数有可能是0或者2,需要继续判断丢到左边还是右边。而从 pl 换过来的数,要么是0要么是1,不需要再往右边丢了。因此这里 i 指针还有一个角度可以理解为,i指针的左侧,都是0和1。

 

 

  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值