讀古今文學網 > 編寫高質量代碼:改善Java程序的151個建議 > 建議67:不同的列表選擇不同的遍歷方法 >

建議67:不同的列表選擇不同的遍歷方法

我們來思考這樣一個案例:統計一個省的各科高考平均值,比如數學平均分是多少,語文平均分是多少等,這是每年招生辦都會公佈的數據,我們來想想看該算法應如何實現。當然使用數據庫中的一個SQL語句就能求出平均值,不過這不再我們的考慮之列,這裡還是使用純Java的算法來解決之,看代碼:


public static void main(Stringargs){

//學生數量,80萬

int stuNum=80*10000;

//List集合,記錄所有學生的分數

List<Integer>scores=new ArrayList<Integer>(stuNum);

//寫入分數

for(int i=0;i<stuNum;i++){

scores.add(new Random().nextInt(150));

}

//記錄開始計算時間

long start=System.currentTimeMillis();

System.out.println(\"平均分是:\"+average(scores));

System.out.println(\"執行時間:\"+(System.currentTimeMillis()-start)+\"ms\");

}

//計算平均數

public static int average(List<Integer>list){

int sum=0;

//遍歷求和

for(int i:list){

sum+=i;

}

//除以人數,計算平均值

return sum/list.size();

}


把80萬名學生的成績放到一個ArrayList數組中,然後通過foreach方式遍歷求和,再計算平均值,程序非常簡單,輸出的結果是:


平均分是:74

執行時間:47ms


僅僅求一個算術平均值就花費了47毫秒,不要說考慮其他諸如加權平均值、補充平均值等算法,那花的時間肯定更長。我們仔細分析一下arverage方法,加號操作是最基本操作,沒有什麼可以優化的,剩下的就是一個遍歷了,問題是List的遍歷可以優化嗎?

我們可以嘗試一下,List的遍歷還有另外一種方式,即通過下標方式來訪問,代碼如下:


//計算平均數

public static int average(List<Integer>list){

int sum=0;

//遍歷求和

for(int i=0,size=list.size();i<size;i++){

sum+=list.get(i);

}

//除以人數,計算平均值

return sum/list.size();

}


不再使用foreach方式遍歷列表,而是採用下標方式遍歷,我們看看輸出結果如何:


平均分是:74

執行時間:16ms


執行時間已經大幅度下降,性能提升了65%,這是一個飛速提升!那為什麼我們使用下標方式遍歷數組會有這麼高的性能提升呢?

這是因為ArrayList數組實現了RandomAccess接口(隨機存取接口),這也就標誌著ArrayList是一個可以隨機存取的列表。在Java中,RandomAccess和Cloneable、Serializable一樣,都是標誌性接口,不需要任何實現,只是用來表明其實現類具有某種特質的,實現了Cloneable表明可以被拷貝,實現了Serializable接口表明被序列化了,實現了RandomAccess則表明這個類可以隨機存取,對我們的ArrayList來說也就標誌著其數據元素之間沒有關聯,即兩個位置相鄰的元素之間沒有相互依賴和索引關係,可以隨機訪問和存儲。

我們知道,Java中的foreach語法是iterator(迭代器)的變形用法,也就是說上面的foreach與下面的代碼等價:


for(Iterator<Integer>i=list.iterator();i.hasNext();){

sum+=i.next();

}


那我們再想想什麼是迭代器,迭代器是23個設計模式中的一種,「提供一種方法訪問一個容器對像中的各個元素,同時又無須暴露該對象的內部細節」,也就是說對於ArrayList,需要先創建一個迭代器容器,然後屏蔽內部遍歷細節,對外提供hasNext、next等方法。問題是ArrayList實現了RandomAccess接口,已表明元素之間本來沒有關係,可是,為了使用迭代器就需要強制建立一種互相「知曉」的關係,比如上一個元素可以判斷是否有下一個元素,以及下一個元素是什麼等關係,這也就是通過foreach遍歷耗時的原因。

Java為ArrayList類加上了RandomAccess接口,就是在告訴我們,「嘿,ArrayList是隨機存取的,採用下標方式遍歷列表速度會更快」,接著又有一個問題了:為什麼不把RandomAccess加到所有的List實現類上呢?

那是因為有些List實現類不是隨機存取的,而是有序存取的,比如LinkedList類,LinkedList也是一個列表,但它實現了雙向鏈表,每個數據結點中都有三個數據項:前節點的引用(Previous Node)、本節點元素(Node Element)、後繼節點的引用(Next Node),這是數據結構的基本知識,不多講了,也就是說在LinkedList中的兩個元素本來就是有關聯的,我知道你的存在,你也知道我的存在。那大家想想看,元素之間已經有關聯關係了,使用foreach也就是迭代器方式是不是效率更高呢?我們修改一下例子,代碼如下:


public static void main(Stringargs){

//學生數量,80萬

int stuNum=80*10000;

//List集合,記錄所有學生的分數

List<Integer>scores=new LinkedList<Integer>();

/*其他代碼沒有改變,不再贅述*/

}

public static int average(List<Integer>list){

int sum=0;

//foreach遍歷求和

for(int i:list){

sum+=i;

}

//除以人數,計算平均值

return sum/list.size();

}


運行的結果如下:


平均分是:74

執行時間:16ms


確實如此,也是16毫秒,效率非常高。可能大家還想要測試一下下標方式(也就是採用get方法訪問元素)遍歷LinkedList元素的情況,其實不用測試,效率真的非常低,我們直接看源碼:


public E get(int index){

return entry(index).element;

}


由entry方法查找指定下標的節點,然後返回其包含的元素,看entry方法:


private Entry<E>entry(int index){

/*檢查下標是否越界,代碼不再拷貝*/

Entry<E>e=header;

if(index<(size>>1)){

//如果下標小於中間值,則從頭節點開始搜索

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

e=e.next;

}else{

//如果下標大於等於中間值,則從尾節點反向遍歷

for(int i=size;i>index;i--)

e=e.previous;

}

return e;

}


看懂了嗎?程序會先判斷輸入的下標與中間值(size右移一位,也就是除以2了)的關係,小於中間值則從頭開始正向搜索,大於中間值則從尾節點反向搜索,想想看,每一次的get方法都是一個遍歷,「性能」兩字從何說起呢!

明白了隨機存取列表和有序存取列表的區別,我們的average方法就必須重構了,以便實現不同的列表採用不同的遍歷方式,代碼如下:


public static int average(List<Integer>list){

int sum=0;

if(list instanceof RandomAccess){

//可以隨機存取,則使用下標遍歷

for(int i=0,size=list.size();i<size;i++){

sum+=list.get(i);

}

}else{

//有序存取,使用foreach方式

for(int i:list){

sum+=i;

}

}

//除以人數,計算平均值

return sum/list.size();

}


如此一來,列表的遍歷就可以「以不變應萬變」了,無論是隨機存取列表還是有序列表,它都可以提供快速的遍歷。

注意 列表遍歷不是那麼簡單的,其中很有「學問」,適時選擇最優的遍歷方式,不要固化為一種。