C初學者如何從內置基本數據類型進階到抽象高級數據類型

注:《C primer plus》筆記

內置數據類型:
一、基本類型:
    有符號整數:int、long(long int)、short(short int)、long long(long long int)
    無符號整數:unsigned int;unsigned long;unsigned short
    字符:char
    布爾值:_Bool
    實浮點數:float、double、long double
    複數和虛浮點數:float _Complex、double _Complex、long double _Complex、float _Imaginary、double _Imaginary、long double _Imaginary
    
二、衍生類型:
    數組、字符串、指針、結構、聯合

三、抽象數據類型:
    鏈表、二叉搜索樹

問題1:如何設計一個存儲電影信息的程序,其中電影的數量是可變。電影信息包含電影片名,電影票價,電影評分(0-10)。
分析:從其成員來看,包含的數據類型包含字符串數組、float和unsigned int;從其數量來看,用結構的數組來存儲所有電影信息;從其數量的可變性質來看,結構數組的大小最好通過動態分配。綜上所述,使用malloc()函數爲一個結構指針動態分配內存,然後用該指針當做數組來對結構進行存儲。

問題2:要求對問題1的電影數量不提前確定,即既非編譯前確定數量,也非運行程序的開始確定數量,而是想用多少數量,就用多少數量,直到內存分配完(或達到操作系統給一個進程的內存分配限制或者操作系統的內存溢出保護限制)。
分析:這種情況最笨的方法是直接分配最大的一塊內存空間,任由程序使用。靈活的方法是每次添加電影信息的時候,才分配內存。但是這種多次分配內存的方法,就可能會產生不連續或順序錯亂的內存分配,也就是說,使用數組存儲數據,就不可能了。
如何處理這種多次分配內存產生的數據的管理呢?可以創建一個足夠大的指針數組來依次存儲這些多次分配的內存的地址,但是這種情況,仍然會在數組沒用盡的時候浪費內存空間,並且具有這個“足夠大”的上限。另一個方法就是重新定義電影信息這個結構,使其包含指向下一個結構的指針。
    struct film {
        char title[40];
        float price;
        int rating;
        struct film * next; //注意,結構本身不能含有同類型的結構,但是可以含有指向同類型結構的指針,這是鏈表的基礎
    };
這個結構如何工作?我們來假設場景:輸入第一部電影信息,程序需要設計不檢查第一步電影信息的上個電影信息,因爲它是第一部,然後設置next指針的值爲NULL,因爲它本身也是最後一部。第一個結構的指針稱爲頭指針,指向我們需要存儲的電影信息的第一個地址,這個地址我們需要保存到一個變量中,以備使用。然後輸入第二部電影信息,程序分配內存,存儲電影本身的信息後,將next指針設置爲NULL,到這裏,程序執行的操作和存儲第一步電影信息時差不多,下面進入不同的部分,接下來,程序通過頭指針,循環查找next,直至next爲NULL,這個操作就是找到存儲電影信息的這串信息中的最後一條(或者每次添加之後保存最後一條的地址,以備使用),然後將存儲第二部電影信息的結構的地址,存入到最後一個結構的next指針中,這樣就完成了對接。
    while(ptr)  
        ptr = ptr->next;
    ptr = second;
