iT邦幫忙

2023 iThome 鐵人賽

DAY 9
0

今天要介紹最後一種時間複雜度的分析技巧,要是你能夠把這幾天介紹的三種技巧融會貫通,之後在大部分的場景你應該都有辦法看出一段程式的時間複雜度。

之所以說大部分是因為遞迴的時間複雜度分析比較特別一點,不過這不是現在要提的東西

那廢話不多說,讓我們開始最後一項技巧的介紹吧!

均攤分析(Amortized Analysis)

前面兩項技巧的名稱(數迴圈、數學分析)都只是我們自己取的,但均攤分析很明顯有屬於自己的正式名稱,不論是在教科書還是論文中這種作法都被叫做均攤分析(Amortized Analysis),由此應該看得出來它的與眾不同之處。

開始之前先簡單列點介紹一下這種技巧:

  • 使用情境:
    • 當某個演算法或資料結構有好幾種不同的操作,而某個操作的時間成本特別大
    • 當某個演算法或資料結構有的一些操作有好幾種狀態,而當那些操作處於特定狀態時的時間成本會變得特別大

因為前面沒特別提過什麼是資料結構,因此這邊惡補一下XD:所謂資料結構就是透過「某種形式」來存資料,使演算法能節省時間並更高效的利用記憶體。
如陣列就是透過連續的序列來存資料,因此它的特性就是能高效的修改元素,但它的劣勢就是搜尋/刪除元素了。

  • 核心概念:
    • 分析那些時間最糟糕的操作,看看能不能證明那些糟糕操作的出現次數不會那麼多,因此整體的平均時間不會像想像的那麼糟糕
    • 目的在求出平均時間,因此可能有時候呼叫某操作會跑特別久,但因為可以證明其他大部分時候都一定會跑很快,因此整體時間沒那麼差
    • 時間成本較低的操作去「牽制」時間成本高的操作、或者說將時間成本高的操作的時間能「均攤」到時間成本比較低的操作後取平均時間
    • 分析對象是最糟糕情況下的平均時間,"average time in the worst case"、而非普通的"average time",所以別和之前 Day 6 講插入排序時提到過的 "average time complexity" 搞混囉!
  • 簡單範例:
    • 假設某演算法連續進行 https://chart.googleapis.com/chart?cht=tx&chl=n 次操作所花費的時間總共是 https://chart.googleapis.com/chart?cht=tx&chl=T%28n%29 ,那該演算法的均攤成本就會是 https://chart.googleapis.com/chart?cht=tx&chl=%5Cfrac%7BT%28n%29%7D%7Bn%7D

不過其實均攤分析只是一種分析的「策略」,所以就我所知它還能再往下分成三種技巧,分別為:

  1. Aggregate analysis(聚集法)
  2. The accounting method(記帳法)
  3. The potential method(位能法)

雖說分成三種技巧,不過他們比較像是用三種不同的角度來做同一件事情。

以下將分開來介紹這三種技術,不過在此之前要先舉出範例作為等一下展示這些技術時的例子。

堆疊(stack)是一種經典的資料結構,用比喻來說的話它就像一個洋芋片罐,對於這個罐子你只能做兩件事:放洋芋片、拿洋芋片。然後你會發現到你最早放進去的洋芋片會最晚被拿出來,這就是 stack LIFO(Last in, First out)後進先出的特性

