【数据结构与算法】《剑指offer》学习笔记----第一章、第二章 基础知识(含1-15题)
学习概要
数据结构
数组
字符串
链表(高频)
树(尤其二叉树)
栈(与递归调用密切相关)
队列(图包括树的层序遍历中用到)
算法
查找(二分查找)
排序(快速排序、归并排序)
回溯法(解决迷宫类似问题)
动态规划(求最优解)
贪婪算法(动态规划过程中每一步都存在一个可能的最优解的选择时可以尝试贪婪算法)
循环和递归的不同实现
位运算
正文开始:
1 编程语言
1 sizeof
定义一个空类型(不包含任何成员变量和成员函数)—> sizeof值为1,而非0,因为我们生命该类型的实例的时候必须在内存中占有一定的空间,否则无法使用这些实例,具体占用多少内存由编译器决定,在VS中,每个空类型的实例占用1字节内存。
在空类型中增加一个构造函数和析构函数,sizeof仍为1。因为调用构造函数和析构函数只需要知道函数的地址即可,这些地址只与类型相关,与类型的实例无关,编译器也不会因为这两个函数而在实例里添加任何额外的信息。
如果把析构函数标记为虚函数,sizeof为4或8(32位机器上,一个指针占4字节;64位机器上,一个指针占8字节)。因为编译器一旦发现一个类型中有虚函数,会为这个类型生成虚函数表,并在该类型的每个实例中添加一个指向虚函数表的指针。
2 复制构造函数
结论:C++的标准不允许复制构造函数传值参数。
原因:复制构造函数A(A other)
传入的参数是A的一个实例,是值传递,在把形参复制到实参的过程会调用复制构造函数,会形成无休止的递归调用最终导致栈溢出。应改为A(const A& other)
,传常量引用。
3 C++中struct和class的区别
如果没有标明成员函数或成员变量的访问权限级别,那么,在struct中默认是public,class中默认private。
面试题1 赋值运算符函数
考察点:
(1)函数返回类型应声明为该类型的引用,并在函数结束前返回实例自身的引用(*this
)。
(2)传入的参数类型应声明为常量引用。免拷贝,不修改。
(3)释放自身已有内存。
(4)判断传入的参数与当前实例(*this
)是否是同一个实例。减少任务量,且避免自身释放风险。
ClassName& ClassName::operator=(const ClassName& str){
//判断传入的参数与当前实例(`*this`)是否是同一个实例。减少任务量,且避免自身释放风险。
if(this==&str){
return *this;
}
//释放自身已有内存。
delete[] m_pData;
m_pData = nullptr;
//申请内存
m_pData = new char[strlen(str.m_pData)+1];
strcpy(m_pData,str.m_pData);
return *this;
}
(5)异常安全性。如果按上述写法,析构了自身数据后,如果申请内存失败,自身实例就遭到破坏,违背了异常安全性原则。
解决办法1:可以先申请内存,再析构自身。这样就算申请内存失败,也没有破坏自身数据。
解决办法2:先创建一个实例,再交换临时实例与原来的实例:
ClassName& ClassName::operator=(const ClassName& str){
if(this!=&str){
ClassName strTemp(str); //创建一个临时实例strTemp,并用str初始化它;到了if的结尾会自动释放该临时实例;如果申请内存不成功,则无法进行下面的交换,也就是无法修改自身数据,符合异常安全原则
//将strTemp的数据与自身的数据交换,交换完成后,strTemp的m_pData指向原来的那个待释放的数组
char* pTemp = strTemp.m_pData;
strTemp.m_pData = m_pData;
m_pData = pTemp;
}
return *this;//返回自身实例
面试题2 实现Singleton模式
单例模式,设计一个类,我们只能生成该类的一个实例。
留着,等学习了设计模式再来补充。https://blog.csdn.net/Hackbuteer1/article/details/7460019
2 数据结构
数组
基本情况:占据一块连续内存,并按照顺序存储数据。
优点与应用:读写元素快,可用来实现简单哈希表。
缺点与改进:常有空闲区域没有得到充分利用,动态数组vector,先开辟一块小空间,存满了就再找一块大空间(2倍),把小空间的数据拷贝到大空间,释放小空间。
数组与指针
sizeof(数组名) 得到的是数组中元素的总字节长度
sizeof(指针)得到的是4或8
当数组作为函数的参数进行传递时,数组自动退化为同类型的指针
int GetSize(int data[])
{
return sizeof(data);//此时返回4或8
}
小技巧:用位运算实现除以2
两者等价
(end-start)/2
(end-start)>>2
面试题3 数组中的重复数字
找出数组中重复的数字。 在一个长度为 n 的数组 nums 里的所有数字都在 0~n-1
的范围内。数组中某些数字是重复的,但不知道有几个数字重复了,也不知道每个数字重复了几次。请找出数组中任意一个重复的数字。
示例 1:
输入:[2, 3, 1, 0, 2, 5, 3]
输出:2 或 3
class Solution {
public:
int findRepeatNumber(vector<int>& nums) {
int len = nums.size();
//排除极端情况:数组为空
if(len<=0){
return -1;
}
//对数组中的元素进行验证看是否符合要求
for(int i=0;i<len;++i){
if(nums[i]<0 || nums[i]>len-1){
return -2;
}
}
//开始处理
for(int i=0;i<len;++i){
while(nums[i]!=i){
if(nums[i] == nums[nums[i]]){//说明重复了
return nums[i];
}
//若没有重复
std::swap(nums[i],nums[nums[i]]);
}
}
return -3;
}
};
面试题4 二维数组中的查找
在一个 n * m
的二维数组中,每一行都按照从左到右递增的顺序排序,每一列都按照从上到下递增的顺序排序。请完成一个函数,输入这样的一个二维数组和一个整数,判断数组中是否含有该整数。
示例:
现有矩阵 matrix 如下:
[
[1, 4, 7, 11, 15],
[2, 5, 8, 12, 19],
[3, 6, 9, 16, 22],
[10, 13, 14, 17, 24],
[18, 21, 23, 26, 30]
]
给定 target = 5,返回 true。
给定 target = 20,返回 false。
限制:
0 <= n <= 1000
0 <= m <= 1000
class Solution {
public:
bool findNumberIn2DArray(vector<vector<int>>& matrix, int target) {
bool found = false;
int rows = matrix.size(); //获得二维数组的行数
if(row==0){//行数为零,这是个空的数组
return found;
}
int columns = matrix[0].size();//获得二维数组的列数
if(rows>0 && columns>0){//行列都大于0
int row = 0;//从第一行开始往下遍历
int column = columns-1;//从最后一列开始往前遍历
while(row<rows && column>=0){//当还有可走的余地时(即行数还没走到底,列数还没走到开头)
if(matrix[row][column]==target){//如果二维数组的元素值==target,找到了
found = true;
break;
}else if(matrix[row][column]>target){//当前元素值大于target,说明
--column;
}else{
++row;
}
}
}
return found;
}
};
字符串
C/C++的字符串结尾是字符’\0’。要声明一个长度为2的字符数组,才能完成"a"的存储。
为了节省内存,C/C++把常量字符串放到一个单独的一个内存区域。当几个指针赋值给相同的常量字符串时,他们实际上会指向相同的内存地址。但用常量内存初始化数组,则情况不同。
char str1[] = "hello world";
char str2[] = "hello world";
此时,str1 != str2。编译器会先为两个字符串数组分配两个长度为12字节的空间,并把“hello world”内容复制进去,数组的初始地址不同
char *str3 = "hello world";
char *str4 = "hello world";
此时,str3 == str4。“hello world”是常量字符串,存放在内存中,且仅有一个拷贝,两个指针都指向这个拷贝
面试题5 替换空格
请实现一个函数,把字符串 s 中的每个空格替换成"%20"。
示例 1:
输入:s = “We are happy.”
输出:“We%20are%20happy.”
限制:
0 <= s 的长度 <= 10000
使用额外空间:
class Solution {
public:
string replaceSpace(string s) {
string res;//定义一个额外的字符串
for(auto c:s){//对于s中的每个字符进行遍历
if(c==' '){//如果字符为空格
res += "%20";//将要替换的内容赋给res
}else{
res += c;//如果不是空格,就原样复制字符
}
}
return res;//最后返回这个额外的辅助字符串即可
}
};
不使用额外空间:
class Solution {
public:
string replaceSpace(string s) {
if(s.length()==0) //处理空字符串情况
return s;
int originallen = s.size() - 1;//字符串原始长度,注意-1
int numblank = 0;//空格个数,初始化为0,for循环统计空格总个数
for(auto c:s){
if(c == ' ') ++numblank;
}
int newlen = originallen + numblank * 2;//替换后新的长度
s += string(numblank * 2,' ');//在字符串尾部开辟新的必备空间,长度为numblank * 2,内容为空格
//当原始字符串最后一个字符的下标>=0且后面的新指针一直在前面的原始指针后面
while(originallen>=0 && newlen > originallen){
//遇到空格,从后往前替换成0,2,%
if(s[originallen] == ' '){
s[newlen--] = '0';
s[newlen--] = '2';
s[newlen--] = '%';
}
else{//不是空格,就原样复制,把前面的字符复制给后面之后,让后面的字符索引往前来一个位置
s[newlen--] = s[originallen];
}
originallen--;//不管是遇到的空格,还是遇到的非空字符,处理完上一个字符后,都要把前面的索引往前走一个位置
}
return s;
}
};
当合并两个数组(包括字符串)时,如果从前往后复制每个数字(或字符)则需要重复移动数字(或字符)多次,那么可以考虑从后往前复制,这样能减少移动的次数,提升效率。
链表
链表是动态数据结构,创建时不需要知道长度。
插入一个节点时,只需要为新节点分配内存,然后调整指针的指向来确保新节点被链接到链表中。
内存分配不是在创建链表的时候一次性完成,而是每添加一个节点分配一次内存。
没有闲置内存,空间效率比数组高。
定义一个单向链表的节点:
struct ListNode{
int m_nValue;
ListNode * m_pNext;
}
往该链表的末尾添加一个节点:
void AddToTail(ListNode**pHead,int value){
//新建一个节点,并完成数据和指针的初始化
ListNode * pNew = new ListNode();
pNew->m_nValue = value;
pNew->m_pNext = nullptr;
//如果pHead指向空,那么说明是第一个节点,直接让它指向pNew
if(*pHead==nullptr){
*pHead = pNew;
}else{//不是第一个节点
ListNode* pNode= *pHead;//新建一个节点,让这个节点代表*pHead,向后遍历到末尾
while(pNode->m_pNext!=nullptr){
pNode = pNode->m_pNext;
}
pNode->m_pNext = pNew;
}
}
在链表中找到第一个含有某值的节点并删除该节点的代码:
void RemoveNode(ListNode ** pHead,int value)
{
if(pHead == nullptr || *pHead == nullptr){//链表为空
return;
}
ListNode* pToBeDeleted = nullptr;//新建一个节点,用于保存待删除的节点
if((*pHead)->m_nValue==value){//如果当前上来第一个节点的数值就等于value,那就直接删除这个节点,同时更新指针值,使它指向刚才的那个节点的下一个指向
pToBeDeleted = *pHead;
*pHead = (*pHead)->m_pNext;
}else{//如果第一个节点数值不等于value,说明这个节点不该删除,继续向下遍历,直到找到要删除的那个节点
ListNode* pNode = *pHead;//定义一个新的节点用于遍历
//如果当前节点指向不为空,且,下一个节点的值不等于value,那就继续向下找
while(pNode->m_pNext != nullptr && pNode->m_pNext->m_nValue != value){
pNode = pNode->m_pNext;
}
//既然退出了循环,那么,要么当前节点的指向为空,要么下一个节点的值等于value。只对第二种情况处理,删除下一个节点,当前节点的指向改为指向下一个节点的下一个节点
if(pNode->m_pNext != nullptr && pNode->m_pNext->m_nValue == value){
pToBeDeleted = pNode->m_pNext;
pNode->m_pNext = pNode->m_pNext->m_pNext;
}
}
//上面所说的删除节点的动作,其实是在这做的,之前都是用个节点记录下来哪个是要被删的节点
if(pToBeDeleted != nullptr){
delete pToBeDeleted;
pToBeDeleted= nullptr;
}
}
面试题6 从尾到头打印链表
链表节点定义:
struct ListNode{
int m_nKey;
ListNode * m_pNext;
}
如果不让修改链表的结构,用栈+循环解决问题:
void PrintListReversingly_Iteratively(ListNode* pHead) {
std::stack<ListNode*> nodes;
//从头到尾遍历,让链表节点的指针依序入栈
ListNode* pNode = pHead;
while (pNode != nullptr) {
nodes.push(pNode);
pNode = pNode->m_pNext;
}
//依序输出栈中的指针指向的节点的值,并弹出
while (!nodes.empty()) {
pNode = nodes.top();
printf("%d\t", pNode->m_nKey);
nodes.pop();
}
}
递归的方法,代码简洁,但是当链表很长的时候有栈溢出的风险,所以用栈+循环的方法鲁棒性更强。
void PrintListReversingly_Iteratively(ListNode* pHead) {
if (pHead != nullptr) {
if (pHead->m_pNext != nullptr) {
PrintListReversingly_Iteratively(pHead->m_pNext);
}
printf("%d\t", pHead->m_nKey);
}
}
具体题目描述:输入一个链表的头节点,从尾到头反过来返回每个节点的值(用数组返回)。
示例 1:
输入:head = [1,3,2]
输出:[2,3,1]
限制:
0 <= 链表长度 <= 10000
递归方法:
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
vector<int> res;//定义在递归函数外部
vector<int> reversePrint(ListNode* head) {
if(head!=NULL){
if(head->next!=NULL){
reversePrint(head->next);
}
res.push_back(head->val);
}
return res;
}
};
栈+循环方法:
class Solution {
public:
vector<int> res;
vector<int> reversePrint(ListNode* head) {
std::stack<ListNode*> nodes;
while(head){
nodes.push(head);
head = head->next;
}
while(!nodes.empty()){
res.push_back(nodes.top()->val);
nodes.pop();
}
return res;
}
};
树
二叉树:一个父节点,两个子节点。
二叉搜索树:根大于等于左子节点,小于等于右子节点。
堆:大顶堆、小顶堆,可快速查找最大值和最小值。
红黑树:把树中的节点定义为红黑两种颜色,通过规则确保从根节点到叶节点的最长路径的长度不超过最短路径的两倍。C++的STL中,set、multiset、map、multimap等数据结构都是基于红黑树实现的。
面试题7 重建二叉树(中等难度)
输入某二叉树的前序遍历和中序遍历的结果,请重建该二叉树。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。
例如,给出:
前序遍历 preorder = [3,9,20,15,7]
中序遍历 inorder = [9,3,15,20,7]
返回如下的二叉树:
3
/ \
9 20
/ \
15 7
限制:
0 <= 节点个数 <= 5000
class Solution {
public:
TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
return build(preorder, inorder, 0, 0, inorder.size() - 1);//从头开始,不断构建树的过程
}
TreeNode* build(vector<int>& preorder, vector<int>& inorder, int root, int start, int end){//中序的start和end
if(start > end) return NULL;//如果一开始start就大于end,返回空
TreeNode *tree = new TreeNode(preorder[root]);//新建节点,用根节点去初始化它
int i = start;
while(i < end && preorder[root] != inorder[i]) i++;//寻找根节点在中序中的位置,等跳出循环的时候的i若<end,那么i就是根节点在中序中的位置
tree->left = build(preorder, inorder, root + 1, start, i - 1);//构建左树,左树的根节点在前序中的位置变为root+1,在中序中的起点和终点变为0和i-1,表明从中序的根节点往左都是左树范围
tree->right = build(preorder, inorder, root + 1 + i - start, i + 1, end);//构建右树,右树的根节点在前序中的位置:从原来根节点位置往后数i个,这些都是原根节点+左子树的节点,再往后1个才是右子树的根节点。这里-start很是精辟,暂时不懂。中序起点和终点变为i+1,end
return tree;
}
};
面试题8 二叉树的下一个节点
题目:给定一个二叉树,找出中序遍历序列的下个节点。
分析:
(1)如果该节点有右子树,那么该节点的下一个节点就是这个右子树的最左节点。
(2)如果该结点没有右子树,且它是它父节点的左子节点,那么下个节点就是这个节点的根。
(3)如果该结点既没有右子树,且它还是它父节点的右节点,那么沿着它的父节点向上找,直到找到一个节点a,节点a的父亲是节点b,节点a时节点b的左节点,那么节点b就是要找的下一个节点。
代码:
BinaryTreeNode* GetNext(BinaryTreeNode* pNode)
{
if(pNode==nullptr){
return nullptr;
}
BinaryTreeNode* pNext = nullptr;
if(pNode->m_pRight != nullptr){
BinaryTreeNode* pRight = pNode->m_pRight;
while(pRight->m_pLeft != nullptr){
pRight = pRight->m_pLeft;
}
pNext = pRight;
}
else if(pNode->m_pParent != nullptr){
BinaryTreeNode* pCurrent = pNode;
BinaryTreeNode* pParent = pNode->m_pParent;
while(pParent != nullptr && pCurrent == pParent->m_pRight){//如果父节点不为空且当前节点是它父节点的左孩子
pCurrent = pParent;
pParent = pParent->m_pParent;
}
pNext = pParent;
}
return pNext;
}
栈和队列
栈:先进后出,后进先出;一般不考虑排序,需要O(n)时间才能从栈中找到min或max
队列:先进先出,后进后出;树的层序遍历
面试题9 用两个栈实现队列
用两个栈实现一个队列。队列的声明如下,请实现它的两个函数 appendTail 和 deleteHead ,分别完成在队列尾部插入整数和在队列头部删除整数的功能。(若队列中没有元素,deleteHead 操作返回 -1 )
示例 1:
输入:
["CQueue","appendTail","deleteHead","deleteHead"]
[[],[3],[],[]]
输出:[null,null,3,-1]
示例 2:
输入:
["CQueue","deleteHead","appendTail","appendTail","deleteHead","deleteHead"]
[[],[],[5],[2],[],[]]
输出:[null,-1,null,null,5,2]
提示:
1 <= values <= 10000
最多会对 appendTail、deleteHead 进行 10000 次调用
中规中矩:
class CQueue {
private:
std::stack<int> stack1;
std::stack<int> stack2;
public:
CQueue() {
}
void appendTail(int value) {
stack1.push(value);//将数据压入专门负责存储的栈1
}
int deleteHead() {
if(stack2.empty()){//若专门负责弹出的栈2为空
while(!stack1.empty()){//把栈1中所有数据倒入栈2
int data = stack1.top();
stack1.pop();
stack2.push(data);
}
}
if(stack2.size()==0){//从栈1中取了数据给栈2,栈2还是空栈,说明栈1也没有数据,空队列
return -1;
}
int head = stack2.top();
stack2.pop();
return head;
}
};
/**
* Your CQueue object will be instantiated and called as such:
* CQueue* obj = new CQueue();
* obj->appendTail(value);
* int param_2 = obj->deleteHead();
*/
递归和循环
递归代码简洁,但要消耗调用栈空间,如果层级过多,会导致调用栈溢出。
动态规划解决问题时,是用递归的思路分析问题,但由于递归分解的子问题中存在大量重复,因此我们总是用自下而上的循环来实现代码。
面试题10- I 斐波那契数列
写一个函数,输入 n ,求斐波那契(Fibonacci)数列的第 n 项。斐波那契数列的定义如下:
F(0) = 0,
F(1) = 1 F(N) = F(N - 1) + F(N - 2), 其中 N > 1.斐波那契数列由 0 和 1 开始,之后的斐波那契数就是由之前的两数相加而得出。
答案需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1。
示例 1:
输入:n = 2
输出:1
示例 2:
输入:n = 5
输出:5
提示:
0 <= n <= 100
这是实际可用的:
class Solution {
public:
int fib(int n) {
if(n==0) return 0;
if(n==1) return 1;
long long biggerone =1;
long long smallerone =0;
long long sum=0;
for(int i=2;i<=n;++i){
sum = (biggerone + smallerone)%1000000007;
smallerone = biggerone;//用大的更新小的
biggerone = sum; //用和更新大的
}
return sum;
}
};
面试题10- II 青蛙跳台阶问题
一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。
答案需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1。
示例 1:
输入:n = 2
输出:2
示例 2:
输入:n = 7
输出:21
提示:
0 <= n <= 100
思路:
当n>2时,青蛙第一次跳的时候有两种选择:
(1)跳1个台阶,此时跳法数目为f(n-1);
(2)跳2个台阶,此时跳法数目为f(n-2)。
即:f(n) = f(n-1) + f(n-2)
代码如下:
class Solution {
public:
int numWays(int n) {
if(n==0) return 1;
if(n==1) return 1;
if(n==2) return 2;
long long bigger=2;
long long smaller=1;
long long sum=0;
for(int i=3;i<=n;++i){
sum = (bigger+smaller)%1000000007;
smaller = bigger;
bigger = sum;
}
return sum;
}
};
查找和排序
常用查找算法:
(1)顺序查找
(2)二分查找:应能信手拈来写出完整正确的二分查找代码
(3)哈希表查找:重点考察数据结构。主要优点:能在O(1)时间内查找某一元素,是效率最高的查找方式;缺点:需要额外空间实现哈希表。
(4)二叉排序树:重点考察数据结构。二叉搜索树的遍历。
常用排序算法:
比较插入排序、冒泡排序、归并排序、快速排序等不同算法的优劣。
从额外空间消耗、平均时间复杂度、最差时间复杂度等方面比较。
白板写出快排代码,及相应测试代码。
快速排序
实现快排的关键:在数组中选一个数字,接下来把数字中的数字分成两部分,比它小的移到数组左边,比它大的移到数组右边。
int Partition(int data[], int length, int start, int end) {
if (data == nullptr || length <= 0 || start < 0 || end >= length) {
throw new std::exception("Invalid Parameters");
}
int index = RandomInRange(start, end);
Swap(&data[index], &data[end]);
int small = start - 1;
for (index = start;index < end;++index) {
if (data[index] < data[end]) {
++small;
if (small != index) {
Swap(&data[index], &data[small]);
}
}
}
++small;
Swap(&data[small], &data[end]);
return small;
}
void QuickSort(int data[], int length, int start, int end) {
if (start == end) {
return;
}
int index = Partition(data, length, start, end);
if (index > start) {
QuickSort(data, length, start, index - 1);
}
if (index < end) {
QuickSort(data, length, index + 1, end);
}
}
面试题11 旋转数组的最小数字
把一个数组最开始的若干个元素搬到数组的末尾,我们称之为数组的旋转。输入一个递增排序的数组的一个旋转,输出旋转数组的最小元素。例如,数组 [3,4,5,1,2] 为 [1,2,3,4,5] 的一个旋转,该数组的最小值为1。
示例 1:
输入:[3,4,5,1,2]
输出:1
示例 2:
输入:[2,2,2,0,1]
输出:0
class Solution {
public:
int minArray(vector<int>& numbers) {
int length = numbers.size();
int index1 = 0;//负责指向位于前面递增序列的元素
int index2 = length-1;//负责指向位于后面递增序列的元素
int indexMid = index1;//如果第一个数字就小于最后一个数字,可以直接范围,这是旋转了0个数字到数组末尾的情况
while(numbers[index1]>=numbers[index2]){//当前指针指向的值 >= 后指针指向的值
//如果两个指针相距距离为1,说明后指针所指值就是最小值,跳出循环
if(index2-index1==1){
indexMid = index2;
break;
}
indexMid = index1 + (index2-index1)/2;//二者取中
//排除一种特殊情况:index1和index2和indexMid三者指向的值相同的时候,按顺序查找找min
if(numbers[index1]==numbers[index2]&&numbers[index1]==numbers[indexMid]){
int k = index1;
for(int i = index1+1;i<=index2;++i){
if(numbers[k]>numbers[i]){
k = i;
}
}
indexMid = k;
break;
}
if(numbers[indexMid]>=numbers[index1]){//中间点的值>=前指针所指值,说明中间点之前都是递增子序列
index1 = indexMid;//缩小前半部分范围
}else if(numbers[indexMid]<=numbers[index2]){//中间点的值<=后指针所指值,说明中间点之后都是递增子序列
index2 = indexMid;//缩小后半部分范围
}
}
return numbers[indexMid];
}
};
另一个简洁的写法:
class Solution {
public:
int minArray(vector<int>& nums) {
int low=0,high=nums.size()-1,mid;
while(low<high){//当low<high时执行,low>=high时,直接跳过,返回nums[low]
mid = low+(high-low)/2;
if(nums[mid]>nums[high]){//中间值>右端值,说明右端不是递增序列,此时min在右边
low=mid+1;//减去左端的范围,连同mid
}else if(nums[mid]<nums[high]){//中间值<右端值,说明右端是递增序列,min不在右边
high=mid;//减去右端的范围,不包括mid
}else{//中间值==右端值,说明重复了,需要去重
high--;//小步缩小范围,去重
}
}
return nums[low];
}
};
面试题12. 矩阵中的路径
请设计一个函数,用来判断在一个矩阵中是否存在一条包含某字符串所有字符的路径。路径可以从矩阵中的任意一格开始,每一步可以在矩阵中向左、右、上、下移动一格。如果一条路径经过了矩阵的某一格,那么该路径不能再次进入该格子。例如,在下面的3×4的矩阵中包含一条字符串“bfce”的路径(路径中的字母用加粗标出)。
[["a","b","c","e"],
["s","f","c","s"],
["a","d","e","e"]]
但矩阵中不包含字符串“abfb”的路径,因为字符串的第一个字符b占据了矩阵中的第一行第二个格子之后,路径不能再次进入这个格子。
示例 1:
输入:board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCCED"
输出:true
示例 2:
输入:board = [["a","b"],["c","d"]], word = "abcd"
输出:false
提示:
1 <= board.length <= 200
1 <= board[i].length <= 200
class Solution {
vector<vector<int>> dir = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};//4种搜索方向
bool dfs(vector<vector<char>>& board, vector<vector<int>>&visited,
int row, int col, string& word, int pathLength){
visited[row][col] = 1;//设置为已经访问过
if(pathLength == word.size()) return true;//如果已经通过的字符数等于word的size,说明整个字符串都已经找到了,返回true
pathLength++;//这里递增1个字符
for(auto xy : dir){//遍历每种搜索方向
int x = row + xy[0], y = col + xy[1];
if( x < 0 || x >= board.size() ||
y < 0 || y >= board[0].size() ||
visited[x][y] ||
board[x][y] != word[pathLength - 1]) continue;
else{
if(dfs(board, visited, x, y, word, pathLength)) return true;
}
}
visited[row][col] = 0;
return false;
}
public:
bool exist(vector<vector<char>>& board, string word) {
int rows = board.size(), cols = board[0].size();
vector<vector<int>>visited(rows, vector<int>(cols, 0));
for(int row = 0; row < rows; row++){
for(int col = 0; col < cols; col++){
if(board[row][col] == word[0]){//当前位置的字符等于字符串的开头字符
if(dfs(board, visited, row, col, word, 1)) return true;//开始无休止dfs下去,直到返回true;若最终还是返回false,那继续遍历表格的row和col
}
}
}
return false;
}
};
面试题13. 机器人的运动范围
地上有一个m行n列的方格,从坐标 [0,0] 到坐标 [m-1,n-1] 。一个机器人从坐标 [0, 0] 的格子开始移动,它每次可以向左、右、上、下移动一格(不能移动到方格外),也不能进入行坐标和列坐标的数位之和大于k的格子。例如,当k为18时,机器人能够进入方格 [35, 37] ,因为3+5+3+7=18。但它不能进入方格 [35, 38],因为3+5+3+8=19。请问该机器人能够到达多少个格子?
示例 1:
输入:m = 2, n = 3, k = 1
输出:3
示例 2:
输入:m = 3, n = 1, k = 0
输出:1
提示:
1 <= n,m <= 100
0 <= k <= 20
中规中矩回溯法:一般物体或人在二维方格运动这类问题都可以用回溯法解决
class Solution {
public:
int movingCount(int m, int n, int k) {
vector<vector<int>> visited(m, vector<int>(n, 0));
queue<pair<int, int>> q;
q.push({0, 0});
int res = 0;
visited[0][0] = 1;
while (!q.empty()) {//队列不为空时
auto front = q.front(); q.pop();//取出数据
int x = front.first;
int y = front.second;
res += 1;
for (auto d : directions) {//判断上下左右四个方向能不能走路
int new_x = x + d.first;
int new_y = y + d.second;
if (new_x < 0 || new_x >= m || new_y < 0 || new_y >= n
|| visited[new_x][new_y] == 1 ||
sumDigit(new_x, new_y) > k) {//不能走的话就跳转继续for循环
continue;
}//至此,说明找到了可走的路
q.push({new_x, new_y});//加入队列,等待while循环的分析
visited[new_x][new_y] = 1;//标记走过
}
}
return res;
}
int sumDigit(int i, int j) {
int sum = 0;
while (i > 0) {
sum += i % 10;
i /= 10;
}
while (j > 0) {
sum += j % 10;
j /= 10;
}
return sum;
}
private:
vector<pair<int, int>> directions = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
};
另辟蹊径:
class Solution {
public:
bool map[100][100];
int movingCount(int m, int n, int k) {
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++)
map[i][j] = 0;
return dfs(0, 0, m, n, k);
}
int dfs(int x, int y, int m, int n, int k) {
if (map[x][y] == 1 || x < 0 || y < 0 || x >= m || y >= n || (x / 10 + x % 10 + y / 10 + y % 10) > k)
return 0;
map[x][y] = 1;
return dfs(x + 1, y, m, n, k) + dfs(x, y + 1, m, n, k) + 1;
}
};
动态规划
动态规划:
如果题目要求一个问题的最优解(通常是最大值或最小值),而且该问题能分解成若干子问题,并且子问题之间还有重叠的更小子问题,就可以考虑动态规划来解决。
使用条件:
(1)求最优解。
(2)整体的最优解是依赖各个子问题的最优解:看能否把大问题分解为小问题,每个小问题是否也存在最优解,而且每个小问题的最优解组合起来能得到大问题的最优解。
(3)存在公共最小子问题:把大问题分解为小问题,这些小问题之间还有相互重叠的更小的子问题。
(4)从上往下分析问题,从下往上求解问题:子问题在分解大问题的过程中反复出现,为了避免重复求解子问题,可以用自下而上的顺序,先计算小问题的最优解并存储下来,再以此为基础求取更大问题的最优解。
如果符合这三个条件,动态规划了解一下。
解决方法:
动态规划,总是从解决最小问题开始,并把已经解决的子问题的最优解存储下来(一维数组或二维数组),把子问题的最优解组合起来逐步解决大的问题。
应用动态规划时,每一步都可能面临若干个选择,我们并不知道哪个方法最优,那就都试过来,比较得出最优法。
贪婪算法
贪婪算法每步都能做出一个贪婪选择,基于这个选择,我们确定能够得到最优解。
为什么这样的贪婪选择能得到最优解,这是应用贪婪算法时需要问的问题。需要用数学的方式证明贪婪选择是正确的。
面试题14- I. 剪绳子
给你一根长度为 n 的绳子,请把绳子剪成整数长度的 m 段(m、n都是整数,n>1并且m>1),每段绳子的长度记为 k[0],k[1]…k[m-1] 。请问 k[0]k[1]…*k[m-1] 可能的最大乘积是多少?例如,当绳子的长度是8时,我们把它剪成长度分别为2、3、3的三段,此时得到的最大乘积是18。
示例 1:
输入: 2
输出: 1
解释: 2 = 1 + 1, 1 × 1 = 1
示例 2:
输入: 10
输出: 36
解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36
提示:
2 <= n <= 58
分析1:
这道题首先我们可以定义 dp(n) 代表长度为n的绳子分割后的最大可能乘积,那么如果先从 n 中分出来 i 长度的一段,那么 dp(n) = dp(n-i)*dp(i),然后求最大值就好了。为了避免重复计算,开辟一个数组res存储每一个计算好的dp(i) ,如果存在就不需要再去计算 dp(i) 了。双100%。
动态规划:
class Solution {
int dp(int n, vector<int>& res){
if(n <= 4) return n;
int ans = n;
for(int i = 1; i <= n/2; i ++){
if(res[n - i] == 0){
res[n - i] = dp(n - i, res);
}
if(res[i] == 0){
res[i] = dp(i, res);
}
ans = max(ans, res[n - i]*res[i]);
}
return ans;
}
public:
int cuttingRope(int n) {
if(n == 2) return 1;
if(n == 3) return 2;
vector<int> res(n, 0);
return dp(n, res);
}
};
分析2:
绳子的长度为N,剪为长度 L(1<=L<=N)的子段,求每段绳子长度的最大乘积,即为完全背包问题。dp[i] 表示 绳子长为 i 的最大乘积,则有动态转换方程 dp[i] = Max{dp[i - L] * i} (1 <=L<n, L<=i<=n)。双100%。
完全背包:
class Solution {
public:
int cuttingRope(int n) {
vector<int> dp(n+1, 0);
dp[0] = 1;
for (int i = 1; i <= (n+1)/2; i++) {
for (int j = i; j <= n; j++) {
dp[j] = max(dp[j], dp[j-i] * i);
}
}
return dp[n];
}
};
分析3:
假设 L = L1 + L2 + … + Li,并且 L1 * L2 * … * Li 是最大乘积。
我们用数学归纳法来推理一下:
L = 2时,分成两段 1 *(2-1) = 1;
L = 3时,分成两段 1 *(3-1)= 2
L >= 4时,我们尽量避免产生长度1
L = 4时,分成两段 2 *(4-2) = 4;
L >= 5时,2 *(5-2)= 6
…
我们可以发现一些规律:
当L <= 3时,最大乘积是 L-1
当L > 3的时候,把L进行拆分得到的乘积 要大于等于 L(需证明);
当L >= 4时,我们发现2和3的因子往往是可以产生较大的值的(需求解哪个更大);
对于第1条结论L=4,枚举就可以求解;
对于第2条结论,我们只要证明当只进行一次分割时(分割所得长度都>=2),所有乘积都大于等于 L即可:
枚举所有方案2 * (L-2), 3* (L-3), 4*(L-4)…, K*( L-K)(每一段长度满足>=2)
L-2 >= 2时,即 L>=4,满足 2L-4 >= L,也就是2 (L-2)>= L,
同理可证 L-3 >=2时,L>=5,满足3 (L-3) >= L,
通项公式证明:L- K>=2时, L>= K+2, 对K( L-K) >= L进行化解,得到L(K-1) >= K^2,带入 L>=K+2, 显然(K+2)*(K-1) >= K^2,化解K >= 2,显然成立
对于第3条结论,我们要分析到底是分解成更多的2还是分解成更多的3得到的乘积更大?
让我们对3 * (L - 3) 和 2 * (L-2)的值进行比较,化解得到 3L -9 和 2L-4, 显然当L >=5时,3L -9 >= 2L-4,即分解成更多的3得到的乘积更大
得到最终结论:
L<=3时,返回L-1, L>3时,优先分解成更多的3,然后分解成2,得到的乘积最大
转换成代码思路:
当 L <= 3 时,最优解为 1 * (L-1)
当 L > 3时,
如果 L mod 3的值为 1,则分解为两个2,剩下的分解为3
如果 L mod 3的值为 2,则分解为1个2,剩下的分解为3
如果 L mod 3的值为 0,全部分解为3
另一种声音:
我们可以知道当(n>=5)时,存在(2(n-2))>n和(3(n-3))>n。 又因为当n>=5 时有 3(n-3)>=2(n-2),所以我们应该尽量剪出3来得到最大值。 当长度为4时,2*2最大。 时间复杂度与空间复杂度都是O(1)
贪心算法:双100%
class Solution {
public:
int cuttingRope(int n) {
if (n <= 3) return 1 * (n - 1);
int res = 1;
if (n % 3 == 1) res = 4, n -= 4;
else if (n % 3 == 2) res = 2, n -= 2;
while (n) res *= 3, n -= 3;
return res;
}
};
面试题14- II. 剪绳子 II
给你一根长度为 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。
答案需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1。
示例 1:
输入: 2
输出: 1
解释: 2 = 1 + 1, 1 × 1 = 1
示例 2:
输入: 10
输出: 36
解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36
提示:
2 <= n <= 1000
在贪心算法的基础上,稍作修改即可:
class Solution {
public:
int cuttingRope(int n) {
if (n <= 3) return 1 * (n - 1);
long res = 1;//在源代码的基础上,修改res从int变为long
if (n % 3 == 1){
res = 4;
n -= 4;
}
else if (n % 3 == 2){
res = 2;
n -= 2;
}
while (n){
res = (res * 3)%1000000007;//在源代码的基础上,取余
n -= 3;
}
return res;
}
};
位运算
位运算:把数字用二进制表示后,对每一位上的0或者1的运算。
主要包括5种:与、或、异或、左移、右移。
运算 | 结果 |
---|---|
与 | 有0得0,全1得1 |
或 | 全0得0,有1得1 |
异或 | 不同为1,相同为0 |
左移n位 | 最左边n位被丢弃,同时最右边补n个0 |
右移n位 | 最右边n位被丢弃,同时最左边补n个符号位(正数补0,负数补1) |
面试题15. 二进制中1的个数
请实现一个函数,输入一个整数,输出该数二进制表示中 1 的个数。例如,把 9 表示成二进制是 1001,有 2 位是 1。因此,如果输入 9,则该函数输出 2。
示例 1:
输入:00000000000000000000000000001011
输出:3
解释:输入的二进制串 00000000000000000000000000001011 中,共有三位为 '1'。
示例 2:
输入:00000000000000000000000010000000
输出:1
解释:输入的二进制串 00000000000000000000000010000000 中,共有一位为 '1'。
示例 3:
输入:11111111111111111111111111111101
输出:31
解释:输入的二进制串 11111111111111111111111111111101 中,共有 31 位为 '1'。
可以避免死循环的常规解法:
class Solution {
public:
int hammingWeight(uint32_t n) {
int count = 0;
unsigned int i= 1;
while(i){
if(n&i){
++count;
}
i=i<<1;
}
return count;
}
};
出类拔萃:
把一个数减掉1,如1100,减去1后是1011,改变的是最右边的是1的那个位,如果它右边还有0,则所有0变为1,但并不影响左边含1的数量。用x & (x-1)
,就是把一个整数减去1,再和原整数做与运算,会把该整数最右边的1变成0。与到最后,n变为0,就可以求出一个整数的二进制表示中含有多少个1了:
class Solution {
public:
int hammingWeight(uint32_t n) {
int count = 0;
while(n){
n = n&(n-1);
++count;
}
return count;
}
};
学习总结
数据结构
数组
字符串
链表(高频)
树(尤其二叉树)
栈(与递归调用密切相关)
队列(图包括树的层序遍历中用到)
算法
查找(二分查找)
排序(快速排序、归并排序)
回溯法(解决迷宫类似问题)
动态规划(求最优解)
贪婪算法(动态规划过程中每一步都存在一个可能的最优解的选择时可以尝试贪婪算法)
循环和递归的不同实现
位运算