剑指 Offer 系列题解

本文详细介绍了多种解决TopK问题的方法,包括大顶堆和快速选择。堆方法适用于数据流,而快速选择在特定条件下更优。同时,文章涵盖了其他算法题目,如链表操作、数组查找、字符串处理、二叉树和链表问题,展示了多种数据结构和算法在实际问题中的应用。

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

剑指 Offer 系列

TopK 问题

4种解法秒杀TopK(快排/堆/二叉搜索树/计数排序)

Top K 的两种经典解法(堆/快排变形)与优劣比较

最小的 K 个数

大顶堆
	public int[] getLeastNumbers(int[] arr, int k) {
    if (k == 0) {
        return new int[0];
    }
    // 使用一个最大堆(大顶堆)
    // Java 的 PriorityQueue 默认是小顶堆,添加 comparator 参数使其变成最大堆
    Queue<Integer> heap = new PriorityQueue<>(k, (i1, i2) -> Integer.compare(i2, i1));

    for (int e : arr) {
        // 当前数字小于堆顶元素才会入堆
        if (heap.isEmpty() || heap.size() < k || e < heap.peek()) {
            heap.offer(e);
        }
        if (heap.size() > k) {
            heap.poll(); // 删除堆顶最大元素
        }
    }

    // 将堆中的元素存入数组
    int[] res = new int[heap.size()];
    int j = 0;
    for (int e : heap) {
        res[j++] = e;
    }
    return res;
}
快排选择
	public int[] getLeastNumbers(int[] arr, int k) {
    if (k == 0) {
        return new int[0];
    } else if (arr.length <= k) {
        return arr;
    }
    
    // 原地不断划分数组
    partitionArray(arr, 0, arr.length - 1, k);
    
    // 数组的前 k 个数此时就是最小的 k 个数,将其存入结果
    int[] res = new int[k];
    for (int i = 0; i < k; i++) {
        res[i] = arr[i];
    }
    return res;
}

void partitionArray(int[] arr, int lo, int hi, int k) {
    // 做一次 partition 操作
    int m = partition(arr, lo, hi);
    // 此时数组前 m 个数,就是最小的 m 个数
    if (k == m) {
        // 正好找到最小的 k(m) 个数
        return;
    } else if (k < m) {
        // 最小的 k 个数一定在前 m 个数中,递归划分
        partitionArray(arr, lo, m-1, k);
    } else {
        // 在右侧数组中寻找最小的 k-m 个数
        partitionArray(arr, m+1, hi, k);
    }
}

// partition 函数和快速排序中相同,具体可参考快速排序相关的资料
// 代码参考 Sedgewick 的《算法4》
int partition(int[] a, int lo, int hi) {
    int i = lo;
    int j = hi + 1;
    int v = a[lo];
    while (true) { 
        while (a[++i] < v) {
            if (i == hi) {
                break;
            }
        }
        while (a[--j] > v) {
            if (j == lo) {
                break;
            }
        }

        if (i >= j) {
            break;
        }
        swap(a, i, j);
    }
    swap(a, lo, j);

    // a[lo .. j-1] <= a[j] <= a[j+1 .. hi]
    return j;
}

void swap(int[] a, int i, int j) {
    int temp = a[i];
    a[i] = a[j];
    a[j] = temp;
}
两种方法的优劣性比较

在面试中,另一个常常问的问题就是这两种方法有何优劣。看起来分治法的快速选择算法的时间、空间复杂度都优于使用堆的方法,但是要注意到快速选择算法的几点局限性:

第一,算法需要修改原数组,如果原数组不能修改的话,还需要拷贝一份数组,空间复杂度就上去了。

第二,算法需要保存所有的数据。如果把数据看成输入流的话,使用堆的方法是来一个处理一个,不需要保存数据,只需要保存 k 个元素的最大堆。而快速选择的方法需要先保存下来所有的数据,再运行算法。当数据量非常大的时候,甚至内存都放不下的时候,就麻烦了。所以当数据量大的时候还是用基于堆的方法比较好。

剑指 Offer

