1. 程式人生 > >什麼是陣列:從二進位制到多重連結串列深入理解資料組合的內部機制

什麼是陣列:從二進位制到多重連結串列深入理解資料組合的內部機制

陣列是一種簡單的資料集結方式

10100001 10110011

     在計算機內部這些二進位制對應著“開關”的“ON”和“OFF”;平時我們寫的程式碼最終被解釋翻譯成二進位制的“機器語言”,歸根結底所有的資料都是0和1的組合;這些0和1如同一個個雞蛋被放在分隔好的架子上,這裡的“架子”就是所謂的“記憶體(memory)”,每個被分割好的小隔間可以容納一個0或者1,這就是“位(bit)”的概念,而每8個位又組合成為一個“位元組(byte)”,在位元組的水平上於是又了“地址(address)”的概念,位元組是資料儲存的最小單位;這樣做是有理由的,因為畢竟1位的資訊太少,而8位,根據簡單的數學乘法原理可知這裡存在2^8中潛在的可能性(在物理上對應2^8中電路狀態)而且就現在所知,我們使用的文字(英文、日文等)和一些符號剛好可以用這些潛在的邏輯可能性來表示(這涉及到編碼,通用的是ASCII編碼,後來由於語言的擴充又引入了新的編碼方式但是8位表示就這樣傳承下來了事實證明這是合理的);但是如果要表示不同的資料型別怎麼辦?總不能把“整數”和“浮點數”或者“單字元”用一種方式儲存吧!這種做法當然可行不過不好,因為造成了大量的記憶體空間浪費;於是人們像可不可以就像“位”集結成“位元組”那樣把“位元組”也集結成新的“資料型別”呢?這是一著大膽的想法,於是我們規定“XX的位元組表示int型別”、“YY的位元組表示char型別”,這樣以來就可以各盡其用了,而且不同的資料型別從邏輯上也有利於人們辨別不同的資料種類;最後要介紹的就是陣列,陣列是“資料型別”的集結,是進一步的封裝,比如

int arr[5];

就是宣告一個“由5個int型別的資料構成的陣列”,在記憶體空間中表現為“連續的”一塊區域

陣列在概念上是連續的,在物理上也是連續的

arr[0]=1;
arr[1]=2;
arr[2]=3;
arr[3]=4;

      你可以通過下標訪問陣列元素,而下標從0開始依次遞增看上去好像是連續的;而實際上(如圖)
這裡寫圖片描述
在物理儲存方式上它也是連續的;陣列的下標是陣列元素的唯一身份標識,這樣的“連續性”為我們訪問陣列元素提供了便利(空間複雜度僅為O(1))

陣列即然是資料的集結,那麼它的儲存就可能不在一處

int arr={1,2,3,4,}
     在C語言中你可以這樣宣告並初始化一個數組,其中右值是陣列實體部分,把它賦給左邊的變數
int[] arr=new int[4]
     同樣的你也可以在Java中使用new關鍵字宣告一個數組,這裡即然使用了new就表明“陣列是物件”,所以“陣列實體”或者說“陣列物件”儲存在“堆記憶體”中,而左邊的接受變數則儲存在“棧記憶體”裡面(如圖)這裡寫圖片描述
這裡“堆記憶體”是記憶體的一塊區域,用來存放“實體部分”而“棧記憶體”則用來存放一些變數之類的東西;可以看出“棧”和“堆”是記憶體空間的兩塊不同區域,所以說“陣列的儲存是不在一處的”,即實體和儲存變數分開儲存,變數擁有實體的“管理權”,通過“地址”(反映到實際中就是陣列元素的下標)訪問對應陣列元素;可以說,“陣列變數”和“陣列元素”存在這樣“一對多”的關係

實現變長陣列(Resizable Array)

int arr1[1];
int arr2[2]
int arr3[3];

     這裡建立了三個不同的陣列,一共可以管理1+2+3=6個不同的“元素型別”,但是現在陣列的缺點也顯現出來了:陣列一旦被建立其大小也就隨之確定,那麼怎樣才能實現“變長陣列”呢?這要用到一些封裝的技巧

typedef struct{
    int *array;
    int size;
} Array;

首先建立一個結構體Array

void increase_size(Array *a, int more_size)
{
    int *p=(int*)malloc(sizeof(int)(a->size+more_size));
    int i;
    for(i=0; i<a->a.size; i++){
        p[i]=a.array[i];
    }
    free(a->array);
    a->array=p;
    a->size+=more_size;
}

