[初章] 06 符文辨識與解讀

先前只能以整數和我們可愛的使魔溝通,怎麼受得了!
現在,我們終於可以調教牠辨識文字了,開心吧!

先從一個常見的東西開始:
第一行輸入你的姓名,第二行輸入對象姓名,只能使用英文;之後依照名字輸出這兩人配對合不合得來,給予0到99的分數!
0代表非常不合,99代表簡直是天造地設的一對!



其實這比較適合做成網頁,計算結果並顯示之餘,偷偷把輸入的數據存下來或寄到自己信箱,然後丟給朋友或喜歡的女孩子算命,就可以探出對方喜歡誰…
咳,這不是今天的主題,有興趣請自行研究,現成的大概也找得到。

第一個難題是,我們根本沒辦法吃輸入姓名!難道要先翻譯成整數嗎?不,這太糟了!
今天我們將會提到「字元」以及「編碼」的概念。還記得前篇有提到身份證字號與道具ID的關係嗎?其實字元和編碼也是完全一樣的概念!

字元,就是一個符號。它可以是字母、標點、數字…等等。
所謂的「文字」,不過只是符號的一種。對於你看不懂的語言,甚至連分辨其是不是文字、屬於哪種語言都有困難!沒錯吧?
由於我們學過中文,因此在腦中會有一張表格,自動將符號對應到文字,並把它和代表的意思、發音等等連結起來。而對於不懂中文的外國人無法解讀漢字,就是因為沒有學中文,因此腦中不具備這種表格。

我們透過定義「符號」來編織文字,並透過教育傳承與散播,讓所有持有相同的「定義」的人,能夠使用這些符號來溝通。
由於「文字」是由人為「定義」並透過「教育」傳承,從現代的一般人閱讀文言文相當困難,一樣是中文,可以看得出來是會改變的,包括寫法、讀音與意義都是。因應時代而出現的新詞,即便再博學的古人,肯定也不認識。想想你如果跟孔子談資訊科學,他會有什麼反應?你問古人什麼是萌,回答絕對跟你想像的不一樣。問他什麼是囧?嗯…他也許會現場囧給你看。
由此可以了解文字是人為定義,而非天生具有意義。

像之前提過的道具欄一樣,電腦沒辦法直接儲存文字,因此透過「編碼」將符號一對一對應到一個獨一無二的整數ID,並以ID來儲存。
編碼有很多種,對應到不同的符號。C語言內定使用ASCII編碼,能對應英文大小寫字母、數字以及半形標點符號。
比如說,大寫A對應的ASCII值是65,數字1則對應到49;但是無法對應中文日文等等。

就像之前提過的道具一樣,如果藥草和長劍ID都是1,看到ID為1會無法分辨是什麼道具!事實上ID為1到底什麼意思,由我們來定義並命令程式遵守。由於字母或數字幾乎是世界共通了,不像藥草和長劍是比較私有的定義,所以會有公認的編碼。
ASCII碼則是世界公認的編碼之一,當我們接受ASCII編碼的定義並使用它,就能和所有使用ASCII碼的人持有共同認知,也就能夠溝通。就像秦始皇實施的車同軌、書同文一樣意義重大。

對編碼有個初步概念後,來看看如何使用它。首先是儲存文字用的型態「字元」:

char c;

很眼熟對吧?和宣告整數變數,只差在int變成了char!
所以,我們只是多學了一個型別而已!
但是和整數一樣,它也只能存一個東西。也就是說,一個字元。
如果要讓它存大寫A怎麼辦?像這樣:

c = 65;

WTF!?這不是當整數在用嗎!!
再說這是要我背下整張ASCII編碼表!?
哈哈,別急別急!還有另一種寫法~

c = 'A';

所以不用背ASCII碼影響也不大!用單引號'括起來,就代表字元!但是只能括一個字!
只是要知道一件事,這兩個寫法效果一樣。也就是說,'A'會被視為整數65。只是讓你可以不用背ASCII碼表,而且看起來比65更容易理解它的實質意義!
實質意義上我們要存的是'A',但物理上儲存的是A的ASCII碼: 65。
就像我們要存藥草這個道具,物理上是儲存藥草的ID。
C語言沒法存文字,因此透過ASCII編碼,用整數來儲存文字。
也因此,字元型態可以視為整數來操作。

c = 'A'+1;

c會是66,這很顯然;隱含的意義是它等同於'B'。在ASCII碼中,大寫字母從A到Z連續,小寫字母從a到z連續,阿拉伯數字從0到9連續。這能幫助我們計算。
同樣地我們要找到5的ASCII碼,也可以這麼做:

c = '0'+5;

另外,C語言中,大寫與小寫字母是視為相異的;小寫a為97。編碼是一對一的映射表,所以大小寫會視為相異。
ASCII是整數與符號的對應,A和a即使意義上相同,符號本身也是相異的。
即使妹妹和女朋友都指向同一個人,文字上也是相異的。

接下來讓我們看看如何輸入與輸出字元:

char c;
scanf("%c", &c);
printf("you enter character **%c**\n", c);

除了%d改成%c之外,都和整數相同。有了前面累積的經驗,處理起字元只需記住型別和scanf的暗號就足夠了!很輕鬆吧!
事實上,字元型態僅在輸出輸入時自動查表做轉換,其餘時刻以整數形式存在變數中時,一律都是作為整數看待!所以加法和減法都可以用,乘除雖然也可以但是意義不大…

那麼第一道難關解決了!接下來是下一個問題:
沒有講怎麼計算分數!

這的確是個嚴重的問題。為了確保一樣的輸入能得出一樣的結果,顯然我們必須拿輸入的兩個名字做些計算得出一個數字,且將它們對調不會影響結果。
第一個想到對調不影響的是加法或乘法,由於分數範圍不小,我們選擇乘法。超過99也沒關係,看末兩位就好。也就是除以100取其餘數!

接下來問題就在於,怎麼把名字換成數字。我們定義將名字中每個符號賦予一個值,最後加總起來。

A: 10
B: 11
C: 12
...
Z: 35
a: 10
b: 11
...
z: 35
0: 0
1: 1
...
9: 9
其它: 37

這樣就沒問題了!接下來是下一個難關:
從哪裡到哪裡是一個名字?

為求簡單起見,一個名字一行!也就是說,出現換行表示一個名字結束了。
好,整理分析一下結論:

1. 讀名字
2. 算分數
3. 顯示分數

好像有點糊,或者說非常糊;讓我們拆細一點:


1. 讀名字
  1.1 讀第一個名字
    1.1.1 不斷讀入字元,直到換行為止
  1.2 讀第二個名字
    1.2.1 不斷讀入字元,直到換行為止2. 算分數
  2.1 算第一個名字分數加總
    2.1.1 對每個字依表格算出分數
    2.1.2 將所有分數一個一個加起來
  2.2 算第二個名字分數加總
    2.2.1 對每個字依表格算出分數
    2.2.2 將所有分數一個一個加起來
  2.3 將兩個名字分數相乘
  2.4 取末兩位
    2.4.1 除以100之餘數
3. 顯示分數
  3.1 分數90以上時恭喜
  3.2 分數60以上時鼓勵
  3.3 拍拍

好,這樣過程應該夠清楚了!動工!

1. 讀名字
int len;
char name[50];

void read_name()
{
    int end;
    len = 0;
    end = 0;
    while(end == 0)  <== 當一串名字仍未結束,則持續進行!
    {
        scanf("%c", &name[len]);  <== 讀入下一個字元
        if(name[len] == '\n')  <== 如果遇到換行,表示結束!
        {
            end = 1;
        }
        else  <== 若尚未結束,則長度+1,移向下一個字
        {
            len = len + 1;
        }
    }
}

2. 算分數
int char_to_point(char c)
{
    if(c >= 'a' && c <= 'z')
    {
        return c-'a'+10;  <== 不容易一次想對的話,舉一兩個例子計算看看最清楚!
    }
    if(c >= 'A' && c <= 'Z')
    {
        return c-'A'+10;  <== 尾減去頭即為頭尾距離
    }
    if(c >= '0' && c <= '9')
    {
        return c-'0';  <== 注意'0'是48而不是0
    }
    return 37;  <== 其它
}

