實現 sqrt(x):二分查找法和牛頓法

最近忙裏偷閒,天天刷一道 LeetCode 的簡單題保持手感,發現簡單題雖然很容易 AC,但若去了解其全部的解法,也可學習到很多新的知識點,擴展知識的廣度。算法

創做本文的思路來源於:LeetCode Problem 69. x 的平方根安全

簡述題目大意(不想跳轉連接,能夠看這裏):給定一個非負整數 x,要求計算並返回 x 的平方根(取整)。例如,輸入 4,則輸出 2;輸入 8,則輸出 2(8 的平方根是 2.82842……,因爲返回類型是整數,所以小數部分被捨去)。即給定一個 \(x\),咱們要計算出 \(\lfloor \sqrt{x} \rfloor\)函數

最簡單最直覺的方法天然是從 0 開始遍歷,直到找到第一個其平方值大於 \(x\) 的數 \(n\),則 \(n-1\) 便是答案。對於任意的 \(x\),其取整後平方根必定在 \([0, x]\) 區間上,代碼以下:學習

int sqrt(int x)
{
    if (x == 0)
        return x;
    int ans = 1;
    while (ans <= x / ans)
        ans++;
    return ans - 1;
}

這裏須要注意的有兩點:測試

  1. 第 6 行代碼中,while 的判斷條件能夠避免溢出。很大機率上,你可能會寫成 while (ans * ans <= x),這更天然、更直觀,但當 ans 的值很大時,ans * ans 的結果可能會超過 int 類型的最大表示範圍。舉個例子,好比咱們要計算 \(x\) 的取整平方根(其值爲 \(n\),即 \(\lfloor \sqrt{x} \rfloor = n\)),算法會將 ans 遍歷到第一個平方超過 \(x\) 的值,即 \(n+1\) 後中止。若是 \(x\) 的值就是 int 類型可以表示的最大值,那麼當 ans 遍歷到 \(n+1\) 時,計算 ans * ans 的結果就超出了 int 類型的表示範圍。
  2. 因爲在 while 的循環判斷中,咱們用除法代替了乘法,所以 ans 便不能再從 0 開始遍歷(不然會致使除零錯誤)。爲此,咱們能夠在算法開始單獨處理 \(x = 0\) 的狀況,而後讓 ans 從 1 開始遍歷。

做爲一道簡單題,這種暴力樸素的算法天然是能夠 AC 的。但其效率極低(須要遍歷 \(O(\sqrt{n})\) 次),在 LeetCode 上的時間效率只能快過約 5% 的用戶,使用 C++ 語言的運行時間平均要 90ms 以上。所以,本文提供了兩種更加高效的算法:二分查找法和牛頓法。spa

1. 二分查找法

若是你在暴力求解的基礎上繼續思考,很大機率會想到用二分搜索求解。.net

沒錯,思考暴力求解的策略,咱們在區間 \([0, x]\) 上搜索解,而搜索區間 \([0, x]\) 自然是有序的,天然能夠用二分搜索代替線性搜索,以大大提升搜索效率。code

更進一步的,咱們還能夠縮小咱們的搜索區間。直覺告訴咱們,對於一個非負整數 \(x\),其 \(\sqrt{x}\) 應該不會大於 \(x / 2\)(例如,\(\sqrt{25} = 5\),小於 \(25 / 2 = 12.5\))。咱們能夠證實:blog

\[ \begin{aligned} &\text{設 } y = \frac{x}{2} - \sqrt{x},\text{ 則 } y^\prime = \frac{1}{2} - \frac{1}{2\sqrt{x}}, \\[2ex] &\text{令 } y^\prime = 0, \text{ 可得駐點 } x = 1, \\[2ex] &\text{當 } x > 1 \text{ 時}, y^\prime > 0, \text{ 即當 } x > 1 \text{ 時 }, y = \frac{x}{2} - \sqrt{x} \text{ 的值單調遞增}, \\[2ex] &\text{可推出, 當 } x > 1 \text{ 時}, \lfloor \frac{x}{2} \rfloor - \lfloor \sqrt{x} \rfloor \text{ 的值單調遞增}, \\[2ex] &\text{又由於當 } x = 2 \text{ 時}, \lfloor \frac{x}{2} \rfloor - \lfloor \sqrt{x} \rfloor = 0, \\[2ex] &\text{因此當 } x \geq 2 \text{ 時}, \lfloor \frac{x}{2} \rfloor - \lfloor \sqrt{x} \rfloor \geq 0, \text{ 即 } x \geq 2 \text{ 時},\lfloor \frac{x}{2} \rfloor \geq \lfloor \sqrt{x} \rfloor &\text{(證畢)} \end{aligned} \]圖片

經過證實咱們能夠得知,當所求的 \(x\) 大於等於 \(2\) 時,sqrt(x) 的搜索空間爲 \([1, x / 2]\),對於 \(x < 2\) 的狀況,咱們只要特殊處理便可(這裏咱們也能夠獲得結論:當 \(x \geq 1\) 時,\(\lfloor \frac{x}{2} \rfloor + 1 \geq \lfloor \sqrt{x} \rfloor\),而後單獨處理 \(x < 1\) 的狀況)。代碼:

int sqrt(int x)
{
    if (x < 2)  // 處理特殊狀況
        return x;
    
    int left = 1, right = x / 2;
    while (left <= right) {
        # 避免溢出,至關於 mid = (left + right) / 2
        int mid = left + ((right - left) >> 1);
        if (mid == x / mid)
            return mid;
        else if (mid > x / mid)
            right = mid - 1;
        else
            left = mid + 1;
    }
    return right;
}