指 Offer 03. 数组中重复的数字
  public int findRepeatNumber(int[] nums) {
        Set<Integer> cache = new HashSet<>();
        for (int i = 0; i < nums.length; i++) {
            if (!cache.contains(nums[i])){
                cache.add(nums[i]);
            }else {
                return nums[i];
            }
        }
        return -1;
    }
剑指 Offer 04. 二维数组中的查找
    public boolean findNumberIn2DArray(int[][] matrix, int target) {
        int i = matrix.length - 1;
        int j = 0;
        while (i >= 0 && j < matrix[0].length) {
            if (matrix[i][j] > target) {
                i--;
            } else if (matrix[i][j] < target) {
                j++;
            } else {
                return true;
            }
        }
        return false;
    }
剑指 Offer 05. 替换空格
   public String replaceSpace(String s) {
        char[] chars = s.toCharArray();
        StringBuilder sb = new StringBuilder();
        for (char c:chars){
            if (c==' '){
                sb.append("%20");
            }else {
                sb.append(c);
            }
        }
        return sb.toString();
    }
剑指 Offer 06. 从尾到头打印链表
	public int[] reversePrint(ListNode head) {
        // 构建一个栈,用来存储链表中每个节点的值
        Stack<Integer> stack = new Stack<>();

        // 构建一个指针,指向链表的头结点位置,从它开始向后遍历
        ListNode curNode = head;

        // 不断的遍历原链表中的每个节点,直到为 null
        while (curNode != null){
            // 把每个节点的值加入到栈中
            stack.push(curNode.val);
            // curNode 向后移动
            curNode = curNode.next;
        }

        // 获取栈的长度
        int size = stack.size();

        // 通过栈的长度,定义一个同样长度的数组 res
        int[] res = new int[size];

        // 遍历栈,从栈顶挨个弹出每个值,把这些值依次加入到数组 res 中
        for(int i = 0 ; i < size; i++){
            // 数组接收栈顶元素值
            res[i] = stack.pop();
        }
        // 最后返回结果数组就行
        return res;
    }
剑指 Offer 09. 用两个栈实现队列
public class CQueue {

    Stack<Integer> stack1;
    Stack<Integer> stack2;

    public CQueue() {
        stack1 = new Stack<>();
        stack2 = new Stack<>();
    }

    public void appendTail(int value) {
        stack1.push(value);
    }

    public int deleteHead() {
        // 1、如果 stack2 栈不为空,说明 stack2 里面已经存储了一些元素,
        // 并且 stack2 的栈顶元素就是两个栈中最早加入的元素
        if(!stack2.isEmpty()){
            // 返回 stack2 的栈顶元素,满足了队列先进先出的特点
            return stack2.pop();
        }

        // 2、如果 stack2 为空,并且发现 stack1 也为空,
        // 说明 stack1 和 stack2 构建的队列中没有元素,
        if(stack1.isEmpty()){
            // 根据题意,直接返回 -1
            return -1;
        }

        // 3、如果 stack2 为空,但 stack1 不为空
        // 那么需要先将 stack1 中的元素依次【倒序】放入 stack2 中
        // 对于 stack1 来说,越早加入的元素在【栈底】,越晚加入的元素在【栈顶】
        // 由于队列是【先进先出】,所以删除的应该是 stack1 的【栈底】元素
        while(!stack1.isEmpty()){
            // 获取 stack1 的栈顶元素并将该元素从 stack1 中弹出
            int topValue = stack1.pop();
            // 把该元素加入到 stack2
            // 这样 stack2 的栈顶元素就是 stack1 的栈底元素
            stack2.push(topValue);
        }

        // 4、返回 stack2 的栈顶元素,满足了队列先进先出的特点
        return stack2.pop();
    }
}
剑指 Offer 11. 旋转数组的最小数字

首先,我们明确知道这个数组是被旋转了,也就意味着,这个数组实际上可以被划分为两个部分。

  • 1、左边是一个递增的数组
  • 2、右边是一个递增的数组
  • 3、左右两部分相交的位置出现了一个异常点,小的数字在大的数字后面,比如下图中 1 在 7 的后面,正常来说递增数组应该是 1 在 7 的前面,而在这个位置产生了异常。