int get_point()
{
    int i, point;
    point = 0;  <== 一開始分數為 0
    i = 0;
    while(i < len)  <== 直到看完每一個字為止
    {
        point = point + char_to_point(name[i]);  <== 分數加總起來
        i = i + 1;  <== 這個字算完了,換看下個字
    }
    return point;
}

3. 顯示分數
void show_result(int res)
{
    if(res >= 90)  <== 假如有90+
    {
        printf("Congratulations!! match score (%d) is over 90!!\n", res);
        return;  <== 90+直接結束,等於幫後面if排除掉90+的情形!
    }
    if(res >= 60)  <== 90+的已return掉了,所以這裡等同 >=60 && <90
    {
        printf("good!! match score (%d) is higher than 60!!\n", res);
        return;
    }
    printf("sad, match score (%d) is less than 60..\n", res);
}

好,每個部件都完成了!最後把它們組合起來:
int main()
{
    int score0, score1, res;

    printf("please enter your name: ");
    read_name();  <== 先輸入名字
    printf("please enter your lover's name: ");
    read_name();  <== 再輸入喜歡的人的名字

    score0 = get_point();  <== 分別計算分數
    score1 = get_point();
    res = score0 * score1;
    res = res % 100;  <== a % b 的結果為 a 除以 b 所得之餘數
    show_result(res);

    scanf(" ");  <== 防執行完瞬間關閉
    return 0;
}

短短的超簡潔,而且一眼就能看懂整個流程!一切看起來都很完美,每件事都有做到,但總覺得哪邊不對勁…read_name都做一樣的事,也沒給參數,不就都存同一個地方去了嗎!?
看來,對電腦而言上述還是不夠詳細。沒有指定名字存哪,所以第一個名字和第二個名字會存到相同的地方去,第二個名字會蓋過第一個名字!

初步想到的解決策略: 開兩個陣列,分存不同地方!
可是這樣必須能判斷第一個存哪、第二個存哪;要不是開兩個function做一樣的事,只差對象不同;要不就是一個function用if分兩邊判斷,怎麼想都不是很妙。
我們也不知道如何透過參數傳遞對象,現在只能傳遞本身儲存的數值…

但仔細想想,我們留完整名字,只是為了計算分數!其它根本沒用到,也就是說分數算完就可以丟了!過河就可以拆橋啦~
如果我們把流程改變如下:

1. 讀入第一個名字,計算分數
2. 讀入第二個名字,計算分數
3. 將分數相乘取末兩位
4. 輸出結果

就不用管這問題了!寫起來就變成:

int main()
{
    int score0, score1, res;

    printf("please enter your name: ");
    read_name();  <== 先輸入名字
    score0 = get_point();  <== 分別計算分數    printf("please enter your lover's name: ");
    read_name();  <== 再輸入喜歡的人的名字
    score1 = get_point();

    res = score0 * score1;
    res = res % 100;  <== a % b 的結果為 a 除以 b 所得之餘數
    show_result(res);

    scanf(" ");  <== 防執行完瞬間關閉
    return 0;
}

WTF?
仔細想想,其它完全不用改耶!只是挪動一下順序就MAGIC了!很神奇吧?超好玩的對吧!
明明一樣的零件,只是擺不同順序,就從有問題變成沒問題啦!
誰說名字一定要留的?這裡告訴我們一件事: 變數或陣列都是拿來儲存「以後還有用」的資訊!以後沒用的東西扔一扔比較快還存它幹嘛?嫌空間大感覺冷清寂寞需要人陪嗎?

等下!這麼說來,每個字不也是算完就可以扔了嗎!?
對耶!!太有道理了,讓我們做個小修改:



int main()
{
    int score0, score1, res;

    printf("please enter your name: ");
    score0 = read_name();  <== 輸入名字同時邊計算分數    printf("please enter your lover's name: ");
    score1 = read_name();

    res = score0 * score1;
    res = res % 100;  <== a % b 的結果為 a 除以 b 所得之餘數
    show_result(res);

    scanf(" ");  <== 防執行完瞬間關閉
    return 0;
}

相對應的read_name改一下
int read_name()
{
    int end, score;
    char c;

    end = 0;
    score = 0;  <== 起始分數為0
    while(end == 0)  <== 直到一個名字讀完為止
    {
        scanf("%d", &c);  <== 讀入一個字元
        if(c == '\n')  <== 如果是換行,表示一個名字結束了
        {
            end = 1;
        }
        else  <== 如果未結束,就計算分數後加總起來,然後c的值就沒用了,可以丟了!
        {
            score = score + char_to_point(c);
        }
    }
    return score;
}

get_point就完全無出場餘地啦!也不用陣列和多一個變數記長度啦!超棒的吧?

事實上有好有壞。前者記有名字,因此在出錯時也方便將名字輸出看看,是否有讀錯等等;最後也可以輸出雙方名字和結果。
而後者因為沒有保留名字,就做不到這件事。相對地在計算上,由於沒什麼多餘動作,也沒多花力氣記錄非必要的資訊,在速度和使用的記憶空間都較佳。
只是簡短之餘,因為利用了一些特性改進了效率,所以相對比較難以理解。如果沒有解釋,比較難懂為什麼要這樣寫、可能得思考一下會不會有什麼問題。若未來輸出結果時要附帶名字,也會比較難以修改。

就像在解從1加到100,女孩A選擇細心地從頭加到尾,算完還驗算一遍;女孩B則觀察規律後兩三行迅速地計算出來。
前者麻煩費時,但是直接好懂,日後改成不規則亂數相加也沒有問題。
後者繞了兩三個彎以上,巧妙但較難理解,若被改成其它規則甚至沒有規則的相加,在修改上也需要費較大功夫;因為太針對了。

所以嚴格說起來,並無一定的好壞之分。同樣的問題並沒有唯一的解,認為遇到什麼問題便一定要怎麼解的,不算厲害;能依需求、依各種環境條件,在各方面做出取捨後,選擇最合適的解,才是真的厲害!
當然現階段能夠找出一個可行的解,並正確地寫成程式,就很厲害了。不過也鼓勵大家多嘗試、多思考不同方法!

最後,還是來個練習題。
輸入一篇文章,將所有出現的單字找出來,印成一行一個單字。
單字的定義是一串連續出現的字母,其頭尾是字母以外的字元或是文章的首尾。
例如輸入以下文字

I am the bone of my sword.
Steel is my body and fire is my blood.
I have created over a thousand blades.
Unknown to death,
Nor known to life.
Have withstood pain to create many weapons.
Yet those hands will never hold anything.
So as I pray, "Unlimited Blade Works."

輸出
I
am
the
bone
of
my
sword
Steel
is
my
body
(中略)
So
as
I
pray
Unlimited
Blade
Works

不用管重覆不重覆,能正確切割出來就行。每讀完一個完整單字就輸出也可以,由於預設的輸入方式是,當你按下enter之後,才一次性地將這整行送給程式讀取。在按下enter之前是被暫存起來的,程式無法馬上讀取。
所以讀完一個完整單字後馬上輸出,看起來會跟讀入一行後輸出一樣,不會太亂,詳細效果可以自行測試。不用刻意把整行的單字存起來再一次噴出來。
目前我們沒有判斷「輸入結束」的手段,所以不必強求要整篇文章輸入完再輸出結果。

寫完之後可以自行測試看看,並試想什麼情形可能會錯,然後實際測試看看。
注意像是you're在本題的定義上必須拆作you和re,這是符合題目要求的,不拆反而不符。
如果你需要複製貼上,在小黑窗的標題列上按右鍵 -> 編輯 -> 貼上,就可以貼上了。
如果你覺得這太麻煩,標題列上按右鍵 -> 預設值 -> 勾選快速編輯模式,對當前的小黑窗沒用,但以後開啟的新的小黑窗,只要在小黑窗上按滑鼠右鍵,就有貼上的功能了!

恭喜你!到這一章學成之後,已有進入下一階段的能力了。你已了解最基本的語法,具備最基本的程式能力,知道必須將問題定義清楚,並將方法轉為程式碼。
下一階段會慢慢接觸更多進階、深入的語法,同時會開始寫更複雜的程式。下一章,將帶領你正式進入程式解題的世界,接觸何謂線上評測系統,以及程式解題競賽。
在那裡,你將會看到更寬廣的世界,多彩多姿的題目與解題的巧思!下一章將告訴你如何入門!



沒有留言:

張貼留言