在本場景中,就是找到頭指針的next,並將第二部電影信息的結構地址存儲其中。當需要存儲第三部電影信息時,重複存儲第二部電影信息的操作,創建第三部電影信息的結構,並將地址存儲到從頭指針開始查找到最後一個結構的next指針中。一直循環操作下去,直至退出程序或者分配內存失敗。
這種結構中包含下一個結構地址的抽象高級數據類型,叫做鏈表。形象地描述,就像一輛多節的火車。
下面用程序實現用鏈表存儲電影信息
#include <stdio.h>
#include <stdlib.h>     //提供malloc()原型
#include <string.h>    //提供strcpy()原型
#define TSIZE 45
struct film {
    char title[TSIZE];
    int rating;
    struct film * next;    //指向鏈表的下一個結構
};
int main(void)
{
    struct film * head = NULL;//初始化頭指針爲空。
    struct film * prev,* current,* next;
    char input[TSIZE];
  /*收集並存儲信息*/
    puts("Enter first movie title:");
    while(gets(input) != NULL && input[0] != '\0')
    {
        current = (struct film *)malloc(sizeof(struct film));
        if(head == NULL)  //第一個結構
            head = current;
        else                       //後續結構
            prev->next = current;   //不是第一個結構,那麼就把當前結構的地址,存入到上一個結構的next指針中,進行對接。
        current->next = NULL;     //作爲最後一個項目,下一個項目當然是空了
        strcpy(current->title,input);
        puts("Enter your rating <0-10>:");
        scanf("%d",&current->rating);
        while(getchar() != '\n'); //除去回車等多餘輸入
        puts("Enter next movie title (empty line to stop):");
        prev = current;    //當前的結構地址存入prev,以便添加下一個項目時,可以方便地找到上一個結構的next指針,並進行賦值。
    }
    //顯示電影列表
    if(head == NULL)
        printf("No data entered.");
    else
        printf("Here is the movie list:\n");
    current = head;
    while(current != NULL)
    {
        printf("Movie:%s Rating:%d\n",current->title,current->rating);
        current = current->next;
    }
//釋放所有分配的內存
    current = head;
    while(current != NULL)
    {
        next = current->next;  //指針保存下一個結構的地址
        free(current);                //釋放當前指針指向的結構的內存
        current = next;             //將下一個結構的地址賦值給current以便循環操作。
//     free(current);   這兩句是書上的源代碼,替代上面的三句,但是我認爲這樣做有隱患,因爲current指向的地址內存已經釋放了,那麼該地址中的數據就是無效數據,包括內存上存儲的下一個結構的地址,那麼在下一句中將其地址賦值給指針current,將會導致current存儲的地址有可能不是原current->next存儲的地址,就會導致錯誤。
//     current = current->next;    
    }
    printf("Bye!\n");

    return 0;
}

本示例程序中,沒有對編碼細節和概念模型進行分割(比如,用函數來實現顯示、分配和釋放的功能),並隱藏分配內存等操作細節,這樣對小程序是沒有太大影響的,但是對於代碼稍多的程序,甚至是工程來講,就會影響程序設計,這時候,就需要對問題進行抽象,對細節進行隱藏,只有這樣才能實現更復雜的功能。

抽象數據類型
類型由屬性集和操作集組成。例如int的屬性是整數,表示的是數值。可對int執行的操作包括整數的幾乎所有操作,如+-*/%等。
那麼,創建一個新的類型,就需要三個步驟來實現從抽象到具體的過程:
1,爲類型的屬性和可對類型執行的操作提供一個抽象的描述。這個描述不應受任何特定實現的約束,甚至不應受到任何特定編程語言的約束。這樣一種正式的抽象描述被稱爲抽象數據類型(ADT)。
2,開發一個實現該ADT的編程接口。即說明如何存儲數據並描述用於執行所需操作的函數集合。比如在C中,您可能同時提供一個結構的定義和用來操作該結構的函數的原型。這些函數對用戶自定義類型的作用和C的內置運算符對C基本類型的作用一樣。想要使用這種新類型的人可以使用這個接口來進行編程。
3,編寫代碼來實現這個接口。當然,這一步至關重要,但是使用這種新類型的程序員無需瞭解實現的細節。

問題3:如何使用抽象數據類型來創建一個鏈表類型?如何對電影信息進行存儲。
分析:按照抽象數據類型的具體實現的步奏進行。
1,抽象描述
類型名稱
簡單列表
類型屬性:
可以保存一個項目序列
類型操作:
把列表初始化爲空列表

確定列表是否爲空

確定列表是否已滿

確定列表中項目的個數

向列表末尾添加項目

遍歷列表,處理列表中的每個項目

清空列表

2,構造接口
要求完成的接口可以隱藏編程細節,並且包含所有的類型操作,並按照抽象的過程,先聲明類型操作的函數的原型,再利用接口實現整個程序,最後實現這些函數的功能。所以先構造list.h
,第二步編寫films3.c,最後實現list.c。
//list.h
#ifndef LIST_H_    //和末尾的#endif形成代碼塊區域,意思是如果之前未包含本文件,則執行下面的聲明語句,以免重複聲明
#define LIST_H_   //如果未包含,那就聲明爲包含,因爲正在執行聲明
#include <stdbool.h>
//構造項目
#define TSIZE 45
struct film {
    char title[TSIZE];
    int rating;
};
typedef struct film Item;
//構造包含項目和下一個項目地址的節點
typedef struct node {
    Item item;
    struct node * next;
} Node;
//構造列表,列表是指向Node的指針。
typedef Node * List;
//初始化列表,InitializeList(&plist);爲何需要使用地址符&?因爲要修改該指針中的值,所以只能傳地址而非變量作爲參數。
//操作前,plist指向一個列表,操作後,該列表被初始化爲空列表
void InitializeList(List * plist);
//確認列表是否爲空列表  ListIsEmpty(plist);
//如果爲空,返回true,否則返回false
bool ListIsEmpty(const List * plist);
//確認列表是否爲滿列表  ListIsFull(plist);
//如果爲滿,返回true,否則返回false
bool ListIsFull(const List * plist);
//計算列表中項目(節點)的個數ListItemCount(plist);
//返回項目的個數
unsigned int ListItemCount(const List * plist);
//在列表尾部添加一個項目AddItem(item,&plist);
//添加成功返回true,否則返回false
bool AddItem(Item item,List * plist);
//把一個函數作用於列表中的每個項目Traverse(plist,pfun);
void Traverse(const List * plist,void (* pfun)(Item item));
//清空爲項目分配的內存 EmptyTheList(plist);
//列表變成空列表
void EmptyTheList(List * plist);
//結束聲明
#endif

