1. 程式人生 > >給資料結構初學者:跨過演算法和程式之間的鴻溝

給資料結構初學者:跨過演算法和程式之間的鴻溝

【摘要】學習資料結構時,將各種基本操作通過程式實現,可以加深對演算法的理解,也是提高程式設計能力的一種有效手段。針對初學者在搭建演算法和程式之間聯絡困難的問題,本文以線性表部分為例,介紹瞭如何從讀演算法中找出實現程式的線索,圍繞演算法和程式之間的聯絡、抽象的描述和具體的實現之間的關係,引導讀者學到抽象演算法的精髓,最後對實踐的路線、方案等進行了總結,並給出一些建議。

【正文】

  計算機是演算法的科學。學習IT的童鞋,在演算法中下多大的功夫都不為過。在學習《資料結構》課程的時候,將教材中給出的演算法用程式程式碼描述出來,在實現的過程中,可以不斷加深思考;在除錯程式的過程中,對演算法的細節能夠進行精細的鑽研,這些都是獲得演算法精髓的方法。演算法往往用“虛擬碼”的形式給出,學生在學習過程中,將這種抽象的描述與能夠執行的具體形態的程式碼之間建立聯絡,使得演算法形象起來,這樣一個學習過程,以及由此帶來的體驗,將會使學生在技術成長之路上受益菲淺。

  在我組織的“未來IT工程師協會/CSDN高校俱樂部”的活動中,結合同學們正在“演算法與資料結構”課程,創辦“演算法達人修煉營”,組成合作學習團體,實踐相關的各種演算法,討論在演算法學習中遇到的問題,以此來提高駕馭演算法的能力。為幫助同學們做好抽象的資料結構、演算法與某種語言編寫的程式之間的過渡,特撰寫此文。

  結合我校大二同學已經有的知識結構,本文以嚴蔚敏老師的《資料結構(C語言版)》為基礎說資料結構和演算法,實現演算法的語言用CC++。(建議:讀本文中,一邊翻著教材才有感覺。

  一、讀演算法中找出實現程式的線索

  要看懂演算法,解決其中存在的障礙,需要同學們在讀書時能夠做到前後對照。

  以P23

中的演算法2.3為例講讀演算法的方法,以及如何前後對照。

  演算法2.3的順序儲存的線性表的初始化問題,虛擬碼是:

  為便於後續的說明,為演算法加些行號:

1. Status InitList_sq(SqList &L){2.  //構造一個空的線性表L3.  L.elem =(ElemType *)malloc(LIST_INIT_SIZE * size(ElemType));4. if(!L.elem) exit(OVERFLOW); //儲存分配失敗5. L.length = 0;  //空表長度為06.  L.listsize = LIST_INIT_SIZE; //初始儲存容量7.  return OK;8. }

  這個演算法要解決的問題非常顯然,用思維導圖表達出來是:

  演算法中的邏輯非常簡單,常有同學說,演算法是能看懂。這得益於抽象(後面專門要說),使我們忽略了很多實現中要考慮的細節,所以容易看懂,這是抽象的好處。而恰好由於忽略了實現細節中的具體形態,使得在考慮如何實現演算法時出現障礙。這不是一個大問題,卻成為初學者起步的一個障礙,尤其是對程式設計的功底並不很深的同學。(程式設計功底的加強是必需的,但已經到了這個階段,並不是一定要先補上那一課再能學資料結構,時候不等人。實際上,學資料結構,同時也促程式設計。)

  障礙主要來自於,演算法描述中出現的“詞彙”和曾經程式設計中用過的似乎並不相同。“字”都不認識,談何理解,又何談實現。實際上,會看書的同學應該發現,演算法中出現的“詞”,在教材前面都曾經出現過,我們找出來,將其聯絡到一起。

  說有些同學不會看書可能委屈,更多的是沒有耐心,一門課程起步階段,基礎性的內容要看細了。

  演算法第1行:Status InitList_sq(SqList &L)

  InitList_sq是函式名自不用說。Status 顯然是函式InitList_sq()的返回值型別,但究竟是什麼型別呢?CC++中沒有這種資料型別,其他語言中也沒有,可以猜到是自定義型別。教材P10有解釋:

  教材接著給出了在C語言實現演算法時的建議:

//Status是函式的型別,其值是函式結果的程式碼typedef int Status

  其實如果用PASCAL實現,需要按PASCAL語言的語法寫作:

type Status=integer;

  一個函式執行結束後,函式結果的程式碼給出一些約定(如1是成功,0是失敗)通過返回值通知呼叫函式執行的情況,這種設計很常見。那麼,此處Status用整型表示,其具體取值與含義是什麼?從演算法第7行 return OK;可以看出,這個OK就是Status可取的值。同樣在P10,有一些常定義(只列兩行,ERROR在其他函式中用到):

#define OK 1#define ERROR 0

  在PASCAL中,對應的定義是:

const OK=1; const ERROR=0; 

  還沒有說Java,不說不夠意思。C/C++PASCAL中利用自定義型別解決,而Java中沒有提自定義型別一詞,但實際就在不斷地宣告自定義型別(calss)。在此做自定義類實現涉嫌殺雞用牛刀,一種合適的解決方法是用列舉型別(其實這種方法對C/C++也合適):

enum Status {ERROR, OK};

  理解:抽象的Status在各種語言中實現的途徑不同,甚至在一種語言中也可以有不同的實現方案。演算法這樣的寫法有兩個方面的好處:(1)可以供使用不同語言程式設計的人使用;(2)對學習演算法的人而言,可以忽略(用某語言實現的)細節,而將注意力集中到演算法本身。這兩點好處對於後面的複雜演算法更加重要。再次強調,要習慣並喜歡上這種抽象的描述。

  接下來講函式InitList_sq()的形式引數&L。

  形式引數&L的型別是對SqList型別的引用。SqList型別是何型別?自定義型別。SqList是一個結構體型別,其定義就在P22,演算法2.3前的一點點:

  SqList結構體包括有三個資料成員,在函式中都會用到。Lengthlistsize成員的型別是整型int好理解,ElemType又是個什麼型別?理解了前面Status抽象的意義後,可以猜到ElemType又是個抽象資料型別,對應的是順序表中要儲存的資料的型別。ElemType(見名知義,元素型別)在教材前面出現過,但放在不同應用背景下,可以給出不同的定義。這個資料可以是簡單的整型(若干整數的序列構成一個線性表),也可以是浮點型,甚至ElemType是一個字串、結構體。例如,可以是:

typedef int ElemType 

  還可以是:

typedef struct student{  char num[10];  char name[16];  int age;  float score;}ElemType;

  在教材例2.4中,線性表中的資料是多項式中的一項,需要記錄資料下係數和指數,使用了結構體:

typedef struct{  float coef;   //係數  int expn;   //指數}ElemType;

  至於用其他語言和任務的實現,不再給出分析和示例,道理一樣。

  下面的思維導圖 給出一個直觀些的總結:

  理解了上面的內容,從第2行開始就好辦了。第2行註釋說明了這個演算法完成的操作,第3行分配記憶體空間:

L.elem =(ElemType *)malloc(LIST_INIT_SIZE * size(ElemType));

  malloc()函式是分配記憶體空間,相當於C++中的new運算子。查手冊知malloc()的原型是:

void *malloc(unsigned int num_bytes); 

  由函式呼叫可以看出,分配的空間大小為LIST_INIT_SIZE * size(ElemType),足夠存放LIST_INIT_SIZE(定義的常量,值為100,見P22)個ElemType型的值,返回的地址指標轉換為指向ElemType型的指標。函式呼叫結束後,將指標賦值給L.elem。

  演算法第4行:if(!L.elem) exit(OVERFLOW);是在儲存分配失敗時的處理,OVERFLOWP10定義的常量,值為-2

  第5行和第6行演算法就不再多述。其實也都是C中的內容,不清楚的內容通過參考資料可以解決。

  從上面的描述可以看出,教材中說得是用虛擬碼描述演算法,演算法實際上已經就是程式了。以C語言實現為例,在實現時,需要把這段演算法/程式所需要的其他內容寫到一個檔案中。需要寫一些型別定義typedef、常量定義#define、包含檔案#include等,這是語言的要求。另外,還要增加mian()函式作為程式執行的入口,在其中設定測試和除錯程式的程式碼,如果必要,還應該增加一些專門用於輸入、輸出的函式。

  這樣,為實踐演算法2.3寫出的對應程式是:

#include<stdio.h> //printf()等#include<malloc.h> // malloc()等#include<process.h> //exit()#define OK 1#define OVERFLOW -2typedef int Status;    //Status是函式的型別,其值是函式結果狀態程式碼,如OK等typedef int ElemType;  //ElemType是線性表中資料元素的型別,此處用int#define LIST_INIT_SIZE 10 // 線性表儲存空間的初始分配量,為方便測試,改為10typedef struct {  ElemType *elem; // 儲存空間基址  int length; // 當前長度  int listsize; // 當前分配的儲存容量(以sizeof(ElemType)為單位)}SqList;Status InitList(SqList &L) // 演算法2.3{   // 操作結果:構造一個空的順序線性表  L.elem=(ElemType*)malloc(LIST_INIT_SIZE*sizeof(ElemType));  if(!L.elem)    exit(OVERFLOW); // 儲存分配失敗 L.length=0; // 空表長度為0 L.listsize=LIST_INIT_SIZE; // 初始儲存容量 return OK;}Status ListTraverse(SqList L){    // 初始條件:順序線性表L已存在 // 操作結果:依次輸出L中的每個資料元素,函式名沒有用display,而是用了更專業的術語——遍歷Traverse ElemType *p; int i; p=L.elem; printf("線性表當前容量為: %d,", L.listsize); if (L.length>0) {   printf("線性表中有 %d 個元素,分別是:",L.length);   for(i=1;i<=L.length;i++)      printf("%d ",*p++); } else   printf("目前還是空線性表。"); printf("\n"); return OK;}int main(){ SqList La; Status i; i=InitList(La); ListTraverse(La); return 0;}  

  對於上面的程式,執行結果為:

  上面的程式要用C++(或Java)實現時,將SqList定義為一個類,基本操作為成員函式(或稱為方法)。

二、理解演算法和程式之間的“跨度”

  下面羅列網上收集來的幾組說法,適當修改、補充,提供給讀者從多個角度分別體會:

  說法1

  演算法是解決問題的步驟;程式是演算法的程式碼實現。

  演算法要依靠程式來完成功能;程式需要演算法作為靈魂。

  說法2

  演算法與程式:(1)一個程式不一定滿足有窮性,而演算法需要在有限步驟內完成。(2)程式中的指令必須是機器可執行的,而演算法中的指令則無此限制。(3)演算法代表了對問題的解,而程式則是演算法在計算機上的特定的實現。一個演算法若用程式設計語言來描述,則它就是一個程式

  說法3

  從計算機的角度講,程式是用一種計算機能理解並執行的計算機語言描述解決問題的方法步驟。演算法是解決問題的方法步驟(不一定要讓計算機能理解並執行)。程式設計是分析解決問題的方法步驟,並將其記錄下來的過程,其關鍵就是將演算法描述出來。

  資料結構課程裡面的程式碼,都是虛擬碼,也就是說,用C編譯器編譯是通不過的,還要做很多的修改才可以。演算法是程式設計的核心,演算法出來了,我們就可以考慮用哪種語言實現比較簡單,不一定要選C。學資料結構學的是演算法思想,學會如何去解決問題,這才是最重要的,用C實現次之。在資料結構C語言版裡面,我們只是將這種資料結構的操作用偽C程式碼描述出來而已。而在實現時,再考慮語言細節,將演算法用計算機可以接受的方式重新描述即可。

  說法4

  演算法和程式都是指令的有限序列 ,但是:程式是演算法,而演算法不一定是程式。 

  演算法和程式的區別主要在於:

  (1) 在語言描述上,程式必須是用規定的程式設計語言來寫,而演算法很隨意;

  (2) 在執行時間上,演算法所描述的步驟一定是有限的,而程式可以無限地執行下去。

  所以: 程式 資料結構 演算法(這是面向過程程式設計的概念,在面向物件程式設計的語境下需要拓展。)

三、理解演算法與資料結構中的抽象

  首先談一下計算科學中的抽象。

  抽象(Abstraction)是簡化複雜的現實問題的途徑,可以忽略一個主題中與當前目標無關的那些方面,以便更充分地注意與當前目標有關的方面。抽象並不打算了解全部問題,而只是選擇其中的一部分,暫時不用部分細節。運用抽象,可以使我們的注意力集中到要解決問題的主要矛盾上來,主要矛盾解決了,再解決次要矛盾。例如,在用計算機解決問題時,先設計抽象的資料結構和演算法,再考慮如何用程式設計語言表達出來。除了降低難度,還利於保證正確性、可靠性,也便於分工,便於工程組織。

  抽象是電腦科學與技術中最基本的思維模式之一。抽象包括兩個方面,一是過程抽象,二是資料抽象。它側重於相關的細節和忽略不相關的細節。抽象作為識別基本行為和消除不相關的和繁瑣的細節的過程,允許設計師專注於解決一個問題的考慮有關細節而不考慮不相關的較低級別的細節。

  在學習資料結構時,要能看出抽象程式的高低。舉一個例子,本文前面舉例用的是P23演算法2.3,而不是p20的演算法2.1,我為什麼這樣安排?

  線性表可以採取順序表示和鏈式表示兩種方法進行實現。演算法2.3對線性表的初始化針對的是順序表示。在鏈式表示時,針對初始化的操作,另有演算法。演算法2.3是和具體實現相關的演算法。而演算法2.1所涉及的兩個線性表的合併,卻是和採用的儲存方式、資料結構的實現方法無關的,適合於上面提及的各種儲存結構。從這個意義上講,演算法2.3的抽象程式比演算法2.1的低,考慮細節,考慮實現更多。

  於是引出一個建議。在將教材中的演算法轉換為程式的過程中,不必依著教材的順序實現,而是應該先實現抽象程度低的,再考慮抽象程度高的。演算法2.1的描述中,呼叫了不少其他的基本操作,從這一點也可以看出其抽象程度高,實現的細節都“隱藏”到其他操作中了。當然在實現和測試時,這樣的演算法不應該優先安排。

四、用程式語言實現演算法的小結

  下面的思維導圖給出了將演算法寫成程式的要點:

  五、實踐指導

  以“第2章 線性表”為例,P19已經給出了抽象資料型別線性表的定義(ADT List)。無論採取什麼樣的資料結構,其中的基本操作都是要實現的。換句話說,在今後進行專案開發時,只要涉及線性表,用到的就是這樣一組演算法。在學習期間,建立起自己的演算法庫,還是一件非常有意義的事。

  在以實踐為主的課外自主性學習中,可以採取下面的學習策略:

  • 閱讀教材,注重看出教材編寫的結構
  • 對某抽象資料型別
    • 鑽研抽象資料型別ADT的定義,理由(要品出的味道)
      • 基本操作即要實現的演算法
      • 基本操作中的形式引數經過提煉
      • 基本操作是具體資料結構的“總綱”、清單
    • 選定儲存結構
      • 研習教材中結合出的演算法
      • 自行設計教材中沒有給出,但ADT中要求的演算法
  • 程式設計並除錯
    • 實現ADT中規定的基本操作
    • 必備:要實現的演算法、這個演算法的支撐演算法、測試函式(main)

  以第2章線性表為例,依著這樣的策略,在鑽研P18的線性表的ADT基礎上,可以安排出的實踐任務是: