1. 程式人生 > 程式設計 >[譯] 動態規劃演演算法的實際應用:接縫裁剪

[譯] 動態規劃演演算法的實際應用:接縫裁剪

動態規劃演演算法的實際應用:接縫裁剪

我們一直認為動態規劃(dynamic programming)是一個在學校裡學習的技術,並且只是用來通過軟體公司的面試。實際上,這是因為大多數的開發者不會經常處理需要用到動態規劃的問題。本質上,動態規劃可以高效求解那些可以分解為高度重複子問題的問題,因此在很多場景下是很有用的。

在這篇文章中,我將會仔細分析動態規劃的一個有趣的實際應用:接縫裁剪(seam carving)。Avidan 和 Shamir 的這篇文章 Seam Carving for Content-Aware Image Resizing 中詳細討論了這個問題以及提出的技術(搜尋文章的標題可以免費獲取)。

這篇文章是動態規劃的系列文章中的一篇。如果你還不瞭解動態規劃技術,請參閱我寫的動態規劃的圖形化介紹

(由於 Medium 不支援數學公式渲染,我是用圖片來顯示覆雜的公式的。如果訪問圖片有困難,可以看我個人網站上的文章。)

環境敏感的圖片大小調整

為了用動態規劃解決實際問題,我們需要將問題建模為可以應用動態規劃的形式。本節介紹了這個問題的必要的準備工作。

論文的原作者介紹了一種在智慧考慮圖片內容的情況下改變圖片的寬度或高度的方法,叫做環境敏感的圖片大小調整(content-aware image resizing)。後面會介紹論文的細節,但這裡先做一個概述。假設你想調整下面這個衝浪者圖片的大小。

