題目:給定兩個大小分別為 m 和 n 的正序(從小到大)陣列 nums1 和 nums2。請你找出並返回這兩個正序陣列的 中位數 。
演算法的時間複雜度應該為 O(log (m+n)) 。
作為目前遇到的第一個困難級別題目,我感覺這題還是挺難的,研究了三天總算研究明白了,下面就給大家分享一下這題的幾種解法,請大家跟著我的思路循序漸進地來解這道題。
解法一、娛樂法(內建方法)
鑑於這題的難度,我們先來一個娛樂解法給大家先放鬆一下,而且在專案上遇到這樣的需求我想絕大多少都會用這種方式直接處理,而且這種思路更符合人的最直觀思維。簡單說下思路,就是直接用語言自帶的方法先把兩個陣列拼接起來,然後對拼接後的陣列排序,最後根據陣列個數奇偶直接獲取值。程式碼如下:
//使用內建方法直接合並陣列並排序法
public static double MergedArraySort(int[] nums1, int[] nums2)
{
var merged = nums1.Concat(nums2).ToArray();
Array.Sort(merged);
var len = merged.Length;
if (len % 2 == 0)
{
return (merged[(len / 2) - 1] + merged[len / 2]) / 2.0;
}
else
{
return merged[len / 2];
}
}
當然我們做演算法題是為了研究演算法思維,尋找更優的解決方案,因此我們繼續探索更演算法的解法。
解法二、雙指標+合併陣列法
雖說上面的解法有娛樂的成分,但是卻也提供了最直觀的思路,我們沿用上面的整體思路,把呼叫語言內建方法的地方改為我們自己實現。先梳理一下整體思路。
1.首先構建一個可以容納兩個陣列的大陣列;
2.然後同時從兩個陣列的起始位置開始比較,如果值較小則進入大陣列,值較大則繼續和後面元素比較;
3.處理陣列1沒有資料,陣列2有資料的情況;
4.處理陣列2沒有資料,陣列1有資料的情況;
5.處理其他情況;
6.這樣兩個陣列元素都處理完成,則陣列合並完成,並且是有序的;
7.根據大陣列元素個數奇偶取出中位數。
可以看看下圖實際步驟詳解:
實現程式碼如下:
//雙指標合併陣列後取值法
public static double TwoPointerMergedArray(int[] nums1, int[] nums2)
{
//建立一個可以容納兩個陣列的大陣列
var list = new int[nums1.Length + nums2.Length];
//陣列1的索引
var idx1 = 0;
//陣列2的索引
var idx2 = 0;
//大陣列的索引
var idx = 0;
//如果兩個陣列都處理完成則退出,每次大陣列索引前進1位
while (idx1 < nums1.Length || idx2 < nums2.Length)
{
if (idx1 >= nums1.Length)
{
//如果陣列1沒有資料了,則把陣列2當前值填充至大陣列後,把陣列2索引前進1位
list[idx] = nums2[idx2++];
}
else if (idx2 >= nums2.Length)
{
//如果陣列2沒有資料了,則把陣列1當前值填充至大陣列,把陣列1索引前進1位
list[idx] = nums1[idx1++];
}
else
{
//當兩個陣列都有資料,則比較當前兩個數,數小的則填充至大陣列,並且把其對應的索引前進1位
if (nums1[idx1] < nums2[idx2])
{
list[idx] = nums1[idx1++];
}
else
{
list[idx] = nums2[idx2++];
}
}
//大數字索引前進1位
idx++;
}
if (idx % 2 == 0)
{
//偶數則取中間兩位數,並取平均數
return (list[(idx / 2) - 1] + list[idx / 2]) / 2.0;
}
else
{
//奇數則去中間一位資料直接返回
return list[idx / 2];
}
}
因為構建了大陣列所以空間複雜度是O(n+m),因為把兩個陣列都遍歷了一遍,所以此演算法時間複雜度為O(n+m),因此不能滿足題目要求的O(log(n+m))。雖然此解法不是我們所要的,但是也給我們繼續尋找更好解法提供了邏輯基礎。
解法三、雙指標+直接取值法
想想題目,我們是要求中位數,回想一下上一節的解法,我們完全沒有必要構建一個大陣列,也沒有必要把兩個陣列都處理完,只有找到中位數就可以了,也就是如果偶然我們只需要取兩個值,而如果是奇數我們只需要取一個值。因此上一節解法中存在大量沒必要的計算。
因此上一節整個解題思路架構可以保留下來,把不必要的去掉即可,思路如下:
1.同時從兩個陣列的起始位置開始比較,如果值較小則存入臨時變數中;
2.處理陣列1沒有資料,陣列2有資料的情況;
3.處理陣列2沒有資料,陣列1有資料的情況;
4.處理其他情況;
5.到達右中位數位置處,則結束處理;
6.根據奇偶和臨時變數取出中位數。
先來看程式碼:
//雙指標直接取值法
public static double TwoPointerDirectValue(int[] nums1, int[] nums2)
{
//兩個陣列總長度
var len = nums1.Length + nums2.Length;
//左數,偶數:左中位數,奇數:中位數前一位
var left = 0;
//右數,偶數:右中位數,奇數:中位數
var right = 0;
//陣列1索引
var idx1 = 0;
//陣列2索引
var idx2 = 0;
//當中位數位處理完則退出
while (true)
{
//把上一個值先放入左數,右數存當前值
left = right;
if (idx1 >= nums1.Length)
{
//如果陣列1沒有資料了,則把陣列2當前值放入右數,把陣列2索引前進1位
right = nums2[idx2++];
}
else if (idx2 >= nums2.Length)
{
//如果陣列2沒有資料了,則把陣列1當前值放入右數,把陣列1索引前進1位
right = nums1[idx1++];
}
else
{
//當兩個陣列都有資料,則比較當前兩個數,數小的則放入右數,並且把其對應的索引前進1位
if (nums1[idx1] < nums2[idx2])
{
right = nums1[idx1++];
}
else
{
right = nums2[idx2++];
}
}
//當中位數位處理完則結束處理
if (len / 2 == idx1 + idx2 - 1)
{
break;
}
}
if (len % 2 == 0)
{
//偶數則取左數和右數平均數
return (left + right) / 2.0;
}
else
{
//奇數則直接返回右數
return right;
}
}
透過程式碼可以發現和上一節解法有幾個小差別以及關鍵的。
1.去掉了大陣列,改為新增了left,right兩個變數來儲存中位數;
- 關鍵點“left = right;”,因為我們需要儲存兩個值來應對奇偶兩種情況,所以這裡就採用了一個小技巧,每次迭代開始計算前,先把上一次計算結果存放到left中,然後用right儲存當前計算結果,所以left,right就是兩個連續的元素。如果在一次計算中把要的兩個數同時取出來,顯然情況會非常複雜難以處理,而這個小技巧正好解決了這個問題使得可以在兩次計算中分別取出想要的元素。
3.關鍵點為什麼if (len / 2 == idx1 + idx2 - 1)作為結束處理標誌,首先為什麼idx1 + idx2 – 1要減1,這是因為在上面取值的同時都進行了後++操作,所以這裡要把這個1減掉。然後為什麼是len / 2作為結束點,因為/是向下取整,因此一個偶然和比這個偶數大1的奇數,在經過/2處理後值是一樣的,又因為len表示的是陣列元素個數,而元素個數正好是比索引大1,因此當陣列為奇數時len/2就表示中位數,當陣列為偶數時len/2就表示右中位數,因此可以視為處理結束標誌。
因為去掉了構建大陣列因此空間複雜度是O(1),因為處理的資料量減少了一半因此時間複雜度是O((n+m)/2),也就是O(n+m)。因此還是沒滿足要求。
解法四、雙指標+二分排除法
顯然要想達到O(log(n+m))的時間複雜度,就不能一個元素一個元素處理,這不由的讓我們想到二分思想,既然一個一個處理不行,那麼我們就一次多處理幾個,把不滿足要求的資料都排除掉,一次排除一半,這樣不就離我們的目標值越來越近了嘛,最後就到達我們的目標值了。
我們繼續沿用上一節解法思想框架,做一些改動,思路如下:
1.同時從兩個陣列的左中位數位置的一半處開始比較,如果值較小則排除掉;
2.處理陣列1沒有資料,陣列2有資料的情況,並結束處理;
3.處理陣列2沒有資料,陣列1有資料的情況,並結束處理;
4.處理指標停留在左中位數位置的情況,並結束處理;
5.一次比較完成後,繼續取當前位置到左中位數位置的一半處,進行下一次比較;
6.根據奇偶和臨時變數取出中位數。
下面我們結合圖例來做個詳細說明。
找到左中位數也就意味著可以同時直接處理左右中位數。
我們在來看看程式碼實現:
//雙指標二分排除查詢法
public static double TwoPointerBinaryExclude(int[] nums1, int[] nums2)
{
//陣列總長度
var len = nums1.Length + nums2.Length;
//目標數,表示中位數在陣列中第幾個數,偶數則代表左中位數,奇數則代表中位數
var target = (len + 1) / 2;
//左中位數
int left;
//右中位數
int right;
//陣列1索引
var idx1 = 0;
//陣列2索引
var idx2 = 0;
//當中位數位處理完則退出
while (true)
{
//陣列1沒資料了,則直接在陣列2中獲取中位數
if (idx1 >= nums1.Length)
{
//因為陣列1沒有資料了,所以陣列2索引前進到目標數處
idx2 += target - 1;
//直接取中位數
if (len % 2 == 0)
{
//偶數
//左中位數為當前值
left = nums2[idx2];
//右中位數為下一個值
right = nums2[idx2 + 1];
}
else
{
//奇數,左右中位數相同都是當前值
left = right = nums2[idx2];
}
break;
}
//陣列2沒資料,則直接在陣列1中獲取中位數
if (idx2 >= nums2.Length)
{
//因為陣列2沒有資料了,所以陣列1索引前進到目標數處
idx1 += target - 1;
//直接取中位數
if (len % 2 == 0)
{
//偶數
//左中位數為當前值
left = nums1[idx1];
//右中位數為下一個值
right = nums1[idx1 + 1];
}
else
{
//奇數,左右中位數相同都是當前值
left = right = nums1[idx1];
}
break;
}
//當目標數為1時,表明當前值就是要找的值
if (target == 1)
{
//直接取中位數
if (len % 2 == 0)
{
//偶數
if (nums1[idx1] < nums2[idx2])
{
//如果nums1當前值比較小,則檢視其之後是否還有元素
if (idx1 + 1 > nums1.Length - 1)
{
//如果其之後沒有元素,則左中位數為nums1當前值,右中位數為nums2當前值
left = nums1[idx1];
right = nums2[idx2];
}
else
{
//如果其之後有元素,則左中位數為nums1當前值,右中位數則為nums1當前值後一個值和nums2當前值中較小值
var temp = nums1[idx1 + 1];
left = nums1[idx1];
right = Math.Min(nums2[idx2], temp);
}
}
else
{
//如果nums2當前值比較小,則檢視其之後是否還有元素
if (idx2 + 1 > nums2.Length - 1)
{
//如果其之後沒有元素,則左中位數為nums2當前值,右中位數為nums1當前值
left = nums2[idx2];
right = nums1[idx1];
}
else
{
//如果其之後有元素,則左中位數為nums2當前值,右中位數則為nums2當前值後一個值和nums1當前值中較小值
var temp = nums2[idx2 + 1];
left = nums2[idx2];
right = Math.Min(nums1[idx1], temp);
}
}
}
else
{
//奇數,左右中位數相同,取nums1當前值和nums2當前值中較小值
left = right = Math.Min(nums1[idx1], nums2[idx2]);
}
break;
}
//取出目標數位置的一半
var half = target / 2;
//確定nums1比較數,並確保其不會大於自身長度
var compare1 = Math.Min(idx1 + half, nums1.Length);
//確定nums2用比較數,並確保其不會大於自身長度
var compare2 = Math.Min(idx2 + half, nums2.Length);
//比較兩個陣列比較數,compare-1因為比較數表示第幾個數,減1轉為索引
if (nums1[compare1 - 1] < nums2[compare2 - 1])
{
//nums1的比較數 小,則排除掉
//要查詢的目標數需要減掉已經排除掉的個數
target -= compare1 - idx1;
//同時nums1當前索引前進到被排除掉元素的後一位
idx1 = compare1;
}
else
{
//nums2的比較數 小,則排除掉
//要查詢的目標數需要減掉已經排除掉的個數
target -= compare2 - idx2;
//同時nums2當前索引前進到被排除掉元素的後一位
idx2 = compare2;
}
}
return (left + right) / 2.0;
}
雖然上面程式碼還很符合我們的思維理解方式的,但是有兩個問題需要好好思考:
1.為什麼當兩個陣列值比較時值較小的數及其之前的數可以直接被排除掉?
2.三處結束處理標記程式碼塊中都包含了對奇偶不同情況的處理,使得程式碼冗長複雜,有什麼更好的處理方式嗎?
解法五、雙指標+二分查詢第K小數法
在這個解法裡,我們來解答上面的兩個問題。
其實上一節解法中已經包含了二分查詢第K小數的核心程式碼,因為我們在上一解法中最核心的就是先找到左中位數,所以只要把獲取右中位數處理及奇偶判斷相關程式碼刪掉,其實就得到一份二分查詢第K小數演算法了,然後分別把左第K小數和右第K小數分別帶入演算法得到左中位數和右中位數,最後平均得到中位數。
具體程式碼如下:
//雙指標二分查詢第K小數法
public static double TwoPointerBinaryFindKth(int[] nums1, int[] nums2)
{
//左第K小數,當為偶數,代表左中位數,當為奇數,代表中位數
var leftKth = (nums1.Length + nums2.Length + 1) / 2;
//右第K小數,當為偶數,代表右中位數,當為奇數,代表中位數(因為/向下取整特性)
var rightKth = (nums1.Length + nums2.Length + 2) / 2;
//獲取左中位數
var left = TwoPointerBinaryFindKth(leftKth, nums1, nums2);
//獲取右中位數
var rigth = TwoPointerBinaryFindKth(rightKth, nums1, nums2);
return (left + rigth) / 2.0;
}
//雙指標二分查詢第K小數
public static int TwoPointerBinaryFindKth(int kth, int[] nums1, int[] nums2)
{
//陣列1索引
var idx1 = 0;
//陣列2索引
var idx2 = 0;
//找到第K小數則退出
while (true)
{
//陣列1沒資料了,則直接在陣列2中查詢K
if (idx1 >= nums1.Length)
{
//因為陣列1沒有資料了,所以陣列2索引前進到K數索引處
idx2 += kth - 1;
return nums2[idx2];
}
//陣列2沒資料,則直接在陣列1中查詢K
if (idx2 >= nums2.Length)
{
//因為陣列2沒有資料了,所以陣列1索引前進到K數索引處
idx1 += kth - 1;
return nums1[idx1];
}
//當第K小數為1時,表明當前值就是要找的值
if (kth == 1)
{
return Math.Min(nums1[idx1], nums2[idx2]);
}
//取出第K小數位置的一半
var half = kth / 2;
//確定nums1比較數,並確保其不會大於自身長度
var compare1 = Math.Min(idx1 + half, nums1.Length);
//確定nums2用比較數,並確保其不會大於自身長度
var compare2 = Math.Min(idx2 + half, nums2.Length);
//比較兩個陣列比較數,compare-1因為比較數表示第幾個數,減1轉為索引
if (nums1[compare1 - 1] < nums2[compare2 - 1])
{
//nums1的比較數 小,則排除掉
//要查詢的第K小數需要減掉已經排除掉的個數
kth -= compare1 - idx1;
//同時nums1當前索引前進到被排除掉元素的後一位
idx1 = compare1;
}
else
{
//nums2的比較數 小,則排除掉
//要查詢的第K小數需要減掉已經排除掉的個數
kth -= compare2 - idx2;
//同時nums2當前索引前進到被排除掉元素的後一位
idx2 = compare2;
}
}
}
透過程式碼可以發現這個解法已經解決了上一節中的第二個問題,透過對奇偶兩種情況左右中位數是第幾位數的表示進行了統一,解決了需要判斷奇偶做不同處理邏輯的問題,同時因為只關心一個數K,所以整個程式碼就簡潔清晰了。
對比也能發現兩個解法最核心的二分思想部分程式碼完全一樣,差別就是一次處理一個值和一次處理兩個值的差別。
這裡面也有問題和上一個解法第一個問題一樣,為什麼當兩個陣列值比較時值較小的數及其之前的數可以直接被排除掉?
下面我們就來詳細講解一下這裡可以直接排除掉的原理。
先梳理一下思路,為什麼可以直接排除?如果我能證明每次排除的數都是再第K小的左邊那邊排除不就成立了嘛,因此上面的問題可以轉化為證明每次排除掉的數都在第K小數的左邊。而我們一直再從左邊排除數,因此左邊一直再變化著,但是右邊的數一直沒有變,因此我們再把問題轉換一下,我們來證明被排除數右邊的數量大於等於(n-k+1)個數。
下面我們以下面兩個陣列第一次排除為例,做個詳細證明,其他次排除同理。
nums1:[1,4,8,9,11]
nums2:[2,3,5,6,7,10]
因為兩陣列總個數為11,所以我們找第6小數,即k為6,因此第一次從第6/2=3個數開始比較,即比較num1[idx2]與num2[idx2]。如下圖。
因為num1[idx2] > num2[idx2],所以[2,3,5]需要排除掉,因此要證明紅框中的個數一定是大於等於(n-k+1=11-6+1=6)。
首先看陣列nums1因為8>5所以8肯定是被留下的,因此nums1至少有(len1-idx2)個數是大於5的,再看陣列num2因為5要被排除,所以num2有(len2-(idx2+1))個數大於5。
因此大於5的數至少有[(len1-idx2)+(len2-(idx2+1))]
又因為idx2=k/2
所以大於5的數至少有[(len1-k/2)+(len2-(k/2+1))]
化簡可得[len1+len2-k+1]
即[n-k+1]
同理如果陣列為偶數也可證明,因此為能證明每次排的數右側都剩下大於等於(n-k+1)個數,因此就證明排除的數肯定是再第K小數的左邊可以直接排除,並且從上圖可以發現排除的數並不一定是順序的,但是這不重要,只有在第K小數左邊就可以刪除。
解法六、雙指標+二分查詢平分法
上面兩種二分解法已經滿足題目要求了,但是這一題還有更優的解法,我們繼續來探索。
首先思考一下中位數有什麼特性,我們是否能用中位數自身的特性來解題呢?
中位數又叫中值,就是按順序排列的一組資料中居於中間位置的值,由此我們可以得出兩個特性:
1.以中位數為分界線,則中位數左右兩邊資料個數相等;
2.以中位數為分界線,則中位數左邊的最大數必然小於等於中位數右邊的最小數。
那要如何應用這兩個特性來解題呢?
如上圖,就是如果我們找到一條線來分割兩個陣列,同時以分割線為準,分割線左邊部分看作一個整體當作中位數左邊,分割線右邊部分看作一個整體當作中位數右邊,如果左右兩部分能滿足上面兩個特性就意味著這題就解決了,題目就轉變成求解這條分割線了。
顯然上圖是不可能滿足的因為陣列總算是奇數,因此無論怎麼分割左邊和右邊數都不可能相當的,除非把分割線畫在一個數上,把一個數一分兩半,顯然不可能,這就沒法滿足第一個特性。
遇到問題就解決問題,既然對於奇數問題,不能使左右兩邊相等,那麼我們就使得一邊總比另一邊多一個數總可以了吧,因為有序特性,因此這樣的改變並不會影響第二個特性,也就相當於滿足了兩個特性。
到這裡解題的核心思想框架就有了:找到一條分割兩個陣列的線,使得其能直接或間接滿足中位數的兩個特性。
核心思想框架有了,接下來就要填充關鍵邏輯處理點了。
第一個問題:從哪裡開始處理?
當然我們還需要繼續採用二分思想,因此從陣列1中間位置開始處理。
第二個問題:既然從陣列1中間開始位置處理,那麼陣列2怎麼辦?怎麼取值?
並且當陣列1當前索引idx1確定後陣列2當前索引idx2就可以透過idx1計算得到。
因為當陣列總長為偶數時,左右兩邊個數相等,所以可以得到
idx1+idx2=len1-idx1+len2-idx2,即idx2=(len1+len2)/2 – i;
當陣列總長為計算時,我們設定左邊比右邊多一個數,可以得到
idx1+idx2=len1-idx1+len2-idx2+1,即idx2=(len1+len2+1)/2 – i;
第三個問題:既然分割線是要找出來的那,又是從陣列1中間位置開始處理,那應該如何確定往那個方向找呢?
可以透過分割後左邊最大值和右邊最小值比較確定往那個方向找,就是要做到前進的方向可以把較小值分到左邊,較大值分到右邊。
第四個問題:處理的節奏是什麼?
第一個問題也說了,因為我們還是繼續採用二分思想,所以每次處理都會取上次的一半。
第五個問題:還有什麼要注意的點嗎?
剩下的就是對特殊邊界的處理問題了,比如分割到陣列的兩端,以及陣列總數奇偶性單獨處理的部分。
到這裡整個解題思路就梳理完成了,主要梳理了核心思想框架以及關鍵邏輯處理點,並沒有具體去說有哪些特色邊界問題、如何判斷前進方向等,因為我感覺核心思想框架+關鍵邏輯處理點才是重中之重,然後我們再輔助一些圖例,最後再看原始碼,基本就是一看就明白了。
下面以圖例來看看兩個陣列應用二分查詢平分法的完整查詢過程。
具體程式碼如下:
//雙指標二分查詢平分法
public static double TwoPointerBinaryHalves(int[] nums1, int[] nums2)
{
//當陣列1長度比陣列2長度大,則交換兩個陣列,保證陣列1為較短的陣列
if (nums1.Length > nums2.Length)
{
//交換兩個變數的值,C#7.0中 元組解構賦值 語法
(nums1, nums2) = (nums2, nums1);
}
//左指標
int idxLeft = 0;
//右指標
int idxRight = nums1.Length;
//對陣列1進行二分查詢
while (idxLeft <= idxRight)
{
//計算得到陣列1分割後的當前索引
var idx1 = (idxLeft + idxRight) / 2;
//計算得到陣列2分割後的當前索引
var idx2 = ((nums1.Length + nums2.Length + 1) / 2) - idx1;
//當陣列2左邊最大值大於陣列1右邊最小值時,左指標向右前進
if (idx2 != 0 && idx1 != nums1.Length && nums2[idx2 - 1] > nums1[idx1])
{
idxLeft = idx1 + 1;
}
//當陣列1左邊最大值大於陣列2右邊最小值時,右指標向左前進
else if (idx1 != 0 && idx2 != nums2.Length && nums1[idx1 - 1] > nums2[idx2])
{
idxRight = idx1 - 1;
}
else
{
//左邊最大值
int leftMax;
//如果分割到陣列1的最左邊即陣列1當前索引為0,則左邊最大值直接取陣列2
if (idx1 == 0)
{
leftMax = nums2[idx2 - 1];
}
//如果分隔到陣列2的最左邊即陣列2當前索引為0,則左邊最大值直接取陣列1
else if (idx2 == 0)
{
leftMax = nums1[idx1 - 1];
}
//否則左邊最大值為陣列1和陣列2中左邊較大的值
else
{
leftMax = Math.Max(nums1[idx1 - 1], nums2[idx2 - 1]);
}
//如果陣列為計算,則直接返回左邊最大值,結束計算
if ((nums1.Length + nums2.Length) % 2 == 1)
{
return leftMax;
}
//右邊最小值
int rightMin;
//如果分隔到陣列最右邊即陣列1索引為其自身長度,則右邊最小值直接取陣列2
if (idx1 == nums1.Length)
{
rightMin = nums2[idx2];
}
//如果分隔到陣列最右邊即陣列2索引為其自身長度,則右邊最小值直接取陣列1
else if (idx2 == nums2.Length)
{
rightMin = nums1[idx1];
}
//否則右邊最小值為陣列1和陣列2中右邊較小的值
else
{
rightMin = Math.Min(nums2[idx2], nums1[idx1]);
}
return (leftMax + rightMin) / 2.0;
}
}
return 0.0;
}
基準測試
最後我們來對6種解法進行一組基準對比測試,每個方法分三組測試,三組分別表示陣列大小為100,1000,10000,並且生成的兩個陣列中會隨機控制為99或100、999或1000、9999或10000,即使得奇偶隨機,同時對每一組隨機生成10000組測試資料,進行對比測試。測試結果如下。
透過測試可以發現效能如下:
解法六雙指標+二分查詢平分法 > 解法四雙指標+二分排除法 > 解法五雙指標+二分查詢第K小數法 > 解法三雙指標+直接取值法 > 解法二雙指標+合併陣列法 > 解法一娛樂法(內建方法),二分查詢第K小數法比二分排除法要差一些也正常,畢竟它呼叫了兩次演算法。
注:測試方法程式碼以及示例原始碼都已經上傳至程式碼庫,有興趣的可以看看。https://gitee.com/hugogoos/Planner