LeetCode題集-3 - 無重複字元的最長子串

IT规划师發表於2024-09-09

題目:給定一個字串 s ,請你找出其中不含有重複字元的最長子串的長度。

我們先來好好理解題目,示例1中怎麼得到長度為3的?

如果以第一個字元a為起始,不含重複的最長子串是abc;則我們這樣表示(a)bcabcbb -> (abc)abcbb,如此表達列舉出所有可能的情況如下:

1.(a)bcabcbb -> (abc)abcbb;

2.a(b)cabcbb -> a(bca)bcbb;

3.ab(c)abcbb -> ab(cab)cbb;

4.abc(a)bcbb -> abc(abc)bb;

5.abca(b)cbb -> abca(bc)bb;

6.abcab(c)bb -> abcab(cb)b;

7.abcabc(b)b -> abcabc(b)b;

8.abcabcb(b) -> abcabcb(b);

在所有可能的情況中滿足條件的最長的子串分別為abc、bca、cab三個,三個長度都是3,因此示例1的結果為3。

01、解法一、雙指標法

透過上面列舉出的所有情況,可以發現滿足要求的字串是從起始位置向結束位置滾動的,並且在這個過程中,字串的長度也是在變化的,那也就是說只要我們準備兩個指標start和end,並控制好兩個指標前進的節奏就可以完成任務。

那如何控制指標節奏呢?

首先說第二個指標end,我們把上面1.(a)bcabcbb -> (abc)abcbb中步驟進行補充,應該是1.(a)bcabcbb -> (ab)cabcbb -> (abc)abcbb即指標end一步一步往後走,即使遇到重複字元依舊穩步前進。

每當指標end往後移動一位,只需判斷這一位有沒有在之前的字串中出現過,如果出現過則開始調整指標start。

例如上面的1->2即(abc)abcbb -> a(bca)bcbb過程中,當指標end到第二個a時,而前面的子串abc中已經出現過a了,因此需要把指標start跳轉到b即跳轉到子串中重複字元後一個位置。

我們用圖例詳細描述一下從指標end移動到start移動的具體過程。

下面看看具體實現程式碼。

public static int SlidingWindow(string s)
{
    //start指標
    var startIndex = 0;
    //end指標
    var endIndex = 0;
    //當前不重複子串長度
    var currentLength = 0;
    //最長不重複子串長度
    var maxLength = 0;
    //一直處理直到end指標不小於字串長度
    while (endIndex < s.Length)
    {
        //獲取待處理字元
        var pendingChar = s[endIndex];
        //判斷待處理字串是否在當前子串中存在
        for (var i = startIndex; i < endIndex; i++)
        {
            //如果子串中已經存在待處理字元
            if (pendingChar == s[i])
            {
                //把start指標跳轉至子串中重複字元下一個位置
                startIndex = i + 1;
                //重新計算當前不重複子串長度
                currentLength = endIndex - startIndex;
                break;
            }
        }
        //end指標向後移動一位
        endIndex++;
        //當前不重複子串長度加1
        currentLength++;
        //比較並更新最大不重複子串長度
        if (currentLength > maxLength)
        {
            maxLength = currentLength;
        }
    }
    return maxLength;
}

分析可知,因為是雙層迴圈while+for所以演算法時間複雜度是:O(N2),又因為沒有引用額外的空間因此空間複雜度是:O(1)。

02、解法二、雙指標+雜湊法

對於雙層迴圈我們還是有辦法進行最佳化的,最常見的做法是空間換時間,即把內層迴圈透過雜湊表替換換掉,這樣透過雜湊表提供O(1)查詢時間複雜度,使得整個演算法時間複雜度達到O(N)。但是雜湊表需要額外的O(N)空間

如果用雜湊表儲存已經存在字元,應該如何儲存呢?key存什麼?value存什麼?這裡有一個問題是雜湊表只存當前子串的字元?還是存所有已存在字元?如果只存當前子串的字元意味著每次都要清除雜湊表,而且清除動作時間複雜度是O(N)。所以我們選擇存所有已存在字元。

