數據結構與算法分析-表

抽象數據類型

抽象數據類型(abstract data type,ADT) 是一些操作的集合。抽象數據類型是數學的抽象;在ADT的定義中根本沒涉及如何實現操作的集合。

表ADT

將處理一般的形如A1,A2,A3,,ANA_1,A_2,A_3,\cdots,A_N的表。這個表的大小是NN。稱大小爲0的表爲空表(empty list​)

對於除空表外的任何表,我們說Ai+1A_{i+1}後繼AiA_i(或繼AiA_i之後)並稱Ai1(i<N)A_{i-1}(i<N)前驅Ai(i>1)A_i(i>1)。表中的第一個元素是A1A_1,而最後一個元素是ANA_N。我們將不定義A1A_1的前驅元,也不定義ANA_N的後繼元。元素AiA_i在表中的位置爲ii

與這些“定義”相關的是我們要在表ADT上進行的操作的集合。PrintListPrintListMakeEmptyMakeEmpty是常用的操作,其功能顯而易見;FindFind返回關鍵字首次出現的位置;InsertInsertDeleteDelete一般是從表的某個位置插入和刪除某個關鍵字;而FindKthFindKth則返回某個位置上(作爲參數被指定)的元素。

表的簡單數組實現

對錶的所有操作都可以通過使用數組來實現。雖然數組是動態指定的,但是還是需要對錶的大小的最大值進行估計。通常需要估計得大一些,從而會浪費大量的空間。這是嚴重的侷限,特別是在存在許多未知大小的表的情況下。

數組實現使得PrintListPrintListFindFind正如所預期的那樣以線性時間執行,而FindKthFindKth則花費常數時間。然而,插入和刪除的花費是昂貴的。這兩種操作的最壞情況爲O(N)O(N)

因爲插入和刪除的運行時間是如此的慢以及表的大小還必須事先已知,所以簡單數組一般不用來實現表這種結構。

鏈表

爲了避免插入和刪除的線性開銷,我們需要允許表可以不連續存儲,否則表的部分或全部需要整體移動。

鏈表由一系列不必在內存中相連的結構組成。每一個結構均含有表元素和只想包含該元素後繼元的結構的指針。我們稱之爲NextNext指針。最後一個NextNext指針指向NULL。

程序設計細節

我們將留出一個標誌結點,有時候稱之爲表頭(header)啞結點(dummy node)

作爲例子,我們將把這些表ADT的半數例程編寫出來。首先,下面給出我們需要的聲明。

#ifndef _List_h
#define _List_h

typedef int ElementType;
struct Node;
typedef struct Node *PtrToNode;
typedef PtrToNode List;
typedef PtrToNode Position;

List MakeEmpty(List L);
int IsEmpty(List L);
int IsLast(Position P, List L);
Position Find(ElementType X, List L);
void Delete(ElementType X, List L);
Position FindPrevious(ElementType X, List L);
void Insert(ElementType X, List L, Position P);
void DeleteList(List L);
Position Header(List L);
Position First(List L);
Position Advance(Position P);
ElementType Retrieve(Position P);

#endif /* _List_h */

/* Place in the implementation file */
struct Node
{
    ElementType Element;
    Position Next;
};

按照C的約定,作爲類型的List(表)Postiion(位置)以及函數的原型都列在所謂的.h頭文件中。具體的Node(結點)聲明則在.c文件中。

我們將編寫的第一個函數是測試空表的。當我們編寫涉及指針的任意數據結構的代碼時,最好總是要先畫出一張圖來。下面很容易寫出了該函數。

/* Return true if L is empty */
int IsEmpty(List L)
{
    return L->Next == NULL;
}

下一個函數在下面表出,它測試當前的元素是否是表的最後一個元素,假設這個元素是存在的。

/* Return true if P is the last position in list L */
/* Parameter L is unused in thie implementation */
int IsLast(Position P, List L)
{
    return P->Next == NULL;
}

我們要寫的下一個例程是FindFindFindFind在下面表出,它返回某個元素在表中的位置。第6行用到與(&&)操作走了捷徑,即結果與(and)運算的前半部分爲假,那麼結果就自動爲假,而後半部分則不再執行。

/* Return Position of X in L; NULL if not found */
Position Find(ElementType X, List L)
{
    Position P;
    P = L->Next;
    while(P != NULL && P->Element != X)
        P = P->Next;
    return P;
}

