這個前端居然用動態規劃寫瀑布流佈局?給我打死他!

前言

瀑布流佈局是前端領域中一個很常見的需求,因爲圖片的高度是不一致的,因此在多列布局中默認佈局下很難得到滿意的排列。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 }
}

咱們獲得了 leftright 數組,對應左右兩列渲染圖片的下標,而且咱們也有了每一個圖片的高度,那麼渲染到頁面上就很簡單了:

<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 的高度正好是 1,因此此時dp[0][0]所填寫的值是 { max: 1, indexes: [0] },也就表明用總高度還剩 1,而且只考慮圖片 1 的狀況下,咱們的最優解是選用第一張圖片。
  2. 湊高度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)

此時問題就變的有些複雜了,在多張圖片的狀況下,咱們能夠有兩種選擇:

  1. 選擇當前圖片,那麼假設當前要湊的總高度爲 3,當前圖片的高度爲 2,剩餘的高度就爲 1,此時咱們能夠用剩餘的高度去「上一個縱座標」裏尋找「只考慮前面幾種圖片」的狀況下,高度爲 1 時的最優解。而且記錄 當前圖片的高度 + 前幾種圖片湊剩餘高度的最優解max1
  2. 不選擇當前圖片,那麼就直接去「只考慮前面幾種圖片」的上一個縱座標裏,找到當前高度下的最優解便可,記爲 max2
  3. 比較 max1max2,找出更大的那個值,記錄爲當前狀態下的最優解。

有了這個前置知識,來繼續分解這個問題,在縱座標爲 1 的狀況下,咱們手上能夠選擇的圖片有圖片 1 和圖片 2:

  1. 湊高度 1:因爲圖片 2 的高度爲 2,至關因而容量超了,因此這種狀況下不選擇圖片 2,而是直接選擇圖片 1,因此 dp[1][0] 能夠直接沿用 dp[0][0]的最優解,也就是 { max: 1, indexes: [0] }
  2. 湊高度 2:

    1. 選擇圖片 2,圖片 2 的高度爲 4,可以湊成的高度爲 4,已經超出了當前要湊的高度 2,因此不能選則圖片 2。
    2. 不選擇圖片 2,在只考慮圖片 1 時的最優解數組 dp[0] 中找到高度爲 2 時的最優解: dp[0][2],直接沿用下來,也就是 { max: 1, indexes: [0] }
    3. 這種狀況下只能不選擇圖片 2,而沿用只選擇圖片 1 時的解, { max: 1, indexes: [0] }
  3. 省略湊高度 3 ~ 4 的狀況,由於得出的結果和湊高度 2 是同樣的。
  4. 湊高度 5:高度爲 5 的狀況下就比較有意思了:

    1. 選擇圖片 2,圖片 2 的高度爲 4,可以湊成的高度爲 4,此時剩餘高度是 1,再去只考慮圖片 1 的最優解數組 dp[0]中找高度爲 1 時的最優解dp[0][1],發現結果是 { max: 1, indexes: [0] },這兩個高度值 4 和 1 相加後沒有超出高度的限制,因此得出最優解:{ max: 5, indexes: [0, 1] }
    2. 不選擇圖片 2,在圖片 1 的最優解數組中找到高度爲 5 時的最優解: dp[0][5],直接沿用下來,也就是 { max: 1, indexes: [0] }
    3. 很明顯選擇圖片 2 的狀況下,能湊成的高度更大,因此 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] 的推理過程是這樣的:

  1. 用最後一張高度爲 4 的圖片,加上前三張圖片在高度爲 7 - 4 = 3 時的最優解也就是 dp[2][3],獲得結果 4 + 1 = 5。
  2. 不用最後一張圖片,直接取前三張圖片在高度爲 7 時的最優解,也就是 dp[2][7],獲得結果 6。
  3. 對比這二者的值,獲得最優解 6。

至此咱們就完成了整個動態規劃的過程,獲得了考慮全部圖片的狀況下,最大高度爲 7 時的最優解:6,所需的兩張圖片的下標爲 [0, 2],對應高度是 15

給出代碼:

// 儘量選出圖片中高度最接近圖片總高度一半的元素
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)

得出效果:

優化 1

因爲縱軸的每一層的最優解都只須要參考上一層節點的最優解,所以能夠只保留兩行。經過判斷除 2 取餘來決定「上一行」的位置。此時空間複雜度是 O(n)。

優化 2

因爲每次參考值都只須要取上一行和當前位置左邊位置的值(由於減去了當前高度後,剩餘高度的最優解必定在左邊),所以 dp 數組能夠只保留一行,把問題轉爲從右向左求解,而且在求解的過程當中不斷覆蓋當前的值,而不會影響下一次求解。此時空間複雜度是 O(n),可是其實佔用的空間進一步縮小了。

而且在這種狀況下對於時間複雜度也能夠作優化,因爲優化後,求當前高度的最優解是倒序遍歷的,那麼當發現求最優解的高度小於當前所考慮的那個圖片的的高度時,說明本次求解不可能考慮當前圖片了,此時左邊的高度的最優解必定是「上一行的最優解」。

代碼地址

預覽地址

完整代碼地址

總結

算法思想在前端中的應用仍是能夠見到很多的,本文只是爲了演示動態規劃在求解最優解問題時的威力,並不表明這種算法適用於生產環境(實際上性能很是差)。

在實際場景中咱們可能必定須要最優解,而只是須要左右兩側的高度不要相差的過大就好,那麼這種狀況下簡單的貪心算法徹底足夠。

在業務工程中,咱們須要結合當前的人力資源,項目週期,代碼可維護性,性能等各個方面,去選擇最適合業務場景的解法,而不必定要去找到那個最優解。

可是算法對於前端來講仍是很是重要的,想要寫出 bug free 的代碼,在複雜的業務場景下也能遊刃有餘的想出優化複雜度的方法,學習算法是一個很是棒的途徑,這也是工程師必備的素養。

推薦

我維護了一個 LeetCode 的題解倉庫,這裏會按照標籤分類記錄我日常刷題時遇到的一些比較經典的問題,而且也會常常更新 bobo 老師的力扣算法課程中提到的各個分類的經典算法,把他 C++ 的解法改寫成 JavaScript 解法。歡迎關注,我會持續更新。

參考資料

一文搞懂動態規劃

玩轉算法面試 從真題到思惟全面提高算法思惟

❤️ 感謝你們

1.若是本文對你有幫助,就點個贊支持下吧,你的「贊」是我創做的動力。

2.關注公衆號「前端從進階到入院」便可加我好友,我拉你進「前端進階交流羣」,你們一塊兒共同交流和進步。

相關文章
相關標籤/搜索