想知道更多可以參考 Stack: Intro(簡介)

  1. 堆疊操作(stack operation)
    假設一個 stack 有以下三個操作:

    1. PUSH(S, x):將元素 x 推入堆疊 S 頂端,這是個 https://chart.googleapis.com/chart?cht=tx&chl=%5Cmathcal%7BO%7D%7B%281%29%7D 操作
    2. POP(S):將堆疊 S 頂端的一個元素拿走,這是個 https://chart.googleapis.com/chart?cht=tx&chl=%5Cmathcal%7BO%7D%7B%281%29%7D 操作(如果堆疊 S 已經空了就不能 POP)
    3. MULTIPOP(S, K):連續地從堆疊 S 中拿走 K 個元素
      while (!S.empty() && k != 0) {
          S.pop();
          --k;
      }
      
      以上是 MULTIPOP 的實作,因此它是個 https://chart.googleapis.com/chart?cht=tx&chl=%5Cmathcal%7BO%7D%7B%28%5Cmin%7B%28S.size%28%29%2C%20K%29%7D%29%7D 的操作,因為要不就是連續 pop() K 次;要不就是沒 pop() 滿 K 次結果 stack 就空了。
      請問對於前面的 stack,假設 stack 一開始為空,則連續執行 n 次操作的總時間複雜度是多少?
  2. 二進位計數器(incrementing a binary counter)
    一個有 K 個位元的二進位計數器,從 0 開始往上計數, https://chart.googleapis.com/chart?cht=tx&chl=0%2C%201%2C%202%2C%203%2C%20%5Ccdots
    說的嚴謹一點:存在一個長度為 K 的陣列 A,然後我們有一個操作叫做 increment(A, K) 。
    以下是該計數器的實作:

    void increment(bool A[], int K) {
        int i = 0;
        while (i < K && A[i] == 1) {
            A[i] = 0;
    		++i;
        }
        if (i < K) A[i] = 1;
    }
    
    int main() {
        int K;
        cin >> K;
        // 陣列 A 可以被表示為長度為 K 的 bool 陣列
        bool A[K + 1];
        // 求經過一連串 increment(A, K) 後的時間複雜度
    }
    

    下圖是我們計數器從 0 算到 16 的示意圖


    求從 0 開始,經過 n 次 increment 運算後,K 位元的二進位計數器的總時間複雜度為多少?

我隨便算

  1. 請問對於前面的 stack,假設 stack 一開始為空,則連續執行 n 次操作的總時間複雜度是多少?
    因為三種操作中,時間複雜度最糟糕的操作是 MULTIPOP,而每次 MULTIPOP 最糟糕會花費 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cmathcal%7BO%7D%7B%28n%29%7D 的時間,因此總時間複雜度是 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cmathcal%7BO%7D%7B%28n%5E2%29%7D

  2. 求從 0 開始,經過 n 次 increment 運算後,K 位元的二進位計數器的總時間複雜度為多少?
    最糟糕的情況下,每做一次 increment 需要 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cmathcal%7BO%7D%7B%28K%29%7D 的時間(例如:7 + 1 = 8 需要變動 4 個 bit),因此總時間複雜度為 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cmathcal%7BO%7D%7B%28nK%29%7D

其實上面這樣分析也不能說它不對喔!因為按照 Big O 的定義它是正確的,不過真的有點太悲觀了

Aggregate analysis 聚集分析