有些編程人員發線遞歸地編寫FindFind例程頗有吸引力,大概是因爲這樣可能避免冗長的終止條件。這是一個非常糟糕的想法,我們要不惜一切代價避免它。

第四個例程是刪除表LL中的某個元素XX。我們的例程將刪除第一次出現的XX,如果XX不在表中我們就什麼也不做。爲此,我們通過調用FindPreviousFindPrevious函數找出含有XX的表元的前驅元PP。下面是代碼實現。

/* Delete first occurrence of X from a list */
/* Assume use of header node */
void Delete(ElementType X, List L)
{
    Position P, TmpCell;
    P = FindPrevious(X, L);
    if(!IsLast(P, L))	/* Assumeption of header use */
    {					/* X is found; deleted it */
        TmpCell = P->Next;
        P->Next = TmpCell->Next;	/* Bypass deleted cell */
        free(TmpCell);
    }
}

FindPreviousFindPrevious例程類似於FindFind,在下面給出。

/* If X is not found, then Next field of returned */
/* Position is NULL */
/* Assumes a header */
Position FindPrevious(ElementType X, List)
{
    Position P;
    P = L;
    while(P->Next != NULL && P->Next->Element != X)
        P = P->Next;
    return P;
}

最後一個例程是插入例程。將要插入的元素與表LL和位置PP一起傳入。這個InsertInsert例程將一個元素插入到由PP所指示的位置之後。下面是代碼。

/* Insert (after legal position P) */
/* Header implementation assumed */
/* Parameter L is unused in this implementation */
void Insert(ElementType X, List L, Position P)
{
    Position TmpCell;
    TmpCell = (struct Node*)malloc(sizeof(struct Node));
    if (TmpCell == NULL)
        FatalError("Out of space!!!");
    TmpCell->Element = X;
    TmpCell->Next = P->Next;
    P->Next = TmpCell;
}

注意,我們已經把表LL傳遞給InsertInsert例程和IsLastIsLast例程,儘管它從未被使用過。之所以這麼做,是因爲別的實現方法可能會需要這些信息,因此,若不傳遞表LL有可能使得使用ADT的想法失敗。

這個是合法的,不過有些編譯器會發出警告。

除了FindFindFindPreviousFindPrevious例程外(還有例程DeleteDelete,它調用FindPreviousFindPrevious),我們已經編碼的所有操作均需O(1)O(1)時間。對於例程FindFindFindPreviousFindPrevious,在最壞的情況下運行時間是O(N)O(N),因此此時若元素未找到或位於表的末尾則可能遍歷整個表。平均來看,運行時間是O(N)O(N),因爲必須平均掃描半個表。

常見的錯誤

最常遇到的錯誤是你的程序因來自系統的棘手的錯誤信息而崩潰,比如“memory access violation”或“segmentation violation”這種信息通常意味着有指針變量包含了僞地址。一個通常的原因是初始化變量失敗。一個典型的錯誤就是關於上面插入例程的代碼中的最後一行,如果P是NULL​,則指向是非法的。這個函數知道P不是NULL,所以例程沒有問題。無論何時只要確定一個指向,那麼你就必須保證該指針不是NULL。有些C編譯器隱式地做了這種檢查,不過這並不是C標準的一部分。當將程序從一個編譯器移至另一個編譯器下時,可能就會發現不再正常運行。這就是這種錯誤常見的原因之一。

有些空間不再需要時,可以用free​命令通知系統來回收它。free§的結果是:P正在指向的地址沒變,但在該地址處的數據此時已無定義了。

作爲一個例子,下面代碼就不是刪除整個表的正確方法(雖然在有些系統上它能夠運行)。

/* Incorrect DeleteList algorithm */
void DeleteList(List L)
{
    Position P;
    P = L->Next;	/* Header assumed */
    L->Next = NULL;
    while(P != NULL)
    {
        free(P);
        P = P->Next;
    }
}

下面顯示了刪除工作的正確方法。

/* Correct DeleteList algorithm */
void DeleteList(List L)
{
    Position P, Tmp;
    P = L->Next;	/* Header assumed */
    L->Next = NULL;
    while(P != NULL)
    {
        Tmp = P->Next;
        free(P);
        P = Tmp;
    }
}

處理閒置空間的工作未必很快完成,因此可能要檢查看是否處理的例程會引起性能下降,如果是則要考慮周密。

雙鏈表

雙鏈表(doubly linked list)只要在數據結構上附加一個域,使它包含指向前一個單元的指針即可。其開銷是一個附加的鏈,它增加了空間的需求,同時也使得插入和刪除的開銷增加一倍,因爲由更多的指針需要定位。另一方面,它簡化了刪除操作,不再被迫使用一個指向前驅元的指針來訪問一個關鍵字。

循環鏈表

讓最後的單元反過來直指第一個單元。它可以有表頭,也可以沒有表頭(若有表頭,則最後的單元就指向它),並且還可以是雙向鏈表(第一個單元的前驅元指針指向最後的單元)。這無疑會影響某些測試,不過這種結構在某些應用程序中卻很流行。

例子

我們提供三個使用鏈表的例子。第一例是表示一元多項式的簡單方法。第二例是在某些特殊情況下以線性時間進行排序的一種方法。最後,我們介紹一個複雜的例子,它說明了鏈表如何用於大學的課程註冊。

多項式ADT

我們可以用表來定義一種關於一元(具有非負次冪)多項式的抽象數據類型。令F(X)=i=0NAiXiF(X)=\sum^N_{i=0}A_iX^i。如果大部分系數非零,那麼我們可以用一個簡單數組來存儲這些係數。然後,可以編寫一些對多項式進行加、減、乘、微分及其他操作的例程。下面代碼給出類型聲明。

typedef struct
{
    int CoeffArray[MaxDegree + 1];
    int HighPower;
} * Polynomial;

這時,我們就可編寫進行各種不同的操作的例程了。加法和乘法是兩種可能的運算;下面代碼給出。

/* 將多項式初始化爲零的過程 */
void ZeroPolynomial(Polynomial Poly)
{
    int i;
    for(i = 0; i <= MaxDegree; i++)
        Poly->CoeffArray[i] = 0;
    Poly->HighPower = 0;
}
/* 兩個多項式相加的過程 */
void AddPolynomial(const Polynomial Poly1, const Polynomial Poly2, Polynomial PolySum)
{
    int i;
    ZeroPolynomial(PolySum);
    PolySum->HighPower = Max(Poly1->HighPower, Poly2->HighPower);
    for(i = PolySum->HighPower; i >= 0; i--)
    {
        PolySum->CoeffArray[i] = Poly1->CoeffArray[i] + Poly2->CoeffArray[i];
    }
}
/* 兩個多項式相乘的過程 */
void MultPolynomial(const Polynomial Poly1, const Polynomial Poly2, Polynomial PolyProd)
{
    int i, j;
    ZeroPolynomial(PolyProd);
    PolyProd->HighPower = Poly1->HighPower + Poly2->HighPower;
    if(PolyProd -> HighPower > MaxDegree)
        Error("Exceeded array size");
    else
        for(i = 0; i <= Poly1->HighPower; i++)
            for(j = 0; j <= Poly2->HighPower; j++)
                PolyProd->CoeffArray[i + j] += Poly1->CoeffArray[i] * Poly2->CoeffArray[j];
}

另一種方法是使用單鏈表(singly linked list)。多項式的每一項含在一個單元中,並且這些單元以次數遞減的順序排序。下面代碼實現了類型聲明。

typedef struct Node *PtrToNode;

struct Node
{
    int Coefficient;
    int Exponent;
    PtrToNode Next;
};

typedef PtrTONode Polynomial;	/* Nodes sorted by exponent */

上述操作將很容易實現。唯一的潛在困難在於,當兩個多項式相乘的時候所得到的多項式必須合併同類項。這可以用多種方法實現。

基數排序

使用鏈表的第二個例子叫做基數排序(radix sort)基數排序有時也成爲卡式排序(card sort),因爲直到現代計算機出現之前,它一直用於對老式穿孔卡的排序。

如果我們有NN個整數,範圍從11MM(或從00M1M-1),我們可以利用這個信息得到一種快速的排序,叫做桶式排序(bucket sort)。我們留置一個數組,稱之爲CountCount,大小爲MM,並初始化爲零。於是,CountCountMM個單元(或桶),開始時它們都是空的。當AiA_i被讀入時Count[Ai]Count[A_i]11。在所有的輸入被讀進以後,掃描數組CountCount,打印輸出排好序的表。該算法花費O(M+N)O(M+N)。如果M=Θ(N)M=\Theta(N),則桶式排序爲O(N)O(N)

