堆排序和優先隊列

堆是一棵滿足一定性質的二叉樹,具體的講堆具有如下性質:父節點的鍵值總是不大于它的孩子節點的鍵值(小頂堆), 堆可以分為小頂堆大頂堆,這里以小頂堆為例

由于二叉樹良好的形態已經包含了父節點和孩子節點的關系信息,因此就可以不使用鏈表而簡單的使用數組來存儲堆。

要實現堆的基本操作,涉及到的兩個關鍵的函數

siftUp(i, x)?: 將位置i的元素x向上調整,以滿足堆得性質,常常是用于insert后,用于調整堆;

siftDown(i, x):同理,常常是用于delete(i)后,用于調整堆;

具體的操作如下:

private void siftUp(int i) {

int key = nums[i];

for (; i > 0;) {

int p = (i - 1) >>> 1;

if (nums[p] <= key)

break;

nums[i] = nums[p];

i = p;

}

nums[i] = key;

}


private void siftDown(int i) {

int key = nums[i];

for (;i < nums.length / 2;) {

int child = (i << 1) + 1;

if (child + 1 < nums.length && nums[child] > nums[child+1])

child++;

if (key <= nums[child])

break;

nums[i] = nums[child];

i = child;

}

nums[i] = key;

??}

可以看到siftUp和siftDown不停的在父節點和子節點之間比較、交換;在不超過logn的時間復雜度就可以完成一次操作。

有了這兩個基本的函數,就可以實現上述提及的堆的基本操作。

首先是如何建堆,實現建堆操作有兩個思路:

一個是不斷地insert(insert后調用的是siftUp)

另一個將原始數組當成一個需要調整的堆,然后自底向上地

在每個位置i調用siftDown(i),完成后我們就可以得到一個滿足堆性質的堆。這里考慮后一種思路:

通常堆的insert操作是將元素插入到堆尾,由于新元素的插入可能違反堆的性質,因此需要調用siftUp操作自底向上調整堆;堆移除堆頂元素操作是將堆頂元素刪除,然后將堆最后一個元素放置在堆頂,接著執行siftDown操作,同理替換堆頂元素也是相同的操作。

建堆


// 建立小頂堆

private void buildMinHeap(int[] nums) {

int size = nums.length;

for (int j = size / 2 - 1; j >= 0; j--)

siftDown(nums, j, size);

}

那么建堆操作的時間復雜度是多少呢?答案是O(n)。雖然siftDown的操作時間是logn,但是由于高度在遞減的同時,每一層的節點數量也在成倍減少,最后通過數列錯位相減可以得到時間復雜度是O(n)。

extractMin

由于堆的固有性質,堆的根便是最小的元素,因此peek操作就是返回根nums[0]元素即可;

若要將nums[0]刪除,可以將末尾的元素nums[n-1]覆蓋nums[0],然后將堆得size = size-1,調用siftDown(0)調整堆。時間復雜度為logn。

peek

同上

delete(i)

刪除堆中位置為i的節點,涉及到兩個函數siftUp和siftDown,時間復雜度為logn,具體步驟是,

將元素last覆蓋元素i,然后siftDown

檢查是否需要siftUp

注意到堆的刪除操作,如果是刪除堆的根節點,則不用考慮執行siftUp的操作;若刪除的是堆的非根節點,則要視情況決定是siftDown還是siftUp操作,兩個操作是互斥的。


public int delete(int i) {

int key = nums[i];

//將last元素移動過來,先siftDown; 再視情況考慮是否siftUp

int last = nums[i] = nums[size-1];

size--;

siftDown(i);

//check #i的node的鍵值是否確實發生改變(是否siftDown操作生效),若發生改變,則ok,否則為確保堆性質,則需要siftUp

if (i < size && nums[i] == last) {

System.out.println("delete siftUp");

siftUp(i);

}

???? return key;

}

case 1 :

刪除中間節點i21,將最后一個節點復制過來;

由于沒有進行siftDown操作,節點i的值仍然為6,因此為確保堆的性質,執行siftUp操作;

case 2

刪除中間節點i,將值為11的節點復制過來,執行siftDown操作;

由于執行siftDown操作后,節點i的值不再是11,因此就不用再執行siftUp操作了,因為堆的性質在siftDown操作生效后已經得到了保持。

可以看出,堆的基本操作都依賴于兩個核心的函數siftUp和siftDown;較為完整的Heap代碼如下:


class Heap {

private final static int N = 100; //default size

private int[] nums;

private int size;

public Heap(int[] nums) {

this.nums = nums;

this.size = nums.length;

heapify(this.nums);

}

public Heap() {

this.nums = new int[N];

}

/**

* heapify an array, O(n)

* @param nums An array to be heapified.

*/

private void heapify(int[] nums) {

for (int j = (size - 1) >> 1; j >= 0; j--)

siftDown(j);

}

/**

* append x to heap

* O(logn)

* @param x

* @return

*/

public int insert(int x) {

if (size >= this.nums.length)

expandSpace();

size += 1;

nums[size-1] = x;

siftUp(size-1);

return x;

}

/**

* delete an element located in i position.

* O(logn)

* @param i

* @return

*/

public int delete(int i) {

rangeCheck(i);

int key = nums[i];

//將last元素覆蓋過來,先siftDown; 再視情況考慮是否siftUp;

int last = nums[i] = nums[size-1];

size--;

siftDown(i);

//check #i的node的鍵值是否確實發生改變,若發生改變,則ok,否則為確保堆性質,則需要siftUp;

if (i < size && nums[i] == last)

siftUp(i);

return key;

}

/**

* remove the root of heap, return it's value, and adjust heap to maintain the heap's property.

* O(logn)

* @return

*/

public int extractMin() {

rangeCheck(0);

int key = nums[0], last = nums[size-1];

nums[0] = last;

size--;

siftDown(0);

return key;

}

/**

* return an element's index, if not exists, return -1;

* O(n)

* @param x

* @return

*/

public int search(int x) {

for (int i = 0; i < size; i++)

if (nums[i] == x)

return i;

return -1;

}

/**

* return but does not remove the root of heap.

* O(1)

* @return

*/

public int peek() {

rangeCheck(0);

return nums[0];

}

private void siftUp(int i) {

int key = nums[i];

for (; i > 0;) {

int p = (i - 1) >>> 1;

if (nums[p] <= key)

break;

nums[i] = nums[p];

i = p;

}

nums[i] = key;

}

private void siftDown(int i) {

int key = nums[i];

for (;i < size / 2;) {

int child = (i << 1) + 1;

if (child + 1 < size && nums[child] > nums[child+1])

child++;

if (key <= nums[child])

break;

nums[i] = nums[child];

i = child;

}

nums[i] = key;

}

private void rangeCheck(int i) {

if (!(0 <= i && i < size))

throw new RuntimeException("Index is out of boundary");

}

private void expandSpace() {

this.nums = Arrays.copyOf(this.nums, size * 2);

}

@Override

public String toString() {

// TODO Auto-generated method stub

StringBuilder sb = new StringBuilder();

sb.append("[");

for (int i = 0; i < size; i++)

sb.append(String.format((i != 0 ? ", " : "") + "%d", nums[i]));

sb.append("]\n");

return sb.toString();

}

}

2.堆的應用:堆排序

運用堆的性質,我們可以得到一種常用的、穩定的、高效的排序算法————堆排序。堆排序的時間復雜度為O(n*log(n)),空間復雜度為O(1),堆排序的思想是:

對于含有n個元素的無序數組nums, 構建一個堆(這里是小頂堆)heap,然后執行extractMin得到最小的元素,這樣執行n次得到序列就是排序好的序列。

如果是降序排列則是小頂堆;否則利用大頂堆。

Trick

由于extractMin執行完畢后,最后一個元素last已經被移動到了root,因此可以將extractMin返回的元素放置于最后,這樣可以得到sort in place的堆排序算法。

具體操作如下:


int[] n = new int[] {1,9,5,6,8,3,1,2,5,9,86};

Heap h = new Heap(n);

for (int i = 0; i < n.length; i++)

n[n.length-1-i] = h.extractMin();

當然,如果不使用前面定義的heap,則可以手動寫堆排序,由于堆排序設計到建堆extractMin, 兩個操作都公共依賴于siftDown函數,因此我們只需要實現siftDown即可。(trick:由于建堆操作可以采用siftUp或者siftDown,而extractMin是需要siftDown操作,因此取公共部分,則采用siftDown建堆)。

