瀑布流佈局是前端領域中一個很常見的需求,因爲圖片的高度是不一致的,因此在多列布局中默認佈局下很難得到滿意的排列。javascript
咱們的需求是,圖片高度不規律的狀況下,在兩列布局中,讓左右兩側的圖片總高度儘量的接近,這樣的佈局會很是的美觀。html
注意,本文的目的僅僅是討論算法在前端中能如何運用,而不是說瀑布流的最佳解法是動態規劃,能夠僅僅當作學習拓展來看。前端
本文的圖片節選自知乎問題《有個漂亮女友是種怎樣的體驗?》,我先去看美女了,本文到此結束。(逃java
從預覽圖中能夠看出,雖然圖片的高度是不定的,可是到了這個佈局的最底部,左右兩張圖片是正好對齊的,這就是一個比較美觀的佈局了。git
那麼怎麼實現這個需求呢?從頭開始拆解,如今咱們能拿到一組圖片數組 [img1, img2, img3]
,咱們能夠經過一些方法獲得它對應的高度 [1000, 2000, 3000]
,那麼如今咱們的目標就是可以計算出左右兩列 left: [1000, 2000]
和 right: [3000]
這樣就能夠把一個左右等高的佈局給渲染出來了。github
首先準備好小姐姐數組 SISTERS
:面試
let SISTERS = [ 'https://pic3.zhimg.com/v2-89735fee10045d51693f1f74369aaa46_r.jpg', 'https://pic1.zhimg.com/v2-ca51a8ce18f507b2502c4d495a217fa0_r.jpg', 'https://pic1.zhimg.com/v2-c90799771ed8469608f326698113e34c_r.jpg', 'https://pic1.zhimg.com/v2-8d3dd83f3a419964687a028de653f8d8_r.jpg', ... more 50 items ]
準備好一個工具方法 loadImages
,這個方法的目的就是把全部圖片預加載之後獲取對應的高度,放到一個數組裏返回。而且要對外通知全部圖片處理完成的時機,有點相似於 Promise.all
的思路。算法
這個方法裏,咱們把圖片按照 寬高比
和屏幕寬度的一半進行相乘,獲得縮放後適配屏寬的圖片高度。數組
let loadImgHeights = (imgs) => { return new Promise((resolve, reject) => { const length = imgs.length const heights = [] let count = 0 const load = (index) => { let img = new Image() const checkIfFinished = () => { count++ if (count === length) { resolve(heights) } } img.onload = () => { const ratio = img.height / img.width const halfHeight = ratio * halfInnerWidth // 高度按屏幕一半的比例來計算 heights[index] = halfHeight checkIfFinished() } img.onerror = () => { heights[index] = 0 checkIfFinished() } img.src = imgs[index] } imgs.forEach((img, index) => load(index)) }) }
有了圖片高度之後,咱們就開始挑選適合這個需求的算法了。工具
在人的腦海中最直觀的想法是什麼樣的?在每裝一個圖片前都對比一下左右數組的高度和,往高度較小的那個數組裏去放入下一項。
這就是貪心算法,咱們來簡單實現下:
let greedy = (heights) => { let leftHeight = 0 let rightHeight = 0 let left = [] let right = [] heights.forEach((height, index) => { if (leftHeight >= rightHeight) { right.push(index) rightHeight += height } else { left.push(index) leftHeight += height } }) return { left, right } }
咱們獲得了 left
,right
數組,對應左右兩列渲染圖片的下標,而且咱們也有了每一個圖片的高度,那麼渲染到頁面上就很簡單了:
<div class="wrap" v-if="imgsLoaded"> <div class="half"> <img class="img" v-for="leftIndex in leftImgIndexes" :src="imgs[leftIndex]" :style="{ width: '100%', height: imgHeights[leftIndex] + 'px' }" /> </div> <div class="half"> <img class="img" v-for="rightIndex in rightImgIndexes" :src="imgs[rightIndex]" :style="{ width: '100%', height: imgHeights[rightIndex] + 'px' }" /> </div> </div>
效果如圖:
預覽地址:
https://sl1673495.github.io/d...
能夠看出,貪心算法只尋求局部最優解(只在考慮當前圖片的時候找到一個最優解),因此最後左右兩邊的高度差仍是相對較大的,局部最優解很難成爲全局最優解。
再回到文章開頭的圖片去看看,對於一樣的一個圖片數組,那個預覽圖裏的高度差很是的小,是怎麼作到的呢?
和局部最優解對應的是全局最優解,而說到全局最優解,咱們很難不想到動態規劃這種算法。它是求全局最優解的一個利器。
若是你尚未了解過動態規劃,建議你看一下海藍大佬的 一文搞懂動態規劃,也是這篇文章讓我入門了最基礎的動態規劃。
動態規劃中有一個很著名的問題:「01 揹包問題」,題目的意思是這樣的:
有 n 個物品,它們有各自的體積和價值,現有給定容量的揹包,如何讓揹包裏裝入的物品具備最大的價值總和?
關於 01 揹包問題的題解,網上不錯的教程彷佛很少,我推薦看慕課網 bobo 老師的玩轉算法面試 從真題到思惟全面提高算法思惟 中的第九章,會很仔細的講解揹包問題,對於算法思惟有很大的提高,這門課的其餘部分也很是很是的優秀。
我也有在我本身維護的題解倉庫中對老師的 01 揹包解法作了一個js 版的改寫。
那麼 01 揹包問題和這個瀑布流算法有什麼關係呢?這個思路確實比較難找,可是咱們仔細想一下,假設咱們有 [1, 2, 3]
這 3 個圖片高度的數組,咱們怎麼經過轉化成 01 揹包問題呢?
因爲咱們要湊到的是圖片總高度的一半,也就是 (1 + 2 + 3) / 2 = 3
,那麼咱們此時就有了一個 容量爲3
的揹包,而因爲咱們裝進左列中的圖片高度須要低於總高度的一半,待裝進揹包的物體的總重量和高度是相同的 [1, 2, 3]
。
那麼這個問題也就轉化爲了,在 容量爲3的揹包
中,儘量的從重量爲 [1, 2, 3]
,而且價值也爲 [1, 2, 3]
的物品中,儘量的挑選出總價值最大的物品集合裝進揹包中。
也就是 總高度爲3
,在 [1, 2, 3]
這幾種高度的圖片中,儘量挑出 總和最大,可是又小於3
的圖片集合,裝進數組中。
能夠分析出 狀態轉移方程 是
dp[heights][height] = max( // 選擇當前圖片放入列中 currentHeight + dp[heights - 1][height - currnetHeight], // 不選擇當前圖片 dp[heights - 1][height] )
注意這裏的縱座標命名爲 heights
,表明它的意義是「可選擇圖片的集合」,好比 dp[0]
意味着只考慮第一張圖片,dp[1]
則意味着既考慮第一張圖片又考慮第二張圖片,以此類推。
咱們構建的二維 dp 數組
縱座標 y 是:當前能夠考慮的圖片,好比 dp[0]
是隻考慮下標爲 0 的圖片,dp[1]
是考慮下標爲 0 的圖片,而且考慮下標爲 1 的圖片,以此類推,取值範圍是 0 ~ 圖片數組的長度 - 1
。
橫座標 x 是:用當前考慮的圖片集合,去儘量湊到總高度爲 y
時,所能湊成的最大高度 max
,以及當前所使用的圖片下標集合 indexes
,取值範圍是 0 ~ 高度的一半
。
就以 [1, 4, 5, 4]
這四張圖片高度爲例,高度的一半是 7,用肉眼能夠看出最接近 7 的子數組是[1, 5]
,咱們來看看動態規劃是怎麼求出這個結果的。
咱們先看縱座標爲 0
,也就是隻考慮圖片 1 的狀況:
1
:咱們知道圖片 1 的高度正好是 1,因此此時dp[0][0]
所填寫的值是 { max: 1, indexes: [0] }
,也就表明用總高度還剩 1,而且只考慮圖片 1 的狀況下,咱們的最優解是選用第一張圖片。2 ~ 7
:因爲當前只有 1 能夠選擇,因此最優解只能是選擇第一張圖片,它們都是 { max: 1, indexes: [0] }
。高度 1 2 3 4 5 6 7 圖片1(h=1) 1 1 1 1 1 1 1
這一層在動態規劃中叫作基礎狀態,它是最小的子問題,它不像後面的縱座標中要考慮多張圖片,而是隻考慮單張圖片,因此通常來講都會在一層循環中單獨把它求解出來。
這裏咱們還要考慮第一張圖片的高度大於咱們要求的總高度的狀況,這種狀況下須要把 max 置爲 0,選擇的圖片項也爲空。
let mid = Math.round(sum(heights) / 2) let dp = [] // 基礎狀態 只考慮第一個圖片的狀況 dp[0] = [] for (let cap = 0; cap <= mid; cap++) { dp[0][cap] = heights[0] > cap ? { max: 0, indexes: [] } : { max: heights[0], indexes: [0] } }
有了第一層的基礎狀態後,咱們就能夠開始考慮多張圖片的狀況了,如今來到了縱座標爲 1,也就是考慮圖片 1 和考慮圖片 2 時求最優解:
高度 1 2 3 4 5 6 7 圖片1(h=1) 1 1 1 1 1 1 1 圖片2(h=2)
此時問題就變的有些複雜了,在多張圖片的狀況下,咱們能夠有兩種選擇:
當前圖片的高度 + 前幾種圖片湊剩餘高度的最優解
爲 max1
。max2
。max1
和max2
,找出更大的那個值,記錄爲當前狀態下的最優解。有了這個前置知識,來繼續分解這個問題,在縱座標爲 1 的狀況下,咱們手上能夠選擇的圖片有圖片 1 和圖片 2:
dp[1][0]
能夠直接沿用 dp[0][0]
的最優解,也就是 { max: 1, indexes: [0] }
。湊高度 2:
dp[0]
中找到高度爲 2 時的最優解: dp[0][2]
,直接沿用下來,也就是 { max: 1, indexes: [0] }
{ max: 1, indexes: [0] }
3 ~ 4
的狀況,由於得出的結果和湊高度 2 是同樣的。湊高度 5:高度爲 5 的狀況下就比較有意思了:
dp[0]
中找高度爲 1 時的最優解dp[0][1]
,發現結果是 { max: 1, indexes: [0] }
,這兩個高度值 4 和 1 相加後沒有超出高度的限制,因此得出最優解:{ max: 5, indexes: [0, 1] }
dp[0][5]
,直接沿用下來,也就是 { max: 1, indexes: [0] }
dp[1][2]
的最優解選擇 { max: 5, indexes: [0, 1] }
仔細理解一下,相信你能夠看出動態規劃的過程,從最小的子問題 只考慮圖片1
出發,先求出最優解,而後再用子問題的最優解去推更大的問題 考慮圖片一、2
、考慮圖片一、二、3
的最優解。
畫一下[1,4,5,4]
問題的 dp 狀態表吧:
能夠看到,和咱們剛剛推論的結果一致,在考慮圖片 1 和圖片 2 的狀況下,湊高度爲 5,也就是dp[1][5]
的位置的最優解就是 5。
最右下角的 dp[3][7]
就是考慮全部圖片的狀況下,湊高度爲 7 時的全局最優解。
dp[3][7]
的推理過程是這樣的:
dp[2][3]
,獲得結果 4 + 1 = 5。dp[2][7]
,獲得結果 6。至此咱們就完成了整個動態規劃的過程,獲得了考慮全部圖片的狀況下,最大高度爲 7 時的最優解:6,所需的兩張圖片的下標爲 [0, 2]
,對應高度是 1
和 5
。
給出代碼:
// 儘量選出圖片中高度最接近圖片總高度一半的元素 let dpHalf = (heights) => { let mid = Math.round(sum(heights) / 2) let dp = [] // 基礎狀態 只考慮第一個圖片的狀況 dp[0] = [] for (let cap = 0; cap <= mid; cap++) { dp[0][cap] = heights[0] > cap ? { max: 0, indexes: [] } : { max: heights[0], indexes: [0] } } for ( let useHeightIndex = 1; useHeightIndex < heights.length; useHeightIndex++ ) { if (!dp[useHeightIndex]) { dp[useHeightIndex] = [] } for (let cap = 0; cap <= mid; cap++) { let usePrevHeightDp = dp[useHeightIndex - 1][cap] let usePrevHeightMax = usePrevHeightDp.max let currentHeight = heights[useHeightIndex] // 這裏有個小坑 剩餘高度必定要轉化爲整數 不然去dp數組裏取到的就是undefined了 let useThisHeightRestCap = Math.round(cap - heights[useHeightIndex]) let useThisHeightPrevDp = dp[useHeightIndex - 1][useThisHeightRestCap] let useThisHeightMax = useThisHeightPrevDp ? currentHeight + useThisHeightPrevDp.max : 0 // 是否把當前圖片歸入選擇 若是取當前的圖片大於不取當前圖片的高度 if (useThisHeightMax > usePrevHeightMax) { dp[useHeightIndex][cap] = { max: useThisHeightMax, indexes: useThisHeightPrevDp.indexes.concat(useHeightIndex), } } else { dp[useHeightIndex][cap] = { max: usePrevHeightMax, indexes: usePrevHeightDp.indexes, } } } } return dp[heights.length - 1][mid] }
有了一側的數組之後,咱們只須要在數組中找出另外一半,便可渲染到屏幕的兩列中:
this.leftImgIndexes = dpHalf(imgHeights).indexes this.rightImgIndexes = omitByIndexes(this.imgs, this.leftImgIndexes)
得出效果:
因爲縱軸的每一層的最優解都只須要參考上一層節點的最優解,所以能夠只保留兩行。經過判斷除 2 取餘來決定「上一行」的位置。此時空間複雜度是 O(n)。
因爲每次參考值都只須要取上一行和當前位置左邊位置的值(由於減去了當前高度後,剩餘高度的最優解必定在左邊),所以 dp 數組能夠只保留一行,把問題轉爲從右向左求解,而且在求解的過程當中不斷覆蓋當前的值,而不會影響下一次求解。此時空間複雜度是 O(n),可是其實佔用的空間進一步縮小了。
而且在這種狀況下對於時間複雜度也能夠作優化,因爲優化後,求當前高度的最優解是倒序遍歷的,那麼當發現求最優解的高度小於當前所考慮的那個圖片的的高度時,說明本次求解不可能考慮當前圖片了,此時左邊的高度的最優解必定是「上一行的最優解」。
算法思想在前端中的應用仍是能夠見到很多的,本文只是爲了演示動態規劃在求解最優解問題時的威力,並不表明這種算法適用於生產環境(實際上性能很是差)。
在實際場景中咱們可能必定須要最優解,而只是須要左右兩側的高度不要相差的過大就好,那麼這種狀況下簡單的貪心算法徹底足夠。
在業務工程中,咱們須要結合當前的人力資源,項目週期,代碼可維護性,性能等各個方面,去選擇最適合業務場景的解法,而不必定要去找到那個最優解。
可是算法對於前端來講仍是很是重要的,想要寫出 bug free 的代碼,在複雜的業務場景下也能遊刃有餘的想出優化複雜度的方法,學習算法是一個很是棒的途徑,這也是工程師必備的素養。
我維護了一個 LeetCode 的題解倉庫,這裏會按照標籤分類記錄我日常刷題時遇到的一些比較經典的問題,而且也會常常更新 bobo 老師的力扣算法課程中提到的各個分類的經典算法,把他 C++ 的解法改寫成 JavaScript 解法。歡迎關注,我會持續更新。
1.若是本文對你有幫助,就點個贊支持下吧,你的「贊」是我創做的動力。
2.關注公衆號「前端從進階到入院」便可加我好友,我拉你進「前端進階交流羣」,你們一塊兒共同交流和進步。