剑指offer刷题详细分析:part8:36题——40题

本文详细解析《剑指Offer》中的经典算法题目,包括寻找链表公共节点、统计排序数组中数字出现次数、计算二叉树深度、判断平衡二叉树及找出数组中唯一出现的数字,提供多种解题思路和代码实现。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

  • 剑指offer所有题目详解,可访问我的github项目:KongJetLin-offer

  • 剑指offer所有题目详解,可访问我的github项目:KongJetLin-offer

  • 目录

  1. Number36:两个链表的第一个公共结点
  2. Number37:数字在排序数组中出现的次数
  3. Number38:二叉树的深度
  4. Number39:平衡二叉树
  5. Number40:数组中只出现一次的数字

题目36 两个链表的第一个公共结点

  题目描述:输入两个链表,找出它们的第一个公共结点。(注意因为传入数据是链表,所以错误测试数据的提示是用其他方式显示的,保证传入数据是正确的)
  分析:因为是单链表,只有一个next指针,则公共结点之后的内容相同。那么这个数据结构的拓扑形状看起来像一个Y,而不可能像X:
在这里插入图片描述
  有几个方法:

方法1:暴力遍历(不推荐)
  在第一链表上顺序遍历每个结点,每遍历到一个结点的时候,在第二个链表上顺序遍历每个结点。如果在第二个链表上有一个结点和第一个链表上的结点一样,说明两个链表在这个结点上重合,于是就找到了它们的公共结点。如果第一个链表的长度为m,第二个链表的长度为n,显然该方法的时间复杂度是O(mn)。
  这种方法不推荐,代码较为简单不赘述;

方法2:利用栈存储
  分别把两个链表的结点放入两个栈里,这样两个链表的尾结点就位于两个栈的栈顶,接下来比较两个栈顶的结点是否相同。如果相同,则把栈顶弹出接着比较下一个栈顶,直到找到最后一个相同的结点。
  如果链表的长度分别为m和n,那么空间复杂度是O(m+n)。这种思路的时间复杂度也是O(m+n)。和最开始的蛮力法相比,时间效率得到了提高,相当于是用空间消耗换取了时间效率。

 //利用栈的方法
    public ListNode FindFirstCommonNode(ListNode pHead1, ListNode pHead2)
    {
        if(pHead1 == null || pHead2 == null)
            return null;

        Stack<ListNode> nodes1 = new Stack<>();
        Stack<ListNode> nodes2 = new Stack<>();

        //将2个链表的结点保存到栈
        while (pHead1 != null)
        {
            nodes1.push(pHead1);
            pHead1 = pHead1.next;
        }
        while (pHead2 != null)
        {
            nodes2.push(pHead2);
            pHead2 = pHead2.next;
        }

        pHead1 = null;
        pHead2 = null;
        ListNode common = null;

        //当2个栈都不为null的时候,取出栈的元素进行比较
        while((!nodes1.isEmpty()) && (!nodes2.isEmpty()))
        {
            pHead1 = nodes1.pop();
            pHead2 = nodes2.pop();

            if(pHead1 == pHead2)
            {
                common = pHead1;//将当前结点保存
            }
            else
            {
                break;
            }
        }
        return common;
    }

方法3:利用栈存储
  我们可以首先遍历两个链表得到它们的长度,就能知道哪个链表比较长,以及长的链表比短的链表多几个结点。在第二次遍历的时候,在较长的链表上先走若干步,接着再同时在两个链表上遍历,找到的第一个相同的结点就是它们的第一个公共结点。
  上述思路与借助栈的方法的时间复杂度都是O(m+n),但我们不再需要辅助的栈,因此提高了空间效率。
  但是很奇怪,我们这些代码在编译器上面可以找到相应的结点,但是在牛客网上没办法通过。反正思路是正确的就可以!