直接分析一連串操作的總時間上界 https://chart.googleapis.com/chart?cht=tx&amp;chl=T%28n%29 ,然後再計算平均時間 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cfrac%7BT%28n%29%7D%7Bn%7D

  • 從整體來看事情、很豪邁的硬派做法
  1. 請問對於前面的 stack,假設 stack 一開始為空,則連續執行 n 次操作的總時間複雜度是多少?

    因為 stack 一開始為空,因此我們可以知道一個東西在 stack 中,放進去後只會被拿出來一次
    所以因為 https://chart.googleapis.com/chart?cht=tx&amp;chl=PUSH 的次數 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cle%20nhttps://chart.googleapis.com/chart?cht=tx&amp;chl=POP%2BMULTIPOP 的次數 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cle%20n 也必成立
    因此有 https://chart.googleapis.com/chart?cht=tx&amp;chl=PUSH%2BPOP%2BMULTIPOP 的次數 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cle%202n%3D%5Cmathcal%7BO%7D%7B%28n%29%7D
    總時間複雜度為 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cmathcal%7BO%7D%7B%28n%29%7D ,平均每個操作為 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cfrac%7B%5Cmathcal%7BO%7D%7B%28n%29%7D%7D%7Bn%7D%3D%5Cmathcal%7BO%7D%7B%281%29%7D

  2. 求從 0 開始,經過 n 次 increment 運算後,K 位元的二進位計數器的總時間複雜度為多少?

    因為很難精準計算每個數字在 increment 時的時間,因此我們可以換個角度來看:
    A[0] 位置的 bit 每 1 次 increment 都會變...總共變 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cfrac%7Bn%7D%7B1%7D
    A[1] 位置的 bit 每 2 次 increment 都會變...總共變 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Clfloor%5Cfrac%7Bn%7D%7B2%7D%5Crfloor
    A[2] 位置的 bit 每 4 次 increment 都會變...總共變 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Clfloor%5Cfrac%7Bn%7D%7B4%7D%5Crfloor
    發現規律之後就可以加總列式了,因為知道 n 個數大概會有 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Clfloor%20%5Clog%7Bn%7D%5Crfloor%2B1 個位元,因此對於每次更新的位元數量和可列式如下:
    https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Csum_%7Bi%3D0%7D%5E%7B%5Clfloor%20%5Clog%7Bn%7D%5Crfloor%7D%7B%5Clfloor%5Cfrac%7Bn%7D%7B2%5Ei%7D%5Crfloor%7D%3Cn%5Csum_%7Bi%3D0%7D%5E%7B%5Cinfty%7D%7B%5Cfrac%7B1%7D%7B2%5Ei%7D%7D
    右邊的無窮等比級數和昨天有算過,因此偷懶套一下結論:
    https://chart.googleapis.com/chart?cht=tx&amp;chl=n%5Csum_%7Bi%3D0%7D%5E%7B%5Cinfty%7D%7B%5Cfrac%7B1%7D%7B2%5Ei%7D%7D%3D2n%3D%5Cmathcal%7BO%7D%7B%28n%29%7D
    總時間複雜度為 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cmathcal%7BO%7D%7B%28n%29%7D ,平均每個操作為 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cfrac%7B%5Cmathcal%7BO%7D%7B%28n%29%7D%7D%7Bn%7D%3D%5Cmathcal%7BO%7D%7B%281%29%7D

The accounting method 記帳分析

從金錢記帳的角度來分析複雜度,是一個很有趣也很直覺的分析方法,

它的做法可以想像成有對於每個操作都各有一本銀行帳簿,此時為每一種操作定義不同的「均攤成本」(比一比:聚集分析是把每個操作的成本看作一樣直接加起來分析)。

這種「均攤成本」與真實的成本不一定一樣,有些操作的均攤成本是「操作本身的開銷」+「預付成本」;有些操作的均攤成本為 0。

https://chart.googleapis.com/chart?cht=tx&amp;chl=c_i 代表第 https://chart.googleapis.com/chart?cht=tx&amp;chl=i 個操作的真實成本; https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Chat%7Bc_i%7Dhttps://chart.googleapis.com/chart?cht=tx&amp;chl=i 次運算的均攤成本。

我們設計的成本必須使下式成立:
https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Csum_%7Bi%3D1%7D%5E%7Bn%7D%7B%5Chat%7Bc_i%7D%7D%5Cge%5Csum_%7Bi%3D1%7D%5E%7Bn%7D%7Bc_i%7D

則總預付成本:https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Csum_%7Bi%3D1%7D%5E%7Bn%7D%7B%5Chat%7Bc_i%7D%7D-%5Csum_%7Bi%3D1%7D%5E%7Bn%7D%7Bc_i%7D

如此一來,我們只要加總所有的均攤成本就能知道總時間複雜度了!

這種分析技巧的原理其實就是前面說的讓時間成本高的操作的時間能「均攤」到時間成本比較低的操作