image-20220301230734524

那么,只要我们可以找到异常点,也就找到了旋转数组的最小元素。

具体操作如下:

1、设置两个指针 leftright ,其中 left 指向当前区间的开始位置,right 指向当前区间的结束位置,计算出两者的中间索引位置 mid

image-20220301230802890

2、接下来,我们研究思考一下这三个指针指向的元素之间的关系。

  • 1、如果 mid 指向的元素大于 right 指向的元素,也就意味着异常点肯定是发生在 [ mid + 1 , right ] 这个区间的,不需要再在 [ left ,mid ] 这个区间里面查找,那么可以更新 left 的位置为 mid + 1。
  • 2、如果 mid 指向的元素小于 right 指向的元素,也就意味着 [ mid , right ] 这个区间中所有的元素都是正常递增,不需要再在 [ mid , right ] 这个区间里面查找,异常点发生在 [ left , mid ] 这个区间,那么可以更新 right的位置为 mid 。
  • 3、由于数组中可能存在重复的元素,比如 [1, 1, 1, 0, 1],那么mid 指向的元素会等于 right 指向的元素,此时无法判断异常点会在哪个区间,因此我们可以从头到尾遍历一下剩下的区间,找到那个最小的元素。
    public int minArray(int[] numbers) {
        int left = 0;
        int right = numbers.length - 1;
        while (left < right) {
            int mid = (left + right) / 2;
            if (numbers[mid] > numbers[right]) {
                left = mid + 1;
            } else if (numbers[mid] < numbers[right]) {
                right = mid;
            } else {
                return findMin(numbers, left, right);
            }
        }
        // 返回数组第一个元素即为最小元素
        return numbers[left];
    }

    private int findMin(int[] numbers, int left, int right) {
        int min = numbers[left];
        for (int i = left; i < +right; i++) {
            if (numbers[i] < min) {
                min = numbers[i];
            }
        }
        return min;
    }
剑指 Offer 12. 矩阵中的路径( DFS + 剪枝 )

TODO: 需要在 b 站上再进行学习下

class Solution {
    public boolean exist(char[][] board, String word) {
        // 先将字符串进行拆分,一个一个元素进行匹配
        char[] words = word.toCharArray();
        // 通过两层嵌套,覆盖所有的情况
        for(int i = 0; i < board.length; i++) {
            for(int j = 0; j < board[0].length; j++) {
                // 以该元素为起始点,递归检查是否符合要求
                if(dfs(board, words, i, j, 0)) return true;
            }
        }
        return false;
    }

    boolean dfs(char[][] board, char[] word, int i, int j, int k) {
        // 边界情况判断
        // 行越界
        // 列越界
        // 矩阵元素已访问过 
        if(i >= board.length || i < 0 || j >= board[0].length || j < 0 || board[i][j] != word[k]) return false;

        // 之前已经和目标字符串匹配成功了 length - 1 个字符,此时又匹配成功了最后一个元素,直接返回结果
        if(k == word.length - 1) return true;

        // 标记当前矩阵元素,将其修改为特殊字符 #  ,代表此元素已访问过,防止之后搜索时重复访问。
        board[i][j] = '#';

      
        //  检查元素的四个方向 上 左 下 右

        boolean res = dfs( board , word , i , j - 1 , k + 1 ) 
                   || dfs( board , word , i - 1 , j , k + 1 ) 
                   || dfs( board , word , i , j + 1 , k + 1 ) 
                   || dfs( board , word , i + 1 , j , k + 1 );

        
        board[i][j] = word[k];
        return res;
    }
}
剑指 Offer 18. 删除链表的节点
 	public ListNode deleteNode(ListNode head, int val) {
    		// 特殊节点的处理
        if(head.val==val){
            return head.next;
        }
    
    		// 双指针处理
        ListNode pre = head;
        ListNode cur = head.next;
        while(cur!=null&&cur.val!=val){
            pre = cur;
            cur = cur.next;
        }
        pre.next = cur.next;
        
        return head;
    }
