欢迎访问我的博客首页。
面试中的各项能力
1. 知识迁移能力
1.1 在排序数组中查找数字
题目一:统计一个数字在排序数组中出现的次数。例如输入排好序的数组 {1,2,3,3,3,3,4,5} 和数字 3,输出 4,因为数字 3 在数组中出现了 3 次。
分析:因为数组有序,为了时间效率可以使用二分查找找到所给数字的下标,然后在下标左右统计周围还有没有这个数字。下面的程序假设数组从小到大有序:
int binarySearch(vector<int>& nums, int start, int end, int target) {
if (start > end)
return -1;
int middle = (start + end) / 2;
if (nums[middle] == target)
return middle;
if (nums[middle] < target)
return binarySearch(nums, middle + 1, end, target);
return binarySearch(nums, start, middle - 1, target);
}
int search(vector<int>& nums, int target) {
if (nums.size() == 0)
return 0;
int index = binarySearch(nums, 0, nums.size() - 1, target);
if (index == -1)
return 0;
int res = 0;
for (int i = index - 1; i >= 0 && nums[i] == target; i--)
res++;
for (int i = index + 1; i < nums.size() && nums[i] == target; i++)
res++;
return res + 1;
}
代码:虽然二分查找,找到下标的时间复杂度是 O ( l o g 2 n ) O(log_2n) O(log2n),但找到下标后,在附近搜索的时间复杂度仍然是 O(n)。
int binarySearch_first(vector<int>& nums, int start, int end, int target) {
if (start > end)
return -1;
int middle = (start + end) / 2;
if (nums[middle] == target) {
if (middle == start || (middle > start && nums[middle - 1] < nums[middle]))
return middle;
return binarySearch_first(nums, start, middle - 1, target);
}
if (nums[middle] < target)
return binarySearch_first(nums, middle + 1, end, target);
return binarySearch_first(nums, start, middle - 1, target);
}
int binarySearch_last(vector<int>& nums, int start, int end, int target) {
if (start > end)
return -1;
int middle = (start + end) / 2;
if (nums[middle] == target) {
if (middle == end || (middle < end && nums[middle + 1] > nums[middle]))
return middle;
return binarySearch_last(nums, middle + 1, end, target);
}
if (nums[middle] < target)
return binarySearch_last(nums, middle + 1, end, target);
return binarySearch_last(nums, start, middle - 1, target);
}
int search(vector<int>& nums, int target) {
if (nums.size() == 0)
return 0;
int first = binarySearch_first(nums, 0, nums.size() - 1, target);
if (first == -1)
return 0;
int last = binarySearch_last(nums, 0, nums.size() - 1, target);
return last - first + 1;
}
代码:这个代码使用两个二分查找分别查找左右边界,充分利用了二分查找。
题目二:在长度为 n 的递增数组中有范围 0 ~n 的数,每个数最多有 1 个,也就是说在数组中少了 0 ~n 的某一个数,请找到这个数。
分析:这个题和上个题类似,找第一个 arr[i] > i (确切地说是 arr[i]=i-1)的下标。
int binarySearch(vector<int>& nums, int start, int end) {
if (start > end)
return -1;
int middle = (start + end) / 2;
if (nums[middle] > middle) {
if (middle == start)
return middle;
return binarySearch(nums, start, middle - 1);
}
if (middle == end)
return end + 1;
return binarySearch(nums, middle + 1, end);
}
int missingNumber(vector<int>& nums) {
if (nums.size() == 0)
return -1;
return binarySearch(nums, 0, nums.size() - 1);
}
代码:注意不要忘了考虑第 10 行的情况。这时所有的 i 都满足 arr[i] = i。
题目三:在递增的数组中找出任意一个数值等于其下标的元素。例如数组是 {-3,-1,1,3,5},数字 3 和它的下标相等。
int binarySearch(vector<int>& nums, int start, int end) {
if (start > end)
return -1;
int middle = (start + end) / 2;
if (nums[middle] == middle)
return middle;
if (nums[middle] < middle) {
if (nums[end] < end)
return -1;
return binarySearch(nums, middle + 1, end);
}
if (nums[start] > start)
return -1;
return binarySearch(nums, start, middle - 1);
}
int getNumberSameAsIndex(vector<int>& nums) {
if (nums.size() == 0)
return -1;
return binarySearch(nums, 0, nums.size() - 1);
}
1.2 二叉搜索树第 k 大结点
题目:给定一颗二叉树,请找出其中第 k 大的结点的值。如图第三大的结点值是 4。
分析:二叉搜索树的中序遍历序列是有序的,使用中序遍历即可。
1.3 二叉树的深度
题目一:输入一颗二叉树的根结点,输出二叉树的深度,即层数。
分析:可以使用层序遍历算法(和求左视图、求右视图算法相同),或者求左右视图的递归算法。
1. 二叉树的层序遍历
int level_traverse(TreeNode* tree) {
if (tree == nullptr)
return 0;
int res = 0, residue = 1, next = 0;
queue<TreeNode*> qu;
qu.push(tree);
while (qu.empty() != true) {
TreeNode* temp = qu.front();
qu.pop();
residue--;
if (temp->left != nullptr) {
qu.push(temp->left);
next++;
}
if (temp->right != nullptr) {
qu.push(temp->right);
next++;
}
if (residue == 0) {
residue = next;
next = 0;
res++;
}
}
return res;
}
int maxDepth(TreeNode* root) {
if (root == nullptr)
return 0;
return level_traverse(root);
}
2. 二叉树左右视图算法
void lrView(TreeNode* tree, int& sharedLevel, int level = 1) {
if (tree == nullptr)
return;
if (sharedLevel < level)
sharedLevel = level;
lrView(tree->left, sharedLevel, level + 1);
lrView(tree->right, sharedLevel, level + 1);
}
int maxDepth(TreeNode* root) {
if (root == nullptr)
return 0;
int res = 1;
lrView(root, res);
return res;
}
代码:这个算法类似求二叉树左视图的递归算法。值传递的 level 记录的是结点 tree 的层数。引用传递的 sharedLevel 记录的是递归过程中 level 的最大值。
3. 普通的递归遍历算法
int getDepth(TreeNode* tree) {
if (tree == nullptr)
return 0;
int left = getDepth(tree->left);
int right = getDepth(tree->right);
return left > right ? left + 1 : right + 1;
}
int maxDepth(TreeNode* root) {
if (root == nullptr)
return 0;
return getDepth(root);
}
代码:这个算法比较精简,而且通过返回值得到结果。为了理解这个算法,不妨以链表为例,下面的递归算法分别计算链表长度、逆序打印链表:
int countLenOfList(ListNode* list) {
if (list == nullptr)
return 0;
return countLenOfList(list->next) + 1;
}
void reversePrint(ListNode* list) {
if (list == nullptr)
return;
reversePrint(list->next);
cout << list->data << " ";
}
题目二:如果某二叉树中任意结点的左右子树深度相差不超过 1,则它是平衡二叉树。请根据一颗二叉树根结点判断它是不是平衡二叉树。
分析:方法之一是遇到一个结点就使用题目一中的函数 getDepth 获取深度。这种方法中,下层的结点会被遍历多次,效率不高。下面介绍每个结点只被遍历一次的方法:
int isBalancedBinaryTree(TreeNode* root, bool& res) {
if (root == nullptr)
return 0;
int left = isBalancedBinaryTree(root->left, res);
int right = isBalancedBinaryTree(root->right, res);
if (abs(left - right) > 1)
res = false;
return left > right ? left + 1 : right + 1;
}
bool isBalanced(TreeNode* root) {
if (root == nullptr)
return true;
bool res = true;
isBalancedBinaryTree(root, res);
return res;
}
代码:下面这种写法和上面的代码类似:
bool isBalancedBinaryTree(TreeNode* root, int& depth) {
if (root == nullptr) {
depth = 0;
return true;
}
int left, right;
bool check_left = isBalancedBinaryTree(root->left, left);
bool check_right = isBalancedBinaryTree(root->right, right);
if (abs(left - right) <= 1 && check_left && check_right) {
depth = left > right ? left + 1 : right + 1;
return true;
}
return false;
}
bool isBalanced(TreeNode* root) {
if (root == nullptr)
return true;
int depth = 1;
return isBalancedBinaryTree(root, depth);
}
1.4 数组中数字出现的次数
题目一:一个整型数组有两个元素的出现次数是 1,其它元素的出现次数都是 2。请使用时间复杂度 O(n) 空间复杂度 O(1) 的算法找出这两个数。
分析:假如一个整型数组中只有一个元素出现次数为单数,其它元素出现次数都是双数。使用一个初始值为 0 的元素 res 依次与数组的每个元素异或(^),最后 res 的值就是出现次数为单数的元素的值。因此,如果把题干中的整型数组分为两部分,两个出现次数为单数的元素(假设是元素 a 和元素 b)在不同部分中,其它出现次数为 2 的元素,要么两个都在第一部分,要么两个都在第二部分。这样问题就好解决了。
因为元素 a 和元素 b 不相等,所以它们异或的结果 c 不为 0。也就是说 c 中至少存在一个值为 1 的比特位,假设这个比特位是从左到右第 x 位,那么 a 和 b 的第 x 位必定是一个为 1,另一个为 0。我们可以根据第 x 个比特位把数组的元素分为两部分,然后从每一部分找出唯一一个出现次数为 1 的元素。
int findFirstBitOne(int num) {
int index = 0;
while((num&1) == 0 && index < sizeof(int) * 8) {
num = num >> 1;
index++;
}
return index;
}
bool theBitIsOneOrNot(int num, int index) {
return (num >> index) & 1;
}
vector<int> singleNumbers(vector<int>& nums) {
int res1_xor_res2 = 0;
for (auto x: nums)
res1_xor_res2 ^= x;
int firstBitOne = findFirstBitOne(res1_xor_res2);
int res1 = 0, res2 = 0;
for (auto x: nums) {
if (theBitIsOneOrNot(x, firstBitOne))
res1 ^= x;
else
res2 ^= x;
}
return { res1, res2 };
}
函数 findFirstBitOne(num) 在一个十进制数 num 的二进制形式中,从右向左找出第一个 1 出现的下标(从 0 开始)。比如 12 的二进制形式是 1100,函数返回 2。
函数 theBitIsOneOrNot(num, index) 判断十进制数 num 的二进制形式中从右向左下标为 index 的比特位是不是 1(下标从 0 开始)。
函数 singleNumbers 是主要函数。res1_xor_res2 是数组中仅出现 1 次的两个数的异或结果。第 21 行根据比特位 index 是不是 1 把数组元素分为两类。
题目二:整型数组中只有一个元素出现一次,其它元素都出现 3 次。请找出这个元素。
分析:我们可以让所有元素的二进制比特位对应相加。用数组 arr 存放相加的结果,数组的每个元素 arr[i] 是原数组所有元素第 i 个二进制比特位相加的和。然后把 arr 每个元素对 3 取余,保留余数,把余数转换成十进制。
int singleNumber(vector<int>& nums) {
const int intbit = sizeof(int) * 8;
int arr[intbit];
memset(arr, 0, sizeof(int) * intbit);
for (auto x: nums) {
int index = 0;
auto binary = bitset<intbit>(x);
for (int i = 0; i < binary.size(); i++)
arr[i] = (arr[i] + binary[i]) % 3;
}
string num;
for (int i = intbit - 1; i >= 0; i--)
num.push_back(arr[i] + '0');
bitset<intbit> res(num);
return res.to_ulong();
}
代码:上面的代码,我们使用 bitset 实现二进制与十进制的转换。当然我们也可以自己写函数实现这样的转换。数组 arr 和 binary 一样,下标 0 处存放的是最低位的二进制。第 7 行的 binary 存放的是十进制数的二进制序列。第 14、15 把字符串形式的二进制数转换成数字形式。
总结:上面两题考察位运算,我们的解法时间复杂度都是 O(n),空间复杂度都是 O(1)。如果使用排序,时间复杂度最低是
O
(
n
l
o
g
2
n
)
O(nlog_2n)
O(nlog2n),空间复杂度是 O(1)。如果使用哈希表,时间复杂度是 O(n),空间复杂度也是 O(n)。
1.5 和为 s 的数字
题目一:输入一个递增排序的数组和一个数字 s,在数组中找出两个和为 s 的数并输出它们。如果有多对和为 s 的数,输出一对即可。
分析:如果数组无序,可以使用哈希表。既然数组有序,可以使用二分查找:遍历到元素 x 时在它后面查找有没有 s-x,时间复杂度是
O
(
n
l
o
g
2
n
)
O(nlog_2n)
O(nlog2n)。如果使用双指针法,可以把时间复杂度降低到 O(n):令 start=0, end=n-1,如果 arr[start]+arr[end]>s,end–;如果 arr[start] + arr[end] < s,start++;如果 arr[start] + arr[end] = s,得到 arr[start] 和 arr[end]。
vector<int> twoSum(vector<int>& nums, int target) {
if (nums.size() < 2)
return{};
int start = 0, end = nums.size() - 1;
while (start < end) {
int sum = nums[start] + nums[end];
if (sum == target)
return{ nums[start], nums[end] };
if (sum < target)
start++;
else
end--;
}
return{};
}
题目二:输入一个正数 s,打印出所有和为 s 的公差为 1 的正数序列。例如 s = 9 时,正数序列有 {2, 3, 4} 和 {4, 5}。
分析:首先我们把 1 当作等差数列的首项
a
1
a_1
a1。既然是公差为 1 且前 n 项和 s 已知,那么根据等差数列前 n 项和的公式,我们能计算出
a
n
a_n
an:
a
n
=
−
1
2
+
a
1
2
−
a
1
+
2
s
+
1
4
.
a_n = - \frac{1}{2} + \sqrt{a_1^2 - a_1 + 2s + \frac{1}{4}}.
an=−21+a12−a1+2s+41. 当计算出的
a
n
a_n
an 是整数时,才满足要求。
我们不需要把从 1 到 s 的每个数都当成
a
1
a_1
a1 去计算
a
n
a_n
an,当
a
1
+
(
a
1
+
1
)
>
s
a_1 + (a_1 + 1) > s
a1+(a1+1)>s 时就可以结束循环了。所以循环到大概 n/2 就可以了,时间复杂度是 O(n)。
int getEnd(long long start, int target) {
double temp = -1.0 / 2 + sqrt(start * start - start + 2 * target + 1.0 / 4);
int end = ceil(temp);
if (temp - end != 0)
return -1;
return end;
}
vector<int> start2end(int start, int end) {
vector<int> res;
for (int i = start; i <= end; i++)
res.push_back(i);
return res;
}
vector<vector<int>> findContinuousSequence(int target) {
if (target < 2)
return{};
vector<vector<int>> res;
for (int start = 1; 2 * start + 1 <= target; start++) {
int end = getEnd(start, target);
if (end != -1)
res.push_back(start2end(start, end));
}
return res;
}
代码:注意函数 getEnd 的形参 start 的类型范围应该较大,否则第 2 行求 start 的平方可能会溢出。第 2 行的常数相除,记得使用小数,否则 1/2 = 0,1/4 = 0。
还可以像题目一那样,使用双指针找等差数列的
a
1
a_1
a1 和
a
n
a_n
an。
1.6 翻转字符串
题目一:翻转单词顺序。输入一个英文句子,翻转单词的顺序,但单词内字符的顺序不变。为简单起见,标点符号当成普通字母处理。比如 “I am a student.” 翻转后是 “student. a am I”。
题目二:左旋转字符串。输入一个字符串 s 和一个数字 n,把 s 的前 n 个字符移到最后。如 s=“abcdefg”,a=2,左旋转后是 “cdefgab”。
1.7 队列的最大值
题目一:滑动窗口的最大值。输入一个字符串和一个表示滑动窗口大小的正整数 x,输出所有滑动窗口的最大值。
1. 使用带 max 函数的栈实现队列
分析:前面我们实现了带有 max 函数的栈,可以用 O(1) 的时间复杂度获取栈内最大值,前面还用栈实现了队列。所以我们可以用带有 max 函数的栈实现一个队列,窗口每滑动一次,我们调用 max 函数就可以了。
template<typename T>
class myqueue {
public:
myqueue(int x = 1) :windows_size(x) {}
T slid(T x) {
push(x);
if (size() > windows_size)
pop();
return max();
}
void push(T x) {
st_back.push(x);
if (st_back_max.empty() == true || st_back_max.top() < x)
st_back_max.push(x);
else
st_back_max.push(st_back_max.top());
}
T front() {
if (empty())
throw logic_error("Queue is empty.");
if (st_front.empty() == true)
move();
return st_front.top();
}
void pop() {
if (empty())
throw logic_error("Queue is empty.");
if (st_front.empty() == true)
move();
st_front.pop();
st_front_max.pop();
}
T max() {
if (empty())
throw logic_error("Queue is empty.");
if (st_front.empty() == true)
return st_back_max.top();
if (st_back.empty() == true)
return st_front_max.top();
return st_back_max.top() > st_front_max.top() ? st_back_max.top() : st_front_max.top();
}
int size() {
return st_front.size() + st_back.size();
}
bool empty() {
return st_front.empty() && st_back.empty();
}
private:
void move() {
while (st_back.empty() != true) {
T temp = st_back.top();
st_front.push(temp);
if (st_front_max.empty() == true || temp > st_front_max.top())
st_front_max.push(temp);
else {
T max = st_front_max.top() > temp ? st_front_max.top() : temp;
st_front_max.push(max);
}
st_back.pop();
st_back_max.pop();
}
}
int windows_size;
stack<T> st_front, st_back, st_front_max, st_back_max;
};
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
myqueue<int> mqu(k);
vector<int> res;
for (auto x: nums) {
int max = mqu.slid(x);
if(mqu.size() == k)
res.push_back(max);
}
return res;
}
代码:st_front 的栈顶是队首,st_back 的栈顶是队尾。当 st_front 为空时,获取队首元素、出队列前都要把 st_back 的元素转移到 st_front,所以 front() 和 pop() 的时间复杂度是 O(k)。
性能:使用两个栈,这两个栈最多共装 k 个元素。出队列或者获取队首元素时需要调用 move 函数,时间复杂度为 O(k),处理 n 个元素的时间是 O(kn)。所以时间复杂度和空间复杂度分别为 O(kn)、O(k)。
2. 定义单调队列
如果先用栈实现队列,再用队列做这个题,相当于用两个题的代价做了一个题,面试的时候一般不会出这样的题。对于这种求滑动窗口最值的问题,有一种专门的数据结构叫做单调队列。所谓单调队列,就是队列中的元素从队尾到队首有序。下面实现从队尾到队首递增的单调递增队列,单调递增队列用于求滑动窗口的最大值。
template<typename T>
class monotoneQueue {
public:
monotoneQueue(int x = 1): window_size(x), cur_idx(0) {}
T slide(T x) {
while (deq_monotone.empty() != true && x >= deq_monotone.back()) {
deq_monotone.pop_back();
deq_idx.pop_back();
}
deq_monotone.push_back(x);
deq_idx.push_back(cur_idx++);
if (deq_idx.back() - deq_idx.front() + 1 > window_size) {
deq_monotone.pop_front();
deq_idx.pop_front();
}
return deq_monotone.front();
}
private:
int window_size, cur_idx;
deque<T> deq_monotone;
deque<int> deq_idx;
};
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
monotoneQueue<int> mqu(k);
vector<int> res;
int idx = 0;
for (auto x: nums) {
int max = mqu.slide(x);
idx++;
if(idx >= k)
res.push_back(max);
}
return res;
}
代码:单调队列的实现很简单,只有两步。第 1 步插入元素 x:从队尾开始,把小于等于 x 的元素全部从队尾移除,然后 x 进入队尾。第 2 步删除过期元素:如果刚进队尾的元素 x 与队首的元素在原序列中的下标差加上 1 大于滑动窗口大小,移除队首元素。
为了实现第 2 步,我们使用了另一个双端队列 deq_idx,它的大小与单调队列 deq_monotone 始终一致,存放的是单调队列的元素在原序列中的下标。第 6 行使用大于等于的原因:当待插入元素 x 与队尾元素相等时,在第 8、11 行更新下标。
理解:单调队列也很好理解。在单调递增队列中,值越大的元素本领越大。当元素 x 来到队尾时,它从队尾把本领小于等于它的元素都赶走,然后排在队尾。此时 x 前面要么没有元素,要么都比它大。然后,如果队列中的元素个数超过窗口大小,排在队首的元素要离开。
性能:使用两个双端队列,每个队列最多装 k 个元素。所以时间复杂度和空间复杂度分别是 O(n)、O(2k)。
3. 利用单调队列的思想
上面第 2 种方法先定义了一个单调队列,然后使用这个定义的数据结构解题。单调队列是一个通用的数据结构,可以解决一系列类似的题。但是我们注意到 deq_monotone 和 deq_idx 是耦合的,即 deq_monotone[i] = nums[deq_idx[i] ](双端队列可以按下标访问)。如果我们在 slide 中能访问到 nums,则只需要 deq_idx 就行了。注意,使用 deq_monotone 和 deq_idx 可以处理数据流。
为什么 deq_idx 是必须的?因为我们要及时清除已经从窗口滑出的元素。比如窗口大小是 3:
- 第 1 次滑入 0 且 nums[0]=9 则 deq_idx=[0]。
- 第 2 次滑入 1 且 nums[1]=5 则 deq_idx=[0, 1]。
- 第 3 次滑入 2 且 nums[2]=7 则 deq_idx=[0, 2]。
- 第 4 次滑入 3 且 nums[3]=4 则 deq_idx 不是 [0, 2, 3] 而是 [2, 3]。因为 3 - 0 + 1 > 3,第 1 次滑入的元素已经滑出窗口了,它应该被移出 deq_idx。
下面就是只使用 deq_idx(idxs) 的方法:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
if (nums.size() < k || k < 1)
return{};
vector<int> res;
deque<int> idxs;
for (int i = 0; i < nums.size(); i++) {
while (idxs.empty() != true && nums[i] >= nums[idxs.back()])
idxs.pop_back();
idxs.push_back(i);
if (idxs.back() - idxs.front() + 1 > k)
idxs.pop_front();
if (i + 1 >= k)
res.push_back(nums[idxs.front()]);
}
return res;
}
性能:时间复杂度和空间复杂度分别是 O(n)、O(k)。
题目二:请定义一个队列,并实现函数 max 得到队列中的最大值,要求 push_back、pop_front、max 的时间复杂度都是 O(1)。
1. 使用带 max 函数的栈实现队列
性能:push 和 max 的时间复杂度是 O(1),front 可能需要调用 move 函数,时间复杂度是 O(n)。
2. 使用单调队列
分析:由题目一我们很容易想到使用单调队列解这个题。问题的关键是,data 队首 max 出站时,单调队列 deq_monotone 的队首 max 要不要跟着出栈。比如入队序列 data = {1, 2, 2, 1} 则 deq_monotone = {1, 2},大括号右侧是队首。当 data 的 2 出栈时,deq_monotone 的 2 要不要出栈呢?答案是:如果单调队列 deq_monotone 队首 max 就是队列 data 队首 max,那么单调队列要跟着出栈,否则单调队列不出栈。因为 deq_monotone 的 2 是 data 中从右往左数第二个 2,所以 data 第二个 2 出栈时 deq_monotone 的 2 才出栈。
为了判断两个队列的队首 max 是不是同一个元素,我们给每个元素附加一个 ID,这个ID 可以用入队序号。
class MaxQueue {
public:
MaxQueue() : count(0) {}
int max_value() {
if (data.empty() == true)
// throw logic_error("Queue is empty!");
return -1;
return deq_monotone.front().value;
}
void push_back(int value) {
data.push(item(value, count));
while (deq_monotone.empty() != true && deq_monotone.back().value <= value)
deq_monotone.pop_back();
deq_monotone.push_back(item(value, count));
count++;
}
int pop_front() {
if (data.empty() == true)
// throw logic_error("Queue is empty!");
return -1;
item front = data.front();
data.pop();
if (deq_monotone.front().index == front.index)
deq_monotone.pop_front();
return front.value;
}
private:
struct item {
item(int val = 0, int idx = 0) : value(val), index(idx) {}
int value, index;
};
int count;
queue<item> data;
deque<item> deq_monotone;
};
3. 使用改进的单调队列
为了给 data 和 deq_monotone 的元素添加 ID,我们使用了 2n 的额外空间。这个 ID 的作用只是用于判断 data 的队首 max 出队列时,单调队列 deq_monotone 的队首 max 要不要出队列。原因是data 中可能有多个 max,而 deq_monotone 中只有一个 max。所以,如果把 data 中的所有 max 同时也放入 deq_monotone,当发现它们队首相等时,就可以果断把 deq_monotone 的队首出栈。
class MaxQueue {
public:
MaxQueue() {}
int max_value() {
if (data.empty() == true)
return -1;
return deq_monotone.front();
}
void push_back(int value) {
data.push(value);
while (deq_monotone.empty() != true && deq_monotone.back() < value)
deq_monotone.pop_back();
deq_monotone.push_back(value);
}
int pop_front() {
if (data.empty() == true)
return -1;
int front = data.front();
data.pop();
if (deq_monotone.front() == front)
deq_monotone.pop_front();
return front;
}
private:
queue<int> data;
deque<int> deq_monotone;
};
代码:只需在第 11 行使用小于号而不是小于等于号就可以了。
2. 抽象建模能力
2.1 n 个骰子的点数
1. 递归
相比全排列算法,该递归算法不是保存每次骰子的点数,而是计算它们的和。map<a, b> 的意义是全排列中和为 a 的排列的数量是 b。
void permutation(int n, map<int, int>& mp, int sum = 0, int index = 0) {
if (index == n) {
mp[sum]++;
return;
}
for (int i = 1; i <= 6; i++)
permutation(n, mp, sum + i, index + 1);
}
2. 动态规划
vector<int> fac(const int n) {
vector<vector<int>> dp(n + 1, vector<int>(6 * n + 1));
for (int i = 1; i <= 6; i++)
dp[1][i] = 1;
for (int i = 2; i <= n; i++)
for (int j = i; j <= 6 * i; j++)
for (int k = 1; k <= 6; k++)
if (k < j)
dp[i][j] += dp[i - 1][j - k];
vector<int> res(dp[n].begin() + n, dp[n].end());
return res;
}
3. 更好的算法
2.2 扑克牌中的顺子
题目:从扑克牌中抽出 5 张牌,判断它们能否组成一个顺子,即这 5 张牌的大小是不是连续的。其中 2~10 代表数字本身,A、J、Q、K 分别代表 1、11、12、13。大小王用数字 0 表示。规定 A 不能当做 14 用。
分析:首先排序,然后统计大小王的个数,接着统计非大小王扑克间的间隙。如果大小王能填充这些间隙,则是对子。间隙:当 a、b 在排好序的序列中相邻且 b 大于 a 时,a 与 b 的间隙 gap = b - a - 1。
bool isStraight(vector<int>& nums) {
if (nums.size() < 5)
return false;
sort(nums.begin(), nums.end());
int kingnum = 0;
int i = 0;
for (; i < nums.size(); i++) {
if (nums[i] == 0)
kingnum++;
else
break;
}
i++;
int gap = 0;
for (; i < 5; i++) {
if (nums[i] == nums[i - 1])
return false;
gap += nums[i] - nums[i - 1] - 1;
}
if (gap <= kingnum)
return true;
return false;
}
代码:排序算法直接调用 sort 函数,当然因为范围已知也可以使用计数排序。因为只有 5 个数据,而计数排序需要 15 个额外空间,所以没有必要纠结排序算法。
统计非大小王的间隙时,如果遇到相邻扑克牌相等,则出现对子就不是顺子。然后,只要大小王的总数大于等于 gap 就是顺子。
2.3 圆圈中最后剩下的数字
题目:0 到 n-1 这 n 个数排成一圈。从 0 开始每次删除第 m 个数字,求圆圈中剩下的最后一个数字。
1. 模拟圆环
分析:创建长度为 n 的数组 arr 并赋值 0 到 n-1,删除的元素赋值 -1。用 remain 记录剩余元素个数。使用 loc 不停地从头到尾遍历数组。计数器 count 遇到大于等于 0 的数就加 1,此时如果 remain 等于 1 就返回 arr[loc%n]。count 等于 m 时令 arr[loc % n] = -1 表示删除,同时 count 置 0,remain 减 1。
性能:因为 loc 走 m 步才删除一个元素,所以时间复杂度是 O(mn),空间复杂度是 O(n)。
int lastRemaining(int n, int m) {
int* arr = new int[n];
for (int i = 0; i < n; i++)
arr[i] = i;
int loc = 0, count = 0, remain = n;
while (true) {
if (arr[loc] != -1) {
if (remain == 1)
return arr[loc];
count++;
if (count == m) {
arr[loc] = -1;
remain--;
count = 0;
}
}
loc = (loc + 1) % n;
}
}
2. 使用 list
上面的方法用 -1 表示元素被删除。遍历元素时需要同时使用 loc 和 count,如果我们真的删除元素,就会简单一点。
int lastRemaining(int n, int m) {
if (n < 1 || m < 1)
return -1;
list<int> ls;
for (int i = 0; i < n; i++)
ls.push_back(i);
int count = 1;
list<int>::iterator previous, it = ls.begin();
while (ls.size() > 1) {
if (count == m) {
previous = --it;
it++;
ls.erase(it);
it = previous;
count = 0;
}
count++;
it++;
if (it == ls.end())
it = ls.begin();
}
return *ls.begin();
}
为了避免迭代器失效,我们使用 previous 指向 it 前一个元素,删除 it 后把 previous 赋值给 it。之所以指向前一个,是因为第 18 行还要 it++。第 11 行只能是 --it 而不能是 it–。
3. 更好的方法
2.4 股票的最大利润
假设某股票的价格按照时间先后顺序存储在数组中,请问买卖该股票一次最多能赚多少钱。比如股票价格是 {9, 11, 8, 5, 7, 12, 16, 14},则在价格是 5 时买 16 时卖能获得最大利润 11。
分析:把数组相邻元素相减得到一个新数组,可以发现本题是求最大子串和。其实这一题比最大子串和问题容易,因为结果是大于等于 0 的。但为了一般性,我们还是按照最大子串和问题解决。
int maxProfit(vector<int>& prices) {
if (prices.size() <= 1)
return 0;
int resMax = prices[1] - prices[0], tempMax = prices[1] - prices[0];
for (int i = 2; i < prices.size(); i++) {
tempMax = max(prices[i] - prices[i - 1], tempMax + prices[i] - prices[i - 1]);
resMax = max(resMax, tempMax);
}
return max(0, resMax);
}
代码:我们没有创建新数组,而是直接使用原数组相邻元素的差。
3. 发散思维能力
3.1 求 1 + 2 + … + n
题目:输入正整数 n,请输出 1 + 2 + … + n 的结果。要求不能使用乘法、除法、for、while、if、else、switch、case 等关键字及条件判断语句(A ? B:C)。
1. 使用构造函数
分析:创建 n 个对象,每个对象有一个大于 0 的 ID 和一个用于记录前 n 项和的 sum。很明显,ID 和 sum 都要是静态成员变量。
class Accumulate {
public:
Accumulate() {
ID++;
sum += ID;
}
static int ID, sum;
};
int Accumulate::ID = 0;
int Accumulate::sum = 0;
int sumNums(int n) {
Accumulate::ID = 0;
Accumulate::sum = 0;
Accumulate* acc = new Accumulate[n];
delete[] acc;
return Accumulate::sum;
}
代码:sumNums 函数可能被多次调用,所以它首先就应该把 Accumulate 类的静态变量置 0。为了代码简洁,没有考虑封装,直接在类外访问成员变量。
2. 使用虚函数
两次取非:对布尔类型 true 或非 0 数字两次取非得到布尔型 true;对布尔类型 false 或数字 0 两次取非得到布尔型 false。true 的值为 1,false 的值为 0。
我们可以让 n 递减的过程中调用函数 f1 实现累加,n 减到 0 的时候调用函数 f0 结束。虽然不能使用 if 语句,但可以借助两次取反的结果选择调用函数 f1 和 f0:两次取反得到值为 1 的 true 时调用 arr[1] = f1;两次取反得到值为 0 的 false 时调用 arr[0] = f0。为了让两个不同的函数放到同一个数组中,可以利用虚函数实现多态。
struct Base;
Base* arr[2];
struct Base {
virtual int sum(int n) {
return 0;
}
};
struct Derive :public Base {
virtual int sum(int n) {
return arr[!!n]->sum(n - 1) + n;
}
};
int sumNums(int n) {
Base base;
Derive derive;
arr[0] = &base;
arr[1] = &derive;
return arr[1]->sum(n);
}
3. 使用函数指针
上面方法的关键是在两次求反的情况下,通过虚函数把两个不同对象放在一个数组中,从而根据两次求反的结果调用数组中不同对象的函数。
把两个对象放入同一个数组的目的是为了利用同一个数组调用不同函数。能不能把两个不同函数放入同一个数组呢?利用函数指针就可以。
int fac1(int n);
int fac2(int n);
typedef int(*p)(int);
p fac[2] = { fac1, fac2 };
int fac1(int n) {
return 0;
}
int fac2(int n) {
return n + fac[!!n](n - 1);
}
int sumNums(int n) {
return n + fac[!!n](n - 1);
}
4. 使用非类型模板参数
分析:让编译器在推断模板参数时实现递归的累加。
template<int n>
struct Example {
enum value { N = Example<n - 1>::N + n };
};
template<>
struct Example<1> {
enum value { N = 1 };
};
int sumNums(const int n) {
return Example<100>::N;
}
编译器看到 Example<100> 时就会以参数 100 生成 struct Example<100>,于是出现递归:生成 struct Example<99>、struct Example<98>、…、struct Example<2>。因为 struct Example<1> 已经被显式定义,所以递归到 2 就停止。此时生成的 Example 类的成员变量 N 就是 1 到 n 的和。
因为编译器在编译期间推断模板参数,所以模板参数必须是常数,因此不能根据 sumNums 的参数生成代码。此外编译器对递归编译代码的递归深度也是有限制的。
为什么使用枚举类型?
5. 使用短路特性
int sumNums(int n) {
int res = 0;
n == 0 || (res = sumNums(n - 1));
return n + res;
}
3.2 不用加减乘除做加法
题目:写一个函数求两个整数的和。要求在函数体内不能使用加减乘除四则运算。
分析:先探究十进制数加法的计算过程。以 9948 + 327 为例:
- 9948 + 327:sum = 9265,carry = 101 << 1 = 1010。其中 sum 是不带进位的和,carry 是进位,左移一位代表乘 10。sum 第 i 个数位上的数 s u m i = ( a i + b i ) % 10 sum_i = (a_i + b_i) \% 10 sumi=(ai+bi)%10,左移前 carry 第 i 个数位上的数 c a r r y i = ( a i + b i ) / 10 carry_i = (a_i + b_i) / 10 carryi=(ai+bi)/10。
- sum + carry:sum = 275,carry = 1000 << 1 = 10000。
- sum + carry:sum = 10275,carry = 0 << 1 = 0。停止。
所以加法运算的步骤是先计算不带进位的 sum,接着计算进位得到 carry ,然后把 sum 与 carry 相加。相加的过程可能还会产生进位,以同样的方法相加直到进位为 0。
使用位运算很容易得到不带进位的 sum 和左移前的 carry:sum = a ^ b,carry = a & b。
int add(const int a, const int b) {
int sum = a ^ b;
int carry = (a & b) << 1;
while (carry != 0 ) {
int temp = sum;
sum = sum ^ carry;
carry = (temp & carry) << 1;
}
return sum;
}
代码:有些环境不支持负数左移,报错 “runtime error: left shift of negative value -2147483648”。这时需要把负数转换成无符号数再左移。比如把第 3、7 行改为:
int carry = (unsigned)(a & b) << 1;
carry = (unsigned)(temp & carry) << 1;
3.3 构造乘积数组
题目:给定一个数组 A[0, 1, …, n-1],请构建一个数组 B[0, 1, …, n-1]。其中 B 中的元素 B [ i ] = A [ 0 ] × A [ 1 ] × ⋯ × A [ i − 1 ] × A [ i + 1 ] × ⋯ × A [ n − 1 ] B[i] = A[0] \times A[1] \times \cdots \times A[i-1] \times A[i+1] \times \cdots \times A[n-1] B[i]=A[0]×A[1]×⋯×A[i−1]×A[i+1]×⋯×A[n−1],即 B[i] 不含因子 A[i]。要求不能用除法。
分析:利用除法设计时间复杂度为 O(n) 的算法,不利用除法设计时间复杂度为
O
(
n
2
)
O(n^2)
O(n2) 的算法都很容易。不利用除法设计时间复杂度为 O(n) 的算法,就需要认真找规律了。
不利用除法 1 步计算出 B[i] 的值是困难的,但我们可以分 2 步计算出 B[i] 的值。观察上图可以发现,红色箭头和绿色箭头都是在前一项的基础上再乘一个因子。这说明我们遍历两次数组 A 就可以构造乘积数组 B 了。第一次遍历数组 A 为 B[0] 到 B[n-1] 赋值,此时 B[i] 包含红色箭头内的因子
A
[
0
]
,
A
[
1
]
,
⋯
,
A
[
i
−
1
]
A[0], A[1], \cdots, A[i-1]
A[0],A[1],⋯,A[i−1];第二次遍历数组 A 为 B[n-2] 到 B[0] 赋值,此时 B[i] 增加了绿色箭头内的因子
A
[
i
+
i
]
,
A
[
i
+
2
]
,
⋯
,
A
[
n
−
1
]
A[i+i], A[i+2], \cdots, A[n-1]
A[i+i],A[i+2],⋯,A[n−1]。
vector<int> constructArr(vector<int>& a) {
if (a.size() <= 1)
return{};
vector<int> b;
int c = 1;
for (int i = 0; i < a.size(); i++) {
b.push_back(c);
c *= a[i];
}
int d = 1;
for (int i = a.size() - 2; i >= 0; i--) {
d *= a[i + 1];
b[i] *= d;
}
return b;
}