這里便于和前面統一,采用小頂堆數組進行降序排列。


public void heapSort(int[] nums) {

int size = nums.length;

buildMinHeap(nums);

while (size != 0) {

// 交換堆頂和最后一個元素

int tmp = nums[0];

nums[0] = nums[size - 1];

nums[size - 1] = tmp;

size--;

siftDown(nums, 0, size);

}

}


// 建立小頂堆

private void buildMinHeap(int[] nums) {

int size = nums.length;

for (int j = size / 2 - 1; j >= 0; j--)

siftDown(nums, j, size);

}


private void siftDown(int[] nums, int i, int newSize) {

int key = nums[i];

while (i < newSize >>> 1) {

int leftChild = (i << 1) + 1;

int rightChild = leftChild + 1;

// 最小的孩子,比最小的孩子還小

int min = (rightChild >= newSize || nums[leftChild] < nums[rightChild]) ? leftChild : rightChild;

if (key <= nums[min])

break;

nums[i] = nums[min];

i = min;

}

nums[i] = key;

}

3.堆的應用:優先隊列

優先隊列是一種抽象的數據類型,它和堆的關系類似于,List和數組、鏈表的關系一樣;我們常常使用堆來實現優先隊列,因此很多時候堆和優先隊列都很相似,它們只是概念上的區分。

優先隊列的應用場景十分的廣泛:

常見的應用有:

Dijkstra’s algorithm(單源最短路問題中需要在鄰接表中找到某一點的最短鄰接邊,這可以將復雜度降低。)

Huffman coding(貪心算法的一個典型例子,采用優先隊列構建最優的前綴編碼樹(prefixEncodeTree))

Prim’s algorithm for minimum spanning tree

Best-first search algorithms

這里簡單介紹上述應用之一:Huffman coding

Huffman編碼是一種變長的編碼方案,對于每一個字符,所對應的二進制位串的長度是不一致的,但是遵守如下原則:

出現頻率高的字符的二進制位串的長度小

不存在一個字符c的二進制位串s是除c外任意字符的二進制位串的前綴

遵守這樣原則的Huffman編碼屬于變長編碼,可以無損的壓縮數據,壓縮后通常可以節省20%-90%的空間,具體壓縮率依賴于數據的固有結構。

Huffman編碼的實現就是要找到滿足這兩種原則的?字符-二進制位串?對照關系,即找到最優前綴碼的編碼方案(前綴碼:沒有任何字符編碼后的二進制位串是其他字符編碼后位串的前綴)。

這里我們需要用到二叉樹來表達最優前綴碼,該樹稱為最優前綴碼樹

一棵最優前綴碼樹看起來像這樣:

算法思想:用一個屬性為freqeunce關鍵字的最小優先隊列Q,將當前最小的兩個元素x,y合并得到一個新元素z(z.frequence = x.freqeunce + y.frequence),

然后插入到優先隊列中Q中,這樣執行n-1次合并后,得到一棵最優前綴碼樹(這里不討論算法的證明)。

一個常見的構建流程如下:

樹中指向某個節點左孩子的邊上表示位0,指向右孩子的邊上的表示位1,這樣遍歷一棵最優前綴碼樹就可以得到對照表。


import java.util.Comparator;

import java.util.HashMap;

import java.util.Map;

import java.util.PriorityQueue;


/**

*

*????????????????????????????root

*????????????????????????????/?? \

*????????????????????--------- ----------

*????????????????????|c:freq | | c:freq |

*????????????????????--------- ----------

*

*

*/

public class HuffmanEncodeDemo {


public static void main(String[] args) {

// TODO Auto-generated method stub

Node[] n = new Node[6];

float[] freq = new float[] { 9, 5, 45, 13, 16, 12 };

char[] chs = new char[] { 'e', 'f', 'a', 'b', 'd', 'c' };

HuffmanEncodeDemo demo = new HuffmanEncodeDemo();

Node root = demo.buildPrefixEncodeTree(n, freq, chs);

Map collector = new HashMap<>();

StringBuilder sb = new StringBuilder();

demo.tranversalPrefixEncodeTree(root, collector, sb);

System.out.println(collector);

String s = "abcabcefefefeabcdbebfbebfbabc";

StringBuilder sb1 = new StringBuilder();

for (char c : s.toCharArray()) {

sb1.append(collector.get(c));

}

System.out.println(sb1.toString());

}


public Node buildPrefixEncodeTree(Node[] n, float[] freq, char[] chs) {

PriorityQueue pQ = new PriorityQueue<>(new Comparator() {

public int compare(Node o1, Node o2) {

return o1.item.freq > o2.item.freq ? 1 : o1.item.freq == o2.item.freq ? 0 : -1;

};

});

Node e = null;

for (int i = 0; i < chs.length; i++) {

n[i] = e = new Node(null, null, new Item(chs[i], freq[i]));

pQ.add(e);

}


for (int i = 0; i < n.length - 1; i++) {

Node x = pQ.poll(), y = pQ.poll();

Node z = new Node(x, y, new Item('$', x.item.freq + y.item.freq));

pQ.add(z);

}

return pQ.poll();

}

/**

* tranversal??

* @param root

* @param collector

* @param sb

*/

public void tranversalPrefixEncodeTree(Node root, Map collector, StringBuilder sb) {

// leaf node

if (root.left == null && root.right == null) {

collector.put(root.item.c, sb.toString());

return;

}

Node left = root.left, right = root.right;

tranversalPrefixEncodeTree(left, collector, sb.append(0));

sb.delete(sb.length() - 1, sb.length());

tranversalPrefixEncodeTree(right, collector, sb.append(1));

sb.delete(sb.length() - 1, sb.length());

}

}


class Node {

public Node left, right;

public Item item;


public Node(Node left, Node right, Item item) {

super();

this.left = left;

this.right = right;

this.item = item;

}


}


class Item {

public char c;

public float freq;


public Item(char c, float freq) {

super();

this.c = c;

this.freq = freq;

}

}

輸出如下:

1

2

{a=0, b=101, c=100, d=111, e=1101, f=1100}

010110001011001101110011011100110111001101010110011110111011011100101110110111001010101100

4 堆的應用:海量實數中(一億級別以上)找到TopK(一萬級別以下)的數集合。

A:通常遇到找一個集合中的TopK問題,想到的便是排序,因為常見的排序算法例如快排算是比較快了,然后再取出K個TopK數,時間復雜度為O(nlogn),當n很大的時候這個時間復雜度還是很大的;

B:另一種思路就是打擂臺的方式,每個元素與K個待選元素比較一次,時間復雜度很高:O(k*n),此方案明顯遜色于前者。

對于一億數據來說,A方案大約是26.575424*n;

C:由于我們只需要TopK,因此不需要對所有數據進行排序,可以利用堆得思想,維護一個大小為K的小頂堆,然后依次遍歷每個元素e, 若元素e大于堆頂元素root,則刪除root,將e放在堆頂,然后調整,時間復雜度為logK;若小于或等于,則考察下一個元素。這樣遍歷一遍后,最小堆里面保留的數就是我們要找的topK,整體時間復雜度為O(k+n*logk)約等于O(n*logk),大約是13.287712*n(由于k與n數量級差太多),這樣時間復雜度下降了約一半。

A、B、C三個方案中,C通常是優于B的,因為logK通常是小于k的,當K和n的數量級相差越大,這種方式越有效。

以下為具體操作:


import java.io.File;

import java.io.FileNotFoundException;

import java.io.PrintWriter;

import java.io.UnsupportedEncodingException;

import java.util.Arrays;

import java.util.Scanner;

import java.util.Set;

import java.util.TreeSet;