基數排序就是這種方法的推廣。設我們有1010個數,範圍在00999999之間,我們將其排序。一般來說,這是00Np1N^p-1間的NN個數,pp是某個常數。顯然,我們不能使用桶式排序,那樣桶就太多了。我們的策略是使用多趟桶式排序。我們用最低(有效)“位”優先的方式進行桶式排序,那麼算法將得到正確結果。當然,有可能多餘一個數落入相同的桶中,但有別於原始的桶式排序,這些數可能不同,因此我們把它們放到一個表中。注意,所有的數可能都有某位數字,因此如果使用簡單的數組表示表,那麼每個數組必然大小爲NN,總的空間需求是Θ(N2)\Theta(N^2)

下面例子說明10個數的桶式排序的具體做法。本例輸入是64,8,216,512,27,729,0,1,343,125(前10個立方數,隨機排列)。第一步按照最低位優先進行桶式排序。爲使問題簡化,此時操作按基是10進行,不過一般並不做這樣的假設。下面顯示出這些桶的位置。

0 1 512 343 64 125 216 27 8 729
0 1 2 3 4 5 6 7 8 9

因此按最低位優先排序得到的表是0,1,512,343,64,125,216,27,8,729。

現在再按照次最低位(即10位上的數字)優先進行第二趟排序。

8 729
1 216 27
0 512 125 343 64
0 1 2 3 4 5 6 7 8 9

第二趟排序輸出0,1,8,512,216,125,27,729,343,64。

現在這個表是按兩個最小的位排序得到的表。最後一趟桶式排序是按最高位進行的,其結果如下。

64
27
8
1
0 125 216 343 512 729
0 1 2 3 4 5 6 7 8 9

最得到的表是0,1,8,27,64,125,216,343,512,729。

爲使算法能夠得出正確的結果,要注意唯一出錯的可能是如果兩個數出自同一個桶但順序確是錯誤的。不過,前面各趟排序保證了當幾個數進入一個桶的時候,它們是以 排序的順序進入的。

該排序的運行時間是O(P(N+B))O(P(N+B)),其中PP是排序的趟數,NN是要被排序的元素的個數,而BB是桶數。本例中B=NB=N

多重表

最後一個例子闡述鏈表更復雜的應用。一所40000名學生和2500門課程的大學需要生成兩種類型的報告。第一個報告列出每個班的註冊者,第二個報告列出每個學生註冊的班級。

常用的實現方法是使用二維數組。這樣一個數組將有1億項。平均大約一個學生註冊三門課程,因此實際上有意義的數據只有120000項,大約佔0.1%。

現在需要的是列出每個班及每個班所包含的學生的表。我們也需要每個學生及其所註冊的班級的表。如下圖所示實現方法。

註冊問題的多重表實現

如該圖所顯示的,我們已經把兩個表合併成爲一個表。所有的表都各有一個表頭並且都是循環的。比如,爲了列出C3班的所有學生,我們從C3開始通過向右行進而遍歷其表。第一個單元屬於學生S1。雖然不存在明顯的信息,但是可以通過跟蹤該生鏈表直達到該表表頭而確定該生的信息。一旦找到該生信息,我們就轉回到C3的表(在遍歷該生的表之前,我們存儲了在課表中的位置)並找到可以確定屬於S3的另外一個單元,我們繼續併發線S4和S5也在該班上。對任意一名學生,我們也可以用類似的方法確定該生註冊的所有課程。

使用循環表節省空間但是要花費時間。在最壞的情況下,如果第一個學生註冊了每一門課程,那麼表中的每一項都要檢測以確定該生的所有課程名。如果懷疑會產生問題,那麼每一個(非表頭)單元就要有直接指向學生和班的表頭指針。這使空間的需求加倍,但是卻簡化和加速實現的過程。

鏈表的遊標實現

諸如BASIC和FORTRAN等許多語言都不支持指針。如果需要鏈表又不能使用指針,那麼就必須使用另外的實現方法。我們將描述這種方法並稱爲*遊標(cursor)*實現法。

在鏈表的指針實現中有兩個重要的特點。

  1. 數據存儲在一組結構體中。每一個結構體包含有數據以及指向下一個結構體的指針。
  2. 一個新的結構體可以通過malloc而從系統全局內存(global memory)得到,並通過調用free而被釋放。

遊標法必須能夠模仿實現這兩條特性。滿足條件1的邏輯方法是要有一個全局的結構體數組。對於該數組中的任何單元,其數組下標可以用來代表一個地址。下面代碼給出鏈表遊標實現的聲明。

