编码习惯,优化直觉

来源:互联网 发布:角色数据读取失败 编辑:程序博客网 时间:2024/05/17 19:22


就像观察奥运会选手打乒乓球,

“顶尖水平”是一种参考,而不是模仿。

有人说打球要严格按照标准选手的动作打——在自己身上打出人家的影子。

借鉴对方的长处,来调整自己的打球策略——在人家身上找到自己的影子。



作为一个还有点追求的程序员,在编程实践中得来几点看法。就两点“编码习惯”和“优化直觉”,但本文有一个局限。


一、 变量定义的越多,程序越好写


    变量本质是内存中的一块区域,里面存储着某些值。在编程实践中,使用适当的变量,在适当的地方,存储适当的中间结果,有很多好处。比如省去重复计算,使程序流程清晰,易于表达,有时还是某些算法中的硬性需要。

1.isPrime为例

bool isPrime(const int &x) {if(x<2)return false;if(x==2)return true;int bound = (int)sqrt(1.0*x) + 1;for (int i(2); i <= bound/*(int)sqrt(1.0*x) + 1*/; ++i)if (x%i == 0)return false;return true;}//isPrime

在for循环中每次计算sqrt(1.0*x)+1,会很费时费力。采用bound暂存一下sqrt(1.0*x)+1的值,省去每次循环的重复计算。

尝试转换几次思考方向,避过sqrt。采用 i * i <= x 来判断会成立。

bool isPrime(const int &x) {
......for (int i(2); i * i <= x; ++i)if (x%i == 0)return false;......}//isPrime
照此发展, (int)pow(1.0*i,(double)i) <=x 来进一步替换,isPrime仍旧成立。

bool isPrime(const int &x) {......
for (int i(2); (int)pow(1.0*i,(double)i) <= x; ++i)if (x%i == 0)return false;......}//isPrime

最终使用myPow来代替系统函数pow,给出二分求幂的一般性代码(递归版)。

long long BPRecur(long long base, const int &exp) {if (exp == 0)return 1;long long tmp(BPRecur(base, exp >> 1));tmp *= tmp;return (exp & 1) ? base*tmp : tmp;}//BPRecurlong long myPow(const int &a, const int &b) {long long base(a);int exp(b);//假定参数a,b合法,不同时为0,0if (base == 0 && exp == 0)return -1;//简单处置一下if (base == 0 || base == 1 || exp == 1)return base;long long tmp(BPRecur(base, exp >> 1));tmp *= tmp;return (exp & 1) ? base*tmp : tmp;}//myPowbool isPrime(const int &x) {if (x<2)return false;if (x == 2)return true;for (int i(2); (int)myPow(i, i) <= x; ++i) if (x%i == 0) return false;return true;}//isPrime
核心函数BPRecur,利用tmp来暂存BPRecur(base,exp>>1)的值,然后将两值相乘。因为这两个值是相同的,没必要计算两次。


2. 以leetcode78为例,求某个集合的子集合为例


//这种方式见过好多哦遍了。//一层一层地穿衣服,retVecs一个变量足矣。//要暂存那个sizeOfRetVecs非常必要。class Solution {public:vector<vector<int>> subsets(const vector<int> &nums) {int sizeOfNums = (int)nums.size();vector<vector<int>> retVecs{ {} };for (int i(0); i < sizeOfNums; ++i) {int sizeOfRetVecs = (int)retVecs.size();for (int j(0); j < sizeOfRetVecs; ++j) {auto tmpVec(retVecs[j]);tmpVec.push_back(nums[i]);retVecs.push_back(tmpVec);}//for j}//for ireturn retVecs;}//subsets};


牺牲了一小块内存来存储中间计算结果,省去可能发生的重复计算,所以叫做“以土地换和平”。


扩展:dp算法,对于当前问题的求解会依赖前面的若干子问题,而那些子问题的计算结果早就被存储在数组里,这样就省去对子问题的反复求解。

