I believe that the best way to get better programs is to teach programmers how to think better. Thinking is not the ability to manipulate language; it’s the ability to manipulate concepts. Computer science should be about concepts, not languages. — Leslie Lamport
Most developers love to write code. Many of us code in our free time. This is where we’re most comfortable. It’s what we’ve been trained to do. When it comes to our jobs, it’s easy for us to see ourselves as coders. It’s easy to believe that our job is to translate requirements into code.
Our job is not to write code. Our job is to solve problems.
Even if you’re not a professional, it’s still not enough for you to just code — not if you want to be good. It’s not enough to know how to work with standard algorithms. It’s not enough to memorize languages, design patterns, and best practices.
Good programmers know how to problem solve.
To move from coding to problem-solving, we need to change how we think and how we work. This begins with a change in perspective. Then, it involves understanding how to problem solve.
The first step in understanding how to solve problems is understanding problems themselves.
Most problems are more complicated than people realize. They have many facets. Like chopping off the head of a Hydra, if you get rid of one problem, many others can come to take its place. Problems have perspectives. A problem for one person may not be a problem for another. It may be a benefit!
Here’s a “simple” example. Consider a small town with one market. Customers of the market are complaining because the price of apples has gone up. Apples are a staple of the diet in this town. The market owner is refusing to lower the price of the apples — even though apples in the market of a neighboring town cost less.
What is the problem here?
On the surface level, you could say the problem is that the market is charging too much for its apples. But this simple analysis misses important things.
To start with, there are several perspectives on what the problem is:
- From the customer’s point of view, the problem is that the price of apples has gone up.
- For the market owner, the problems are rising operating costs, short-term financial difficulties, and difficulty selling other fruits.
- For the market’s fruit supplier, the problem is that the market isn’t selling enough apples. They have a surplus of apples that will soon go to rot in storage.
- Some other businesses in town are actually pleased by the circumstances. For example, the local cafe has started selling apples at a lower price than the market. People are now coming to the cafe to buy apples. This has incidentally increased their sales of coffee, and sweets.
Knowing the perspectives of the different groups affected by the problem lets us know what outcomes they want. While this is helpful, it also creates a new set of problems:
- Which outcome do we decide to work for — i.e., which group’s problem do we decide to solve?
- Is it possible for us to meet the needs of more than one group? If so, how do we do that?
- Solving one group’s problem could cause problems for another group. How are we going to handle those problems?
- What do we do about groups whose needs aren’t met?
Understanding the Problem
Then, there’s our perspective as problem solvers. What biases are we bringing to the situation? How much do our own needs, and our tendency to not see our contributions impact our ability to fully see the problem? How well do we really understand the problem? In a fast-paced world, where quick answers are often rewarded, it’s easy to take the stated problem at face value and rush to solutions.
However, if you can’t think of at least three things that might be wrong with your understanding of the problem — you probably haven’t spent enough time with it. As you can see from the simple example listed above, lots of things can be missing from a problem definition.
It’s not just the problem definition that can be rushed. If you can’t think of at least three solutions to the problem — you probably haven’t spent enough time brainstorming.
Consider the problem of maintaining a specific level of water in a tub. What are our options? Many people will say: “That’s simple! Fill the tub up with water to the level you need, then plug it with a stopper.” Is this the best option? Maybe. It depends. Why do we need to maintain this level of water in the tub, to begin with? What problem is that solution designed to solve?
Let’s assume, for now, that we do need to maintain the water level in the tub. Is plugging the tub the best solution? It’s not the only solution. Here is another one:
- Start with a tub full of water. Open the drain, letting water out. When the tub is at the desired level, turn on the faucet so the rate of water flowing in is equal to that flowing out.(1)
Is this a better solution? It depends on the problem that maintaining the level of water in the tub was designed to solve.
In addition to perspectives, problems have context. If we just look at the problem on a surface level, we may be missing important information. Let’s return to our problem of the market with the increased price of apples. What happens to your perception of the problem when you start asking a few background questions:
- Why did the market raise its prices?
- How long has the market been charging its current prices?
- Is the price increase temporary?
- How do those prices compare with the competition?
- What is the history between the market and its customers, suppliers, and the other businesses in town?
The answers to these, and many other questions, could change how we view the problem dramatically.
Let’s say that we came up with a “solution” to our apple problem. We determined that the market needed to lower the cost of apples. This satisfied the customers. However, now we have new problems.
The market had to absorb the increased cost of apples. Therefore, it could no longer afford to sell oranges. This didn’t have a significant impact on the town’s population. They didn’t like oranges much. However, it did impact the market’s relationship with their supplier. The supplier, now, is threatening to drop the market because it’s no longer meeting its minimum fruit order. Furthermore, if the supplier drops this market, it will drop all markets in the surrounding area, because there will no longer be enough markets for the supplier to continue to make a profit.
The town cafe, which was selling apples on the cheap to draw in customers, no longer has that draw. They are experiencing a downturn in business, for which they are not happy. So… now our poor market owner has all kinds of people furious at him!
Your Relationship to the Problem
As we’ve discussed, every problem has a context. It’s important to keep in mind that the problem solver is also a part of this context. Your relationship to the problem has an important impact on your ability to solve it well.
- A web apps developer in New York is probably not the best person to solve an apple pricing problem for a small German town. Best to let the townspeople solve the problem. It matters more to them, and they have to live with the solutions implemented.
- If you are emotionally involved in the problem, you have to be very careful because your judgment is likely to be clouded. You could, for example: stand to benefit from the problem, be very angry or sad about it, feel threatened or scared. Strong emotions — happy or sad — often drive us to ignore important information and options.
- If you know the problem space very well, this can also be a limitation. Confidence or familiarity can drive you to blindness or default thinking. As Shunryu Suzuki has famously said: “In the beginner’s mind, there are many possibilities. In the expert’s mind, there are few.”
- Problems are almost always more complicated than we think they are. This can easily become apparent when we consider the problem from multiple perspectives.
- It’s helpful for us to understand our perspective on the problem, and contrast it with other problem solvers.
- To really understand a problem, we need to know its context. This means that we must spend time with it and ask a lot of questions.
- Solving one problem often leads to the introduction of not one, but many other problems.
- Make sure you’re the right person/group to solve this problem. Ideally, the people who have to live with the solution should be involved in solving the problem.
- Be aware of your relationship to the problem, and how this can impact both your ability to understand it, and to solve it.
But wait, you may be saying, I solve problems all the time and I don’t deal with all this craziness! Enter some of the most difficult factors in problem-solving: Time, Linearity, and Systems.
Time, Linearity, and Systems
Because of feedback delays within complex systems, by the time a problem becomes apparent, it may be unnecessarily difficult to solve.
— Donella Meadows
Time is one of the most complicated factors in understanding the impact of an action. When there is a time gap between an action and a consequence, humans have difficulty making causal connections. This fact is eagerly exploited by credit card companies who allow you to buy now and pay later.
The issue of time is compounded by the fact that, as engineers, we work with — and within — systems. In addition to this, we tend to think linearly in simple patterns of cause and effect. However, systems are more complex. They often have multiple things happening at the same time. Systems are jumbles of connections in many directions, simultaneously. A symptom in one place could be the result of several causal factors. Changing one of those factors can have unpredictable impacts.
If you have a situation where you seem to be stuck with long-term problems — you may be dealing with feedback delays or patching symptoms. In these situations, it can be very difficult to see the systemic connections and relationships that are driving circumstances.
Problems are often more complicated than we see — especially when time and complex systems are involved. It is easy to believe that a problem is solved, only to realize with time or new information that it’s not — or that other problems have come to take its place.
Let’s Bring This Back to Code
At the beginning of this article, I said that just knowing how to code wasn’t enough. You had to know how to problem solve. So far, all the examples we’ve looked at haven’t involved code. Surely, when we’re dealing with code, things are different… right? Nope.
Let’s look at an example of a simple, common interview task:
Write a function that counts the duplicates in an unsorted array of numbers.
That seems pretty straightforward — at first glance. But, we’re a bit wiser now than we were before. What could we be missing?
- How big is the array?
- What happens in cases such as: Incorrect values, no values, or the wrong data type?
- Do I know anything about the expected number of unique values? If there are only a few unique values, this could impact the effectiveness of certain algorithms.
- Are there specific performance constraints?
- How will the function be used? Is it for use in a library? Is it a utility function? Is it local to a component?
What other questions would you ask?
This is another good example of how a seemingly simple problem statement is more complex than it appears. It is also, incidentally, a good example of how decay and complexity can enter a system. There are lots of algorithms just like this one in most applications. These functions start off simple — often due to a simple understanding of the problem they’re trying to solve. Then, they get refactored for various reasons. Those reasons are rarely documented. Sometimes, they’re validated with unit tests — if a unit test is appropriate. The impact of this multiplies over time, as the refactoring done for one scenario impacts the refactoring done for a previous scenario. With each refactoring, functionality can be subtly impacted, and information lost — thus creating new problems that may only be discovered over time.
So, what’s an engineer to do? Problem-solving is… a complicated problem! There are a lot of possible answers. Here are 10 things to keep in mind:
- Take more time to sit with your problems. Slow down. Don’t jump to solutions. Flesh out the details of the problem. Ask questions. Talk with the people involved and get their perspectives.
- Problem solve with others. Different perspectives bring more information. Every problem faces issues of understanding. There are things we know, and things we don’t know. When we seek feedback from others, they often bring us information we wouldn’t otherwise have.
- Take time to consider multiple solutions. Find the option that best fits your problem, and creates the fewest number of collateral problems.
- Look for patterns over time. If you see recurring or re-appearing issues, look for problems with feedback mechanisms in your systems — technical or personal.
- Don’t fall into the fallacy of the single cause. Look for multiple, influencing factors.
- Expect your solution to generate new problems. Try to anticipate what those problems might be.
- Make sure this is your problem, to begin with. If it’s not, you’re probably not the best person/group to solve it.
- Take documentation seriously. Yes, the code documents what the function currently does. No, that’s not enough. If you don’t have some form of documentation, you can’t easily see the history or context of the function when you sit down to refactor it the next time.
- Make time to practice. Work with all kinds of problems, in all kinds of situations. The problem you work with doesn’t have to be technical.
- Learn about problem-solving. A great resource I can recommend for this is Gause and Weinberg’s Are Your Lights On? I first learned about it from DHH. Many thanks for the referral.
- Another potential solution to the problem of keeping the tub half full is to freeze the water in it.