剑指 Offer 24. 反转链表
	public ListNode reverseList(ListNode head) {
       ListNode pre=null,cur = head;
       while(cur!=null){
           ListNode next = cur.next;
           cur.next = pre;
           pre =cur;
           cur = next;
       }
       return pre;
    }

时间复杂度:O(N)

空间复杂度:O(1)

剑指 Offer 22. 链表中倒数第k个节点

解法一:

	public ListNode getKthFromEnd(ListNode head, int k) {
        int length = 0;
        ListNode p =head;
        while(p!=null){
            length++;
            p = p.next;
        }

        int m = length - k+1;
        for(int i=1;i<m;i++){
            head = head.next;
        }
        return head;
    }

时间复杂度:O(N)

空间复杂度:O(1)

解法二:快慢指针法

		public ListNode getKthFromEnd(ListNode head, int k) {
        ListNode slow =head,fast = head;
      	// 快指针先走 k 步
        while(k-->0){
            fast = fast.next;
        }

      	// 快慢指针一起向前走
        while(fast!=null){
            slow =slow.next;
            fast = fast.next;
        }
        return slow;
    }

时间复杂度:O(N)

空间复杂度:O(1)

剑指 Offer 25. 合并两个排序的链表
	public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
        // 虚拟头节点
        ListNode dummy =  new ListNode(-1);
        // 指针
        ListNode p = dummy;
        // 两者比较大小,谁小谁先姐
        while(l1!=null&&l2!=null){
            if(l1.val>l2.val){
                p.next = l2;
                l2 = l2.next;
            }else if(l1.val<=l2.val){
                p.next = l1;
                l1 = l1.next;
            }
            p = p.next;
        }

        // l2 先走完,l1 未走完
        if(l1!=null){
            p.next = l1;
        }
        // l1 先走完,l2 未走完
        if(l2!=null){
            p.next = l2;
        }
        return dummy.next;
    }

时间复杂度:O(N)

空间复杂度:O(1)

剑指 Offer 52. 两个链表的第一个公共节点

算法思想:当 A 链表走到结尾的时候走 B 链表;当 B 链表走到结尾的时候走 A 链表。当两个链表的节点相等的时候,便是两个链表的公共节点。

	public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
        // 边界判断
        if (headA == null || headB == null) return null;
        ListNode l1 = headA,l2 = headB;
        while(l1!=l2){
            if(l1 ==null){
                l1 = headB;
            }else{
                l1 = l1.next;
            }
            if(l2 ==null){
                l2 = headA;
            }else{
                 l2 = l2.next;
            }
        }
        return l1;
    }
剑指 Offer 53 - I. 在排序数组中查找数字 I

方法一:利用 HashMap

	 public int search(int[] nums, int target) {
        Map<Integer, Integer> cache = new HashMap<>();
        for (int i = 0; i < nums.length; i++) {
            if (!cache.containsKey(nums[i])) {
                cache.put(nums[i], 1);
            } else {
                cache.put(nums[i], cache.getOrDefault(nums[i], 1) + 1);
            }
        }
        if (cache.containsKey(target)) {
            return cache.get(target);
        } else {
            return 0;
        }
    }
  • 时间复杂度:O(N)
  • 空间复杂度:O(N)

方法二:双指针

	 public int search(int[] nums, int target) {
       int left = 0, right = nums.length - 1;
        while (left <= right) {
            if (nums[left] == target) {
                break;
            } else {
                left++;
            }
        }

        while (left <= right) {
            if (nums[right] == target) {
                break;
            } else {
                right--;
            }
        }
        return right - left + 1;
    }
  • 时间复杂度:O(N)
  • 空间复杂度:O(1)
剑指 Offer 57. 和为s的两个数字

解法一:哈希表

	 public int[] twoSum(int[] nums, int target) {
        Map<Integer, Integer> cache = new HashMap<>();
        for (int i = 0; i < nums.length; i++) {
            if (cache.containsKey(target - nums[i])) {
                return new int[]{nums[i], cache.get(target - nums[i])};
            } else {
                cache.put(nums[i], nums[i]);
            }
        }
        return new int[]{};
    }