接著定義一個increase_size函式用來擴充套件陣列的大小;這個函式最終要被封裝在另外的函式(提示:放在返回index的函式中,只要期望index>=實際可用的index就increase_size)裡面以達到“自動”增長的目的,increase_size是實現增長的核心程式碼,反映在實體記憶體上就是“重新申請一塊新的記憶體空間並將老的陣列元素的值賦值給新的陣列元素的值”;值得一提的是,Java本身就提供有“可變陣列”並可以通過import java.util.ArrayList匯入使用,但是C語言並沒有提供這樣的東西(需要自己造輪子),這也許就是為什麼說C語言更接近底層的原因吧!

進一步封裝——使用結構定義自己的資料型別

Array a = create_array(100);

     接著上面的“可變陣列”的做法,先呼叫create_array函式建立一個初始大小為100的可變陣列,之後如果要往下標為100或者更大的地方寫資料,會呼叫increase_size函式並自動增加相應的大小以提供空間來寫資料;看上去很完美,但是可變陣列的問題,或者說malloc動態申請記憶體空間的缺點是:malloc出來的記憶體空間地址是值是單方向變化的,也就是說返回的指標所指的地址會接著原來被free掉的地址(如圖)這裡寫圖片描述 這裡假設黃色的是我們剛剛free掉的記憶體,這時候為了建立一個更大的陣列必須丟棄掉這個黃色的區域然後在它的後面開闢一塊新的記憶體空間如圖灰色的那一塊區域(不會使用原來的黃色區域,這就是free的意思);當這個灰色區又被用完了就丟棄並開闢藍色記憶體空間……周而復始(malloc出來的記憶體是單方向的!),這導致之前我們使用過而丟棄的記憶體空間不能被重複利用,所以總有一個時刻會“申請到右邊灰色的邊線”,於是就申請不動了——可是在左邊還有很多可以利用的空間,這就造成了所謂的“浪費”,這是動態申請記憶體的缺點;為此,我們要定義一種新的資料型別(或者說“結構”)

typedef struct _node{
    int value;
    struct _node *next;
} Node;

定義了一種新的資料型別“連結串列”,每個“連結串列的成員”除了尾結點都指向其它結點(使用next指標),如圖所示 這裡寫圖片描述

Node *p = (Node*)malloc(sizeof(Node));
p->value=number;
p->next=NULL;

用於對“連結串列”結構的每個成員初始化,其中number的值視具體而定

Node *last=head;
if(last){
    while(last->next){
        last=last->next;
    }
    last->next=p;
}else{
    head=p;
}

這樣就建立了一個連結串列,連結串列通過指標把相鄰的兩個元素集結在一起構成一個鏈式儲存儲存結構,在實體記憶體上可以是分散的,所以可以有效的利用記憶體空間,尤其當需要頻繁的插入或刪除“資料組合”中的元素時推薦使用“連結串列”;基本上每個連結串列成員都儲存著相鄰元素的地址指標,可是由於沒有統一的下標所以查詢元素的時候開銷比較大,這時候反而推薦使用在物理上連續儲存的陣列

更高階的結構——從十字連結串列開始探索

這裡寫圖片描述

     先放一張圖感受一下——這就是十字連結串列,可以儲存所謂的“稀疏矩陣”;十字連結串列也是一種“有機的資料儲存方式”,不同的是在原來相對簡單的“連結串列”的基礎上加上了額外的一個域用來存放額外的維度

typedef struct _OLNode{
    int value;
    int lin, col;
    struct _OLNode *right, *down;
} OLNode;

定義了一個十字連結串列,其中value是其要存放的值,lin和col分別表示行和列的資訊,而*right和*down則是用來指向下一個元素的指標域;由於引入了連個指標域所以可以在兩個維度上儲存資料並且可以“稀疏的”儲存資料,即“在邏輯認知上的空位處可以沒有元素”,和二維陣列不同的是二維陣列沒有指向相鄰元素的指標域所以必須填滿整個二維空間的矩形平面 這裡寫圖片描述 但是十字連結串列,由於引入了兩個指標域(在上面的矩形平面中)有些元素可以不填充(為NULL);以上就是十字連結串列的基本思路;不只是連結串列像這樣被封裝起來的資料結構還有很多種人們為了區分研究它們給予它們不同的命名,相應的不同的資料結構有著不同那個的特點,如先進後出的“棧結構”就是一種典型的資料結構型別;由於本文的目的是提供讀者以“導論”的作用,在此不再一一展開講解;說到底,“資料結構”就是“資訊”的高度抽象和有機封裝,從二進位制開始,層層封裝,產生出無窮多的可能性——當然這些可能性都是為了解決實際問題的,凡是脫離了實際問題的資料結構都是空中樓閣,是沒有靈魂的!