淺入淺出數據結構(6)——游標數組及其實現


  在前兩次博文中,我們由線性表講到數組,然后又由數組的缺陷提出了指針式鏈表。但是指針式鏈表也不是完美無缺的,在某些沒有指針數據類型的編程語言中,指針式鏈表是無法由我們來實現的,但是有時候我們又希望能用上鏈表,因為鏈表可以快速的進行插入和刪除。這個時候我們就可以使用一種由數組來實現的“鏈表”——游標數組。其優點是可以快速的插入和刪除(由於不需要指針式鏈表的malloc和free等內存操作,所以使用時游標數組可能會比指針式鏈表還要快一點),缺點則是繼承自數組——固定大小。

  綜合考慮來說,在C語言中如果要使用鏈表,我們一般優先考慮指針式,主要原因是鏈表的游標實現的固定大小不容忽視,而指針式雖然插入刪除略慢於游標數組,但整體上更具有適應性。那我們為什么還要討論游標數組呢?

  原因有兩個,第一是我覺得游標數組這種“精妙”的思想值得我寫一篇博文;第二是我覺得如果能理解游標數組的思想,那么對鏈表的理解就肯定到位了;第三是我個人認為指針式鏈表的誕生初衷是數組的固定大小帶來的弊端,而不是為了實現更快的插入刪除而出現的指針式鏈表有些人認為指針式鏈表的誕生初衷就是想快速插入刪除,能夠動態變化大小只是次要的“副作用”)。而游標數組支持我的觀點的一個例子,它可以快速插入刪除(比指針式更快,因為沒有內存操作),卻不能動態變化大小。

 

  好了,接下來我們就開始討論游標數組的想法及實現吧。

 

  首先我們要明白的一點就是:游標數組的“基礎思想”是鏈表,目的在於通過數組來模擬“鏈表”。那么,為了達到模擬或者說“山寨”的目的,我們自然要分析指針式鏈表的關鍵點有哪些,即分析出哪些點或概念是我們必須模擬出的,然后分析該如何模擬出那些點或概念。

  那么,根據“設計鏈表的過程”(http://www.cnblogs.com/mm93/p/6574912.html),我們可以發現,鏈表實現中很重要的一點就是“每個結點都正確記住了下一個結點的位置”廣義上說,數組型表的元素也“記住了”下一個元素的位置,即自身位置+1,但如果刪除元素時我們不移動其后面的元素,則原front元素所“記住的”下一元素位置就是錯的,我們希望的是“靈活”地記住正確的位置,而不是“死板”地記住+1),再進一步對這一點分析,很容易發現,正是這一點使得我們的插入、刪除變得非常快速。當我們插入某個結點時,需要改變的僅僅是front結點的next以及新結點的next(令它們指向新的正確的“下一結點位置”),而當我們刪除結點時,我們只需要改變front結點的next(同理,令它指向新的正確的“下一結點位置”)。

  既然這一點是實現快速插入與刪除的關鍵,那我們模擬時顯然不能丟下,因此我們先記下這一必須模擬的點,即:

  1.每個結點都正確記住了下一個結點的位置。

 

  知道了我們需要模擬的這一點后,我們就可以開始試着動手了。首先來畫畫圖,看看基本的實現概念是怎樣的(從刪除操作入手)

  這是一個普通的數組型線性表表:

 

  如果我們刪除了原元素2卻不將后面的元素向前移,則會出錯:

 

  想要解決這個錯誤很簡單,令元素1添加一個指向新元素2的“指針”變成“結點1”即可:

  

  顯而易見的,我們遇上了一個新的小問題,那就是在數組中,我們又該如何讓“結點1記住結點2的位置呢”?現在我們是假定沒有指針可用的,所以這個問題看起來很麻煩,但稍加思考就可以發現,數組中的元素都是“自帶地址”的,那就是它們的下標!於是我們的結點定義很快就出來了,那就是:

struct node
{
    char data;    //我們之后都將假定元素類型為char以便與next更好的區分
    size_t  next;    //指向下一元素下標
};

   結點的定義解決了,接下來擺在眼前的問題是,我們這個由數組“改”過來的“鏈表”該怎么對待大小問題呢?畢竟這可是數組的巨大缺陷之一呢!可是正如我們前面所說的:游標數組繼承了數組的缺點——固定大小。所以對於這個問題,我們的做法就是“不作為”,不僅如此,我們還要將這個數組定義得很大很大。一方面是防止元素個數超過了數組的極限,另一方面則是我們之后才會說的——將數組當做鏈表專用內存。我們暫且忽略第二點,只認為我們將數組定義的很大是為了防止表的大小超過數組極限。

#define SIZE 10000
typedef size_t Position;   //為了方便形象,我們設定類型別名
typedef struct node Node;

Node CursorSpace[SIZE];

   

  接下來讓我們先把目光放到插入操作上。假設我們執行插入到表尾的操作,我們就會發現下一個問題,那就是“表尾在哪”?在指針式鏈表中,這個問題很好解決,只要遍歷(遍歷就是指將所有結點訪問一次)鏈表直到某個結點的next==NULL時該結點就是最后一個結點。數組中也很好解決,只要用一個size_t記錄下線性表中有多少個元素即可,因為數組是“連貫”的,總共N個元素則表尾就在下標為N-1處。那么,游標數組該如何解決這個問題呢?其實也很簡單,類似於指針式鏈表尾元素的next==NULL,我們只要令表尾結點的next指向下標0就好了。但這又帶來一個小疑問,如果表尾指向0,那么CursorSpace[0].next又該是什么呢?嗯,這個問題將引出我們之前所說的“將數組當做鏈表專用內存”的討論。

  既然表尾的next指向0,那么CursorSpace[0]顯然不能再作為表中的元素,看起來我們似乎又浪費了數組中的一個位置(之前我們簡略討論了刪除操作的實現原理,但我們並沒有說被刪除的結點如何“回收”,因此我們暫時認為被刪除的結點是被永久拋棄了)。然而實際上,我們可以利用這個“廢棄”的CursorSpace[0]來做一些更有意義的事情,甚至可以說是因為我們把CursorSpace[0]拿去做更有意義的事情了,才使得它得以空閑出來做表尾next的“NULL”。

  那么,CursorSpace[0]要做的更有意義的事情是什么呢?當然是一個很重要的、我們還沒有解決的事情嘍!回顧插入操作,我們會發現,鏈表中的插入操作第一步是“分配新結點”,而我們的游標數組中雖然有大量的空位置,但我們還沒有用來找到空位置的辦法呢!(我們必須得准確地找到空位置,不然將表中的某個結點當做空位置分配給新結點可不是什么好事,這一點在指針式鏈表中通過malloc實現)現在我們有辦法了,就是讓CursorSpace[0].next來告訴我們哪兒有空位置,通過它,我們可以實現“在數組中malloc”一般的功能!嗯,不錯,但如果我們繼續“推進”,很快就會發現下一個問題,當CursorSpace[0].next所指的那個空位置被用掉了,該如何“更新”CursorSpace[0].next使它再次指向一個空位置?這個問題看似麻煩,其實簡單,只要讓被用掉的原空位置在“臨死”前(最近英雄電影看多了……)告訴我們另一個空位置就好了。也就是說,CursorSpace[0]知道一個空位置,而那個空位置又知道另一個空位置,另一個空位置又知道另另一個空位置,以此類推。這樣一來,我們就將空位置們“鏈接”起來了~要做到這一點很也很簡單,只要這樣的一個初始化程序就好了:

void Init(int N)
{
    for (int i = 0;i < N-1;++i)
        CursorSpace[i].next = i + 1;
    CursorSpace[N - 1].next = 0;
}

  不難理解,初始化后的游標數組中只有負責malloc的第一個元素和空位置,CursorSpace[0]指向了第一個空位置[1],而[1]又指向了[2],以此類推,最終所有空位置都指向了下一個空位置,且不會有兩個空位置同時指向一個空位置,[SIZE-1]指向0表示其是最后一個空位置。

  

  接下來,讓我們“手動追蹤”一下數組的操作變化(紅色為表頭,黃色為表中元素,黑色為空位置,藍色為被刪除結點,暫不回收

  初始化后,從CursorSpace[0].next到CursorSpace[n-1].next是這樣的:

1,2,3,4,5,6,7,8,9,10……SIZE-1

  假設我們插入一個元素,那么根據CursorSpace[0].next,開辟的結點是CursorSpace[1],CursorSpace[0].next變為CursorSpace[1].next以指向新的空位置:

2,0,3,4,5,6,7,8,9,10……SIZE-1

  我們繼續插入一個元素,則:

3,2,0,4,5,6,7,8,9,10……SIZE-1

  刪除掉表中第一個元素,則:

3,X,0,4,5,6,7,8,9,10……SIZE-1

  再插入,則:

4,X,3,0,5,6,7,8,9,10……SIZE-1

 

  實話實說,我覺得上面這個“手動追蹤”例子可能會讓人更直觀地感受一次游標數組的操作,也可能會讓人更迷糊,但如果認真走一遍“手動追蹤”應該是可以達到前者目的的╮(╯_╰)╭

  講到這兒,差不多也該稍稍總結一下游標數組的特點了,不然估計得出現每一步都懂但最后就是不會的尷尬局面……

  游標數組(尚未考慮刪除結點的回收)的特點有這么幾個:

  1.數組中下標為0的CursorSpace[0]保存着一個空位置的下標(下標即游標數組中的“地址”),它可以給我們起到“malloc”的功能

  2.我們在程序中定義一個Position類型的head(或別的名字,都行,類似於指針式鏈表中程序所存儲的那個List變量)存儲我們“鏈表”的表頭位置,其值為表的第一個結點在數組中的下標

  3.從表中第一個結點開始,表中的每一個結點的next都保存着表中下一個結點的下標,除了表尾,表尾結點的next==0

  4.為了實現1,數組中的空位置的next均指向“下一個”空位置的下標,除了“最后一個空位置”,“最后一個空位置”的next為0

  5.“最后一個空位置”初始化時為CursorSpace[SIZE-1],即數組的最后一個位置

  6.當分配掉CursorSpace[0].next所指的位置時,令CursorSpace[0].next=分配掉的結點.next(此處寫“分配掉的結點”是為了邏輯簡單易懂,真實代碼應為CursorSpace[0].next=CursorSpace[CursorSpace[0].next].next)

  7.顯然地,當分配掉最后一個空結點時,因為最后一個空結點.next為0,所以CursorSpace[0].next=最后一個空結點.next,會令CursorSpace[0].next=0,所以當我們發現CursorSpace[0].next==0時我們認為數組(鏈表專用內存)已滿,“malloc”失敗

 

  再次回顧,可以發現,我們先是通過“下標”模擬“指針”從而確定了游標數組的結點形式,也就解決了查找、插入、刪除最核心的問題;然后,我們通過利用CursorSpace[0]及其next和所有空結點的next,解決了模擬“malloc”的方法CursorSpace[0].next及所有空結點的next,而有了模擬“malloc”的方法,自然而然地,我們就找到了實現插入的方法。

 

#include <stdio.h>
#define SIZE 1000

struct node
{
    char data;  //使用char作為元素類型以方便區分next
    size_t  next;   
};
typedef size_t Position;
typedef struct node Node;  
typedef Position Head;  //新增的類型別名,用於在程序中保存表頭下標

Node CursorSpace[SIZE];

//初始化全局“內存”,但我們不顯式調用它,初始化會由Create()完成
void Init(int N)
{
    for (int i = 0;i < N-1;++i)
        CursorSpace[i].next = i + 1;
    CursorSpace[N - 1].next = 0;
}

//用於創建一個新的鏈表,起到類似於malloc的功能,同時初始化分配到的“頭結點”
Head Create(char Elem)
{
    //靜態變量,用於判斷是否已初始化“內存”
    static bool IsInit = false;
    if (!IsInit)
    {
        Init(SIZE);
        IsInit = true;
    }

    //若CursorSpace[0].next==0則說明“內存”中已沒有空位置可分配
    if (CursorSpace[0].next == 0)
        return -1;
    //反之則說明“內存”中尚有位置
    else
    {
        Head result;  //用於返回,作用相當於“表頭指針”
        result = CursorSpace[0].next;   //CursorSpace[0].next總是指向一個“空位置”或0,但此時必然不是0
        CursorSpace[0].next = CursorSpace[result].next; //令CursorSpace[0].next指向下一個“空位置”或0(若分配的位置是最后一個空位置)

        CursorSpace[result].data = Elem;  //令新分配的“結點”的數據域保存新元素
        CursorSpace[result].next = 0; //令新分配“結點”的next為0表示其是表中最后一個結點
        return result;  //相當於返回指向新結點的“指針”
    }
}


//查找函數,用於返回第N位置上的結點(默認Head中至少有一個結點)
Node Find(Head Head, int N, bool *success)
{
    //先用局部變量保存下“頭結點”位置
    Position temp = Head;

    if (N <= 0)
    {
        (*success) = false;
        return CursorSpace[0]; //返回值是“隨意的”,調用者需要自己對success進行判斷來確定是否成功
    }

    //稍微注意一下循環次數
    for (int i = 1;i < N;++i)
    {
        //若出現CursorSpace[temp].next==0的情況,即未到第N位置卻已到表尾,說明N越界
        if (CursorSpace[temp].next == 0)
        {
            (*success) = false;
            return CursorSpace[0];
        }
        temp = CursorSpace[temp].next;  //類似於指針式鏈表
    }
    (*success) = true;
    return CursorSpace[temp];
}

//插入函數,接收Head*因為可能要修改Head指向的位置(插入到第1個位置時),N為要插入的位置,Elem為要插入的元素
bool Insert(Head* Head, int N ,char Elem)
{
    //簡單地判斷是否非法
    if ((*Head) <= 0)
        return false;

    //若CursorSpace[0].next==0則說明“內存”中已沒有空位
    if (CursorSpace[0].next == 0)
        return false;

    //分配一個空位置並初始化(沒有修改next因為時候未到),令CursorSpace[0].next保存下一個空位置(一個空位置的next指向另一個空位置或0)
    Position newIndex = CursorSpace[0].next;
    CursorSpace[0].next = CursorSpace[newIndex].next;
    CursorSpace[newIndex].data = Elem;

    //若希望的插入位置為1,則特殊對待(需要利用Head*)
    if (N == 1)
    {
        CursorSpace[newIndex].next = (*Head);  //注意我們現在修改了新結點的next
        (*Head) = newIndex;
    }
    //若N<0則插入到表末尾
    else if (N <= 0)
    {
        Position temp = (*Head);
        for (;CursorSpace[temp].next != 0;++temp);
        CursorSpace[temp].next = newIndex;
        CursorSpace[newIndex].next = 0;
    }
    //其他情況下我們將結點插入到第N位置上
    else
    {
        Position temp = (*Head);
        //注意循環次數,我們停在了第N-1個結點處
        for (int i = 1;i < N - 1;++i)
        {
            //若還沒到N-1處就到了表的末尾,則出錯
            if (CursorSpace[temp].next == 0)
            {
                return false;
            }
            temp = CursorSpace[temp].next;
        }
        //此處類似指針式鏈表操作
        CursorSpace[newIndex].next = CursorSpace[temp].next;
        CursorSpace[temp].next = newIndex;
    }
    
    return true;
}

 

  在我們開始討論刪除操作之前,我們先給出一個簡單的遍歷輸出函數:

void printCursor(Head h)
{
    Position temp = h;
    for(int i = 0;i < SIZE; ++i)
    {
        if (CursorSpace[temp].next == 0)
        {
            printf("%c\n", CursorSpace[temp].data);
            return;
        }
        printf("%c", CursorSpace[temp].data);
        temp = CursorSpace[temp].next;
    }
}

 

  好了,現在我們可以來討論討論刪除操作了。

  乍一看,Delete應該是一項很簡單的工作,只要令CursorSpace[front].next=CursorSpace[front].next.next就行了。在指針式鏈表中,我們必須記得free()來釋放結點所占的位置,但在游標數組中這一操作似乎變得可有可無,畢竟我們並沒有真的調用過malloc()。但是稍加思考我們就會發現,如果我們沒有模擬free()操作,那么其帶來的影響和指針式鏈表Delete時未free()是類似的,都會造成“內存占用浪費”,如果我們在游標數組中Delete時沒有實現free(也就是我們之前一直說的“回收”),那么我們的鏈表其實最多只能累積Insert()SIZE次(即使你delete過某些結點,它們也被永久廢棄,而整個數組就只有SIZE大小)。

  因此,我們的游標數組必然需要考慮如何模擬free(),否則Delete帶來的浪費實在太大了。

 

  其實模擬free()在游標數組中的關鍵點就是:怎么讓被delete的結點插入到“空位置組成的那個鏈表”里。可能這么說有點繞口,再簡單點說就是:怎么讓要回收的結點可以被CursorSpace[0].next找到?之前被delete的結點會永久廢棄就是因為不論是CursorSpace[0].next還是其它空位置,沒有誰的next是指向要回收的結點的,我們要改變的就是這一點,就是要讓CursorSpace[0]或其它空結點知道某個結點又“回來了”。

  其實解決的方法簡單得不行,CursorSpace[0].next不是指向一個空位置嗎?那我們就讓它指向我們回收的這個“新”空位置(即discard,丟棄的結點的下標):CursorSpace[0].next=discard;那原先的空位置們怎么辦?簡單啊,在CursorSpace[0].next=discard;之前,先CursorSpace[discard].next=CursorSpace[0].next不就行了?

 

  很快,我們的Delete函數就出爐了:

bool Delete(Head * Head, int N)
{
    //簡單地判斷是否非法
    if ((*Head) <= 0)
        return false;

    Position temp = (*Head);

    //默認N<0時刪除尾結點
    if (N <= 0)
    {
        //若只有一個結點
        if (CursorSpace[temp].next == 0)
        {
            CursorSpace[temp].next = CursorSpace[0].next;
            CursorSpace[0].next = temp;
            (*Head) = 0;
            return true;
        }
        else
        {
            //注意循環條件,我們在倒數第二個結點處停下
            while (CursorSpace[CursorSpace[temp].next].next != 0)
                temp = CursorSpace[temp].next;

            Position discard = CursorSpace[temp].next;
            CursorSpace[temp].next = CursorSpace[discard].next; //CursorSpace[discard].next就等於0
            CursorSpace[discard].next = CursorSpace[0].next;
            CursorSpace[0].next = discard;
            return true;
        }
    }
    else if (N == 1) //刪除第一個結點
    {
        (*Head) = CursorSpace[temp].next;
        CursorSpace[temp].next = CursorSpace[0].next;
        CursorSpace[0].next = temp;
        return true;
    }
    else if (N > 1)
    {
        //注意循環次數,我們停在第N-1個結點處
        for (int i = 1;i < N - 1;++i)
        {
            //若出現CursorSpace[temp].next==0的情況說明鏈表大小小於N
            if (CursorSpace[temp].next == 0)
                return false;
            temp = CursorSpace[temp].next;
        }
        Position discard = CursorSpace[temp].next;
        CursorSpace[temp].next = CursorSpace[discard].next;
        CursorSpace[discard].next = CursorSpace[0].next;
        CursorSpace[0].next = discard;
        return true;
    }

    return false;
}

 

  Oh,Yeah!看起來一切都萬事大吉了!我們利用數組的下標,模擬了內存的地址;利用數組的一個位置(CursorSpace[0]),我們模擬出了malloc;通過一點小智慧,我們實現了free也即回收;通過這些模擬,我們完成了游標數組!

  但是等一等,我們貌似還是沒說出“鏈表專用內存”是個什么玩意兒……哈哈,其實這個東西沒什么高大上的!你想想看,我們幾乎模擬出了整套鏈表的內存操作:地址、malloc、free,那這整個數組是不是就相當於一個“專屬內存”?一個只能存儲指定結點的,專用於鏈表的“內存”?並且!既然是一個“內存”,就說明它可以支持不僅僅一個鏈表在其中!這才是我們真正強調“內存”的意義所在哦~

  稍加思考就可以發現,其實我們只要不斷通過Create()得到一個“頭結點”,然后在Insert()和Delete()時給定“頭結點”,我們就可以在一個游標數組中保存多個結點結構相同的“鏈表”!!

  是不是瞬間明白了為什么我們說要盡可能的把數組定義得大一點的原因呢?O(∩_∩)O

  好了,這下我們是真的把游標數組講完了orz     希望這篇文章做到了將游標數組淺顯易懂的講解的目的呢~

 

 

—————————————————————————————————————————————————————————————

寫這篇博文花了挺長時間,期間一直試圖將知識點拆散成合適的講解順序,並且將各個關鍵點都講得淺顯易懂,但過程中卻經常出現“越想講簡單,就越容易講詳細,越講詳細就越容易講復雜”的怪圈,希望哪里講解的不好不對的,能夠有人提醒我一下,寫完已是深夜,所以結尾略草率,但粗看一遍覺得還是不難的,因此就此打住,發布!

—————————————————————————————————————————————————————————————

  

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM