c++如何输入数组_从一个数组中找出 N 个数,其和为 M 的所有可能最 nice 的解法...
編者按:本文由前端狂想錄公眾號授權奇舞周刊轉載。
故事的背景
這是一個呆萌炫酷吊炸天的前端算法題,曾經乃至現在也是叱咤風云在各個面試場景中。
可以這樣說,有 90% 以上的前端工程師不會做這個題目。
這道題涉及的知識點很多,雖然網上也有相關的解答文章,但是在算法小分隊的討論和分析中,一致認為網上的文章太舊了,而且最多就是貼貼代碼,寫寫注釋,并沒有具體的分析。
暴風雨前的寧靜
我們看一下題目:
從一個數組中找出 N 個數,其和為 M 的所有可能。
大家可以中斷 5 分鐘想一下,不管想什么,反正先看著題目想 5 分鐘。
想完了嗎?
是不是感受到了一股殺氣,好像知道些,但是又無從下手的那種勝利的感覺,嗯...?
機智(雞賊)的我,選擇先把題目留在這兒,我們先去探討一下算法。
為什么算法就可以無法無天?
關于上面這個無解的問題,我們默默不說話。現在,我們來思考幾個簡單點的哲學問題:
什么是算法?
算法的作用和目的是什么?
如何設計我們的算法?
算法的復雜度該如何表示?
如何測試和優化我們的算法?
嗯?我怎么感覺死循環了呢?無限遞歸且沒有終止條件?
什么是算法
《算法導論》一書將算法( algorithm )描述為定義良好的計算過程,它取一個或一組值作為輸入,并產生一個或一組值作為輸出。
計算的組成
計算機主要的單元包括:I/O 、CPU 、內存等,這里我們要介紹的是CPU。
CPU 負責的功能主要就是解釋和執行程序,它能認識的就是一堆 0 和 1 組成的機器碼。
我們敲下的每一行代碼最終都被編譯成機器碼,然后將機器碼交給 CPU 執行。
想一想上面的話,我們可以知道:
我們所知的程序,其實就是指令和數據的集合,而算法的本質就是執行設計好的指令集,從輸入到產生輸出的過程。
算法是抽象的概念,但越是抽象的東西,其越具有清晰的特征。特征如下:
確定性: 算法的每一個步驟都是明確的、可行的、結果可預期的
有窮性: 算法要有一個終止條件
輸入和輸出: 算法是用來解決問題的,少不了輸入和輸出
大多數人只看見了樹,卻未見森林
看到這,你可能有疑問,為什么要這樣說呢?且聽我娓娓道來:
列舉一些大家可能知道的一些詞吧:
遞歸法、貪婪法、分治法、動態規劃法、線性規劃法、搜索和枚舉法(包括窮盡枚舉)、極大極小值法、 Alpha-beta 、剪枝等等。
看到上面這些稀罕詞,很多人認為這就是算法了,但其實這只是算法設計范式中,一些前人總結出來的模式而已。
我們可以將這種算法層面的模式和平常我們說的設計模式進行對比。
對比后,會發現,這就是一種算法抽象的最佳實踐范式。其實我們寫的任何一個代碼片段(包含輸入和輸出),都可以認為是算法的子集,甚至程序也只是算法的一種存在形式而已。
之前看到有人把程序比做水流,從源頭順流而下(順序執行),也可以分流而下(分支、并發),還可以起個漩渦(遞歸),其實這些也都只是算法中具體的實現或者組織方式而已。
算法的領域極其廣闊,不要把思維僅僅局限在計算機領域中。
對于計算機行業人員來說,算法就是內功。就好比乾坤大挪移,學成之后,天下武功皆能快速掌握。
算法設計
這一塊兒其實是很龐大的知識體系,需要苦練內功根基。下面簡要介紹下算法設計方面的知識。
順序執行、循環和分支跳轉是程序設計的三大基本結構。
算法也是程序,千姿百態的算法也是由這三大基礎結構構成的。
算法和數據結構關系緊密,數據結構是算法設計的基礎。
如果對諸如哈希表、隊列、樹、圖等數據結構有深刻的認識,那在算法設計上將會事半功倍。
上面提到的知識,主要的目的是拋磚引玉。算法的設計與分析是無上神功的心法口訣和入門要領。無論多么精妙絕倫的算法實現,都是由一些最基礎的模型和范式組裝起來的。
關于算法設計,這里給大家推薦一門課程,很不錯,小伙伴可以看看:
算法設計與分析-Design and Analysis of Algorithms(https://zh.coursera.org/learn/algorithms)
TIPS: 小伙伴如果有好的資源,也可以留言 mark 哦。
最?NICE?的解法
降維分析,化繁為簡
現在,到了最關鍵的時刻。我們回到題目中,開始設計我們的算法。
題干信息很簡單,核心問題在于:
如何從數組中選取?N?個數進行求和運算。
如何做,這里我們通常且正確的做法,是對問題進行降維分析,并且化繁為簡。
下面開始降維分析,化繁為簡:
假如 N = 2 ,也就是找出數組中兩個數的和為 M 的話,你會怎么做?可能你會想到每次從數組中彈出一個數,然后與余下的每個數進行相加,最后做判斷。
那么問題來了,當 N = 3 呢,N = 10 呢,會發現運算量越來越大,之前的方式已經不可行了。
不妨換一種思路:
數組中選取不固定數值 N ,我們可以嘗試著使用標記的方式,我們把 1 表示成選取狀態, 把 0 表示成未選取狀態。
假設數組 const arr=[1,2,3,4] ,對應著每個元素都有標記 0 或者 1 。如果 N=4 ,也就是在這個數組中,需要選擇 4 個元素,那么對應的標記就只有一種可能 1111 ,如果 N=3 ,那就有 4 種可能,分別是 1110 、 1101 、1011 以及 0111 (也就是 C4取3->4 ) 種可能。
開始抽象
通過上面的層層敘述,我們現在的問題可以抽象為:
標記中有幾個 1 就是代表選取了幾個數,然后再去遍歷這些 1 所有可能存在的排列方式,最后做一個判斷,這個判斷就是:每一種排列方式,都代表著數組中不同位置的被選中的數的組合,所以這里就是將選中的這些數字,進行求和運算,然后判斷求出的和是不是等于 M 。
于是,問題開始變得簡單了。
如何將數組和標記關聯
0101?這樣的數據一眼望上去第一反應就是二進制啊
對于 arr 來說,有 4 個元素,對應的選擇方式就是從 0000( N = 0 )到 1111( N = 4 )的所有可能。
而 1111 就是 15 的二進制,也就是說這所有的可能其實對應的就是 0 - 15 中所有數對應的二進制。
這里的問題最終變成了如何從數組長度?4?推導出?0 - 15
這里采用了位運算--左移運算, 1 << 4 的結果是 16 。
所以我們可以建立這樣一個迭代:
const?arr?=?[1,?2,?3,?4]
let?len?=?arr.length,?bit?=?1?<<?len
// 這里忽略了 0 的情況(N = 0),取值就是 1 - 15
for(let?i?=?1;?i?<?bit;?i++)?{
??// ...
}
如何從?1110?標記中取出?1?的個數
最簡單的方式:
const?n?=?num?=>?num.toString(2).replace(/0/g,?'').length
這其實也是一道算法常考題,因為位運算是不需要編譯的,肯定速度最快。
PS: 如果不理解位運算為何會提高性能的同學,可以自行搜索一下位運算。簡單點說就是:位運算直接用二進制進行表示,省去了中間過程的各種復雜轉換,提高了速度。
我們嘗試使用?&?運算來解決這個問題
首先我們肯定知道 1 & 1 = 1; 1 & 0 = 0 這些結論的。所以我們從 15 & 14 => 14 可以推導出 1111 & 1110 => 1110 ,為什么可以這樣推導呢,因為 15 的二進制就是 1111 ,14 同理。
我們可以看到,通過上面的操作消掉了最后的 1。
所以我們可以建立一個迭代,通過統計消除的次數,就能確定最終有幾個 1 了。
代碼如下:
const?n?=?num?=>?{
??let?count?=?0
??while(num)?{
????num?&=?(num?-?1)
????count++
??}
??return?count
}
計算和等于?M
現在我們已經可以把所有的選取可能轉變為遍歷一個數組,然后通過迭代數組中的每個數對應的二進制,有幾個 1 來確定選取元素的個數。
那么,現在需要的最后一層判斷就是選取的這些數字和必須等于?M
這里其實就是建立一個映射:
1110 到 [1, 2, 3, 4] 的映射,就代表選取了 2, 3, 4,然后判斷 2 + 3 + 4 與 M 。
這里可以這樣看:1110 中的左邊第一個 1 對應著數組 [1, 2, 3, 4] 中的 4 。
現在有一個問題,該如何建立這個映射關系呢?
我們知道前者 1110 其實就是對應的外層遍歷中的 i = 14 的情況。
再看看數組[1, 2, 3, 4] ,我們可以將元素及其位置分別映射為 1000 0100 0010 0001。
實現方式也是通過位運算--左位移來實現:
1 << inx ,inx 為數組的下標。
位掩碼介紹
對 位掩碼 不熟悉的童鞋會有點暈,這里簡單科普下:
實質上,這里的 1 << j ,是指使用 1 的移位來生成其中僅設置第 j 位的位掩碼。
比如:14 的二進制表示為 1110,其代表(從右往左)選取了第 2 , 3 , 4 位。
那么(下面故意寫成上下對應的方式):
// demo1
??1110
&
??0001
=
??0000
// demo2
??1110
&
??0010
=
??0010
PS: 通過上面代碼,我們可以看到上下對應的 0 和 1 在進行 & 運算以后,得出的結果和在 js 中進行相同條件下 & 運算的結果相同。
所以:
1?<<?0?// 1 -> 0001
1?<<?1?// 2 -> 0010
1?<<?2?// 4 -> 0100
1?<<?3?// 8 -> 1000
// 說白了,就是把左邊的值變成二進制形式,然后左移或者右移,超出補0
// 所以, 1110 對應著 第一位沒有選取,那么 1110 & 0001(設置為第一位的位掩碼) = 0,如果 i & (1 << inx) !== 0 代表該位被選取了
for(let?j?=?0;?j?<?arr.length;?j++){
??if((i?&?(1?<<?j)?!==?0)?{
????// 代表這個數被選取了,我們做累加求和就行
??}
}
所以綜上所述,最終代碼實現如下:
// 參數依次為目標數組、選取元素數目、目標和
const?search?=?(arr,?count,?sum)?=>?{
??// 計算某選擇情況下有幾個 `1`,也就是選擇元素的個數
??const?n?=?num?=>?{
????let?count?=?0
????while(num)?{
??????num?&=?(num?-?1)
??????count++
????}
????return?count
??}
??let?len?=?arr.length,?bit?=?1?<<?len,?res?=?[]
??// 遍歷所有的選擇情況
??for(let?i?=?1;?i?<?bit;?i++){
????// 滿足選擇的元素個數 === count
????if(n(i)?===?count){
??????let?s?=?0,?temp?=?[]
??????// 每一種滿足個數為 N 的選擇情況下,繼續判斷是否滿足 和為 M
??????for(let?j?=?0;?j?<?len;?j++){
????????// 建立映射,找出選擇位上的元素
????????if((i?&?1?<<?j)?!==?0)?{
??????????s?+=?arr[j]
??????????temp.push(arr[j])
????????}
??????}
??????// 如果這種選擇情況滿足和為 M
??????if(s?===?sum)?{
????????res.push(temp)
??????}
????}
??}
??return?res
}
如何測試
這其實也是可以單獨寫一篇文章的知識點。
測試的種類、方式多種多樣,我們將自己想象成一個 TroubleMaker ,各種為難自己寫的算法,然后不斷優化自己的代碼,這個過程也是有趣極了。
首先二話不說,照著心里一開始就想的最簡單的方式擼一個測試用例。
代碼如下:
// 寫一個很大的數組進行測試
const?arr?=?Array.from({length:?10000000},?(item,?index)?=>?index)
// 測試不同選取容量
const?mocks?=?sum?=>?[3,?300,?3000,?30000,?300000,?3000000].map(item?=>?({count:?item,?sum?}))
let?res?=?[]
mocks(3000).forEach((count,?sum)?=>?{
??const?start?=?window.performance.now()
??search(arr,?count,?sum)
??const?end?=?window.performance.now()
??res.push(end?-?start)
})
然后結果如下圖:
發現造了一個長度為 1000 萬的數組,找 6 個數,居然只要 0.014 秒。什么鬼,這么快的么,開掛了吧。不行,感覺這不靠譜,還是從長計議,寫一個專業的測試案例比較好,請繼續往下看。
我們主要從兩個方向下手:
第一個方向:全方位攻擊
其實就是摳腦袋想出一萬種情況去折騰自己的代碼,也就是所謂的 地毯式測試案例轟炸。
完整代碼如下:
// 比如針對上面的算法,可以這樣寫
export?const?assert?=?(desc,?condition)?=>?{
??condition?=?typeof?condition?===?"function"???condition()?:?condition;
??console.info(`[Test] %c${desc}`,?"color: orange");
??if?(!condition)?{
????throw?new?Error(`[Error] %c${desc}?failed.`,?"color: pink");
??}
??console.info(`[Success] %c${desc}?success.`,?"color: #198");
};
const?mock_a?=?Array.from({?length:?4?},?(item,?index)?=>?index);
const?mock_b?=?Array.from({?length:?6?},?(item,?index)?=>?index?-?3);
const?mock_c?=?Array.from({?length:?4?},?()?=>?0);
assert(`正整數情況測試`,?()?=>?{
??const?res?=?search(mock_a,?2,?3);
??const?lengthTest?=?res.length?===?2;
??const?resTest?=?JSON.stringify(res)?===?JSON.stringify([[1,?2],?[0,?3]]);
??return?lengthTest?&&?resTest;
});
assert(`負數情況測試`,?()?=>?{
??const?res?=?search(mock_b,?2,?0);
??const?lengthTest?=?res.length?===?2;
??const?resTest?=?JSON.stringify(res)?===?JSON.stringify([[-1,?1],?[-2,?2]]);
??return?lengthTest?&&?resTest;
});
// ...
codesandbox 完整代碼地址(https://codesandbox.io/s/38pn0jvmr1)
在 codesandbox 的運行控制臺下,可以看到測試結果如下圖所示:
會發現,這里把很多測試場景都覆蓋了,這樣的好處就是讓自己的代碼能夠更魯棒、更安全。
當然,上面的 case 還可以再極端點,從而可以驅使代碼變得更加魯棒。
極端的場景帶來的優化如下:
第一個極端:增加小數的情況。因為精度誤差的問題,這里就可以將代碼繼續優化,以支持小數情況。
第二個極端:增加公差參數的情況。不用絕對相等來取數,可以通過公差來增加靈活性。
第二個方向:性能測試和持續優化
關注的第二個點在于性能測試,為什么呢?
如果要將自己的算法付諸生產,除需要穩定的功能表現之外,還要有足夠讓人信服的性能。畢竟,性能高的代碼是我們普遍的追求。
這里分享一下有用的技巧:
通過 window.performance.now() ,或者 console.time() ,我們可以簡單快捷的獲取到程序執行的時間。
在寫這樣的測試案例的時候,有幾個原則,分別是:
只關注自己的測試內容。
保持干凈
考慮極端情況(比如數組很大很大)
將多次測試結果進行對比分析
當然你也可以使用類似 jsperf 之類的工具,然后一點一點的去扣這些優化點,來持續打磨自己的代碼。
這里提一下,雖然通過測試數據的反饋,可以調整你的算法。但是不要為了性能盲目的進行優化,性能優化其實就是找一個平衡點,原則是足夠用就好。
具體怎么做呢?對于上面的算法,我們如果采用空間換時間的優化方式,可以在計算 1 的個數的 n 函數上做一些優化 ,比如加個緩存。
然后對比性能,如下圖所示::
完整的測試案例地址(https://jsperf.com/acm-perf/1)
測試性能對比
說到測試性能對比,這里主要收集一些實現方式,比如基于 jsperf 做一些對比。
如果有其他的實現方式,歡迎小伙伴留言補充。
jsperf 測試性能結果,如下圖所示::
完整的測試案例地址(https://jsperf.com/acm-r/1)
總結
這道題很有難度,用到的知識也很多,認真想一想,一定能有很多收獲的,這道算題的解決,大致用到了以下這些知識:
二進制以及位運算的知識
剪枝的思想
如何全方位、地毯式、走極端、多方面的對一個算法進行性能測試
算法思想,算法設計相關的簡要知識
如何將極其抽象的問題進行降維分析,化繁為簡
番外篇--復雜度
算法復雜度
這涉及到大 O 判斷法。(算法業內一般不叫大 O ,叫 big O 連起來讀,比如 B溝N )
這部分內容大家自行維基搜索吧,很詳細的。下面我們提一下,離我們工作很近的一種復雜度的計算法則。
如何判斷業務代碼的復雜度
對前端來說,大 O 判斷法不能太適用,其實還有一種很有趣的判斷復雜度的方法,它叫做 Tom McCabe 方法。
該方法很簡單,通過計算函數中 決策點 的數量來衡量復雜度。下面是一種計算決策點(結合前端)的方法:
從 1 開始,一直往下通過函數
一旦遇到 if while for else 或者帶有循環的高階函數,比如 forEach map 等就加 1
給 case 語句中的每一種情況都加 1
比如下面代碼:
function?fun(arr,?n)?{
??let?len?=?arr.length
??for?(let?i?=?0;?i?<?len;?i++)?{
????if?(arr[i]?==?n)?{
????????// todo...
????}?else?{
????????// todo...
????}
??}
}
按照 Tom McCabe 方法來計算復雜度,那么這個 fun 函數的決策點數量是 3 。知道決策點數量后,怎么知道其度量結果呢?這里有一個數值區間判斷:
| 0-5 | 這個函數可能還不錯 |
| 6-10 | 得想辦法簡化這個函數了 |
| 10+ | 把這個函數的某一部分拆分成另一個函數并調用他 |
從上面的判斷依據可以看出,我上面寫的代碼,數量是 3 ,可以稱為函數還不錯。我個人覺得這個判斷方法還是很科學和簡單的,可以作為平時寫代碼時判斷函數需不需要重構的一個考慮點,畢竟一個函數,一眼掃去,決策點數量大致就知道是多少了,很好計算。
感謝付出的小伙伴
非常感謝前端算法小分隊的成員一起努力攻克了這個題目。
特此感謝:
本文第一作者:小金剛(大佬)
github 地址:https://github.com/cbbfcd
掘金地址:https://juejin.im/user/593df367128fe1006aecb3cf
文章排版和內容深度完善:源碼終結者
github 地址:https://github.com/godkun
參與審校和核對:Ninja
github 地址:https://github.com/jiameng123
其他小伙伴的貢獻:前端狂想錄算法小分隊整體成員
編者:此外還有一篇《改進,從一個數組中找出 N 個數,其和為 M 的所有可能》(https://juejin.im/post/5c81fee66fb9a049b82b4128),采用的是二進制正序表示法,各位同學可以對照看一下。
關于奇舞周刊
《奇舞周刊》是360公司專業前端團隊「奇舞團」運營的前端技術社區。關注公眾號后,直接發送鏈接到后臺即可給我們投稿。
總結
以上是生活随笔為你收集整理的c++如何输入数组_从一个数组中找出 N 个数,其和为 M 的所有可能最 nice 的解法...的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 航空工业集团:运五 B 灭火型取证机机身
- 下一篇: 增幅不超过 20%,消息称苹果 A17