public static ListNode FindFirstCommonNode2(ListNode pHead1, ListNode pHead2)
    {
        if(pHead1 == null || pHead2 == null)
            return null;

        int length1 = getListLength(pHead1);
        int length2 = getListLength(pHead2);

        ListNode longList = null;
        ListNode shortList = null;

        int diff = length1 - length2;
        if(diff < 0)
        {
            longList = pHead2;
            shortList = pHead1;
            diff = length2 - length1;
        }
        else
        {
            longList = pHead1;
            shortList = pHead2;
        }

        //将较长的链表后移diff长度
        while (diff!=0)
        {
            longList = longList.next;
            diff--;
        }

        while (longList!=null && shortList!=null && longList != shortList)
        {
            longList = longList.next;
            shortList = shortList.next;
        }

        return longList;
    }

    private static int getListLength(ListNode node)
    {
        int length = 0;
        ListNode tempNode = node;
        while(tempNode != null)
        {
            length++;
            tempNode = tempNode.next;
        }
        return length;
    }

方法4:推荐
  设 A 的长度为 a + c,B 的长度为 b + c,其中 c 为尾部公共部分长度,可知 a + c + b = b + c + a。
  当访问链表 A 的指针访问到链表尾部时,令它从链表 B 的头部重新开始访问链表 B;同样地,当访问链表 B 的指针访问到链表尾部时,令它从链表 A 的头部重新开始访问链表 A。这样就能控制访问 A 和 B 两个链表的指针能同时访问到交点。
  这种方法的时间复杂度是O(m+n),不需要消耗额外的空间。
在这里插入图片描述

public ListNode FindFirstCommonNode(ListNode pHead1, ListNode pHead2) {
        ListNode p1 = pHead1;
        ListNode p2 = pHead2;

        /**
         * p1 遍历过程中,如果不为null,就指向下一个结点;当 p1 遍历完 pHead1 之后,它指向null,此时他肯定还不等于 p2,将 p1 指向pHead2链表头,
         * 继续向下遍历;同样的,p2 在遍历完 pHead2之后,指向 pHead1 继续遍历,这样就会遇到 p1=p2 的情况!
         * p1 与 p2 在遍历第一个链表的时候肯定不会相遇
         */
        while (p1 != p2)
        {
            p1 = (p1==null)?pHead2:p1.next;
            p2 = (p2==null)?pHead1:p2.next;
        }
        return p1;
    }

题目37 数字在排序数组中出现的次数

  题目描述:统计一个数字在排序数组中出现的次数。
  思路:

1、首先,遍历数组肯定就能知道某个数字的个数,此时的时间复杂度O(n)。但是,在数据大的情况下,这种算法时间消耗过多。

2、我们注意到,任务本质上是查找问题,而且是排序好的数组,可以尝试用二分查找算法。
	这样我们可以通过二分法,找到一个k(k是要查找的数字),然后根据这个k的位置,继续使用二分法,知道找到k第一次出现以及最后一次出现的位置,这样就可以很容易算得出现的次数。
	当数组长度很大,而k出现的次数远小于数组长度且k分布于数组中间的时候,这种算法的效率比遍历整个数组的方法高很多。
	但是如果k是n个呢?这个算法本质上时间复杂度还是O(n)

  关于此处使用的二分法的图解,参考:添加链接描述,这位老哥讲得很好!

  我们可以使用循环二分查找与递归二分查找,如下代码:

public class OfferGetTest37
{
    public static int GetNumberOfK(int [] array , int k) {
        if(array == null || array.length == 0)
            return 0;

        //使用循环二分查找,找到k在数组中第一次出现的位置——从0到array.length-1 开始查找
        int firstPlace = getFirstPlace(array , k , 0 , array.length-1);

        //使用递归二分查找,找到k在数组中最后一次出现的位置——从0到array.length-1 开始查找
        int lastPlace = getLastPlace(array , k , 0 , array.length-1);

        if(firstPlace!=-1 && lastPlace!=-1)
            return lastPlace-firstPlace+1;//这个就是k在数组中出现的次数
        return 0;//如果firstPlace、lastPlace某一个为-1,说明查找失败,返回0
        // 查找失败的原因,要么是上面的数组不对,要么是k在数组中不存在,找不到它出现的第一位和最后一位
    }

    //k在数组中第一次出现的位置——循环
    public static int getFirstPlace(int[] array , int k , int begin , int end)
    {
        if(begin > end)
            return -1;//说明数组长度为0,直接返回-1,查找失败(其实前面判断过,这里可以省略)

        int middle = 0;

        while (begin<=end)//begin=end 的时候,仍然需要进行查找,此时 mid=(begin+end)/2
        {
            middle = (begin+end)/2;
            if(array[middle] > k)
            {
                end = middle-1;
            }
            else if(array[middle] < k)
            {
                begin = middle+1;
            }
            /*
            此处,排除 array[middle] > k 与 array[middle] < k,就剩下array[middle] = k,此时需要判断middle-1位置是否为k,
            但是,在这之前,循环有可能遍历到数组首位,middle-1可能不存在,因此需要讨论middle-1是否存在。

            middle-1不存在,此时array[middle] = k,那么middle就是k第一次出现的位置,也是数组首位;
            middle-1存在,如果 array[middle-1] != k,那么middle就是k第一次出现的位置;
            middle-1存在,如果 array[middle-1] = k,说明middle不是k第一次出现的位置,需要继续向左二分查询,end=middle-1;

            那么我们只要排除 middle-1 存在且 array[middle] = k ,此时继续二分查询;剩下的情况直接返回middle即可!
             */
            else if(middle-1>=0 && array[middle-1] == k)
            {
                end = middle-1;
            }
            else
            {
                return middle;
            }
        }
        return -1;//如果循环完没有找到,说明数组中不存在k这个数,返回-1表示 数组不存在k
    }

    //k在数组中最后一次出现的位置——递归,递归方法涵义:查找数组array在 begin到end范围内 k在数组中最后一次出现的位置
    public static int getLastPlace(int[] array , int k , int begin , int end)
    {
        //1、解决规模最小问题——递归到最后 begin>end 的时候,还没有返回middle,说明查找的k在数组中不存在,返回-1(这里不能省略)
        if(begin>end)
            return -1;//规模最小问题(数组无法查找到k的情况)

        int middle = (begin+end)/2;
        //2、解决规模较小的问题——注意递归方法的涵义
        if(array[middle] > k)
        {
            //当 array[middle] > k 的时候,我们调用 getLastPlace 查找数组array在 begin到middle-1范围内 k在数组中最后一次出现的位置(这个是规模较小的问题)
            //3、将较小问题的解整合成为较大问题的解:当较小问题查找到后,直接返回查找结果,这个查找结果也是较大问题的查找结果。
            return getLastPlace(array , k , begin , middle-1);
        }
        else if(array[middle] < k)
        {
            return getLastPlace(array , k , middle+1 , end);
        }
        else if(middle+1<array.length && array[middle+1] == k)//middle+1<array.length:middle+1存在
        {
            return getLastPlace(array , k , middle+1 , end);//继续递归
        }
        else
        {
            return middle;//这个也是规模最小问题(能查找到k最后一次出现位置的情况)
        }
        //当递归到规模最小完问题,结果就会一级一级递归返回,直到返回最大问题的结果。
    }

    public static void main(String[] args)
    {
        int arr[] = null;
        System.out.println(GetNumberOfK(arr , 7));
    }
}

题目38 二叉树的深度

  题目描述:输入一棵二叉树,求该树的深度。从根结点到叶结点依次经过的结点(含根、叶结点)形成树的一条路径,最长路径的长度为树的深度。
  注意该二叉树不是满二叉树、也不是完全二叉树,其最深的结点可能在左子树,也可能在右子树。

//查询以root为根的二叉树的深度
    public int TreeDepth(TreeNode root)
    {
        //当遍历到二叉树的末尾的时候,root=null,此时这里没有结点,返回0
        if(root == null)
            return 0;

        //由于二叉树的最深处可能在左子树,也可能在右子树,我们需要分别查询左子树以及右子树的深度,找出最深的深度
        int lDepth = TreeDepth(root.left);
        int rDepth = TreeDepth(root.right);
        //查询到左子树与右子树的最大深度,并加上当前结点的深度1,就是以root为根的树的最大深度
        return 1+ (lDepth>rDepth ? lDepth : rDepth);
    }

    //上面代码可以简化为如下
    public int TreeDepth1(TreeNode root)
    {
        return root==null ? 0 : 1+Math.max(TreeDepth1(root.left) , TreeDepth1(root.right));
    }