dp算法难在确定状态和转移方程,编码却比较简单。大多开辟数组来存储中间计算结果,来获得大规模提速。

被利用的这点叫有重叠子问题性质,dp算法还有无后向性和最优化原理,url,还没写,占个坑)

、不求有功,但求无过

    编程是一件很危险的事,最重要的是保证逻辑正确,功能得到有效地实现。稍不留神,就有可能出bug。从现在开始码代码,命名规范,流程清晰,考虑全面,从头到尾,一气呵成,就像王勃写《滕王阁序》那样,意境开阔,才华横溢,挥毫泼墨,语惊四座。这种境界也是所有程序员梦寐以求的,但是现实很骨感,写程序犹如履薄冰,必须谨小慎微,步步为营,心态到位之后还需要扎实的基础和丰富的debug经验。

1.比如并查集的核心api之一

inline int findRoot(int x) {return parents[x] == x ? x : (parents[x] = findRoot(parents[x]));}//findRoot
寻找x所在集合的根,即有根树的下标,并返回之。还执行了“路径压缩”这一优化手段。

inline int findRoot(int x) {if (parents[x] == x)return x;//递归出口int ret(-1);//记录有根树的根ret = findRoot(parents[x]);//递归找根parents[x] = ret;//路径压缩return ret;//将根返回}//findRoot

展开:逻辑清晰,易懂,易调试,易注释。

条件运算符的确可以简化程序代码,提高运行效率。if else虽然朴实无华,但在表达分支流程方面,却是最简单有效的。

2.再比如Leetcode100,判断两棵二叉树是否相同

bool isSameTree(const TreeNode * const p,const TreeNode * const q){return p==NULL&&q==NULL||p!=NULL&&q!=NULL&&p->val==q->val||isSameTree(p->lch,q->lch)&&isSameTree(p->rch,q->rch);}//isSameTree

对于返回bool类型的递归函数,容易采用关系运算符来组织代码,尤其是遇到二叉树或字符串的问题。

但是对于生手来说,还是下面的程序更有表现力。

bool isSameTree(TreeNode* p, TreeNode* q) {if (p == NULL&&q == NULL)return true;if (p == NULL&&q != NULL || p != NULL&&q == NULL)return false;if (p->val != q->val)return false;return isSameTree(p->left, q->left) && isSameTree(p->right, q->right);}//isSameTree

3.多用强制转换

很多语言都支持默认类型转换,比如表达式运算,实参向形参传值,函数返回值部分。

这种转换由系统完成,架空了程序员,既然编程是一件很危险的事,还是让程序员来一人承担所有的转换责任,以强制转换来代替默认转换,避免奇葩的错误出现。

for (unsigned i(0); i >= 0; --i) {printf("i=%d\n", i);//cout << "i=" << i << endl;system("pause");}//for i
开头的iunsigned类型,

如果不改动i的类型,就要改i >= 0(int)i>=0,再观察程序的表现。

unsigned k(0);long long times(0);for (int i(0); i < k - 1; ++i) {//printf("i=%u\n", i);++times;}//for i//printf("times=%lld\n",times);

该例来源于《剑指offer》的求链表中倒数第k个结点。

i本来是int型,在i<k-1时,i被默认转换为unsigned,而右侧对应的正好是最大的unsigned数。

所以输出times2^32-1

4.小括号是保险措施

    inttmpI(4);

   if(tmpI & 1 == 0)puts("是福不是霍");

    elseputs("是霍躲不过");

tmpI&1是用位运算来加速tmpI%2这种判断奇偶数的操作。

tmpI&1==0先算1==0得到假,在C/C++里面,假即是0tmpI0&运算,永远得0

优先级不明确,可以自己用小括号分割,只是多敲了几对小括号,并不是一件太蠢的事。


带参数的宏定义也是类似的例子。

#define ISLEAP(x)  ((x)%100!=0&&(x)%4==0)||(x)&&400==0

对读入数据不放心,保险起见,全加括号。即使笨拙,但总不至于产生错误。


扩展:防御式编程就是“疑人不用,用人不疑”。程序应该具备一定的容错性,把不好的挡在外面,把可信的放进来。数据永远不会错,错的是我们。

5.函数、参数、变量,能定死的尽量定死。

bool operator<(const struct node &x)const;

void print(const vector<int> &nums);

重载小于运算符,返回bool,属于常成员函数;

打印函数一般都是只读的,不应该影响传入的数据。

如果某些函数是只读的,某些变量不应被更改,就不要给人们留下任何可能的幻想。


这种改进的确是可有可无的,加上它们也许就是为了图个心安。难道编程不是应该先求稳嘛?

不求有功,但求无过,也算是一种处事哲学。


扩展:一个java抽象类的设计

package cn.edu.zju.ccnt.PizzaTestDrive;public abstract class PizzaStore {public final Pizza orderPizza(String type){Pizza pizza = null;pizza = createPizza(type);pizza.prepare();pizza.bake();pizza.cut();pizza.box();return pizza;}//orderPizzaprotected abstract Pizza createPizza(String type);}//PizzaStore

PizzaStrore类来源于《HeadFirst》的工厂模式,该类极其巧妙且安全。

PizzaStore专门用于被继承的类,尽量用abstract注明,肯定不能实例化了。

orderPizza是“一统天下”的方法,采用final修饰符来禁止其被子类覆盖。

createPizza是工厂方法模式的核心,一定要留给子类覆盖,所以用abstract修饰。

如此设计,该避免的风险全都避免掉,该给的提醒全都提醒到。

三、 无聊的编程增添一点乐趣

1.命名规则:驼峰标识,下划线分割,匈牙利风格,帕斯卡风格。

tmpVec,tmpI,tmpStr, dummyHead

retVec,retVecs,retStr,retStrs

routine_backup, node_lamb

minCost,allCost,marks,parents

isPrime,myPow

smallYellowCar,bigBeautifulGirl

匈牙利风格写MFC时用过;帕斯卡风格,我用在函数名上的少,用在类名上的多,尤其是java的类。

灵感来源于英语语法:形容词前置做定语,副词后置做定语,状词后置做定语,名词连成一小串。

公认的缩写:temp->tmp,count->cnt,number->num,increment->inc

2.刻在骨子里的小习惯。

一个不漏的优化语句。

二分求幂的迭代版

long long myPow(const int &a, const int &b) {long long ret(1);long long base(a);int exp(b);//输入自觉点,不要出非法的,防御式没做if (base == 0 || base == 1 || exp == 1)return base;while (exp) {ret *= (exp & 1) ? base : 1;base *= base;exp >>= 1;}//whilereturn ret;}//myPow

能优化的尽量都优化:

exp%2==1--->(exp&1)==1

exp = exp/2--->exp>>=1

base = base*base--->base *=base


这不是算法上的优化直觉,而是每次敲键盘时,就要有编码习惯。


一个不漏的初始化。

int tmp(-1)ListNode* head(NULL)

尽管C++的静态变量或是java类的成员变量都有默认值,但是显示的初始化并不会造成误解,就是为了突出一个严谨的态度。


一个不漏的返回值。

void print(){

       //do something

       return;

}//print

即便是空函数,也有返回的必要。

3、 多思考,多变化,多封装,多优化

二叉树层序遍历的经典代码是使用队列作为辅助数据结构,但是对于在C语言环境下长大的孩子来说,levelOrder是这个样子的。


void levelOrder(TreeNode *root) {TreeNode *myQueue[100000];int front(0), back(0);if (root)myQueue[front] = root, ++back;//int nextLine(0);while (front < back) {TreeNode *cur = myQueue[front];printf("%d ", cur->val);//if (front == nextLine)printf("\n");++front;if (cur->lch)myQueue[back++] = cur->lch;if (cur->rch)myQueue[back++] = cur->rch;//if (front - 1 == nextLine)nextLine = back - 1;}//whilereturn;}//levelOrder

后来有了STL里面的queuelevelOrder变成了这样

void levelOrder(TreeNode *root) {queue<pair<TreeNode*, int>> que;if (root)que.push(make_pair(root, 0));//int preLevel(0);while (que.empty() == false) {auto cur = que.front();que.pop();//if (cur.second > preLevel) preLevel = cur.second, printf("\n");printf("%d ",cur.first->val);if (cur.first->left)que.push(make_pair(cur.first->left, cur.second + 1));if (cur.first->right)que.push(make_pair(cur.first->right, cur.second + 1));}//whilereturn;}//levelOrder


前者对队列的操作,都暴露在frontback上了。

后者把这些操作,都封装进queue中,这应该是一种进步吧。

封装后的队列,操作更简单,思路更清晰,维护更容易,更能突出主要业务逻辑代码。

例如,比较两者注释部分的代码,都是完成层序遍历的换行操作。


多思考一下,二叉树的层序遍历是否有递归版?

//用递归来做levelOrder,//没有用队列。////其实buildVec里面的那三行代码顺序可以任意,//依照目前的排列,是DLR。//也可以是DRL,RDL等等。class Solution {public:vector<vector<int>> levelOrder(TreeNode *root) {vector<vector<int>> vecs;buildVec(root, 0, vecs);return vecs;}//levelOrderprivate:void buildVec(TreeNode *root, int level, vector<vector<int>> &vecs) {if (root == NULL)return;if ((int)vecs.size() <= level)vecs.push_back(vector<int>{});vecs[level].push_back(root->val);buildVec(root->left, level + 1, vecs);buildVec(root->right, level + 1, vecs);return;}//buildVec};


再想想,如果将辅助数据结构queue改成stack,又会是何种遍历?

答:是DLR遍历。参考Leetcode111背景。

//基于levelOrder的遍历class Solution {public:int minDepth(TreeNode *root) {queue<pair<TreeNode*, int>> que;if (root)que.push(make_pair(root, 0));int ret(-1);while (que.empty() == false) {auto cur = que.front();que.pop();if (cur.first->left == NULL&&cur.first->right == NULL) if (ret == -1 || cur.second < ret)ret = cur.second;if (cur.first->left)que.push(make_pair(cur.first->left, cur.second + 1));if (cur.first->right)que.push(make_pair(cur.first->right, cur.second + 1));}//whilereturn ret + 1;}//minDepth};//换queue为stack,深度优先遍历的。class Solution {public:int minDepth(TreeNode *root) {stack<pair<TreeNode*, int>> stk;if (root)stk.push(make_pair(root, 0));int ret(-1);while (stk.empty() == false) {auto cur = stk.top();stk.pop();if (cur.first->left == NULL&&cur.first->right == NULL) if (ret == -1 || cur.second < ret)ret = cur.second;if (cur.first->right)stk.push(make_pair(cur.first->right, cur.second + 1));
if (cur.first->left)stk.push(make_pair(cur.first->left, cur.second + 1));}//whilereturn ret + 1;}//minDepth};


灵感来源于二叉树的DLRLDRLRD的递归和非递归实现,url(还未写,再占个坑)。


4.优化直觉主要是对时间和空间的直觉。


作为一种追求,要靠耐心、经验和积累,也算是闲得蛋疼时,给无聊的编程增添一点乐趣。

参考top1001的背景,详细情况参考http://blog.csdn.net/gentledongyanchao/article/details/56047650

disjointSet的quick-find版本

//quick-find//findRoot是O(1)的,用来得到集合序号//isOneSet用来判断xRoot和yRoot是否为同一集合。//unionSet是O(n)的,用来合并xRoot到yRoot里面//getDates用来得到该集合的元素个数。因此初始化全为1。dates还有别的用途。class disjointSet {private:vector<int> parents, dates;int cnt, size;public:disjointSet(int size) {this->size = size;cnt = size;parents.resize(size);for (int i(0); i < size; ++i)parents[i] = i;dates.assign(size, 1);return;}//disjointSetinline int findRoot(int x) {return parents[x];}//findRootinline int getCount() {return cnt;}//getCountinline bool isOneSet(int xRoot, int yRoot) {return xRoot == yRoot;}//inOneSetvoid unionSet(int xRoot, int yRoot) {for (int i(0); i < this->size; ++i) {if (parents[i] != xRoot)continue;parents[i] = yRoot;}//for idates[yRoot] += dates[xRoot];--cnt;return;}//unionSetinline int getDates(int x) {return dates[x];}//getDates};

调整一下findRoot和unionSet的策略,得到侧重于quick-union的版本,适用范围更广。

//quick-union//findRoot是近似O(1)的,里面采用的是路径压缩,用来得到集合序号,findRoot本身是递归实现的//isOneSet用来判断xRoot和yRoot是否为同一集合。//unionSet是O(1)的,采用ranks来优化的,用来合并xRoot到yRoot里面//getDates用来得到该集合的元素个数。因此初始化全为1。dates还有别的用途。class disjointSet {private:vector<int> parents, ranks, dates;int cnt;public:disjointSet(int size) {cnt = size;parents.resize(size);for (int i(0); i < size; ++i)parents[i] = i;ranks.assign(size, 0);dates.assign(size, 1);return;}//disjointSetinline int findRoot(int x) {return parents[x] == x ? x : (parents[x] = findRoot(parents[x]));}//findRootinline int getCount() {return cnt;}//getCountinline bool isOneSet(int xRoot, int yRoot) {return xRoot == yRoot;}//inOneSetvoid unionSet(int xRoot, int yRoot) {if (ranks[xRoot] == ranks[yRoot])++ranks[yRoot];if (ranks[xRoot] < ranks[yRoot])parents[xRoot] = yRoot, dates[yRoot] += dates[xRoot];else parents[yRoot] = xRoot, dates[xRoot] += dates[yRoot];--cnt;return;}//unionSetinline int getDates(int x) {return dates[x];}//getDates};

quick-union里面的findRoot是递归实现的,可能会造成函数调用栈的溢出。

还可以用while来实现。

下面这种最直观,易懂。
tmpVec来存储中间结点,然后将tmpVec里面的点统统指向x。

但是由于findRoot调用频率高,所以会频繁产生tmpVec,虽然这个数组是在栈上开辟的,但是相应的分配消耗还是存在。

int findRoot(int x) {//提交后最后一个case是超时的vector<int> tmpVec;while (x != parents[x]) {tmpVec.push_back(x);x = parents[x];}//whilefor (auto tmp : tmpVec)parents[tmp] = x;return x;}//findRoot


所以采用了将x结点的父结点设置为它的爷爷结点这个策略。
这个方法的压缩幅度不太狠,但是总体看来,效果还算不错。

int findRoot(int x) {//那个超时解决了while (x != parents[x]) {//这行代码也算是路径压缩,将x结点的父结点设置为它的爷爷结点。parents[x] = parents[parents[x]];x = parents[x];}//whilereturn x;}//findRoot


ranks的那种优化手段,效果不如findRoot里面路径压缩强。
类似的优化手段还有很多,比如dates初始化为全1,里面的值表达该集合含有元素的个数,可以采用
if(dates[xRoot]<=dates[yRoot])parents[xRoot]=yRoot;
else parents[yRoot]=xRoot;
这也是为了平衡一下这根树,尽量让“小树”向“大树”靠拢。


关于dates里面放什么数据,这里初始化为全1,dates的意义就是集合个数。
里面放每个城市的人口数,关联关系按“是否属于同省”来定义,dates的意义就是每个省的总人口。
里面放每个城市的石油储备,关联关系按“是否属于同省”来定义,dates的意义就是每个省的总石油。



只因为我不是世界冠军,并不代表我打乒乓球的方法不可取。

局限:实践来源都是C/C++java这种强类型静态编译的语言。

Slowly but surely, we’ll become something else, something better.

Gentle Dong, Fourth Version,20170226



0 0
原创粉丝点击