1. 程式人生 > 實用技巧 >Leetcode之三數之和

Leetcode之三數之和

問題描述

給你一個包含 n 個整數的陣列 nums,判斷 nums 中是否存在三個元素 a,b,c ,使得 a + b + c = 0 ?請你找出所有滿足條件且不重複的三元組。
注意:答案中不可以包含重複的三元組。

示例:

給定陣列 nums = [-1, 0, 1, 2, -1, -4],

滿足要求的三元組集合為:
[
[-1, 0, 1],
[-1, -1, 2]
]

解法

剛開始思考這道題想到的是採用兩個相加再去比較等不等於每一個元素的相反數.但是這樣做的話就避免不了重複的數字不同的順序排或者重複的數字相同的順序.最後想到的是排序再加上初次之外的兩個標識(即排序加雙指標).排序保證了相同的數字都在一起.雙指標可以指向只比當前元素標號大的值(可能相等也可能不等),這樣可以避免重複,保證了重複的數字組成只會出現一次.

public static List<List<Integer>> threeSum(int[] nums) {
		List<List<Integer>> res=new ArrayList<List<Integer>>();
		//排序
		Arrays.sort(nums);
		for(int i=0;i<nums.length;i++) {
			//以i為中心的值要取沒有當過中心的值
			if(i!=0)
				while(nums[i]==nums[i-1]) {
					i++;
                                        //防止溢位
					if(i>=nums.length)
						break;
				}
			//雙指標
			int start=i+1;
			int end=nums.length-1;
			while(end>start) {
				//左邊右移直到變大(和變大)
				if(nums[start]+nums[end]+nums[i]<0) {
					start++;
					while(start<end&&nums[start]==nums[start-1]) {
						start++;
					}
				}else if(nums[start]+nums[end]+nums[i]==0) {
					//相等,是一組結果
					List<Integer> temp=new ArrayList<Integer>();
					temp.add(nums[i]);
					temp.add(nums[start]);
					temp.add(nums[end]);
					res.add(temp);
					//將左邊右移(和變大)到變值並且將右邊左移到變值(和變小)
					start++;
					while(start<end&&nums[start]==nums[start-1]) {
						start++;
					}
					end--;
					while(start<end&&nums[end]==nums[end+1]) {
						end--;
					}
				}else {
					//右邊左移直到變大(和變小)
					end--;
					while(start<end&&nums[end]==nums[end+1]) {
						end--;
					}
				}
			}
		}
		return res;
		}

結果

官方解法

題目中要求找到所有「不重複」且和為 0的三元組,這個「不重複」的要求使得我們無法簡單地使用三重迴圈列舉所有的三元組。這是因為在最壞的情況下,陣列中的元素全部為 0,即
[0, 0, 0, 0, 0, ..., 0, 0, 0]

任意一個三元組的和都為 0。如果我們直接使用三重迴圈列舉三元組,會得到O(N^3)個滿足題目要求的三元組(其中 N 是陣列的長度)時間複雜度至少為 O(N^3)。在這之後,我們還需要使用雜湊表進行去重操作,得到不包含重複三元組的最終答案,又消耗了大量的空間。這個做法的時間複雜度和空間複雜度都很高,因此我們要換一種思路來考慮這個問題。
「不重複」的本質是什麼?我們保持三重迴圈的大框架不變,只需要保證:
第二重迴圈列舉到的元素不小於當前第一重迴圈列舉到的元素;
第三重迴圈列舉到的元素不小於當前第二重迴圈列舉到的元素。

也就是說,我們列舉的三元組 (a,b,c) 滿足 a≤b≤c,保證了只有(a, b, c) 這個順序會被列舉到,而(b, a, c) 等等這些不會,這樣就減少了重複。要實現這一點,我們可以將陣列中的元素從小到大進行排序,隨後使用普通的三重迴圈就可以滿足上面的要求。
同時,對於每一重迴圈而言,相鄰兩次列舉的元素不能相同,否則也會造成重複。舉個例子,如果排完序的陣列為
[0, 1, 2, 2, 2, 3]
^ ^ ^

我們使用三重迴圈列舉到的第一個三元組為 (0,1,2),如果第三重迴圈繼續列舉下一個元素,那麼仍然是三元組 (0,1,2),產生了重複。因此我們需要將第三重迴圈「跳到」下一個不相同的元素,即陣列中的最後一個元素 3,列舉三元組 (0,1,3)。
下面給出了改進的方法的虛擬碼實現:

nums.sort()
for first = 0 .. n-1
    // 只有和上一次列舉的元素不相同,我們才會進行列舉
    if first == 0 or nums[first] != nums[first-1] then
        for second = first+1 .. n-1
            if second == first+1 or nums[second] != nums[second-1] then
                for third = second+1 .. n-1
                    if third == second+1 or nums[third] != nums[third-1] then
                        // 判斷是否有 a+b+c==0
                        check(first, second, third)

這種方法的時間複雜度仍然為 O(N^3),畢竟我們還是沒有跳出三重迴圈的大框架。然而它是很容易繼續優化的,可以發現,如果我們固定了前兩重迴圈列舉到的元素 a和 b,那麼只有唯一的 c 滿足 a+b+c=0。當第二重迴圈往後列舉一個元素 b' 時,由於 b′>b ,那麼滿足 a+b′+c′=0 的 c′ 一定有 c′<c,即 c′在陣列中一定出現在 c的左側。也就是說,我們可以從小到大列舉 b,同時從大到小列舉 c,即第二重迴圈和第三重迴圈實際上是並列的關係。
有了這樣的發現,我們就可以保持第二重迴圈不變,而將第三重迴圈變成一個從陣列最右端開始向左移動的指標
,從而得到下面的虛擬碼:

nums.sort()
for first = 0 .. n-1
    if first == 0 or nums[first] != nums[first-1] then
        // 第三重迴圈對應的指標
        third = n-1
        for second = first+1 .. n-1
            if second == first+1 or nums[second] != nums[second-1] then
                // 向左移動指標,直到 a+b+c 不大於 0
                while nums[first]+nums[second]+nums[third] > 0
                    third = third-1
                // 判斷是否有 a+b+c==0
                check(first, second, third)

方法就是我們常說的「雙指標」,當我們需要列舉陣列中的兩個元素時,如果我們發現隨著第一個元素的遞增,第二個元素是遞減的,那麼就可以使用雙指標的方法,將列舉的時間複雜度從O(N^2) 減少至 O(N)。為什麼是 O(N) 呢?這是因為在列舉的過程每一步中,「左指標」會向右移動一個位置(也就是題目中的 b),而「右指標」會向左移動若干個位置,這個與陣列的元素有關,但我們知道它一共會移動的位置數為 O(N),均攤下來,每次也向左移動一個位置,因此時間複雜度為 O(N)。
注意到我們的虛擬碼中還有第一重迴圈,時間複雜度為 O(N),因此列舉的總時間複雜度為O(N^2)。由於排序的時間複雜度為 O(NlogN),在漸進意義下小於前者,因此演算法的總時間複雜度為 O(N^2)。
上述的虛擬碼中還有一些細節需要補充,例如我們需要保持左指標一直在右指標的左側(即滿足 b≤cb \leq cb≤c),

其他

方法:排序+雙指標

class Solution {
public List<List<Integer>> threeSum(int[] nums) {// 總時間複雜度:O(n^2)
        List<List<Integer>> ans = new ArrayList<>();
        if (nums == null || nums.length <= 2) return ans;

        Arrays.sort(nums); // O(nlogn)

        for (int i = 0; i < nums.length - 2; i++) { // O(n^2)
            if (nums[i] > 0) break; // 第一個數大於 0,後面的數都比它大,肯定不成立了
            if (i > 0 && nums[i] == nums[i - 1]) continue; // 去掉重複情況
            int target = -nums[i];
            int left = i + 1, right = nums.length - 1;
            while (left < right) {
                if (nums[left] + nums[right] == target) {
                    ans.add(new ArrayList<>(Arrays.asList(nums[i], nums[left], nums[right])));
                    
                    // 現在要增加 left,減小 right,但是不能重複,比如: [-2, -1, -1, -1, 3, 3, 3], i = 0, left = 1, right = 6, [-2, -1, 3] 的答案加入後,需要排除重複的 -1 和 3
                    left++; right--; // 首先無論如何先要進行加減操作
                    while (left < right && nums[left] == nums[left - 1]) left++;
                    while (left < right && nums[right] == nums[right + 1]) right--;
                } else if (nums[left] + nums[right] < target) {
                    left++;
                } else {  // nums[left] + nums[right] > target
                    right--;
                }
            }
        }
        return ans;
    }
}

結果

可以看到我們的基本思想基本相同,但是它的效率和思路都比我高
可以看到,首先它增加了nums[I]>0直接break的優化。其次,就是while中的移動是冗餘的,外面的部分也可以做這樣的工作