因此我們若觀察到某些較耗時的操作的出場頻率會被一些較省時的操作牽制,此時我們就可以將耗時操作的成本設定為 0、省時操作的成本設定為「操作本身的開銷」+「預付成本」,並讓預付成本來支付耗時操作的開銷。

  • 讓耗時操作用免費的,時間成本就用省時操作辛辛苦苦存的錢去付包養
  1. 請問對於前面的 stack,假設 stack 一開始為空,則連續執行 n 次操作的總時間複雜度是多少?

因為很明顯 POP 和 MULTIPOP 會受到 PUSH 的牽制,因為若堆疊內的元素不夠根本就不能拿東西出來!因此可以如下設計均攤成本:

操作 實際成本 均攤成本
PUSH 1 2
POP 1 0
MULTIPOP min(S.size(), K) 0

可以注意到 PUSH 的均攤成本為 2,1 用來支付自己的實際成本,剩下的 1 作為預付成本「存起來」,等到需要 POP 或 MULTIPOP 的時候就用預付成本來支付,因為 MULTIPOP 若能夠拿出 k 個元素,就代表它們在被 PUSH 進來時就有存好自己的預付成本了,因此 MULTIPOP 完全不需要自己支付成本。
這樣的總時間複雜度顯然是 https://chart.googleapis.com/chart?cht=tx&amp;chl=2n%3D%5Cmathcal%7BO%7D%7B%28n%29%7D ,平均每個操作為 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cfrac%7B%5Cmathcal%7BO%7D%7B%28n%29%7D%7D%7Bn%7D%3D%5Cmathcal%7BO%7D%7B%281%29%7D
或者換個角度來看可以直接把它的均攤成本當成平均的操作成本 https://chart.googleapis.com/chart?cht=tx&amp;chl=2%3D%5Cmathcal%7BO%7D%7B%281%29%7D ,結果一樣。

  1. 求從 0 開始,經過 n 次 increment 運算後,K 位元的二進位計數器的總時間複雜度為多少?

首先我們可以觀察到兩件事:(1.) 因為在計數器中是從 0 開始的,因此可以發現位元從 0 變 1 的次數無論何時都會大於等於 1 變 0 的次數,因為 0 要先變 1 才有 1 能變回 0;(2.) 每次 increment 只會恰好產生一次的 0 變 1
根據兩點觀察,我們可以如下設計均攤成本:

操作 實際成本 均攤成本
0 變 1 1 2
1 變 0 1 0

讓 0 變 1 的成本變成 2,多出來的 1 作為預付成本來支付之後所需要的 1 變 0。
這樣的總時間複雜度顯然是 https://chart.googleapis.com/chart?cht=tx&amp;chl=2n%3D%5Cmathcal%7BO%7D%7B%28n%29%7D ,平均每個操作為 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cfrac%7B%5Cmathcal%7BO%7D%7B%28n%29%7D%7D%7Bn%7D%3D%5Cmathcal%7BO%7D%7B%281%29%7D
或者換個角度來看可以直接把它的均攤成本當成平均的操作成本 https://chart.googleapis.com/chart?cht=tx&amp;chl=2%3D%5Cmathcal%7BO%7D%7B%281%29%7D ,結果一樣。

The potential method 位能分析

我個人認為這方法的名稱取的很到位!

在物理學中,位能是物體由於其位置或狀態而儲存的能量。最常見的位能形式有重力位能、電位能等等。
而位能的函數通常被定義成一個僅與位置有關的函數,使得位能轉換所作的功可表達為這兩點位能函數值的差。講白話一點就是:位能升高代表了能量的儲存、位能降低代表了能量的釋放。
那所謂的位能分析就像是在做一台雲霄飛車一樣,你在爬坡時慢慢累積你的位能。而經歷了漫長的位能累積後,在頂端一次釋放,急遽釋放剛才累積的位能

那先直接開始介紹:

  • 起初我們資料結構的狀態會用 https://chart.googleapis.com/chart?cht=tx&amp;chl=D_0 表示。
  • https://chart.googleapis.com/chart?cht=tx&amp;chl=D_i 表示當資料結構 https://chart.googleapis.com/chart?cht=tx&amp;chl=D_%7Bi-1%7D 被第 https://chart.googleapis.com/chart?cht=tx&amp;chl=i 個操作改變後的狀態
  • https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cphi%7B%28D_i%29%7D 表示位能函數(potential function),它表示了資料結構 https://chart.googleapis.com/chart?cht=tx&amp;chl=D_i 當下某種狀態的數值
  • https://chart.googleapis.com/chart?cht=tx&amp;chl=C_i 表示第 https://chart.googleapis.com/chart?cht=tx&amp;chl=i 個操作的真實成本
  • https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Chat%7BC_i%7D 表示第 https://chart.googleapis.com/chart?cht=tx&amp;chl=i 個操作的均攤成本, https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Chat%7BC_i%7D%3DC_i%2B%28%5Cphi%7B%28D_i%29%7D-%5Cphi%7B%28D_%7Bi-1%7D%29%7D%29
  • 上一點也表示了: https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Csum_%7Bi%3D1%7D%5E%7Bn%7D%7B%5Chat%7Bc_i%7D%7D%3D%5Csum_%7Bi%3D1%7D%5E%7Bn%7D%7Bc_i%7D%2B%28%5Cphi%7B%28D_n%29%7D-%5Cphi%7B%28D_0%29%7D%29
  • 根據上一點,只要 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cphi%7B%28D_n%29%7D%5Cge%5Cphi%7B%28D_0%29%7D ,就代表我們的均攤成本能夠成為實際成本的上界,也因此一般傾向讓 https://chart.googleapis.com/chart?cht=tx&amp;chl=D_0%3D0

多看幾遍會發現其實位能分析跟記帳分析很像,只不過記帳分析沒有規定均攤成本和實際成本的差該訂多少;而位能分析則明確定義均攤成本就是操作本身的開銷加上該操作對位能的變化量(對資料結構某種「狀態」的改變量)。因此其實可以把位能分析當成更通用的記帳分析。

因為只需要知道該如何定義位能函數,使得某些較省時的操作累積位能、讓耗時操作消耗位能,並證明出均攤開銷為常數就可以了!所以其實在較為複雜的狀況下,位能分析比較實用!在一些較正式的演算法書籍中也是如此。

  1. 請問對於前面的 stack,假設 stack 一開始為空,則連續執行 n 次操作的總時間複雜度是多少?

首先將位能函數 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cphi%7B%28D_i%29%7D 定義為 stack 中元素的數量

操作 實際成本 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cphi%7B%28D_i%29%7D-%5Cphi%7B%28D_%7Bi-1%7D%29%7D 均攤成本
PUSH 1 https://chart.googleapis.com/chart?cht=tx&amp;chl=%28S.size%28%29%2B1%29-S.size%3D1 1+1=2
POP 1 https://chart.googleapis.com/chart?cht=tx&amp;chl=%28S.size%28%29-1%29-S.size%28%29%3D-1 1+(-1)=0
MULTIPOP https://chart.googleapis.com/chart?cht=tx&amp;chl=K%27%3D%5Cmin%7B%28S.size%28%29%2C%20K%29%7D https://chart.googleapis.com/chart?cht=tx&amp;chl=%28S.size%28%29-K%27%29-S.size%28%29%3D-K%27 https://chart.googleapis.com/chart?cht=tx&amp;chl=K%27%2B%28-K%27%29%3D0