时间复杂度:O(N)

空间复杂度:O(N)

解法二:双指针:

	public int[] twoSum(int[] nums, int target) {
         int left = 0, right = nums.length - 1;
        while (left <= right) {
            int mid = (left + right) / 2;
            if (nums[left] + nums[right] > target) {
                right--;
            } else if (nums[left] + nums[right] < target) {
                left++;
            } else {
                return new int[]{nums[left], nums[right]};
            }
        }
        return new int[]{};
    }
剑指 Offer 33. 二叉搜索树的后序遍历序列

方法一:

	public boolean verifyPostorder(int[] postorder) {
        return recur(postorder, 0, postorder.length - 1);
    }
    boolean recur(int[] postorder, int i, int j) {
        if(i >= j) return true;
        int p = i;
        while(postorder[p] < postorder[j]) p++;
        int m = p;
        while(postorder[p] > postorder[j]) p++;
        return p == j && recur(postorder, i, m - 1) && recur(postorder, m, j - 1);
    }

作者:jyd
链接:https://leetcode-cn.com/problems/er-cha-sou-suo-shu-de-hou-xu-bian-li-xu-lie-lcof/solution/mian-shi-ti-33-er-cha-sou-suo-shu-de-hou-xu-bian-6/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
  • 时间复杂度:O(N^2)
  • 空间复杂度:O(N)

方法二:

	 public boolean verifyPostorder(int[] postorder) {
        return isPostOrder(postorder, 0, postorder.length - 1);
    }

    private boolean isPostOrder(int[] postorder, int left, int right) {
      	// 当 left >= right 时,说明当前区间只有一个节点或者没有节点了
        // 不需要在判断下去
        if (left >= right) {
            return true;
        }
        int rootValue = postorder[right];
        // 左子树区间[ledft,index-1]
        int index = left;
        while (postorder[index] < rootValue) {
            index++;
        }
        // 右子树区间 [rightIndex,right-1]
        int rightIndex = index;
        while (rightIndex < right) {
            if (postorder[rightIndex] < rootValue) {
                return false;
            }
            rightIndex++;
        }
        // 递归判断左子树、右子树是否是二叉搜索树
        return isPostOrder(postorder, left, index - 1) && isPostOrder(postorder, index, right - 1);
    }
剑指 Offer 39. 数组中出现次数超过一半的数字

方法一:排序法

	public int majorityElement(int[] nums) {
        Arrays.sort(nums);
        return nums[nums.length/2];
    }
  • 时间复杂度:O(nlogn) 因为数组排序的时间复杂度是:O(nlogn)
  • 空间复杂度:O(logn),需要使用 Log(n)的栈空间。

方法二:哈希表

	private Map<Integer, Integer> countNums(int[] nums) {
        Map<Integer, Integer> counts = new HashMap<Integer, Integer>();
        for (int num : nums) {
            if (!counts.containsKey(num)) {
                counts.put(num, 1);
            } else {
                counts.put(num, counts.get(num) + 1);
            }
        }
        return counts;
    }

    public int majorityElement(int[] nums) {
        Map<Integer, Integer> counts = countNums(nums);

        Map.Entry<Integer, Integer> majorityEntry = null;
        for (Map.Entry<Integer, Integer> entry : counts.entrySet()) {
            if (majorityEntry == null || entry.getValue() > majorityEntry.getValue()) {
                majorityEntry = entry;
            }
        }

        return majorityEntry.getKey();
    }		
  • 时间复杂度:O(N)
  • 空间复杂度:O(N)

