全栈开发知识库

45.快乐数

题目

编写一个算法来判断一个数 n 是不是快乐数。

「快乐数」 定义为:

  • 对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。
  • 然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。
  • 如果这个过程 结果为 1,那么这个数就是快乐数。
  • 如果 n 是 快乐数 就返回 true ;不是,则返回 false 。

示例 1:

  • 输入:n = 19
  • 输出:true

  • 解释:

    • 1² + 9² = 82
    • 8² + 2² = 68
    • 6² + 8² = 100
    • 1² + 0² + 0² = 1

示例 2:

  • 输入:n = 2
  • 输出:false

提示:

  • 1 <= n <= 231 - 1

思路

  • 快乐数的定义是基于一个计算过程,即对一个正整数,不断将其替换为它各个位上数字的平方和,如果最终这个过程能够收敛到1,则这个数被称为快乐数。

  • 相反,如果在这个过程中形成了一个不包含1的循环,则该数不是快乐数。

  • 对于非快乐数,它们的平方和序列会进入一个固定的循环,例如4 → 16 → 37 → 58 → 89 → 145 → 42 → 20 → 4。

image

根据我们的探索,我们猜测会有以下三种可能:

  • 最终会得到 1。
  • 最终会进入循环。
  • 值会越来越大,最后接近无穷大(不会发生,因为假设9999999999999,那他每个位置的数值平方相加后的值是1053,再往后循环只会越来越小)。

第三个情况比较难以检测和处理。我们怎么知道它会继续变大,而不是最终得到 1 呢?我们可以仔细想一想,每一位数的最大数字的下一位数是多少。

位数 最大值 Next
1 9 81
2 99 162
3 999 243
4 9999 324
13 9999999999999 1053
  • 对于 3 位数的数字,它不可能大于 243。这意味着它要么被困在 243 以下的循环内,要么跌到 1。

  • 4 位或 4 位以上的数字在每一步都会丢失一位,直到降到 3 位为止。

  • 所以我们知道,最坏的情况下,算法可能会在 243 以下的所有数字上循环,然后回到它已经到过的一个循环或者回到 1。但它不会无限期地进行下去,所以我们排除第三种选择。

方法一:哈希表检测

所以这道题的解法主要是两部分:

  • 按照题目的要求做数位分离,求平方和。

  • 使用哈希集合完成。每次生成链中的下一个数字时,我们都会检查它是否已经在哈希集合中。

    • 如果它不在哈希集合中,我们应该添加它。
    • 如果它在哈希集合中,这意味着我们处于一个循环中,因此应该返回 false。
class Solution {

    fun isHappy(n: Int): Boolean {
        val hasSet = HashSet<Int>()

        var next = n
        while (next != 1 && !hasSet.contains(next)) {
            hasSet.add(next)
            next = getNext(next)

        }
        return next == 1
    }

    fun getNext(n: Int): Int {
        var result = 0

        var num = n
        while (num > 0) {
            var a = num % 10
            num = num / 10
            result += a * a
        }
        return result
    }
}
class Solution {
    private int getNext(int n) {
        int totalSum = 0;
        while (n > 0) {
            int d = n % 10;
            n = n / 10;
            totalSum += d * d;
        }
        return totalSum;
    }

    public boolean isHappy(int n) {
        Set<Integer> seen = new HashSet<>();
        while (n != 1 && !seen.contains(n)) {
            seen.add(n);
            n = getNext(n);
        }
        return n == 1;
    }
}
  • 时间复杂度: O(Logn)
  • 空间复杂度: O(Logn)
方法二:快慢指针法

通过反复调用 getNext(n) 得到的链是一个隐式的链表。隐式意味着我们没有实际的链表节点和指针,但数据仍然形成链表结构。起始数字是链表的头 “节点”,链中的所有其他数字都是节点。next 指针是通过调用 getNext(n) 函数获得。

意识到我们实际有个链表,那么这个问题就可以转换为检测一个链表是否有环。因此我们在这里可以使用弗洛伊德循环查找算法。这个算法是两个奔跑选手,一个跑的快,一个跑得慢。在龟兔赛跑的寓言中,跑的慢的称为 “乌龟”,跑得快的称为 “兔子”。

不管乌龟和兔子在循环中从哪里开始,它们最终都会相遇。这是因为兔子每走一步就向乌龟靠近一个节点(在它们的移动方向上)。

所以整体的思路就是快指针每次走两步,慢指针每次走一步。

class Solution {
    fun isHappy(n: Int): Boolean {
        var slow = n
        var fast = next(n)
        while (fast != 1 && slow != fast) {
            slow = next(slow)
            fast = next(next(fast))
        }
        return fast == 1
    }

    private fun next(n: Int): Int {
        var sum = 0
        var x = n
        while (x > 0) {
            val d = x % 10
            sum += d * d
            x /= 10
        }
        return sum
    }
}
class Solution {

     public int getNext(int n) {
        int totalSum = 0;
        while (n > 0) {
            int d = n % 10;
            n = n / 10;
            totalSum += d * d;
        }
        return totalSum;
    }

    public boolean isHappy(int n) {
        int slowRunner = n;
        int fastRunner = getNext(n);
        while (fastRunner != 1 && slowRunner != fastRunner) {
            slowRunner = getNext(slowRunner);
            fastRunner = getNext(getNext(fastRunner));
        }
        return fastRunner == 1;
    }
}

复杂度分析:

  • 时间复杂度:O(logn)。该分析建立在对前一种方法的分析的基础上,但是这次我们需要跟踪两个指针而不是一个指针来分析,以及在它们相遇前需要绕着这个循环走多少次。 如果没有循环,那么快跑者将先到达 1,慢跑者将到达链表中的一半。我们知道最坏的情况下,成本是 O(2⋅logn)=O(logn)。
    一旦两个指针都在循环中,在每个循环中,快跑者将离慢跑者更近一步。一旦快跑者落后慢跑者一步,他们就会在下一步相遇。假设循环中有 k 个数字。如果他们的起点是相隔 k−1 的位置(这是他们可以开始的最远的距离),那么快跑者需要 k−1 步才能到达慢跑者,这对于我们的目的来说也是不变的。因此,主操作仍然在计算起始 n 的下一个值,即 O(logn)。
  • 空间复杂度:O(1),对于这种方法,我们不需要哈希集来检测循环。指针需要常数的额外空间。

  • 下一题


  • 邮箱 :charon.chui@gmail.com
  • Good Luck!