如果存所有已存在字元,則要注意判斷無效資料,比如abc(ba)b中我們不能把最後一個b和第一個b比較,因為當前子串是(ba),所以應該和第二個b做判斷。

實現程式碼如下:

public static int SlidingWindowDictionary(string s)
{
    //start指標
    var startIndex = 0;
    //end指標
    var endIndex = 0;
    //當前不重複子串長度
    var currentLength = 0;
    //最長不重複子串長度
    var maxLength = 0;
    //字典表,儲存已存在字元
    var dic = new Dictionary<char, int>();
    //一直處理直到end指標不小於字串長度
    while (endIndex < s.Length)
    {
        //獲取待處理字元
        var pendingChar = s[endIndex];
        //判斷待處理字元是否在字典表中存在,並且其索引位置在當前子串中
        if (dic.TryGetValue(pendingChar, out var value) && value >= startIndex)
        {
            //把start指標跳轉至子串中重複字元下一個位置
            startIndex = value + 1;
            //重新計算當前不重複子串長度
            currentLength = endIndex - startIndex;
        }
        //更新字典表已存在字元最後的索引位置
        dic[pendingChar] = endIndex;
        //end指標向後移動一位
        endIndex++;
        //當前不重複子串長度加1
        currentLength++;
        //比較並更新最大不重複子串長度
        if (currentLength > maxLength)
        {
            maxLength = currentLength;
        }
    }
    return maxLength;
}

03、解法三、雙指標+陣列法

那這個演算法還有最佳化空間嗎?我們知道雜湊表操作是有消耗的,有沒有比雜湊表更好的儲存方式呢?

針對不同的問題可能有不同的方式,對於這一題,的確有點特別,不知道有沒有注意到題目最下面的“s 由英文字母、數字、符號和空格組成”描述,這不由的讓我想到ASCII碼錶。

如果是s是由ASCII碼錶裡的字元組成,那麼就代表每一個字元都有一個對應的十進位制值,這就是天然的下標,然後以所有的ASCII碼錶數量構建一個字元陣列用來存放已經存在的字元,而每個字元存放位置就是其對應的十進位制值,這樣不就可以解決儲存的問題了嗎?

因為我們先構建了陣列,因此還需要給陣列每個元素賦值為-1,用來標記當前元素還沒有使用。

具體實現程式碼如下:

public static int SlidingWindowArray(string s)
{
    //start指標
    var startIndex = 0;
    //end指標
    var endIndex = 0;
    //當前不重複子串長度
    var currentLength = 0;
    //最長不重複子串長度
    var maxLength = 0;
    //定義可能存在的字元陣列,並全部填充為-1
    var arr = new int[128];
    Array.Fill(arr, -1);
    //一直處理直到end指標不小於字串長度
    while (endIndex < s.Length)
    {
        //獲取待處理字元
        var pendingChar = s[endIndex];
        //判斷待處理字元索引位置是否在當前子串內
        if (arr[pendingChar] >= startIndex)
        {
            //把start指標跳轉至子串中重複字元下一個位置
            startIndex = arr[pendingChar] + 1;
            //重新計算當前不重複子串長度
            currentLength = endIndex - startIndex;
        }
        //更新陣列中已存在字元最後的索引位置
        arr[pendingChar] = endIndex;
        //end指標向後移動一位
        endIndex++;
        //當前不重複子串長度加1
        currentLength++;
        //比較並更新最大不重複子串長度
        if (currentLength > maxLength)
        {
            maxLength = currentLength;
        }
    }
    return maxLength;
}

雖然已經實現了三種解題方法,但是到底效能如何?下面我們對三個方法進行一組基準測試,每個方法測試10000次,每次隨機構建一個長度為10000的字串。

可以發現雙指標+雜湊表比單純的雙指標效能還有差很多,而雙指標+陣列整體表現就好很多了。由此可見雙指標+雜湊表還是有其侷限性的,雖然理論值很好,但是實際表現不盡如人意,這也提醒我們要在合適的地方使用合適的方法,才能更好的解決問題。

測試方法程式碼以及示例原始碼都已經上傳至程式碼庫,有興趣的可以看看。https://gitee.com/hugogoos/Planner

相關文章