這個題目是什麼意思呢?簡單來說就是在一個陣列中找出兩個元素,使其和為我們設定的值,並且每個元素只能用一次。
如下圖具體示例:
到這裡不知道你是否已經有解題思路了呢?
解法一:雙層迴圈
我第一反應就是雙層迴圈,直接暴力破解。因為題目要求每個元素只能使用一次,並且已經計算過的也沒必要再次計算,因此內層迴圈索引起始可以以外層索引+1作為起始點,具體程式碼如下:
public static int[] TwoSumForFor(int[] nums, int target) { for (var i = 0; i < nums.Length; i++) { for (var j = i + 1; j < nums.Length; j++) { if (nums[i] + nums[j] == target) { return [i, j]; } } } return []; }
我們直接驗證一下,透過了:
因為是雙層迴圈因此演算法時間複雜度是:O(N2),因為沒有引用額外的空間因此空間複雜度是:O(1)。
注:上面的[] ,[i, j]是C#12版本新增功能,是陣列簡潔表達語法。
解法二:雙層迴圈+左右開弓
如果想在雙層迴圈基礎上繼續最佳化演算法要怎麼辦?
我們就按正常思維邏輯來梳理一下雙層迴圈幹了什麼,外層迴圈:表示第一個加數,並且從第一個數到最後一個數迴圈一遍;內層迴圈:表示第二個加數,其作用就是使第一個加數按從前到後的順序和其後面的每一個數加一遍。既然如此那能不能從前往後計算的同時也從後往前計算呢?顯然使可以的,程式碼如下:
public static int[] TwoSumForForBidirectional(int[] nums, int target) { for (var i = 0; i < nums.Length; i++) { var front = nums[i]; var backIndex = nums.Length - 1 - i; var back = nums[backIndex]; for (var j = i + 1; j < nums.Length; j++) { if (front + nums[j] == target) { return [i, j]; } if (back + nums[j - 1] == target) { return [j - 1, backIndex]; } } } return []; }
執行結果如下:
理想情況下可能會提升一倍的效率,但是細心的朋友應該發現,平臺上的執行結果,比雙層迴圈還長了3ms,不過感覺這個平臺結果不是很準,下面我們用基準測試,對兩個方法進行測試,我們隨機構建長度為2000的陣列,並把目標數隨機放到不同位置,測試10000次。
從基準測試的結果來看,整體上並沒有提升多少效能。這是因為這個演算法本質上時間複雜度還是:O(N2),因此並沒有真正起到最佳化的作用,只有特定的資料分別可能才會有相對較好的表現,這個演算法就當作給我們提供了一種解題思路吧。
解法三:單層迴圈+LastIndexOf
既然左右開弓不行,我們換一個思路,想辦法去掉一層迴圈。
首先外層迴圈需要保留,因為需要把每個元素都計算一遍,因此我們從內層迴圈下手。想想題目,是要找到兩個數使其和為目標值,那麼我們是否可以在迴圈第一個數時候,透過目標值計算出我們要找的第二個值,看看這個值是否存在,如果存在,則完成演算法,否則繼續迴圈直到找到為止。按照這個思路我立馬想到C#裡的IndexOf和LastIndexOf方法,直接上程式碼:
public static int[] TwoSumForLastIndexOf(int[] nums, int target) { for (var i = 0; i < nums.Length; i++) { var j = Array.LastIndexOf(nums, target - nums[i], nums.Length - 1, nums.Length - 1 - i); if (j >= 0 && j != i) { return [i, j]; } } return []; }
執行結果如下:
注:Array.LastIndexOf<T>(T[] array, T value, int startIndex, int count)方法可以指定從什麼地方開始查詢,查詢多少個數。
同樣我們再做一次基準測試做對比。
可以發現這一版本演算法效能大幅提升,但是我們細想一下,LastIndexOf方法本質還是在陣列中找一個元素,最壞的情況還是要把整個陣列遍歷一遍,只能說C#本身做了很好的最佳化使其效能很高,但是從演算法時間複雜度的角度來看,其仍然是 O(N) 的,也就是說這一版本演算法時間複雜度還是O(N2)。
解法四:單層迴圈+字典(雜湊)
哪到底如何才能把O(N)的集合查詢時間複雜度最佳化了呢?如果能改造到O(1) 就好了,順著這個思路還真想到了一種資料結構-雜湊表,可以做到O(1) 的查詢時間複雜度。
在C#中可以使用Dictionary字典型別,資料結構選好了,下面就是怎麼用的問題,key存什麼?value存什麼?
再次回憶一下題目要求,是找到兩個數使其和為目標值,假設x+y=target,x為第一個數,並且外層迴圈第一個數,那麼當處理資料x時,同時能得到y=target-x,如果陣列中後面存在值為y的元素,是不是就意味著這對[x,y]就是我們要找的值,因此我們可以在處理x時,把y=target-x和x所在索引記錄下來,正好分別對應Dictionary的key和value。程式碼如下:
public static int[] TwoSumDictionary(int[] nums, int target) { var dic = new Dictionary<int, int>(); for (var i = 0; i < nums.Length; i++) { if (dic.TryGetValue(nums[i], out var value)) { return [value, i]; } dic.TryAdd(target - nums[i], i); } return []; }
執行結果如下
執行正確,我們再來用基準測試對比一下。
結果和我們預想竟然不一樣,還沒有LastIndexOf方法效果好,哪裡出了問題呢?
下面我們對這兩種方法單獨做一次全方面的基準測試對比,對每個方法分別用陣列長度為100,1000,10000三種情況,各進行10000次測試。結果如下:
可以發現,只有當陣列長度越來越大的時候,雜湊表方案優勢才慢慢體現出來。
我們再來分析一下這個演算法複雜度,首先外層迴圈時間複雜度為O(N) ,字典操作時間複雜度為O(1),因此整體時間複雜度為O(N) 。因為字典需要額外的儲存空間並且最大長度為陣列長度減1,因此空間就複雜度為O(N) 。這是經典的以空間換時間方案。
從上面的對比不難發現,即使再好的方案也有其使用場景,資料量的大小,空間的大小都會制約著演算法方案的選擇,因此需要我們因時制宜選出最合適的方案。
沒有最好只有最合適。
注:測試方法程式碼以及示例原始碼都已經上傳至程式碼庫,有興趣的可以看看。https://gitee.com/hugogoos/Planner