一個衝浪者在平靜的海面中間清晰可見的俯檢視,右邊是身後洶湧的海浪。圖片來自 [Pixabay](https://pixabay.com/photos/blue-beach-surf-travel-surfer-4145659/) 上的 [Kiril Dobrev](https://pixabay.com/users/kirildobrev-12266114/)。
(一個衝浪者在平靜的海面中間清晰可見的俯檢視,右邊是身後洶湧的海浪。圖片來自 Pixabay 上的 Kiril Dobrev。)

論文中詳細討論了,有多種方法可以減少圖片的寬度。我們最先想到的是裁剪和縮放,以及它們相關的缺點。刪除圖片中部的幾列畫素也是一種方法,但你也可以想象得到,這樣會在圖片中留下一條可見的分割線,左右的內容無法對齊。而且即使是這些方法全用上了,也只能刪掉這麼點圖片:

嘗試通過裁掉圖片的左側和中間部分來減少圖片寬度。裁掉中間會在圖片中留下一條可見的分割線。
(嘗試通過裁掉圖片的左側和中間部分來減少圖片寬度。裁掉中間會在圖片中留下一條可見的分割線。)

Avidan 和 Shamir 在他們的論文中展示的是一個叫做接縫裁剪的技術。它首先會識別出圖片中不太有意義的“低能量”區域,然後找到穿過圖片的能量最低的“接縫”。對於減少圖片寬度的情況,接縫裁剪會找到一個豎向的、從圖片頂部延伸到底部、下一行最多向左或向右移動一個畫素的接縫。

在衝浪者的圖片中,能量最低的接縫穿過圖片中部水面最平靜的位置。這和我們的直覺相符。

衝浪者圖片中發現的最低能量接縫。接縫通過一條五個畫素寬的紅線來視覺化,實際上接縫只有一個畫素寬。
(衝浪者圖片中發現的最低能量接縫。接縫通過一條五個畫素寬的紅線來視覺化,實際上接縫只有一個畫素寬。)

通過識別出能量最低的接縫並刪除它,我們可以把圖片的寬度減少一個畫素。不斷重複這個過程可以充分減少圖片的寬度。

寬度減少了 1024 畫素後的衝浪者圖片。
(寬度減少了 1024 畫素後的衝浪者圖片。)

這個演演算法刪除了圖片中間的靜止水面,以及圖片左側的水面,這仍然符合我們的直覺。和直接剪裁圖片不同的是,左側水面的質地得以保留,也沒有突兀的過渡。圖片的中間確實有一些不是很完美的過渡,但大部分的結果看起來很自然。

定義圖片的能量

這個演演算法的關鍵在於找到能量最低的接縫。要做到這一點,我們首先定義圖片中每個畫素的能量,然後應用動態規劃演演算法來尋找穿過圖片的能量最低的路徑。下一節中會詳細討論這個演演算法。讓我們先看看如何為圖片中的畫素定義能量。

論文中討論了一些不同的能量函式,以及它們在調整圖片大小時的效果。簡單起見,我們使用一個簡單的能量函式,表達圖片中的顏色在每個畫素周圍的變化強烈程度。為了完整起見,我會將能量函式介紹得詳細一點,以備你想自己實現它,但這部分的計算僅僅是為後續動態規劃作準備。

左半邊表示,當相鄰畫素的顏色非常不同時這個畫素的能量大。右半邊表示,當相鄰畫素的顏色比較相似時畫素的能量小。
(左半邊表示,當相鄰畫素的顏色非常不同時這個畫素的能量大。右半邊表示,當相鄰畫素的顏色比較相似時畫素的能量小。)

為了計算單個畫素的能量,我們檢查這個畫素左右的畫素。我們計算逐個分量之間的平方距離,也就是分別計算紅色、綠色、藍色分量之間的平方距離,然後相加。我們對中心畫素上下的畫素進行同樣的計算。最終,我們將水平和垂直距離相加。

唯一的特殊情況是當畫素位於邊緣,例如左側邊緣時,它的左邊沒有畫素。對於這種情況,我們只需比較將其和右邊的畫素比較。對於上邊緣、右邊緣、下邊緣的畫素,會進行類似的調整。

當週圍畫素的顏色非常不同時,能量函式較大;而當顏色相似時,能量函式較小。

衝浪者圖片中每個畫素的能量,用白色顯示高能量畫素、黑色顯示低能量畫素來視覺化。不出所料,中間的衝浪者和右側的湍流的能量最高。
(衝浪者圖片中每個畫素的能量,用白色顯示高能量畫素、黑色顯示低能量畫素來視覺化。不出所料,中間的衝浪者和右側的湍流的能量最高。)

這個能量函式在衝浪者圖片上效果很好。然而,能量函式的值域很廣,當對能量進行視覺化時,圖片中的大部分畫素看起來能量為零。實際上,這些區域的能量只是相對於能量最高的區域比較低,但並不是零。為了讓能量函式更容易視覺化,我放大了衝浪者,並調亮了該區域。

使用動態規劃搜尋低能量接縫

為每個畫素計算出了能量之後,我們現在可以搜尋從圖片頂部延伸到底部的低能量接縫了。同樣的分析方法也適用於從左側延伸至右側的水平接縫,可以讓我們減少原始圖片的高度。不過,我們現在只關注垂直的接縫。

我們先定義最低能量接縫的概念:

  • 接縫是畫素的序列,其中每行有且僅有一個畫素。要求對於連續的兩行,x 座標的變化最多為 1,這保證了這是一條相連的接縫。
  • 最低能量接縫是指接縫中所有畫素的能量總和最小的一條接縫。

注意,最低能量接縫不一定會經過圖片中的最低能量畫素。是讓接縫的能量總和最小,而不是讓單個畫素的能量最小。

貪心的方法行不通。過早選擇了低能量畫素後,我們陷入了圖片的高能量區域,如圖中紅色路徑所示。
(貪心的方法行不通。過早選擇了低能量畫素後,我們陷入了圖片的高能量區域,如圖中紅色路徑所示。)

從上圖中可以看到,“從最頂行開始,依次選擇下一行中的最低能量畫素”的貪心方法是行不通的。在選擇了能量為 2 的畫素之後,我們被迫走入了圖片中的一個高能量區域。而如果我們在中間一行選擇一個能量相對高一點的畫素,我們還有可能進入左下的低能量區域。

將問題分解為子問題

上述的貪心方法的問題在於,當決定如何延伸接縫時,我們沒有考慮到未來的接縫剩餘部分。我們無法預知未來,但我們可以記錄下目前所有已知的資訊,從而可以觀察過去。

讓我們反過來進行選擇。我們不再從多個畫素中選擇一個來延伸單個接縫,而是從多個接縫中選擇一個來連線單個畫素。 我們要做的是,對於每個畫素,在上一行可以連線的畫素中進行選擇。如果上一行中的每個畫素都編碼了到那個畫素為止的路徑,我們本質上就觀察了那個畫素之前的所有歷史。

對每個畫素,我們檢視上一行中的三個畫素。本質的問題是,我們應當延伸哪個接縫?
(對每個畫素,我們檢視上一行中的三個畫素。本質的問題是,我們應當延伸哪個接縫?)

這表明了可以對圖片中的每個畫素劃分子問題。因為子問題需要記錄到那個畫素的最優路徑,比較好的方法是將每個畫素的子問題定義為以那個畫素結尾的最低能量接縫的能量。

和貪心的方法不同,上述方法本質上嘗試了圖片中的所有路徑。只不過,當嘗試所有可能的路徑時,在一遍又一遍地解決相同的子問題,讓動態規劃成為這個方法的一個完美的選擇。

定義遞迴關係

與往常一樣,我們現在需要將上述的思路形式化為一個遞迴關係。子問題是關於原圖片中的每一個畫素的,因此遞迴關係的輸入可以簡單的是那個畫素的 xy 座標。這可以使輸入是簡單的整數、使子問題的排序變得容易,也使我們可以用一個二維陣列儲存計算過的值。

我們定義函式 M(x,y) 表示從圖片頂部開始、到畫素 (x,y) 結束的最低能量的垂直接縫。使用字母 M 是因為論文裡就是這麼定義的。

首先,我們定義基本情況(base case)。在圖片的最頂行,所有以這些畫素結尾的接縫都只有一個畫素長,因為再往上沒有其他畫素了。因此,以這些畫素結尾的最低能量接縫就是這些畫素的能量:

對於其他的所有畫素,我們需要檢視上一行的畫素。由於接縫需要是相連的,我們的候選只有左上方、上方、右上方三個最近的畫素。我們要選取以這些畫素結尾的接縫中能量最低的那個,然後加上當前畫素的能量:

我們需要考慮所檢視的畫素位於圖片的左邊緣或右邊緣時的邊界情況。對於左、右邊緣處的畫素,我們分別忽略 M(x−1,y−1) 或者 M(x+1,y−1)。

最終,我們需要取得豎向延伸了整個圖片的最低能量接縫的能量。這意味著檢檢視片的最底行,選擇以這些畫素中的一個結尾的最低能量接縫。設圖片寬 W 個畫素,高 H 個畫素,我們要的是:

有了這個定義,我們就得到了一個遞迴關係,包括我們所需的所有性質:

  • 遞迴關係的輸入為整數。
  • 我們所需的最終結果易於從遞迴關係中提取。
  • 這個關係只依賴於自身。

檢查子問題的 DAG(有向無環圖)

由於每個子問題 M(x,y) 對應於原圖片中的單個畫素,子問題的依賴圖非常容易視覺化,只需將子問題放在二維網格中,就像在原圖片中的排列一樣!

子問題放置在二維網格中,就像在原圖片中的排列一樣。
(子問題放置在二維網格中,就像在原圖片中的排列一樣。)

如遞迴關係的基本情況(base case)所示,最頂行的子問題對應於圖片的最頂行,可以簡單地用單個畫素的能量值初始化。

子問題的第一行不依賴於任何其他子問題。注意最頂行的單元沒有出來的箭頭。
(子問題的第一行不依賴於任何其他子問題。注意最頂行的單元沒有出來的箭頭。)

從第二行開始,依賴關係開始出現。首先,在第二行的最左單元,我們遇到了一個邊界情況。由於左側沒有其他單元,標記為 (0,1) 的單元只依賴於上方和右上方最近的單元。對於第三行最左側的單元來說也是同樣的情況。

左邊緣處的子問題只依賴於上方的兩個子問題。
(左邊緣處的子問題只依賴於上方的兩個子問題。)

再看第二行的第二個單元,標記為 (1,1) 的單元。這是遞迴關係的一個最典型的展示。這個單元依賴於左上、上方、右上最近的三個單元。這種依賴結構適用於第二行及以後的所有“中間”的單元。

左右邊緣之間的子問題依賴於上方的三個子問題。
(左右邊緣之間的子問題依賴於上方的三個子問題。)

第二行的最後,右邊緣處表示了第二個邊界情況。因為右側沒有其他單元,這個單元只依賴於上方和左上最近的單元。

右邊緣處的子問題只依賴於上方的兩個子問題。
(右邊緣處的子問題只依賴於上方的兩個子問題。)

最後,對所有後續行重複這個過程。

因為依賴於包含了太多的箭頭,這裡的動畫逐個顯示了每個子問題的依賴。
(因為依賴於包含了太多的箭頭,這裡的動畫逐個顯示了每個子問題的依賴。)

由於完整的依賴圖箭頭數量極多,令人生畏,逐個地觀察每個子問題能讓我們建立直觀的依賴模式。

自底向上的實現

從上述分析中,我們可以得到子問題的順序:

  • 從圖片的頂部到底部。
  • 對於每一行,可以以任意順序。自然的順序是從左至右。

因為每一行只依賴於前一行,所以我們只需要維護兩行的資料:前一行和當前行。實際上,如果從左至右計算,我們實際上可以丟棄前一行使用過的一些元素。不過,這會讓演演算法更復雜,因為我們需要弄清楚前一行的哪部分可以丟棄,以及如何丟棄。

在下面的 Python 程式碼中,輸入是行的列表,其中每行是數字的列表,表示這一行中每個畫素的能量。輸入命名為 pixel_energies,而 pixel_energies[y][x] 表示位於座標 (x,y) 處畫素的能量。

首先計算最頂行的接縫的能量,只需拷貝最頂行的單個畫素的能量:

previous_seam_energies_row = list(pixel_energies[0])
複製程式碼

接著,迴圈遍歷輸入的其餘行,計算每行的接縫能量。最棘手的部分是確定引用前一行中的哪些元素,因為左邊緣畫素的左側和右邊緣畫素的右側是沒有畫素的。

在每次迴圈中,會為當前行建立一個新的接縫能量的列表。每次迴圈結束時,將前一行的資料替換為當前行的資料,供下一輪迴圈使用。這樣我們就丟棄了前一行。

# 在迴圈中跳過第一行
for y in range(1,len(pixel_energies)):
    pixel_energies_row = pixel_energies[y]

    seam_energies_row = []
    for x,pixel_energy in enumerate(pixel_energies_row):
        # 判斷要在前一行中遍歷的 x 值的範圍。這個範圍取決於當前畫素是在圖片
        # 的中間還是邊緣。
        x_left = max(x - 1,0)
        x_right = min(x + 1,len(pixel_energies_row) - 1)
        x_range = range(x_left,x_right + 1)

        min_seam_energy = pixel_energy + \
            min(previous_seam_energies_row[x_i] for x_i in x_range)
        seam_energies_row.append(min_seam_energy)

    previous_seam_energies_row = seam_energies_row
複製程式碼

最終, previous_seam_energies_row 包含了最底行的接縫能量。取出這個列表中的最小值,這就是答案!

min(seam_energy for seam_energy in previous_seam_energies_row)
複製程式碼

你可以測試這個實現:把它包裝在一個函式中,然後建立一個二維陣列作為輸入呼叫這個函式。下面的輸入資料會讓貪心演演算法失敗,但同時也有明顯可見的最低能量接縫:

ENERGIES = [
    [9,9,0,9],[9,1,8,0],]

print(min_seam_energy(ENERGIES))
複製程式碼

時間和空間複雜度

對於原圖片中的每一個畫素,都有一個對應的子問題。每個子問題最多有 3 個依賴,所以解決每個子問題的工作量是常數。最後,我們需要再遍歷最後一行一遍。那麼,如果圖片寬 W 畫素,高 H 畫素,時間複雜度是 O(W×H+W)。

在任意時刻,我們持有兩個列表,分別儲存前一行和當前行。前一行的列表共有 W 個元素,而當前行的列表不斷增長,最多有 W 個元素。那麼,空間複雜度是 O(2W),也就是 O(W)。

注意到,如果我們真的從前一行的資料中丟棄一部分元素,我們可以在當前行的列表增長的同時縮減前一行的列表。不過,空間複雜度仍舊是 O(W)。取決於圖片的寬度,常量係數可能會有一點影響,但通常不會有什麼大的影響。

用於尋找最低能量接縫的後向指標

現在我們找到了最低能量垂直接縫的能量,那麼如何利用這個資訊呢?事實上我們並不關心接縫的能量,而是接縫本身!問題是,從接縫的最後一個畫素,我們無法回溯到接縫的其餘部分。

這是我在文章前面的內容中跳過的部分,但很多動態規劃的問題也有相似的考慮。例如,如果你還記得盜賊問題,我們可以知道盜竊的數值並提取出最大值,但我們不知道哪些房子產出了那個總和的值。

表示後向指標

解決方法是通用的:儲存後向指標。在接縫裁剪的問題中,我們不僅需要每個畫素處的接縫能量值,還想要知道前一行的哪個畫素得到了這個能量。通過儲存這個資訊,我們可以沿著這些指標一路到達圖片的頂部,得到組成了最低能量接縫的畫素。

首先,我們建立一個類來儲存一個畫素的能量和後向指標。能量值會用來計運算元問題。因為後向指標只是記錄了前一行的哪個畫素產生了當前的能量,我們可以只用 x 座標來表示這個指標。

class SeamEnergyWithBackPointer():
    def __init__(self,energy,x_coordinate_in_previous_row=None):
        self.energy = energy
        self.x_coordinate_in_previous_row = \
            x_coordinate_in_previous_row
複製程式碼

每個子問題將會是這個類的一個例項,而不再只是一個數字。

儲存後向指標

在最後,我們需要回溯整個圖片的高度,沿著後向指標重建最低能量的接縫。不幸的是,這意味著我們需要儲存圖片中所有的畫素,而不僅是前一行。

為了實現這一點,我們將保留所有子問題的全部結果,即使可以丟棄前面行的接縫能量數值。我們可以用像輸入的陣列一樣的二維陣列來儲存這些結果。

讓我們從第一行開始,這一行只包含單個畫素的能量。由於沒有前一行,所有的後向指標都是 None。但是為了一致性,我們還是會儲存 SeamEnergyWithBackPointer 的例項:

seam_energies = []

# 拷貝最頂行的畫素能量來初始化最頂行的接縫能量。最頂行沒有後向指標。
seam_energies.append([
    SeamEnergyWithBackPointer(pixel_energy)
    for pixel_energy in pixel_energies[0]
])
複製程式碼

主迴圈的工作方式幾乎和先前的實現相同,除了以下幾點區別:

  • 前一行的資料包含的是 SeamEnergyWithBackPointer 的例項,所以當計算遞迴關係的值時,我們需要在這些物件內部查詢接縫能量。
  • 當為當前畫素儲存資料時,我們需要建立一個新的 SeamEnergyWithBackPointer 例項。在這個例項中我們既儲存當前畫素的接縫能量,又儲存用於計算當前接縫能量的前一行的 x 座標。
  • 在每一行計算結束後,不會丟棄前一行的資料,而是簡單地將當前行的資料追加到 seam_energies 中。
# 在迴圈中跳過第一行
for y in range(1,x_right + 1)

        min_parent_x = min(
            x_range,key=lambda x_i: seam_energies[y - 1][x_i].energy
        )

        min_seam_energy = SeamEnergyWithBackPointer(
            pixel_energy + seam_energies[y - 1][min_parent_x].energy,min_parent_x
        )

        seam_energies_row.append(min_seam_energy)

    seam_energies.append(seam_energies_row)
複製程式碼

沿著後向指標前進

當全部的子問題表格都填滿後,我們就可以重建最低能量的接縫。首先找到最底行對應於最低能量接縫的 x 座標:

# 找到最底行接縫能量最低的 x 座標
min_seam_end_x = min(
    range(len(seam_energies[-1])),key=lambda x: seam_energies[-1][x].energy
)
複製程式碼

然後,從圖片的底部走向頂部,y 座標從 len(seam_energies) - 1 降到 0。 在每輪迴圈中,將當前的 (x,y) 座標對新增到表示接縫的列表中,然後將 x 的值設為當前行的 SeamEnergyWithBackPointer 物件所指向的位置。

# 沿著後向指標前進,得到一個構成最低能量接縫的座標列表
seam = []
seam_point_x = min_seam_end_x
for y in range(len(seam_energies) - 1,-1,-1):
    seam.append((seam_point_x,y))

    seam_point_x = \
        seam_energies[y][seam_point_x].x_coordinate_in_previous_row

seam.reverse()
複製程式碼

這樣就自底向上地構建出了接縫,將列表反轉就得到了自頂向下的接縫座標。

時間與空間複雜度

時間複雜度和之前相似,因為我們仍然需要將每個畫素處理一次。在最後還需要從最後一行中找出最低的接縫能量,然後向上走一個圖片的高度來重建接縫。那麼,對於 W×H 的圖片,時間複雜度是 O(W×H+W+H)。

至於空間複雜度,我們仍然為每個子問題儲存常量級的資料,但是現在我們不再丟棄任何資料。那麼,我們使用了 O(W×H) 的空間。

刪除低能量的接縫

找到了最低能量的垂直接縫後,我們可以簡單地將原圖片中的畫素複製到新圖片中。新圖片中的每一行都是原圖片中對應行除去最低能量接縫的畫素後的剩餘畫素。因為我們在每一行都刪去了一個畫素,那麼我們可以從一個 W×H 的圖片得到 (W−1)×H 的圖片。

我們可以重複這個過程,在新圖片上重新計算能量函式,然後找到新圖片上的最低能量接縫。你可能很想在原圖片上找到不止一個低能量的接縫,然後一次性把它們都刪除。但問題是兩個接縫可能相關交叉,在中間共享同一個畫素。在第一個接縫刪掉之後,第二個接縫就會由於缺少了一個畫素而不再有效。

上述視訊展示了應用於衝浪者圖片上的接縫刪除過程(視訊連結在此——譯者注)。我是通過獲取每次迭代的圖片,然後在上面新增最低能量接縫的視覺化線條來製作的這個視訊。

另一個例子

已經有很多深入的講解了,那讓我們以一些漂亮的照片結束吧!請看下面的在拱門國家公園的巖層的照片:

拱門國家公園中間的一個有孔的巖層。圖片來自 [Flickr](https://flic.kr/p/4hxxz5) 上的 [Mike Goad](https://www.flickr.com/photos/exit78/)。
(拱門國家公園中間的一個有孔的巖層。圖片來自 Flickr 上的 Mike Goad。)

這個圖片的能量函式:

拱門圖片中每個畫素的能量,用白色顯示高能量畫素、黑色顯示低能量畫素來視覺化。注意巖層孔洞邊緣旁的高能量。
(拱門圖片中每個畫素的能量,用白色顯示高能量畫素、黑色顯示低能量畫素來視覺化。注意巖層孔洞邊緣旁的高能量。)

這產生了下面的最低能量接縫。注意到這個接縫穿過了右側的岩石,正好從岩石頂部被照亮與天空顏色一致的部分進入。或許我們需要選擇一個更好的能量函式!

拱門圖片中的最低能量接縫。接縫通過一條五個畫素寬的紅線來視覺化,實際上接縫只有一個畫素寬。
(拱門圖片中的最低能量接縫。接縫通過一條五個畫素寬的紅線來視覺化,實際上接縫只有一個畫素寬。)

最終,調整拱門圖片的大小之後:

寬度減少了 1024 畫素後的拱門圖片。
(寬度減少了 1024 畫素後的拱門圖片。)

這個結果肯定不太完美,原圖片中的很多邊緣在調整大小後的圖片中都有些變形。一種可能的改進是實現另一個論文中討論的能量函式。


動態規劃雖然常常只在教學中遇到,但它還是解決實際的複雜問題的有用技術。在本文中,我們討論了動態規劃的一個應用:使用接縫裁剪實現環境敏感的圖片大小調整。

我們應用了相同的原理,將問題分解為子問題,分析子問題之間的依賴關係,然後以時間、空間複雜度最小的順序求解。另外,我們還探索了通過後向指標,除了計算最小的數值,還能找到產生這個數值的特定選擇。然後將這部分內容應用到實際的問題上,對問題進行預處理和後處理,讓動態規劃演演算法真正有用。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