3,使用接口
//film3.c
#include <stdio.h>
#include <stdlib.h>
#include "list.h"
void showmovies(Item item);

int main(void)
{
    List movies;
    Item temp;

    InitializeList(&movies);
    if(ListIsFull(&movies))
    {
        fprintf(stderr,"No memeory available!Bye!\n");
        exit(1);
    }

    puts("Enter first movie title:");
    while(gets(temp.title) != NULL && temp.title[0] != '\0')
    {
        puts("Enter your rating <0-10>:");
        scanf("%d",&temp.rating);
        while(getchar() != '\n');
        if(AddItem(temp,&movies) == false)
        {
            fprintf(stderr,"Problem allocating memory\n");
            break;
        }
        if(ListIsFull(&movies))
        {
            puts("The list is now full.");
            break;
        }
        puts("Enter next movie title(empty line to stop):");
    }
    
    if(ListIsEmpty(&movies))
        printf("No data entered.");
    else
    {
        printf("Here is the movie list :\n");
        Traverse(&movies,showmovies);
    }
    printf("You entered %d movies.\n",ListItemCount(&movies));
    
    EmptyTheList(&movies);
    printf("Bye!\n");
    return 0;
}
void showmovies(Item item)
{
    printf("Movies:%s Rating:%d\n",item.title,item.rating);
}
注意:原書上除了InitializeList(),AddItem(),EmptyTheList(),其餘函數使用參數movies時都未添加地址符&,這是錯誤的,編譯時會警告並且運行不能達到預期效果,解決辦法是所有接口函數使用movies時都帶上&,或者修改list.h和下面的list.c中的函數定義,將非InitializeList(),AddItem(),EmptyTheList()函數的其中的List *的*去掉,並修改其代碼塊中的變量使用也去掉*。
4,實現接口
//list.c
#include <stdio.h>
#include <stdlib.h>
#include "list.h"

static void CopyToNode(Item item,Node * pnode);

void InitializeList(List * plist)
{
    * plist = NULL;
}

bool ListIsEmpty(const List * plist)
{
    if(* plist == NULL)
        return true;
    else
        return false;
}

bool ListIsFull(const List * plist)
{
    Node * pt;
    bool full;

    pt = (Node *)malloc(sizeof(Node));
    if(pt == NULL)
        full = true;
    else
        full = false;
    return full;
}

unsigned int ListItemCount(const List * plist)
{
    unsigned int count = 0;
    Node * pnode = * plist;

    while(pnode != NULL)
    {
        ++count;
        pnode = pnode->next;
    }
    return count;
}

bool AddItem(Item item,List * plist)
{
    Node * pnew;
    Node * scan = * plist;

    pnew = (Node *)malloc(sizeof(Node));
    if(pnew == NULL)
        return false;
    
    CopyToNode(item,pnew);
    pnew->next = NULL;
    if(scan == NULL)
        * plist = pnew;
    else
    {
        while(scan->next != NULL)
            scan = scan->next;
        scan->next = pnew;
    }
    return true;
}

void Traverse(const List * plist,void (* pfun)(Item item))
{
    Node * pnode = *plist;
    while(pnode != NULL)
    {
        (* pfun)(pnode->item);
        pnode = pnode->next;
    }
}

void EmptyTheList(List * plist)
{
    Node * psave;
    while(*plist != NULL)
    {
        psave = (*plist)->next;
        free(*plist);
        *plist = psave;
    }
}

static void CopyToNode(Item item,Node * pnode)
{
    pnode->item = item;
}

