資料結構 - 陣列

IT规划师發表於2024-09-26

今天我們將開始第一個資料型別-陣列的學習。

經常會看到這樣的問題,怎麼學習資料結構,我的答案是搞清楚具體資料結構對應的抽象資料型別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

相關文章