Skip to content

手撕sqrt() (可以不看,拓竹常考

作者:青见春山
发表于:2026-07-21
字数统计:2400 字
预计阅读9分钟

1. 二分法的基本思路

二分法(Binary Search)用于在一个已知范围内,逐渐缩小范围,找到一个满足某种条件的值。在求平方根的过程中,二分法的目标是找出一个数 mid,使得 mid * mid 尽可能接近 x,即 midx 的平方根。

2. 初始化搜索范围

我们知道平方根的值一定在 [0, x] 或者 [0, 1] 之间。比如:

  • 对于 x = 9,平方根在 [0, 9] 范围内。

  • 对于 x = 0.25,平方根在 [0, 1] 范围内。

根据 x 的大小,我们初始化了左右区间:

  • low = 0

  • high = x (如果 x >= 1),如果 x < 1high 设置为 1,这是为了避免误差问题。

3. 循环条件 **high - low > epsilon**

我们希望通过不断缩小 lowhigh 的差值来逼近平方根的值。精度 epsilon 设定了我们希望得到的精度范围。

  • epsilon:它定义了二分法停止的精度。比如 1e-7 就表示当我们找到的 lowhigh 的差值小于 0.0000001 时,我们就认为结果已经足够精确。

  • 为什么是 high - low > epsilon

    • 精度控制:当 highlow 的差值小于设定的精度时,说明我们已经找到了一个非常接近 x 的平方根,进一步的搜索就没有意义了。

    • 缩小范围:每次迭代时,我们都会将 lowhigh 的范围缩小一半,因此循环会逐步减少误差,直到达到预定的精度。

4. 更新搜索范围

每次计算中间值 mid,然后检查 mid * midx 的关系:

  • 如果 mid * mid > x,说明平方根在 mid 左侧,因此将 high = mid

  • 如果 mid * mid < x,说明平方根在 mid 右侧,因此将 low = mid

通过这种方式,我们不断缩小 lowhigh 的区间,逐渐逼近真实的平方根。

5. 为什么最后返回 **low** 和 **high** 的平均值?

由于每次迭代都在缩小 lowhigh 的范围,当它们的差值小于 epsilon 时,说明 lowhigh 已经非常接近真实的平方根。在这种情况下,lowhigh 都是平方根的近似值,但它们并不完全相等。

为了进一步提高精度,我们取 lowhigh 的平均值作为最终的结果。这样可以确保返回的是两者之间的最佳近似值。简单来说,当 lowhigh 越来越接近时,它们的平均值就是我们要找的平方根。

总结:

  • epsilon 是精度控制参数,确定了循环何时停止。

  • high - low > epsilon 是判断二分法是否还需要继续缩小搜索范围,直到 lowhigh 足够接近,满足所需精度。

  • 返回 **(low + high) / 2** 是因为经过多次迭代后,lowhigh 已经非常接近平方根,我们返回它们的平均值,作为平方根的近似值。

这种方法的核心就是通过不断缩小搜索区间,逼近真实的平方根,直到满足精度要求。

JavaScript
function sqrt(x) {
  if (x < 0) {
   return NaN
  }

  if (x === 0 || x === 1) {
    return x; // 0和1的平方根直接返回
  }

  let low = 0;
  let high = x;
  let epsilon = 1e-7; // 精度控制

  // 如果x小于1,右边界high设为1
  if (x < 1) {
    high = 1;
  }

  while (high - low > epsilon) {
    let mid = (low + high) / 2;
    let midSquared = mid * mid;

    if (midSquared > x) {
      high = mid; // 说明平方根小于mid
    } else {
      low = mid; // 说明平方根大于mid
    }
  }

  return (low + high) / 2; // 返回low和high的平均值,作为平方根
}

console.log(sqrt(9));    // 输出: 3
console.log(sqrt(2));    // 输出: 1.4142135623746899
console.log(sqrt(0.25)); // 输出: 0.5
console.log(sqrt(0));    // 输出: 0

以上是小数模式

以下是整数模式

核心原理:循环不变量

在整个二分查找过程中,始终维护以下不变量

  • left 的平方 可能 小于等于 x,也可能大于 x

  • right 的平方 始终 小于等于 x(初始时 right = x,但 可能大于 x,所以初始时这个不变量需要验证)

更准确地说,循环结束时:

  • left第一个满足 left² > x 的整数

  • right = left - 1,且 right² ≤ x

为什么一定能保证?

关键在于二分查找的结束条件移动规则

JavaScript
while (left <= right) {
  let mid = left + ((right - left) >> 1)
  if (mid * mid < x) {
    left = mid + 1      // 当 mid² < x,left 向右移动
  } else {
    right = mid - 1     // 当 mid² >= x,right 向左移动
  }
}

两种情况分析

情况1:mid² < x

  • 此时 mid 的平方小于 x,说明 mid 太小了

  • 真正的平方根在 mid 右侧

  • 所以 left = mid + 1排除掉 **mid** 及左侧所有元素

情况2:mid² >= x

  • 此时 mid 的平方大于等于 x,说明 mid 太大了或正好相等

  • 真正的平方根在 mid 左侧(包括 mid 本身如果正好相等)

  • 所以 right = mid - 1排除掉 **mid** 及右侧所有元素

关键洞察

当循环结束时(left > right),我们可以推导出:

  1. right** 是最后一个 **right² ≤ x** 的数**

    • 因为每次移动 right 时,都是因为 mid² ≥ x,我们将 right 移到了 mid - 1

    • 而被排除的 mid 本身就满足 mid² ≥ x

    • 所以所有被排除的数(≥ mid)平方都 ≥ x

    • 剩下的 right 自然就是最后一个平方 ≤ x 的数

  2. left = right + 1

    • 循环结束时 left > right,且每次只移动一步

    • 所以 left = right + 1

  3. left² > x** 必然成立**

    • 因为 left = right + 1

    • right 是最后一个平方 ≤ x 的数

    • 所以 right + 1(即 left)就是第一个平方 > x 的数

实例追踪

x = 8 的完整过程

步骤leftrightmidmid²比较操作
10841616≥8right=3
203111<8left=2
323244<8left=3
433399≥8right=2
结束32---left>right
  • right = 22² = 4 ≤ 8

  • left = 33² = 9 > 8

  • left = right + 1

x = 9 的完整过程

步骤leftrightmidmid²比较操作
10941616≥9right=3
203111<9left=2
323244<9left=3
433399≥9right=2
结束32---left>right
  • right = 22² = 4 ≤ 9

  • left = 33² = 9 = x,这里 left² = x 而不是 > x

  • 等等!这里出现了特殊情况

重要修正

x 是完全平方数时(如 9),循环结束时:

  • left = 3left² = 9 = x不是大于 x

  • right = 2right² = 4 < x

所以准确的说法应该是:

循环结束时,**left** 是第一个满足 **left² ≥ x** 的整数,**right = left - 1** 满足 **right² < x**

验证

  • x = 8(非完全平方):left=33²=9 ≥ 8 ✅,right=22²=4 < 8

  • x = 9(完全平方):left=33²=9 ≥ 9 ✅,right=22²=4 < 9

最终返回逻辑解释

JavaScript
return left * left > x ? left - 1 : left
  • 如果 left² > x:说明 x 不是完全平方数,平方根是 left - 1

  • 如果 left² <= x:说明 x 是完全平方数,平方根就是 left

这个判断实际上等价于:

JavaScript
return left * left > x ? left - 1 : left
// 等价于
return right  // 因为 right = left - 1

因为当 left² > x 时,答案就是 left - 1 = right

left² = x 时,答案就是 left(此时 right = left - 1 是错的)

所以更简洁的写法应该是:

JavaScript
return right  // 但这样当 x 是完全平方数时会返回 left-1,错误!
// 所以必须用原写法

核心就是二分查找保证结束时 left 是第一个平方 ≥ x 的数。

JavaScript
/**
 * @param {number} x
 * @return {number}
 */
var mySqrt = function(x) {
    let low = 0
    let high = x
    if(x == 0 || x == 1){
        return x
    }
    while(low <= high){
        let mid = Math.floor((high + low)/2)
        let midSquared = mid*mid
        if(midSquared < x){
            //平方根小于mid
            low = mid + 1
        }else{
            high = mid - 1
        }
    }
    return low * low > x ? low -1 : low
};

手撕 call stack最大化(忘记哪家的了

“手撕”实现 Call Stack 最大化,实际上是在考察你对 JavaScript 执行上下文、调用栈异步操作 的理解。一般来说,我们可以通过不断地递归调用来将调用栈推到最大。

原理

JavaScript 的调用栈(Call Stack)用于管理函数调用,栈的大小受到浏览器的限制。如果你不断嵌套函数调用,栈会逐渐增大,直到超出浏览器的最大栈深度,导致“栈溢出”(stack overflow)。

示例代码

JavaScript
function recursive() {
  // 自己调用自己,不断递归
  recursive();
}

try {
  recursive();
} catch (e) {
  console.log("Stack overflow occurred!");
}

解释:

  1. 递归调用:每次调用 recursive() 都会让函数压入调用栈,栈的深度增加。

  2. 栈溢出:当递归调用的层数超过浏览器的栈深度限制时,会抛出栈溢出错误 Stack overflow occurred!

  3. 异步避免溢出:如果你用异步操作(比如 setTimeoutPromise)则调用栈会在执行时被清空,能够避免栈溢出,但这样就不符合最大化栈的要求。

实现一个能测量当前环境最大调用栈深度的函数。

JavaScript
let count = 0;
function measureStack() {
    try {
        count++;
        return measureStack();
    } catch (e) {
        return count;
    }
}
console.log(measureStack()); // 输出当前环境的最大调用深度