程式設計之美(第3章 結構之法-字串及連結串列的探索)總結

HHXUN發表於2018-04-17
3.1 字串移位包含的問題

問題:給定字串s1和s2,判定s2是否能夠被s1做迴圈移位得到的字串包含。例如 s1 = AABCD,s2 = CDAA, 返回true,s1 = ABCD,s2 = ACBD,返回false。

方法一:對s1進行迴圈移位,遍歷判斷s2是否能被包含

方法二:觀察規律,s1移位的結果是字串的頭移到字串的結尾,如果把兩個s1拼接起來,就包含了所有s1移位的字串。因此再判斷s2是否被兩個s1拼接的字串包含就行。

總結:利用空間來換取時間。即 提高空間複雜度來換取時間複雜度的降低。

3.2 電話號碼對應英文單詞

問題一:儘可能快地從這些字母組合中找到一個有意義的單詞來表述一個電話號碼。如用單詞“computer”來描述號碼26678837

問題二:對於一個電話號碼,是否可以用一個單詞來表示?

問題一方法一:直接迴圈法。
    將各個數字所能代表的字元儲存在一個二維陣列中,char c[10][10]
    將各個數字所能代表的字元總數記錄在一個陣列 int total[10] = {0, 0, 3, 3, ,3 ,3 ,3 , 4, 3, 4};
    用一個陣列儲存電話號碼:int number[TelLength]
    將數字目前所代表的字元在其所能代表的字符集中的位置用一個陣列儲存起來: int answer[TelLength]

    通過兩個while迴圈和一個for迴圈來遍歷完所有的字母可能。

問題一方法二:遞迴的方法。
    通過改變第i位數字的索引來遞迴完成所有數字變化時的字母組合。

問題二方法一:利用問題一的解法,將電話號碼對應的字元全部計算出來,然後去匹配字典,判斷是否有答案。
問題二方法二: 如果查詢次數多,直接將字典裡面的單詞按照轉換規則轉換為數字,存到檔案中,使之成為一本數字字典。然後通過電話號碼查表的方式來得到結果。

3.3 計算字串的相似度

定義了一套方法把兩個不相同的字串變得相同,具體操作為:
  1. 修改一個字元;
  2. 增加一個字元;
  3. 刪除一個字元。
把操作需要的次數定義為兩個字串的距離,而相似度等於“距離+1”的倒數。

問題:給定任意兩個字串,是否能夠寫出一個演算法來計算出它們的相似度?

分析與解法:考慮如何把這個問題轉化為規模較小的同樣的問題。如果有兩個串A=xabcdae和B=xfdfa,它們的第一個字元是相同的,只要計算A[2,...,7]= abcdae和B[2,...,5]=fdfa的距離就可以了。但是如果兩個串的第一個字元不相同,那麼可以進行如下的操作:
  1. 刪除A串的第一個字元,然後計算A[2,...,lenA]和B[1,...,lenB]的距離。
  2. 刪除B串的第一個字元,然後計算A[1,...,lenA]和B[2,...,lenB]的距離。
  3. 修改A串的第一個字元為B串的第一個字元,然後計算A[2,...,lenA]和B[2,...,lenB]的距離。
  4. 修改B串的第一個為A串的第一個字元,然後計算A[2,...,lenA]和B[2,...,lenB]的距離。
  5. 增加B串的第一個字元到A串的第一個字元之前,然後計算A[1,...,lenA]和B[2,...,lenB]的距離。
  6. 增加A串的第一個字元到B串的第一個字元之前,然後計算A[2,...,lenA]和B[1,...,lenB]的距離。

可以將上面6個操作合併為:
  1. 一步操作之後,再將A[2,...,lenA]和B[1,...,lenB]變成相同字串;
  2. 一步操作之後,再將A[1,...,lenA]和B[2,...,lenB]變成相同字串;
  3. 一步操作之後,再將A[1,...,lenA]和B[2,...,lenB]變成相同字串。

可以利用遞迴來完成計算。

3.4 從無頭單連結串列中刪除節點

問題:假如有一個沒有頭指標的單連結串列。一個指標指向此單連結串列中間的 一個節點(不是第一個,也不是最後一個節點),請將該節點從單連結串列中刪除。

解法:可以將該節點的下一節點的data賦值給當前節點,然後將當前節點的next指向下一節點的next,看似把下一節點給刪除,但已經把下一節點的data賦值給此節點。實則是刪除的此節點。  演算法版的狸貓換太子。

3.5 最短摘要的生成

