今天我們將開始第一個資料型別-陣列的學習。
經常會看到這樣的問題,怎麼學習資料結構,我的答案是搞清楚具體資料結構對應的抽象資料型別ADT,拋開語言層面自帶的資料型別,然後自己從頭 實現一遍。
其實資料結構沒多複雜,資料結構就是人們的經驗總結,根據其特定進行抽象定義命名,說到底就是我們定義的,你叫它是陣列它就是陣列,叫它是數集那它就是數集,所有我們只需要知道一個資料結構的定義,並且可以自己實現其定義,那麼可以說你已經完全掌握這個資料結構了。
01、定義
什麼是陣列?陣列是同型別的元素序列,陣列是一種線性資料結構,它用一組連續的記憶體空間來儲存一組型別相同的元素。
一個長度為10的int型別陣列,在記憶體中儲存類似下圖佈局。
陣列的線性資料結構體現為資料一個挨著一個,連續的記憶體空間體現為在儲存地址[1000-1039]這個空間中間是一個整體沒有間隙的,相同元素指的是所有空間都用來儲存int型別。
因此我們可以總結出陣列的以下特性:
-
長度固定,因為記憶體一旦分配後大小將無法直接改變。
-
記憶體空間地址是連續的。
-
元素型別相同,既可以是值型別也可是引用型別。
-
索引一般從0開始。
-
隨機訪問,能夠透過索引即下標直接訪問到元素。
02、實現
1、ADT定義
抽象資料型別(Abstract Data Type,簡稱ADT),是一種資料抽象方法,用於描述資料物件的邏輯特徵和操作,通常使用三元組表示法,即ADT=(D,S,P),具體含義如下:
D(Data Objects):資料物件,定義資料的集合和性質。
S(Structure):資料物件之間的關係集,描述了資料物件內部各元素之間的結構和約束條件。
P(Primitive Operations):資料物件的基本操作集,如插入、刪除、修改、查詢、遍歷等。
如果我們要實現陣列就要先定義好陣列,下面我們用ADT定義下陣列。
ADT Array{
資料物件:D 是一個有限、非空的整數序列,D = {a1, a2, ..., an},其中 ai 表示序列中的第i個元素,n是序列的長度。
資料關係:D中的元素透過它們的索引(位置)進行組織,索引是從0到n-1的整數。
基本操作:[
Init(n) :初始化一個長度為n的陣列,所有元素初始值為元素對應型別預設值。
Length:返回陣列長度。
Get(i):返回索引為i的元素,如果i無效,則報錯。
Set(i,v):設定索引為i的元素值為v。如果i無效,則報錯。
Insert(i,v):在索引為i位置處插入v。如果i處無元素,則直接插入v;如果i處有元素並且其後面存在還未儲存元素的位置,則從未儲存元素位置之前的元素開始都像後移動一個位置直至騰出i位置,然後插入v。如果i處有元素並且其後面所有位置中都已儲存元素,則報錯;如果i無效,則報錯。
Remove(i):移除索引為i位置處元素,並將其後所有元素都向前移動一位,永遠保持元素是連續的,並且刪除空間都移動到陣列尾部,且不可訪問。
]
}
定義好陣列ADT,下面我們就可以開始自己實現一個int型別陣列型別了。
2、定義類
如果我們要實現上面關於陣列的定義,那麼需要哪些欄位來給這些功能提供支援呢?
因為我們需要直接管理記憶體,所以需要一個管理記憶體的指標欄位;
因為我們需要直接獲取陣列長度,所以需要一個儲存陣列長度欄位;
因此我們的類初步是這樣的:
public class MyselfArray
{
//申請記憶體起始位置指標
private IntPtr _pointer;
//陣列長度
private int _length;
}
3、初始化Init
先想下我們平時是怎麼使用陣列的?
int[] array = new int[5]
我們平時寫的很簡單一行程式碼就定義好了一個指定長度的陣列,但是它的背後卻做了很多事。new int[5] 相當於分配了一個能儲存5個整數的記憶體空間,並且都初始化為0。
那我們現在就自己在實現這個過程。我們首先需要申請能存放5個整數的空間,然後再初始化每個元素值,具體實現程式碼如下:
//初始化陣列為指定長度,並元素設定預設值0
public MyselfArray Init(int capacity)
{
//初始化陣列長度為capacity
_length = capacity;
//分配指定位元組數的記憶體空間
_pointer = Marshal.AllocHGlobal(capacity * sizeof(int));
//初始化陣列元素
for (int i = 0; i < _length; i++)
{
//初始化每個元素為0
Marshal.WriteInt32(_pointer + i * sizeof(int), 0);
}
//返回陣列
return this;
}
以下兩點需要單獨說明一下。
怎麼計算需要分配的位元組數?因為陣列中所有元素都是同型別的,這裡我們是用int型別舉例,所以申請的空間就是一個int型別的大小乘以陣列長度即capacity * sizeof(int)。
怎麼計算每個元素的位置?我們再來回顧一下這張圖,因為每個元素型別是相同的,因此每個元素所佔的空間大小也是相同的,因此我們可以透過下面的定址公司計算出指定元素的記憶體地址。
a[i]記憶體地址 = a[0]記憶體地址 + i * 型別大小
我們程式碼中IntPtr _pointer就是表示分配的記憶體塊首地址,也就是對應如圖a[0]記憶體地址,型別大小可以透過sizeof(int)獲取,所以我們就可以透過首地址指標和指定元素索引定位到具體元素,然後直接進行記憶體操作賦值。
這裡還有一個有趣的小知識,為什麼大多數語言索引都是從0開始?設想一下如果索引從1開始,上面的定址公式為:
a[i]記憶體地址 = a[0]記憶體地址 + (i-1) * 型別大小
這樣就導致每次訪問陣列元素都要多一步減1的操作,而對應CPU來說就是多一次減法指令,所以索引從0開始很大一部分原因就是這樣可以最佳化效能,簡化計算。
4、陣列長度Length
這個比較簡單直接把陣列長度私有欄位返回即可。
//陣列長度
public int Length
{
get
{
return _length;
}
}
5、根據索引獲取元素值Get
在獲取元素時,我們首先需要校驗索引是否有效,首先索引小於0肯定是無意義的;其次大於陣列最大元素索引也是沒有意義的,具體程式碼如下:
//根據索引獲取元素
public int Get(int index)
{
//索引小於0 或者索引大於陣列長度-1 則報錯
if (index < 0 || index > _length - 1) throw new IndexOutOfRangeException();
//讀取指定索引元素值
return Marshal.ReadInt32(_pointer + index * sizeof(int));
}
6、根據索引設定元素值Set
同樣的設定元素值時,也需要校驗索引有效性。
//根據索引設定元素
public void Set(int index, int value)
{
//索引小於0 或者索引大於陣列長度-1 則報錯
if (index < 0 || index > _length - 1) throw new IndexOutOfRangeException();
//根據索引設定元素值
Marshal.WriteInt32(_pointer + index * sizeof(int), value);
}
7、根據索引插入元素Insert
這塊邏輯是目前最複雜的一個,首先需要對索引有效性校驗,其次需要判斷當前索引位置上是否有值,沒值直接插入,有值則繼續檢視其後是否有空位,無空位直接報錯,有空位則移動元素騰出索引處位置用於插入新元素。具體實現程式碼如下:
//根據索引插入元素
public void Insert(int index, int value)
{
//索引小於0 或者索引大於陣列長度-1 則報錯
if (index < 0 || index > _length - 1) throw new IndexOutOfRangeException();
//獲取索引處的值
var v = Get(index);
//如果索引處無值
if (v == 0)
{
//直接在索引處插入新元素並返回
Set(index, value);
return;
}
//定義空位置索引
var nullIndex = -1;
//檢查插入位置之後是否有空位
for (int i = index + 1; i < _length; i++)
{
//有空位
if (Get(i) == 0)
{
//記錄空位置處索引,並結束檢查
nullIndex = i;
break;
}
}
//如果沒找到空位,則報錯
if (nullIndex == -1)
{
throw new InvalidOperationException("沒有可用的空位用於插入。");
}
//從插入位置到空位之前的元素向後移動一位
for (int i = nullIndex; i > index; i--)
{
Set(i, Get(i - 1));
}
//在指定索引處插入新元素
Set(index, value);
}
注:這裡使用值為0判斷是否為空位,因為陣列初始化就是預設值0,因此使用0表示空位即還沒賦值,這是我們自己的定義。實際上可能0本身也是有意義的,如果要想準確判斷是否有空位還需額外的處理,這裡我們只是為了理解陣列核心概念而進行簡單演示,不用糾結這個0判斷。
8、根據索引移除元素Remove
這個方法邏輯也比較簡單,先驗證索引有效性,然後從要移除索引位置處開始把後面所有元素向前移動一位,最後一位則變為預設值0。
//根據索引移除元素
public void Remove(int index)
{
//索引小於0 或者索引大於陣列長度-1 則報錯
if (index < 0 || index > _length - 1) throw new IndexOutOfRangeException();
//後面的元素(除了最後一個元素)向前移動一位
for (int i = index; i < _length - 1; i++)
{
Set(i, Get(i + 1));
}
//最後一位設為預設值0
Set(_length - 1, 0);
}
9、釋放記憶體Dispose
支援陣列型別基本完成,還差最後關鍵一步,因為記憶體是我們直接申請的,所以用完後還需要釋放,因此我們的類需要實現IDisposable介面,並實現Dispose方法,具體方法如下:
public void Dispose()
{
if (_pointer != IntPtr.Zero)
{
Marshal.FreeHGlobal(_pointer);
_pointer = IntPtr.Zero;
}
}
自此我們的陣列型別大功告成。
透過上面方法實現我們也能發現插入元素和刪除元素是很繁瑣的,特別是一些特殊情況怎麼處理,不同的定義就是不同的實現。比如上面的插入元素,插入後面如果有多個空位怎麼辦?是後面所有元素都向後移動一位,還是隻用到第一個空位處向後移動一位?如果全部向後移動一位,那麼如果最後一位是直接扔掉還是報錯不讓操作?
而且涉及到移動元素,就涉及效能問題,因此像C#語言陣列本身是沒有插入、刪除方法的。我們這裡這樣定義陣列,並且來實現這些方法,主要還是學習資料結構。
同時還是那句話資料結構終究還是我們人為定義出來的,我們定義有就有,我們怎麼定義那麼這個資料結構就是什麼樣子的,所以資料結構沒有你像的那麼難,那麼難以理解。只要把關鍵要素理解掌握了你就會了。
注:測試方法程式碼以及示例原始碼都已經上傳至程式碼庫,有興趣的可以看看。https://gitee.com/hugogoos/Planner