【Leetcode 做題學演算法週刊】第四期
首發於微信公眾號《前端成長記》,寫於 2019.11.21
背景
本文記錄刷題過程中的整個思考過程,以供參考。主要內容涵蓋:
- 題目分析設想
- 編寫程式碼驗證
- 查閱他人解法
- 思考總結
目錄
- 67.二進位制求和
- 69.x的平方根
- 70.爬樓梯
- 83.刪除排序連結串列中的重複元素
- 88.合併兩個有序陣列
Easy
67.二進位制求和
題目地址
題目描述
給定兩個二進位制字串,返回他們的和(用二進位制表示)。
輸入為非空字串且只包含數字 1
和 0
。
示例:
輸入: a = "11", b = "1"
輸出: "100"
輸入: a = "1010", b = "1011"
輸出: "10101"
題目分析設想
這道題又是一道加法題,所以記住下,直接轉數字進行加法可能會溢位,所以不可取。所以我們需要遍歷每一位來做解答。我這有兩個大方向:補0後遍歷,和不補0遍歷。但是基本的依據都是本位相加,逢2進1即可,類似手寫10進位制加法。
- 補0後遍歷,可以採用先算出的位數推入陣列最後反轉,也可以採用先算出的位數填到對應位置後直接輸出
- 不補0遍歷,根據短陣列的長度進行遍歷,長陣列剩下的數字與短陣列生成的進位進行計算
查閱他人解法
Ⅰ.補0後遍歷,先算先推
程式碼:
/** * @param {string} a * @param {string} b * @return {string} */ var addBinary = function(a, b) { let times = Math.max(a.length, b.length) // 需要遍歷次數 // 補 0 while(a.length < times) { a = '0' + a } while(b.length < times) { b = '0' + b } let res = [] let carry = 0 // 是否進位 for(let i = times - 1; i >= 0; i--) { const num = carry + (a.charAt(i) | 0) + (b.charAt(i) | 0) carry = num >= 2 ? 1 : 0 res.push(num % 2) } if (carry === 1) { res.push(1) } return res.reverse().join('') };
結果:
- 294/294 cases passed (68 ms)
- Your runtime beats 95.13 % of javascript submissions
- Your memory usage beats 72.58 % of javascript submissions (35.4 MB)
- 時間複雜度
O(n)
Ⅱ.補0後遍歷,按位運算
程式碼:
/** * @param {string} a * @param {string} b * @return {string} */ var addBinary = function(a, b) { let times = Math.max(a.length, b.length) // 需要遍歷次數 // 補 0 while(a.length < times) { a = '0' + a } while(b.length < times) { b = '0' + b } let res = [] let carry = 0 // 是否進位 for(let i = times - 1; i >= 0; i--) { res[i] = carry + (a.charAt(i) | 0) + (b.charAt(i) | 0) carry = res[i] >= 2 ? 1 : 0 res[i] %= 2 } if (carry === 1) { res.unshift(1) } return res.join('') };
結果:
- 294/294 cases passed (60 ms)
- Your runtime beats 99.65 % of javascript submissions
- Your memory usage beats 65.82 % of javascript submissions (35.5 MB)
- 時間複雜度
O(n)
Ⅲ.不補0遍歷
當然處理方式還是可以選擇上面兩種,我這就採用先算先推來處理了。
程式碼:
/**
* @param {string} a
* @param {string} b
* @return {string}
*/
var addBinary = function(a, b) {
let max = Math.max(a.length, b.length) // 最大長度
let min = Math.min(a.length, b.length) // 最大公共長度
// 將長字串拆成兩部分
let left = a.length > b.length ? a.substr(0, a.length - b.length) : b.substr(0, b.length - a.length)
let right = a.length > b.length ? a.substr(a.length - b.length) : b.substr(b.length - a.length)
// 公共長度部分遍歷
let rightRes = []
let carry = 0
for(let i = min - 1; i >= 0; i--) {
const num = carry + (right.charAt(i) | 0) + (((a.length > b.length ? b : a)).charAt(i) | 0)
carry = num >= 2 ? 1 : 0
rightRes.push(num % 2)
}
let leftRes = []
for(let j = max - min - 1; j >= 0; j--) {
const num = carry + (left.charAt(j) | 0)
carry = num >= 2 ? 1 : 0
leftRes.push(num % 2)
}
if (carry === 1) {
leftRes.push(1)
}
return leftRes.reverse().join('') + rightRes.reverse().join('')
};
結果:
- 294/294 cases passed (76 ms)
- Your runtime beats 80.74 % of javascript submissions
- Your memory usage beats 24.48 % of javascript submissions (36.2 MB)
- 時間複雜度
O(n)
查閱他人解法
看到一些細節上的區別,我這使用 '1' | 0
來轉數字,有的使用 ''1' - '0''
。另外還有就是初始化結果陣列長度為最大長度加1後,最後判斷首位是否為0需要剔除的,我這使用的是判斷最後是否還要進位補1。
這裡還看到用一個提案中的 BigInt
型別來解決的
Ⅰ.BigInt
程式碼:
/**
* @param {string} a
* @param {string} b
* @return {string}
*/
var addBinary = function(a, b) {
return (BigInt("0b"+a) + BigInt("0b"+b)).toString(2);
};
結果:
- 294/294 cases passed (52 ms)
- Your runtime beats 100 % of javascript submissions
- Your memory usage beats 97.05 % of javascript submissions (34.1 MB)
- 時間複雜度
O(1)
思考總結
通過 BigInt
的方案我們能看到,使用原生方法確實性能更優。簡單說一下這個型別,目前還在提案階段,看下面的等式基本就能知道實現原理自己寫對應 Hack
來實現了:
BigInt(10) = '10n'
BigInt(20) = '20n'
BigInt(10) + BigInt(20) = '30n'
雖然這種方式很友好,但是還是希望看到加法題的時候,能考慮到遍歷按位處理。
69.x的平方根
題目地址
題目描述
實現 int sqrt(int x)
函式。
計算並返回 x 的平方根,其中 x 是非負整數。
由於返回型別是整數,結果只保留整數的部分,小數部分將被捨去。
示例:
輸入: 4
輸出: 2
輸入: 8
輸出: 2
說明: 8 的平方根是 2.82842...,
由於返回型別是整數,小數部分將被捨去。
題目分析設想
同樣,這裡類庫提供的方法 Math.sqrt(x)
就不說了,這也不是本題想考察的意義。所以這裡有幾種方式:
- 暴力法,這裡不用考慮溢位是因為x沒溢位,所以即使加到平方根加1,也會終止迴圈
- 二分法,直接取中位數運算,可以快速排除當前區域一半的區間
編寫程式碼驗證
Ⅰ.暴力法
程式碼:
/**
* @param {number} x
* @return {number}
*/
var mySqrt = function(x) {
if (x === 0) return 0
let i = 1
while(i * i < x) {
i++
}
return i * i === x ? i : i - 1
};
結果:
- 1017/1017 cases passed (120 ms)
- Your runtime beats 23 % of javascript submissions
- Your memory usage beats 34.23 % of javascript submissions (35.7 MB)
- 時間複雜度
O(n)
Ⅱ.二分法
程式碼:
/**
* @param {number} x
* @return {number}
*/
var mySqrt = function(x) {
if (x === 0) return 0
let l = 1
let r = x >>> 1
while(l < r) {
// 這裡要用大於判斷,所以取右中位數
const mid = (l + r + 1) >>> 1
if (mid * mid > x) {
r = mid - 1
} else {
l = mid
}
}
return l
};
結果:
- 1017/1017 cases passed (76 ms)
- Your runtime beats 96.08 % of javascript submissions
- Your memory usage beats 59.17 % of javascript submissions (35.5 MB)
- 時間複雜度
O(log2(n))
查閱他人解法
這裡看見了兩個有意思的解法:
- 2的冪次底層優化
- 牛頓法
Ⅰ.冪次優化
稍微解釋一下,二分法需要做乘法運算,他這裡改用加減法
/**
* @param {number} x
* @return {number}
*/
var mySqrt = function(x) {
let l = 0
let r = 1 << 16 // 2的16次方,這裡我猜是因為上限2^32所以取一半
while (l < r - 1) {
const mid = (l + r) >>> 1
if (mid * mid <= x) {
l = mid
} else {
r = mid
}
}
return l
};
結果:
1017/1017 cases passed (72 ms)
Your runtime beats 98.46 % of javascript submissions
Your memory usage beats 70.66 % of javascript submissions (35.4 MB)
- 時間複雜度
O(log2(n))
Ⅱ.牛頓法
演算法說明:
在迭代過程中,以直線代替曲線,用一階泰勒展式(即在當前點的切線)代替原曲線,求直線與 xx 軸的交點,重複這個過程直到收斂。
首先隨便猜一個近似值 x
,然後不斷令 x
等於 x
和 a/x
的平均數,迭代個六七次後 x
的值就已經相當精確了。
公式可以寫為 X[n+1]=(X[n]+a/X[n])/2
程式碼:
/**
* @param {number} x
* @return {number}
*/
var mySqrt = function(x) {
if (x === 0 || x === 1) return x
let a = x >>> 1
while(true) {
let cur = a
a = (a + x / a) / 2
// 這裡是為了消除浮點運算的誤差,1e-5是我試出來的
if (Math.abs(a - cur) < 1e-5) {
return parseInt(cur)
}
}
};
結果:
- 1017/1017 cases passed (68 ms)
- Your runtime beats 99.23 % of javascript submissions
- Your memory usage beats 9.05 % of javascript submissions (36.1 MB)
- 時間複雜度
O(log2(n))
思考總結
這裡就提一下新接觸的牛頓法吧,實際上是牛頓迭代法,主要是迭代操作。由於在單根附近具有平方收斂,所以可以轉換成線性問題去求平方根的近似值。主要應用場景有這兩個方向:
- 求方程的根
- 求解最優化問題
70.爬樓梯
題目地址
題目描述
假設你正在爬樓梯。需要 n
階你才能到達樓頂。
每次你可以爬 1
或 2
個臺階。你有多少種不同的方法可以爬到樓頂呢?
注意:給定 n
是一個正整數。
示例:
輸入: 2
輸出: 2
解釋: 有兩種方法可以爬到樓頂。
1. 1 階 + 1 階
2. 2 階
輸入: 3
輸出: 3
解釋: 有三種方法可以爬到樓頂。
1. 1 階 + 1 階 + 1 階
2. 1 階 + 2 階
3. 2 階 + 1 階
題目分析設想
這道題很明顯可以用動態規劃和斐波那契數列來求解。然後我們來看看其他正常思路,如果使用暴力法的話,那麼複雜度將會是 2^n
,很容易溢位,但是如果能夠優化成 n
的話,其實還可以求解的。所以這道題我就從以下三個方向來作答:
- 雜湊遞迴,也就是暴力運算的改進版,通過存下算過的值降低複雜度
- 動態規劃
- 斐波那契數列
編寫程式碼驗證
Ⅰ.雜湊遞迴
程式碼:
/**
* @param {number} n
* @return {number}
*/
var climbStairs = function(n) {
let hash = {}
return count(0)
function count (i) {
if (i > n) return 0
if (i === n) return 1
// 這步節省運算
if(hash[i] > 0) {
return hash[i]
}
hash[i] = count(i + 1) + count(i + 2)
return hash[i]
}
};
結果:
- 45/45 cases passed (52 ms)
- Your runtime beats 98.67 % of javascript submissions
- Your memory usage beats 48.29 % of javascript submissions (33.7 MB)
- 時間複雜度
O(n)
Ⅱ.動態規劃
程式碼:
/**
* @param {number} n
* @return {number}
*/
var climbStairs = function(n) {
if (n === 1) return 1
if (n === 2) return 2
// dp[0] 多一位空間,省的後面做減法
let dp = new Array(n + 1).fill(0)
dp[1] = 1
dp[2] = 2
for(let i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2]
}
return dp[n]
};
結果:
- 45/45 cases passed (48 ms)
- Your runtime beats 99.48 % of javascript submissions
- Your memory usage beats 21.49 % of javascript submissions (33.8 MB)
- 時間複雜度
O(n)
Ⅲ.斐波那契數列
其實斐波那契數列就可以用動態規劃來實現,所以下面的程式碼思路很相似。
程式碼:
/**
* @param {number} n
* @return {number}
*/
var climbStairs = function(n) {
if (n === 1) return 1
if (n === 2) return 2
let num1 = 1
let num2 = 2
for(let i = 3; i <= n; i++) {
let count = num1 + num2
num1 = num2
num2 = count
}
// 相當於fib(n)
return num2
};
結果:
- 45/45 cases passed (56 ms)
- Your runtime beats 95.49 % of javascript submissions
- Your memory usage beats 46.1 % of javascript submissions (33.7 MB)
- 時間複雜度
O(n)
查閱他人解法
檢視題解發現這麼幾種解法:
- 斐波那契公式(原來有計算公式可以直接用,尷尬)
- Binets 方法
- 排列組合
Ⅰ.斐波那契公式
程式碼:
/**
* @param {number} n
* @return {number}
*/
var climbStairs = function(n) {
const sqrt_5 = Math.sqrt(5)
// 由於 F0 = 1,所以相當於需要求 n+1 的值
const fib_n = Math.pow((1 + sqrt_5) / 2, n + 1) - Math.pow((1 - sqrt_5) / 2, n + 1)
return Math.round(fib_n / sqrt_5)
};
結果:
- 45/45 cases passed (52 ms)
- Your runtime beats 98.67 % of javascript submissions
- Your memory usage beats 54.98 % of javascript submissions (33.6 MB)
- 時間複雜度
O(log(n))
Ⅱ.Binets 方法
演算法說明:
使用矩陣乘法來得到第 n 個斐波那契數。注意需要將初始項從 fib(2)=2,fib(1)=1
改成 fib(2)=1,fib(1)=0
,來達到矩陣等式的左右相等。
解法參考官方題解
程式碼:
/**
* @param {number} n
* @return {number}
*/
var climbStairs = function(n) {
function pow(a, n) {
let ret = [[1,0],[0,1]] // 矩陣
while(n > 0) {
if ((n & 1) === 1) {
ret = multiply(ret, a)
}
n >> 1
a = multiply(a, a)
}
return ret;
}
function multiply(a, b) {
let c = [[0,0], [0,0]]
for (let i = 0; i < 2; i++) {
for(let j = 0; j < 2; j++) {
c[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j]
}
}
return c
}
let q = [[1,1], [1, 0]]
let res = pow(q, n)
return res[0][0]
};
結果:
測試用例可以輸出,提交發現超時。
這個筆者還沒完全理解,所以很抱歉,暫時沒有 js 相應程式碼分析,後續會補上。也歡迎您補充給我,感謝!
Ⅲ.排列組合
程式碼:
/**
* @param {number} n
* @return {number}
*/
var climbStairs = function(n) {
// n 個臺階走 i 次1階和 j 次2階走到,推匯出 i + 2*j = n
function combine(m, n) {
if (m < n) [m, n] = [n, m];
let count = 1;
for (let i = m + n, j = 1; i > m; i--) {
count *= i;
if (j <= n) count /= j++;
}
return count;
}
let total = 0;
// 取出所有滿足條件的解
for (let i = 0,j = n; j >= 0; j -= 2, i++) {
total += combine(i, j);
}
return total;
};
結果:
- 45/45 cases passed (60 ms)
- Your runtime beats 87.94 % of javascript submissions
- Your memory usage beats 20.72 % of javascript submissions (33.8 MB)
- 時間複雜度
O(n^2)
思考總結
這種疊加的問題,首先就會想到動態規劃的解法,剛好這裡又滿足斐波那契數列,所以我是推薦首選這兩種解法。另外通過檢視他人解法學到了斐波那契公式,以及站在排列組合的角度去解,開拓了思路。
83.刪除排序連結串列中的重複元素
題目地址
題目描述
給定一個排序連結串列,刪除所有重複的元素,使得每個元素只出現一次。
示例:
輸入: 1->1->2
輸出: 1->2
輸入: 1->1->2->3->3
輸出: 1->2->3
題目分析設想
注意一下,給定的是一個排序連結串列,所以只需要依次更改指標就可以直接得出結果。當然,也可以使用雙指標來跳過重複項即可。所以這裡有兩個方向:
- 直接運算,通過改變指標指向
- 雙指標,通過跳過重複項
如果是無序連結串列,我會建議先得到所有值然後去重後(比如通過Set)生成新連結串列作答。
編寫程式碼驗證
Ⅰ.直接運算
程式碼:
/**
* @param {ListNode} head
* @return {ListNode}
*/
var deleteDuplicates = function(head) {
// 複製一個用做操作,由於物件是傳址,所以改指標指向即可
let cur = head
while(cur !== null && cur.next !== null) {
if (cur.val === cur.next.val) { // 值相等
cur.next = cur.next.next
} else {
cur = cur.next
}
}
return head
};
結果:
- 165/165 cases passed (76 ms)
- Your runtime beats 87.47 % of javascript submissions
- Your memory usage beats 81.21 % of javascript submissions (35.5 MB)
- 時間複雜度
O(n)
Ⅱ.雙指標法
程式碼:
/**
* @param {ListNode} head
* @return {ListNode}
*/
var deleteDuplicates = function(head) {
// 新建哨兵指標和當前遍歷指標
if (head === null || head.next === null) return head
let pre = head
let cur = head
while(cur !== null) {
debugger
if (cur.val === pre.val) {
// 當前指標移動
cur = cur.next
} else {
pre.next = cur
pre = cur
}
}
// 最後一項如果重複需要把head.next指向null
pre.next = null
return head
};
結果:
- 165/165 cases passed (80 ms)
- Your runtime beats 77.31 % of javascript submissions
- Your memory usage beats 65.1 % of javascript submissions (35.7 MB)
- 時間複雜度
O(n)
查閱他人解法
忘記了,這裡確實還可以使用遞迴來作答。
Ⅰ.遞迴法
程式碼:
/**
* @param {ListNode} head
* @return {ListNode}
*/
var deleteDuplicates = function(head) {
if(head === null || head.next === null) return head
if (head.val === head.next.val) { // 值相等
return deleteDuplicates(head.next)
} else {
head.next = deleteDuplicates(head.next)
}
return head
};
結果:
- 165/165 cases passed (80 ms)
- Your runtime beats 77.31 % of javascript submissions
- Your memory usage beats 81.21 % of javascript submissions (35.5 MB)
- 時間複雜度
O(n)
思考總結
關於連結串列的題目一般都是通過修改指標指向來作答,區分單指標和雙指標法。另外,遍歷也是可以實現的。
88.合併兩個有序陣列
題目地址
題目描述
給定兩個有序整數陣列 nums1
和 nums2
,將 nums2
合併到 nums1
中,使得 num1
成為一個有序陣列。
說明:
- 初始化
nums1
和nums2
的元素數量分別為m
和n
。 - 你可以假設
nums1
有足夠的空間(空間大小大於或等於m + n
)來儲存nums2
中的元素。
示例:
輸入:
nums1 = [1,2,3,0,0,0], m = 3
nums2 = [2,5,6], n = 3
輸出: [1,2,2,3,5,6]
題目分析設想
之前我們做過刪除排序陣列中的重複項,其實這裡也類似。可以從這幾個方向作答:
- 數組合並後排序
- 遍歷陣列並進行插入
- 雙指標法,輪流比較
但是由於題目有限定空間都在 nums1
,並且不要寫 return
,直接在 nums1
上修改,所以我這裡主要的思路就是遍歷,通過 splice
來修改陣列。區別就在於遍歷的方式方法。
- 從前往後
- 從後往前
- 合併後排序再賦值
編寫程式碼驗證
Ⅰ.從前往後
程式碼:
/**
* @param {number[]} nums1
* @param {number} m
* @param {number[]} nums2
* @param {number} n
* @return {void} Do not return anything, modify nums1 in-place instead.
*/
var merge = function(nums1, m, nums2, n) {
// 兩個陣列對應指標
let p1 = 0
let p2 = 0
// 這裡需要提前把nums1的元素拷貝出來,要不然比較賦值後就丟失了
let cpArr = nums1.splice(0, m)
// 陣列指標
let p = 0
while(p1 < m && p2 < n) {
// 先賦值,再進行+1操作
nums1[p++] = cpArr[p1] < nums2[p2] ? cpArr[p1++] : nums2[p2++]
}
// 已經有p個元素了,多餘的元素要刪除,剩餘的要加上
if (p1 < m) {
// 剩餘元素,p1 + m + n - p = m + n - (p - p1) = m + n - p2
nums1.splice(p, m + n - p, ...cpArr.slice(p1, m + n - p2))
}
if (p2 < n) {
// 剩餘元素,p2 + m + n - p = m + n - (p - p2) = m + n - p1
nums1.splice(p, m + n - p, ...nums2.slice(p2, m + n - p1))
}
};
結果:
- 59/59 cases passed (48 ms)
- Your runtime beats 100 % of javascript submissions
- Your memory usage beats 64.97 % of javascript submissions (33.8 MB)
- 時間複雜度
O(m + n)
Ⅱ.從後往前
程式碼:
/**
* @param {number[]} nums1
* @param {number} m
* @param {number[]} nums2
* @param {number} n
* @return {void} Do not return anything, modify nums1 in-place instead.
*/
var merge = function(nums1, m, nums2, n) {
// 避免 nums1 = [0,0,0,0], nums2 = [1,2] 這種 nums1.length > nums2.length 並且 m = 0
nums1.splice(m, nums1.length - m)
// 兩個陣列對應指標
let p1 = m - 1
let p2 = n - 1
// 陣列指標
let p = m + n - 1
while(p1 >= 0 && p2 >= 0) {
// 先賦值,再進行-1操作
nums1[p--] = nums1[p1] < nums2[p2] ? nums2[p2--] : nums1[p1--]
}
// 可能nums2有剩餘,由於指標是下標,所以擷取數量需要加1
nums1.splice(0, p2 + 1, ...nums2.slice(0, p2 + 1))
};
結果:
- 59/59 cases passed (52 ms)
- Your runtime beats 99.76 % of javascript submissions
- Your memory usage beats 78.3 % of javascript submissions (33.6 MB)
- 時間複雜度
O(m + n)
Ⅲ.合併後排序再賦值
程式碼:
/**
* @param {number[]} nums1
* @param {number} m
* @param {number[]} nums2
* @param {number} n
* @return {void} Do not return anything, modify nums1 in-place instead.
*/
var merge = function(nums1, m, nums2, n) {
arr = [].concat(nums1.splice(0, m), nums2.splice(0, n))
arr.sort((a, b) => a - b)
for(let i = 0; i < arr.length; i++) {
nums1[i] = arr[i]
}
};
結果:
- 59/59 cases passed (64 ms)
- Your runtime beats 90.11 % of javascript submissions
- Your memory usage beats 31.21 % of javascript submissions (34.8 MB)
- 時間複雜度
O(m + n)
查閱他人解法
這裡看到一個直接用兩次 while
,然後直接用 m/n
來計算下標的,沒有額外空間,但是本質上也是從後往前遍歷。
Ⅰ.兩次while
程式碼:
/**
* @param {number[]} nums1
* @param {number} m
* @param {number[]} nums2
* @param {number} n
* @return {void} Do not return anything, modify nums1 in-place instead.
*/
var merge = function(nums1, m, nums2, n) {
// 避免 nums1 = [0,0,0,0], nums2 = [1,2] 這種 nums1.length > nums2.length 並且 m = 0
// nums1.splice(m, nums1.length - m)
// 從後開始賦值
while(m !== 0 && n !== 0) {
nums1[m + n - 1] = nums1[m - 1] > nums2[n - 1] ? nums1[--m] : nums2[--n]
}
// nums2 有剩餘
while(n !== 0) {
nums1[m + n - 1] = nums2[--n]
}
};
結果:
- 59/59 cases passed (56 ms)
- Your runtime beats 99.16 % of javascript submissions
- Your memory usage beats 64.26 % of javascript submissions (33.8 MB)
- 時間複雜度
O(m + n)
思考總結
碰到陣列操作,會優先考慮雙指標法,具體指標方向可以由題目邏輯來決定。
(完)
本文為原創文章,可能會更新知識點及修正錯誤,因此轉載請保留原出處,方便溯源,避免陳舊錯誤知識的誤導,同時有更好的閱讀體驗
如果能給您帶去些許幫助,歡迎 ⭐️star 或 ✏️ fork
(轉載請註明出處:https://chenjiahao.xyz)