1. 程式人生 > >向量的基本原理-擴容,縮容,插入,刪除,唯一化

向量的基本原理-擴容,縮容,插入,刪除,唯一化

線上性結構中,各資料項按照一個線性次序構成一個整體。最基本的的線性結構統稱為序列(sequence),根據其中資料項的邏輯次序與其物理儲存地址對應關係的不同,又可以進一步地將序列區分為向量(vector)和列表(list)。在向量中,所有資料項的物理存放在位置與邏輯次序完全吻合,此時的邏輯次序也稱作秩(rank);而在列表中,邏輯上相鄰的資料項在物理上未必是相鄰,而是採用間接地址的方式通過封裝後的位置(position)相互作用。
在這裡,我的目的肯定不是為了講述vector作為一個內建型別的ADT介面用法,而是對其資料結構做一定的分析和記錄。

構造與析構
1.預設構造方法
與所有的物件一樣,向量在使用之前也需要首先被系統建立–藉助建構函式做初始化。其中預設的構造方法

是,首先根據建立者指定的初始化容量,向系統申請空間,以建立內部私有陣列_elem[]:若容量未明確指定,則使用預設值DEFAULT_CAPACITY。接下來,鑑於初生的向量尚不包含任何元素,故將指示規模的變數_size初始化為0。
這整個過程沒有任何迭代,故若忽略用於分配陣列空間的時間,共只需常數時間
2.析構方法
向量物件的析構物件:只需要釋放用於存放元素的內部陣列_elem[],將其佔用的空間交還作業系統。_capacity和_size之類的內部變數無需做任何處理,它們作為向量物件自身的一部分被系統回收,此後既無需也無法被引用。

擴容
1.擴容原理
我在看北郵人論壇的時候,看到了一個帖子,面試官問了他vector擴容的機制是什麼?所以想要對擴容有一個更深刻印象,是我寫這篇文章的初衷。內部陣列所佔物理空間的容量,若在向量的生命期內不允許調整,則稱靜態空間策略

。向量的實際規模與內部陣列容量的比值(_size/_capacity),也稱為裝填因子(load factor),它是衡量空間利用率的重要指標。我們要做的便是保證向量的裝填因子既不超過1,也不太接近於0,我們需要尋找一個balance。所以我們要改用動態空間的策略,使用可擴充向量是一個好方法。

T *oldElem=_elem; //_elem轉換成指向陣列首元素的指標
_elem=new T[_capacity<<=1];
for(int i=0; i<_size;++i)
         _elem[i]=oldElem[i];//複製原向量內容
delete []oldElem;         

實際上,在呼叫insert()介面插入之前新元素之前,都要先呼叫該演算法,檢查內部陣列的可用容量,一旦當前資料區已滿(_size==_capacity),則將原來陣列替換為一個更大的陣列。

這裡值得注意的一點是,新陣列的地址由作業系統分配,與原資料區沒有直接關係。在這種情況下,若直接引用陣列(就是直接改變陣列的大小),比如說:

T &temp=_elem;
temp=new T[_capacity<<=1];

這種情況往往會導致共同指向原陣列的其他指標失效,稱為野指標。我也不知道理解是否準確,但這不是關鍵,關鍵是新陣列的容量總是取作原陣列的兩倍。

2.時間複雜度
準確地,每一次由n到2n的擴容,都需要花費O(2n)=O(n)時間,這也是最壞情況下,單次插入操作所需要的時間。似乎,效率不高。但是實際上,隨著向量規模的不斷擴大,在執行插入操作之前需要進行擴容的概率,也迅速降低。

縮容
動態縮容shrik()演算法:

if(_capacity<DEFAULT_CAPACITY<<1) return;//不至於收縮到DEFAULT_CAPACITY以下
if(_size<<2>_capacity) return;//以%25為界
T *oldElem=_elem; //_elem轉換成指向陣列首元素的指標
_elem=new T[_capacity>>=1];
for(int i=0; i<_size;++i)
         _elem[i]=oldElem[i];//複製原向量內容
delete []oldElem;

可見,每次刪除操作之後,一旦空間利用率已降至某一閾值以下,該演算法隨即申請一個容量減半的新陣列,將原陣列中的元素逐一搬遷至其中,最後將原陣列所佔空間交還給作業系統。但在實際應用中,為避免出現頻繁交替擴容和縮容的情況,可以選用更低的閾值,甚至取做0,相當於禁止縮容。

插入
根據向量ADT定義,插入操作insert(r,e)負責將任意給定的元素e插到任意指定的秩為r的單元:

expand(); //若有必要,擴容
for(int i=_size;i>r;i--){
    _elem[i]=_elem[i-1];
    elem[i]=e;
    _size++;
    return r;
  }

我們可以看到,執行插入操作的時間主要消耗於後繼元素的後移,線性正比於後綴元素。可見,新插入元素越靠後(前)所需的時間越短。

刪除
我們在這裡考慮區間刪除:remove(lo,hi)

if(lo==hi) return 0;  //出於效率考慮,我們要考慮退化情況,比如remove(0,0)
while(hi<_size) _elem[lo++]=_elem[hi++];
_size=lo;
shrink();//如有必要,還應該要縮容

remove(lo,hi)的計算成本,只要消耗於後續元素的前移,線性正比於後綴的長度,總體不過O(m+1)=O(_size-hi+1)。這與我們的期望完全吻合:區間操作所需的時間,應該取決於後繼元素的數目,而與被刪除區間本身的寬度無關。一般來說,本刪除元素在向量中的位置越靠後(前)所需時間越短(長),最好為O(1),最壞為O(n)=O(_size)。

唯一化
在很多應用中,在進一步處理之前都要求資料元素互異。下面看看針對無序向量的唯一化演算法:

int oldsize=_size;
Rank i=1;
while(i<_size)
     find(_elem[i],0,i)<0 ?i++ :remove(i);

隨著迴圈的不斷進行,當前元素的後續持續地嚴格減少,因此,經過n-2步迭代之後該演算法必然終止。
這裡所需的時間,主要消耗於find()和remove()兩個介面,因此每步迭代所需時間為O(n),總體複雜度應為O(n2)。

下面來看看一道關於唯一化的面試題目,已知道陣列中不重複且按升序排序,使用二分法查詢法的遞迴或者非遞迴方式,查詢數字m是否在陣列a中,如果是則返回m在陣列a中的位置。

遞迴解法:

void find(int b[],int target,int lo, int hi, int& res){
    if(lo==hi&&b[lo]==target) {res=lo;return;}
    if(lo==hi) return;
    int mi=(lo+hi)>>1;
    find(b,7,lo,mi,res);
    find(b,7,mi+1,hi,res);
}
int main() {
    int a[8]={0,1,2,3,4,5,6,7};
    int res=10;
    find(a,7,0,7,res);
    cout<<res<<endl;
}

複雜度分析:在這裡,遞迴例項一共用2n-1個,故這個演算法的運算時間為O(2n-1)=O(n),二分遞迴版本的時間複雜度和線性版本的時間複雜度是一樣的。其實這個演算法還可以做一些優化,比如說當找到這個target的時候,可以直接返回到main函式。所以演算法還需要改進。

非遞迴解法:
關於非遞迴解法,我覺得應該先給陣列排序,這無疑增加了空間複雜度,我覺得還是使用遞迴版本的演算法比較好。