1. 程式人生 > >淺析經典面試演算法題-two pointer的運用

淺析經典面試演算法題-two pointer的運用

前幾天和朋友討論 Google 電面的一道題, 由此啟發, 總結了下 two pointer 的使用場景, 在大部分情況下, 恰當地使用 two pointer 可以使時間複雜度保持在 O(n), 像 online judge 裡部分 medium 題經常提及的子數列型別問題, two pointer 也可以提供不錯的切入角度。

前記

前幾天和朋友討論 Google 電面的一道題, 由此啟發, 總結了下 two pointer 的使用場景, 在大部分情況下, 恰當地使用 two pointer 可以使時間複雜度保持在 O(n), 像 online judge 裡部分 medium 題經常提及的子數列型別問題, two pointer 也可以提供不錯的切入角度。

Two Sum

Original

Question [EASY] 找到兩個數, 其和為指定數量。 Given an array of integers, find two numbers such that they add up to a specific target number.

The function twoSum should return indices of the two numbers such that they add up to the target, where index1 must be less than index2. Please note that your returned answers (both index1 and index2) are not zero-based.

很典型的two sum問題。除去brute force的n方時間複雜度的演算法,還有n的方法。簡單來說, 用hashmap找另一個值是否存在, 典型的用空間換時間, 這裡空間複雜度也是n。

/**
 * Hashmap implementation. O(n) runtime, O(n) space.
**/
public int[] twoSum(int[] numbers, int target) {
  Map<Integer, Integer> map = new HashMap<>();
  for (int i = 0; i < numbers.length; i++){
    if (map.containsKey(target - numbers[i])) {
      return new int[] { i + 1, map.get(target - numbers[i]) + 1};
    }
    else{
      if (!map.containsKey(numbers[i])){ // edge case: duplicate items.
        map.put(numbers[i], i);
      }
    }
  }
  throw new IllegalArgumentException("No two sum solution");
}

Hashmap特別要注意的地方就是對於duplicates的考慮, 題目究竟是返回true or false就可以了, 還是需要返回所有符合的index, 還是隻是最小(或最大)的index, 都會有不同的實現方案。

Sorted

如果integer array已經排序過了的話,可以用兩個pointer來實現n時間1空間複雜度的方案; 不難理解, 兩個pointer從兩頭往中間移動, 其sum只有三種可能, 和比要求的target小, 那麼起始點的pointer右移, 和比要求大, 末尾pointer左移, 直到得到和或者return沒有結果為止。

/**
 * Two pointer implementation. Given that array is sorted. O(n) runtime, O(1) space.
**/
public int[] twoSum(int[] numbers, int target) {
  int p1 = 0;
  int p2 = size - 1;
  while (p1 < p2){
    if (numbers[p1] + numbers[p2] < target) {
      p1 += 1;
    }
    else if (numbers[p1] + numbers[p2] > target) {
      p2 -= 1;
    }
    else {
      return new int[] {p1, p2};    
    }
  }
  throw new IllegalArgumentException("No two sum solution");
}

留意一下two pointer適合哪些情景? 基礎的變式是通過兩個移動不同步長的pointer來完成一些事情, 也在暗指, 本身iterate through的這個array必須存在一些特性使得pointer可以有不同的移動。

Tricks

藉此稍微總結一下, 運用到two pointer的場景和技巧。

Question [EASY]合併兩個sorted array變成一個sorted array。 Given two sorted arrays A and B, each having length N and M respectively. Form a new sorted merged array having values of both the arrays in sorted format.

利用設定在兩個sorted array開頭的指標, 來達到m+n時間複雜度的效果。 比較簡單。

Question [EASY]一次迴圈找到連結串列的中間元素。How to find middle element of linked list in one pass?

Question [EASY]判斷一個連結串列是否存在環。 How to find if linked list has a loop ?

Question [EASY]找到連結串列中倒數第三個元素。How to find 3rd element from end in a linked list in one pass?

以上都是關於單向鏈類似的問題, 利用two pointer都可以得到快速的解答。

Example1 - continuous maximum subarray

Question [MEDIUM]找到不大於M的連續最大和子數列。 Given an array having N positive integers, find the contiguous subarray having sum as great as possible,, but not greater than M.

其實第二題還涉及到了另一個技巧, 就是在對於部分求和問題裡, 使用cumulative sum array是一個可能的切入口。 在將原數列生成對應的cumulative sum array之後, 這個題目也就相應轉換為找到兩個index, 使得對於這個遞增的和數列, 滿足:

cum[endIndex] - cum[startIndex-1] <= M and cum[endIndex+1] - cum[startIndex-1] > M的條件, 而endIndexstartIndex在原數列裡對應的子數列, 就是滿足要求的最大和子數列。

轉換了題意之後, 對於這個遞增數列, 可以接著用two pointer的思想來處理, 設定start和end兩個pointer從頭開始, 右移end指標只到不能滿足需求為止,然後右移start指標來減少sum使得end指標可以繼續右移。 記錄下每次start指標右移是的sum, 最大的那個sum所對應的指標位置, 對應回原數列, 就是我們想要找到的連續最大和子數列。可以說, 這道題的突破口是利用cumulative sum來創造一個遞增的數列, 從而使two pointer的實現方式更為簡潔。

public int[] main(int[] numbers, int ceiling) {
  int[] cumSum = new int[numbers.length() + 1]; // obtain cumulative sum.
  int sum = 0;
  cumSum[0] = 0;
  for (int i = 0; i < numbers.length(); i++) {
    sum += numbers[i+1];
    cumSum[i+1] = sum;
  }
  int l = 0, r = 0; // two pointers start at tip of the array.
  int max = 0;
  int[] ids = new int[2];
  while (l < cumSum.length()) {
    while (r < cumSum.length() && cumSum[r] - cumSum[l] <= M) {
      r++;
    }
    if (cumSum[r-1] - cumSum[l] > max) { // since cumSum[0] = 0, thus r always > 0.
      max = cumSum[r-1] - cumSum[l];
      ids[0] = l; ids[1] = r;
    }
    l++;
  }
  return ids;
}

Example2 - continuous minimum distinct subarray

Question [MEDIUM]找到至少含有K個不同數字的連續最小和子數列。 Given an array containing N integers, you need to find the length of the smallest contiguous subarray that contains atleast K distinct elements in it. Output “−1−1” if no such subarray exists.

從題意上和上一問求連續最大和子數列很像, 其實處理方式也有共同之處, 利用cumulative sum來負責和的部分, 利用set的實現來負責distince element的部分, 和之前相比end指標移動的條件, 更換為使得set中元素至少有K個, 記錄此時sum和對應得end - start得長度, 然後移動start指標, 更新set元素, 由此往復。n的時間複雜度。

Example3 - minimum hustle subsequence

Question [MEDIUM] 找到K個給出最小hustle值的子數列, 不要求連續。 Given an array having N integers, you need to find out a subsequence of K integers such that these K integers have the minimum hustle. Hustle of a sequence is defined as sum of pair-wise absolute differences divided by the number of pairs.

明確了hustle值, 也就是pair的差絕對值之和之後, 對於不要求連續的子數列找最小值, 可以利用sorting來排序, 轉換為類似尋找連續最小和子數列的型別。可以稍微改變上題的方法來處理這道題。nlogn + n的時間複雜度。

Example4 - Google phone interview

前幾天看到朋友發的Google的其中一道電面的題, 和上面討論的題型很像, 不過稍加改動之後還更簡單了。

Question [EASY] 找到number X滿足最大cover。Given a set S of 10^6 doubles. Find a number X so that the [X, X+1) half-open real interval contains as many elements of S as possible.For example, given this subset:[2.7, 0.23, 8.32, 9.65, -6.55, 1.55, 1.98, 7.11, 0.49, 2.75, 2.95, -96.023, 0.14, 8.60], the value X desired is 1.98 because there are 4 values in the range 1.98 to 2.97999 (1.98, 2.7, 2.75, 2.95)

首先還是將數字sort一遍, 之後可以將題目轉換為two pointer的型別。array[end] - array[start] < 1, 同時使得collection的size最大。 每次end指標右移, 新增element到collection裡面, 在start指標右移的時候記錄下對應的array[start]的值和collection的size, 最後拿到當collection的size最大的時候的那個元素值就可以滿足在[X, X+1)的區間中數列的元素最多了。 由於不需要考慮distinct element, 所以採用collection而不是set。 達到O(n)的時間複雜度, 較優的解法。

Example5 - three pointers

Question [MEDIUM] 找到最長只含有兩個不同字母的子數列長度。 Given a string S, find the length of the longest substring T that contains at most two distinct characters. For example, Given S = “eceba”, T is “ece” which its length is 3.

仍然是two pointer的變式, 在維持two pointer的移動規則, 以及保持map的元素一直為兩個的同時, 利用第三個pointer來從map中移除元素。在這種情況下不是簡單的每次直接移除一個, 而是利用第三個pointer來移除移除元素到map裡只剩兩個元素。之所以用map而不用set的原因是, 移除一個字元並不代表後面就沒有該元素重複, 而是採用map<char, int>來計數, 當int為0時才從map的key中移除該字元。

/**
 * find longest substring with at most two distinct elements.
 */
public int longestSubstringTwoDistinctElement(String s) {
	int l = 0, m = 0, r = 0;
   int maxLen = 0;
   Map<char, int> map = new HashMap<char, int>();
   while (r < s.length()) {
      while (map.size() < 3) { // keep the set fixed.
         maxLen = max(maxLen, r - l);
         r++;
         if (map.containsKey(s[r])) {
            map.put(s[r], map.get(s[r]) + 1);
         } else {
            map.put(s[r], 1);
         };
      }

      m = l; // pointer m moves the from left till map becomes two.
      while (map.size() > 2) {
         if (map.get(s[m]) > 1) {
            map.put(s[m], map.get(s[m]) - 1);
         } else { // remove that char from map.
            map.remove(s[m]);
         }
         m++;
      }
      l = m;
   }
}