5,編譯運行
list.h,list.c和films3.c都存放於同一目錄,執行
#gcc list.c films3.c
#./a.out
Enter first movie title:
a
Enter your rating <0-10>:
10
Enter next movie title(empty line to stop):
b
Enter your rating <0-10>:
9
Enter next movie title(empty line to stop):
c
Enter your rating <0-10>:
8
Enter next movie title(empty line to stop):
d
Enter your rating <0-10>:
7
Enter next movie title(empty line to stop):
Here is the movie list :
Movies:a Rating:10
Movies:b Rating:9
Movies:c Rating:8
Movies:d Rating:7
You entered 4 movies.
Bye!

問題4:如果我需要對鏈表中間的項目進行插入、刪除或替換呢?
分析:不難實現,通過計數定位(第N個)、查找定位(電影名是Tatnic),然後保存上一個項目的地址和當前項目的地址,如果插入,則將上一個項目的next指向新增的一個項目,然後新增的項目的next指向當前的項目;如果刪除,則將當前項目的next保存到上一個項目的next中,然後釋放當前項目的內存;如果替換,則直接替換當前項目的Item即可。同理,這些操作適用於鏈表頭部和尾部。
特殊的鏈表:
只能尾部加入,頭部刪除的鏈表叫做——隊列。
只能尾部加入,尾部刪除的鏈表叫做——棧。

問題5:鏈表相比數組有一些優勢,那有沒有缺點呢?能否總結一下鏈表和數組的優缺點?
分析:以表格形式展現
數據形式
優點
缺點
數組
C對其直接支持
提供隨機訪問
編譯時決定其大小,插入和刪除元素很費時
鏈表
運行時決定其大小,快速插入和刪除元素
不能隨機訪問,用戶必須提供編程支持
可爲隨機訪問?就是直接訪問數據結構中某個項目的操作。與之對應的是順序訪問,就是按照數據結構的起始位置一直訪問到末尾。
鏈表要訪問某個項目,必須根據提供的位置或匹配項從頭開始查找,而數組則可以根據其索引直接訪問,如果有大量的這種隨機訪問操作,使用鏈表將會成爲性能瓶頸。

爲什麼數組插入和刪除元素很費時?因爲數組是連續的內存空間,如果刪除一個項目,那麼就必須將後面的項目依次前移,填補空白,如果插入一個項目,需要將當前位置之後的項目全部後移一位,才能將項目存入當前的內存,如果刪除時不移動項目,那麼多次刪除之後,數組的可用項目位置將會大大減少,如果插入時不移動項目,插入動作將會變成替換。這樣插入和刪除一個項目,都要大量移動其他項目的操作,將會非常耗時。而鏈表爲何又可以快速插入刪除呢?這取決於鏈表的內存分散性,只需將新增的項目鏈接到鏈表中和將刪除的項目之後的項目與其之前的項目連接起來即可。

問題6:那有沒有一種數據結構,包含數組和鏈表的優點,又避免它們的缺點呢?
分析:有,那就是二叉搜索樹。
考慮一種搜索方法,將所有項目按照順序排列,當需要查找某個項目,則從項目的中間開始查找,如果等於,則返回結果,如果比中間的項目大,則到所有項目的後半部分查找;如果比中間的項目小,則到所有項目的前半部分查找。再查查找時,仍然到該部分的中間進行查找,依次類推,最後找到,則返回結果,沒找到,則報告查找失敗。
這種搜索方法,比數組的按照索引查找要慢一點,因爲需要若干次的查找,但是比起順序查找,卻是要快上許多,這種查找叫做二分查找或者折半查找。研究表明:二分查找查找到一個項目需要的最多次數爲n,那麼它查找的數據總量則爲2 ** n - 1(2的n次方減去1),數據總量越大,優勢越明顯。
二分查找和鏈表正是二叉搜索樹的基礎。
二叉搜索樹的抽象模型如下:

該模型就像一顆倒置的樹,最上層是根,左邊是左子節點,其下又有節點,因此形成左子樹。右邊是右子節點,其下又有節點,因此形成右子樹。沒有子節點的節點,稱爲葉節點。
其中的left和right是存儲子節點的地址的指針變量,通過這種兩個分支的做法,可以容易實現二分搜索。
通過二叉搜索樹,就可以實現隨機快速訪問,快速插入和刪除以及運行時決定其大小的優點。

問題7:二叉搜索樹有缺點嗎?
分析:有,首先,實現二叉樹的接口比鏈表複雜,詳細見書本,其次,二叉搜索樹的優點是基於所有的項目呈平衡狀態的情況下,何謂平衡狀態?也就是根節點的左右子樹的節點數量和節點深度都差不多,最好是每個節點都有兩個子節點直至葉節點的情況。至於如何讓數變得平衡,這又是另一個更深的話題了。













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