方法三:投票法

		/**
     * 这个也就是投票法
     * @param nums
     * @return
     */
    public int majorityElement3(int[] nums) {
        // 统计擂台上擂主的个数
        int count = 0;

        // candidate 表示擂主的编号
        // 一开始,擂台上没有擂主
        int candidate = 0;

        // 数组中的所有数字开始轮番上擂台进行挑战,每个数字的战斗力均为 1
        // 1、相同势力的数字可以都停留在擂台上
        // 2、不同势力的数字会同归于尽
        for (int num : nums) {
            // 擂台上没有擂主时
            if (count == 0) {
                // 此时登场的 num 就是擂主
                candidate = num;
            }
            // 擂台上有擂主
            // 并且此时登场的 num 和擂主属于相同的势力
            // 那么两者都停留在场上
            if (num == candidate) {
                count += 1;

                // 否则说明此时登场的 num 和擂主属于不同的势力
                // num 会和一个擂主同归于尽
            } else {
                count -= 1;
            }

        }
        // 一轮遍历下来,停留在场上的擂主就是所求的数字
        return candidate;
    }
  • 时间复杂度:O(N)

  • 空间复杂度:O(1)

剑指 Offer 40. 最小的k个数
快速排序算法

1.先快速排序

2.再返回前 k 个数

	class Solution {
    public int[] getLeastNumbers(int[] arr, int k) {
        quickSort(arr, 0, arr.length - 1);
        return Arrays.copyOf(arr, k);
    }
    private void quickSort(int[] arr, int l, int r) {
        // 子数组长度为 1 时终止递归
        if (l >= r) return;
        // 哨兵划分操作(以 arr[l] 作为基准数)
        int i = l, j = r;
        while (i < j) {
            while (i < j && arr[j] >= arr[l]) j--;
            while (i < j && arr[i] <= arr[l]) i++;
            swap(arr, i, j);
        }
        swap(arr, i, l);
        // 递归左(右)子数组执行哨兵划分
        quickSort(arr, l, i - 1);
        quickSort(arr, i + 1, r);
    }
    private void swap(int[] arr, int i, int j) {
        int tmp = arr[i];
        arr[i] = arr[j];
        arr[j] = tmp;
    }
}
基于快速排序的数组划分
	class Solution {
    public int[] getLeastNumbers(int[] arr, int k) {
        if (k >= arr.length) return arr;
        return quickSort(arr, k, 0, arr.length - 1);
    }
    private int[] quickSort(int[] arr, int k, int l, int r) {
        int i = l, j = r;
        while (i < j) {
            while (i < j && arr[j] >= arr[l]) j--;
            while (i < j && arr[i] <= arr[l]) i++;
            swap(arr, i, j);
        }
      	// 此处的这个 i 就是已经拍好序的基数的下标,这个值的左边都小于这个值,右边都大于这个值
        swap(arr, i, l);
      	
      	// 这里是和快排的区别
      	// 条件成立:说明前 k 个在左区间
        if (i > k) return quickSort(arr, k, l, i - 1);
      	// 条件成立:说明前 k 个在右区间
        if (i < k) return quickSort(arr, k, i + 1, r);
        return Arrays.copyOf(arr, k);
    }
    private void swap(int[] arr, int i, int j) {
        int tmp = arr[i];
        arr[i] = arr[j];
        arr[j] = tmp;
    }
}
剑指 Offer 41. 数据流中的中位数

图解:面试题41. 数据流中的中位数(优先队列 / 堆,清晰图解)

	class MedianFinder {
    Queue<Integer> A, B;
    public MedianFinder() {
      	// 小顶堆,保存较大的一半
        A = new PriorityQueue<>(); 
      	// 大顶堆,保存较小的一半
        B = new PriorityQueue<>((x, y) -> (y - x)); 
    }
    public void addNum(int num) {
        if(A.size() != B.size()) {
            A.add(num);
            B.add(A.poll());
        } else {
            B.add(num);
            A.add(B.poll());
        }
    }
    public double findMedian() {
        return A.size() != B.size() ? A.peek() : (A.peek() + B.peek()) / 2.0;
    }
}

时间复杂度:

空间复杂度:

剑指 Offer 42. 连续子数组的最大和

面试题42. 连续子数组的最大和(动态规划,清晰图解)

class Solution {
    public int maxSubArray(int[] nums) {
        int res = nums[0];
        for(int i = 1; i < nums.length; i++) {
            nums[i] += Math.max(nums[i - 1], 0);
            res = Math.max(res, nums[i]);
        }
        return res;
    }
}
  • 时间复杂度:O(N)

  • 空间复杂度:O(1)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值