假設給定的已經是經過網頁分詞之後的結果,語句序列陣列為W。其中W[0],W[1],...,W[N]為一些已經分好的詞。
假設使用者輸入的搜尋關鍵詞為陣列Q。其中Q[0], Q[1], ..., Q[m]為所有輸入的搜尋關鍵詞。
這樣,,生成的最短摘要實際上就是一串相互聯絡的分詞序列。比如從W[i]到W[j], 其中,0<i<j<=N。

這些最短摘要是怎樣生成的呢?

解法一:
  1. 從W陣列的第一個位置開始查詢出一段包含所有關鍵詞陣列Q的序列。計算當前的最短長度,並更新Seq陣列。
  2. 對目標陣列W進行遍歷,從第二個位置開始,重新查詢包含所有關鍵詞陣列Q的序列,同樣計算出其最短長度,以及更新包含所有關鍵詞的序列Seq,然後求出最短距離。
  3. 以此操作下去,一直到遍歷至目標陣列W的最後一個位置為止。
演算法的複雜度為:O(N²*M)

解法二:
通過觀察兩次掃描的結果。第二次掃描從第一次掃描結束的地方開始。使之變成字串匹配的問題。

3.6 程式設計判斷兩個連結串列是否相交

給出兩個單向連結串列的頭指標,比如h1、h2,判斷這兩個連結串列是否相交,為了簡化問題,假設兩個連結串列均不帶環。

解法一:直觀的想法
直接判斷第一個連結串列的每個節點是否在第二個連結串列中。這種方法的時間複雜度為O(Length(h1)*Length(h2))。

解法二:利用計數的方法
如果兩個連結串列相交,那麼這兩個連結串列就會有共同的節點。而節點地址又是節點的唯一標識。所有通過能夠判斷兩個連結串列中是否存在一致的節點,就可以知道這兩個連結串列是否相交。
一個簡單的辦法就是對第一個連結串列的節點地址進行hash排序,建立hash表,然後針對第二個連結串列的每個節點的地址查詢hash表,如果它在hash表中出現,那麼說明第二個連結串列和第一個連結串列有共同的節點。此方法的時間複雜度為O(Length(h1) + Length(h2))。但是要同時附加O(Length(h1))的儲存空間來儲存雜湊表。

解法三:轉化為另一已知問題
由於兩個連結串列都沒有環,可以把第二個連結串列接在第一個連結串列後面,如果得到的連結串列有環,則說明這兩個連結串列相交。否則,這兩個連結串列不相交。這樣就可以把問題轉化為判斷一個連結串列是否有環。
需要注意的是: 在這裡 如果有環,則第二個連結串列的表頭一定在環上,只需要從第二個連結串列開始遍歷,看是否會回到起始點就可以判斷出來。

解法四:抓住要點

抓住“如果兩個沒有環的連結串列相較於某一節點,那麼在這個節點之後的所有節點都是兩個連結串列共有的”。

先遍歷第一個連結串列,記住最後一個節點。然後遍歷第二個連結串列,到最後一個節點時和第一個連結串列的最後一個節點做比較,如果相同,則相交,否則,不相交。
時間複雜度為O(Length(h1) + Length(h2)), 而且只用了一個額外的指標來儲存最後一個節點。

3.7 佇列中取最大值操作問題

假設有這樣一個擁有3個操作的佇列:
  1. EnQueue(v): 將v加入佇列中
  2. DeQueue: 使佇列中的隊首元素刪除並返回此元素
  3. MaxElement:返回佇列中的最大元素

設計一種資料結構和演算法,讓MaxElement操作的時間複雜度儘可能地低。

解法一:這個問題的關鍵在於取最大值的操作,並且得考慮當佇列裡面的元素動態增加和減少的時候,如何能夠非常快速地把最大值取出。

最直接的思路是按照傳統方式來實現佇列:利用一個陣列或連結串列來儲存佇列的元素,利用兩個指標分別指向佇列的隊首和隊尾。採用這種方式,那麼MaxElement操作需要遍歷佇列的所有元素。在佇列的長度為N的條件下,時間複雜度為O(N)。

解法二:
考慮用最大堆來維護佇列中的元素。堆中每個元素都有指標指向它的後續元素。這樣,堆就可以很快實現返回最大元素的操作。同時,也能保證佇列的正常插入和刪除。MaxElement操作其實就是維護一個最大堆,其時間複雜度為O(1)。而入隊和出隊操作的時間複雜度為O(log2N)。

解法三:
解法二比解法一快是因為它利用一個指標集合保持了佇列中元素的相對大小關係。所以返回最大值只需要O(1)的時間複雜度,而在元素入隊或出隊時,更新這個指標集合都需要O(log2N)的時間複雜度。所以給我們提供一個思路就是去尋找一種新的儲存佇列中元素相當大小關係的指標集合,並且使得更新這個指標集合的時間複雜度更低。