#ifndef _Cursor_h
#define _Cursor_h

typedef int ElementType;

typedef int PtrToNode;
typedef PtrToNode List;
typedef PtrToNode Position;

void InitializeCursorSpace(void);

List MakeEmpty(List L);
int IsEmpty(const List L);
int IsLast(const Position P, const List L);
Position Find(ElementType X, const List L);
void Delete(ElementType X, List L);
Position FindPrevious(ElementType X, const List L);
void Insert(ElementType X, List L, Position P);
void DeleteList(List L);
Position Header(const List L);
Position First(const List L);
Position Advance(const List L);
ElementType Retrieve(const Position P);

#endif /* _Cursor_h */


/* Place in the implementation file */
#define SpaceSize 10

struct Node
{
    ElementType Element;
    Position Next;
};

struct Node CursorSpace[SpaceSize];

現在我們必須模擬條件2,讓CursorSpace數組中的單元代行malloc和free的職能。爲此,我們將保留一個表(即freelist),這個表由不在任何表中的單元構成。該表用單元0作爲表頭。其初始配置爲下圖表示。

CoursorSpace

對於Next,0的值等價於NULL指針。CursorSpace的初始化是一個簡單的循環結構。爲執行malloc功能,將(在表頭後面的)第一個元素從freelist中刪除。爲了執行free功能,我們將單元放在freelist的前端。下面表示出malloc和free的遊標的實現。

Position CursorAlloc(void)
{
    Position P;
    P = CursorSpace[0].Next;
    CursorSpace[0].Next = CursorSpace[P].Next;
    return P;
}

void CursorFree(Position P)
{
    CursorSpace[P].Next = CursorSpace[0].Next;
    CursorSpace[0].Next = P;
}

注意,如果沒有可用空間,那麼我們的例程通過置P = 0會正確地實現。它表明沒有空間可用,並且也可以使CursorAlloc的第二行成爲空操作(no-op)。

爲了前後一致,我們將用一個頭結點實現鏈表。作爲一個例子,下圖中,如果L的值是5而M的值爲3,則L表示鏈表a,b,e,而M表示鏈表c,d,f。

鏈表遊標實現的例子

爲了寫出用遊標實現鏈表的這些函數,我們必須傳遞和返回與指針實現時相同的參數。下面是一個測試表是否爲空表的函數。

/* Return true if L is empty */
int IsEmpty(List L)
{
    return CursorSpace[L].Next == 0;
}

下面實現對當前位置是否是表的末尾的測試。

/* Return true if P is the last position in list L */
/* Parameter L is unused in this implementation */
int IsLast(Position P, List L)
{
    return CursorSpace[P].Next == 0;
}

下面實現函數Find返回表L中X的位置。

/* Return Position of X in L; 0 if not found */
/* Uses a header node */
Position Find(ElementType X, List L)
{
    Position P;
    P = CursorSpace[L].Next;
    while(P && CursorSpace[P].Element != X)
        P = CursorSpace[P].Next;
    return P;
}

實現刪除的代碼。

/* Delete first occurrence of X from a list */
/* Assume use of a header node */
void Delete(ElementType X, List L)
{
    Position P, TmpCell;
    P = FindPrevious(X, L);
    if(!IsLast(P, L))	/* Assumeption of header use */
    {					/* X is found; delete it */
        TmpCell = CursorSpace[P].Next;
        CursorSpace[P].Next = CursorSpace[TmpCell].Next;
        CursorFree(TmpCell);
    }
}

最後,給出Insert的遊標實現。

/* Insert (after legal position P) */
/* Header implementation assumed */
/* Parameter L is unused in this implementation */
void Insert(ElementType X, List L, Position P)
{
    Position P, TmpCell;
    TmpCell = CursorAlloc();
    if(TmpCell == 0)
        FatalError("Out of space!!!");
    CursorSpace[TmpCell].Element = X;
    CursorSpace[TmpCell].Next = CursorSpace[P].Next;
    CursorSpace[P].Next = TmpCell;
}

遊標實現可以用來代替鏈表實現,實際上在程序的其餘部分不需要變化。由於缺少內存管理例程,因此,如果運行的Find函數相對很少,則遊標實現的速度會顯著加快。

freelist從字面上看錶示一種有趣的數據結構。從freelist刪除的單元是剛剛由free放在那裏的單元。因此,最後被放在freelist的單元是被最先拿走的單元。有一種數據結構也具有這種性質,叫做棧(stack)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章