记录书中一些比较经典的题目。
找出数组中重复的数字。
在一个长度为 n 的数组 nums 里的所有数字都在 0~n-1
的范围内。数组中某些数字是重复的,但不知道有几个数字重复了,也不知道每个数字重复了几次。请找出数组中任意一个重复的数字。
根据鸽巢原理,不断将数字交换到自身对应的下标上,直到发现重复的数字。
虽然代码中有两重循环,但每个数字最多只要交换两次就能找到属于它自己的位置,因此总的时间复杂度为
\(O(n)\) 。空间复杂度为 \(O(1)\) 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class Solution {public : int findRepeatNumber (vector<int > &nums) { for (int i = 0 ; i < nums.size (); i++) { while (nums[i] != i) { if (nums[nums[i]] == nums[i]) { return nums[i]; } int temp = nums[i]; nums[i] = nums[temp]; nums[temp] = temp; } } return -1 ; } };
给定一个包含 n + 1 个整数的数组 nums,其数字都在 1 到 n 之间(包括 1
和
n),可知至少存在一个重复的整数。假设只有一个重复的整数,找出这个重复的数。
方法一:二分查找
对区间 [1,n]
(注意不是对数组 nums
)进行二分查找,每次只需统计前一半区间内的数字个数,如果大于区间长度,则说明这段区间一定有重复数字,否则另一半区间内一定有重复数字,然后继续对该半个区间进行划分。
一共划分 \(\log n\)
次,每次统计个数需要花费 O(n)
的时间,所以总的时间复杂度为
\(O(n\log n)\) 。空间复杂度为 \(O(1)\) 。
注意:若数组有多个重复的数字,此方法不能保证找出所有重复的数字。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 class Solution {public : int findDuplicate (vector<int > &nums) { int lo = 1 , hi = nums.size () - 1 ; while (lo <= hi) { if (lo == hi) return lo; int mid = lo + ((hi - lo) >> 1 ); int cnt = count (nums, lo, mid); if (cnt > mid - lo + 1 ) { hi = mid; } else { lo = mid + 1 ; } } return -1 ; } int count (vector<int > &nums, int lo, int hi) { int cnt = 0 ; for (int i : nums) { if (i >= lo && i <= hi) { cnt++; } } return cnt; } };
方法二:快慢指针
如果将数组看成链表,每个元素的下一个元素是其值作为下标对应的元素,我们发现,如果数组中存在重复元素,那么链表中一定会形成环,而环的入口就是那个重复的元素。于是我们的目标变成找到环的入口。
我们设置两个指针 slow
和 fast
,前者每走一步,后者走两步。假设 slow
走了 \(n\) 步时与 fast
相遇,则此时
fast
走了 \(2n\)
步,且其多走的 \(n\)
步一定是环周长的整数倍。然后我们再设置一个指针 entry
,让它从起点开始和 slow
(从与 fast
的相遇点开始)一起同速前进,当它们再相遇时,指向的就是环的入口。
为什么 entry
和 slow
一定会在入口相遇呢?我们假设起点距离入口为 \(m\) ,则 slow
和
fast
相遇时,slow
已经在环中走了 \(n-m\) 步,如果再走 \(m\) 步,则一定会回到环的入口处( \(n\) 是周长的整数倍),而此时
enrty
也走到了环的入口。
总结一下,这个算法有两个关键点:
先通过快慢指针得到 \(n\)
是环周长的整数倍,这个结论之后要用到。
slow
和 fast
相遇后,我们只知道此时
slow
再走 \(m\)
步就会回到环的入口处,但并不知道 \(m\)
具体是多少。设置 enrty
的目的就是通过它和 slow
的相遇来指示已同时到达环的入口。
时间复杂度 \(O(n)\) ,空间复杂度
\(O(1)\) 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class Solution {public : int findDuplicate (vector<int > &nums) { int slow = 0 ; int fast = 0 ; do { slow = nums[slow]; fast = nums[nums[fast]]; } while (slow != fast); int entry = 0 ; while (entry != slow) { entry = nums[entry]; slow = nums[slow]; } return entry; } };
把一个数组最开始的若干个元素搬到数组的末尾,我们称之为数组的旋转。输入一个递增排序的数组的一个旋转,输出旋转数组的最小元素。例如,数组
[3,4,5,1,2] 为 [1,2,3,4,5] 的一个旋转,该数组的最小值为1。
旋转数组由两段递增序列组成,且左子序列中的元素均不小于右子序列中的元素。
采用二分查找的思想,考虑三种情况:
中间元素小于最右元素,说明中间元素在右子序列中,排除其右侧部分;
中间元素大于最右元素,说明中间元素在左子序列中,排除其左侧部分;
中间元素等于最右元素,此时无法判断应排除哪一侧(例如最左、中间、最右元素均相等的情况),但至少可以排除最右元素,因为即使最右元素是最小值,我们还可以取与之相等的中间元素。
值得一提的是,该算法也兼容数组整体递增(把前 0
个元素搬到数组末尾)的特殊情况。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class Solution {public : int minArray (vector<int > &numbers) { int lo = 0 , hi = numbers.size () - 1 ; while (lo < hi) { int mid = lo + ((hi - lo) >> 1 ); if (numbers[mid] < numbers[hi]) hi = mid; else if (numbers[mid] > numbers[hi]) lo = mid + 1 ; else hi--; } return numbers[lo]; } };
请设计一个函数,用来判断在一个矩阵中是否存在一条包含某字符串所有字符的路径。路径可以从矩阵中的任意一格开始,每一步可以在矩阵中向左、右、上、下移动一格。如果一条路径经过了矩阵的某一格,那么该路径不能再次进入该格子。
回溯法,通过将 board
中的元素置 0
表明已经过该格子,从而省去另开一个二维数组的空间开销。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 class Solution {public : bool exist (vector<vector<char >> &board, string word) { if (word.empty ()) return true ; int n = board.size (); int m = board[0 ].size (); if (word.size () > n * m) return false ; for (int i = 0 ; i < n; i++) { for (int j = 0 ; j < m; j++) { if (dfs (board, word, 0 , i, j)) return true ; } } return false ; } bool dfs (vector<vector<char >> &board, string word, int index, int i, int j) { if (index >= word.size ()) return true ; if (i < 0 || j < 0 || i >= board.size () || j >= board[0 ].size ()) return false ; if (board[i][j] == word[index]) { board[i][j] = 0 ; if (dfs (board, word, index + 1 , i, j + 1 ) || dfs (board, word, index + 1 , i + 1 , j) || dfs (board, word, index + 1 , i, j - 1 ) || dfs (board, word, index + 1 , i - 1 , j)) return true ; board[i][j] = word[index]; } return false ; } };
给你一根长度为 n 的绳子,请把绳子剪成整数长度的 m
段(m、n都是整数,n>1并且m>1),每段绳子的长度记为
k[0],k[1]...k[m] 。请问 k[0]*k[1]*...*k[m]
可能的最大乘积是多少?例如,当绳子的长度是8时,我们把它剪成长度分别为2、3、3的三段,此时得到的最大乘积是18。
方法一:动态规划
注意考虑余下的 i - j
长度段不再剪的情况,即取
i - j
和 dp[i-j]
的最大值。
1 2 3 4 5 6 7 8 9 10 11 12 13 class Solution {public : int cuttingRope (int n) { vector<int > dp (n + 1 ) ; dp[1 ] = 1 ; for (int i = 2 ; i <= n; i++) { for (int j = 1 ; j < i; j++) { dp[i] = max (dp[i], max (i - j, dp[i-j]) * j); } } return dp[n]; } };
方法二:贪心
当 n <= 3
时,由于题目要求至少剪一刀,返回
n - 1
。
当 n > 3
时,先尽可能多地剪出长度为 3
的片段,然后根据余下的长度分三种情况讨论:
余 0 :直接返回 \(3^n\) 。
余 1 :\(3+1=2+2\) ,即返回 \(3^{n-1} * 4\) 。
余 2 :返回 \(3^n * 2\) 。
(详细证明见《剑指Offer》或 Leetcode 题解)
1 2 3 4 5 6 7 8 9 10 11 12 class Solution {public : int cuttingRope (int n) { if (n <= 3 ) return n - 1 ; int q = n / 3 ; int r = n % 3 ; if (r == 0 ) return (int )pow (3 , q); if (r == 1 ) return (int )pow (3 , q - 1 ) * 4 ; return (int )pow (3 , q) * 2 ; } };
请实现一个函数,输入一个整数,输出该数二进制表示中 1 的个数。例如,把
9 表示成二进制是 1001,有 2 位是 1。因此,如果输入 9,则该函数输出
2。
方法一:有几位循环几次
左移 flag
而不是右移 n
,因为后者为负数时会导致死循环。
1 2 3 4 5 6 7 8 9 10 11 12 class Solution {public : int hammingWeight (uint32_t n) { int cnt = 0 ; unsigned int flag = 1 ; while (flag) { if (n & flag) cnt++; flag = flag << 1 ; } return cnt; } };
方法二:有几个 1 循环几次
把一个整数减去 1,再和原整数做与运算,会把该整数最右边的 1 变成
0。
1 2 3 4 5 6 7 8 9 10 11 class Solution {public : int hammingWeight (uint32_t n) { int cnt = 0 ; while (n) { cnt++; n = n & (n - 1 ); } return cnt; } };
实现函数double Power(double base, int
exponent),求base的exponent次方。不得使用库函数,同时不需要考虑大数问题。
题目看似简单却暗藏杀机,至少有以下几点需要注意:
注意指数为负时可能引发的除零错误(虽然 LeetCode
上没有这种测试用例)。
注意边界情况。本题 n
的范围是 \([-2^{31},2^{31}-1]\) ,当 n
恰好为 \(-2^{31}\) 时,其绝对值将超出
32 位整型的表示范围 \([-2^{31},2^{31}-1]\) ,因此需要转换为
long long
类型。
注意时间复杂度。朴素的累乘会超时,这里我们用快速幂算法,复杂度可降到
\(O(\log n)\) 。
此外,还可以在一些小细节上做优化,例如用与运算代替求余运算符、用右移运算代替除法运算符。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class Solution {public : double myPow (double x, int n) { double res = 1.0 ; long long N = n; if (N < 0 ) { N = -N; x = 1.0 / x; } while (N) { if (N & 1 ) res *= x; x *= x; N >>= 1 ; } return res; } };
请实现一个函数用来匹配包含'.'和'*'的正则表达式。模式中的字符'.'表示任意一个字符,而'*'表示它前面的字符可以出现任意次(含0次)。在本题中,匹配是指字符串的所有字符匹配整个模式。例如,字符串"aaa"与模式"a.a"和"ab*ac*a"匹配,但与"aa.a"和"ab*a"均不匹配。
方法一:递归
首先定义一下“字符匹配”:字符串中的字符和模式中的字符相同,或模式中的字符为'.'。
设置两个指针 i
和 j
分别指向字符串的当前字符和模式的当前字符,分情况讨论:
模式中的第二个字符不是'*'
如果字符串中的第一个字符和模式中的第一个字符相匹配,则 i
和 j
都向后移动一个字符,然后匹配剩余的字符串和模式;否则直接返回
false
。
模式中的第二个字符是'*'
如果字符串中的第一个字符和模式中的第一个字符相匹配,则有三种情况:
'*'匹配字符串中的 0 个字符
仅在模式上向后移动两个字符,而字符串不动,相当于'*'和它前面的字符直接被忽略;
'*'匹配字符串中的 1 个字符
在字符串上向后移动一个字符,并在模式上向后移动两个字符,表明'*'已匹配结束;
'*'匹配字符串中的多个字符
仅在字符串上向后移动一个字符,而模式不动,表明'*'还要继续匹配;
否则只可能是'*'匹配字符串中的 0
个字符的情况,忽略'*'和它前面的字符,继续向后匹配。
注意:递归解法的效率很低,字符串传参的时候必须传引用,否则就会超时。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 class Solution {public : bool isMatch (string s, string p) { return _isMatch(s, p, 0 , 0 ); } bool _isMatch(string &s, string &p, int i, int j) { if (i == s.size () && j == p.size ()) return true ; if (i < s.size () && j == p.size ()) return false ; if (j + 1 < p.size () && p[j+1 ] == '*' ) { if (i < s.size () && (s[i] == p[j] || p[j] == '.' )) { return _isMatch(s, p, i, j + 2 ) || _isMatch(s, p, i + 1 , j + 2 ) || _isMatch(s, p, i + 1 , j); } else { return _isMatch(s, p, i, j + 2 ); } } if (i < s.size () && (s[i] == p[j] || p[j] == '.' )) return _isMatch(s, p, i + 1 , j + 1 ); return false ; } };
方法二:动态规划
定义状态 dp[i][j]
为 s
的前 i
个字符 s[0..i-1]
是否匹配 p
的前
j
个字符 p[0..j-1]
。
根据 s[i-1]
和 p[j-1]
的关系分类讨论:
s[i-1]
和 p[j-1]
匹配(同方法一中字符匹配的定义)
只要看 s
的前 i-1
个字符是否匹配
p
的前 j-1
个字符,即
dp[i][j] = dp[i-1][j-1]
;
p[j-1]
是'*'
考虑以下两种情况的或运算,即两者中成立一个就匹配:
p[j-1]
是正常字符却和 s[i-1]
不相同
只能是 dp[i][j] = false
(代码中省略了,因为初值默认就是
false
)。
此外,还要尤其注意一下 dp
数组的初始化:
dp[0][0] = true
,表示空字符串和空模式是匹配的;
dp[1..n][0] = false
,表示非空字符串和空模式是不匹配的;
dp[0][1..m]
需要讨论,空字符串是有可能与非空模式相匹配的,因为'*'包括匹配 0
次的情况:
如果模式的当前字符 p[j-1]
是'*',且模式向前两个字符的状态是匹配的,即dp[0][j-2] = true
,则 dp[0][j] = true
;
否则 dp[0][j] = false
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 class Solution {public : bool isMatch (string s, string p) { int n = s.size (), m = p.size (); vector<vector<bool >> dp (n + 1 , vector <bool >(m + 1 )); dp[0 ][0 ] = true ; for (int j = 2 ; j <= m; j++) { if (p[j-1 ] == '*' && dp[0 ][j-2 ]) dp[0 ][j] = true ; } for (int i = 1 ; i <= n; i++) { for (int j = 1 ; j <= m; j++) { if (s[i-1 ] == p[j-1 ] || p[j-1 ] == '.' ) { dp[i][j] = dp[i-1 ][j-1 ]; } else if (p[j-1 ] == '*' ) { dp[i][j] = dp[i][j-2 ] || ((s[i-1 ] == p[j-2 ] || p[j-2 ] == '.' ) && dp[i-1 ][j]); } } } return dp[n][m]; } };
请实现一个函数,用来判断一棵二叉树是不是对称的。如果一棵二叉树和它的镜像一样,那么它是对称的。
如果前序遍历和其对称遍历的序列一样,则二叉树是对称的。
注意这两个方向的遍历是可以同时进行并及时判别的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 class Solution {public : bool isSymmetric (TreeNode* root) { if (root == nullptr ) return true ; return _isSymmetric(root->left, root->right); } bool _isSymmetric(TreeNode* root1, TreeNode* root2) { if (root1 == nullptr && root2 == nullptr ) return true ; if (root1 == nullptr || root2 == nullptr ) return false ; if (root1->val != root2->val) return false ; return _isSymmetric(root1->left, root2->right) && _isSymmetric(root1->right, root2->left); } };
请实现 copyRandomList
函数,复制一个复杂链表。在复杂链表中,每个节点除了有一个
next
指针指向下一个节点,还有一个 random
指针指向链表中的任意节点或者 null
。
分三步走:
沿着 next
复制每一个节点,并将复制的节点接在对应的原节点后面。
从头遍历链表,将每个复制节点的 random
设为原节点的
random
的下一个节点。
将链表拆分成原链表和复制链表。
这种方法既能达到 \(O(n)\)
的时间复杂度,也没有使用辅助空间。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 class Solution {public : Node* copyRandomList (Node* head) { if (head == nullptr ) return nullptr ; Node* curr = head; while (curr != nullptr ) { Node* copy = new Node (curr->val); copy->next = curr->next; curr->next = copy; curr = copy->next; } curr = head; while (curr != nullptr ) { Node* copy = curr->next; if (curr->random != nullptr ) copy->random = curr->random->next; curr = curr->next->next; } Node* head_copy = head->next; curr = head; while (curr != nullptr ) { Node* copy = curr->next; curr->next = copy->next; if (copy->next != nullptr ) copy->next = copy->next->next; curr = curr->next; } return head_copy; } };
输入一棵二叉搜索树,将该二叉搜索树转换成一个排序的循环双向链表。要求不能创建任何新的节点,只能调整树中节点指针的指向。
基于递归的中序遍历,head
表示整个双向链表的首节点,pre
表示上一个根节点。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 class Solution {private : Node* head = nullptr ; Node* pre = nullptr ; public : Node* treeToDoublyList (Node* root) { if (!root) return root; inorder (root); head->left = pre; pre->right = head; return head; } void inorder (Node* root) { if (root->left) inorder (root->left); root->left = pre; if (pre) pre->right = root; else head = root; pre = root; if (root->right) inorder (root->right); } };
输入一个字符串,打印出该字符串中字符的所有排列。
你可以以任意顺序返回这个字符串数组,但里面不能有重复元素。
方法一:追加
从空字符串开始不断向末尾追加字符。
通过先对输入字符串进行排序,然后每次循环中判断前一个字符是否访问过且和当前字符相同,从而避免因存在重复字符而生成重复的排列。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 class Solution {public : vector<string> permutation (string s) { vector<string> res; vector<bool > visited (s.size()) ; string t; sort (s.begin (), s.end ()); dfs (res, visited, s, t); return res; } void dfs (vector<string>& res, vector<bool >& visited, string& s, string& t) { if (t.size () == s.size ()) { res.push_back (t); return ; } for (int i = 0 ; i < s.size (); i++) { if (visited[i]) continue ; if (i > 0 && s[i-1 ] == s[i] && visited[i-1 ]) continue ; visited[i] = true ; t.push_back (s[i]); dfs (res, visited, s, t); t.pop_back (); visited[i] = false ; } } };
方法二:交换
直接在输入字符串上两两字符做交换。
通过判断待交换元素之间是否有与当前元素相同的元素,从而实现对重复情况的剪枝。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 class Solution {public : vector<string> permutation (string s) { vector<string> res; dfs (res, s, 0 ); return res; } void dfs (vector<string>& res, string& s, int start) { if (start == s.size () - 1 ) { res.push_back (s); return ; } for (int i = start; i < s.size (); i++) { if (duplicate (s, start, i)) continue ; swap (s[start], s[i]); dfs (res, s, start + 1 ); swap (s[start], s[i]); } } bool duplicate (string& s, int start, int i) { for (int j = start; j < i; j++) { if (s[j] == s[i]) return true ; } return false ; } };