題目:給定一個字串 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