public class TopKNumbersInMassiveNumbersDemo {


public static void main(String[] args) {

// TODO Auto-generated method stub

int[] topK = new int[]{50001,50002,50003,50004,50005};

genData(1000 * 1000 * 1000, 500, topK);

long t = System.currentTimeMillis();

findTopK(topK.length);

System.out.println(String.format("cost:%fs", (System.currentTimeMillis() - t) * 1.0 / 1000));

}

public static void genData(int N, int maxRandomNumer, int[] topK) {

File f = new File("data.txt");

int k = topK.length;

Set index = new TreeSet<>();

for (;;) {

index.add((int)(Math.random() * N));

if (index.size() == k)

break;

}

System.out.println(index);

int j = 0;

try {

PrintWriter pW = new PrintWriter(f, "UTF-8");

for (int i = 0; i < N; i++)

if(!index.contains(i))

pW.println((int)(Math.random() * maxRandomNumer));

else

pW.println(topK[j++]);

pW.flush();

} catch (FileNotFoundException e) {

// TODO Auto-generated catch block

e.printStackTrace();

} catch (UnsupportedEncodingException e) {

// TODO Auto-generated catch block

e.printStackTrace();

}

}

public static void findTopK(int k) {

int[] nums = new int[k];

//read

File f = new File("data.txt");

try {

Scanner scanner = new Scanner(f);

for (int j = 0;j < k; j++)

nums[j] = scanner.nextInt();

heapify(nums);

//core

while (scanner.hasNextInt()) {

int a = scanner.nextInt();

if (a <= nums[0])

continue;

else {

nums[0] = a;

siftDown(0, k, nums);

}

}

System.out.println(Arrays.toString(nums));

} catch (FileNotFoundException e) {

// TODO Auto-generated catch block

e.printStackTrace();

}

}

//O(n), minimal heap

public static void heapify(int[] nums) {

int size = nums.length;

for (int j = (size - 1) >> 1; j >= 0; j--)

siftDown(j, size, nums);

}

private static void siftDown(int i, int n, int[] nums) {

int key = nums[i];

for (;i < (n >>> 1);) {

int child = (i << 1) + 1;

if (child + 1 < n && nums[child] > nums[child+1])

child++;

if (key <= nums[child])

break;

nums[i] = nums[child];

i = child;

}

nums[i] = key;

}

}

ps:大致測試了一下,10億個數中找到top5需要140秒左右,應該是很快了。

5 總結

堆是基于樹的滿足一定約束的重要數據結構,存在許多變體例如二叉堆、二項式堆、斐波那契堆(很高效)等。

堆的幾個基本操作都依賴于兩個重要的函數siftUp和siftDown,堆的insert通常是在堆尾插入新元素并siftUp調整堆,而extractMin是在

刪除堆頂元素,然后將最后一個元素放置堆頂并調用siftDown調整堆。

二叉堆是常用的一種堆,其是一棵二叉樹;由于二叉樹良好的性質,因此常常采用數組來存儲堆。

堆得基本操作的時間復雜度如下表所示:

heapifyinsertpeekextractMindelete(i)

O(n)O(logn)O(1)O(logn)O(logn)

二叉堆通常被用來實現堆排序算法,堆排序可以sort in place,堆排序的時間復雜度的上界是O(nlogn),是一種很優秀的排序算法。由于存在相同鍵值的兩個元素處于兩棵子樹中,而兩個元素的順序可能會在后續的堆調整中發生改變,因此堆排序不是穩定的。降序排序需要建立小頂堆,升序排序需要建立大頂堆。

堆是實現抽象數據類型優先隊列的一種方式,優先隊列有很廣泛的應用,例如Huffman編碼中使用優先隊列利用貪心算法構建最優前綴編碼樹。

堆的另一個應用就是在海量數據中找到TopK個數,思想是維護一個大小為K的二叉堆,然后不斷地比較堆頂元素,判斷是否需要執行替換對頂元素的操作,采用

此方法的時間復雜度為n*logk,當k和n的數量級差距很大的時候,這種方式是很有效的方法。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,345評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,494評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,283評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,953評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,714評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,410評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,940評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,776評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,210評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,654評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373

推薦閱讀更多精彩內容

  • 1. Java基礎部分 基礎部分的順序:基本語法,類相關的語法,內部類的語法,繼承相關的語法,異常的語法,線程的語...
    子非魚_t_閱讀 31,717評論 18 399
  • 轉載自:https://egoistk.github.io/2016/09/10/Java%E6%8E%92%E5...
    chad_it閱讀 998評論 0 18
  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,782評論 18 139
  • 今天推薦一本剛讀完的書—《橫向領導力》。剛拿到這本書就覺得很震撼《影響力》作者羅伯特.西奧迪尼、《高效能人士的七個...
    攀巖的蝸牛111閱讀 734評論 0 6
  • 一轉眼,2017接近尾聲,2018巧然來臨。 回顧這一年,也走過了不少城市。 2月份,在長沙,與合伙人散團,人生第...
    科學001閱讀 632評論 0 0