https://zhuanlan.zhihu.com/p/431240843
- 1.1 算法策略
- 1.2 適用場景
- 1.3 使用遞歸算法求解的一些經典問題 DOM樹爲例
- 2.1 算法策略
- 2.2 適用場景
- 2.3 使用分治法求解的一些經典問題二分查找
- 3.1 算法策略
- 3.2 適用場景
- 3.3 經典案例:活動選擇問題
- 4.1 算法策略
- 4.2 適用場景
- 4.3 使用回溯算法的經典案例正則表達式匹配
- 5.1 算法策略
- 5.2 適用場景
- 5.3 使用動態規劃求解的一些經典問題 爬樓梯問題
- 6.1 算法策略
- 6.2 解題思路
- 7.1 爬樓梯問題
-
- 解法:動態規劃
- 7.2 使用最小花費爬樓梯
-
- 解法:動態規劃
- 7.3 最大子序和
- 7.4 買賣股票的最佳時機
-
- 解法:動態規劃
- 7.5 迴文子串
-
- 解法一:暴力法
- 解法二:動態規劃
- 7.6 最長迴文子串
-
- 解法:動態規劃
- 7.7 最小路徑和
- 7.8 買賣股票的最佳時機 II
-
- 解法一:峯底買入,峯頂賣出
- 解法二:貪心算法
- 7.9 分發餅乾
-
- 解法:貪心算法
- 7.10 分割數組爲連續子序列
-
- 解法:貪心算法
- 7.11 全排列問題
-
- 解法:回溯算法
- 7.12 括號生成
-
- 解答:回溯算法(深度優先遍歷)
算法思想是解決問題的核心,萬丈高樓起於平地,在算法中也是如此,95% 的算法都是基於這 6 種算法思想,接下了介紹一下這 6 種算法思想,幫助你理解及解決各種算法問題。
1 遞歸算法
1.1 算法策略
遞歸算法是一種直接或者間接調用自身函數或者方法的算法。
遞歸算法的實質是把問題分解成規模縮小的同類問題的子問題,然後遞歸調用方法來表示問題的解。遞歸算法對解決一大類問題很有效,它可以使算法簡潔和易於理解。
優缺點:
- 優點:實現簡單易上手
- 缺點:遞歸算法對常用的算法如普通循環等,運行效率較低;並且在遞歸調用的過程當中系統爲每一層的返回點、局部量等開闢了棧來存儲,遞歸太深,容易發生棧溢出
1.2 適用場景
遞歸算法一般用於解決三類問題:
- 數據的定義是按遞歸定義的。(斐波那契數列)
- 問題解法按遞歸算法實現。(回溯)
- 數據的結構形式是按遞歸定義的。(樹的遍歷,圖的搜索)
遞歸的解題策略:
- 第一步:明確你這個函數的輸入輸出,先不管函數裏面的代碼什麼,而是要先明白,你這個函數的輸入是什麼,輸出爲何什麼,功能是什麼,要完成什麼樣的一件事。
- 第二步:尋找遞歸結束條件,我們需要找出什麼時候遞歸結束,之後直接把結果返回
- 第三步:明確遞歸關係式,怎麼通過各種遞歸調用來組合解決當前問題
1.3 使用遞歸算法求解的一些經典問題
- 斐波那契數列
- 漢諾塔問題
- 樹的遍歷及相關操作
DOM樹爲例
下面以以 DOM 爲例,實現一個 document.getElementById
功能
由於DOM是一棵樹,而樹的定義本身就是用的遞歸定義,所以用遞歸的方法處理樹,會非常地簡單自然。
第一步:明確你這個函數的輸入輸出
從 DOM 根節點一層層往下遞歸,判斷當前節點的 id 是否是我們要尋找的 id='d-cal&
amp;amp;#x27;
輸入:DOM 根節點 document
,我們要尋找的 id='d-cal&
amp;amp;#x27;
輸出:返回滿足 id='sisteran'
的子結點
function getElementById(node, id){}
第二步:尋找遞歸結束條件
從document開始往下找,對所有子結點遞歸查找他們的子結點,一層一層地往下查找:
- 如果當前結點的 id 符合查找條件,則返回當前結點
- 如果已經到了葉子結點了還沒有找到,則返回 null
function getElementById(node, id){
// 當前結點不存在,已經到了葉子結點了還沒有找到,返回 null
if(!node) return null
// 當前結點的 id 符合查找條件,返回當前結點
if(node.id === id) return node
}
第三步:明確遞歸關係式
當前結點的 id 不符合查找條件,遞歸查找它的每一個子結點
function getElementById(node, id){
// 當前結點不存在,已經到了葉子結點了還沒有找到,返回 null
if(!node) return null
// 當前結點的 id 符合查找條件,返回當前結點
if(node.id === id) return node
// 前結點的 id 不符合查找條件,繼續查找它的每一個子結點
for(var i = 0; i < node.childNodes.length; i++){
// 遞歸查找它的每一個子結點
var found = getElementById(node.childNodes[i], id);
if(found) return found;
}
return null;
}
就這樣,我們的一個 document.getElementById
功能已經實現了:
function getElementById(node, id){
if(!node) return null;
if(node.id === id) return node;
for(var i = 0; i < node.childNodes.length; i++){
var found = getElementById(node.childNodes[i], id);
if(found) return found;
}
return null;
}
getElementById(document, "d-cal");
最後在控制檯驗證一下,執行結果如下圖所示:
使用遞歸的優點是代碼簡單易懂,缺點是效率比不上非遞歸的實現。Chrome瀏覽器的查DOM是使用非遞歸實現。非遞歸要怎麼實現呢?
如下代碼:
function getByElementId(node, id){
//遍歷所有的Node
while(node){
if(node.id === id) return node;
node = nextElement(node);
}
return null;
}
還是依次遍歷所有的 DOM
結點,只是這一次改成一個 while
循環,函數 nextElement
負責找到下一個結點。所以關鍵在於這個 nextElement
如何實現非遞歸查找結點功能:
// 深度遍歷
function nextElement(node){
// 先判斷是否有子結點
if(node.children.length) {
// 有則返回第一個子結點
return node.children[0];
}
// 再判斷是否有相鄰結點
if(node.nextElementSibling){
// 有則返回它的下一個相鄰結點
return node.nextElementSibling;
}
// 否則,往上返回它的父結點的下一個相鄰元素,相當於上面遞歸實現裏面的for循環的i加1
while(node.parentNode){
if(node.parentNode.nextElementSibling) {
return node.parentNode.nextElementSibling;
}
node = node.parentNode;
}
return null;
}
在控制檯裏面運行這段代碼,同樣也可以正確地輸出結果。不管是非遞歸還是遞歸,它們都是深度優先遍歷,這個過程如下圖所示。
實際上 getElementById 瀏覽器是用的一個哈希 map 存儲的,根據 id 直接映射到 DOM 結點,而 getElementsByClassName 就是用的這樣的非遞歸查找。
參考:我接觸過的前端數據結構與算法
2 分治算法
2.1 算法策略
在計算機科學中,分治算法是一個很重要的算法,快速排序、歸併排序等都是基於分治策略進行實現的,所以,建議理解掌握它。
分治,顧名思義,就是 分而治之 ,將一個複雜的問題,分成兩個或多個相似的子問題,在把子問題分成更小的子問題,直到更小的子問題可以簡單求解,求解子問題,則原問題的解則爲阿子問題解的合併。
2.2 適用場景
當出現滿足以下條件的問題,可以嘗試只用分治策略進行求解:
- 原始問題可以分成多個相似的子問題
- 子問題可以很簡單的求解
- 原始問題的解是子問題解的合併
- 各個子問題是相互獨立的,不包含相同的子問題
分治的解題策略:
- 第一步:分解,將原問題分解爲若干個規模較小,相互獨立,與原問題形式相同的子問題
- 第二步:解決,解決各個子問題
- 第三步:合併,將各個子問題的解合併爲原問題的解
2.3 使用分治法求解的一些經典問題
- 二分查找
- 歸併排序
- 快速排序
- 漢諾塔問題
- React 時間分片
二分查找
也稱折半查找算法,它是一種簡單易懂的快速查找算法。例如我隨機寫0-100之間的一個數字,讓你猜我寫的是什麼?你每猜一次,我就會告訴你猜的大了還是小了,直到猜中爲止。
第一步:分解
每次猜拳都把上一次的結果分出大的一組和小的一組,兩組相互獨立
- 選擇數組中的中間數
function binarySearch(items, item) {
// low、mid、high將數組分成兩組
var low = 0,
high = items.length - 1,
mid = Math.floor((low+high)/2),
elem = items[mid]
// ...
}
第二步:解決子問題
查找數與中間數對比
- 比中間數低,則去中間數左邊的子數組中尋找;
- 比中間數高,則去中間數右邊的子數組中尋找;
- 相等則返回查找成功
while(low <= high) {
if(elem < item) { // 比中間數高
low = mid + 1
} else if(elem > item) { // 比中間數低
high = mid - 1
} else { // 相等
return mid
}
}
第三步:合併
function binarySearch(items, item) {
var low = 0,
high = items.length - 1,
mid, elem
while(low <= high) {
mid = Math.floor((low+high)/2)
elem = items[mid]
if(elem < item) {
low = mid + 1
} else if(elem > item) {
high = mid - 1
} else {
return mid
}
}
return -1
}
最後,二分法只能應用於數組有序的情況,如果數組無序,二分查找就不能起作用了
function binarySearch(items, item) {
// 快排
quickSort(items)
var low = 0,
high = items.length - 1,
mid, elem
while(low <= high) {
mid = Math.floor((low+high)/2)
elem = items[mid]
if(elem < item) {
low = mid + 1
} else if(elem > item) {
high = mid - 1
} else {
return mid
}
}
return -1
}
// 測試
var arr = [2,3,1,4]
binarySearch(arr, 3)
// 2
binarySearch(arr, 5)
// -1
測試成功
3 貪心算法
3.1 算法策略
貪心算法,故名思義,總是做出當前的最優選擇,即期望通過局部的最優選擇獲得整體的最優選擇。
某種意義上說,貪心算法是很貪婪、很目光短淺的,它不從整體考慮,僅僅只關注當前的最大利益,所以說它做出的選擇僅僅是某種意義上的局部最優,但是貪心算法在很多問題上還是能夠拿到最優解或較優解,所以它的存在還是有意義的。
3.2 適用場景
在日常生活中,我們使用到貪心算法的時候還是挺多的,例如:
從100章面值不等的鈔票中,抽出 10 張,怎樣才能獲得最多的價值?
我們只需要每次都選擇剩下的鈔票中最大的面值,最後一定拿到的就是最優解,這就是使用的貪心算法,並且最後得到了整體最優解。
但是,我們任然需要明確的是,期望通過局部的最優選擇獲得整體的最優選擇,僅僅是期望而已,也可能最終得到的結果並不一定不能是整體最優解。
例如:求取A到G最短路徑:
根據貪心算法總是選擇當前最優選擇,所以它首先選擇的路徑是 AB,然後 BE、EG,所得到的路徑總長爲 1 + 5 + 4 = 10,然而這並不是最短路徑,最短路徑爲 A->C->G : 2 + 2 = 4,所以說,貪心算法得到得並不一定是最優解。
那麼一般在什麼時候可以嘗試選擇使用貪心算法喃?
當滿足一下條件時,可以使用:
- 原問題複雜度過高
- 求全局最優解的數學模型難以建立或計算量過大
- 沒有太大必要一定要求出全局最優解,“比較優”就可以
如果使用貪心算法求最優解,可以按照以下 步驟求解 :
- 首先,我們需要明確什麼是最優解(期望)
- 然後,把問題分成多個步驟,每一步都需要滿足:
-
- 可行性:每一步都滿足問題的約束
-
- 局部最優:每一步都做出一個局部最優的選擇
- - 不可取消:選擇一旦做出,在後面遇到任何情況都不可取消
- 最後,疊加所有步驟的最優解,就是全局最優解
3.3 經典案例:活動選擇問題
使用貪心算法求解的經典問題有:
- 最小生成樹算法
- 單源最短路徑的 Dijkstra 算法
- Huffman 壓縮編碼
- 揹包問題
- 活動選擇問題等
其中活動選擇問題是最簡單的,這裏詳細介紹這個。
活動選擇問題是《算法導論》上的例子,也是一個非常經典的問題。有 n 個活動(a1,a2,…,an)需要使用同一個資源(例如教室),資源在某個時刻只能供一個活動使用。每個活動 ai 都有一個開始時間 si 和結束時間 fi 。一旦被選擇後,活動 ai 就佔據半開時間區間 [si,fi) 。如果 [si,fi) 和 [sj,fj) 互不重疊,ai 和 aj 兩個活動就可以被安排在這一天。
該問題就是要安排這些活動,使得儘量多的活動能不衝突的舉行。例如下圖所示的活動集合S,其中各項活動按照結束時間單調遞增排序。
共有 7 個活動,它們在 18 個小時內需要佔用的時間如上圖,如何選擇活動,能讓這間教室利用率最高喃(能夠舉行更多的活動)?
貪心算法對這種問題的解決很簡單的,它開始時刻開始選擇,每次選擇開始時間與與已選擇活動不衝突的,結束時間又比較靠前的活動,這樣會讓剩下的時間區間更長。
- 首先 a1 活動的結束時間最早,選擇 a1 活動
- a1 結束後,a2 有時間衝突,不可選擇,a3、a4 都可選擇,但 a4 結束時間最早,選擇 a4
- 依次選擇時間沒有衝突的,又結束時間最早的活動
最終選擇活動爲 a1,a4,a5,a7。爲最優解。
4 回溯算法
4.1 算法策略
回溯算法是一種搜索法,試探法,它會在每一步做出選擇,一旦發現這個選擇無法得到期望結果,就回溯回去,重新做出選擇。深度優先搜索利用的就是回溯算法思想。
4.2 適用場景
回溯算法很簡單,它就是不斷的嘗試,直到拿到解。它的這種算法思想,使它通常用於解決廣度的搜索問題,即從一組可能的解中,選擇一個滿足要求的解。
4.3 使用回溯算法的經典案例
- 深度優先搜索
- 0-1揹包問題
- 正則表達式匹配
- 八皇后
- 數獨
- 全排列
等等,深度優先搜索我們在圖那一章已經介紹過,這裏以正則表達式匹配爲例,介紹一下
正則表達式匹配
var string = "abbc"
var regex = /ab{1,3}c/
console.log( string.match(regex) )
// ["abbc", index: 0, input: "abbc", groups: undefined]
它的匹配過程:
在第 5 步匹配失敗,此時 b{1,3}
已經匹配到了兩個 b
正在嘗試第三個 b
,結果發現接下來是 c
。此時就需要回溯到上一步, b{1,3}
匹配完畢(匹配到了 bb
),然後再匹配 c
,匹配到了 c
匹配結束。
5 動態規劃
5.1 算法策略
動態規劃也是將複雜問題分解成小問題求解的策略,與分治算法不同的是,分治算法要求各子問題是相互獨立的,而動態規劃各子問題是相互關聯的。
所以,動態規劃適用於子問題重疊的情況,即不同的子問題具有公共的子子問題,在這種情況下,分治策略會做出很多不必要的工作,它會反覆求解那些公共子子問題,而動態規劃會對每個子子問題求解一次,然後保存在表格中,如果遇到一致的問題,從表格中獲取既可,所以它無需求解每一個子子問題,避免了大量的不必要操作。
5.2 適用場景
動態規劃適用於求解最優解問題,比如,從面額不定的100個硬幣中任意選取多個湊成10元,求怎樣選取硬幣纔可以使最後選取的硬幣數最少又剛好湊夠了10元。這就是一個典型的動態規劃問題。它可以分成一個個子問題(每次選取硬幣),每個子問題又有公共的子子問題(選取硬幣),子問題之間相互關聯(已選取的硬幣總金額不能超過10元),邊界條件就是最終選取的硬幣總金額爲 10 元。
針對上例,也許你也可以說,我們可以使用回溯算法,不斷的去試探,但回溯算法是使用與求解廣度的解(滿足要求的解),如果是用回溯算法,我們需要嘗試去找所有滿足條件的解,然後找到最優解,時間複雜度爲 O(2^n^) ,這性能是相當差的。大多數適用於動態規劃的問題,都可以使用回溯算法,只是使用回溯算法的時間複雜度比較高而已。
最後,總結一下,我們使用動態規劃求解問題時,需要遵循以下幾個重要步驟:
- 定義子問題
- 實現需要反覆執行解決的子子問題部分
- 識別並求解出邊界條件
5.3 使用動態規劃求解的一些經典問題
- 爬樓梯問題:假設你正在爬樓梯。需要 n 階你才能到達樓頂。每次你可以爬 1 或 2 個臺階。你有多少種不同的方法可以爬到樓頂呢?
- 揹包問題:給出一些資源(有總量及價值),給一個揹包(有總容量),往揹包裏裝資源,目標是在揹包不超過總容量的情況下,裝入更多的價值
- 硬幣找零:給出面額不定的一定數量的零錢,以及需要找零的錢數,找出有多少種找零方案
- 圖的全源最短路徑:一個圖中包含 u、v 頂點,找出從頂點 u 到頂點 v 的最短路徑
- 最長公共子序列:找出一組序列的最長公共子序列(可由另一序列刪除元素但不改變剩下元素的順序實現)
這裏以最長公共子序列爲例。
爬樓梯問題
這裏以動態規劃經典問題爬樓梯問題爲例,介紹求解動態規劃問題的步驟。
第一步:定義子問題
如果用 dp[n]
表示第 n
級臺階的方案數,並且由題目知:最後一步可能邁 2 個臺階,也可邁 1 個臺階,即第 n
級臺階的方案數等於第 n-1
級臺階的方案數加上第 n-2
級臺階的方案數
第二步:實現需要反覆執行解決的子子問題部分
dp[n] = dp[n−1] + dp[n−2]
第三步:識別並求解出邊界條件
// 第 0 級 1 種方案
dp[0]=1
// 第 1 級也是 1 種方案
dp[1]=1
最後一步:把尾碼翻譯成代碼,處理一些邊界情況
let climbStairs = function(n) {
let dp = [1, 1]
for(let i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2]
}
return dp[n]
}
複雜度分析:
- 時間複雜度:O(n)
- 空間複雜度:O(n)
優化空間複雜度:
let climbStairs = function(n) {
let res = 1, n1 = 1, n2 = 1
for(let i = 2; i <= n; i++) {
res = n1 + n2
n1 = n2
n2 = res
}
return res
}
空間複雜度:O(1)
6 枚舉算法
6.1 算法策略
枚舉算法的思想是:將問題的所有可能的答案一一列舉,然後根據條件判斷此答案是否合適,保留合適的,丟棄不合適的。
6.2 解題思路
- 確定枚舉對象、枚舉範圍和判定條件。
- 逐一列舉可能的解,驗證每個解是否是問題的解。
7 刷題
7.1 爬樓梯問題
假設你正在爬樓梯。需要 n 階你才能到達樓頂。
每次你可以爬 1 或 2 個臺階。你有多少種不同的方法可以爬到樓頂呢?
注意: 給定 n 是一個正整數。
示例 1:
輸入: 2
輸出: 2
解釋: 有兩種方法可以爬到樓頂。
1. 1 階 + 1 階
2. 2 階
示例 2:
輸入: 3
輸出: 3
解釋: 有三種方法可以爬到樓頂。
1. 1 階 + 1 階 + 1 階
2. 1 階 + 2 階
3. 2 階 + 1 階
解法:動態規劃
動態規劃(Dynamic Programming,DP)是一種將複雜問題分解成小問題求解的策略,但與分治算法不同的是,分治算法要求各子問題是相互獨立的,而動態規劃各子問題是相互關聯的。
分治,顧名思義,就是分而治之,將一個複雜的問題,分成兩個或多個相似的子問題,在把子問題分成更小的子問題,直到更小的子問題可以簡單求解,求解子問題,則原問題的解則爲子問題解的合併。
我們使用動態規劃求解問題時,需要遵循以下幾個重要步驟:
- 定義子問題
- 實現需要反覆執行解決的子子問題部分
- 識別並求解出邊界條件
第一步:定義子問題
如果用 dp[n]
表示第 n
級臺階的方案數,並且由題目知:最後一步可能邁 2 個臺階,也可邁 1 個臺階,即第 n
級臺階的方案數等於第 n-1
級臺階的方案數加上第 n-2
級臺階的方案數
第二步:實現需要反覆執行解決的子子問題部分
dp[n] = dp[n−1] + dp[n−2]
第三步:識別並求解出邊界條件
// 第 0 級 1 種方案
dp[0]=1
// 第 1 級也是 1 種方案
dp[1]=1
最後一步:把尾碼翻譯成代碼,處理一些邊界情況
let climbStairs = function(n) {
let dp = [1, 1]
for(let i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2]
}
return dp[n]
}
複雜度分析:
- 時間複雜度:O(n)
- 空間複雜度:O(n)
優化空間複雜度:
let climbStairs = function(n) {
let res = 1, n1 = 1, n2 = 1
for(let i = 2; i <= n; i++) {
res = n1 + n2
n1 = n2
n2 = res
}
return res
}
空間複雜度:O(1)
更多解答
7.2 使用最小花費爬樓梯
數組的每個索引作爲一個階梯,第 i
個階梯對應着一個非負數的體力花費值 cost[i]
(索引從0開始)。
每當你爬上一個階梯你都要花費對應的體力花費值,然後你可以選擇繼續爬一個階梯或者爬兩個階梯。
您需要找到達到樓層頂部的最低花費。在開始時,你可以選擇從索引爲 0 或 1 的元素作爲初始階梯。
示例 1:
輸入: cost = [10, 15, 20]
輸出: 15
解釋: 最低花費是從cost[1]開始,然後走兩步即可到階梯頂,一共花費15。
示例 2:
輸入: cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1]
輸出: 6
解釋: 最低花費方式是從cost[0]開始,逐個經過那些1,跳過cost[3],一共花費6。
注意:
cost
的長度將會在[2, 1000]
。- 每一個
cost[i]
將會是一個Integer類型,範圍爲[0, 999]
。
解法:動態規劃
本題注意理解題意:
- 第
i
級臺階是第i-1
級臺階的階梯頂部。 - 踏上第
i
級臺階花費cost[i]
,直接邁一大步跨過而不踏上去則不用花費。 - 樓梯頂部在數組之外,如果數組長度爲
len
,那麼樓頂就在下標爲len
第一步:定義子問題
踏上第 i
級臺階的體力消耗爲到達前兩個階梯的最小體力消耗加上本層體力消耗:
- 最後邁 1 步踏上第
i
級臺階:dp[i-1] + cost[i]
- 最後邁 1 步踏上第
i
級臺階:dp[i-2] + cost[i]
第二步:實現需要反覆執行解決的子子問題部分
所以踏上第 i
級臺階的最小花費爲:
dp[i] = min(dp[i-2], dp[i-1]) + cost[i]
第三步:識別並求解出邊界條件
// 第 0 級 cost[0] 種方案
dp[0] = cost[0]
// 第 1 級,有兩種情況
// 1:分別踏上第0級與第1級臺階,花費cost[0] + cost[1]
// 2:直接從地面開始邁兩步直接踏上第1級臺階,花費cost[1]
dp[1] = min(cost[0] + cost[1], cost[1]) = cost[1]
最後一步:把尾碼翻譯成代碼,處理一些邊界情況
let minCostClimbingStairs = function(cost) {
cost.push(0)
let dp = [], n = cost.length
dp[0] = cost[0]
dp[1] = cost[1]
for(let i = 2; i < n; i++){
dp[i] = Math.min(dp[i-2] , dp[i-1]) + cost[i]
}
return dp[n-1]
}
複雜度分析:
- 時間複雜度:O(n)
- 空間複雜度:O(n)
優化:
let minCostClimbingStairs = function(cost) {
let n = cost.length,
n1 = cost[0],
n2 = cost[1]
for(let i = 2;i < n;i++){
let tmp = n2
n2 = Math.min(n1,n2)+cost[i]
n1 = tmp
}
return Math.min(n1,n2)
};
- 時間複雜度:O(n)
- 空間複雜度:O(1)
更多解答
7.3 最大子序和
給定一個整數數組 nums
,找到一個具有最大和的連續子數組(子數組最少包含一個元素),返回其最大和。
示例:
輸入: [-2,1,-3,4,-1,2,1,-5,4]
輸出: 6
解釋: 連續子數組 [4,-1,2,1] 的和最大,爲 6。
進階:
如果你已經實現複雜度爲 O(n) 的解法,嘗試使用更爲精妙的分治法求解。
第一步:定義子問題
動態規劃是將整個數組歸納考慮,假設我們已經知道了以第 i-1
個數結尾的連續子數組的最大和 dp[i-1]
,顯然以第i
個數結尾的連續子數組的最大和的可能取值要麼爲 dp[i-1]+nums[i]
,要麼就是 nums[i]
單獨成一組,也就是 nums[i]
,在這兩個數中我們取最大值
第二步:實現需要反覆執行解決的子子問題部分
dp[n] = Math.max(dp[n−1]+nums[n], nums[n])
第三步:識別並求解出邊界條件
dp[0]=nums[0]
最後一步:把尾碼翻譯成代碼,處理一些邊界情況
因爲我們在計算 dp[i]
的時候,只關心 dp[i-1]
與 nums[i]
,因此不用把整個 dp
數組保存下來,只需設置一個 pre
保存 dp[i-1]
就好了。
代碼實現(優化):
let maxSubArray = function(nums) {
let max = nums[0], pre = 0
for(const num of nums) {
if(pre > 0) {
pre += num
} else {
pre = num
}
max = Math.max(max, pre)
}
return max
}
複雜度分析:
- 時間複雜度:O(n)
- 空間複雜度:O(1)
更多解答
7.4 買賣股票的最佳時機
給定一個數組,它的第 i 個元素是一支給定股票第 i 天的價格。
如果你最多隻允許完成一筆交易(即買入和賣出一支股票一次),設計一個算法來計算你所能獲取的最大利潤。
注意:你不能在買入股票前賣出股票。
示例 1:
輸入: [7,1,5,3,6,4]
輸出: 5
解釋: 在第 2 天(股票價格 = 1)的時候買入,在第 5 天(股票價格 = 6)的時候賣出,最大利潤 = 6-1 = 5 。
注意利潤不能是 7-1 = 6, 因爲賣出價格需要大於買入價格;同時,你不能在買入前賣出股票。
示例 2:
輸入: [7,6,4,3,1]
輸出: 0
解釋: 在這種情況下, 沒有交易完成, 所以最大利潤爲 0。
解法:動態規劃
第一步:定義子問題
動態規劃是將整個數組歸納考慮,假設我們已經知道了 i-1
個股票的最大利潤爲 dp[i-1]
,顯然 i
個連續股票的最大利潤爲 dp[i-1]
,要麼就是就是 prices[i] - minprice
( minprice
爲前 i-1
支股票的最小值 ),在這兩個數中我們取最大值
第二步:實現需要反覆執行解決的子子問題部分
dp[i] = Math.max(dp[i−1], prices[i] - minprice)
第三步:識別並求解出邊界條件
dp[0]=0
最後一步:把尾碼翻譯成代碼,處理一些邊界情況
因爲我們在計算 dp[i]
的時候,只關心 dp[i-1]
與 prices[i]
,因此不用把整個 dp
數組保存下來,只需設置一個 max
保存 dp[i-1]
就好了。
代碼實現(優化):
let maxProfit = function(prices) {
let max = 0, minprice = prices[0]
for(let i = 1; i < prices.length; i++) {
minprice = Math.min(prices[i], minprice)
max = Math.max(max, prices[i] - minprice)
}
return max
}
複雜度分析:
- 時間複雜度:O(n)
- 空間複雜度:O(1)
更多解答
7.5 迴文子串
給定一個字符串,你的任務是計算這個字符串中有多少個迴文子串。
具有不同開始位置或結束位置的子串,即使是由相同的字符組成,也會被視作不同的子串。
示例 1:
輸入:"abc"
輸出:3
解釋:三個迴文子串: "a", "b", "c"
示例 2:
輸入:"aaa"
輸出:6
解釋:6個迴文子串: "a", "a", "a", "aa", "aa", "aaa"
提示:
- 輸入的字符串長度不會超過 1000 。
解法一:暴力法
let countSubstrings = function(s) {
let count = 0
for (let i = 0; i < s.length; i++) {
for (let j = i; j < s.length; j++) {
if (isPalindrome(s.substring(i, j + 1))) {
count++
}
}
}
return count
}
let isPalindrome = function(s) {
let i = 0, j = s.length - 1
while (i < j) {
if (s[i] != s[j]) return false
i++
j--
}
return true
}
複雜度分析:
- 時間複雜度:O(n^3^)
- 空間複雜度:O(1)
解法二:動態規劃
一個字符串是迴文串,它的首尾字符相同,且剩餘子串也是一個迴文串。其中,剩餘子串是否爲迴文串,就是規模小一點的子問題,它的結果影響大問題的結果。
我們怎麼去描述子問題呢?
顯然,一個子串由兩端的 i
、j
指針確定,就是描述子問題的變量,子串 s[i...j]
( dp[i][j]
) 是否是迴文串,就是子問題。
我們用二維數組記錄計算過的子問題的結果,從base case出發,像填表一樣遞推出每個子問題的解。
j
a a b a
i a ✅
a ✅
b ✅
a ✅
注意: i<=j
,只需用半張表,豎向掃描
所以:
i === j:dp[i][j]=true
j - i == 1 && s[i] == s[j]:dp[i][j] = true
j - i > 1 && s[i] == s[j] && dp[i + 1][j - 1]:dp[i][j] = true
即:
s[i] == s[j] && (j - i <= 1 || dp[i + 1][j - 1]): dp[i][j]=true
否則爲 false
代碼實現:
let countSubstrings = function(s) {
const len = s.length
let count = 0
const dp = new Array(len)
for (let i = 0; i < len; i++) {
dp[i] = new Array(len).fill(false)
}
for (let j = 0; j < len; j++) {
for (let i = 0; i <= j; i++) {
if (s[i] == s[j] && (j - i <= 1 || dp[i + 1][j - 1])) {
dp[i][j] = true
count++
} else {
dp[i][j] = false
}
}
}
return count
}
代碼實現(優化):
把上圖的表格豎向一列看作一維數組,還是豎向掃描,此時僅僅需要將 dp
定義爲一維數組即可
let countSubstrings = function(s) {
const len = s.length
let count = 0
const dp = new Array(len)
for (let j = 0; j < len; j++) {
for (let i = 0; i <= j; i++) {
if (s[i] === s[j] && (j - i <= 1 || dp[i + 1])) {
dp[i] = true
count++
} else {
dp[i] = false
}
}
}
return count;
}
複雜度分析:
- 時間複雜度:O(n^2^)
- 空間複雜度:O(n)
更多解答
7.6 最長迴文子串
給定一個字符串 s,找到 s 中最長的迴文子串。你可以假設 s 的最大長度爲 1000。
示例 1:
輸入: "babad"
輸出: "bab"
注意: "aba" 也是一個有效答案。
示例 2:
輸入: "cbbd"
輸出: "bb"
解法:動態規劃
第 1 步:定義狀態
dp[i][j]
表示子串 s[i..j]
是否爲迴文子串,這裏子串 s[i..j]
定義爲左閉右閉區間,可以取到 s[i]
和 s[j]
。
第 2 步:思考狀態轉移方程
對於一個子串而言,如果它是迴文串,那麼在它的首尾增加一個相同字符,它仍然是個迴文串
dp[i][j] = (s[i] === s[j]) && dp[i+1][j-1]
第 3 步:初始狀態:
dp[i][i] = true // 單個字符是迴文串
if(s[i] === s[i+1]) dp[i][i+1] = true // 連續兩個相同字符是迴文串
代碼實現:
const longestPalindrome = (s) => {
if (s.length < 2) return s
// res: 最長迴文子串
let res = s[0], dp = []
for (let i = 0; i < s.length; i++) {
dp[i][i] = true
}
for (let j = 1; j < s.length; j++) {
for (let i = 0; i < j; i++) {
if (j - i === 1 && s[i] === s[j]) {
dp[i][j] = true
} else if (s[i] === s[j] && dp[i + 1][j - 1]) {
dp[i][j] = true
}
// 獲取當前最長迴文子串
if (dp[i][j] && j - i + 1 > res.length) {
res = s.substring(i, j + 1)
}
}
}
return res
}
複雜度分析:
- 時間複雜度:O(n^2^)
- 空間複雜度:O(n^2^)
更多解答
7.7 最小路徑和
給定一個包含非負整數的 m x n
網格 grid
,請找出一條從左上角到右下角的路徑,使得路徑上的數字總和爲最小。
說明:每次只能向下或者向右移動一步。
示例 1:
輸入:grid = [[1,3,1],[1,5,1],[4,2,1]]
輸出:7
解釋:因爲路徑 1→3→1→1→1 的總和最小。
示例 2:
輸入:grid = [[1,2,3],[4,5,6]]
輸出:12
提示:
m == grid.length
n == grid[i].length
1 <= m, n <= 200
0 <= grid[i][j] <= 100
1、DP方程 當前項最小路徑和 = 當前項值 + 上項或左項中的最小值 grid[i][j] += Math.min( grid[i - 1][j], grid[i][j - 1] )
2、邊界處理 grid的第一行與第一列 分別沒有上項與左項 故單獨處理計算起項最小路徑和 計算第一行:
for(let j = 1; j < col; j++) grid[0][j] += grid[0][j - 1]
計算第一列:
for(let i = 1; i < row; i++) grid[i][0] += grid[i - 1][0]
3、代碼實現
var minPathSum = function(grid) {
let row = grid.length, col = grid[0].length
// calc boundary
for(let i = 1; i < row; i++)
// calc first col
grid[i][0] += grid[i - 1][0]
for(let j = 1; j < col; j++)
// calc first row
grid[0][j] += grid[0][j - 1]
for(let i = 1; i < row; i++)
for(let j = 1; j < col; j++)
grid[i][j] += Math.min(grid[i - 1][j], grid[i][j - 1])
return grid[row - 1][col - 1]
};
更多解答
7.8 買賣股票的最佳時機 II
給定一個數組,它的第 i 個元素是一支給定股票第 i 天的價格。
設計一個算法來計算你所能獲取的最大利潤。你可以儘可能地完成更多的交易(多次買賣一支股票)。
注意: 你不能同時參與多筆交易(你必須在再次購買前出售掉之前的股票)。
示例 1:
輸入: [7,1,5,3,6,4]
輸出: 7
解釋: 在第 2 天(股票價格 = 1)的時候買入,在第 3 天(股票價格 = 5)的時候賣出, 這筆交易所能獲得利潤 = 5-1 = 4 。
隨後,在第 4 天(股票價格 = 3)的時候買入,在第 5 天(股票價格 = 6)的時候賣出, 這筆交易所能獲得利潤 = 6-3 = 3 。
示例 2:
輸入: [1,2,3,4,5]
輸出: 4
解釋: 在第 1 天(股票價格 = 1)的時候買入,在第 5 天 (股票價格 = 5)的時候賣出, 這筆交易所能獲得利潤 = 5-1 = 4 。
注意你不能在第 1 天和第 2 天接連購買股票,之後再將它們賣出。
因爲這樣屬於同時參與了多筆交易,你必須在再次購買前出售掉之前的股票。
示例 3:
輸入: [7,6,4,3,1]
輸出: 0
解釋: 在這種情況下, 沒有交易完成, 所以最大利潤爲 0。
提示:
1 <= prices.length <= 3 * 10 ^ 4
0 <= prices[i] <= 10 ^ 4
解法一:峯底買入,峯頂賣出
如圖,在第二天買入,第三天賣出,第四天買入,第五天賣出獲利最高,此處代碼不再贅述,可以自己嘗試寫一下
解法二:貪心算法
貪心算法,故名思義,總是做出當前的最優選擇,即期望通過局部的最優選擇獲得整體的最優選擇。
某種意義上說,貪心算法是很貪婪、很目光短淺的,它不從整體考慮,僅僅只關注當前的最大利益,所以說它做出的選擇僅僅是某種意義上的局部最優,但是貪心算法在很多問題上還是能夠拿到最優解或較優解,所以它的存在還是有意義的。
對應於該題,第一天買入,第二天賣出,…,第 i
天買入,第 i+1
天賣出,如果 i
天買入第 i+1
天賣出有利潤則買入,否則不買
第 i-1
天買入第 i
天賣出獲利 prices[i+1]-prices[i]
,我們僅僅需要將 prices[i+1]-prices[i]
的所有正值加起來就是可獲取的最大利益
代碼實現:
let maxProfit = function(prices) {
let profit = 0
for (let i = 0; i < prices.length - 1; i++) {
if (prices[i + 1] > prices[i]) {
profit += prices[i + 1] - prices[i]
}
}
return profit
}
複雜度分析:
- 時間複雜度:O(n)
- 空間複雜度:O(1)
更多解答
7.9 分發餅乾
假設你是一位很棒的家長,想要給你的孩子們一些小餅乾。但是,每個孩子最多隻能給一塊餅乾。對每個孩子 i ,都有一個胃口值 g~i~ ,這是能讓孩子們滿足胃口的餅乾的最小尺寸;並且每塊餅乾 j ,都有一個尺寸 s~j~。如果 s~j~ >= g~i~ ,我們可以將這個餅乾 j 分配給孩子 i ,這個孩子會得到滿足。你的目標是儘可能滿足越多數量的孩子,並輸出這個最大數值。
注意:
你可以假設胃口值爲正。一個小朋友最多隻能擁有一塊餅乾。
示例 1:
輸入: [1,2,3], [1,1]
輸出: 1
解釋:
你有三個孩子和兩塊小餅乾,3個孩子的胃口值分別是:1,2,3。
雖然你有兩塊小餅乾,由於他們的尺寸都是1,你只能讓胃口值是1的孩子滿足。
所以你應該輸出1。
示例 2:
輸入: [1,2], [1,2,3]
輸出: 2
解釋:
你有兩個孩子和三塊小餅乾,2個孩子的胃口值分別是1,2。
你擁有的餅乾數量和尺寸都足以讓所有孩子滿足。
所以你應該輸出2.
解法:貪心算法
const findContentChildren = (g, s) => {
if (!g.length || !s.length) return 0
g.sort((a, b) => a - b)
s.sort((a, b) => a - b)
let gi = 0, si = 0
while (gi < g.length && si < s.length) {
if (g[gi] <= s[si++]) gi++
}
return gi
}
更多解答
7.10 分割數組爲連續子序列
給你一個按升序排序的整數數組 num
(可能包含重複數字),請你將它們分割成一個或多個子序列,其中每個子序列都由連續整數組成且長度至少爲 3 。
如果可以完成上述分割,則返回 true
;否則,返回 false
。
示例 1:
輸入: [1,2,3,3,4,5]
輸出: True
解釋:
你可以分割出這樣兩個連續子序列 :
1, 2, 3
3, 4, 5
示例 2:
輸入: [1,2,3,3,4,4,5,5]
輸出: True
解釋:
你可以分割出這樣兩個連續子序列 :
1, 2, 3, 4, 5
3, 4, 5
示例 3:
輸入: [1,2,3,4,4,5]
輸出: False
提示:
- 輸入的數組長度範圍爲 [1, 10000]
解法:貪心算法
從頭開始,我們每次僅僅尋找滿足條件的序列(連續子序列長度爲3),剔除之後,依次往後遍歷:
- 判斷當前元素是否能夠拼接到前一個滿足條件的連續子序列上,可以的話,則拼接
- 如果不可以,則判斷以當前元素開始能否構成連續子序列(長度爲3),可以的話,則剔除連續子序列
- 否則,返回 false
const isPossible = function(nums) {
let max = nums[nums.length - 1]
// arr:存儲原數組中數字每個數字出現的次數
// tail:存儲以數字num結尾的且符合題意的連續子序列個數
let arr = new Array(max + 2).fill(0),
tail = new Array(max + 2).fill(0)
for(let num of nums) {
arr[num] ++
}
for(let num of nums) {
if(arr[num] === 0) continue
else if(tail[num-1] > 0){
tail[num-1]--
tail[num]++
}else if(arr[num+1] > 0 && arr[num+2] > 0){
arr[num+1]--
arr[num+2]--
tail[num+2]++
} else {
return false
}
arr[num]--
}
return true
}
複雜度分析:
- 時間複雜度:O(n)
- 空間複雜度:O(n)
更多解答
7.11 全排列問題
給定一個 沒有重複 數字的序列,返回其所有可能的全排列。
示例:
輸入: [1,2,3]
輸出:
[
[1,2,3],
[1,3,2],
[2,1,3],
[2,3,1],
[3,1,2],
[3,2,1]
]
解法:回溯算法
本題是回溯算法的經典應用場景
1. 算法策略
回溯算法是一種搜索法,試探法,它會在每一步做出選擇,一旦發現這個選擇無法得到期望結果,就回溯回去,重新做出選擇。深度優先搜索利用的就是回溯算法思想。
2. 適用場景
回溯算法很簡單,它就是不斷的嘗試,直到拿到解。它的這種算法思想,使它通常用於解決廣度的搜索問題,即從一組可能的解中,選擇一個滿足要求的解。
3. 代碼實現
我們可以寫一下,數組 [1, 2, 3] 的全排列有:
- 先寫以 1 開頭的全排列,它們是:[1, 2, 3], [1, 3, 2],即 1 + [2, 3] 的全排列;
- 再寫以 2 開頭的全排列,它們是:[2, 1, 3], [2, 3, 1],即 2 + [1, 3] 的全排列;
- 最後寫以 3 開頭的全排列,它們是:[3, 1, 2], [3, 2, 1],即 3 + [1, 2] 的全排列。
即回溯的處理思想,有點類似枚舉搜索。我們枚舉所有的解,找到滿足期望的解。爲了有規律地枚舉所有可能的解,避免遺漏和重複,我們把問題求解的過程分爲多個階段。每個階段,我們都會面對一個岔路口,我們先隨意選一條路走,當發現這條路走不通的時候(不符合期望的解),就回退到上一個岔路口,另選一種走法繼續走。
這顯然是一個 遞歸 結構;
- 遞歸的終止條件是:一個排列中的數字已經選夠了 ,因此我們需要一個變量來表示當前程序遞歸到第幾層,我們把這個變量叫做
depth
,或者命名爲index
,表示當前要確定的是某個全排列中下標爲index
的那個數是多少; - used(object):用於把表示一個數是否被選中,如果這個數字(num)被選擇這設置爲
used[num] = true
,這樣在考慮下一個位置的時候,就能夠以 O(1)的時間複雜度判斷這個數是否被選擇過,這是一種「以空間換時間」的思想。
let permute = function(nums) {
// 使用一個數組保存所有可能的全排列
let res = []
if (nums.length === 0) {
return res
}
let used = {}, path = []
dfs(nums, nums.length, 0, path, used, res)
return res
}
let dfs = function(nums, len, depth, path, used, res) {
// 所有數都填完了
if (depth === len) {
res.push([...path])
return
}
for (let i = 0; i < len; i++) {
if (!used[i]) {
// 動態維護數組
path.push(nums[i])
used[i] = true
// 繼續遞歸填下一個數
dfs(nums, len, depth + 1, path, used, res)
// 撤銷操作
used[i] = false
path.pop()
}
}
}
4. 複雜度分析
- 時間複雜度:O(n∗n!),其中 n 爲序列的長度
這是一個排列組合,每層的排列組合數爲:A^m^ ~n~=n!/(n−m)! ,故而所有的排列有 :
A^1^ ~n~ + A^2^ ~n~ + … + A^n-1^ ~n~ = n!/(n−1)! + n!/(n−2)! + … + n! = n! * (1/(n−1)! + 1/(n−2)! + … + 1) <= n! * (1 + 1/2 + 1/4 + … + 1/2^n-1^) < 2 * n!
並且每個內部結點循環 n 次,故非葉子結點的時間複雜度爲 O(n∗n!) - 空間複雜度:O(n)
更多解答
7.12 括號生成
數字 n
代表生成括號的對數,請你設計一個函數,用於能夠生成所有可能的並且 有效的 括號組合。
示例:
輸入:n = 3
輸出:[
"((()))",
"(()())",
"(())()",
"()(())",
"()()()"
]
解答:回溯算法(深度優先遍歷)
算法策略: 回溯算法是一種搜索法,試探法,它會在每一步做出選擇,一旦發現這個選擇無法得到期望結果,就回溯回去,重新做出選擇。深度優先搜索利用的就是回溯算法思想。
對應於本題,我們可以每次試探增加 (
或 )
,注意:
- 加入
(
的條件是,當前是否還有(
可以選擇 - 加入
)
的時候,受到(
的限制,如果已選擇的結果裏的(
小於等於已選擇裏的)
時,此時是不能選擇)
的,例如如果當前是()
,繼續選擇)
就是())
,是不合法的
代碼實現:
const generateParenthesis = (n) => {
const res = []
const dfs = (path, left, right) => {
// 肯定不合法,提前結束
if (left > n || left < right) return
// 到達結束條件
if (left + right === 2 * n) {
res.push(path)
return
}
// 選擇
dfs(path + '(', left + 1, right)
dfs(path + ')', left, right + 1)
}
dfs('', 0, 0)
return res
}
複雜度分析(來源leetcode官方題解):