题目39 平衡二叉树

  题目描述:输入一棵二叉树,判断该二叉树是否是平衡二叉树。在这里,我们只需要考虑其平衡性,不需要考虑其是不是排序二叉树。

  分析:一般情况下,平衡二叉树就是AVL树,它首先要满足二叉搜索树性质,其次其平衡性指的是:对于平衡二叉树所有的结点,这些结点左右子树高度之差不超过1。但是在本题中,没有二叉搜索树的要求,只对平衡与否进行判断即可。

  方法1:我们很容易想到,在遍历树的每一个结点时,求其左右子树的深度,判断深度之差,如果每个结点的左右子树深度相差都不超过1,那么就是一棵平衡二叉树。本思路直观简洁,但是我们从树的根结点开始判断,每次都需要从当前结点递归到树的叶子结点,这样才能算出当前结点的高度,这样使得很多结点需要重复遍历多次,时间效率不高。

  方法2:为了避免重复遍历,我们采用后序遍历的方式遍历二叉树的每个结点,这样在遍历到每个结点的时候就已经访问了它的左右子树。即我们先求某个结点左右孩子结点的高度,同时计算左右孩子结点的平衡性,如果左右孩子结点已经不平衡,直接返回-1,没必须再计算当前结点的高度,同样直接返回-1。这种方法从底层开始计算每一个结点的高度与其平衡性,从而使得所有的结点最多被遍历一次!

  代码如下:

public boolean IsBalanced_Solution(TreeNode root)
    {
        int height = getHeight(root);
        //在计算root结点高度的过程中,如果返回-1,说明树中有不平衡结点,树不是平衡二叉树。
        if(height == -1)
            return false;
        //如果返回root树的高度,则该树是平衡二叉树!
        return true;
    }

    /** 用于获取node结点高度的方法
    1) 这个方法当当前结点左右孩子结点不平衡、或者当前结点不平衡,则不会返回当前结点高度,而是返回-1;
        如果当前结点平衡则会返回当前结点高度。
    2) 只要底层有一个结点不平衡,就会全部返回-1,那么其他上面的结点也不需要计算高度,因为已经直接返回-1了;
    这个方法即在计算当前结点高度之前,先计算左右孩子结点高度,并判断左右孩子结点是否平衡 
     */
    private int getHeight(TreeNode node)
    {
        if(node == null)
            return 0;//空结点高度为0
        
        //先求当前结点左右孩子结点高度,并计算左右孩子结点平衡性(后序遍历会递归到叶子结点开始)
        int leftNodeHeight = getHeight(node.left);
        if(leftNodeHeight == -1)
            return -1;//如果发现子结点不满足平衡性返回-1,整棵树不满足平衡性,那么当前结点的平衡性不需要计算,直接返回-1
        int rightNodeHeight = getHeight(node.right);
        if(rightNodeHeight == -1)
            return -1;
        
        //如果孩子结点都平衡,我们利用左右孩子结点的高度,计算当前结点的平衡因子
        int balanceFactor = Math.abs(leftNodeHeight - rightNodeHeight);
        if(balanceFactor > 1)
            return -1;//如果当前结点不平衡,直接返回-1
        else
            return 1 + Math.max(rightNodeHeight , leftNodeHeight);
    }

题目40 数组中只出现一次的数字

  题目描述:一个整型数组里除了两个数字之外,其他的数字都出现了两次。请写程序找出这两个只出现一次的数字。

方法1(推荐)
  首先,对于异或运算,有如下规则:

a^a = 0;
a^0 = a;
a^b^c = a^(b^c) = (a^b)^c

  当数组中只有一个数出现一次,其他数都出现2次时,我们把数组中所有的数,依次异或运算,最后剩下的就是落单的数,因为成对儿出现的都抵消了。
  依照这个思路,对于有2个数只出现1次,其他数出现2次的数组(假设2个数是A与B)。我们首先还是先异或,剩下的数字肯定是A、B异或的结果。这个结果的二进制中的1,表现的是A和B同一位上的值不同。

//这个结果的二进制中的1,表现的是A和B同一位上的值不同
如A=101,B=110,那么A^B,可以表示为:
101
110
————
011
我们发现A与B对应位上不相等,这一位异或的结果才是1

  那么我们可以根据这个特性,来把A与B区分开。我们找到A与B异或结果中第一个结果为1的位,假设是第3位,接着把原数组分成两组,分组标准是第3位是否为1。如此,相同的数肯定在一个组,因为相同数字所有位都相同,而不同的数,肯定不在一组。因此,由于A与B不同,那么他们肯定被分在2组。然后把这两个组按照最开始的思路,依次异或,剩余的两个结果就是这两个只出现一次的数字。
  这个方法时间复杂度为O(n)
  代码如下:

public void FindNumsAppearOnce(int [] array,int num1[] , int num2[]) {
        int length = array.length;
        //如果只有2个数,不需要比较直接返回
        if(length == 2)
        {
            num1[0] = array[0];
            num2[0] = array[1];
            return;
        }

        int res = 0;//用于存储2个不同的数(假设为A、B的异或结果)
        for (int i = 0; i < length ; i++)
        {
            res ^= array[i];
        }

        //找到A、B异或结果中第一个为1的位
        int bitRes = 0;//用于存储res的第几位为1,位数从0开始计算
        for (int i = 0; i < 32 ; i++)//因为传入的是int类型的数,最多只有32位,那么我们最多只需遍历32次就可以找到相应的位
        {
            if((res&1) == 0)//如果没找到1的位,将res继续右移一位
            {
                res >>= 1;
            }
            else
            {
                bitRes = i;//找到第一个1的位,就将这个位数赋予bitRes
                break;//找到后跳出循环
            }

        }

        //将数组按 bitRes是否为1分为2组,并且进行异或运算
        for (int i = 0; i < length ; i++)
        {
            if(((array[i] >> bitRes) & 1) == 1)//将数右移bitRes位,判断这个位是否为1
                num1[0] ^= array[i];//由于数组num1与num2初始化进来0位置必须赋值为0,我们用他们的0位置数来进行异或,最后结果也存储在0位
            else
                num2[0] ^= array[i];
        }
        //运行时间:21ms,占用内存:9624k
    }

方法2
  将数值放入hashmap,键为数字,值为数组中数字出现的次数,然后遍历hashmap 找到值为1的数。
  这种方法时间复杂度同样是O(n),但是空间复杂度也为O(n),占用了较多空间。

//方法2,HashMap
    public void FindNumsAppearOnce1(int [] array,int num1[] , int num2[]) 
    {
        if(array==null || array.length==0){
            return ;
        }

        HashMap<Integer, Integer> hashMap = new HashMap<>();
        /*
        将数字的值存储为Map的键,键对应的值为数字在数组中出现的次数。
        为什么不把数字出现的次数作为键?因为键必须不同,而值可以相同,数字只出现1或者2次,没办法保存那么多数字。
        因此,将数字设置为键,将1/2设置为值
         */
        for (int i = 0; i < array.length; i++)
        {
            if(hashMap.containsKey(array[i]))
                hashMap.put(array[i] , hashMap.get(array[i])+1);
            else
                hashMap.put(array[i] , 1);
        }

        ArrayList<Integer> arrayList = new ArrayList<Integer>();
        for (int i = 0; i < array.length ; i++)
        {
            if(hashMap.get(array[i]) == 1)
                arrayList.add(array[i]);
        }
        //注意,找的是只出现1次的数组,不是出现2次
        num1[0] = arrayList.get(0);
        num2[0] = arrayList.get(1);
    }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值