剑指 Offer 系列
TopK 问题
最小的 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 的前面,而在这个位置产生了异常。

那么,只要我们可以找到异常点,也就找到了旋转数组的最小元素。
具体操作如下:
1、设置两个指针 left
和 right
,其中 left
指向当前区间的开始位置,right
指向当前区间的结束位置,计算出两者的中间索引位置 mid
。

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. 数据流中的中位数
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. 连续子数组的最大和
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)