因為 PUSH 最多可能呼叫 https://chart.googleapis.com/chart?cht=tx&amp;chl=n 次,因此這樣的總時間複雜度顯然是 https://chart.googleapis.com/chart?cht=tx&amp;chl=2n%3D%5Cmathcal%7BO%7D%7B%28n%29%7D ,平均每個操作為 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cfrac%7B%5Cmathcal%7BO%7D%7B%28n%29%7D%7D%7Bn%7D%3D%5Cmathcal%7BO%7D%7B%281%29%7D
或者換個角度來看可以直接把它的均攤成本當成平均的操作成本 https://chart.googleapis.com/chart?cht=tx&amp;chl=2%3D%5Cmathcal%7BO%7D%7B%281%29%7D ,結果一樣。

  1. 求從 0 開始,經過 n 次 increment 運算後,K 位元的二進位計數器的總時間複雜度為多少?

首先將位能函數 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cphi%7B%28D_i%29%7D 定義為計數器中 1 的數量;並令 https://chart.googleapis.com/chart?cht=tx&amp;chl=t_i 表示第 https://chart.googleapis.com/chart?cht=tx&amp;chl=i 個 increment 中,從 1 變 0 的位元數。

操作 實際成本 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cphi%7B%28D_i%29%7D-%5Cphi%7B%28D_%7Bi-1%7D%29%7D 均攤成本
increment https://chart.googleapis.com/chart?cht=tx&amp;chl=t_i%2B1 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cphi%7B%28D_i%29%7D-%5Cphi%7B%28D_%7Bi-1%7D%29%7D%5Cle%20%28%5Cphi%7B%28D_%7Bi-1%7D%29%7D-t_i%2B1%29-%5Cphi%7B%28D_%7Bi-1%7D%29%7D%3D1-t_i https://chart.googleapis.com/chart?cht=tx&amp;chl=%28t_i%2B1%29%2B%281-t_i%29%3D2

可以注意到因為除了在溢位時實際成本等於 https://chart.googleapis.com/chart?cht=tx&amp;chl=D_%7Bi-1%7D-t_i 以外,其他時候都會等於 https://chart.googleapis.com/chart?cht=tx&amp;chl=D_%7Bi-1%7D-t_i%2B1 ,因此實際成本一定小於等於 https://chart.googleapis.com/chart?cht=tx&amp;chl=t_i%2B1

因此平均的操作成本 https://chart.googleapis.com/chart?cht=tx&amp;chl=2%3D%5Cmathcal%7BO%7D%7B%281%29%7D ,總時間複雜度為 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Cmathcal%7BO%7D%7B%28n%29%7D

小結

今天的錯誤可能偏多,請各位敬請見諒...若有大神有看到哪邊寫錯的也非常歡迎在留言區指出,謝謝!

今天介紹了均攤分析和其底下的三種分析方法,明天預計會再帶一個均攤分析的案例,請各位敬請期待!


上一篇
Day 8 不數迴圈就沒辦法分析了?技豈是如此不便之物 其二
下一篇
Day 10 不數迴圈就沒辦法分析了?技豈是如此不便之物 終章
系列文
CS補完計畫—演算法與資料結構的第三次衝擊30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
西撒
iT邦新手 5 級 ‧ 2023-09-25 10:20:23

好硬核的分析方法
我只是想通面試的白板題QAQ

時間複雜度 我最搞不懂的是遞迴相關的分析
之後會不會介紹?

比如 backtrack + 剪枝
我完全不知道怎麼算 big O

預計是會講到遞迴的 big O分析,不過要是你想先知道的話,剛剛網路上有找到兩個教學可以先參考一下:

遞迴
Recursion 的複雜度分析

第一份教材講得比較簡單,用的方法就是第二份教材說的遞迴樹,很多情況其實都只需要大概估計一下遞迴樹的葉子節點的數量和樹的高度就可以求出遞迴的時間複雜度了。其他的像是主定理或數學解法我是比較少看到啦!因此不多做評論

如果還有什麼特別想看到的主題可以再跟我講,謝謝!!

我要留言

立即登入留言