在這裏解釋一下最後的返回值爲何是 right。對於二分查找,其搜索空間會不斷收縮到 left == right(關於二分查找,這裏很少贅述,自行手動模擬便可),此時 midleftright 三者的值相等(mid = (left + right) / 2)。根據二分查找的搜索範圍的收縮條件可知,left(或 mid)左側的值都小於等於 \(\lfloor \sqrt{x} \rfloor\)right(或 mid)右側的值都大於 \(\lfloor \sqrt{x} \rfloor\),此時(while 的最後一次循環),判斷 mid 的平方與 x 的大小,有三種狀況:

  1. mid == x / mid。則在循環內直接返回 mid 的值。
  2. mid > x / mid。這種狀況下,由於 mid 左側的值都小於等於 \(\lfloor \sqrt{x} \rfloor\),而 mid 的值大於 \(x\),則 mid-1 便是答案。而按照分支條件,執行 right = mid - 1,可知 right 的值正是應返回的值。此時,循環結束,應返回 right
  3. mid <= x / mid。這種狀況下,midleftright 正是計算答案(右邊的值都大於 \(\lfloor \sqrt{x} \rfloor\))。按照分支條件,執行 left = mid + 1,循環結束。此時,midright 的值爲計算結果。

綜合上面三點可知,若是 while 循環結束,則 right 保存的值必定是計算結果。

和以前的暴力法相比,使用二分查找的思想求解 sqrt(x),只須要循環遍歷 \(O(\lg{\frac{x}{2}})\) 次;空間複雜度爲 \(O(1)\)

2. 牛頓—拉弗森迭代法

牛頓—拉弗森迭代法(簡稱牛頓法)使用以直代曲的思想,是一種求解函數的方法,不只僅適用於求解開方計算。固然使用牛頓法求解函數也存在不少坑,但對於求解開方而言,牛頓法是安全的。關於這一方法,你須要掌握必定的高等數學知識,想了解詳細的內容,能夠參考連接:如何通俗易懂地講解牛頓迭代法求開方?數值分析?—馬同窗的回答

簡單的理解,能夠參考圖片:

圖片來源:牛頓法與擬牛頓法

給定任意一個非負整數 \(n\),咱們想要找到一個 \(x = \lfloor \sqrt{n} \rfloor\),這至關於咱們要計算函數 \(f(x) = x^2 - n\) 的根。咱們首先須要先給出一個猜想值 \(x_0\),不妨令 \(x_0 = \frac{x}{2} + 1\)(證實見第一小節),而後在 \(f(x_0)\) 處做函數的切線,切線與 \(x\) 軸的交點,即爲一次迭代後的值 \(x_1\)。若 \(x_1\) 不是要獲得的結果,則繼續迭代,在 \(f(x_1)\) 處做函數的切線,切線與 \(x\) 軸的交點,即爲第二次迭代後的值 \(x_2\)。以此類推,直到獲得 \(x_n = \lfloor \sqrt{n} \rfloor\)

如今咱們來推導迭代式。對於 \(x_i\),其函數值爲 \(f(x_i)\),則對於點 \((x_i, f(x_i))\),可得其切線方程:

\[ \begin{align} &y - f(x_i) = f(x_i)^\prime(x - x_i) \\[2ex] \implies &y - (x_i^2 - n) = 2x_i(x - x_i) \\[2ex] \implies &y + x_i^2 + n = 2x_ix \end{align} \]

又由於 \(x_{i+1}\) 爲切線與 \(x\) 軸的交點,因此令 \(y=0\),可得:

\[ x_{i+1} = (x_i + n / x_i) / 2 \]

如今,咱們就能夠根據迭代式編寫代碼了:

int sqrt(int x)
{
    // 避免除零錯誤,單獨處理 x = 0 的狀況
    if (x == 0)
        return x;
    int t = x / 2 + 1;
    while (t > x / t)
        t = (t + x / t) / 2;
    return t;
}

爲了確保算法是正確的,咱們還須要一些額外的證實。首先,證實迭代式是單調遞減的:

\[ x_{i+1} - x_i = \left\lfloor \frac{1}{2} (x_i + \frac{n}{x_i}) \right\rfloor - x_i = \left\lfloor \frac{1}{2} (\frac{n}{x_i} - x_i) \right\rfloor \]

可知,在區間 \([\sqrt{x}, +\infty)\) 上,\(x_{i+1} - x_i < 0\)

而後,咱們還要證實迭代式是能夠收斂到 \(\lfloor \sqrt{n} \rfloor\) 的:

\[ x_{i+1} = \left\lfloor \frac{1}{2} \left( x_i + \left\lfloor \frac{n}{x_i} \right\rfloor \right) \right\rfloor = \left \lfloor \frac{1}{2} (x_i + \frac{n}{x_i}) \right \rfloor \geq \left \lfloor \frac{1}{2} \times 2 \times \sqrt{x_i \cdot \frac{n}{x_i}} \right \rfloor = \lfloor \sqrt{n} \rfloor \]

所以,當 while 循環結束時,咱們能夠獲得正確的答案。

關於牛頓法求 sqrt(x) 的時間複雜度,筆者目前也沒有搞清楚,有了解的童鞋歡迎交流~。不過經過查詢資料,以及實際測試,可知牛頓法的時間效率優於二分搜索法。

相關文章
相關標籤/搜索