/*
   Method2: Compare with using map, if we know string only contains ASCII characters, then we can use int[256] to replace with a map<char, int>, since ASCII contains 127 characters, while use 8 bits leave one space for signed or unsigned.
 */
public int lengthOfLongestSubstringTwoDistinct(String s) {
   int[] count = new int[256];
   int i = 0, numDistinct = 0, maxLen = 0;
   for (int j = 0; j < s.length(); j++) {
      if (count[s.charAt(j)] == 0) numDistinct++;
      count[s.charAt(j)]++;
      while (numDistinct > 2) {
         count[s.charAt(i)]--;
         if (count[s.charAt(i)] == 0) numDistinct--;
         i++;
      }
      maxLen = Math.max(j - i + 1, maxLen);
   }
   return maxLen;
}

Substring

Question [EASY] 查詢是否存在子字串。Implement strstr(). Returns the index of the first occurrence of word1 in word2, or -1 if word1 is not part of word2.

其實典型做法就是科班的KMP演算法, mn的時間複雜度, 注意的點還是在於對部分edge cases的考慮。

public int strstr(String raw, String template) {
	for (int i = 0; i < raw.length(); i++) {
		for (int j = 0; j < template.length(); j++) {
			if (i > raw.length() - template.length()) return -1;
			if (template.charAt(j) != raw.charAt(i + j)) break;
		}
		if (j == template.length()) return i;
	}
}

Reverse Words in String

Question [EASY] 將字串中的單詞們首位調換位置。Reverse words in string. Given an input string s, reverse the string word by word. For example, given s = “the sky is blue”, return “blue is sky the”.

注意的點在於使用StringBuilder而不是String concatenation, 因為StringBuilder不需要每次在賦值的時候再建立一個新的物件。 同時和這類題型特別相似的還有將某個integer array向某個方向rotate的, 存在類似的技巧。

Original

基礎的做法:

// parse token, reverse them. n time, n space.
public String reverse(String s) {
	String[] tokens = s.split("\\s+");
	String result = "";
	for(int i = 0; i < tokens.length(); i++) {
		result += tokens[tokens.length() - 1 - i];
	}
	return result;
}

如果不允許使用split()來parse token的話, 可以嘗試two pointer的方式, 一個負責track單詞的起始位置, 一個負責track結束位置, 然後跳過空格繼續執行。
// do better with only one pass, without using split util function. By using two pointers, one tracks the word's beginning, one tracks the end. n time n space.
public String reverse(String s) {
	int start = s.length() - 1;
	int end = s.length() - 1;
	StringBuilder result = new StringBuilder();
	while (start > 0) {
		while (!s.charAt(end)) { // start with a non-space word.
			start--;
			end--;
		}
		while(s.charAt(start)) start--;
		if (result.length() != 0) {
			result.append(' ');
		}
		result.append(s.substring(start + 1, end));
		end = start;
	}
	return result;
}

Tricks

以上提到的兩種方法都是n時間n空間, 也就是我們都建立了一個新的array返回, 也有直接在原數列做交換的方式, 正是上文提到的關於rotate型別的技巧。由於我們觀察到:如果給每一個單詞都reverse一遍, 最後再整個字串reverse一遍, 可以得到相同的結果, 比如:

  • raw: “ab bc cd”
  • target: “cd bc ab”
    • reverse each word from raw: “ba cb dc”
    • reverse whole string:“cd bc ab”, same as target string.
/*
	Reverse words in string, but do it with n time, 1 space.
	- one brilliant idea is, by reversing words first, then reverse the whole string. It has the same effect as reverse words in string.
 */
public void reverse(char[] s) {
	rotate(s);
	for(int i = 0, j = 0; j <= s.length(); j++) {
		if(s[j] == ' ' || j == s.length()){
			rotate(s, i, j);
			i = j + 1;
		}
	}
}

public void rotate(char[] s, int start, int end) {
	for(int i = 0; i < (end - start) / 2; i++) {
		char temp = s[start + i];
		s[start + i] = s[end - i - 1];
		s[end - i - 1] = temp;
	}
}

More

類似的還有剛剛提及的將某數列向某方向rotate的題型, Rotate an array to the right by k steps in-place without allocating extra space. For instance, with k = 3, the array [0, 1, 2, 3, 4, 5, 6] is rotated to [4, 5, 6, 0, 1, 2, 3].

同樣可以用到reverse的技巧。

public void rotateRight(int[] numbers, int step) {
	rotate(numbers, 0, numbers.length() - step);
	rotate(numbers, numbers.length() - step + 1, numbers.length());
	rotate(numbers, 0, numbers.length());
}

public void rotate(int[] numbers, int start, int end) {
	for(int i = 0; i < (end - start) / 2; i++) {
		int temp = numbers[start + i];
		numbers[start + i] = numbers[end - i - 1];
		numbers[end - i - 1] = temp;
	}
}

Reading

後記

剛開始用Java, 其實主要還是平時寫Spring Boot的時候用的比較多, 如果上述的演算法有錯誤或者忽略了edge cases的考慮, 歡迎在評論區指出。 同時大部分問題都存在其他或許更優的解法, 歡迎一起討論~