Evanalysis
1.1嵌入式互動預計閱讀時間: 13 分鐘

1.1 ADT 操作:stack、queue 與 function pointer

先理解 ADT 契約,再逐步追蹤 stack、queue 的狀態變化,以及 C 語言中 function pointer 的分派方式。

課程目錄

CSCI2520:資料結構

針對 CSCI2520 資料結構基礎的結構化筆記,重視操作層級推理與選擇性互動示範。

章節 0程式基礎1
章節 2List 與 recursion1
章節 4Trees 與 BST1

為甚麼先講契約

在 CSCI2520 入面,最常見的錯誤通常唔係少咗一個分號,而係少咗一個 清楚嘅契約。

ADT 會先講清楚四件事:

  • 允許做咩操作,
  • 每個操作要保證咩結果,
  • client 呼叫之後可以假設咩狀態,
  • 空結構同錯誤情況點處理。

呢個分隔好重要,因為同一個 stack 或 queue 介面,可以用 fixed array、 dynamic array,或者 linked list 去做。client code 唔應該因為內部實作改 咗,就要一齊改。

定義

ADT 契約與實作

ADT 契約描述操作同語義。 實作就決定資料欄位、記憶體布局,以及更新方式。 契約係承諾;實作係機制。

Stack 係一個只可以由頂端操作嘅結構

講到 stack,最核心唔係「一疊嘢」呢個比喻,而係 top-only 呢條不變量。

定義

Stack 核心操作

對一個 stack SS

  • push(x):將 x 放入頂端,
  • pop():移除並回傳頂端元素,
  • top():睇頂端但唔移除,
  • isEmpty():檢查有冇元素,
  • StackDepth():回傳深度。

LIFO 係 stack 嘅基本規則:後入先出。

例題

追蹤 stack 狀態

初始係空 stack。

  1. push(10) 之後係 [10]
  2. push(20) 之後係 [10, 20]
  3. top() 回傳 20,狀態唔變
  4. pop() 回傳 20,stack 變成 [10]

Stack 介面會隱藏內部表示

課堂入面用到嘅 opaque type 形式係:

typedef struct stackCDT *stackADT;
typedef int stackElementT;

stackADT EmptyStack(void);
void Push(stackADT stack, stackElementT element);
stackElementT Pop(stackADT stack);
int StackDepth(stackADT stack);
int StackIsEmpty(stackADT stack);

呢個設計唔係修飾,而係保護契約。stackADT 只係指向唔完整 struct 嘅指標,client 可以傳遞 stack,但唔可以直接入去改內部欄位。

定理

表示獨立性

如果 client 只可以透過 ADT 已宣告嘅操作去用個資料結構,咁 internal representation 就可以自由更改,而唔影響 client 可見行為。

Stack 實作方式

slides 入面強調咗三個重要概念。

第一,fixed array 版本會用一個 count 去記錄深度:

struct stackCDT {
   stackElementT elements[100];
   int count;
};

呢個版本簡單,但最大深度固定係 100。當深度超過上限,就會出現 representation-level 嘅 overflow。

第二,dynamic array 版本會再加 size,同埋喺滿咗之後用 realloc() 扩充:

struct stackCDT {
   stackElementT *elements;
   int count;
   int size;
};

第三,Pop() 仲可以進一步收斂 memory usage,令實作更完整。意思係: ADT 介面唔變,但 memory policy 可以一步一步改善。

例題

Push / Pop 的簡化實作

void Push(stackADT stack, stackElementT element) {
   if (stack->count == stack->size) {
      stack->size += 10;
      stack->elements = realloc(
         stack->elements,
         stack->size * sizeof(stackElementT)
      );
   }
   stack->elements[stack->count] = element;
   stack->count++;
}

stackElementT Pop(stackADT stack) {
   stack->count--;
   return stack->elements[stack->count];
}

重點係:

  • count 代表下一個可用位置,
  • push 先存再加一,
  • pop 先減一再回傳舊頂端。

Queue 雖然似,但語義完全唔同

Queue 嘅規則係 FIFO:先入先出。

定義

Queue 核心操作

對一個 queue QQ

  • enqueue(x):由尾端加入 x
  • dequeue():由前端移除並回傳,
  • front():睇前端但唔移除,
  • isEmpty():檢查有冇元素,
  • QueueLength():回傳長度。

Queue 的 header 都係 opaque 形式:

typedef struct queueCDT *queueADT;
typedef int queueElementT;

queueADT EmptyQueue(void);
void Enqueue(queueADT queue, queueElementT element);
queueElementT Dequeue(queueADT queue);
int QueueLength(queueADT queue);
int QueueIsEmpty(queueADT queue);

Queue 的實作:array、circular array、linked list

tutorial slide 已經清楚指出幾個版本。

  • fixed array 版本最易明白,但前端出咗位之後會浪費空間;
  • circular array 透過 modulus % wrap around,令前後位置可以重用;
  • linked list 用 headtail 指標,增減元素時唔需要搬移舊資料。
struct cellT {
   queueElementT element;
   struct cellT *next;
};

struct queueCDT {
   struct cellT *head;
   struct cellT *tail;
};

linked list 版本嘅意思係:

  • 新節點接去尾端,
  • head 指向前端,
  • QueueIsEmpty() 只要檢查 head==NULLhead == NULL

例題

點解 circular array 比 plain array 好

如果 queue 不斷交替 enqueuedequeue

  • plain array 會喺前端釋放咗空位之後,未必有效重用;
  • circular array 會將 frontrear wrap around,將前面空間重新用返。

所以 tutorial 先會問你:如果重複做幾百萬次 enqueue / dequeue,會發生咩事?

function pointer 令 C 可以做分派同 callback

function pointer 唔係額外技巧,而係 C 做 runtime dispatch 嘅核心工具。

int (*fp)(int);

呢個宣告表示 fp 係一個指向「接收一個 int,回傳一個 int」函數嘅 指標。prototype 就係 type contract。

定義

Function pointer 規則

function pointer 只可以指向參數列表同回傳型別都相符嘅函數。

課堂入面用 hashtable 做咗一個好實際嘅例子:加一個 callback traversal operation。

typedef void (*hashtableFnT)(char *, void *);

void ForEachEntryDo(hashtableFnT fp, hashtableADT table);

client 可以傳入:

void DisplayWordCount(hashtableADT table) {
   ForEachEntryDo(PrintEntry, table);
}

呢個模式對 ADT 好有用,因為 traversal 交畀 implementation 做,而每個 entry 要點處理,就由 client 提供。

同樣,dispatch table 都可以靠 function pointer 去做,避免一大串 if 判斷。

void ApplyOperator(char c, stackADT stack) {
   int y = Pop(stack);
   int x = Pop(stack);

   switch (c) {
   case '+': Push(stack, x + y); break;
   case '-': Push(stack, x - y); break;
   case '*': Push(stack, x * y); break;
   case '/': Push(stack, x / y); break;
   }
}

呢個例子入面,stack 用嚟存 operand,而 operation table 決定點樣 combine 佢哋。function pointer 令 C 有咗一種手動控制嘅 polymorphism。

邊讀邊試

追蹤 ADT 操作語義

這個工具現在把 C 風格程式範例和可編輯指令串列放在一起,讓讀者可以直接測試 stack 與 queue 的 ADT 語義。

程式範例

typedef struct {
  int data[100];
  int top;
} Stack;

void push(Stack *s, int x) {
  s->data[++(s->top)] = x;
}

int pop(Stack *s) {
  return s->data[(s->top)--];
}

自己試一試

行變換: push(10)

stack: [10]

queue: []

頂端現為 10。

自己試一試

push 10

目前狀態: [10]

回傳值: 沒有回傳值

push 20

目前狀態: [10, 20]

回傳值: 沒有回傳值

top

目前狀態: [10, 20]

回傳值: 20

pop

目前狀態: [10]

回傳值: 20

push 7

目前狀態: [10, 7]

回傳值: 沒有回傳值

最後狀態: [10, 7]

用程式測試 ADT 契約

當介面固定之後,下一個問題唔應該只係「可唔可以 compile」,而係 「每一步可觀察到嘅狀態轉移,係咪仍然符合契約」。

一個實用做法係寫細型 trace test:

stackADT s = EmptyStack();
Push(s, 10);
Push(s, 20);
assert(StackDepth(s) == 2);
assert(Pop(s) == 20);
assert(Pop(s) == 10);
assert(StackIsEmpty(s));

呢類測試有價值,因為佢檢查嘅係 ADT 層次嘅承諾,而唔係底層記憶體布局。 就算之後你將 array-based stack 換成 linked representation,只要契約冇變, 同一組測試都應該原封不動咁通過。

queue 都一樣:一段短短嘅 enqueueenqueuedequeuedequeue trace,就應該證明第一個入隊嘅元素仍然係第一個出隊。

常見錯誤

常見錯誤

將 stack 同 queue 語義混淆

push / pop 係 stack;enqueue / dequeue 係 queue。改名唔改行為, 就係破壞 ADT 契約。

常見錯誤

忽略空結構錯誤策略

空 stack 做 pop、空 queue 做 dequeue,都一定要預先定義清楚錯誤處理。

常見錯誤

把介面穩定同實作穩定混為一談

應該保持穩定嘅係介面;應該可以演進嘅係內部 representation。

快速檢查

快速檢查

做完 push(1), push(2), pop() 之後,stack 仲剩低咩?

按 LIFO 去判斷。

解答

答案

快速檢查

做完 enqueue(4), enqueue(7), dequeue() 之後,回傳咩元素?

按 FIFO 去判斷。

解答

答案

快速檢查

點解 ForEachEntryDo(PrintEntry, table) 需要 callback type,而唔係普通 generic pointer?

重點係 type checking 同 parameter list 配對。

解答

引導答案

練習

快速檢查

寫出 Pop(stack) 對非空 stack 嘅前置條件同後置條件。

盡量精確。

解答

引導答案

快速檢查

解釋點解 linked list queue 可以 enqueue 而唔需要搬移舊元素。

headtail 去講。

解答

引導答案

快速檢查

用一小段文字解釋 function pointer 點樣幫 ADT 測試。

要講到介面同實作分離。

解答

引導答案

重點總結

真正重要嘅唔係「stack 同 queue 係兩種資料結構」咁簡單,而係:

  • ADT 契約定義行為,
  • 實作負責存取同記憶體,
  • function pointer 令 C 可以維持 type-safe 嘅分派同 callback。

先備知識

這一節可以獨立閱讀。

本單元重點詞彙