對於棧來講,Push和Pop操作都是在棧頂完成的,所以很容易維護棧中的最大值,它的時間複雜度為O(1)。

class stack
{
public:
    stack()
    {
        stackTop = -1;
        maxStackItemIndex = -1;
    }    
    void Push(Type x)
    {
        stackTop++;
        if(stackTop >= MAXN)
            ;    //超出棧的最大儲存量
        else
        {
            stackItem[stackTop] = x;
            if(x > Max())
            {
                link2NextMaxItem[stackTop] = maxStackItemIndex;
                maxStackItemIndex = stackTop;
            }
            else
                link2NextMaxItem[stackTop] = -1;
        }
    }
    Type Pop()
    {
        Type ret;
        if(stackTop < 0)
            ThrowException();    //已經沒有元素了,所以不能pop
        else
        {
            ret = stackItem[stackTop];
            if(stackTop == maaxStackItemIndex)
            {
                maxStackItemIndex = link2NextMaxItem[stackTop];
            }
            stackTop--;
        }
        return ret;
    }
    Type Max()
    {
        if(maxStackItemIndex >= 0)
            return stackItem(maxStackItemIndex);
        else
            return -INF;
    }
private:
    Type stackItem[MAXN];
    int stackTop;
    int link2NextMaxItem[MAXN];
    int maxStackItemIndex;
}

維護一個最大值的序列(link2NextMaxItem)來保證Max操作的時間複雜度為O(1),相當於用空間複雜度換取了時間複雜度。

3.8 求二叉樹中節點的最大距離

問題:如果把二叉樹看成一個圖,父子節點之間的連線看成是雙向的,我們姑且定義“距離”為兩個節點之間邊的個數。  寫一個程式求一棵二叉樹中相距最遠的兩個節點之間的距離。

解法一:根據相距最遠的兩個節點一定是葉子節點這個規律,可以發現:
對於任意一個節點,以該節點為根,假設這個根有k個孩子節點,那麼相距最遠的兩個節點U和V之間的路徑與這個根節點的關係有兩種情況。
  1. 若路徑經過根Root,則U和V是屬於不同子樹的,且它們都是該子樹中到根節點最遠 的節點,否則跟它們的距離最遠相矛盾。
  2. 如果路徑不經過Root,那麼它們一定屬於根的k個子樹之一。並且它們也是該子樹中相距最遠的兩個頂點。

因此,問題就可以轉化為在子樹上的解,從而利用動態規劃來解決。
設第k棵子樹中相距最遠的兩個節點:Uk和Vk,其距離定義為d(Uk,Vk),那麼節點Uk或Vk即為子樹K到根節點Rk距離最長的節點。設Uk為子樹K中到根節點Rk距離最長的節點,其到根節點的距離定義為d(Uk,R)。取d(Ui,R)(1≤i≤k)中最大的兩個值max1和max2,那麼經過根節點R的最長路徑為max1+max2+2,所以樹R中相距最遠的兩個點的距離為:max{d(U1,V1),..., d(Uk, Vk),max1+max2+2}。
採用深度優先搜尋,只需要遍歷所有的節點一次,時間複雜度為O(|E|) = O(|V| - 1),其中V為點的集合,E為邊的集合。

程式碼如下,使用二叉樹來實現:
//資料結構定義
struct NODE
{
    NODE* pLeft;    //左子樹
    NODE* pRight;    //右子樹
    int nMaxRight;    //左子樹中的最長距離
    int nMaxRight;    //右子樹中的最長距離
    char chValue;    //該節點的值
};

int nMaxLen = 0;

//尋找樹中最長的兩段距離
void FindMaxLen(NODE* pRoot)
{
    //遍歷到葉子節點,返回
    if(pRoot == NULL)
    {
        return;
    }

    //如果左子樹為空,那麼該節點的左邊最長距離為0
    if(pRoot -> pLeft == NULL)
    {
        pRoot -> nMaxLeft = 0;
    }

    //如果右子樹為空,那麼該節點的右邊最長距離為0
    if(pRoot -> pRight == NULL)
    {
        pRoot -> nMaxRight = 0;
    }

    //如果左子樹不為空,遞迴尋找左子樹最長距離
    if(pRoot -> pLeft != NULL)
    {
        FindMaxLen(pRoot -> pLeft);
    }

    //如果右子樹不為空,遞迴尋找右子樹最長距離
    if(pRoot -> pRight != NULL)
    {
        FindMaxLen(pRoot -> pRight);
    }

    //計算左子樹最長節點距離
    if(pRoot -> pLeft != NULL)
    {
        int nTempMax = 0;
        if(pRoot -> pLeft -> nMaxLeft > pRoot -> pLeft -> nMaxRight)
        {
            nTempMax = pRoot -> pLeft -> nMaxLeft;
        }
        else
        {
            nTempMax = pRoot -> pLeft -> nMaxRight;
        }
        pRoot -> nMaxLeft = nTempMax + 1;
    }

    //計算右子樹最長節點距離
    if(pRoot -> pRight != NULL)
    {
        int nTempMax = 0;
        if(pRoot -> pRight -> nMaxLeft > pRoot -> pRight -> nMaxRight)
        {
            nTemp = pRoot -> pRight -> nMaxLeft;
        }
        else
        {
            nTemp = pRoot -> pRight -> nMaxRight;
        }
        pRoot -> nMaxRight = nTempMax + 1;
    }

    //更新最長距離
    if(pRoot -> nMaxLeft + pRoot -> nMaxRight > nMaxLen)
    {
        nMaxLen = pRoot -> nMaxLeft + pRoot -> nMaxRight;
    }
}


分析遞迴問題,要遵循以下:
  1. 先弄清楚遞迴的順序。在遞迴的實現中,往往須要假設後續的呼叫已經完成,在此基礎上,才實現遞迴的邏輯。在該題中,就是假設已經把後面的長度計算出來了,然後繼續考慮後面的邏輯。
  2. 分析清楚遞迴體的邏輯,然後寫出來。比如在上面的問題中,遞迴體的邏輯就是如何計算兩邊最長的距離。
  3. 考慮清楚遞迴退出的邊界問題。也就是說,哪個地方寫return。


3.9 重建二叉樹

二叉樹的三種遍歷次序--前序、中序、後序。如果知道了遍歷的結果,能不能把一棵二叉樹重新構造出來呢?

給定一棵二叉樹,假設每個節點都用唯一的字元來表示,具體結構如下:

struct NODE{
    NODE* pLeft;
    NODE* pRight;
    char chValue;    //也可以是其它資料型別
};

假設已經有了前序遍歷和中序遍歷的結果,希望通過一個演算法重建這棵樹。
給定函式的定義如下:

void Rebuild (char* pPreOrder, char* pInOrder, int nTreeLen, NODE** pRoot)

引數:
pPreOrder: 以null為結尾的前序遍歷結果的字串陣列。
pInOrder: 以null為結尾的中序遍歷結果的字串陣列。
nTreeLen:樹的長度。
pRoot:返回node**型別,根據前序和中序遍歷結果重新構建樹的根節點。

分析:
前序遍歷:先訪問當前節點,然後以前序訪問左子樹,右子樹。
中序遍歷:先以中序遍歷左子樹,接著訪問當前節點,然後以中序遍歷右子樹。

前序遍歷的每一個節點,都是當前子樹的根節點。同時,以對應的節點為邊界,就會把中序遍歷的結果分為左子樹和右子樹。

程式碼如下:
//ReBuild.cpp:根據前序及中序結果,重建樹的根節點

//定義樹的長度,為了後序呼叫實現的簡單,我們直接用巨集定義了樹節點的總數
#define TREELEN 6

//樹節點
struct NODE
{
    NODE* pLeft;    //左節點
    NODE* pRight;    //右節點
    char chValue;    //節點值
};

void ReBuild(char* pPreOrder,    //前序遍歷結果
            char* pInOrder,      //中序遍歷結果
            int nTreeLen,        //樹長度
            NODE** pRoot)        //根節點
{
    //檢查邊界條件
    if(pPreOrder == NULL || pInOrder == NULL)
    {
        return;
    }

    //獲得前序遍歷的第一個節點
    NODE* pTemp = new NODE;
    pTemp -> chValue = *pPreOrder;
    pTemp -> pLeft = NULL;
    pTemp -> pRight = NULL;

    //如果節點為空,把當前節點複製到根節點
    if(*pRoot == NULL)
    {
        *pRoot = pTemp;
    }

    //如果當前樹長度為1,那麼已經是最後一個節點
    if(nTreeLen == 1)
    {
        return;
    }

    //尋找子樹的長度
    char* pOrgInOrder = pInOrder;
    char* pLeftEnd = pInOrder;
    int nTempLen = 0;

    //找到左子樹的結尾
    while(*pPreOrder != pLeftEnd)
    {
        if(pPreOrder == NULL || pLeftEnd == NULL)
        {
            return;    
        }

        nTempLen++;

        //記錄臨時長度,以免溢位
        if(nTempLen > nTreeLen)
        {
            break;
        }
        pLeftEnd++;
    }

    //尋找左子樹長度
    int nLeftLen = 0;
    nLeftLen = (int) (pLeftEnd - pOrgInOrder);

    //尋找右子樹長度
    int nRightLen = 0;
    nRightLen = nTreeLen - nLeftLen - 1;

    //重建左子樹
    if(nLeftLen > 0)
    {
        ReBuild(pPreOrder+1, pInOrder, nLeftLen, &((*pRoot) -> pLeft));
    }

    //重建右子樹
    if(nRightLen > 0)
    {
        Rebuild(pPreOrder + nLeftLen + 1, pInOrder + nLeftLen + 1, nRightLen, &((*pRoot) -> pRight));
    }
}

//示例的呼叫程式碼
int main(int argc, char* argv[])
{
    char szPreOrder[TREELEN] = {'a', 'b', 'd', 'c', 'e', 'f};
    char  szInOrder[TREELEN] = {'d',  'b', 'a', 'e', 'c', 'f};

       NODE* pRoot = NULL;
      ReBuild(szPreOrder, szInOrder, TREELEN, &pRoot);
}

3.10 分層二叉樹

問題一:給定一棵二叉樹,要求按分層遍歷該二叉樹,即從上到下按層次訪問該二叉樹(每一層將單獨輸出一行),每一層要求訪問的順序為從左到右,並將節點依次編號。

問題二:寫另外一個函式,列印二叉樹中某層次的節點(從左到右),其中根節點為第0層,函式原型為int PrintNodeAtLevel(Node* root, int level),成功返回1,失敗則返回0。

分析與解法:
定義節點的資料結構為(該二叉樹中的資料型別為整數):
struct Node
{
    int data;    //節點中的資料
    Node* lChild;    //左子指標
    Node* rChild;    //右子指標
}

假設要求訪問二叉樹中第k層的節點,那麼其實 可以把它轉換成分別訪問“以該二叉樹根節點的左右子節點為根節點的兩顆子樹”中層次為k-1的節點。
//輸出以root為根節點中的第level層中的所有節點(從左到右)
//失敗則返回0
//root為二叉樹的根節點
//level為層次數,其中根節點為第0層
int PrintNodeAtLevel(Node* root, int level)
{
    if(!root || level < 0)
        return 0;
    if(level == 0)
    {
        cout << root -> data << "";
        return 1;
    }
    return PrintNodeAtLevel(node -> lChild, level - 1) + PrintNodeAtLevel(node -> rChild, level - 1);
}

採用遞迴演算法,思路清晰,缺點是遞迴函式的呼叫效率較低,無論是耗費的計算時間還是佔用的儲存空間都比非遞迴演算法要多。

問題一的解法只需要知道二叉樹的深度n,呼叫n次PrintNodeAtLevel()
//層次遍歷二叉樹
//root,二叉樹的節點
//depth,樹的深度
void PrintNodeByLevel(Node* root, int depth)
{
    for(int level = 0; level < depth; level++)
    {
        PrintNodeAtLevel(root, level);
        cout << endl;
    }
}


在訪問第k層的時候,只需要知道第k-1層的節點資訊就足夠了,所以在訪問第k層的時候,要是能夠知道第k-1層的節點資訊,就不再需要從根節點開始遍歷了。

可以從根節點出發,依次將每層的節點從左到右壓入一個陣列,並用一個遊標Cur記錄當前訪問的節點,另一個遊標Last指示當前層次的最後一個節點的下一個位置,以Cur == Last作為當前層次訪問結束的條件,在訪問某一層的同時將該層的所有節點的子節點壓入陣列,在訪問完某一層之後,檢查是否還有新的層次可以訪問,直到訪問完所有的層次。

//按層次遍歷二叉樹
//root,二叉樹的根節點
void PrintNodeByLevel(Node* root)
{
    if(root == NULL)
        return;
    vector<Node*> vec;    
    vec.push_back(root);
    int cur = 0;
    int last = 1;
    while(cur < vec.size())
    {
        Last = vec.size();    //新的一行訪問開始,重新定位last於當前行最後一個節點的下一個位置
        while(cur < last)
        {
            cout << vec[cur] -> data << " ";     //訪問節點
            if(vec[cur] -> lChild)    //當前訪問節點的左節點不為空則壓入
                vec.push_back(vec[cur]->lChild);
                    if(vec[cur] -> rChild)    //當前訪問節點的右節點不為空則壓入
                vec.push_back(vec[cur]->rChild);
            cur++;
        }
        cout << endl;    //當cur == last時,說明該層次訪問結束,輸出換行符
    }
}






 

相關文章